ADR-144: Self-host the delivery queue; outsource only the email last-mile reputation¶
- Status: Accepted, then REVISED 2026-08-04 (post-advisor review — see the revision note below). The original "Listmonk + Amazon SES" decision is superseded; the sections below are retained for history. Where they conflict with the revision, the revision wins.
- Date: 2026-08-04
- Authors: Marko Dragoljevic, Claude (Opus 4.8)
- Related: RFC-110 (delivery design), ADR-145 (the seam this delivers over), PRD-046, PRD-035 Principle 4 (bridge-only audio)
- Tracking: epic #1413 / infra slice #1412
Revision 2026-08-04 — supersedes Listmonk + SES (post-advisor, operator-ratified)¶
The finalized infra design (issue #1412 DESIGN UPDATE) changes the mechanism, not the principle (self-host what we run well; outsource only sending reputation):
- Listmonk DROPPED. The app outbox already is the self-hosted queue this ADR wanted —
source of truth, restart-safe, dedupe-on-
id. A second queue + its subscriber/suppression DB would duplicate and race the app consent store (the seam's declared SSOT) → email a user who already unsubscribed. The app consent store is the only suppression authority. - SES → Resend (operator ruled out AWS). Last-mile reputation via the Resend HTTP API (HTTPS 443), delivered by a thin stateless worker rendering Jinja templates. HTTP not SMTP → the port-25 concern is moot. Bounce/complaint via cursor-based polling of Resend's events API (no public webhook).
- Homelab, tailnet-only, no public ingress (via
tailscale serve, like GlitchTip) — not the VPS public Caddy edge (ADR-114), which only fronts VPS-loopback apps. The "vhost+port on the shared edge" note below no longer applies. - Secrets via the homelab
secrets.sops.env+bootstrap.shconvention (Resend API key + VAPID private key; VAPID key backed up at generation). Resend is the one new runtime dep.
The rest of this ADR (the Web-Push-self-hosted decision, the deliverability reasoning, the alternatives) stands.
Context¶
PRD-046 closes the delivery loop: a per-user digest + resurfacing nudges over Web Push and email. That forces a build-vs-buy call for the delivery mechanism, and the two channels are not symmetric:
- Web Push has no reputation problem. A server with a VAPID keypair signs payloads and hands them to the browser's push endpoints (FCM/Mozilla/Apple). Fully self-hostable, zero third party, zero ongoing cost.
- Email has a reputation problem that is the actual hard part. Running an MTA is trivial; landing in the inbox is not. Self-hosted senders fail on IP reputation: residential ISPs commonly block port 25 outright; cloud/VPS IPs are frequently pre-blocklisted and need weeks of warmup; and Gmail/Outlook silently spam-folder anything without correct SPF + DKIM + DMARC and a warmed IP.
The operator's steer (2026-08-04): host the queue/manager locally (Listmonk), use a service for the last mile that comes with reputation. This ADR records that split and why it's the right boundary — so future contributors don't "simplify" it back into either a pure-SaaS call or a fully self-hosted MTA.
Decision¶
[SUPERSEDED — see the Revision note above.] Items 2–4 (Listmonk + SES + Caddy edge) are no longer the architecture; the app outbox is the queue and email goes via Resend. Item 1 (Web Push self-hosted) still stands. Retained below for history.
- Web Push: fully self-hosted. Generate + store a VAPID keypair; run a push worker in our own infra. No third-party push provider.
- Email queue/management: self-hosted Listmonk, deployed behind the shared Caddy edge (ADR-114, the GlitchTip pattern — vhost + port per app on the homelab).
- Email last-mile: relay through Amazon SES (SMTP smarthost) for sending reputation. SES is the cheapest path with real deliverability and minimal lock-in (standard SMTP; swappable).
- Do not direct-send email from a homelab/VPS IP.
- Secrets (SES credentials, VAPID private key) via the existing secret mechanism — never committed. SES is a new runtime dependency and is accepted as such under this ADR.
The dividing principle: self-host the mechanism we can actually run well; outsource only the one thing that is genuinely hard to self-host — sending reputation.
Consequences¶
Positive
- Deliverability (the hard part) is handled by a provider built for it; the digest lands in inboxes.
- We own the queue, templates, subscriber data, and Web Push end-to-end — most of the stack stays local and swappable; SES is a thin, replaceable edge.
- Web Push ships with no new dependency and no reputation risk, so the loop can be de-risked before email is wired.
Negative / trade-off
- SES is a new external dependency + a small recurring cost (~$0.10/1,000 emails) and an AWS account surface. Accepted: it's the narrowest possible outsourcing (last mile only) and SMTP-standard, so migrating relays later is low-effort.
- Two channels = two delivery code paths (push worker + Listmonk/SES relay) behind the one seam.
Neutral
- Listmonk is a self-hosted service to operate (backups, upgrades) like GlitchTip already is — same operational pattern, no new class of work.
- Email deliverability requires DNS work (SPF/DKIM/DMARC for
closelistening.app) owned by the infra slice (#1412), independent of app work.
Alternatives considered¶
- Pure SaaS (Resend / Postmark) called directly from the app. Zero infra, best time-to-first-send. Rejected: couples product code to a SaaS + its reputation, puts a mail dependency in the app's CI, gives away subscriber data + template control, and offers no clean delegation boundary. The operator explicitly preferred self-hosting the manageable parts.
- Fully self-hosted MTA sending direct (Maddy / Postfix / Haraka), no relay. Maximal control, zero per-email cost. Rejected for real users: IP-reputation/warmup/blocklist tax and ongoing deliverability firefighting. Acceptable only for dogfooding-to-self, not production sends.
- Relay through the domain's existing mailbox provider (Google Workspace / Fastmail SMTP). Free within limits and fine at today's tiny volume, but TOS discourages automated bulk and sending caps bite as users grow. Kept as a possible interim for early dogfooding; SES is the durable choice.
- Third-party Web Push provider (e.g. OneSignal). Rejected: Web Push is trivially self-hostable with VAPID; a provider adds a dependency + data-sharing for no benefit.