Skip to content

Self-Hosted SimpleLogin (2026)

SimpleLogin is an email-alias service: you give it a domain, generate per-site aliases like mountain-rhino-2941@simplymail.org, and it forwards inbound mail to your real mailbox. Reply to the forwarded message and SL sends out as the alias, so the other side never learns your real address. The community self-hosts it under simplymail.org.

Status (2026-06-09): production, v4.81.2, public MX on 46.110.152.204, Authentik OIDC active, in-network delivery to Stalwart for *@irregulars.io recipients. Settled in after the 2026-06-06 mail cutover (off GCP relay, onto direct Proxmox public IPs) and the 2026-06-09 cleanup (dormant CF Email Routing rule purge + multi-homed egress fix + SL→Stalwart transport bypass).

SectionWhat you’ll find
The alias modelHow forward + reverse-alias replies work — the conceptual core
ArchitectureFive containers, networks, ports
Public DNS layoutA/MX/SPF/DKIM/DMARC + the unproxied-MX rule
Public MX binding — 5 things that must line upIP bind, DOCKER-USER, ports, FQDN
Authentik OIDCClient config, alternative_id trap, apex-cookie-split fix (2026-06-08)
In-network delivery to StalwartNEW 2026-06-09 — transport_maps override for @irregulars.io
Reply gatingAuthorized addresses, Apple Mail picker trap
Networking (host-level)Multi-homed egress routing, CF dormant-rule audit
Operational gotchasdb-recreate pool invalidation, upgrade procedure
End-to-end verificationswaks + log-tail recipe
Operator commandsDay-to-day SQL + docker exec cheats

This page is the operator’s guide. End-user-facing docs (how to make aliases, browser extensions, mobile apps) live at simplelogin.io. What’s documented here is what you can’t read in their docs: the self-hosting architecture as actually deployed, and the gotchas that have bitten us.

Why SimpleLogin (not just Cloudflare Email Routing)

Section titled “Why SimpleLogin (not just Cloudflare Email Routing)”
NeedCloudflare Email RoutingSimpleLogin (self-hosted)
Forward info@yourdomain to one inbox✅ free, trivialoverkill
Per-site disposable aliases at signup time❌ (manual creation each time)✅ purpose-built
Reply from the alias so the other party never sees your real email✅ via reverse-alias mechanism
Self-hosted, no third party❌ (you’re on CF)
Browser extension that mints aliases on the fly
Bulk alias dashboard, per-alias enable/disable, contact rules
MFA, OIDC SSO, audit log on the alias account itself

CF Email Routing solves “I want info@ → my Gmail.” SimpleLogin solves “every web service I sign up for gets a different address that I can shut off without losing anything else.”

The two coexist fine on different domains: route info@yourdomain.com via CF, mint disposable aliases at simplymail.org via SL.

Internet sender (e.g. amazon@amazon.com)
simplymail.org MX (46.110.152.204:25)
sl-postfix: queue, validate, hand off to sl-email
sl-email:
- Lookup unwilling_legwork242@simplymail.org → Alias 16
- Lookup Alias 16 → Mailbox 2 (issac@alfaren.xyz)
- Create/find Contact record for the original sender
- REWRITE From: to a per-contact "reverse alias"
e.g. <lpltvap…@simplymail.org>
Outbound via Brevo SMTP relay → issac@alfaren.xyz
------------------------------------------------------------
Reply path (when issac@alfaren.xyz hits "Reply"):
issac@alfaren.xyz
│ replies to <lpltvap…@simplymail.org> ← the reverse alias
simplymail.org MX (46.110.152.204:25) — same listener
sl-email:
- Lookup reverse alias → original Contact (amazon@amazon.com)
- Verify MAIL FROM is one of Mailbox 2's authorized addresses
- REWRITE From: → unwilling_legwork242@simplymail.org
Outbound to amazon@amazon.com
(they see only the alias, never the real mailbox)

The reverse alias is the magic. Each (alias, contact) pair gets a unique random-looking address — that’s how SL knows which alias and which contact to use when you reply.

