Tutorials / Migrate from SendGrid
Migration · Node.js · Python · 20 minutesSendGrid 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 field | GhostParse (event.data) |
|---|---|
from, to, cc (strings) | from, to[], cc[], as { address, name } objects |
subject, text, html | subject, text, html, always UTF-8 (no charsets to apply) |
headers (one string) | headers: an object keyed by lower-case name |
envelope (JSON string), sender_ip | envelope.mail_from, envelope.rcpt_to, envelope.client_ip |
SPF, dkim | authentication: SPF, DKIM and DMARC, with one verdict |
spam_score, spam_report | spam.score and spam.symbols, when spam scanning is on |
attachments, attachment-info, attachment1…N files | attachments[] 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 POST | A 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
- 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. - Add the receiving addresses. Inbound Parse takes any address on the host: for the same, add
*(catch-all). - Under Inbound webhook, set a new URL such as
https://yourapp.com/webhooks/ghostparseand 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,
};
}
"""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."""
import base64
import json
import re
def _json(value):
return json.dumps(value, separators=(",", ":")) # compact, as SendGrid sends it
def _who(a):
return f'{a["name"]} <{a["address"]}>' if a.get("name") else a["address"]
def _header_name(k):
return re.sub(r"(^|-)([a-z])", lambda m: m.group(0).upper(), k)
def to_sendgrid(data: dict) -> dict:
auth = data.get("authentication") or {}
env = data.get("envelope") or {}
attachments = data["attachments"]
names = {f"attachment{i + 1}": a for i, a in enumerate(attachments)}
return {
"headers": "\n".join(f"{_header_name(k)}: {v}" for k, v in (data.get("headers") or {}).items()),
"from": _who(data["from"]) if data.get("from") else "",
"to": ", ".join(_who(a) for a in data["to"]),
"cc": ", ".join(_who(a) for a in data.get("cc") or []),
"subject": data.get("subject") or "",
"text": data.get("text") or "",
"html": data.get("html") or "",
"sender_ip": env.get("client_ip") or "",
"envelope": _json({"to": env.get("rcpt_to") or [a["address"] for a in data["to"]],
"from": env.get("mail_from") or (data.get("from") or {}).get("address", "")}),
"SPF": (auth.get("spf") or {}).get("result", "none"),
"dkim": "{" + ", ".join(f'@{d["domain"]} : {d["result"]}' for d in auth.get("dkim") or [] if d.get("domain")) + "}",
"spam_score": str(data["spam"]["score"]) if data.get("spam") else "",
"spam_report": "",
# GhostParse sends text as UTF-8
"charsets": _json({k: "UTF-8" for k in ("to", "from", "subject", "text", "html")}),
"attachments": str(len(attachments)),
"attachment-info": _json({k: {"filename": a.get("filename") or "attachment", "name": a.get("filename") or "attachment",
"type": a["content_type"], **({"content-id": a["content_id"]} if a.get("content_id") else {})}
for k, a in names.items()}),
"files": {k: {"filename": a.get("filename") or "attachment", "type": a["content_type"],
"content": base64.b64decode(a.get("content") or "")} for k, a in names.items()},
}
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);
});
import os
from flask import Flask, request
from email_parser import verify_webhook, WebhookVerificationError
from sendgrid import to_sendgrid
from your_sendgrid_handler import handle_parse # what used to read request.form and request.files
app = Flask(__name__)
@app.post("/webhooks/ghostparse")
def ghostparse():
try:
event = verify_webhook(request.get_data(), request.headers, os.environ["GHOSTPARSE_WEBHOOK_SECRET"])
except WebhookVerificationError:
return "", 400
if event.type == "email.received":
fields = to_sendgrid(event.data) # from, to, subject, text, html, envelope, attachment-info, ...
handle_parse(fields, fields["files"]) # files["attachment1"] = {"filename", "type", "content": bytes}
return "", 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:
- The day before, lower the TTL of your MX records to 300 seconds, so the switch takes minutes, not hours.
- 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.
- 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. - Watch the delivery log for an hour: every email, where it went, and why if it didn't.
- 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.datadirectly, handler by handler, then delete the adapter. - Skip out-of-office replies with
auto_submitted, and trust the sender withauthentication.verdict. - Send copies to Slack, Teams or Google Sheets, or route by sender and subject, with routing rules.