Tutorials / Without AWS Lambda
Migration · Node.js · Python · 15 minutesHow to parse inbound MIME email without AWS Lambda
SES, a receipt rule, an S3 bucket, a Lambda function and a MIME library, just to read an email. Here's how to replace the chain with an MX record and one webhook that arrives already parsed, keeping your handler and, if you like, your bucket.
The common way to receive email on AWS is a chain: SES accepts the message, a receipt rule saves the raw MIME to S3, and a Lambda function downloads it, parses it with a MIME library and calls your app. It works, but you own every link: the receipt rule set, the bucket policy, IAM roles, the function's runtime and dependencies, retries and a dead-letter queue, and the MIME edge cases (character sets, encodings, nested multiparts, inline images). This guide replaces the whole chain with an MX record and one webhook, while keeping your bucket if you want it.
1. Before and after
| SES + S3 + Lambda | GhostParse | |
|---|---|---|
| Receiving | SES receipt rules, in the regions that support receiving | MX record to mx.ghostparse.com, and the addresses you list |
| Parsing | Your function and its MIME library | Done for you: text, HTML, reply_text, attachments, headers, all UTF-8 |
| Getting it to your app | Your code, its retries and a dead-letter queue | A signed webhook, retried, with a backup URL and the sender asked to try again if you're down |
| Sender checks | SES verdicts in the event | SPF, DKIM and DMARC in authentication, with one verdict |
| Conversations | Your code reads In-Reply-To and References | thread_id for each conversation |
| Attachments | In the raw email in S3 | In the JSON, as files, or uploaded to your S3 bucket with links |
| Pulling out fields | More code | A parser puts them in extracted |
2. The code: before and after
A typical function, with mailparser:
// The usual AWS setup: an SES receipt rule saves the raw email to S3 and invokes this function.
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { simpleParser } from "mailparser";
const s3 = new S3Client({});
export const handler = async (event) => {
const { messageId } = event.Records[0].ses.mail;
const object = await s3.send(new GetObjectCommand({ Bucket: "yourapp-inbound", Key: `incoming/${messageId}` }));
const parsed = await simpleParser(Buffer.from(await object.Body.transformToByteArray()));
await handleEmail(parsed); // your code: parsed.from, parsed.subject, parsed.text, parsed.attachments …
};
The same thing as a webhook endpoint (on the server you already have, or any serverless function with an HTTPS URL):
// No SES rule, bucket trigger or function: GhostParse receives and parses the email and POSTs it here.
import express from "express";
import { verifyWebhook } from "@wildye/email-parser";
import { toParsedMail } from "./mailparser-shape.mjs";
const app = express();
app.post("/webhooks/email", express.raw({ type: "application/json", limit: "40mb" }), async (req, res) => {
let event;
try {
event = verifyWebhook(req.body, req.headers, process.env.GHOSTPARSE_WEBHOOK_SECRET);
} catch {
return res.sendStatus(400);
}
if (event.type === "email.received") await handleEmail(toParsedMail(event.data)); // the same object as before
res.sendStatus(200);
});
The adapter gives you the object simpleParser() returned, so handleEmail doesn't change. Our test
suite runs the same raw email through mailparser and through GhostParse and checks the fields match:
// GhostParse's email.received data in the shape mailparser's simpleParser() returns, for Lambda code that used
// it: `const parsed = toParsedMail(event.data)` instead of `const parsed = await simpleParser(rawFromS3)`.
// Covers the fields most handlers read; GhostParse's own extras (reply_text, thread_id, authentication,
// extracted) are on event.data.
const list = (addresses = []) => addresses.length
? { value: addresses.map((a) => ({ address: a.address, name: a.name ?? "" })), text: addresses.map((a) => (a.name ? `"${a.name}" <${a.address}>` : a.address)).join(", ") }
: undefined;
export function toParsedMail(data) {
return {
messageId: data.message_id ?? undefined,
inReplyTo: data.in_reply_to ?? undefined,
references: data.references?.length ? data.references : undefined,
date: new Date(data.date ?? data.received_at),
subject: data.subject ?? undefined,
from: list(data.from ? [data.from] : []),
to: list(data.to),
cc: list(data.cc),
replyTo: list(data.reply_to),
text: data.text ?? undefined,
html: data.html ?? false, // mailparser uses false when there's no HTML part
headers: new Map(Object.entries(data.headers ?? {})),
attachments: data.attachments.map((a) => ({
type: "attachment",
filename: a.filename ?? undefined,
contentType: a.content_type,
contentDisposition: a.disposition ?? "attachment",
cid: a.content_id?.replace(/^<|>$/g, "") || undefined,
related: a.disposition === "inline",
size: a.size,
content: Buffer.from(a.content ?? "", "base64"),
})),
};
}
On Python with the email package, the same values are already plain fields: msg["From"] is
data["from"], msg.get_body(("plain",)) is data["text"], ("html",) is
data["html"], and each part.get_payload(decode=True) attachment is
base64.b64decode(a["content"]), with a["filename"] and a["content_type"]. Start from the
ready-to-run Python endpoint.
3. Keep your S3 bucket (optional)
- Attachments: on the Inbound webhook page, choose Upload to your own bucket and give it the bucket and an access key that can only write to it. Each attachment is uploaded and the webhook carries its key and a download link. How it works.
- The raw email: turn on Include the original message and the full
.emlcomes too (asdata.raw), with attachment storage it's uploaded to the bucket asmessage.eml, for archiving.
4. Test with emails you already have
Your bucket is full of real test cases. Download a few and run them through GhostParse's parser into your endpoint:
aws s3 cp s3://yourapp-inbound/incoming/0123abcd… ./email.eml export EMAIL_PARSER_API_KEY=ek_live_… npx @wildye/email-parser-cli simulate http://localhost:3000/webhooks/email --eml ./email.eml --print
Nothing is delivered or counted (the sandbox shows attachment names and sizes, not their contents), and
--print shows the JSON. To see real deliveries, point a test address at the dashboard's
webhook inspector.
5. Switch the MX record
Your domain's MX points at SES (inbound-smtp.<region>.amazonaws.com). Create an account,
add the domain and its addresses (or * for every address), set your webhook URL, then:
- The day before, lower the TTL of your MX records to 300 seconds, so the switch takes minutes, not hours.
- Send a real email to a test address on the domain (with an attachment) and check it in the dashboard's Delivery log and in your app.
- Switch the MX record to
mx.ghostparse.com(priority 10) and remove the old one. Mail already queued for the old provider still arrives there, so keep its webhook running for a day or two. - Watch the delivery log for an hour: every email, where it went, and why if it didn't.
- A week later, raise the TTL again and turn off the old provider.
Once mail has stopped arriving at SES, disable the receipt rule set, then remove the function, its trigger and the role.
One behaviour to know: if your endpoint is down, we retry quickly (3 attempts), try your backup URL if you
set one, then ask the sending mail server to try again later; mail servers keep retrying for hours to days, so an
outage doesn't lose email. A 4xx answer (other than 408 and 429) is final: the email is bounced back to the sender.
So answer 400 only for a bad signature, and 5xx when you want it sent again. Delivery & retries.