ContainerImageRole
sl-postfixsimplelogin/postfix:latestPublic MX. Listens 46.110.152.204:25. Validates sender domain, accepts mail for any *@simplymail.org, hands off to sl-email. Brevo SMTP relay for outbound.
sl-emailsimplelogin/app-ci:v4.81.2The handler. Owns alias resolution, contact creation, reverse-alias generation, forward / reply logic. Listens on :20381 (internal), receives from sl-postfix at sl-email:20381.
sl-appsimplelogin/app-ci:v4.81.2The Flask web app. app.simplymail.org — login (Authentik OIDC), dashboard, alias creation. Listens 127.0.0.1:7777, fronted by Cloudflare Tunnel.
sl-job-runnersimplelogin/app-ci:v4.81.2Cron/queue worker — sends notification emails (e.g. “spoof attempt blocked”), processes deferred work.
sl-dbpostgres:16-alpineBacking database (users, mailboxes, aliases, contacts, email_logs). Bridge-internal only.

All five containers live on docker network sl-network = 10.1.0.0/24 on the Proxmox host 100.87.20.88.

Outbound deliveries from sl-email/sl-postfix flow via Brevo SMTP relay (smtp-relay.brevo.com:587, RELAY_HOST in sl-postfix env). Brevo has warm reputation and handles deliverability so we don’t have to manage IP rep for a low-volume alias service.

A mx.simplymail.org 46.110.152.204 ttl=300 proxied=false
MX simplymail.org mx.simplymail.org pri=10
TXT simplymail.org "v=spf1 ip4:46.110.152.204 include:sendinblue.com ~all"
TXT _dmarc.simplymail.org "v=DMARC1; p=quarantine; rua=mailto:..."
CNAME brevo1._domainkey b1.simplymail-org.dkim.brevo.com
CNAME brevo2._domainkey b2.simplymail-org.dkim.brevo.com
CNAME app.simplymail.org <uuid>.cfargotunnel.com proxied=true
CNAME api.simplymail.org <uuid>.cfargotunnel.com proxied=true

Critical: mx.simplymail.org A record MUST be proxied=false (gray cloud). The Cloudflare proxy is HTTP/HTTPS only — SMTP through it doesn’t work. The dashboard creates new A records as proxied=true by default; the API creates them as false. Verify after creating.

The dashboard app.simplymail.org and API api.simplymail.org go through a Cloudflare Tunnel (proxied) — those are HTTP and fine to proxy.

Public MX binding — the parts that have to line up

Section titled “Public MX binding — the parts that have to line up”

Putting sl-postfix:25 on a public IP needs five separate pieces, each of which fails independently if missing.

The /29 secondary IP must actually be on vmbr0. If it’s only “routed to us” but not bound, docker fails container start with cannot assign requested address.

Terminal window
# Runtime
ip addr add 46.110.152.204/29 dev vmbr0 label vmbr0:sl
arping -A -c 3 -I vmbr0 46.110.152.204 # gratuitous ARP so the upstream knows
# Persist in /etc/network/interfaces:
# up ip addr add 46.110.152.204/29 dev $IFACE label $IFACE:sl || true
# up arping -A -c 3 -I $IFACE 46.110.152.204 2>/dev/null || true

⚠️ Kernel IFNAMSIZ trap: interface labels max out at 15 chars total. vmbr0:simplelogin = 17 chars → silent rejection. Use a short suffix like vmbr0:sl.

2. DOCKER-USER ACCEPT for outbound from sl-network

Section titled “2. DOCKER-USER ACCEPT for outbound from sl-network”

SimpleLogin’s docker bridge is 10.1.0.0/24. The host’s DOCKER-USER chain ACCEPTs 172.16/12 for “Docker internal,” then drops by default. 10.1.0.0/24 matches no ACCEPT and gets silently blackholed → outbound DNS/HTTPS from sl-app and sl-postfix times out.

Symptom: sl-app shows unhealthy, /auth/oidc/login returns 500 with requests.exceptions.ConnectionError: ...sso.irregularchat.com [Errno -3] Temporary failure in name resolution. Authentik is reachable from the host fine; only this docker bridge is blocked.

Fix (and persist in /etc/iptables/rules.v4):

Terminal window
iptables -I DOCKER-USER 6 -s 10.1.0.0/24 \
-m comment --comment "SimpleLogin sl-network" -j ACCEPT

