Build a support inbox in Next.js (email in, tickets out)
Point support@yourdomain at MailKite, parse the inbound email to JSON, and POST it to a Next.js Route Handler. Verify the signature against the raw body, create a ticket, and auto-reply — the whole loop, in real code.
A support inbox in Next.js is a Route Handler at app/api/hooks/mailkite/route.ts that MailKite POSTs every parsed inbound email to: it verifies the x-mailkite-signature header against the raw request body, turns the email.received JSON into a ticket in your database, and optionally auto-replies with mk.send(). No IMAP polling, no MIME parsing, no cron job — a webhook fires the instant mail lands on your domain.
I’ve built this exact feature three times at three companies, and the first two times I did it the hard way: a mailbox, a poller, a MIME library, and a weekend I’d like back. Here’s the version I’d hand to anyone starting fresh in Next.js today.
The shape of the thing
The flow is a straight line:
- A customer emails
support@yourdomain.com. - MailKite receives it at the edge, parses the entire MIME tree, and POSTs you one webhook with the whole message as JSON.
- Your Next.js Route Handler verifies the signature, writes a ticket, and (if you want) fires back an auto-acknowledgement.
The only email-specific work you do is steps you’d do for any webhook: verify it’s real, then read the fields. Here’s the payload that arrives:
{
"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=…"
}
]
}
text and html are already decoded — the £ is a £, not £. threadId is what turns a stream of replies into a conversation. And auth tells you whether SPF, DKIM, and DMARC passed, which matters the moment your inbox does something with the mail.
Step 1 — install and point a domain
npm install mailkite
Add the MX record MailKite gives you, verify the domain, and set your webhook URL to https://yourapp.com/api/hooks/mailkite. Two secrets go in your environment: the webhook signing secret (to verify inbound) and an API key (to send replies).
# .env.local
MAILKITE_WEBHOOK_SECRET=whsec_…
MAILKITE_API_KEY=mk_live_…
Step 2 — the Route Handler
This is the whole feature. The one Next.js-specific detail that trips everyone up: you must verify against the raw request body, so read it with await req.text() and do not use await req.json() first. Next.js doesn’t hand you the raw bytes if you’ve already parsed them, and a re-serialized object won’t match the HMAC.
// app/api/hooks/mailkite/route.ts
import { NextRequest, NextResponse } from "next/server";
import { MailKite } from "mailkite";
import { createTicket } from "@/lib/tickets";
const SECRET = process.env.MAILKITE_WEBHOOK_SECRET!;
const mk = new MailKite(process.env.MAILKITE_API_KEY!);
export async function POST(req: NextRequest) {
// Raw body, exactly as sent — the signature is over these bytes.
const raw = await req.text();
const sig = req.headers.get("x-mailkite-signature");
// Recomputes the HMAC, constant-time compares, and rejects anything
// outside the ±5-minute replay window.
if (!MailKite.verifyWebhook(sig, raw, SECRET)) {
return new NextResponse("bad signature", { status: 401 });
}
const event = JSON.parse(raw);
if (event.type !== "email.received") {
return NextResponse.json({ ok: true });
}
// Decide how much to trust the sender before you act on the mail.
const trusted = event.auth?.spf === "pass" && event.auth?.dmarc === "pass";
const ticket = await createTicket({
fromAddress: event.from.address,
subject: event.subject,
body: event.text,
html: event.html,
threadId: event.threadId, // groups replies into one conversation
spam: event.auth?.spam,
trusted,
attachments: event.attachments ?? [], // each has a short-lived signed `url`
});
// Optional: auto-acknowledge. mk.send returns { id, status }.
await mk.send({
from: "support@myapp.ai",
to: event.from.address,
subject: `Re: ${event.subject}`,
html: `<p>Thanks — we've opened ticket #${ticket.id} and a human will reply shortly.</p>`,
});
return NextResponse.json({ ok: true }); // ack fast; heavy work goes out of band
}
That’s it. There is no app/api/hooks/mailkite/GET to write, no polling loop, no MIME parser in your node_modules. The handler that used to be a background service is now forty lines you can read in one sitting.
Step 3 — the two rules that save you a debugging session
Verify the raw bytes. The single most common failure here is calling await req.json(), then re-stringifying to check the signature. JSON round-tripping reorders keys and changes whitespace; the HMAC is computed over the exact bytes MailKite sent. Read req.text() first, verify, then JSON.parse.
Ack fast, work later. Return 200 as soon as the ticket is written. If you block the response on a slow AI summarizer or a flaky third-party API, senders retry and you get duplicate tickets. Push the slow work to a queue or a background function and let the webhook return immediately.
Threading: replies that reopen the same ticket
When Ada replies to your auto-acknowledgement, the next webhook carries the same threadId. Key your tickets on it and a back-and-forth collapses into one conversation instead of ten disconnected rows:
const existing = await findTicketByThread(event.threadId);
const ticket = existing
? await appendReply(existing.id, event.text)
: await createTicket({ /* …as above */ });
That single field is the difference between an inbox and a pile of unrelated messages.
FAQ
Why await req.text() instead of req.json() in the Route Handler?
Webhook signatures are HMACs over the raw request body. If you parse to JSON and re-serialize, the bytes change and verification fails. Read the raw string with req.text(), run MailKite.verifyWebhook(sig, raw, SECRET), then JSON.parse(raw).
Do I need to disable Next.js body parsing?
No. App Router Route Handlers don’t auto-parse the body — you choose with req.text(), req.json(), or req.arrayBuffer(). Just make sure text()/arrayBuffer() is the first thing you read so the raw bytes are still available.
How do I stop spoofed emails from opening tickets?
Don’t trust the From: header — it’s plain text. Check the auth object (spf, dkim, dmarc, and the spam verdict) and decide how much to trust the sender. And always verify the webhook signature so you know the POST genuinely came from MailKite.
How do attachments arrive?
Not inline by default — each is a short-lived signed url in the attachments array that you fetch on demand, so a 13 MB PDF never bloats your webhook body. Store the file or hand the URL straight to your storage layer.
Can the auto-reply count as the delivery?
For simple acknowledgements, mk.send() from your API key is the clearest path and gives you an id to log. MailKite also supports an inline reply ack if you’d rather answer straight from the webhook — see the receiving docs.
That’s a full support inbox: email in, tickets out, replies threaded, in one Route Handler. Point a domain at MailKite and send your first test email to support@ — the webhook fires in seconds.
Related: Receiving email is the part nobody warns you about — why inbound is the hard direction, and Receive email in Python if Django or FastAPI is your stack instead.