Runbook: email → Matrix (Postmoogle)¶
Postmoogle (stacks/postmoogle) is an SMTP server that posts incoming email into Matrix
rooms — so the homelab can receive mail (service notifications, receipts, lists) as Matrix
messages. Bot: @postmoogle:matrix.fmm.house. Decision + rationale:
docs/adr/0006-email-into-matrix-postmoogle.md. Distinct from ntfy (outbound push,
docs/runbooks/notifications.md) and Mailpit (outbound email viewer).
- Image
ghcr.io/etkecc/postmoogle:v0.9.31(distroless → no healthcheck, per ADR-0004). Runs as uid1000so./datais writable. Joinsmatrix_defaultto reach the homeserver athttp://continuwuity:8008; SQLite state + mailbox mappings in./data(git-ignored). - SMTP on host
:25— a raw published port (not Traefik; Traefik is HTTP-only). It's reachable on LAN and tailnet with no ufw rule because Docker's published port bypasses ufw (same asmailpit:2025/forgejo:2222). No internet exposure (no port-forward).
Addresses & the room-per-recipient model¶
1 room = 1 mailbox. POSTMOOGLE_DOMAINS="fmm.house mail.fmm.house" → primary addresses are
<mailbox>@fmm.house (mail.fmm.house also accepted, for cutover). Mail to an unclaimed
address is refused (550 ... no such user here, kupo.) — that rejection is expected, not a bug.
Claim a mailbox (in Element, your account — the bot only takes commands from a room member):
1. Create/pick a room, invite @postmoogle:matrix.fmm.house, wait for it to join.
2. Send !pm mailbox albert → that room now owns albert@fmm.house.
- Catch-all: in a dedicated room, !pm catch-all → it receives anything unclaimed.
- !pm aliases (extra addresses for a room), !pm domain (per-mailbox default domain),
!pm help (full list). Admin = @albert (POSTMOOGLE_ADMINS).
Threading (verified working)¶
- Default: email reply-chains → Matrix threads (via
In-Reply-To/References). A reply lands inside the original's thread.!pm nothreadstoggles this off. !pm threadifytucks each email's body/attachments into a thread under a compact root (keeps a room scannable).
Sending to it ("any box can send")¶
Point a sender's SMTP host at fmm.house (or mail.fmm.house), port 25 (it's an internal
sink; spam checks are off for internal senders — !pm spamcheck:*, !pm greylist per mailbox).
Two ways it resolves with zero per-box config:
- On the LAN: AdGuard resolves fmm.house → .49, and since there's no internal MX, a
standard MTA falls back to the A record as an implicit MX → .49:25.
- Remote on the tailnet: public DNS resolves the MX fmm.house → mail.fmm.house →
Tailscale address → delivers to av over the tailnet.
Simple tools (msmtp, app SMTP settings) just set the host explicitly and skip MX entirely.
Sending from a self-hosted app on this host (e.g. vaporware / ~/apps)¶
An app container on this host delivers OTP/notification mail into Matrix through Postmoogle —
verified 2026-07-17 for the apps repo's vaporware-prod stack. There are two ways to send and the
rules below map both (each wrong turn is a real 5xx, established by handshake not guessed); the
deployed stack takes the authenticated route so OTPs come FROM a clean @fmm.house address.
EMAIL_FROMvs. the Postmoogle domains. A foreign From (noreply@vaporware.fmm.house,noreply@example.com, …) is accepted as inbound mail with no credentials and delivered to the recipient's room. A From on a Postmoogle domain (@fmm.house/@mail.fmm.house) is sending as a local mailbox → refused530 authentication requiredunless you AUTH as it. To send as@fmm.house(the deployed choice): claim the box (!pm mailbox test→test@fmm.house), set its!pm password, and give the appSMTP_USER=test@fmm.house+SMTP_PASS=<pw>— nodemailer doesAUTH PLAINover STARTTLS (235, verified).- The recipient must be a claimed mailbox (
!pm mailbox <name>); an unclaimed address is refused550 … no such user here, kupo.. Sign test accounts up at claimed boxes (e.g.albert@fmm.house). - Pin the SMTP host to the gateway. This host resolves
*.fmm.houseto the Tailscale-IPv6 public zone, which a Docker bridge can't route (same reason the Postmoogle stack pinsmatrix.fmm.house:host-gateway). So the app's compose addsextra_hosts: ["mail.fmm.house:host-gateway"]— Postmoogle's:25is published on the host, and STARTTLS still validates because the served cert is*.fmm.house.
apps/.env (vaporware-prod, the deployed authenticated config) + apps/compose.homelab.yaml:
EMAIL_PROVIDER=smtp
EMAIL_FROM=test@fmm.house # send AS a claimed @fmm.house mailbox (needs the auth below)
SMTP_HOST=mail.fmm.house
SMTP_PORT=25 # STARTTLS opportunistic, then AUTH PLAIN over TLS
SMTP_USER=test@fmm.house # the claimed mailbox (!pm mailbox test)
SMTP_PASS=… # its !pm password — lives in .env (gitignored), never committed
# compose.homelab.yaml → services.web.extra_hosts: ["mail.fmm.house:host-gateway"]
# Simpler alternative (no secret): drop SMTP_USER/PASS and set EMAIL_FROM to a NON-fmm.house domain.
Smoke-test the authenticated path without posting a Matrix message (stops at RCPT, no DATA):
PMPW='<the !pm password>' python3 - <<'PY'
import smtplib, ssl, os
s = smtplib.SMTP("127.0.0.1", 25, timeout=10) # Postmoogle's published :25 on this host
s.ehlo("probe"); s.starttls(context=ssl._create_unverified_context()); s.ehlo("probe")
s.login("test@fmm.house", os.environ["PMPW"]) # 235 authentication succeeded
print("MAIL", s.mail("test@fmm.house")) # 250
print("RCPT", s.rcpt("albert@fmm.house")) # 250 (unclaimed -> 550)
s.rset(); s.quit()
PY
STARTTLS (required for AUTH) & sending outbound¶
- STARTTLS on
:25uses Traefik's*.fmm.housewildcard cert.POSTMOOGLE_TLS_REQUIRED=truemeansAUTHis only offered after STARTTLS — a sender's credentials never cross the wire in clear (verified:AUTHis absent from a plaintextEHLO). Inbound MX receiving stays open (an MX can't demand TLS from arbitrary senders) — but it's WireGuard-encrypted on the tailnet and STARTTLS is offered opportunistically. For a name-validating cert, connect tomail.fmm.house(the wildcard doesn't cover the bare apexfmm.house; MX/implicit-MX delivery is opportunistic, so both names work). - Cert auto-refresh (no maintenance): the
postmoogle-certsidecar (cert-sync.py) extracts the wildcard from Traefik's ACME store every 12h and writes./tls/in place; Postmoogle's fsnotify watcher hot-reloads it with no restart (verified end-to-end — the served cert updates live)../refresh-cert.shforces a refresh immediately if ever needed. Both read Traefik's root-ownedacme.jsonvia a throwaway root container (docker only, no host sudo)../tls/is gitignored (it holds the private key). - Sending from Postmoogle is bidirectional: reply to a received email (stays in-thread) or
!pm send; a client can also authenticate to:25(AUTH PLAIN/LOGIN) with the mailbox's SMTP password (!pm password, username = the mailbox address) and submit. - No relay is configured — this is deliberately tailnet-internal. Outbound to external
inboxes would need
POSTMOOGLE_RELAY_*/!pm relay→ a real provider (+ SPF/DKIM/DMARC), and wouldn't deliver from here anyway (residential/tailnet egress, blocked :25, no sending reputation). Sending between Postmoogle mailboxes / into rooms works without a relay.
DNS design (split-horizon) — why there's no internal MX¶
Mail rides on the same split-horizon as everything else (see docs/architecture.md):
| internal (AdGuard DNS Rewrites UI) | public (porkbun) | |
|---|---|---|
fmm.house, *.fmm.house |
→ 192.168.86.49 (LAN) |
→ Tailscale AAAA fd7a:…:4001:3507 (tailnet-only) |
| MX | none (see below) | fmm.house MX 10 mail.fmm.house |
- The AdGuard DNS Rewrites UI feature does A/AAAA/CNAME only and short-circuits every other
record type (returns NODATA) — so it can't express an MX, and any
$dnsrewriteMX on a rewritten name is shadowed. We don't add an internal MX: the apexA=.49already serves as the implicit MX for LAN senders. Converting the apex off the UI rewrite onto$dnsrewritewas tried and rejected — it risks the root's A record, which also feeds Traefik's apex→www redirect (stacks/traefik/dynamic/apex-redirect.yaml), for no benefit implicit-MX doesn't give. - Public side is a Tailscale wildcard (ADR-0006):
*.fmm.house+ apex → Tailscale, so all services (and mail) are reachable remotely over the tailnet and Traefik does the routing. External senders resolve the MX to a non-routable Tailscale address → delivery fails = no public inbound mail, by design.
Secrets / backup / ops¶
stacks/postmoogle/.env(git-ignored, backup scope):POSTMOOGLE_PASSWORD(the bot's Matrix password) +POSTMOOGLE_DATA_SECRET(at-rest key). Also back updata/postmoogle.db(mailbox↔room mappings). See the backup callout indocs/runbooks/monitoring.md.- Bot re-register (if ever needed): the account was made via the homeserver registration-token
flow (
MATRIX_REGISTRATION_TOKEN, seestacks/matrix/SETUP.md),--rolen/a (regular user). docker logs postmoogle; recreate withdocker compose -f stacks/postmoogle/compose.yml up -d. Version-pinned; Diun watches it for updates.