3. DOCKER-USER ACCEPT for inbound :25 to .204

Section titled “3. DOCKER-USER ACCEPT for inbound :25 to .204”

docker run will publish the listener but conntrack-based DOCKER-USER rules drop external traffic without an explicit ACCEPT for the public IP

  • port:
Terminal window
iptables -I DOCKER-USER 7 -p tcp -m conntrack \
--ctorigdst 46.110.152.204 --ctorigdstport 25 \
-m comment --comment "SimpleLogin postfix :25 (public)" -j ACCEPT
sl-postfix:
ports:
- "46.110.152.204:25:25"

docker compose up -d to apply (NOT restart — port changes need container recreate; see Docker rules in repo CLAUDE.md).

The HELO/EHLO banner reports POSTFIX_FQDN. Set it to the same name as the A record so forward-FCrDNS passes at receivers:

sl-postfix:
environment:
POSTFIX_FQDN: mx.simplymail.org

After change, docker compose up -d sl-postfix to recreate.

External verification once all five are in place:

Terminal window
swaks --server mx.simplymail.org --port 25 \
--to <any-active-alias>@simplymail.org \
--from probe@<a-real-domain> \
--quit-after FIRST-HELO
# Expect:
# 220 mx.simplymail.org ESMTP Postfix
# 250-mx.simplymail.org
# 250-PIPELINING
# ...

⚠️ Sender domain validation: sl-postfix rejects mail-from with TLDs that don’t resolve in DNS (450 4.1.8 Domain not found). Probes must use a real sender domain. @example.test won’t work; @gmail.com will.

OIDC client config in simplelogin.env:

URL=https://app.simplymail.org
OIDC_CLIENT_ID=<32-char-secret-from-authentik>
OIDC_CLIENT_SECRET=<...>
OIDC_WELL_KNOWN_URL=https://sso.irregularchat.com/application/o/simplelogin/.well-known/openid-configuration

Authentik app slug: simplelogin, provider type: OAuth2/OIDC, redirect URI: https://app.simplymail.org/auth/oidc/callback.

For the OIDC redirect to work, sl-app must be able to reach sso.irregularchat.com:443 — that’s why the DOCKER-USER ACCEPT in step 2 above matters. Without it, the well-known endpoint times out and login returns 500.

Section titled “Apex vs canonical-host cookie split (2026-06-08 fix)”

URL=https://app.simplymail.org is the canonical host. The Flask session cookie that holds the OIDC state value gets set on exactly that host.

If you also serve sl-app on the apex simplymail.org (tunnel ingress on both), a user who starts /auth/oidc/login on simplymail.org gets the state cookie on simplymail.org; Authentik then bounces them to app.simplymail.org/auth/oidc/callback; the callback host doesn’t see the cookie → oauthlib.MismatchingStateError 500.

Two ways to fix:

  1. 301-redirect the apex at the edge (recommended). No tunnel ingress on the apex; a Cloudflare Single Redirect Rule via the http_request_dynamic_redirect ruleset returns https://app.simplymail.org/$1 for every request. Single canonical host owns sessions. Configure via CF API:

    Terminal window
    curl -X POST -H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $CF_KEY" \
    -H "Content-Type: application/json" \
    "https://api.cloudflare.com/client/v4/zones/$ZONE/rulesets/phases/http_request_dynamic_redirect/entrypoint" \
    -d '{
    "rules": [{
    "action": "redirect",
    "action_parameters": {
    "from_value": {
    "status_code": 301,
    "target_url": {
    "expression": "concat(\"https://app.simplymail.org\", http.request.uri.path)"
    },
    "preserve_query_string": true
    }
    },
    "expression": "(http.host eq \"simplymail.org\")"
    }]
    }'
  2. Widen the cookie domain. Set SESSION_COOKIE_DOMAIN=.simplymail.org in simplelogin.env, then both hosts share the cookie. Larger blast radius (every subdomain can read it) — fine if you control them all.

Flask-Login stores session['_user_id'] = user.get_id(). get_id() returns alternative_id (a UUID) if set, otherwise the integer primary key. If alternative_id is NULL, the next request calls User.get_by(alternative_id="2") which never matches — session silently dies and the user bounces back to login.

