All posts
Reply-by-email: handling inbound replies in your app
Gabe 15 min read

Reply-by-email: handling inbound replies in your app

When users reply to your notification emails, capture the reply, thread it to the right conversation with threadId, and respond: either an inline ack or a real outbound message. Working Node code, honest DIY path.

This is for a developer wiring reply handling into an app for the first time, whatever email provider you use. Reply handling lives entirely on the inbound side of email, the hard direction, and the whole feature hinges on a single identifier surviving a round-trip: when you send the notification you tag it with a Message-ID, and when the user replies from their own inbox their mail client quotes that id back in the reply’s headers. That returning id is what lets you drop the reply into the right conversation instead of guessing from a subject line. Here’s that round-trip, end to end:

1. You send your app recipient From: replies@myapp.ai Message-ID: <m1@myapp.ai> 2. They reply recipient MX edgeresolve headers webhook JSONthreadId your appmatch convo In-Reply-To / References: <m1@myapp.ai> the reply carries the headers that point back at the message you sent
The reply round-trip: the Message-ID you sent comes back as In-Reply-To / References, the MX edge resolves it to a stable threadId, and your app matches that to the stored conversation.

Here’s the whole capture path as a complete, runnable Node server:

// reply-handler.mjs — a complete reply-by-email webhook. Runs as pasted on Node 18+ (npm i express mailkite).
import express from "express";
import { MailKite } from "mailkite";

const SECRET = process.env.MAILKITE_WEBHOOK_SECRET ?? "whsec_demo_secret";
const threads = new Map(); // stand-in for your database, keyed by threadId

const app = express();
app.use("/hooks/mailkite", express.raw({ type: "application/json" }));

app.post("/hooks/mailkite", (req, res) => {
  // Verify first: header is t=<ms>,v1=<hmac-sha256 hex over `${t}.${rawBody}`>. One call does the
  // parse, the replay-window check, and the constant-time compare.
  if (!MailKite.verifyWebhook(req.headers["x-mailkite-signature"], req.body, SECRET)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body);
  if (event.type === "email.received") {
    // From: is forgeable and a reply address is public, so trust the auth block before acting.
    if (event.auth?.spf !== "pass" || event.auth?.dmarc !== "pass") {
      return res.json(MailKite.replyOk()); // untrusted: drop it, but still ack the delivery
    }
    // Thread on threadId, never subject. Append only the new text.
    const thread = threads.get(event.threadId) ?? [];
    thread.push({ from: event.from.address, body: stripQuoted(event.text ?? "") });
    threads.set(event.threadId, thread);
  }

  return res.json(MailKite.replyOk()); // ack inline: MailKite records this as the delivery
});

function stripQuoted(text) {
  const out = [];
  for (const line of text.split("\n")) {
    const t = line.trim();
    if (/^On .+ wrote:$/.test(t) || /^-{2,}\s*Original Message\s*-{2,}/i.test(t) || t.startsWith(">")) break;
    out.push(line);
  }
  return out.join("\n").trim();
}

app.listen(3000, () => console.log("reply handler on :3000/hooks/mailkite"));

That’s capture, threading, quote-stripping, and an acknowledgement in one file. The rest of this post unpacks the three moving parts, then shows how to send a real reply back so the thread stays coherent in both directions. (Prefer not to take a dependency? MailKite.verifyWebhook is a thin HMAC helper you can hand-roll: the header format is above, and the DIY section sketches the rest.)

The one rule that makes threading work

When you send a notification, send it from a reply-friendly address on a domain you receive on: replies@myapp.ai, or a per-conversation address like ticket+8213@myapp.ai. Store the outgoing message’s Message-ID on your record. When the user hits reply, their client sets In-Reply-To and References back to that Message-ID, the reply lands at your webhook, and those headers get resolved into one stable threadId. Match on it, and the reply drops into the right conversation.

Here’s what that resolved reply looks like when it hits your webhook:

