Parse inbound email to JSON in Node.js
Receive email as a webhook in Node.js: set up MailKite, verify the HMAC signature, parse the JSON payload, and handle attachments. Complete tutorial with working Express code.
MailKite parses inbound email into clean JSON and POSTs it to your Node.js endpoint — no MIME parsing, no mail server, no mailparse dependency. This tutorial walks through the full flow: adding a domain, registering a webhook, verifying the HMAC signature, and handling the payload in Express.
If you want the multi-language version first (Node, Python, Go, PHP side by side), start with the complete email-to-webhook guide. For the product-level overview, see the inbound email API or the email parser API comparison. This post goes deeper on the Node-specific details.
Prerequisites
- A MailKite account (sign up free — no credit card)
- Node.js 18+ (ESM)
- A domain you control (for MX records)
1. Add your domain
In the MailKite dashboard, add your domain. MailKite generates an MX record:
Type: MX
Host: @
Priority: 10
Value: mx1.mailkite.dev
Add this to your DNS. Once it propagates (usually under 5 minutes), MailKite activates the domain and starts receiving email.
2. Set up Express
import express from "express";
import { MailKite } from "mailkite";
const app = express();
const PORT = process.env.PORT || 3000;
const WEBHOOK_SECRET = process.env.MAILKITE_WEBHOOK_SECRET;
// CRITICAL: use express.raw() — do NOT let express.json() parse the body first.
// The HMAC signature covers the raw bytes; if JSON parsing happens first,
// the bytes you hash won't match the bytes MailKite hashed.
app.use("/inbound", express.raw({ type: "application/json" }));
app.post("/inbound", (req, res) => {
// 1. Verify the signature
const sig = req.headers["x-mailkite-signature"] || "";
if (!MailKite.verifyWebhook(sig, req.body, WEBHOOK_SECRET)) {
console.error("Bad signature — rejecting");
return res.sendStatus(401);
}
// 2. Acknowledge immediately (MailKite retries on timeout)
res.sendStatus(200);
// 3. Parse and handle asynchronously
const email = JSON.parse(req.body);
if (email.type !== "email.received") return;
console.log(`From: ${email.from.address}`);
console.log(`Subject: ${email.subject}`);
console.log(`Body: ${email.text}`);
});
app.listen(PORT, () => {
console.log(`Inbound email handler listening on :${PORT}/inbound`);
});
Two things that trip people up:
-
Raw body is mandatory. The HMAC signature covers the exact bytes MailKite signed. If your framework parses JSON before your handler runs, the re-serialized bytes won’t match.
express.raw()is the fix. -
Ack fast. Return
200before doing any heavy processing. MailKite measures response time and retries on timeout. Do your work after the ack.
3. Verify the signature
MailKite.verifyWebhook() recomputes the HMAC-SHA256 of the raw body using your webhook secret and compares in constant time. It also checks the timestamp to prevent replay attacks.
If you’re not using the SDK, here’s the manual verification:
import crypto from "node:crypto";
function verifySignature(sigHeader, rawBody, secret) {
// Parse the header: "t=1234567890,v1=abcdef..."
const parts = Object.fromEntries(
sigHeader.split(",").map((p) => p.split("=", 2))
);
const timestamp = parts.t;
const v1 = parts.v1;
// Recompute: HMAC-SHA256(secret, timestamp + "." + body)
const hmac = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
// Constant-time compare (guard the length — timingSafeEqual throws on a mismatch)
const a = Buffer.from(hmac, "hex");
const b = Buffer.from(v1, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
The SDK does this for you — but understanding it helps when debugging.
4. The payload
Every inbound message arrives as this shape:
{
"type": "email.received",
"id": "msg_2Hk9…",
"from": { "address": "ada@example.com", "name": "Ada Lovelace" },
"to": [{ "address": "support@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": [
{
"filename": "po.pdf",
"contentType": "application/pdf",
"size": 18213,
"url": "https://api.mailkite.dev/att/2Hk9…/0?exp=1754265600&sig=…"
}
]
}
Key fields:
textandhtmlare already decoded — no quoted-printable, no base64, no charset guessing.threadIdlinks replies to the original message. Store themessage_idyou send; when a reply arrives,threadIdmatches it.receivedAt(epoch ms) andreceivedAtIso(ISO string) are when MailKite accepted the message — arrival time, not the sender’s self-assertedDate:header. Identical across retries and replays.authshows SPF/DKIM/DMARC results. Check these before trusting the sender.attachments[].urlis a signed URL — download directly or process in your pipeline.
5. Handle attachments
app.post("/inbound", async (req, res) => {
// ... signature verification ...
res.sendStatus(200);
const email = JSON.parse(req.body);
if (email.type !== "email.received") return;
// Process attachments
for (const att of email.attachments || []) {
console.log(`Attachment: ${att.filename} (${att.size} bytes)`);
// Option A: download via signed URL
const response = await fetch(att.url);
const buffer = await response.arrayBuffer();
// Option B: use base64 content if provided inline
// const buffer = Buffer.from(att.content, "base64");
}
});
6. Deploy
This works on any Node hosting — Railway, Fly.io, Render, Vercel (with export const config = { runtime: 'nodejs' }), or your own VPS. The only requirement: your endpoint must be reachable from the internet on port 443 (HTTPS).
Set these environment variables:
MAILKITE_API_KEY=your-api-key
MAILKITE_WEBHOOK_SECRET=your-webhook-secret
Both are in the MailKite dashboard under your domain’s webhook settings.
What’s next
- Receive email in Python — same flow, Flask version
- The complete email-to-webhook guide — multi-language reference
- Reply by email in your app — send replies back through the same domain
- SendGrid Inbound Parse alternative — why MailKite’s JSON beats multipart
Point an MX record, register a URL, and email becomes just another JSON request. Start free — unlimited domains, no credit card.