This bites only with bulk SQL imports that bypass User.create(). After any direct-DB user creation, verify and backfill:

SELECT id, email, alternative_id FROM users WHERE alternative_id IS NULL;
-- if any:
UPDATE users SET alternative_id = gen_random_uuid()::text
WHERE alternative_id IS NULL;

When the alias owner replies via a reverse alias, sl-email checks that the SMTP envelope MAIL FROM: is one of:

  • the alias-owning mailbox’s email (e.g. issac@alfaren.xyz), or
  • any address in authorized_address for that mailbox

Anything else gets rejected with 250 SL E214 Unauthorized for using reverse alias and the mailbox owner gets a “spoof attempt blocked” notification with a one-click “Allow” link.

This is anti-spoofing — without it, anyone who guessed a reverse alias could forge mail from any of your aliases.

UI path: click the “Allow X” button in the notification email. Sends a verification email to the address before activating.

Operator path (skip verification):

INSERT INTO authorized_address (created_at, user_id, mailbox_id, email)
VALUES (NOW(), <user_id>, <mailbox_id>, '<address>')
ON CONFLICT ON CONSTRAINT uq_authorize_address DO NOTHING;

Then restart sl-email — it caches mailbox/authorized-address lookups in memory:

Terminal window
docker restart sl-email

Schema reminder:

authorized_address(id, created_at, updated_at, user_id, mailbox_id, email)
unique (mailbox_id, email)

No verified column — verification happens in the app layer, so DB insert bypasses it. Fine for operator backfill of addresses you obviously own.

Apple Mail shows every identity in the account. When replying to an SL-forwarded message it defaults to the mailbox that received the forward (e.g. issac@alfaren.xyz). If the user picks a different “From:” — like admin@irregularchat.com — the reply gets rejected unless that address is in authorized_address.

Add each Apple Mail identity that should send-as your aliases once to avoid the gate. Common candidates: Gmail identity, Workspace identity, additional personal domains.

In-network delivery to Stalwart (skip Brevo for hosted domains)

Section titled “In-network delivery to Stalwart (skip Brevo for hosted domains)”

New in 2026-06-09. For recipient domains hosted on our own Stalwart MX (irregulars.io), sl-postfix delivers direct over the docker hairpin instead of looping the message through Brevo. Cuts a third party out of the metadata path and saves Brevo quota.

Why route override instead of merging postfix instances

Section titled “Why route override instead of merging postfix instances”

