Personal mail as a serverless pipeline (and a forwarder identity trap)
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:
- S3: write the raw
.emlto a versioned, object-locked bucket. - Lambda: parse the MIME, write a structured record to DynamoDB,
and forward via
ses.send_raw_emailto Google Workspace.
DynamoDB is single-table:
PK = INBOX#<recipient>,SK = <iso-timestamp>#<message-id>- GSI1 keyed by thread, GSI2 by sender, GSI3 by date
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.