{
  "id": "msg_2Hk9…",
  "type": "email.received",
  "from": { "address": "ada@example.com" },
  "to": [{ "address": "replies@myapp.ai" }],
  "subject": "Re: invoice #1042",
  "text": "Looks good — approved!",
  "html": "<p>Looks good — approved!</p>",
  "threadId": "<a1b2c3@mail.example.com>",
  "receivedAt": 1785196800000,
  "receivedAtIso": "2026-07-28T00:00:00.000Z",
  "auth": { "spf": "pass", "dkim": "pass", "dmarc": "pass", "spam": "ham" },
  "attachments": []
}

The field that does the heavy lifting is threadId. It survives subject-line drift where matching on subject would break: the subject can mutate from “invoice #1042” to “Re: Re: Fwd: invoice #1042” three replies deep, and threadId still points at the same conversation. Don’t thread on subject. Subjects mutate; threadId doesn’t.

Match threadId, not the raw headersSome clients drop In-Reply-To or point it at the wrong ancestor, and References is the fuller chain but still yours to walk. MailKite resolves Message-ID, In-Reply-To, and References into one stable threadId, so match on that and skip the header archaeology.

Stripping the quoted history

Real replies carry baggage. A one-line “Looks good — approved!” arrives with the entire prior message quoted underneath it:

Looks good — approved!

On Tue, Apr 15, Ada <ada@example.com> wrote:
> Here's invoice #1042 for your review…
> > and everything before that, forever

You want the new part, not the archaeology. There’s no perfect universal rule here (this is genuinely one of email’s messy corners), but the pragmatic stripQuoted from the opening covers the overwhelming majority of clients: it cuts at the first line matching a common reply marker, an On … wrote: line, a run of >-prefixed lines, or an -----Original Message----- divider.

Two things worth doing. Parse against event.text, not html: it’s far more predictable to work with. And keep the raw event.text too, so your UI can offer a “show trimmed history” expander. Perfect quote-stripping is a rabbit hole; a good-enough strip plus keeping the original is the right trade.

Pattern A: acknowledge inline with replyOk()

Sometimes you don’t need to send a new email. You just need to tell MailKite “got it, I handled this.” That’s the return res.json(MailKite.replyOk()) in the opening: it returns the body {"status":"ok"}, and MailKite counts your inline response as the delivery, so there’s no second API call.

Use Pattern A when the reply just needs to be captured: a comment appended to a ticket, a yes/no approval recorded, an agent handed the message. You append it to the thread by threadId, ack, done.

Pattern B: send a real reply with mk.send() and inReplyTo

When the user should get an answer back, a support agent typed a response, or an automated system needs to confirm, send a real outbound email with mk.send(). The trick to keeping it in the same email thread is to reply from the same address and pass inReplyTo set to the conversation’s threadId. That sets the outbound In-Reply-To header, so the user’s client stacks your reply under the original, and MailKite files the sent message on the same thread.

const mk = new MailKite(process.env.MAILKITE_API_KEY);

async function replyToThread(event, replyHtml) {
  const { id, status } = await mk.send({
    from: "replies@myapp.ai",         // same reply-friendly address
    to: event.from.address,            // back to whoever wrote in
    subject: "Re: " + event.subject,   // keep the Re: subject
    html: replyHtml,
    inReplyTo: event.threadId,         // thread it: sets In-Reply-To to the conversation anchor
  });
  return { id, status };
}

mk.send() returns { id, status }. Store that alongside the same threadId you matched on inbound, and your conversation stays coherent in both directions: every inbound reply and every outbound response hangs off one thread in your database, mirroring how it looks in the user’s inbox.

Under the hood, that coherence is just the threading headers accumulating. Each message quotes the ones before it in References, so the original Message-ID rides along the whole chain and every message resolves back to the same anchor:

1 · You send the notification Message-ID: <m1@myapp.ai> References: (none yet) 2 · They hit reply from their inbox In-Reply-To: <m1@myapp.ai> References: <m1@myapp.ai> Message-ID: <r1@example.com> 3 · You reply back · mk.send({ inReplyTo }) In-Reply-To: <r1@example.com> References: <m1@myapp.ai> <r1@example.com> Message-ID: <m2@myapp.ai> the root <m1> never leaves the References chain MailKite resolves the chain to one anchor threadId: <m1@myapp.ai> · always the root your app · one conversation keyed by threadId; all three messages hang off it
How the threading chain grows: every reply quotes the root Message-ID in its References header, MailKite resolves all three messages to one threadId, and inReplyTo keeps your outbound reply on the same chain.