Most online guides (e.g. simple-login/app#765, docker-mailserver#2380) merge SL into one big postfix — install postfix-pgsql into docker-mailserver and mount SL’s pgsql lookup files in. We deliberately keep two independent postfix instances and bridge them with a transport map:

ReasonWhy two instances wins
Different security domainsStalwart holds real mailboxes; SL handles disposable aliases with public-facing rewrite. Mix the surfaces and a misconfig in one blast-radiuses the other.
Independent upgrade cadenceStalwart and simplelogin/postfix ship on different schedules. Merged → one upgrade can break the other.
Layered IP reputationBrevo for simplymail.org outbound, Stalwart’s own rep for irregulars.io. Merged → one shared profile.
Migration optionalityDrop SL → delete the container. Stalwart untouched.

sl-postfix’s transport_maps becomes a chain (first match wins):

transport_maps =
pgsql:/etc/postfix/pgsql-transport-maps.cf, # SL alias domains → sl-email handler
texthash:/srv/postfix-extra/transport-extra # in-network domains → Stalwart direct
# relayhost = [smtp-relay.brevo.com]:587 # everything else → Brevo fallback

The transport-extra flat file is:

irregulars.io smtp:[46.110.152.205]:25

Square brackets disable MX lookup — postfix sends straight to the IP, no DNS dependency.

Wrapping the entrypoint (why upstream main.cf can’t be patched directly)

Section titled “Wrapping the entrypoint (why upstream main.cf can’t be patched directly)”

The simplelogin/postfix image’s entrypoint runs generate_config.py --postfix on every container start, which regenerates main.cf from a Jinja template. A bind-mount over main.cf would be stomped on; the template doesn’t expose a transport_maps env hook.

The fix: a wrapper entrypoint at selfhost/simplelogin-app/postfix-extra/entrypoint-override.sh that:

  1. Replicates upstream’s _main() flow (with POSTFIX_CUSTOM_DATA_DIRECTORY exported — see trap #2 below)
  2. Runs generate_config.py --postfix so the stock config is correct
  3. Layers our override: postconf -e "transport_maps = pgsql:..., texthash:..."
  4. Execs postfix start-fg

Compose mounts the wrapper + the flat file:

sl-postfix:
volumes:
- ./postfix-extra/entrypoint-override.sh:/usr/local/bin/entrypoint-override.sh:ro
- ./postfix-extra/transport-extra:/srv/postfix-extra/transport-extra:ro
command: ["/usr/local/bin/entrypoint-override.sh"]
  1. hash: driver isn’t shipped. simplelogin/postfix is Alpine-based, and Alpine deprecated BerkeleyDB. postconf -m lists lmdb, texthash, inline, regexp — but not hash. Upstream tracks this at simplelogin-postfix-docker#7. The reporter tried lmdb: and hit "open database /etc/aliases.lmdb: No such file or directory" — same wall we hit on :ro bind-mounts. texthash: sidesteps it: reads the flat file in place, no postmap, no compiled DB. See Postfix DATABASE_README for the texthash trade-off (“no change detection — requires postfix reload after edits”).

  2. POSTFIX_CUSTOM_DATA_DIRECTORY must be exported, not just assigned. Upstream’s setup_postfix_custom_data exports it. If your wrapper merely assigns it (bash local), python3 generate_config.py --postfix silently fails to write pgsql-transport-maps.cf (Jinja StrictUndefined raises inside the --postfix step, eaten by the wrapper). Symptom: postfix start-fg spams open "pgsql" configuration: No such file or directory for every daemon process at startup, mail to *@simplymail.org stops resolving.

  3. Don’t bind-mount inside /etc/postfix/. Postfix’s startup security check whines not owned by root for every file in /etc/postfix not owned by UID 0. The host-mounted file is owned by the host user, not container-root. Mount to /srv/postfix-extra/ instead and reference with the absolute path.

Terminal window
$EDITOR /datadrive/home/simplelogin-app/postfix-extra/transport-extra
docker exec sl-postfix postfix reload

texthash: reads the flat file directly — no postmap, no container restart.

Look for relay=46.110.152.205[...]:25 on delivery lines:

Terminal window
docker logs sl-postfix --since 5m 2>&1 | grep 'to=<.*@irregulars.io>'
# expected: relay=46.110.152.205[46.110.152.205]:25
# wrong: relay=smtp-relay.brevo.com[...]:587

Population check before extending to a new domain — SL forwards to the mailbox.email value, NOT users.email:

Terminal window
docker exec sl-db psql -U simplymail_user -d simplelogin -c \
"SELECT split_part(email,'@',2), COUNT(*) FROM mailbox GROUP BY 1 ORDER BY 2 DESC;"

If no mailboxes target that domain, the override does nothing yet — it becomes active the first time a user adds a mailbox there.

These bit us after the 2026-06-06 cutover and took the rest of the week to track down. Both affect mail flow but are invisible from SimpleLogin’s logs — diagnosis requires going to the host.

Multi-homed egress routing (2026-06-09 fix)

Section titled “Multi-homed egress routing (2026-06-09 fix)”

The Proxmox host has two uplinks on the same physical interface vmbr0:

  • residential home-LAN 192.168.1.0/24 via gateway 192.168.1.1
  • Metronet public block 46.110.152.200/29 via gateway 46.110.152.201

The default route points at the home gateway. Without policy routing, outbound traffic sourced from .204 (sl-postfix) gets sent via the home router — which drops it because the source IP isn’t on its LAN. Inbound SYNs to .204:25 arrive fine; our SYN-ACK never reaches the remote → SMTP handshakes never complete → mail never gets through.

Symptom: zero RCPT TO: from external MTAs in sl-postfix logs; only persistent scanners occasionally land. Mail accumulates as silent failures from senders’ side (deferred and eventually bounced).

The fix is policy routing keyed on the input interface, not source IP — the kernel chooses return routes before POSTROUTING NAT rewrites the source. Active on the host as metronet-egress-routing.service (systemd oneshot). Affects both br-38a5b629a7c8 (sl-network for sl-postfix) and br-f24e52480289 (Stalwart’s docker bridge).

Sanity-check from inside sl-postfix:

Terminal window
# sl-postfix → Gmail outbound IP must route via Metronet (.201), not home (.1.1)
docker exec sl-postfix sh -c \
'ip route get 209.85.220.41 from 172.17.0.X iif br-38a5b629a7c8'
# expected: via 46.110.152.201 ... table metronet_egress

Full diagnostic + fix recipe lives in the operator’s internal lessons-learned/proxmox-multihomed-egress-routing.md. Key idea: the systemd unit re-reads the docker bridge IDs each boot (they change when networks are recreated) and inserts the policy rules fresh, so it survives docker compose down/up cycles.

CF Email Routing dormant-rule audit (2026-06-09 fix)

Section titled “CF Email Routing dormant-rule audit (2026-06-09 fix)”

When migrating off Cloudflare Email Routing onto a self-hosted MX (SL or Stalwart), disabling Email Routing at the zone level does NOT delete forwarding rules. Dormant rules remain enabled: true in the API and silently intercept mail from senders whose MTAs cache the old MX. Apple iCloud caches MX for days past TTL — so even three weeks after a cutover, real mail can still get siphoned to an old forwarding target.

The siphoned mail we found 2026-06-09: sac@irregulars.io → forward:issac@alfaren.xyz — still active long after irregulars.io’s MX moved to Stalwart on .205.

Audit + purge after any MX cutover:

Terminal window
ZONE=<zone-id-of-affected-domain>
# List forwarding rules — anything with action 'forward:' you don't want is poison
curl -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}'
# Delete by tag
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"

Note: the Catch-all rule cannot be deleted via API — it’s a special zone-level setting. Verify it’s enabled: false and action: drop. SimpleLogin domains shouldn’t have Email Routing enabled at all (we use direct MX); Stalwart-hosted domains shouldn’t either.

sl-db recreation invalidates app connection pools

Section titled “sl-db recreation invalidates app connection pools”

After docker compose up -d --force-recreate sl-db (routine postgres point bump), sl-email’s connection pool holds stale fds pointing at the dead container. Next message arrives → sl-postfix returns 250 to the sender → sl-email throws psycopg2.OperationalError: server closed the connection unexpectedly → mail is silently dropped.

Always restart the app containers after a db recreate:

Terminal window
docker restart sl-email sl-app sl-job-runner

Better future order: stop app containers first, recreate db, start app containers — avoids the first-mail-after-restart failure.

Upgrading SL (the path that worked: v4.78.6 → v4.81.2)

Section titled “Upgrading SL (the path that worked: v4.78.6 → v4.81.2)”

Same-day-tested on 2026-06-06:

Terminal window
# 1. Backup
docker exec sl-db pg_dump -U simplymail_user simplelogin | gzip > sl-db-$(date +%Y%m%d).sql.gz
cp /datadrive/home/simplelogin-app/docker-compose.yml{,.bak.$(date +%Y%m%d_%H%M%S)}
# 2. Bump the image tag in compose, recreate the three app containers
sed -i 's|app-ci:v4.78.6|app-ci:v4.81.2|g' /datadrive/home/simplelogin-app/docker-compose.yml
cd /datadrive/home/simplelogin-app
docker compose up -d --no-deps sl-app sl-email sl-job-runner
# 3. Run alembic
docker exec sl-app alembic upgrade head
# 4. Verify
docker ps --filter "name=sl-" --format "{{.Names}}\t{{.Status}}"
curl -sS -o /dev/null -w "/auth/oidc/login → %{http_code}\n" http://127.0.0.1:7777/auth/oidc/login

Only one migration applied (4a9f8c2e1b3d Add credential metadata columns to fido table). No breaking changes in the changelog between v4.78.6 and v4.81.2 — all bug fixes and small enhancements.

After any change to receive path:

Terminal window
# A. tail both handlers in background
docker logs -f sl-postfix --since 5s | sed "s/^/[postfix] /" &
docker logs -f sl-email --since 5s | sed "s/^/[email] /" &
# B. send a probe
swaks --to <alias>@simplymail.org --from probe@<your-real-domain> \
--server mx.simplymail.org --port 25 \
--h-Subject "verify $(date +%s)"
# C. expect in sl-email log within 1s:
# "New message, mail from probe@... rctp tos ['<alias>@simplymail.org']"
# "Forward <Contact X> -> <Alias Y> -> <Mailbox Z>"
# "Finish ... return code '250 Message accepted for delivery'"
Terminal window
# Containers
docker ps --filter "name=sl-" --format "{{.Names}}\t{{.Status}}"
# Per-container logs
docker logs sl-app --tail 100
docker logs sl-email --tail 50
docker logs sl-postfix --tail 30
docker logs sl-job-runner --tail 30
# Alembic state
docker exec sl-app alembic current
docker exec sl-app alembic heads
# Inspect aliases / mailboxes / authorized addresses
docker exec sl-db psql -U simplymail_user -d simplelogin -c "
SELECT id, user_id, email FROM mailbox ORDER BY id;"
docker exec sl-db psql -U simplymail_user -d simplelogin -c "
SELECT id, email, enabled FROM alias ORDER BY id LIMIT 20;"
docker exec sl-db psql -U simplymail_user -d simplelogin -c "
SELECT id, mailbox_id, email FROM authorized_address;"
# Force outbound test (sl-postfix → Brevo)
docker logs sl-postfix 2>&1 | grep 'status=sent' | tail -5
# Free disk in container volumes (rare but possible)
du -sh /datadrive/home/simplelogin-app/{,uploads,db_data}
/datadrive/home/simplelogin-app/
├── docker-compose.yml # service definitions
├── simplelogin.env # config (DB, OIDC, RELAY_HOST, etc.)
├── custom-wsgi.py # Apache/WSGI shim (legacy, mostly unused)
├── alembic.ini # migration config
├── app/ # app code (mounted into sl-app)
└── docker-compose.yml.bak.* # version snapshots

DB data lives in a docker volume managed by sl-db.

  • PTR for 46.110.152.204mx.simplymail.org with Metronet (the colo). Forward A is set; reverse leg pending. Without it, strict receivers (Gmail / M365 / Proofpoint) give slightly worse spam scores on direct outbound. Brevo handles outbound today so this is non-blocking — but cleaner once filed.
  • CAPTCHA on signup — currently disabled because we have OIDC-only signup. If we ever open registration, re-evaluate.
  • PGP signing for outbound forwards — supported by SL but not configured here. Could add per the SL docs.
  • Extend in-network transport to more domains. Today only irregulars.io is in transport-extra. When SL mailboxes accumulate on other Stalwart-hosted domains, add them. Use the population query in Verifying the route before extending.

If you’re standing up your own SimpleLogin instance using this repo as a starting point, the minimum substitutions are:

FileReplace
selfhost/simplelogin-app/.envSL_DB_PASSWORD, BREVO_SMTP_USERNAME, BREVO_SMTP_KEY, MAIL_FQDN
selfhost/simplelogin-app/docker-compose.ymlThe public IP in the sl-postfix port binding (46.110.152.204:25:25 → yours)
selfhost/simplelogin-app/postfix-extra/transport-extraIf you’re not running a Stalwart sibling, delete the irregulars.io line (texthash file can be empty — the override loads but matches nothing)
DNSA record mx.<your-domain>, MX 10 pointing at it (unproxied), SPF including ip4:<your-public-ip> + include:sendinblue.com, DKIM CNAMEs from Brevo dashboard
AuthentikProvider + Application + redirect URI https://app.<your-domain>/auth/oidc/callback
/etc/iptables/rules.v4The two DOCKER-USER ACCEPTs (sl-network outbound + public-IP inbound) — see Public MX binding steps 2–3

Optional but recommended once stable:

  • Cloudflare Single Redirect Rule for apex → app.<your-domain> (see Apex cookie split)
  • metronet-egress-routing.service (or equivalent) if you’re multi-homed
  • PTR with your colo provider before flipping outbound away from Brevo