Back to blog
Node.jsInbound EmailWebhooksTutorialExpress

How to Receive Emails in Node.js (Without a Mail Server)

Receive incoming emails in Node.js using webhooks and inbound email parsing. Express handler with signature verification, reply threading, and routing examples.

SendPigeon TeamApril 10, 20265 min read

To receive emails in Node.js, use an inbound parsing service that forwards incoming emails to your webhook as JSON. No SMTP server, no IMAP polling, no mail infrastructure.

TL;DR

Setup:

  1. MX record → inbound.sendpigeon.dev
  2. Express route handles webhook POSTs
  3. Verify signature, process email

No mail server needed. Works with Express, Fastify, Hono, Koa — anything that handles POST requests.


Why Not Run Your Own SMTP Server?

You can receive email in Node.js with packages like smtp-server or Haraka. But then you're managing:

  • TLS certificates for SMTP
  • Spam and virus filtering
  • SPF/DKIM/DMARC validation
  • Connection handling and backpressure
  • Uptime monitoring for a mail server

An inbound parsing service handles all of this. You get clean JSON at a webhook URL.


Setup

1. Add the MX Record

Point your domain (or subdomain) to the parsing service:

TypePriorityValue
MX10inbound.sendpigeon.dev

Use a subdomain like inbound.yourdomain.com to keep inbound parsing separate from your team's mailboxes.

2. Configure the Webhook

Set your webhook URL in the SendPigeon dashboard or via API:

curl -X PUT https://api.sendpigeon.dev/v1/domains/{domainId}/inbound \
  -H "Authorization: Bearer sp_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "webhookEnabled": true, "webhookUrl": "https://yourapp.com/api/inbound" }'

3. Handle the Webhook

import express from "express";
import { createHmac } from "crypto";

const app = express();
app.use(express.json());

const WEBHOOK_SECRET = process.env.SENDPIGEON_WEBHOOK_SECRET!;

function verifySignature(payload: string, signature: string, timestamp: string): boolean {
  const expected = createHmac("sha256", WEBHOOK_SECRET)
    .update(`${timestamp}.${payload}`)
    .digest("hex");
  return expected === signature;
}

app.post("/api/inbound", (req, res) => {
  const signature = req.headers["x-webhook-signature"] as string;
  const timestamp = req.headers["x-webhook-timestamp"] as string;
  const payload = JSON.stringify(req.body);

  if (!verifySignature(payload, signature, timestamp)) {
    return res.status(401).send("Invalid signature");
  }

  const { event, data } = req.body;

  if (event === "email.received") {
    const { id, from, to, subject, text, html } = data;
    console.log(`Email from ${from}: ${subject}`);

    // Process asynchronously — don't block the response
    processEmail(data).catch(console.error);
  }

  res.status(200).send("OK");
});

app.listen(3000);

Always verify the signature. Without it, anyone who discovers your webhook URL can inject fake emails into your system.


Routing by Recipient Address

Use address-based routing to direct emails to different handlers:

async function processEmail(data: InboundEmail) {
  const { to, from, subject, text, html, id } = data;

  // support@yourdomain.com → ticket system
  if (to.startsWith("support@")) {
    return createTicket({ from, subject, body: text || html });
  }

  // reply+{token}@yourdomain.com → thread reply
  const replyMatch = to.match(/^reply\+(.+)@/);
  if (replyMatch) {
    return addReply(replyMatch[1], { from, body: text || html });
  }

  // invoice-{id}@yourdomain.com → document processing
  const invoiceMatch = to.match(/^invoice-(\w+)@/);
  if (invoiceMatch) {
    return processInvoiceReply(invoiceMatch[1], data);
  }

  console.log(`Unrouted email to ${to}`);
}

Replying with Threading

Reply to inbound emails so the response appears in the sender's original thread:

async function replyToInbound(emailId: string, responseHtml: string) {
  const res = await fetch(
    `https://api.sendpigeon.dev/v1/inbound/emails/${emailId}/reply`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.SENDPIGEON_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ html: responseHtml }),
    }
  );
  return res.json();
}

Threading headers (In-Reply-To, References) are set automatically.


IMAP Polling vs. Webhooks

AspectIMAP PollingInbound Webhooks
LatencySeconds to minutes (depends on poll interval)Instant
ConnectionsPersistent IMAP connection per mailboxStandard HTTP
ComplexityHigh (TLS, auth, connection management)Low (one POST handler)
ReliabilityConnection drops, timeout handlingRetries with backoff
ScalingOne connection per mailboxOne endpoint for all
InfrastructureIMAP-capable mailboxMX record + webhook URL

Webhooks win on every axis unless you need to read from an existing mailbox you don't control.


Handling Attachments

The webhook payload includes rawUrl — a presigned URL to the original .eml file:

import { simpleParser } from "mailparser";

async function extractAttachments(rawUrl: string) {
  const res = await fetch(rawUrl);
  const rawEmail = await res.text();
  const parsed = await simpleParser(rawEmail);

  for (const attachment of parsed.attachments) {
    console.log(`${attachment.filename} (${attachment.contentType}, ${attachment.size} bytes)`);
    // Save to S3, process, etc.
  }

  return parsed.attachments;
}

Error Handling

app.post("/api/inbound", async (req, res) => {
  // Verify signature first...

  try {
    await processEmail(req.body.data);
  } catch (error) {
    // Log the error but still return 200
    // Returning 4xx/5xx triggers retries — only do this if you want a retry
    console.error("Failed to process inbound email:", error);
  }

  // Always return 200 to acknowledge receipt
  res.status(200).send("OK");
});

SendPigeon retries failed deliveries 3 times with exponential backoff. Return 200 to acknowledge receipt. Only return 4xx/5xx if you genuinely want a retry.


Production Checklist

  • Webhook URL is HTTPS
  • Signature verification in place
  • Handler returns 200 quickly (queue heavy work)
  • MX record configured on domain/subdomain
  • Error handling doesn't block the response
  • Tested with SendPigeon's webhook tester

Next Steps