All posts
Receive email in real time with Node.js
Gabe 6 min read

Receive email in real time with Node.js

Receive each new email as signed JSON in an Express endpoint, then store it before returning 200. This Node.js example uses the MailKite SDK and built-in SQLite to preserve the raw-body verification boundary and deduplicate deliveries. MailKite is the platform we build.

Email reaches MailKiteSigned POST → Express express.raw()SDK verifies Buffer Commit SQLite rowOne row per message id Return 200 OKWork accepted durably
The middleware order is part of the signing contract.

The runnable source and delivery checks live in the shared receiving-email demo repository.

Save the downloadable receiver as receive.mjs. It requires Node.js 22.13+ for built-in SQLite. The Node.js receiving guide includes installation and domain setup.

import express from "express";
import { DatabaseSync } from "node:sqlite";
import { MailKite } from "mailkite";

const secret = process.env.MAILKITE_WEBHOOK_SECRET;
if (!secret) throw new Error("Set MAILKITE_WEBHOOK_SECRET");
const db = new DatabaseSync(process.env.INBOX_DB || "inbox.sqlite");
db.exec("PRAGMA busy_timeout = 5000");
db.exec("CREATE TABLE IF NOT EXISTS inbox (id TEXT PRIMARY KEY, payload TEXT NOT NULL)");
const insert = db.prepare("INSERT INTO inbox (id, payload) VALUES (?, ?) ON CONFLICT(id) DO NOTHING");
const app = express();
app.post("/hooks/mailkite", express.raw({ type: "application/json", limit: "10mb" }), (req, res) => {
  if (!Buffer.isBuffer(req.body) || !MailKite.verifyWebhook(req.get("x-mailkite-signature") || "", req.body, secret)) {
    return res.sendStatus(401);
  }
  let event;
  try { event = JSON.parse(req.body.toString("utf8")); }
  catch { return res.sendStatus(400); }
  if (!event || typeof event !== "object" || Array.isArray(event)) return res.sendStatus(400);
  if (event.type === "email.received") {
    if (typeof event.id !== "string" || !event.id) return res.sendStatus(400);
    try { insert.run(event.id, req.body.toString("utf8")); }
    catch { return res.sendStatus(503); }
  }
  return res.json({ status: "ok" });
});
app.listen(Number(process.env.PORT || 3000));

Mount the raw-body route before express.json

The verifier needs the Buffer that arrived. A global express.json() mounted before this route consumes the stream and changes what the handler sees. Mount this route first, then ordinary JSON middleware for your other endpoints. Re-serializing a parsed object can change whitespace or Unicode escaping and invalidate the signature.

TypeScript applications use the same SDK. Keep the runtime Buffer check; a request-body type annotation cannot restore signed bytes. Validate the decoded fields your application relies on rather than casting arbitrary JSON to a trusted event interface. The security reference explains HMAC and timestamp freshness.

Save before responding, then process separately

Before launching the receiver, add and verify your receiving domain and create an exact-address webhook route such as inbound@your-receiving-domain. Point it at your public HTTPS /hooks/mailkite URL; a development tunnel can forward that URL to port 3000. Obtain the signing secret for this route and supply it as MAILKITE_WEBHOOK_SECRET through your environment. Install mailkite and express, then run node receive.mjs.

Verification is local, so the receiver needs no API key. Routes can have different signing secrets; use the one belonging to this exact-address route. INBOX_DB optionally changes the SQLite location.

The synchronous insert commits before res.json(). A duplicate message id adds no row. Storage failure returns 503 rather than acknowledging work the application did not accept. Keep the database on persistent disk and outside public assets.

Unsafe: return 200Before saving the event Process crashesUnsaved work is lost Durable: commit firstId + payload on disk Then return 200Event is accepted Worker can resumeFrom the stored event
Acknowledgement is not business-task completion.

This is a small inbox, not a throughput benchmark or a finished worker. Synchronous database work occupies the event loop. Multiple replicas need a shared database or durable queue whose acceptance you await. An ephemeral serverless filesystem will not retain this local inbox across replacements.

The worker should track completion and use idempotency for external actions. A unique inbox key alone cannot prevent repeating an API call if the worker crashes after making it but before saving completion.

Choose direct inbound or forwarding

For an application address, use the exact DNS records from the dashboard or an available, verified hosted subdomain. Keep the webhook route scoped to the one receiving address used by your integration.

Download the synthetic checker as check.mjs. With the receiver running and the same MAILKITE_WEBHOOK_SECRET set in a second terminal, run:

node check.mjs http://127.0.0.1:3000/hooks/mailkite

This exercises valid and duplicate events, tampered bodies, missing signatures and signed malformed JSON. It sends no email and cannot validate DNS or route matching. The dashboard’s domain test targets the catch-all *@domain webhook, not your exact-address route. After the local check, send a real email directly to the exact receiving address and inspect its MailKite delivery record.

The inbox inspector can read this SQLite inbox too. Download it and, with Python 3 installed, run:

python3 inspect-inbox.py inbox.sqlite

Use the actual database path if INBOX_DB differs. The inspector prints the latest ten messages and their bodies, so keep the database and output private. It is a local tool, not a web endpoint or a row-count assertion. Replay the real delivery and check that there is one stored row for its id; a duplicate HTTP 200 alone is not proof of deduplication.

For an existing personal inbox, use the Gmail or iCloud Mail forwarding guide. The original mailbox stays at its provider. “Real time” describes push delivery after arrival, not a fixed SMTP or forwarding delay.

Gmail’s API is the appropriate path when you need labels or mailbox state. Postmark inbound and SES receipt rules are alternatives for domains you own. A self-hosted Haraka receiver gives more operational control, but you own MIME parsing, webhook signing and retries.

Decode the email without flattening its fields

from and to contain address objects. Subjects and bodies can be null; prefer text, then textFromHtml. Save attachment metadata and download required files in the worker before their signed URLs expire.

A valid webhook signature authenticates the HTTP delivery, not the original sender. Use the independent email-authentication verdicts and keep unknown results as unknown, especially for forwarded mail.

Once a worker exists, test its downstream job count and side effects separately from the stored inbox. Continue with the event reference, delivery docs, or Python implementation. The downloadable Node receiver is the exact implementation shown here.

Discuss this post: Hacker News Share on X Share on LinkedIn

Related posts