Parse inbound email to JSON in Node.js – MailKite

aisendhub4 pts0 comments

Parse inbound email to JSON in Node.js — MailKite<br>Beta<br>50% off your first year — founding rate for the first 1,000 customers.<br>View pricing →

Sign in · Start free

All posts<br>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<br>Host: @<br>Priority: 10<br>Value: mx.mailkite.dev<br>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";<br>import { MailKite } from "@mailkite/sdk";

const app = express();<br>const PORT = process.env.PORT || 3000;<br>const WEBHOOK_SECRET = process.env.MAILKITE_WEBHOOK_SECRET;

// CRITICAL: use express.raw() — do NOT let express.json() parse the body first.<br>// The HMAC signature covers the raw bytes; if JSON parsing happens first,<br>// the bytes you hash won't match the bytes MailKite hashed.<br>app.use("/inbound", express.raw({ type: "application/json" }));

app.post("/inbound", (req, res) => {<br>// 1. Verify the signature<br>const sig = req.headers["x-mailkite-signature"] || "";<br>if (!MailKite.verifyWebhook(sig, req.body, WEBHOOK_SECRET)) {<br>console.error("Bad signature — rejecting");<br>return res.sendStatus(401);

// 2. Acknowledge immediately (MailKite retries on timeout)<br>res.sendStatus(200);

// 3. Parse and handle asynchronously<br>const email = JSON.parse(req.body);<br>if (email.type !== "email.received") return;

console.log(`From: ${email.from.address}`);<br>console.log(`Subject: ${email.subject}`);<br>console.log(`Body: ${email.text}`);<br>});

app.listen(PORT, () => {<br>console.log(`Inbound email handler listening on :${PORT}/inbound`);<br>});<br>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 200 before 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) {<br>// Parse the header: "t=1234567890,v1=abcdef..."<br>const parts = Object.fromEntries(<br>sigHeader.split(",").map((p) => p.split("=", 2))<br>);<br>const timestamp = parts.t;<br>const v1 = parts.v1;

// Recompute: HMAC-SHA256(secret, timestamp + "." + body)<br>const hmac = crypto<br>.createHmac("sha256", secret)<br>.update(`${timestamp}.${rawBody}`)<br>.digest("hex");

// Constant-time compare<br>return crypto.timingSafeEqual(Buffer.from(hmac), Buffer.from(v1));<br>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",<br>"id": "msg_2Hk9…",<br>"from": { "address": "ada@example.com", "name": "Ada Lovelace" },<br>"to": [{ "address": "support@myapp.ai" }],<br>"subject": "Re: invoice #1042",<br>"text": "Looks good — approved!",<br>"html": "Looks good — approved!",<br>"threadId": "",<br>"auth": { "spf": "pass", "dkim": "pass", "dmarc": "pass", "spam": "ham" },<br>"attachments": [<br>"filename": "po.pdf",<br>"contentType": "application/pdf",<br>"size": 18213,<br>"url": "https://api.mailkite.dev/att/2Hk9…/0?sig=…"<br>Key fields:

text and html are already decoded — no quoted-printable, no base64, no charset guessing.

threadId links replies to the original message. Store the message_id you send; when a reply arrives, threadId matches it.

auth shows SPF/DKIM/DMARC results. Check these before trusting the sender.

attachments[].url is a signed URL — download directly or process in your pipeline.

5. Handle attachments

app.post("/inbound", (req, res) => {<br>// ... signature verification ...

res.sendStatus(200);

const email = JSON.parse(req.body);<br>if (email.type !== "email.received") return;

// Process attachments<br>for (const att of email.attachments || []) {<br>console.log(`Attachment: ${att.filename} (${att.size} bytes)`);

// Option A: download via signed URL<br>const response = await fetch(att.url);<br>const buffer = await response.arrayBuffer();

// Option B: use base64 content if...

email mailkite const inbound json express

Related Articles