The two patterns compose. A common shape: verify, strip the quoted text, append to the thread by threadId, then if a human or agent produces an answer, mk.send() it with inReplyTo; otherwise replyOk() to ack. Here’s when to reach for each:

Pattern A · replyOk()Pattern B · mk.send() + inReplyTo
What it doesAcks the delivery inlineSends a real outbound reply
Sends an email?NoYes
Extra API callNone, it’s the webhook responseOne mk.send()
Reach for it whenCapture only: append to a ticket, record a yes/no, hand it to an agentThe user should get an answer: a support reply, an automated confirmation
Keeps the threadMatch inbound on threadIdinReplyTo: event.threadId keeps the outbound on-thread

One caution cuts across both patterns, and it’s the one that bites in production:

A public reply address is a spoofing surfaceReply addresses are public and From: is forgeable, so gate on the auth block before a reply can act: if event.auth.spf or event.auth.dmarc didn't pass, treat the message as untrusted before you let it reopen a ticket or trigger an agent. Verifying the webhook signature proves the request is genuine; the auth block proves the sender is.

Doing it without MailKite

Everything above works because the reply arrives already parsed, already threaded, and already signature-checked. That’s the part MailKite (which we build) does for you: the Haraka MX edge parses the raw MIME, resolves the Message-ID / In-Reply-To / References headers into one stable threadId, runs SPF/DKIM/DMARC, and POSTs you signed JSON. The receiving docs and webhook security cover that path.

You can absolutely build it yourself, and it’s worth understanding even if you don’t. The DIY shape is:

  • Receive the raw message. Run an inbound MTA (Postfix, or Haraka like we do) or point at Cloudflare Email Workers.
  • Parse the MIME yourself. mailparser in Node or another MIME library, to get headers, text, html, and attachments.
  • Resolve the thread. Read In-Reply-To and References off the headers and walk them back to the conversation whose Message-ID you stored when you sent. That resolution is the threadId logic you'd otherwise get for free.
  • Verify before trusting. Check SPF/DKIM/DMARC before trusting From:, and if you accept webhooks, sign and verify them yourself.

Reasonable alternatives if you don’t want our stack: Postmark’s inbound parsing gives you mature JSON webhooks; SES receiving drops raw MIME into S3 and fires a Lambda (cheapest at volume, the most assembly to build and operate); Cloudflare Email Workers is a receiving primitive if you’re happy to parse and thread yourself. All of them get you the reply. The only difference is how much of the parsing and threading you own. Pick the one that matches how much of that you want to run.

FAQ

How do I keep replies in the same thread? Use event.threadId. Store it when you first send the notification, and match inbound replies against it. It’s derived from the standard threading headers (Message-ID, In-Reply-To, References), so it survives subject-line drift where matching on subject would break.

What’s the difference between replyOk() and mk.send()? replyOk() is an inline acknowledgement returned from your webhook (the body {"status":"ok"}): MailKite records it as the delivery and you don’t make a second call. mk.send() sends a brand-new outbound email. Use replyOk() to capture a reply; use mk.send() with inReplyTo when the user should receive an actual response.

How do I strip the quoted history from a reply? Cut the message at the first reply marker (an On … wrote: line, a block of >-prefixed lines, or an -----Original Message----- divider), working against event.text. It won’t be perfect for every client, so keep the original text too and let users expand the trimmed history.

Can a spoofed reply reopen a ticket? It can if you don’t check. Reply addresses are public and From: is plain text. Read event.auth.spf and event.auth.dmarc before letting a reply take action, and always verify the webhook signature so you know the request itself is genuine.

Do I need a different domain for replies? No, just an address on a domain you already receive on. A per-conversation address like ticket+8213@myapp.ai works well because it encodes the conversation right in the address, though threadId alone is enough to route correctly.


Reply-by-email is a small feature with a big payoff: users answer from the inbox they already live in, and your app quietly threads it. Point a domain at MailKite and wire up your first reply handler; the field reference is in the receiving docs. If you’re just getting inbound working, start with Parse inbound email to JSON in Node.js.

Discuss this post: Hacker News Share on X Share on LinkedIn

Related posts