Parse inbound email to JSON in Node.js
A hands-on Node.js walkthrough: point a domain at MailKite and receive every inbound email as clean, decoded JSON in one webhook — verify it, then read text, html, and attachments.
To parse inbound email to JSON in Node.js, you point a domain’s MX record at an inbound provider that parses the MIME for you, then handle a single signed email.received webhook — the whole message arrives already decoded, so event.text, event.html, and event.attachments are ready to use without touching a MIME parser. That’s the entire job. No multipart/* tree-walking, no quoted-printable decoding, no charset guessing inside an Express handler.
I wrote a longer piece on why the receiving direction is the hard one — Receiving email is the part nobody warns you about — if you want the full argument. This post is the hands-on version: you have an Express app, and you want inbound email to show up as JSON your code can read. Let’s wire it up.
1. Install and point a domain
npm install mailkite
Then point a domain at MailKite: add the MX record it gives you and verify. Once the domain is verified, mail sent to any address on it — support@, replies@, ada+anything@ — gets parsed at the edge and POSTed to your webhook. Set your webhook URL in the dashboard (or via the API) to something like https://yourapp.com/hooks/mailkite.
Grab two secrets while you’re there: your API key (MAILKITE_API_KEY) and the webhook signing secret (MAILKITE_WEBHOOK_SECRET). Put them in your environment, never in code.
2. The payload you’ll receive
Before writing the handler, know what’s coming. Every inbound message arrives as this shape:
{
"id": "msg_2Hk9…",
"type": "email.received",
"from": { "address": "ada@example.com" },
"to": [{ "address": "support@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": [
{
"id": "msg_2Hk9…:0",
"filename": "po.pdf",
"contentType": "application/pdf",
"size": 18213,
"url": "https://api.mailkite.dev/att/2Hk9…/0?exp=…&sig=…"
}
]
}
Look at what’s not there: no nested multipart tree, no base64 blob, no Content-Transfer-Encoding headers to interpret. text and html are already decoded — the £ is a £, the emoji is an emoji. Attachments are lifted out and handed to you as a short-lived signed url, so a 13 MB PDF never rides inside your webhook body. And auth tells you up front whether the sender’s SPF, DKIM, and DMARC actually passed.
3. The verify-and-handle webhook
Here’s the whole handler. Two things make it correct: it verifies the raw request body (not a re-serialized object), and it acks fast with a 200.
import express from "express";
import { MailKite } from "mailkite";
const app = express();
const mk = new MailKite(process.env.MAILKITE_API_KEY);
const SECRET = process.env.MAILKITE_WEBHOOK_SECRET;
// Capture the RAW bytes for this route — the signature is over the exact
// bytes MailKite sent, and JSON.parse → JSON.stringify changes them.
app.use("/hooks/mailkite", express.raw({ type: "application/json" }));
app.post("/hooks/mailkite", async (req, res) => {
const sig = req.headers["x-mailkite-signature"];
// Recomputes the HMAC, constant-time compares, and rejects anything
// outside the ±5-minute replay window.
if (!MailKite.verifyWebhook(sig, req.body, SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body);
if (event.type === "email.received") {
await handleInbound(event); // your logic — see below
}
res.sendStatus(200); // ack first; do slow work out of band
});
app.listen(3000);
Why raw bytes? If you let express.json() parse the body first, you no longer have the original bytes — you have an object. Re-stringifying it reorders keys and drops whitespace, the recomputed HMAC won’t match, and every legitimate webhook fails verification. Capture the raw buffer, verify, then parse. This is the single most common inbound bug, and it’s covered in more depth in Verify your inbound email webhooks.
4. Doing something with text, html, and attachments
Now the fun part — handleInbound gets a plain object:
async function handleInbound(event) {
const from = event.from.address;
const to = event.to[0].address;
// Decoded body — use text for parsing, html for display.
const body = event.text ?? "";
// Trust the sender only as far as auth lets you.
if (event.auth.spf !== "pass" || event.auth.dmarc !== "pass") {
console.warn("unauthenticated sender", from);
}
// Fetch attachments on demand from the signed URL.
for (const att of event.attachments ?? []) {
const file = await fetch(att.url);
const bytes = Buffer.from(await file.arrayBuffer());
await saveToStorage(att.filename, att.contentType, bytes);
}
// …create a ticket, update a record, hand it to an agent.
await tickets.create({ from, to, subject: event.subject, body });
}
A few things worth internalizing:
event.textvsevent.html. Usetextwhen you want to parse or search the message (fewer surprises),htmlwhen you want to render it. Both are already decoded to UTF-8.- Attachments are fetched, not embedded. The
urlis signed and short-lived — fetch it inside your handler or a queued job and stream it to your own storage. Don’t hold the connection open re-downloading huge files before you’ve acked. authis an authorization input. The moment your app acts on an email — files a ticket, sends a reply, triggers an agent — a forgedFrom:becomes a security decision.From:is plain text; anyone can set it. Thespf/dkim/dmarcresults are how you decide how much to trust it.
5. Ack fast, work later
Senders retry. If your handler does nine seconds of PDF crunching before returning 200, you risk a timeout and a duplicate delivery. Return 200 the instant you’ve verified and stashed the event, then do heavy work — attachment processing, LLM calls, database writes — in a queue or background job. Your webhook’s only job is: verify, capture, ack.
FAQ
Do I need a separate MIME parser library?
No. That’s the whole point of parsing at the edge — MailKite walks the MIME tree and decodes it before the webhook fires. event.text and event.html are decoded strings; you never see multipart boundaries or base64 in your Node handler.
Why does verification fail even though my secret is right?
Almost always because you verified a re-serialized body instead of the raw bytes. Mount express.raw({ type: "application/json" }) on the webhook route, verify req.body (a Buffer), and only JSON.parse it afterward.
Where do attachments go?
They arrive as a signed url in event.attachments[], not inline. Fetch that URL from your handler or a background job and stream it into your own storage (S3, R2, disk). The URL is short-lived, so don’t cache it — re-fetch if you need the bytes later.
How do I keep replies threaded to the same conversation?
Use event.threadId. It’s a stable identifier for the conversation — store it on your ticket and match future inbound messages against it. There’s a full walkthrough in Reply-by-email: handling inbound replies in your app.
Does this work outside Express?
Yes — the shape is identical for Fastify, Next.js route handlers, Hono, or a raw http server. The only requirement is access to the raw request body for signature verification.
That’s inbound email as JSON in Node, start to finish: install, point a domain, verify, read the fields. No MIME parser, no second API call to fetch the body, no encoding bugs. Point a domain at MailKite and handle your first parsed email in a few minutes — the full field reference lives in the receiving docs.