BLOGS

Processing Incoming Emails in Node.js: Webhook Guide

March 28, 2026

Receive and process inbound emails in Node.js with Express. Parse email bodies, handle attachments, and build production-ready webhook handlers.

Processing Incoming Emails in Node.js: Webhook Guide

The Two Approaches

So you want to process incoming emails programmatically in Node.js. There are three main approaches:

Quick Comparison

AspectPingramAWS SES Pipeline
Setup TimeMinutesDays
MX RecordsOptionalRequired
Email ParsingManagedRequired
AttachmentsIncluded in payloadManual S3 extraction
Reply ThreadingAutomaticParse headers manually
CostGenerous free tier up to 3k emails/monthS3, Lambda costs, SES free tier up to 3k emails/month
Other ChannelsEmail + SMS + VoiceEmail only

Option 1: Pingram (Ship in Minutes)

Pingram handles SMTP reception, MIME parsing, and webhook delivery. You write the handler.

1. Set Up Your Inbox

  1. Create a Pingram account and add your domain
  2. Go to Webhooks, enable the EMAIL_INBOUND event
  3. Set the webhook URL to your Express endpoint (e.g., https://api.yourapp.com/webhooks/inbound-email)

Pingram also provides a test inbox out of the box so you can send emails and test your webhook immediately.

2. Webhook Handler Example

import express from 'express';

const app = express();
app.use(express.json({ limit: '10mb' }));

app.post('/webhooks/inbound-email', (req, res) => {
  const payload = req.body;

  if (payload.eventType !== 'EMAIL_INBOUND') {
    return res.status(200).json({ skipped: true });
  }

  const email = payload;

  console.log('Subject: ', email.subject);
  console.log('Body: ', email.bodyText);

  res.status(200).json({ received: true });
});

app.listen(3000, () => console.log('Webhook server running on port 3000'));

Webhook Payload Reference

{
  "eventType": "EMAIL_INBOUND",
  "from": "customer@example.com",
  "fromName": "Jane Customer",
  "to": "support@yourapp.com",
  "subject": "Re: Order #12345",
  "bodyText": "When will my order arrive?",
  "bodyHtml": "<p>When will my order arrive?</p>",
  "trackingId": "018d5a2b-3c4d-7e8f-9a0b-1c2d3e4f5a6b",
  "receivedAt": "2026-03-28T14:32:00.000Z",
  "userId": "user_123",
  "type": "order_confirmation",
  "attachments": [
    {
      "filename": "receipt.pdf",
      "contentType": "application/pdf",
      "size": 24310,
      "content": "base64-encoded-content..."
    }
  ]
}

That’s it. A few minutes and 20 lines of code to process incoming emails in Node.js.


Option 2: Build Your Own Pipeline (AWS SES)

If you need full control over the email reception layer, you can wire AWS SES to your Express server.

Architecture

Incoming Email → MX Records → AWS SES → S3 Bucket → SQS/SNS → Express Webhook

1. Configure AWS SES for Inbound

SES inbound email receiving is only available in certain regions. Make sure you’re working in one of those.

Verify your domain. In the SES console, go to Verified identities → Create identity, select “Domain”, and enter your domain. Follow the DNS instructions to add the DKIM CNAME records.

Add an MX record. In your DNS provider, add an MX record pointing to the SES inbound endpoint for your region:

MX 10 inbound-smtp.us-east-1.amazonaws.com

Create an S3 bucket for storing raw emails. In the S3 console, create a bucket (e.g. your-app-inbound-emails). Add this bucket policy so SES can write to it:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": { "Service": "ses.amazonaws.com" },
      "Action": "s3:PutObject",
      "Resource": "arn:aws:s3:::your-app-inbound-emails/*",
      "Condition": {
        "StringEquals": { "AWS:SourceAccount": "YOUR_ACCOUNT_ID" }
      }
    }
  ]
}

Create an SNS topic. In the SNS console, create a standard topic (e.g. inbound-email-notifications). You’ll subscribe your Express endpoint to this topic later.

Create an SES receipt rule set. In the SES console, go to Email receiving → Create rule set. Then create a rule inside it:

SES doesn’t parse or forward email content directly. It stores the raw file in S3, and the SNS notification is what triggers your code to pull and parse it.

2. Install Dependencies

npm install express @aws-sdk/client-s3 @aws-sdk/client-sqs mailparser

3. Build the Express Handler

import express from 'express';
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
import { simpleParser } from 'mailparser';

const app = express();
app.use(express.json({ limit: '25mb' }));

const s3 = new S3Client({ region: 'us-east-1' });

app.post('/webhooks/inbound-email', async (req, res) => {
  try {
    const notification = req.body;

    // Handle SNS subscription confirmation
    if (notification.Type === 'SubscriptionConfirmation') {
      await fetch(notification.SubscribeURL);
      return res.status(200).json({ confirmed: true });
    }

    const message = JSON.parse(notification.Message);
    const bucket = message.receipt.action.bucketName;
    const key = message.receipt.action.objectKey;

    // Fetch the raw email from S3
    const { Body } = await s3.send(
      new GetObjectCommand({ Bucket: bucket, Key: key })
    );
    const rawEmail = await Body.transformToString();

    // Parse the MIME content
    const parsed = await simpleParser(rawEmail);

    console.log(`From: ${parsed.from?.text}`);
    console.log(`Subject: ${parsed.subject}`);
    console.log(`Text body: ${parsed.text}`);
    console.log(`HTML body: ${parsed.html}`);

    // Process attachments
    for (const attachment of parsed.attachments) {
      console.log(
        `Attachment: ${attachment.filename} (${attachment.contentType}, ${attachment.size} bytes)`
      );
      // attachment.content is a Buffer — upload to S3, save to disk, etc.
    }

    res.status(200).json({ processed: true });
  } catch (error) {
    console.error('Failed to process inbound email:', error);
    res.status(500).json({ error: 'Processing failed' });
  }
});

app.listen(3000, () => console.log('Webhook server running on port 3000'));

Trade-offs: Full control, but you own MX records, SES configuration, S3 lifecycle policies, MIME parsing edge cases, retry logic, and conversation threading.


Error Handling and Resilience

Production email processing needs fault tolerance. Emails are high-value data—you can’t afford to silently drop them.

Idempotent Handlers

Email webhook providers retry on failure. Your handler must handle duplicates:

const processedEmails = new Set(); // Use Redis or a database in production

app.post('/webhooks/inbound-email', (req, res) => {
  const messageId = req.body.trackingId || req.body.messageId;

  if (processedEmails.has(messageId)) {
    return res.status(200).json({ duplicate: true });
  }

  processedEmails.add(messageId);

  // Process the email...

  res.status(200).json({ received: true });
});

Respond Fast, Process Later

Return 200 immediately, then handle heavy work asynchronously. This prevents webhook timeouts and retry storms:

import { Queue } from 'bullmq';

const emailQueue = new Queue('inbound-emails');

app.post('/webhooks/inbound-email', async (req, res) => {
  const payload = req.body;

  // Acknowledge immediately
  res.status(200).json({ received: true });

  // Queue the actual processing
  await emailQueue.add('process-email', {
    from: payload.from,
    subject: payload.subject,
    bodyText: payload.bodyText,
    trackingId: payload.trackingId,
    receivedAt: new Date().toISOString()
  });
});

TLDR

Processing incoming emails in Node.js comes down to two paths:

For teams that have the luxury of time and resources to correctly configure and maintain the inbound email pipeline, the DIY approach is a better option. For smaller teams and startups, Pingram’s managed approach is a better option.