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, and respond — either with an inline ack or a real outbound message.
Reply-by-email means your users answer a notification email from their own inbox — a support update, a comment, an alert — and your app captures that reply, strips the quoted history, threads it to the original conversation using threadId, and either acknowledges it inline or sends a real reply back. It’s the feature that turns one-way notifications into an actual conversation, and it lives entirely on the inbound side of email — the hard direction.
I love reply-by-email as a feature because it meets people where they already are. Nobody wants to click a link, log in, and find the thread to type one sentence. They want to hit reply. Here’s how to build it without owning a MIME parser.
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. When the user replies, that reply lands back at your webhook as a parsed email.received event. Here’s the payload:
{
"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>",
"auth": { "spf": "pass", "dkim": "pass", "dmarc": "pass", "spam": "ham" },
"attachments": []
}
The field that does the heavy lifting is threadId. Email clients set standard threading headers (Message-ID, In-Reply-To, References) when someone replies, and MailKite resolves them into a single stable threadId for the conversation. Store it on your record when you send, match on it when you receive, and replies land in the right place — even when the subject line drifts from “invoice #1042” to “Re: Re: Fwd: invoice #1042” three replies deep. Don’t thread on subject. Subjects mutate; threadId doesn’t.
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 a pragmatic strip covers the overwhelming majority of clients: cut at the first line matching the common reply markers — On … wrote:, a run of lines beginning with >, or an -----Original Message----- divider.
function stripQuoted(text) {
const lines = text.split("\n");
const out = [];
for (const line of lines) {
if (/^On .+ wrote:$/.test(line.trim())) break;
if (/^-{2,}\s*Original Message\s*-{2,}/i.test(line)) break;
if (/^>/.test(line.trim())) break;
out.push(line);
}
return out.join("\n").trim();
}
Parse against event.text (not html) — it’s far more predictable to work with. Keep the raw event.text too, so you can show “…show trimmed history” in your UI. Perfect quote-stripping is a rabbit hole; a good-enough strip plus keeping the original is the right trade.
Pattern A — the inline ack with replyOk()
Sometimes you don’t need to send a new email; you just need to tell MailKite “got it, I handled this.” Returning replyOk() from your webhook does exactly that — MailKite counts your inline response as the delivery, no second API call.
import { MailKite } from "mailkite";
app.use("/hooks/mailkite", express.raw({ type: "application/json" }));
app.post("/hooks/mailkite", (req, res) => {
const sig = req.headers["x-mailkite-signature"];
if (!MailKite.verifyWebhook(sig, req.body, SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body);
if (event.type === "email.received") {
const reply = stripQuoted(event.text ?? "");
// Match the reply to its conversation by threadId.
appendToThread(event.threadId, {
from: event.from.address,
body: reply,
});
}
// Ack the delivery inline — body is {"status":"ok"}.
return res.json(MailKite.replyOk());
});
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, ack, done.
Pattern B — sending a real reply with mk.send()
When the user should get an answer back — a support agent typed a response, 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 send from the same reply address and reference the same conversation, so the user’s client stacks it under the original.
async function replyToThread(event, replyHtml) {
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,
});
}
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.
The two patterns compose. A common shape is: verify → strip quoted text → append to the thread → if a human or agent produces an answer, mk.send() it; otherwise replyOk() to ack. Whatever you do, check event.auth before acting — a reply-by-email address is a public target, and From: is forgeable. If spf or dmarc didn’t pass, treat the message as untrusted before you let it reopen a ticket or trigger an agent.
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 standard email threading headers, 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 — 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() 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.