Replacing Proton with Stalwart

selfhostinghomelabk3scaddystalwartmail

I’ve moved my mail, calendars and contacts off Proton. The main reason is that I didn’t want a subscription anymore. That alone rules out every hosted provider with a custom domain. I also wanted real IMAP (instead of Proton Bridge), calendars that sync on Arch and GrapheneOS, and wanted to keep the ability to invite people to events and answer invitations from my mail clients. Stalwart does all of that in one binary, so it went into the cluster.

Stalwart in my environment

Stalwart offers IMAP, JMAP, SMTP, ManageSieve, CalDAV with scheduling, and CardDAV. That replaced three things: the protonmail-bridge pod, neodav (my own CalDAV/CardDAV server) and Proton Calendar.

Only incoming mail via SMTP is public. The rest is only available over WireGuard.

Inbound through Caddy

My edge VPS is mostly stateless and runs, amongst other apps, Caddy and CrowdSec in Docker Compose. Inbound mail is a layer4 block in the Caddyfile that proxies raw TCP 25 to Stalwart with PROXY protocol v2. Stalwart receives the connection, so SPF, DMARC and reverse-IP checks all run against the real sender.

My first design was a DNAT of port 25 into a WireGuard sidecar, because I wrongly assumed a proxy would lose the sender address. The VPS had an unscoped POSTROUTING -o wg0 -j MASQUERADE rule that would have made every sender on earth look like 10.99.0.1.

A WireGuard sidecar in the Stalwart pod peers with the VPS’s wg-easy, only routing destination port 25. Two things broke:

  • the pod picks its source address before the fwmark re-routes it, so WireGuard dropped everything until I added an SNAT rule,
  • wg-easy’s FORWARD ACCEPT lives in legacy iptables while the host’s nft FORWARD policy is DROP. A drop in either backend wins.

Mail-tester gave 10/10 on the first try. Microsoft 365 put the test mail in the inbox. Gmail put it in spam with “similar to messages identified as spam in the past”, which for a two-line mail from a never-seen IP is sadly expected.

Backup MX

I nearly skipped making a backup MX, figuring senders would retry for several days. Firefox Relay forwards over SES and gives up after about 14 hours, then pauses every mask for a day. Microsoft 365 expires a message after 24 hours.

So there is a Postfix on the NAS now, as a TrueNAS custom app, and Caddy has it as a second upstream with lb_policy first and a health check. One MX record, one IP. Postfix relays its queue into a second Stalwart listener on a separate port. That port expects no PROXY header and isn’t matched by the SenderAuth rules, so relayed mail doesn’t get scored as coming from the NAS.

Clients

I use aerc on my Arch Linux (by the way) machines, which has :accept, :accept-tentative and :decline built in and composes the iMIP reply itself. Stalwart then files the event into the calendar server-side. On my phone it’s the Bulwark Android app, which syncs contacts and calendars both ways with the GrapheneOS apps. Push notifications are handled by JMAP push together with UnifiedPush, delivered through the ntfy I already run. Webmail is a Bulwark server, which is built for Stalwart.

The import

Mail was imported with vandelay, exported from the Proton Bridge over IMAP and imported over JMAP. The first run failed on Stalwart’s upload quota, 1000 files and 50MB per window.

Calendars were exported out of Proton. Before upload I stripped all 922 ATTENDEE lines from the .ics files. Otherwise, I think Stalwart would have sent an invitation to all people for every event I’d ever been in.

Contacts came from neodav first, 387 of them over CardDAV. Then I compared against Proton’s own .vcf export and added what neodav had missed.