Skip to content

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 uid 1000 so ./data is writable. Joins matrix_default to reach the homeserver at http://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 as mailpit: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 nothreads toggles this off.
  • !pm threadify tucks 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_FROM vs. 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 → refused 530 authentication required unless you AUTH as it. To send as @fmm.house (the deployed choice): claim the box (!pm mailbox testtest@fmm.house), set its !pm password, and give the app SMTP_USER=test@fmm.house + SMTP_PASS=<pw> — nodemailer does AUTH PLAIN over STARTTLS (235, verified).
  • The recipient must be a claimed mailbox (!pm mailbox <name>); an unclaimed address is refused 550 … 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.house to the Tailscale-IPv6 public zone, which a Docker bridge can't route (same reason the Postmoogle stack pins matrix.fmm.house:host-gateway). So the app's compose adds extra_hosts: ["mail.fmm.house:host-gateway"] — Postmoogle's :25 is 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 :25 uses Traefik's *.fmm.house wildcard cert. POSTMOOGLE_TLS_REQUIRED=true means AUTH is only offered after STARTTLS — a sender's credentials never cross the wire in clear (verified: AUTH is absent from a plaintext EHLO). 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 to mail.fmm.house (the wildcard doesn't cover the bare apex fmm.house; MX/implicit-MX delivery is opportunistic, so both names work).
  • Cert auto-refresh (no maintenance): the postmoogle-cert sidecar (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.sh forces a refresh immediately if ever needed. Both read Traefik's root-owned acme.json via 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 $dnsrewrite MX on a rewritten name is shadowed. We don't add an internal MX: the apex A=.49 already serves as the implicit MX for LAN senders. Converting the apex off the UI rewrite onto $dnsrewrite was 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 up data/postmoogle.db (mailbox↔room mappings). See the backup callout in docs/runbooks/monitoring.md.
  • Bot re-register (if ever needed): the account was made via the homeserver registration-token flow (MATRIX_REGISTRATION_TOKEN, see stacks/matrix/SETUP.md), --role n/a (regular user).
  • docker logs postmoogle; recreate with docker compose -f stacks/postmoogle/compose.yml up -d. Version-pinned; Diun watches it for updates.