Kai Ole Hartwig
s3mail · documentation

Access, encryption, state

s3mail binds to 127.0.0.1 and knows no users. What protects access are three checks on every request; what protects the data is the bucket itself, and the state sits next to it, shared between machines without a lock.

  • Token against other readers, Host against DNS rebinding, Origin against CSRF
  • Encryption: SSE-S3/SSE-KMS transparently, client-side KMS envelopes are opened
  • State: every change its own object, no If-Match, no 412

Who gets in

Who gets in

s3mail binds to 127.0.0.1 and knows no users. What protects access are three checks on every request, each against a different attack:

What this is not: a login. Whoever has the address with the token sees the whole mailbox. That is enough for a program running next to the browser on your own machine, and carries no further. Putting --host on a public address means putting the mailbox on the network; then a reverse proxy with proper auth belongs in front. And the host check switches itself off then, it only makes sense on loopback.

The AWS credentials sit as a named profile in ~/.aws/credentials (chmod 600), never in s3mail's own configuration.

Encrypted buckets

There are two kinds of encryption, and only one of them takes work.

Server-side (SSE-S3 / SSE-KMS) is the bucket's default encryption. S3 decrypts on GetObject itself, s3mail notices nothing. With SSE-KMS the identity additionally needs kms:Decrypt on the key and kms:GenerateDataKey for writing. When moving, s3mail reads the original's encryption setting with HeadObject and passes it to CopyObject; the copy therefore does not accidentally land under the bucket's default key. The storage class travels along the same way.

Client-side (the KMS option in the SES receipt rule): here SES encrypts the mail before it lands in S3. The bucket then holds not MIME but an envelope: the data key sits, wrapped by KMS, in the object metadata, the content is encrypted with AES-256. A plain GetObject returns gibberish. s3mail recognises that from the metadata and opens it: kms:Decrypt with the encryption context from x-amz-matdesc, otherwise KMS refuses, then AES-GCM or AES-CBC.

Two consequences in operation: the index can no longer fetch parts with a range GET, half a ciphertext cannot be decrypted, so s3mail loads objects whole. And when moving, the envelope stays untouched. A mixed mailbox is no problem, that is decided per object; if kms:Decrypt is missing, only the affected mail drops out as “not readable”.

On disk

What s3mail caches is encrypted with AES-256-GCM, with a key that is not in the home directory but in the system keychain: the login keychain on macOS, DPAPI bound to the Windows account, the Secret Service on Linux. A backup, a folder sync or a disk without FileVault is thereby of no use to anyone. If there is no keychain, s3mail does not start but demands a decision: --no-cache or --cache-plaintext. A silent plaintext cache would undo exactly the promise an encrypted bucket makes.

State: shared, without a lock

Tags, read/unread, star and rules live in the bucket so that several machines see the same state. What is written is not the whole document but the individual change: every write puts a small object under <prefix>.s3mail-state/, on a key only it writes to.

 

mail/.s3mail-state.json                              ← snapshot, rarely written
mail/.s3mail-state/20260820T2131...-0001-a7f3.json   {"ops":[{"t":"flags",…}]}
mail/.s3mail-state/20260820T2131...-0002-b1c9.json   {"ops":[{"t":"tags",…}]}

 

Two machines cannot get in each other's way: there is no shared key both point at, and therefore neither a lock nor If-Match nor 412. Marking a mail as read costs a few hundred bytes instead of the whole mailbox.

Reading takes the snapshot plus every change newer than its watermark, in key order; the keys start with the timestamp. From 50 open changes on, they are compacted. If an object is left behind during cleanup because the delete fails, it is skipped on the next load instead of being applied a second time: deletion is garbage collection, not bookkeeping.

Two things you should know: the clock decides the order, which only matters for changes that build on each other; and all machines should run the same version, an older one reads only the snapshot. If write access is missing altogether, s3mail falls back to local state and says so.

Next

Back to s3mail

The overview: what makes s3mail s3mail, getting started, every chapter.

s3mail →
Back to s3mail

MCP, iPhone, limits

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

Read →
MCP, iPhone, limits