Self-Hosted Mail Server (2026)
Self-Hosted Mail Server (2026)
Section titled “Self-Hosted Mail Server (2026)”Status of the irregulars.io + simplymail.org community mail stack:
production. Updated 2026-06-06 after the same-day cutover that moved
both mail servers off a Google Cloud relay and onto direct Proxmox public
IPs in our /29.
Stack as of today:
- Stalwart 0.15.5 on
46.110.152.205(Proxmox /29) —irregulars.iomailboxes, smtp/imap.irregulars.io, fresh Let’s Encrypt cert via ACME HTTP-01. - SimpleLogin v4.81.2 on
46.110.152.204—simplymail.orgalias service. Forwards to user mailboxes via Brevo, except mail destined for*@irregulars.iomailboxes, which goes direct to Stalwart over the docker hairpin (transport_maps override, 2026-06-09). See the full SimpleLogin (2026) guide for the in-network transport pattern, OIDC apex-cookie-split fix, and the wrapper-entrypoint technique. - SOGo webmail at
mail.irregulars.io, OIDC-SSO via Authentik, XOAUTH2 to Stalwart. - SASL proxy on
.206:1993/:1587— bridges Apple Mail PLAIN auth → OAUTHBEARER for Stalwart 0.15.5, since 0.15 has one globalstorage.directory. Will be retired when Stalwart 0.16’s per-listener directory routing is stable. - GCP MX relay: decommissioned same day. Was carrying inbound
:25for both domains via HAProxy + postfix; deleted instance, static IP, and snapshot once direct delivery to Proxmox was verified.
This page documents what’s deployed end-to-end, the 2026 landscape, and the SSO-integration considerations that drove our architecture.
TL;DR (what’s running and why) — 2026-06-06
Section titled “TL;DR (what’s running and why) — 2026-06-06”| Layer | Service | Address |
|---|---|---|
| Mailbox server (irregulars.io) | Stalwart 0.15.5 | 46.110.152.205 — smtp/imap.irregulars.io ports 25/143/465/587/993/4190, ACME :80 |
| Alias service (simplymail.org) | SimpleLogin v4.81.2 | 46.110.152.204:25 — mx.simplymail.org |
| Webmail (irregulars.io) | SOGo | https://mail.irregulars.io — CF Tunnel → Traefik → sogo container |
| Mail admin (Stalwart) | Stalwart admin UI | https://mail-admin.irregulars.io — CF Tunnel → Traefik |
| Apple Mail / iOS PLAIN→OIDC bridge | saslproxy (custom) | 46.110.152.206:1993/1587 |
| Inbound MX | direct | irregulars.io MX 10 smtp.irregulars.io · simplymail.org MX 10 mx.simplymail.org |
| Outbound (user clients via Stalwart) | Brevo SMTP relay (transitional) | direct from .205 after Metronet PTR is set |
| Outbound (SimpleLogin forwards) | Brevo SMTP relay | low-volume, Brevo’s warm rep is cheap insurance |
| Outbound (app transactional) | Cloudflare Workers send_email binding | mail.irregulars.io per-app onboarding |
| SSO / IdP | Authentik | https://sso.irregularchat.com |
| DNS | Cloudflare (DNS-only for mail; Tunnel for webmail/admin) | unproxied A records on every mail hostname |
Two important new realities in 2026:
-
Cloudflare now does BOTH inbound routing AND outbound sending. Email Routing has been around since 2022; Email Service went public beta in 2026 and lets Cloudflare Workers send transactional email via a
send_emailbinding. We use it for app-level transactional mail (meeting reminders, invites) but not for user mailboxes. -
App Passwords are now the standard pattern for OIDC + legacy clients. Stalwart 0.16.5, Mailcow, Mailu, and every modern self-hosted mail server either ships or supports app-password flows because mainstream clients (Apple Mail, Outlook, Thunderbird) don’t speak OAUTHBEARER/XOAUTH2 with third-party OAuth providers. SSO at the webmail/admin layer, App Passwords at the mail-client layer, is the canonical 2026 architecture.
What works today (verified 2026-06-09)
Section titled “What works today (verified 2026-06-09)”- ✅ Inbound mail to
*@irregulars.iois delivered to Stalwart on46.110.152.205. MX records point atsmtp.irregulars.io→.205. - ✅ Inbound aliases to
*@simplymail.orgare delivered to SimpleLogin on46.110.152.204and forwarded to mailbox owners. MX records point atmx.simplymail.org→.204. - ✅ Fresh ACME cert (Let’s Encrypt via HTTP-01) for
imap/smtp.irregulars.io, issued 2026-06-06, auto-renew at T-30d. Stalwart’sacme.letsencrypt.cachere-issued cleanly on first attempt after the binding moved to.205andmail.irregulars.iowas removed from the cert SAN list (it’s a CF Tunnel CNAME for webmail). - ✅ Outbound from user clients still routes through Brevo SMTP while Metronet PTR is pending.
- ✅ Outbound from apps (TeamCo meeting reminders, event-service guest
invites) goes via Cloudflare Workers
send_emailbinding from<service>@mail.irregulars.io. Seeapps/teamco/docs/EMAIL_SETUP.mdfor the per-worker setup. - ✅ SOGo webmail at
https://mail.irregulars.io— TLS via CF Tunnel. - ✅ Stalwart admin at
https://mail-admin.irregulars.io. - ✅ SOGo + saslproxy peer reachability to Stalwart — both can reach
stalwart-mailover the dockertraefiknetwork now that Stalwart is properly attached again (see “What we discovered during cutover”). - ✅ DKIM with selector
mailforirregulars.io, plus Brevo selectors forsimplymail.org(brevo1._domainkey,brevo2._domainkey). - ✅ SPF (irregulars.io)
v=spf1 ip4:46.110.152.205 include:_spf.brevo.com include:_spf.mx.cloudflare.net ~all - ✅ SPF (simplymail.org)
v=spf1 ip4:46.110.152.204 include:sendinblue.com ~all - ✅ DMARC
v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@<domain>on both zones. - ✅ SimpleLogin authorized_addresses — set up so the alias-owner mailbox can
reply-as-alias from multiple Apple Mail identities (e.g.
admin@irregularchat.comin addition to the underlyingissac@alfaren.xyzmailbox). - ✅ SL → Stalwart in-network delivery for
*@irregulars.iorecipients (2026-06-09). sl-postfix bypasses Brevo and hands mail directly to Stalwart:25over the docker hairpin. See SimpleLogin (2026) — In-network delivery for the wrapper-entrypoint + texthash-transport pattern. - ✅ Multi-homed egress routing (2026-06-09).
metronet-egress-routing.serviceon the host forces docker-bridge return traffic out the Metronet gateway instead of the home-LAN default — see § Post-cutover lessons below.
Post-cutover lessons (2026-06-09)
Section titled “Post-cutover lessons (2026-06-09)”After the same-day 2026-06-06 cutover declared everything healthy, three silent failures surfaced over the following week. Each looked unrelated; all stemmed from cutover-adjacent state. Documented here so the next operator catches them in hours instead of days.
1. Multi-homed egress routing trap
Section titled “1. Multi-homed egress routing trap”proxmox-main has two uplinks on the same physical interface vmbr0:
- residential home-LAN
192.168.1.0/24via gateway192.168.1.1 - Metronet public block
46.110.152.200/29via gateway46.110.152.201
The default route points at the home gateway. Without policy routing,
outbound traffic sourced from .205 or .204 is sent via the home
router — which drops it because the source IP isn’t on its LAN.
Inbound SYN to :25 arrives fine; our SYN-ACK never reaches the
remote → SMTP handshakes never complete → mail accumulates as deferred
failures on the sender’s side.
Symptom: essentially zero RCPT TO: from real MTAs in stalwart /
sl-postfix logs. Persistent scanners occasionally land. Internal
self-tests (curl from the same Proxmox host) succeed because they take
a different return path. The cutover looked successful for three
weeks before someone tried sending in from an external mailbox.
The fix is policy routing keyed on the input interface, not source
IP — the kernel chooses return routes before POSTROUTING NAT rewrites
the source. A single systemd oneshot metronet-egress-routing.service
keeps this in place across reboots; see the
SimpleLogin guide § Multi-homed egress routing
for the full recipe.
2. Cloudflare Email Routing dormant rules
Section titled “2. Cloudflare Email Routing dormant rules”When the cutover moved MX from CF Email Routing → self-hosted MX,
disabling Email Routing at the zone level did NOT delete forwarding
rules. Dormant rules stay enabled: true in the API and silently
intercept mail from senders whose MTA caches the old MX. Apple iCloud
caches MX records for days past TTL — so even three weeks post-cutover,
real mail still landed at the old forward target.
Hit on 2026-06-09: a sac@irregulars.io → forward:issac@alfaren.xyz
rule was siphoning live mail away from Stalwart for the entire cutover
window.
Audit + purge after any MX cutover:
ZONE=<zone-id>
# Anything with action 'forward:' that isn't intentional is poisoncurl -sS -H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $CF_KEY" \ "https://api.cloudflare.com/client/v4/zones/$ZONE/email/routing/rules?per_page=50" \ | jq '.result[] | {tag, enabled, matchers, actions}'
curl -sS -X DELETE -H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $CF_KEY" \ "https://api.cloudflare.com/client/v4/zones/$ZONE/email/routing/rules/$TAG"The Catch-all rule cannot be API-deleted (it’s a zone-level setting) —
verify it’s enabled: false and action: drop. Audit BOTH irregulars.io
and simplymail.org zones if you self-host both.
3. Stalwart silent-Junk for weak-auth mail (mailboxId=[2])
Section titled “3. Stalwart silent-Junk for weak-auth mail (mailboxId=[2])”Stalwart routes mail that fails SPF + iprev + SPF-From all three
checks to the recipient’s Junk folder with no NDR and no sender-side
indication. Senders see 250 OK Message accepted from the SMTP
handshake; recipients see nothing in Inbox; Stalwart logs show
mailboxId=[2] (Junk) on the delivery line.
Hit 2026-06-08 investigating “I sent mail but they didn’t get it” — the message was in Junk the entire time. Mainstream senders (Gmail, M365) pass these checks; the failures came from misconfigured personal servers and a couple of mobile clients sending direct.
Diagnostic — first thing to check before “is Stalwart broken”:
# The bind-mounted log file is JSON-per-line and more verbose than `docker logs`:ssh root@proxmox-main \ "grep -F '<sender-email-or-domain>' /datadrive/home/email/logs/stalwart.log.$(date +%F)"Operators may want to wrap this in a script
(e.g. stalwart-mail-recent.sh --user X --folder junk --hours N) — a
working example lives in the operator’s internal ai-coding-env
toolbox, but it’s straightforward to recreate from the raw ssh + grep
above.
Look for mailboxId=[2] on the delivery line — that’s Junk. If the
sender’s SPF/iprev/DKIM is broken, fix it at the source. If the
sender’s auth is fine and Stalwart is still routing to Junk, it’s a
filter rule (Sieve) or an admin classification — check the user’s
mailbox prefs.
4. Pull-on-demand log inspection (no admin token needed)
Section titled “4. Pull-on-demand log inspection (no admin token needed)”The Stalwart admin API needs STALWART_ADMIN_TOKEN, which is the kind
of secret that rots without anyone noticing — ours was 401-stale as of
2026-06-08 and we hadn’t realized.
The bind-mounted log files don’t care about the admin token. They’re
plain JSON-per-line at /datadrive/home/email/logs/stalwart.log.YYYY-MM-DD
on the host — more verbose than docker logs (which only shows
stdout, not the full event stream).
# Today's log, follow new eventsssh root@proxmox-main \ "tail -F /datadrive/home/email/logs/stalwart.log.$(date +%F)"
# Recent activity for a specific user (last N hours)ssh root@proxmox-main \ "awk -v since=\"$(date -u -v-6H '+%Y-%m-%dT%H')\" \ '\$0 >= since && /issac@irregulars.io/' \ /datadrive/home/email/logs/stalwart.log.$(date +%F)"Useful patterns:
| Pattern | What it surfaces |
|---|---|
grep 'from=<.*X.*>' | All mail from a specific sender |
grep 'mailboxId=\[2\]' | Everything routed to Junk |
grep 'acme.' | ACME / Let’s Encrypt activity |
grep -E 'reject|defer' | Bounces and deferrals (sender-side errors) |
grep '"event":"smtp.connect.failed"' | Outbound delivery problems |
What’s NOT done yet (open items)
Section titled “What’s NOT done yet (open items)”-
PTR (reverse DNS) for
46.110.152.205AND46.110.152.204with Metronet (the colo). Targets:.205→smtp.irregulars.io.204→mx.simplymail.org
Forward A records are set; reverse legs pending. Without PTR, strict receivers (Gmail / M365 / Proofpoint) score outbound slightly worse, though Brevo handles the actual outbound today so this is non-blocking. Ticket filed; expect slow turnaround.
-
Cut over from Brevo to direct outbound from Stalwart — remove
[queue.route.relay]and[queue.route.mx]config once PTR confirms. -
Stalwart 0.15.5 → 0.16.x upgrade. 0.16 has per-listener
storage.directoryrouting which would let us retire the SASL proxy. The 0.16.5 wizard had a bug (x:Bootstrap/set, stalwartlabs/stalwart#3025) that blocked an earlier attempt; v0.16.8 was released 2026-06-06 — verify that’s fixed before the next try. Treat as its own planned maintenance window; do not stack on top of other mail changes. -
Apple Mail PLAIN auth via saslproxy is still the deal until Stalwart 0.16 ships. Documented in
docs/lessons-learned/sasl-proxy.mdin the ai-coding-env repo.
SSO integration patterns
Section titled “SSO integration patterns”The community’s hard requirement is that mail accounts integrate with Authentik SSO. There are three patterns, with different trade-offs:
Pattern A — App Passwords (Stalwart 0.16.5+, recommended)
Section titled “Pattern A — App Passwords (Stalwart 0.16.5+, recommended)”Architecture:
- Authentik is the source of truth for user identity.
- Stalwart’s primary directory is
oidc(Authentik). Users are auto- provisioned on first login. - Webmail (SOGo, Stalwart’s built-in WebUI) authenticates via OIDC → OAUTHBEARER tokens → Stalwart’s userinfo endpoint validates against Authentik. SSO experience preserved.
- For mail clients that don’t speak OAuth (Apple Mail, Outlook, Thunderbird, mobile): each user generates an App Password in Stalwart’s WebUI. The app password is unique to that client and can be scoped/revoked independently.
Why this is the modern best practice:
- It’s what Google, Microsoft, and Apple all do for legacy mail clients.
- Compromised app password ≠ compromised SSO identity (only that one client breaks).
- App passwords can be revoked individually, with expiration dates and IP restrictions (Stalwart 0.16.5+).
- Webmail still gets MFA + SSO benefits via the OIDC path.
Requirements:
- Upgrade Stalwart 0.15.5 → 0.16 (we’re currently on 0.15.5).
- Set
storage.directory = "authentik"(already configured). - In Authentik, configure the OAuth2 Provider’s
access_token_validityto ≥ 1h (currently set to hours=1 perstalwart-mailprovider, pk=21). - Users log into the Stalwart WebUI once via Authentik OIDC, then generate app passwords for each mail client.
Trade-off: Users have to set up app passwords. One-time effort per client per user.
Pattern B — Mailcow with native Authentik IdP (2025-03+)
Section titled “Pattern B — Mailcow with native Authentik IdP (2025-03+)”Architecture:
- Mailcow with IdP feature enabled (System → Configuration → Access → Identity Provider).
- Configure Authentik as the IdP. Mailcow auto-creates mailboxes on first OIDC login.
- Users authenticate to Mailcow’s SOGo/Roundcube via Authentik.
- For mail clients, users must create App Passwords (Mailcow’s built-in feature) — Mailcow forces this because Dovecot doesn’t natively speak OIDC for PLAIN auth.
Why this is interesting:
- Mailcow has had native Authentik integration shipped since 2025-03. No custom config needed — just point it at Authentik’s well-known endpoint.
- Bundled Rspamd (best-in-class spam filtering), ClamAV, SOGo, Roundcube, Dovecot, Postfix in one Docker Compose — opinionated and fully integrated.
- Web UI for everything (no CLI/config-file editing).
Trade-off:
- Migration cost from Stalwart: 1-2 days of careful work moving mailboxes, Sieve scripts, DKIM keys, ACME, and clients.
- More resource-heavy (~2 GB RAM vs Stalwart’s ~150 MB).
- Mailcow’s native IdP is functional but less polished than Mailcow’s own auth UI (Authentik branding shows up).
Pattern C — LDAP with Authentik-as-LDAP-outpost
Section titled “Pattern C — LDAP with Authentik-as-LDAP-outpost”Architecture:
- Authentik runs a built-in LDAP outpost (already in place at
ldap.irregularchat.com). - Mailcow / Mailu / Stalwart authenticates against LDAP.
- Users authenticate with their Authentik password directly (no OAuth flow needed for SMTP/IMAP).
Why this is interesting:
- Apple Mail, Outlook, Thunderbird all “just work” with LDAP-backed mail servers using PLAIN/LOGIN.
- Single password (Authentik) across SSO and mail.
- No app-password management UX needed.
Trade-off:
- Mail server has direct access to user passwords during auth (vs OAuth where it only sees tokens). Mitigated by TLS and short-lived password cache.
- LDAP doesn’t enforce MFA. Combined with SSO MFA at Authentik, this is a security gap: mail clients with valid Authentik password can bypass the MFA prompt that webmail requires.
- App passwords (Pattern A) solve this MFA bypass problem cleanly.
Recommendation: Pattern A (App Passwords on Stalwart 0.16.5) for our specific environment. It preserves SSO for webmail, fixes the legacy- client compatibility problem, and integrates MFA correctly. Pattern B (Mailcow) is the right call if we ever outgrow Stalwart’s web UI limitations or want bundled antivirus.
Reality check (updated 2026-05-17): Two upgrade attempts to Stalwart 0.16 in 24 hours, both blocked:
In-place TOML→JSON migration (2026-05-16): Stalwart 0.16 requires fresh-install +
STALWART_RECOVERY_ADMINenv-var bootstrap; old TOML config is ignored,stalwart-clibinary is gone,[authentication.fallback-admin]no longer honored. Reverted.Parallel deploy on
.205with proper bootstrap (2026-05-17): Got the entire stack standing — listeners, ACME-issued LE cert, OIDC directory pointing at Authentik, DKIM keys auto-generated, domain configured. Wizardx:Bootstrap/setreports “Setup complete” with admin credentials but never persists the admin to the RocksDB.x:Account/queryreturns empty after wizard. Matches upstream issue #3025. Recovery admin (env var) works but isn’t a real principal — can’t create users with credentials. Hard block.What we shipped instead (2026-05-17, working): Pattern A’ — a custom SASL auth proxy in front of Stalwart 0.15.5. Mail clients connect to
mail.irregulars.io:1993(IMAPS) or:1587(SMTP STARTTLS) withPLAINauth + a per-device app password. The proxy bcrypts the password locally, then connects upstream to Stalwart on:993/:587usingOAUTHBEARERwith a per-user Authentik OIDC access token (refreshed on each connection from a stored refresh token). SOGo’s existing OIDC integration is untouched.Per-device enrollment is a one-time browser OAuth flow:
python3 add-mail-creds.py <user> --label <device>→ user signs in to Authentik once → app password printed, ready to paste into Apple Mail. Revoke viapython3 revoke-mail-creds.py <user> --label <device>.Full operator guide:
selfhost/stalwart/sasl-proxy/README.md. Migration writeup:selfhost/stalwart/MIGRATION-TO-PUBLIC-IP-2026-05-16.md. v0.16 retry plan (for when upstream ships a fix):selfhost/stalwart/PLAN-v016-fresh-install.md.
Modern self-hosted mail server comparison (2026)
Section titled “Modern self-hosted mail server comparison (2026)”| Project | Language | Stack | Resource | OIDC/SSO | Spam | Notes |
|---|---|---|---|---|---|---|
| Stalwart | Rust | All-in-one (SMTP, IMAP, JMAP, Sieve, ManageSieve, CalDAV, CardDAV, WebDAV) | ~150 MB RAM | ✅ Native OIDC backend, App Passwords in 0.16+ | Built-in Bayesian + heuristics | What we run today. Best for resource-constrained or modern-protocol-focused deploys. |
| Mailcow | PHP + many containers | Postfix + Dovecot + Rspamd + ClamAV + SOGo + Roundcube + Solr | ~2 GB RAM | ✅ Native Authentik/OIDC since 2025-03 | Rspamd (best available) | Most feature-rich, most production-tested. Best for full-featured deployments. |
| Mailu | Python + many containers | Postfix + Dovecot + Rspamd + Roundcube | ~1 GB RAM | OIDC via reverse proxy or LDAP | Rspamd | Mature, similar to Mailcow but more modular. |
| Mox | Go | All-in-one (SMTP, IMAP, JMAP) | ~80 MB RAM | OAUTH2 in roadmap (not yet stable) | Built-in junk filter | Newest entrant, modern protocol stack (JMAP, MTA-STS, DANE), low-maintenance design philosophy. Not yet recommended for production — webmail is “early stages”. |
| docker-mailserver | Bash + Postfix/Dovecot | Postfix + Dovecot + Rspamd + Postscreen | ~500 MB RAM | LDAP only | Rspamd | Lower-level, ops-team-friendly. No web UI bundled. |
| iRedMail | Mixed | Postfix + Dovecot + Rspamd + Roundcube | ~1.5 GB RAM | LDAP, OIDC via proxy | Rspamd | Older but well-maintained. Free tier vs paid edition. |
| Poste.io | Mixed | Haraka + Dovecot + Rspamd | ~500 MB RAM | LDAP only | Rspamd | Was previously recommended in this wiki. Less active development than Mailcow. |
| Maddy | Go | SMTP + IMAP (LMTP) | ~50 MB RAM | LDAP | Custom | Lightweight, “composable” design. Smaller userbase. |
When to prefer each
Section titled “When to prefer each”- Pick Stalwart if: you want modern protocols (JMAP, CalDAV), low resource use, a single binary that handles everything including ACME, and you value a Rust-native security posture. App Passwords in 0.16 make it fully production-viable for mixed SSO/legacy-client environments.
- Pick Mailcow if: you want maximum compatibility with the larger ecosystem (Rspamd integrations, ClamAV updates, SOGo’s mature ActiveSync/CalDAV), and you have RAM to spare. The native Authentik IdP integration in 2025-03+ is genuinely well-done.
- Pick Mailu if: you want Mailcow-like features but more modular Docker Compose layout, or if you specifically need their granular RBAC.
- Pick Mox if: you’re greenfield and want the most modern stack — but expect rough edges on webmail and OIDC.
- Pick docker-mailserver / Poste.io / iRedMail if: you’re already comfortable with that platform. None of these have a strong case over the top three in 2026.
Cloudflare’s role in 2026
Section titled “Cloudflare’s role in 2026”Cloudflare has TWO email-related products now (post-2026 changes):
Email Routing (inbound, free since 2022)
Section titled “Email Routing (inbound, free since 2022)”Inbound MX service. You point your domain’s MX records at
route1/2/3.mx.cloudflare.net, configure rules in the dashboard, and CF
forwards mail to other addresses. Use cases:
- Forward
info@yourdomain.comto a personal Gmail account. - Catch-all to a single inbox.
- Drop spam at the edge before it hits your server.
What it can’t do: Deliver to your own SMTP server. There is no “deliver to SMTP” rule type. If you want self-hosted mailboxes, you have to bypass CF Email Routing on the MX layer.
We disabled it for irregulars.io on 2026-05-16 because we needed
test@irregulars.io and other addresses to land in Stalwart mailboxes.
Email Service / Email Sending (outbound, public beta in 2026)
Section titled “Email Service / Email Sending (outbound, public beta in 2026)”Cloudflare Workers can now send outbound mail via the send_email
binding. You add [[send_email]] name = "EMAIL" to wrangler.toml,
onboard mail.<your-domain> in the dashboard, and env.EMAIL.send(message)
delivers from CF’s infrastructure.
Use cases:
- Transactional mail from serverless apps (password resets, invites, notifications) without standing up SMTP infrastructure.
- Multi-app shared sender domain (we use
mail.irregulars.iofor TeamCo, future signal-bot, etc. — seeapps/teamco/docs/EMAIL_SETUP.mdfor the runbook).
Pricing: Workers Paid plan ($5/month) required. CF charges per message but is finalizing the model as of mid-2026.
What it can’t do: Host user mailboxes. There’s no IMAP for received mail. CF Email Service is a one-way SEND-only service.
Decision matrix — which Cloudflare option for which use case
Section titled “Decision matrix — which Cloudflare option for which use case”| Use case | Cloudflare service | Or self-host with |
|---|---|---|
Forward info@yourdomain.com → Gmail | Email Routing | Self-host with a forwarding rule |
| Send password-reset emails from Workers | Email Service send_email binding | None — this is CF’s killer use case |
User has a you@yourdomain.com mailbox they log into | Not supported by CF | Self-host (Stalwart, Mailcow, Mailu…) |
| Catch-all spam drop at the edge | Email Routing | rspamd at the mail server (more flexible) |
| Marketing newsletter sends | Not great fit — use Resend/Postmark/Brevo | — |
| Cron-driven transactional sends | Email Service | Stalwart’s queue or self-managed Postfix relay |
Architecture diagram (post-cutover, 2026-06-06)
Section titled “Architecture diagram (post-cutover, 2026-06-06)” Internet │ ┌───────────────────────┬───────────────┴───────────────┬─────────────────────────┐ │ │ │ │ Inbound MX Inbound MX Mail-client traffic Cloudflare Workers irregulars.io simplymail.org imap.* / smtp.* send_email binding │ │ │ │ ▼ ▼ ▼ ▼ 46.110.152.205:25 46.110.152.204:25 46.110.152.205:993/465/587 CF Email Service (Stalwart MX) (sl-postfix) 46.110.152.206:1993/1587 (transactional sends │ │ (saslproxy → Stalwart) from mail.irregulars.io) │ │ │ │ │ ┌────────┴───────┐ │ │ │ │ │ │ │ ▼ ▼ ▼ ▼ ▼ Stalwart sl-email handler sl-postfix Stalwart auth chain External recipients mailboxes (alias lookup, → Brevo SMTP (saslproxy bridges (Gmail, etc.) (sac@, contact create, relay PLAIN→OAUTHBEARER for test@, ...) reverse alias Apple Mail / iOS clients; │ rewrite, From │ SOGo XOAUTH2 direct) │ header rewrite) │ │ │ │ │ ▼ ▼ ▼ ▼ Mailbox access SOGo webmail Outbound forward recipient MX │ mail.iregularsio ▼ (CF Tunnel) │ │ Stalwart queue └─────┬────────┘ │ │ ▼ ▼ Brevo SMTP relay recipient mailbox (smtp-relay.brevo.com:587) (issac@alfaren.xyz │ et al) ▼ recipient MX (Gmail, etc.)IPs at a glance (Proxmox /29 = 46.110.152.200/29, gateway .201):
| IP | Purpose |
|---|---|
.202 | Jitsi JVB (TCP 4443, UDP 10000) |
.203 | Jellyfin :8096 |
.204 | SimpleLogin postfix :25 (mx.simplymail.org) |
.205 | Stalwart :25, :80, :143, :465, :587, :993, :4190 (smtp/imap.irregulars.io) |
.206 | saslproxy :1587, :1993 (Apple Mail PLAIN→OIDC bridge) |
What landed in the 2026-06-06 cutover
Section titled “What landed in the 2026-06-06 cutover”Same-day migration from a GCP MX relay to direct-on-Proxmox MX for both
domains. Took ~3 hours including the GCP cleanup. The plan + execution
record lives at docs/plans/2026-06-06-mail-cutover-to-proxmox-public-ips.md
in the ai-coding-env repo. Headline events:
-
Discovered Stalwart was silently broken before the cutover.
docker inspectshowedNetworks={}— the container had been disconnected from every Docker network for ~6 days. Its docker healthcheck (TCP to its own:8080) was passing, so it was reported as healthy. SOGo and saslproxy couldn’t reach it; none of its declared host port bindings actually published.force-recreateon the stalwart container during the cutover re-bound the network attachment. Captured indocs/lessons-learned/stalwart.md— “Healthy container, zero networks attached” silent failure mode. -
DOCKER-USER chain was dropping
sl-networkoutbound. SimpleLogin uses docker bridge10.1.0.0/24, which matched no ACCEPT rule and hit the chain’s default DROP.sl-apphad beenunhealthylong enough that nobody noticed — OIDC login failed with name-resolution timeouts to Authentik. Fix: one ACCEPT rule. Same trap as GitLab’s192.168.16/20bridge earlier in 2026. -
Added
.204tovmbr0as a secondary IP (was assigned in the /29 but never bound on the interface). KernelIFNAMSIZtrap bit us:vmbr0:simpleloginis 17 chars — exceeds the 15-char limit andip addr addrejects it silently.vmbr0:slworks. -
Bound Stalwart to
.205, all 7 mail ports (25, 80, 143, 465, 587, 993, 4190). Removed the legacy100.87.20.88:*Tailscale bindings since direct internal access works over the docker network. -
ACME HTTP-01 issued cleanly for
imap.irregulars.io+smtp.irregulars.ioonce DNS pointed at.205. The new listenerserver.listener.acme.bind = "[::]:80"is essential — Stalwart’s other HTTP listener is internal:8080, won’t answer Let’s Encrypt validators on world:80. -
Removed
certificate.external.default = truefrom Stalwart config. That flag had been forcing the on-disk (expired) external cert as default, so ACME was being issued but never used. Setcertificate.default.default = trueso ACME takes precedence. -
mail.irregulars.iostays a CF Tunnel CNAME — it’s the SOGo webmail URL, not a mail-server hostname. Removed from Stalwart’sacme.letsencrypt.domainsto avoid HTTP-01 challenges routing through the tunnel. -
DNS for both domains updated via the Cloudflare REST API (not the dashboard) — full record IDs (32 chars, not the 12-char truncated display),
proxied: falseon every A record for a mail hostname. Recipes + scoped-token pattern documented in the cloudflare-emails skill (third-party MX via CF DNS). -
GCP MX relay fully decommissioned the same day. Verified zero live traffic was hitting it after DNS flipped, tarballed the config to
/datadrive/home/_backups/on Proxmox, stopped + disabled HAProxy / postfix / certbot.timer, then deleted the VM, released the static IP (35.185.0.35), and deleted the snapshot. Ongoing cost: $0/mo. -
End-to-end inbound test: send to a registered SL alias from an external sender → sl-postfix queues → sl-email handler resolves alias → forwards via Brevo to the mailbox owner. ~0.3s end to end.
What’s left
Section titled “What’s left”The path forward from where we are now:
-
Get PTR set for
46.110.152.205→smtp.irregulars.ioAND46.110.152.204→mx.simplymail.org. Metronet business support ticket. Until done, keep Brevo as the outbound relay. -
Switch outbound to direct — remove
[queue.route.relay]and[queue.route.mx]from Stalwart config. Stalwart does direct MX delivery from.205. -
Stalwart 0.15.5 → 0.16.x when the v0.16.8 (released 2026-06-06) wizard issue is confirmed fixed. Per-listener
storage.directoryrouting lets us retire the SASL proxy entirely. The infrastructure to retry is provisioned: a scoped Cloudflare DNS-01 token already sits at/etc/stalwart/cloudflare-acme.tokenon Proxmox (mode 0600, scope:irregulars.ioZone:DNS:Edit + Zone:Read), so DNS-01 ACME becomes an option for 0.16’s renewal path if HTTP-01 ever falters. -
When 0.16 works: migrate users from saslproxy → native Stalwart App Passwords. Each user rotates their app password in Stalwart’s WebUI once, then revokes the saslproxy entry. Tear down
/datadrive/home/saslproxy/andselfhost/stalwart/sasl-proxy/. -
Optionally: replace Brevo entirely for app sends by migrating TeamCo / event-service to direct SMTP at
smtp.irregulars.io:587(using app passwords for the service accounts). Reduces moving parts.
Current working stack:
| Mail client | Port | Auth |
|---|---|---|
| SOGo webmail | n/a (browser) | Authentik OIDC SSO |
| Apple Mail / iOS / Outlook | 46.110.152.206:1993 IMAPS + :1587 STARTTLS | App password via saslproxy add-mail-creds.py |
| Direct Stalwart clients (if needed) | imap.irregulars.io:993 + smtp.irregulars.io:465/587 | Stalwart internal directory PLAIN — Authentik bridge not native in 0.15.5 |
| SimpleLogin aliases | mx.simplymail.org:25 MX | inbound only; forwards to mailbox owner via Brevo (or direct to Stalwart over hairpin for *@irregulars.io mailboxes — see SimpleLogin guide § In-network delivery) |
| App transactional (TeamCo, etc.) | Cloudflare Workers send_email binding | n/a |
Webmail picks for 2026 (researched 2026-06-06)
Section titled “Webmail picks for 2026 (researched 2026-06-06)”The question came up after the SOGo slowness investigation (next section): could we replace SOGo with something lighter? Here’s the real shortlist — every option is verified to talk to Stalwart over standard IMAP/SMTP.
| Webmail | Status 2026 | Pros | Cons | Stalwart fit |
|---|---|---|---|---|
| SOGo (what we run) | actively maintained | ActiveSync (iOS/Outlook calendar/contacts sync — killer feature), OIDC SSO works, full-featured groupware | Heavy, slower UI than the others, hard to skin | ✅ via XOAUTH2 (the slowness we hit was an auth-cache problem, not a protocol problem) |
| Roundcube 1.6/1.7 | actively maintained — 1.7.1 released 2026-05-24 | Fast, mature, plugin ecosystem, OAuth2 plugin supports Authentik | Dated default UI (themes help a lot) | ✅ standard IMAP/SMTP |
| SnappyMail 2.38+ | actively maintained, slower cadence (last release Oct 2024) | Lighter, more modern UI than Roundcube, single PHP file | Smaller community, XOAUTH2 setup is more custom | ✅ standard IMAP/SMTP |
| Cypht | maintenance mode | Multi-account aggregator, very lightweight | UX rough vs. the others | ✅ standard IMAP/SMTP |
| Stalwart’s own WebUI | ”early stages” per stalw.art | Native, JMAP-first | Not production-ready in 2026 | n/a |
| JMAP-native (Cyrus.io / inbox-zero / etc.) | mostly research-grade | Modern protocol end-to-end | Sparse mature options | needs JMAP (Stalwart has it) |
| Mox webmail | early stages | Modern protocols, Go | ”Not yet recommended for production” per their own docs | n/a |
What we’d actually pick for this deployment
Section titled “What we’d actually pick for this deployment”iOS + Apple Mail users in the community rely on ActiveSync for calendar/contacts sync. SOGo is the only mainstream FOSS webmail with native ActiveSync — Roundcube and SnappyMail don’t replace that side. That decision-locks us into SOGo for the groupware side.
We landed on both — running SOGo and Roundcube side-by-side:
| URL | What | Who it’s for |
|---|---|---|
mail.irregulars.io | SOGo | Anyone using ActiveSync (iOS, Outlook), calendar, contacts; the “full groupware” experience |
webmail.irregulars.io | Roundcube 1.7.x | Anyone who wants a fast lightweight UI for just reading + sending mail |
Both connect to the same Stalwart backend. Same mailboxes, same folders, same sent items. Users pick whichever UI they prefer for any given session.
The Roundcube side launched 2026-06-06 with this architecture:
browser → https://webmail.irregulars.io (CF Tunnel, proxied) → Traefik (Host header routing) → roundcube container (port 80, Apache + PHP) → tls://saslproxy:1993 (PLAIN auth + the user's app password) → Stalwart IMAP (OAUTHBEARER, validated by storage.directory=authentik)The clever bit is auth reuse: Roundcube has no OIDC, Stalwart’s
directory is OIDC-only, but the SASL proxy already bridges PLAIN→
OAUTHBEARER for Apple Mail / iOS clients. Roundcube uses the same
bridge with the same app password. Users who already enrolled a device
via add-mail-creds.py work in Roundcube with zero new setup.
Setup details in selfhost/roundcube/README.md in the monorepo.
Roundcube plugin set (2026-06-06)
Section titled “Roundcube plugin set (2026-06-06)”Roundcube ships with a useful set of bundled plugins, plus we vendor one community plugin for right-click menus. 15 plugins enabled:
| Plugin | What it adds |
|---|---|
archive · zipdownload · attachment_reminder · markasjunk · managesieve | Day-one defaults — archive button, multi-message ZIP, “you wrote ‘attached’” reminder, junk-marking, server-side Sieve filter editor |
irregularchat_branding | Custom — login-page notice + post-login “Manage app password” link to irregulars.io/setting |
enigma | OpenPGP signing/decryption. Per-user GPG keyrings persisted to disk. Privacy-minded community gets PGP without the SOGo setup tax. |
acl | UI for IMAP ACL — share folders with other Stalwart users |
identity_select | Pick which identity to reply as. Matters for SimpleLogin alias users |
newmail_notifier | Web Notifications + sound on new mail |
hide_blockquote | Collapse quoted text in long reply threads |
subscriptions_option | Folder subscription UI |
vcard_attachments | Inline vCard import/export |
show_additional_headers | Diagnostic — extra headers in message view |
contextmenu (3rd-party, JohnDoh) | Right-click menus in folder + message lists. Vendored as plugin source (no composer in the runtime container) |
What we deliberately did NOT enable (split-state hazards):
password— auth is app-passwords, managed externally atirregulars.io/setting. Two paths to change credentials is confusing.carddav/calendar— SOGo is the contacts + calendar authority. Roundcube duplicating CardDAV/CalDAV would split state across two webmails. Users who want calendar/contacts use the SOGo URL; Roundcube stays mail-focused.automatic_addressbook— same reason; address book lives in SOGo.
Two ops gotchas that bit us during install
Section titled “Two ops gotchas that bit us during install”-
docker execdefaults to root, not the container’s runtime user. The “is this dir writable?” smoke test gave a false positive because exec ran as root. The actual web process (www-data) couldn’t write. Usedocker exec -u <user>when probing permission questions. Captured in~/.claude/rules/docker-ops.md. -
git clone https://github.com/…fails from the Proxmox host with “could not read Username for github.com” even on public repos — some host-level git config is forcing credential prompts on https remotes. Workaround for one-off plugin pulls: download the GitHub archive tarball directly withcurl -sSL <url> | tar xz.
SOGo slowness incident — 2026-06-06 (lesson learned)
Section titled “SOGo slowness incident — 2026-06-06 (lesson learned)”Symptom: Webmail at mail.irregulars.io felt “very very slow” after
login. Page load itself was fast (TTFB 0.55s through CF Tunnel), but
every click — folder switch, opening a message, marking read —
introduced a noticeable lag.
Diagnosis
Section titled “Diagnosis”What we ruled out:
- Container resources — SOGo using 0.11% CPU / 200MB RAM with no GC pressure
- Memcached — 94% hit rate, 0 evictions, healthy
- SOGo → Stalwart docker network latency — sub-millisecond TCP connect
- Apache → SOGo daemon hop — negligible overhead in access logs
- TLS handshake — SOGo uses plain IMAP (
imap://stalwart-mail:143) over the docker network, so no TLS overhead per session
What we found:
# In Stalwart's config.toml — every IMAP auth event:directory.authentik.endpoint.method = "userinfo"directory.authentik.endpoint.url = "https://sso.irregularchat.com/application/o/userinfo/"Stalwart 0.15.5 with storage.directory = authentik validates the
user’s OAUTHBEARER token via Authentik’s /userinfo endpoint on every
authentication. Measured from the Proxmox host: 130–185ms per
userinfo call through Cloudflare Tunnel.
Stalwart 0.15.5 has no directory.<id>.cache.* configuration in its
schema (confirmed by strings on the binary). Each IMAP/JMAP auth is a
fresh round-trip.
Meanwhile, Stalwart’s default imap.timeout.idle = "1m" means SOGo’s
IMAP sessions die quickly when the user pauses to read. Each click that
arrives after that minute opens a new IMAP session → fresh OAUTHBEARER
auth → fresh /userinfo call → 150ms tax → user thinks “this is slow.”
# Appended to /datadrive/home/email/config/config.toml:imap.timeout.authenticated = "30m"imap.timeout.idle = "30m"Then docker compose restart stalwart.
Rationale: SOGo polls for new mail every couple minutes, which now keeps the IMAP session alive indefinitely as long as the tab is open. The once-per-30-min cold-cache cost is acceptable; the dozens-of-clicks-per-minute hot path no longer pays Authentik tax.
Trade-offs
Section titled “Trade-offs”- Each active user now holds an open IMAP TCP socket for up to 30 min instead of 1 min. With ~10–20 users this is negligible — Stalwart comfortably handles thousands of idle sessions.
- Apple Mail and iOS clients via the SASL proxy benefit from the same fix — they also paid the per-session Authentik tax. Now persistent for 30 min.
- A revoked/disabled user could keep using their session up to 30 min after revocation (vs. 1 min). Mitigation: kick their open sessions via the Stalwart admin API if immediate cutoff matters.
Long-term
Section titled “Long-term”Stalwart 0.16.x added per-directory caching of OIDC lookups, which eliminates the userinfo tax entirely. When the v0.16.8 wizard issue (stalwartlabs/stalwart#3025) is confirmed resolved, the upgrade gets rid of this workaround and the SASL proxy at the same time. Watch the release notes.
Lessons (broadly applicable)
Section titled “Lessons (broadly applicable)”- OIDC-validated mail backends are network-bound. Every “directory” lookup is at the speed of your IdP. If the IdP is behind a tunnel / CDN / WAN, the latency compounds badly across mail-client polling.
imap.timeout.idleis a perf knob, not just a resource one. Stalwart’s 1-minute default is too aggressive when authentication has any nontrivial cost.- Docker healthchecks tell you the container is alive, not that it serves users quickly. SOGo + memcached + Stalwart all reported “healthy” the entire time the webmail was slow.
- Tune at the layer with the actual config knob. SOGo doesn’t have a “keep IMAP sessions alive longer” setting — the server has to opt into that. Don’t go looking on the wrong side.
From Stalwart → Mailcow (if needed later)
Section titled “From Stalwart → Mailcow (if needed later)”If we decide Stalwart isn’t the right fit in 12 months:
- Stand up Mailcow on a separate host or proxmox VM.
- Configure Mailcow’s native Authentik IdP (System → Config → Access).
- Migrate mailboxes via
imapsyncper user. - Replicate DKIM keys (or generate new ones).
- Update DNS MX to point at Mailcow’s hostname.
- Decommission Stalwart.
Don’t do this preemptively — Stalwart 0.16.5 + App Passwords solves the core SSO + legacy-client problem. Mailcow’s advantage is Rspamd + the extended feature set (ActiveSync polish, ClamAV updates), not SSO.
Server-level auto-provisioning from Authentik (optional pattern)
Section titled “Server-level auto-provisioning from Authentik (optional pattern)”Independent of which mail server you pick, you can build out “create user in Authentik → mailbox appears on the mail server” auto-provisioning via:
Option 1 — Authentik SCIM provider → mail server SCIM endpoint
Section titled “Option 1 — Authentik SCIM provider → mail server SCIM endpoint”Authentik 2024+ supports SCIM 2.0 as an outbound provider. If the mail server has an SCIM endpoint:
- Stalwart: not yet (request open at github.com/stalwartlabs/mail-server/discussions). Workaround: use Authentik’s OIDC backend and let first-login auto-provision the mailbox.
- Mailcow: not native, third-party scripts exist.
- Authentik → custom Webhook → mail server’s admin API: this is the current best-effort pattern.
Option 2 — Authentik LDAP outpost → mail server’s LDAP integration
Section titled “Option 2 — Authentik LDAP outpost → mail server’s LDAP integration”Already running an LDAP outpost? Most mail servers (Mailcow, Mailu, Postfix-direct) support LDAP user backends. Users in Authentik appear in the mail server. No webhook needed.
Trade-off discussed in “Pattern C” above: LDAP doesn’t enforce MFA, so mail-client auth bypasses the MFA prompt webmail users get.
Option 3 — Auth-time auto-provision (what Stalwart and Mailcow do today)
Section titled “Option 3 — Auth-time auto-provision (what Stalwart and Mailcow do today)”User logs in via OIDC for the first time. The mail server’s OIDC directory creates a new principal on the fly using the email/username from the IdP. No SCIM, no LDAP, no webhook. Simplest and what we already use.
Recommended: Option 3 (auto-provision on first OIDC login). Reserve SCIM/LDAP for environments with hundreds of users where pre-provisioning matters.
Cloudflare’s role in our deployment: DNS only for mail hostnames
Section titled “Cloudflare’s role in our deployment: DNS only for mail hostnames”Important distinction: this stack uses neither Cloudflare Email Routing nor CF Email Service for the user mailbox path. CF provides:
- DNS authority for
irregulars.ioandsimplymail.org - CF Tunnel for the HTTP-only paths (
mail.irregulars.iowebmail,mail-admin.irregulars.ioadmin UI,app.simplymail.orgSL dashboard) - Workers
send_emailfor app-level transactional outbound only
Mail hostnames (smtp.*, imap.*, mx.*) MUST be unproxied A records
(gray cloud). The CF proxy is HTTP-only — SMTP/IMAP/POP/raw TCP through it
break instantly. CF dashboard defaults new A records to proxied=true; the
REST API defaults to false. Verify after creation either way:
dig +short A smtp.irregulars.io # must be 46.110.152.205, NOT 104.21.*dig +short A mx.simplymail.org # must be 46.110.152.204If you see 104.21.* or 172.67.*, the record is proxied — flip it to
unproxied in the dashboard or via PATCH ... -d '{"proxied":false}'.
For DNS-01 ACME against the API, use a scoped per-zone token
(Zone:DNS:Edit + Zone:Read), never the Global Key. Mint via the
Global Key once, drop on the mail server with mode 0600. The full recipe
is in the cloudflare-emails skill (third-party-mx-via-cf-dns.md).
Related guides
Section titled “Related guides”- Self-Hosted SimpleLogin — alias service
on
simplymail.org, full operational guide with the reverse-alias model, Apple Mail “From: picker” trap, and reply gating. - Email Hardening Guide — SPF / DKIM / DMARC tuning, phishing defense, end-user practices.
- Cloudflare — DNS + Tunnel + Workers basics.
- Authentik Installation — the SSO/IdP this all integrates with.
- Self-Host Cloudflare Tunnels
— used to publish
mail.irregulars.iowebmail without exposing the origin IP. apps/teamco/docs/EMAIL_SETUP.md(monorepo) — runbook for app-transactional email via Cloudflare Workerssend_email.
References
Section titled “References”- Stalwart Labs — OpenID Connect docs
- Stalwart 0.16.5 release notes — App Passwords
- Stalwart — App Passwords
- Mailcow Authentik integration (community)
- Mailcow — External Identity Providers
- Authentik — Mailcow integration guide
- Mox — modern mail server in Go
- Cloudflare Email Routing docs
- Cloudflare Email Service docs (send_email binding)
- Cloudflare Email Service public beta announcement
- Privacy Guides — Self-Hosting Email comparison
- Mailflow Authority — Mailcow vs Postal vs Stalwart
- selfhosting.sh — Mailu vs Stalwart