Tutorials / Migrate from Postmark
Migration · Node.js · Python · 20 minutesMigrating from Postmark Inbound Streams to GhostParse
Move your Postmark inbound stream to GhostParse in an afternoon: map the fields, keep your existing webhook handler with a tested adapter, check every request's signature, and switch the MX record without losing an email.
Postmark's inbound streams post each email to your webhook as JSON. GhostParse does the same, with a signature on every request, so the move is mostly a DNS change. This guide keeps your existing handler working unchanged with a small adapter, then shows what to switch to when you have time.
1. What maps to what
GhostParse sends email.received events: the email is in event.data.
| Postmark | GhostParse (event.data) |
|---|---|
From, FromName, FromFull | from.address, from.name |
ToFull[], CcFull[] | to[], cc[], each { address, name } |
OriginalRecipient | recipients[0].address (and envelope.rcpt_to) |
MailboxHash (the part after +) | recipients[0].tag, which can be signed so it can't be forged |
Subject, Date | subject, date (ISO 8601) |
TextBody, HtmlBody | text, html |
StrippedTextReply | reply_text: the new words, without quotes or signature |
MessageID (Postmark's id) | id; the Message-ID header is message_id |
Headers[{ Name, Value }] | headers: an object keyed by lower-case name |
Attachments[{ Name, Content, ContentType, ContentLength }] | attachments[{ filename, content, content_type, size }], base64, or links to your own bucket |
| Basic auth in the URL, or an IP allow-list | A Standard Webhooks signature on every request |
And some things Postmark doesn't send: thread_id for whole conversations, an authentication
verdict (SPF, DKIM and DMARC checked by us, not read from a header), and extracted fields from a
parser.
2. Set up GhostParse next to Postmark
- Create an account, add your domain under Domains and add its records (or connect Cloudflare and we add them). Leave the MX record until step 5.
- Add the addresses that receive, such as
support@. A Postmark inbound domain takes any address: for the same, add*(catch-all). Patterns likereply-*work too. - Under Inbound webhook, set a new URL next to your Postmark one, for example
https://yourapp.com/webhooks/ghostparse, and copy the signing secret.
3. Keep your handler: the Postmark-shaped adapter
This turns GhostParse's event into Postmark's JSON, field for field, so code that reads FromFull.Email or
StrippedTextReply keeps working:
// GhostParse's email.received data in the shape of Postmark's inbound webhook JSON, so a handler written for
// Postmark keeps working while you migrate. Pass it `event.data` from a verified webhook.
const who = (a) => (a ? (a.name ? `"${a.name}" <${a.address}>` : a.address) : "");
const full = (list = []) => list.map((a) => ({ Email: a.address, Name: a.name ?? "", MailboxHash: "" }));
export function toPostmark(data) {
const recipient = data.recipients?.[0];
return {
From: data.from?.address ?? "",
FromName: data.from?.name ?? "",
FromFull: { Email: data.from?.address ?? "", Name: data.from?.name ?? "", MailboxHash: "" },
To: data.to.map(who).join(", "),
ToFull: full(data.to),
Cc: (data.cc ?? []).map(who).join(", "),
CcFull: full(data.cc),
Bcc: "",
BccFull: [],
OriginalRecipient: recipient?.address ?? data.envelope?.rcpt_to?.[0] ?? "",
// Postmark's "+hash" part of the address: GhostParse calls it the tag.
MailboxHash: recipient?.tag ?? "",
ReplyTo: (data.reply_to ?? []).map((a) => a.address).join(", "),
Subject: data.subject ?? "",
// Postmark's own id for the message; the Message-ID header is in Headers, as in Postmark.
MessageID: data.id,
Date: new Date(data.date ?? data.received_at).toUTCString().replace("GMT", "+0000"),
TextBody: data.text ?? "",
HtmlBody: data.html ?? "",
StrippedTextReply: data.reply_text ?? "",
Tag: "",
Headers: Object.entries(data.headers ?? {}).map(([Name, Value]) => ({ Name, Value: String(Value) })),
Attachments: data.attachments.map((a) => ({
Name: a.filename ?? "attachment",
Content: a.content ?? "", // base64, as in Postmark (empty if attachments go to your bucket)
ContentType: a.content_type,
ContentLength: a.size,
ContentID: a.content_id ?? "",
})),
};
}
"""GhostParse's email.received data in the shape of Postmark's inbound webhook JSON, so a handler written for
Postmark keeps working while you migrate. Pass it event["data"] from a verified webhook."""
from datetime import datetime
from email.utils import format_datetime
def _who(a):
return (f'"{a["name"]}" <{a["address"]}>' if a.get("name") else a["address"]) if a else ""
def _full(addresses):
return [{"Email": a["address"], "Name": a.get("name") or "", "MailboxHash": ""} for a in addresses or []]
def to_postmark(data: dict) -> dict:
sender = data.get("from") or {}
recipient = (data.get("recipients") or [None])[0]
when = datetime.fromisoformat((data.get("date") or data["received_at"]).replace("Z", "+00:00"))
return {
"From": sender.get("address", ""),
"FromName": sender.get("name") or "",
"FromFull": {"Email": sender.get("address", ""), "Name": sender.get("name") or "", "MailboxHash": ""},
"To": ", ".join(_who(a) for a in data["to"]),
"ToFull": _full(data["to"]),
"Cc": ", ".join(_who(a) for a in data.get("cc") or []),
"CcFull": _full(data.get("cc")),
"Bcc": "",
"BccFull": [],
"OriginalRecipient": recipient["address"] if recipient else ((data.get("envelope") or {}).get("rcpt_to") or [""])[0],
# Postmark's "+hash" part of the address: GhostParse calls it the tag.
"MailboxHash": (recipient or {}).get("tag") or "",
"ReplyTo": ", ".join(a["address"] for a in data.get("reply_to") or []),
"Subject": data.get("subject") or "",
# Postmark's own id for the message; the Message-ID header is in Headers, as in Postmark.
"MessageID": data["id"],
"Date": format_datetime(when),
"TextBody": data.get("text") or "",
"HtmlBody": data.get("html") or "",
"StrippedTextReply": data.get("reply_text") or "",
"Tag": "",
"Headers": [{"Name": k, "Value": str(v)} for k, v in (data.get("headers") or {}).items()],
"Attachments": [
{
"Name": a.get("filename") or "attachment",
"Content": a.get("content") or "", # base64, as in Postmark (empty if attachments go to your bucket)
"ContentType": a["content_type"],
"ContentLength": a["size"],
"ContentID": a.get("content_id") or "",
}
for a in data["attachments"]
],
}
Then the new endpoint verifies the signature and calls your existing code:
import express from "express";
import { verifyWebhook } from "@wildye/email-parser";
import { toPostmark } from "./postmark.mjs";
import { handleInbound } from "./your-postmark-handler.js"; // the code you already have
const app = express();
// GhostParse signs every webhook: verify it with the raw body, then hand your old handler the Postmark shape.
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") await handleInbound(toPostmark(event.data));
res.sendStatus(200);
});
import os
from flask import Flask, request
from email_parser import verify_webhook, WebhookVerificationError
from postmark import to_postmark
from your_postmark_handler import handle_inbound # the code you already have
app = Flask(__name__)
@app.post("/webhooks/ghostparse")
def ghostparse():
# GhostParse signs every webhook: verify it with the raw body, then hand your old handler the Postmark shape.
try:
event = verify_webhook(request.get_data(), request.headers, os.environ["GHOSTPARSE_WEBHOOK_SECRET"])
except WebhookVerificationError:
return "", 400
if event.type == "email.received":
handle_inbound(to_postmark(event.data))
return "", 200
No SDK? The ready-to-run endpoints verify the signature in PHP, Node.js, Python and Go with nothing to install.
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
With inbound domain forwarding, your domain's MX points at Postmark (inbound.postmarkapp.com). 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.
If you forward mail to your server's …@inbound.postmarkapp.com address instead, change the forwarding rule
to an address on your GhostParse domain.
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
- Move from the adapter to
event.datahandler by handler, then delete the adapter. - Use
thread_idto match replies to tickets, andauthentication.verdictto trust the sender. - Big attachments? Send them to your own S3, R2 or Google Cloud bucket and get links instead of base64.