Tutorials / Migrate from SendGrid

Migration · Node.js · Python · 20 minutes

SendGrid Inbound Parse webhook alternative: migrate to GhostParse

Swap SendGrid's Inbound Parse webhook for signed JSON with DMARC checked, replies cleaned and conversations threaded, without rewriting your handler on day one: a tested adapter gives it the same fields and files it reads today.

SendGrid's Inbound Parse posts each email to your URL as a multipart/form-data form: text fields plus one file per attachment. GhostParse posts JSON (or multipart, if you prefer) with a signature on every request, a DMARC verdict, the reply without its quote chain, and a conversation id. Here's how to move without rewriting your handler on day one. Comparing first? See GhostParse vs SendGrid Inbound Parse.

1. What maps to what

SendGrid Inbound Parse fieldGhostParse (event.data)
from, to, cc (strings)from, to[], cc[], as { address, name } objects
subject, text, htmlsubject, text, html, always UTF-8 (no charsets to apply)
headers (one string)headers: an object keyed by lower-case name
envelope (JSON string), sender_ipenvelope.mail_from, envelope.rcpt_to, envelope.client_ip
SPF, dkimauthentication: SPF, DKIM and DMARC, with one verdict
spam_score, spam_reportspam.score and spam.symbols, when spam scanning is on
attachments, attachment-info, attachment1…N filesattachments[] with base64 content; or real file parts (multipart mode); or links to your bucket
email (with “POST the raw, full MIME message”)raw, when Include the original message is on
Nothing to verify who sent the POSTA Standard Webhooks signature on every request

Messages can be up to 25 MB. Plus things Inbound Parse doesn't have: reply_text, thread_id, auto_submitted (to skip out-of-office replies) and extracted fields from a parser.

2. Set up GhostParse next to SendGrid

  1. Create an account and add the domain or subdomain you receive on (many Inbound Parse setups use one like parse.yourapp.com: add exactly that). Add its records, but leave the MX until step 5.
  2. Add the receiving addresses. Inbound Parse takes any address on the host: for the same, add * (catch-all).
  3. Under Inbound webhook, set a new URL such as https://yourapp.com/webhooks/ghostparse and copy the signing secret. Want SendGrid's style of uploaded files? Choose Attachments: as separate files (multipart/form-data).

3. Keep your handler: the Inbound Parse-shaped adapter

This turns GhostParse's event into the fields SendGrid posts, including envelope and attachment-info as the JSON strings your code parses today, and each attachment as a file:

// GhostParse's email.received data as the fields of SendGrid's Inbound Parse POST (its "parsed" format), so a
// handler written for SendGrid keeps working while you migrate. Pass it `event.data` from a verified webhook.
// Attachments come back as { attachment1: { filename, type, content }, … } in place of the uploaded files.
const who = (a) => (a.name ? `${a.name} <${a.address}>` : a.address);
const headerName = (k) => k.replace(/(^|-)([a-z])/g, (m) => m.toUpperCase());

export function toSendGrid(data) {
  const auth = data.authentication ?? {};
  const files = Object.fromEntries(data.attachments.map((a, i) => [`attachment${i + 1}`,
    { filename: a.filename ?? "attachment", type: a.content_type, content: Buffer.from(a.content ?? "", "base64") }]));
  return {
    headers: Object.entries(data.headers ?? {}).map(([k, v]) => `${headerName(k)}: ${v}`).join("\n"),
    from: data.from ? who(data.from) : "",
    to: data.to.map(who).join(", "),
    cc: (data.cc ?? []).map(who).join(", "),
    subject: data.subject ?? "",
    text: data.text ?? "",
    html: data.html ?? "",
    sender_ip: data.envelope?.client_ip ?? "",
    envelope: JSON.stringify({ to: data.envelope?.rcpt_to ?? data.to.map((a) => a.address), from: data.envelope?.mail_from ?? data.from?.address ?? "" }),
    SPF: auth.spf?.result ?? "none",
    dkim: `{${(auth.dkim ?? []).filter((d) => d.domain).map((d) => `@${d.domain} : ${d.result}`).join(", ")}}`,
    spam_score: data.spam ? String(data.spam.score) : "",
    spam_report: "",
    charsets: JSON.stringify({ to: "UTF-8", from: "UTF-8", subject: "UTF-8", text: "UTF-8", html: "UTF-8" }), // GhostParse sends text as UTF-8
    attachments: String(data.attachments.length),
    "attachment-info": JSON.stringify(Object.fromEntries(data.attachments.map((a, i) => [`attachment${i + 1}`,
      { filename: a.filename ?? "attachment", name: a.filename ?? "attachment", type: a.content_type, ...(a.content_id ? { "content-id": a.content_id } : {}) }]))),
    files,
  };
}

