BLOGS
Processing Incoming Emails in Node.js: Webhook Guide
Receive and process inbound emails in Node.js with Express. Parse email bodies, handle attachments, and build production-ready webhook handlers.
The Two Approaches
So you want to process incoming emails programmatically in Node.js. There are three main approaches:
- Self-hosted SMTP — running your own mail server, configuring MX records, and parsing raw MIME. This is very brittle and not suited for production use, so we’ll skip it.
- AWS SES Pipeline — wiring together SES, S3, and SQS/SNS to receive and parse emails yourself.
- Managed Webhooks (Pingram) — a hosted service that handles SMTP reception and delivers parsed email data as JSON to your endpoint.
Quick Comparison
| Aspect | Pingram | AWS SES Pipeline |
|---|---|---|
| Setup Time | Minutes | Days |
| MX Records | Optional | Required |
| Email Parsing | Managed | Required |
| Attachments | Included in payload | Manual S3 extraction |
| Reply Threading | Automatic | Parse headers manually |
| Cost | Generous free tier up to 3k emails/month | S3, Lambda costs, SES free tier up to 3k emails/month |
| Other Channels | Email + SMS + Voice | Email 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
- Create a Pingram account and add your domain
- Go to Webhooks, enable the
EMAIL_INBOUNDevent - 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:
- Recipients: Add the addresses or domain you want to receive email for (e.g.
yourapp.comto catch all addresses on that domain). - Actions (in order):
- S3 — select your bucket (
your-app-inbound-emails). SES will store the full raw MIME message here. - SNS — select your topic (
inbound-email-notifications). SES will publish a notification containing the S3 bucket name and object key so your code knows where to fetch the email.
- S3 — select your bucket (
- Set the rule set as active — only one rule set can be active at a time.
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:
-
Pingram: Add your domain, enable
EMAIL_INBOUNDwebhooks, write an Express handler. Threading, parsing, and attachments are handled for you with a generous free tier. Under 20 lines of code. -
DIY (AWS SES + S3): Full control over the pipeline. You own MX records, MIME parsing with
mailparser, S3 storage, and retry logic. .
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.