Self-hosted Stalwart Mail Server (SMTP/IMAP/JMAP/ManageSieve, built-in DKIM/SPF/DMARC/ACME) in Proxmox LXC 103, plus Roundcube webmail in the same LXC. Hosts two domains:
codycodez.com and hanna.properties — inbound mail is received directly at home; all outbound mail is relayed through Mailgun (see "Outbound delivery"). Both domains have real Let's Encrypt certificates and a Namecheap-integrated ACME/DNS setup.Correction (2026-09-25): earlier revisions of this page said
hanna.propertiessent "directly, without Mailgun". That was wrong — the routing rule waselse 'mailgun'for every non-local recipient, so both domains have always used Mailgun. Direct sending was tested on 2026-09-25 and is not viable from this connection (below).
Personal (codycodez.com) and business (hanna.properties, Hanna Properties / Cody Codez LLC) mail. Mailboxes (2026-09-25): admin@ + cody@ on codycodez.com; admin@, cody@, pete@, ken@, rob@hanna.properties; alerts@codycodez.com (reserved for the health monitor). postmaster@ and abuse@ are aliases of each domain's admin@ (added 2026-09-25) so DMARC/TLS-RPT reports and the RFC-required postmaster address have somewhere to land.
| Field | Value |
|---|---|
| VMID / Type | 103, LXC, unprivileged, Proxmox node M910q |
| IP / Tailscale | 192.168.86.150 / 100.108.41.60 |
| OS | Debian 13 (trixie) |
| CPU / RAM | 2 cores / 2048MB (+512MB swap) |
| Storage | rootfs 8GB on boot-disks; mp0 16GB on dedicated stalwart-mail Ceph pool at /opt/stalwart |
| Backup | Weekly Proxmox vzdump job (Sun 01:00 → backup-storage, keep 3 weekly + 4 monthly) includes 103 and its mp0 — last four runs verified present. (An earlier note here said no backups existed; that was stale.) |
| Port | Service | External |
|---|---|---|
| 25 | SMTP inbound | Yes |
| 465 / 587 | Submission (implicit TLS / STARTTLS) | Yes |
| 993 | IMAPS | Yes |
| 995 | POP3S | Yes — only because Gmail's POP fetcher still polls it; Google retires that feature (see below). Close the port + router forward once everyone has moved to webmail/IMAP. |
| 4190 | ManageSieve | LAN/loopback only |
| 443 | Stalwart Web Admin / JMAP | LAN/Tailscale (NPM proxies mail.codycodez.com to it) |
| 8081 | Roundcube webmail (nginx, HTTP) | LAN only — reached via NPM |
email.codycodez.com / email.hanna.properties/opt/bulwark/bulwark-standalone, systemd unit bulwark.service (user bulwark, hardened), port 3000./etc/bulwark/env (root-only; JMAP_SERVER_URL=https://mail.codycodez.com, random SESSION_SECRET, TRUSTED_PROXY_DEPTH=1, COOKIE_SECURE=true, SETTINGS_SYNC_ENABLED=true). Data in /var/lib/bulwark/{settings,admin,admin-state,telemetry,version} (covered by the weekly vzdump of LXC 103). No admin dashboard (no ADMIN_PASSWORD set) and telemetry consent is off. /etc/hosts in LXC 103 maps mail.codycodez.com → 127.0.0.1 so Bulwark reaches Stalwart over loopback with its real certificate (Stalwart AllowedIp contains 127.0.0.1, so webmail users can't get the app banned; brute-force protection there is therefore blind — Bulwark's own login handling applies).email.codycodez.com) and 20 (email.hanna.properties) → http://192.168.86.150:3000, websockets on, own Let's Encrypt certs (67/68, issued/renewed by NPM). Verified from outside the LAN: login works, session cookies are Secure; HttpOnly.bulwark-update on LXC 103 (downloads the latest release, swaps it in, health-checks, auto-rolls-back on failure; previous version kept in /opt/bulwark/previous). mail-health e-mails when a newer release exists.x:Http.usePermissiveCors = true, set 2026-09-25 + restart). Bulwark's browser code talks JMAP directly to https://mail.codycodez.com from the email.* origin (only the login route is server-side), so without CORS the login page fails with "The server is reachable but is blocking cross-origin requests". The setting affects every Stalwart HTTP response (Access-Control-Allow-Origin: *) and, like routing changes, only fully applies after systemctl restart stalwart (the preflight answers changed live, normal responses did not). Tradeoff: any web page can attempt JMAP calls, but they still need valid credentials in the Authorization header; there's no cookie auth on Stalwart.:8081, LAN only, no longer reachable from the internet) as a fallback; remove it (apt purge roundcube* nginx php8.4-fpm, drop the 8081 firewall rule) once Bulwark is trusted. Its cody@127.0.0.1 identity oddity came from logging in with a short username — irrelevant now.mail-user (on the SuperPC host, /root/bin/mail-user)mail-user add name@domain [--forward you@gmail.com] (prompts for the Stalwart admin password; stores nothing) creates the mailbox, a random password, optionally a Sieve "copy to Gmail" filter, and prints a welcome card with the webmail URL and phone-app IMAP/SMTP settings. Also list, passwd, del.
Verified against Google's own help pages (2026-09-25): Google is removing POP fetching ("Check mail from other accounts") from Gmail on the web, Gmailify, and — from January 2027 — "Send mail as" for non-Google addresses. Still supported: the Gmail mobile app over IMAP (a separate account inside the app), forwarding into Gmail, third-party clients. Consequently the durable setup is: webmail (Roundcube) + IMAP in any phone app, with optional forwarding into Gmail for receiving. A single merged Gmail inbox for a custom domain is no longer possible.
Sources: support.google.com/mail/answer/16604719 and /answer/22370.
x:MtaOutboundStrategy singleton. Read it with ids:["singleton"] — ids:null wrongly returns an empty list, which is why it was overlooked for a long time. The route expression is if is_local_domain(rcpt_domain) → 'local'; else → 'mailgun'. The mailgun relay route (smtp.mailgun.org:587, second Mailgun account, login stalwart@codycodez.com) is a normal x:MtaRoute.systemctl restart stalwart — the expression is not re-read live (a few seconds of downtime).72.81.141.19 is on the Spamhaus PBL (residential range). Result per provider: iCloud rejects (5.7.1 ... listing in Spamhaus PBL), Outlook and Yahoo drop the connection, Gmail returns 250 OK and then silently discards (test messages never reached inbox, spam or trash). Silent loss is the worst failure mode, so the route was put back to Mailgun. A Verizon PTR record would not fix a PBL listing.x:MtaRoute (Relay) and flip the else branch, restart, retest to Gmail; SPF must be updated to authorize the relay (Stalwart's auto-generated v=spf1 mx -all only covers the home IP), so leave spf out of automatic DNS publishing until then. Do not bother giving each domain its own outbound EHLO name: with one IP and one PTR, EHLO should match the PTR, and per-domain EHLO would hurt more than help.krs._domainkey, mailo._domainkey, include:mailgun.org in the codycodez.com SPF, DMARC rua pointing at Mailgun/OnDMARC, the mailgun route + its stored secret.Both domains use Stalwart's ACME automation (Let's Encrypt, DNS-01 via the Namecheap API). mail.codycodez.com has its own cert; hanna.properties has a wildcard *.hanna.properties. Stalwart selects the cert by SNI (defaultCertificateId stays null).
certificateManagement Manual→Automatic. Expect a gap where the other domain's cert is served (took ~1h once).72.81.141.19, also stored as clientIp in the x:DnsServer object). If the public IP changes, renewals fail silently until the whitelist and clientIp are updated. Let's Encrypt's dns-persist-01 (which removes this dependency) was not yet in production as of June 2026 (blocked on an IETF issue) — recheck; Stalwart already supports the challenge type (DnsPersist01) and already publishes the _validation-persist TXT records.mail-health script prepared, see Outstanding).hanna.properties: auto-publishes dkim, spf, dmarc, tlsRpt. (Deliberately not mx/srv/autoConfig/autoDiscover/mtaSts — they'd revert to mail.codycodez.com.) Stalwart publishes DMARC as p=reject here.codycodez.com: auto-publishes dkim only (added 2026-09-25 — before that its DKIM keys were never in DNS because Mailgun signed instead). mx, spf, dmarc remain hand-maintained (SPF still carries include:mailgun.org; DMARC is p=none).SRV records are not supported by Namecheap); keep srv, autoConfig*, mtaSts out of publishRecords so the sync task doesn't fail repeatedly. autoconfig/autodiscover/mta-sts hostnames would in any case land on NPM, which doesn't route them.setHosts replaces the whole zone and silently drops MX unless EmailType=MX. Use the read-modify-write helper /root/nc_dns.py on SuperPC (dry-run diff by default, verifies exact match after --apply; reads API creds from /root/.namecheap.env, mode 600). Zone snapshots: /root/dns-snapshots/.Web Admin is a schema-driven app over JMAP. Basic auth; GET /api/schema (gzip'd) lists every x:* object; GET /jmap/session gives the accountId (b); POST /jmap/ with ["x:<Type>/get|set", {"accountId":"b",...}, "id"].
x:Account/set create {"@type":"User","name":..,"domainId":..}, then set the password with an update of credentials: {"0": {"@type":"Password","secret":..}} (map keyed "0", not a list, and not at creation time). Aliases are a map keyed "0","1" of {name,domainId,enabled}.x:MtaOutboundStrategy, x:SystemSettings, x:Http) use id "singleton".caa in publishRecords). Its CAA record is bound to Stalwart's ACME account (accounturi=), so it blocks every other Let's Encrypt client — NPM's certbot was refused (CAA record for hanna.properties prevents issuance) and all ~17 NPM-managed *.codycodez.com certificates would have failed to renew from early October 2026. Found and removed on 2026-09-25 (both zones now have no CAA). If CAA is ever wanted, it must list both accounts./usr/local/bin/mail-health + timers mail-health-ip every 5 min and mail-health-check daily 08:15 UTC on LXC 103; config /etc/mail-health/env, root-only). Checks cert expiry (<21 days), disk (>85%), service/port health; follows the public IP (updates Namecheap dynamic DNS for ~20 A records once the per-domain DDNS passwords are filled into the env file; otherwise it just alerts); alerts go by e-mail from alerts@codycodez.com to codyhanna8@gmail.com, de-duplicated 24h. mail-health status prints everything, mail-health test sends a test alert.68.33.104.54 → 72.81.141.19); all ~20 A records in codycodez.com (and mail/email in hanna.properties) are hard-coded and Namecheap shows DDNS disabled on all of them. Namecheap dynamic DNS (a per-domain password from Advanced DNS) is the planned fix._acme-challenge.mail.codycodez.com TXT record from the last issuance (harmless).A (216.198.79.1) and www CNAME of both domains point at Vercel, not the homelab — do not touch them when editing mail records (a past setHosts call once wiped hanna.properties' apex record and it had to be restored).| Date | Change |
|---|---|
| 2026-09-22 | Page created. |
| 2026-09-24 | hanna.properties domain, certificates, Namecheap DNS/ACME integration, POP3S. |
| 2026-09-25 | Replaced Roundcube with Bulwark as the public webmail (in LXC 103, NPM hosts 19/20 repointed to :3000; a mistaken new LXC 104 was created and destroyed, empty). Webmail published through NPM (hosts 19/20). Removed CAA records (they blocked NPM renewals). Installed the mail-health monitor. Decided on Amazon SES as the outbound relay (pending Cody's AWS setup). Found the real routing rule (all outbound → Mailgun); tested and rejected direct sending (Spamhaus PBL); added postmaster@/abuse@ aliases; published codycodez.com DKIM; installed Roundcube webmail + email.* DNS; added mail-user helper; verified Google's POP/Send-as retirement; corrected the "hanna sends directly" and "no backups" errors. |