Back to blog
FILE 0xB8·PERSONAL MAIL AS A SERVERLESS PIPELINE (AND A FORWARDER IDEN

Personal mail as a serverless pipeline (and a forwarder identity trap)

April 20, 2026 · aws, email, lambda

I wanted every email to my personal domains stored in DynamoDB as the primary read surface, with Google Workspace continuing to receive a copy as a cold backup. Built it on SES + Lambda. Then I tripped over SES sandbox identity rules.

The architecture

MX records on all my personal domains point at SES inbound. SES has two actions per inbound message:

  1. S3: write the raw .eml to a versioned, object-locked bucket.
  2. Lambda: parse the MIME, write a structured record to DynamoDB, and forward via ses.send_raw_email to Google Workspace.

DynamoDB is single-table:

Attributes carry parsed subject, snippet, recipients, attachments (S3 keys), DKIM/SPF/DMARC verdicts, and whether the forward to Workspace succeeded.

The reason I went serverless instead of routing through my homelab Postfix container: I wanted to be able to lose the homelab for a weekend without losing mail. SES doesn't care if my house is down.

The forwarder identity trap

While still in the SES sandbox, every forward kept getting rejected. Verified the From identity. Verified the To identity. Still rejected.

The actual rule: SES sandbox rejects forwards if ANY address-bearing header — From, Sender, Reply-To, Return-Path, Resent-*, Delivered-To — contains an unverified identity. So when I tried to preserve the original sender in From, SES saw that random external address and bounced the send.

Fix: strip all address-bearing headers before forwarding, then re-stamp the From with a verified identity and stash the original sender in a non-standard header for the reader UI:

ORIGINAL_FROM = msg.get("From", "")
for h in ("From","Sender","Reply-To","Return-Path",
          "Resent-From","Resent-Sender","Resent-Reply-To",
          "Delivered-To"):
    del msg[h]
display = ORIGINAL_FROM.replace("@", " at ")
msg["From"] = f'"{display} via mail-interceptor" <mailer@cwfrazier.com>'
msg["X-Original-From"] = ORIGINAL_FROM

And then the Reply-To bug

A week later, I noticed replies in Gmail were going back to mailer@cwfrazier.com instead of the actual sender. Of course they were — I'd stripped Reply-To along with everything else and never put it back. Gmail's Reply button uses From when Reply-To is missing.

Fix: capture the original Reply-To (falling back to original From) before stripping, and re-add it after the From rewrite:

ORIGINAL_REPLY_TO = msg.get("Reply-To") or msg.get("From")
# ... strip and rewrite From as above ...
msg["Reply-To"] = ORIGINAL_REPLY_TO

Once SES production access lands, unverified Reply-To addresses are accepted. While in sandbox, this would have bounced — so order matters: leave Reply-To handling for after the production access request clears.

The self-loop

One domain has the same name as the destination Workspace mailbox. When I cut its MX to SES, the forward path became:

SES inbound → Lambda → SES outbound looks up MX for the destination domain → that's me → back to SES inbound → repeat.

Workaround: stand up a subdomain alias (gw.example.com) that Workspace owns, forward everything to that, and let Workspace's alias routing land it back in the real mailbox. Five minutes in the Workspace admin console, zero code change. The other domains had no loop risk and could be cut over immediately.

What I'd do differently

The header-stripping approach works but it's a hack. SES now supports inbound rules with much more flexibility than it did when I started. If I were building this fresh I'd look at whether DMARC alignment + ARC sealing on the forward path would let me preserve original sender headers, instead of replacing them. The non-standard X-Original-From header has been fine for the reader UI but it's not something other mail clients know about.