Tutorials / Without AWS Lambda

Migration · Node.js · Python · 15 minutes

How 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 + LambdaGhostParse
ReceivingSES receipt rules, in the regions that support receivingMX record to mx.ghostparse.com, and the addresses you list
ParsingYour function and its MIME libraryDone for you: text, HTML, reply_text, attachments, headers, all UTF-8
Getting it to your appYour code, its retries and a dead-letter queueA signed webhook, retried, with a backup URL and the sender asked to try again if you're down
Sender checksSES verdicts in the eventSPF, DKIM and DMARC in authentication, with one verdict
ConversationsYour code reads In-Reply-To and Referencesthread_id for each conversation
AttachmentsIn the raw email in S3In the JSON, as files, or uploaded to your S3 bucket with links
Pulling out fieldsMore codeA 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 .eml comes too (as data.raw), with attachment storage it's uploaded to the bucket as message.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:

  1. The day before, lower the TTL of your MX records to 300 seconds, so the switch takes minutes, not hours.
  2. 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.
  3. 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.
  4. Watch the delivery log for an hour: every email, where it went, and why if it didn't.
  5. 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.

Next