Kai Ole Hartwig
Go · Apache-2.0 · macOS, Linux, Windows, iOS

s3mail — your mail sits in your bucket.

Amazon SES writes incoming mail into an S3 bucket as raw MIME. What was missing was a program that reads it from there. s3mail is a single file with no runtime: it starts a web server on 127.0.0.1 and shows the mailbox in your browser. There is an iOS app as well, reading the same bucket directly — with a key of its own.

Nothing sits in between. No server to fail, none for anyone to run, none to read along.

  • Written in: Go — the standard library plus the AWS SDK, and almost nothing else
  • Runs on: macOS, Linux, Windows · iOS
  • Licence: Apache-2.0

SES puts the mail in the bucket. And then?

With s3mail

A mailbox in the browser: folders, search, rules, attachments, HTML in a sandboxed view. Replies go out through SES. And the same mailbox on your phone, straight from the same bucket.

Until now

aws s3 cp, a MIME parser written by hand, and a terminal. Attachments arrive base64-encoded, subjects as encoded words, HTML as source. Replying is not on the table at all.

What makes s3mail s3mail

Reachable for a model — but not to send

Over MCP a language model can read, search, tag, move and write drafts. Not send. An incoming message is a stranger's text inside the model's context; the drafts folder is where a human looks.

The phone gets a key of its own

Pairing means: type a name, scan a QR code, confirm six digits. Behind it an IAM user of its own is created, capped by a permissions boundary — so a lost phone is a single revocation and not a rotation everywhere.

Shared state without a lock

Read, starred, tags and rules live in the bucket as an operation log: every write gets its own key. So there is nothing to resolve and no compare-and-swap to get wrong. The price: every operation has to be idempotent.

Folders are real S3 prefixes

No index claiming to know the truth about the bucket. A folder is a prefix, and the message id is the basename inside it — which is why everything you knew about a message survives moving it.

The policy is the configuration

Bucket, prefix and sender address all come out of the IAM policy of the key s3mail runs as. So it asks for exactly one thing — the key — and works out the rest. Nothing to type means nothing to mistype.

The bucket is yours

The mail sits in your AWS account, written there by SES. s3mail reads it with an access key you created. Nothing passes through a machine belonging to anyone else — and that is not a statement of intent but a description of what the program can do: it knows no address other than AWS's.

Getting started

Two things are needed: a domain SES receives for, and a bucket with an IAM user narrow enough to be the only thing that reads it. A CloudFormation template creates the second.

 

aws cloudformation create-stack \
  --stack-name s3mail \
  --template-body file://go/deploy/s3mail.json \
  --capabilities CAPABILITY_NAMED_IAM \
  --parameters \
      ParameterKey=MailDomain,ParameterValue=example.org \
      ParameterKey=MailboxLocalPart,ParameterValue=you \
      ParameterKey=MailBucket,ParameterValue=your-unique-bucket

 

Two steps a template cannot finish, and each is one command: activating the rule set, because there is no resource for it, and minting an access key. The template deliberately creates none — a key in a stack output is readable by anyone who can read the stack, and stays that way.

Then start s3mail and enter that key. It works the rest out on its own.

The documentation

Setup

What the machine needs (nothing), what the AWS account needs, CloudFormation or by hand, the IAM policy, the first start.

Read →
Setup

Features

Folders as S3 prefixes, reading, writing without double sends, rules with suggestions, search, several mailboxes.

Read →
Features

Access, encryption, state

The three checks on every request, encrypted buckets, the cache on disk, shared state without a lock.

Read →
Access, encryption, state

MCP, iPhone, limits

Reachable for a model, without sending; the iOS app with its own key; what s3mail does not do.

Read →
MCP, iPhone, limits

How it fits together

Bottom to top, and nothing below knows anything about what is above it.

mimeparse turns MIME into headers and parts. It reads only the head for the index, and the whole message when one is opened.

core is the logic without side effects: state, folder names, the rule engine, search. No S3, no HTTP, no files — and no clock inside it: the time is handed in, or the decision cannot be tested.

store puts S3 behind an interface of six methods. Listing fetches only the head of each new message with a range request and compares ETags.

web is the interface: three HTML pages, vanilla JavaScript, no build step. HTML mail renders in a sandboxed iframe with a content security policy, and remote images stay blocked until somebody asks for them — a mail client that loads them hands every sender a read receipt.

Three checks guard the local server: the Host header against DNS rebinding, the Origin header against other sites, and a token against everybody else on the same machine.