Tutorials / Migrate from Postmark

Migration · Node.js · Python · 20 minutes

Migrating 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.

PostmarkGhostParse (event.data)
From, FromName, FromFullfrom.address, from.name
ToFull[], CcFull[]to[], cc[], each { address, name }
OriginalRecipientrecipients[0].address (and envelope.rcpt_to)
MailboxHash (the part after +)recipients[0].tag, which can be signed so it can't be forged
Subject, Datesubject, date (ISO 8601)
TextBody, HtmlBodytext, html
StrippedTextReplyreply_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-listA 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

  1. 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.
  2. Add the addresses that receive, such as support@. A Postmark inbound domain takes any address: for the same, add * (catch-all). Patterns like reply-* work too.
  3. 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 ?? "",
    })),
  };
}

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);
});

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:

  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.

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.data handler by handler, then delete the adapter.
  • Use thread_id to match replies to tickets, and authentication.verdict to trust the sender.
  • Big attachments? Send them to your own S3, R2 or Google Cloud bucket and get links instead of base64.

Next