Your new endpoint verifies the signature, then calls the code that used to read the form:

import express from "express";
import { verifyWebhook } from "@wildye/email-parser";
import { toSendGrid } from "./sendgrid.mjs";
import { handleParse } from "./your-sendgrid-handler.js"; // what used to read req.body and req.files

const app = express();
app.post("/webhooks/ghostparse", express.raw({ type: "application/json", limit: "40mb" }), async (req, res) => {
  let event;
  try {
    event = verifyWebhook(req.body, req.headers, process.env.GHOSTPARSE_WEBHOOK_SECRET);
  } catch {
    return res.sendStatus(400);
  }
  if (event.type === "email.received") {
    const fields = toSendGrid(event.data); // from, to, subject, text, html, envelope, attachment-info, …
    await handleParse(fields, fields.files); // files.attachment1 = { filename, type, content: Buffer }
  }
  res.sendStatus(200);
});

No multer or form parsing needed any more: it's one JSON body. No SDK? Use a ready-to-run endpoint in PHP, Node.js, Python or Go.

4. Test it before you switch

Send your handler a realistic, signed webhook before any mail moves. Save a few real emails as .eml files (most mail apps: Download message or Save as) and run them through GhostParse's parser into your endpoint:

export EMAIL_PARSER_API_KEY=ek_live_…
npx @wildye/email-parser-cli simulate http://localhost:3000/webhooks/ghostparse --eml ./order-question.eml --to support@yourapp.com

Nothing is delivered or counted. The sandbox leaves attachment contents out (names, types and sizes only), so test attachments with a real email in the next step. To see exactly what GhostParse sends, use the dashboard's webhook inspector: create a URL, set it as the webhook of a test address, and email it.

5. Switch the MX record

Inbound Parse needs the host's MX at mx.sendgrid.net. Move it:

  1. The day before, lower the TTL of your MX records to 300 seconds, so the switch takes minutes, not hours.
  2. Send a real email to a test address on the domain (with an attachment) and check it in the dashboard's Delivery log and in your app.
  3. Switch the MX record to mx.ghostparse.com (priority 10) and remove the old one. Mail already queued for the old provider still arrives there, so keep its webhook running for a day or two.
  4. Watch the delivery log for an hour: every email, where it went, and why if it didn't.
  5. A week later, raise the TTL again and turn off the old provider.

Then delete the host under SendGrid's Settings → Inbound Parse.

One behaviour to know: if your endpoint is down, we retry quickly (3 attempts), try your backup URL if you set one, then ask the sending mail server to try again later; mail servers keep retrying for hours to days, so an outage doesn't lose email. A 4xx answer (other than 408 and 429) is final: the email is bounced back to the sender. So answer 400 only for a bad signature, and 5xx when you want it sent again. Delivery & retries.

6. After the move

  • Read event.data directly, handler by handler, then delete the adapter.
  • Skip out-of-office replies with auto_submitted, and trust the sender with authentication.verdict.
  • Send copies to Slack, Teams or Google Sheets, or route by sender and subject, with routing rules.

Next