App Email for the Solo Operator
1. The dedup key IS the idempotency contract (most quotable idea)
- Two different dedup semantics in one codebase, deliberately:
- Tasks dedup against active rows only (partial unique index on
pending/processing) β a completed task can be re-run. - Emails dedup against all rows ever (full unique index on
key) β an email key is sent at most once, ever.
- Tasks dedup against active rows only (partial unique index on
welcome:<email>β can never double-send, even across re-confirms, retries, crashes.team-invite:<token>β re-inviting works because a new invitation mints a new token, hence a new key.- The insight: the builder that constructs the message owns choosing the key, and that choice encodes the business rule about recurrence. Enqueue-anywhere app code genuinely doesn't have to think about duplicates.
- People get this wrong in both directions: welcome emails that re-send on every confirm click, or invites that silently never re-send. Naming the two failure modes makes the design click.
2. Email is testable at every layer without a mocking library
Email is notoriously the untested path β the send is usually buried in a route handler behind a live API client. Three layers, all real code:
- Catalog of typed builders (
welcomeEmail(to)β{to, subject, html, text, key}): pure functions returning an inert message. Rendering, addressing, and key derivation unit-tested with zero infrastructure. buildPostmarkPayloadis pure and reads env at call time (not module load): From/EMAIL_SHUNTredirection/TextBody-omission tested as plain functions. (Module-load env capture is a real bug class β dotenv ordering.)POSTMARK_API_URLenv override β integration tests run the actual delivery loop against a throwawayDeno.serveon port 0: 200 β done, 500 β re-queued with backoff, 422 β dead-letter, stale-processingrecovery. The full retry state machine exercised against a real HTTP boundary, no mocks.- Answers a question people actually search for: "how do you test email?"
3. Sized for a solo operator β the 2am story
- Motivating scenario: transient Postmark blip at 2am must not silently kill a password-reset email on a server nobody is watching. That framing drives every choice.
- Ops surface: admin drawer with the email rendered in a sandboxed iframe (
sandbox=""β scripts inert, "view as the mail client would show it"), attempts counter, one-click retry of dead-lettered rows, failed-work badge on pages you already visit. EMAIL_SHUNTredirects all outbound mail to yourself in dev/staging β dumb, tiny, and saves you from emailing real users from a laptop.- Explicit do-nothing list: no graphile-worker/pg-boss (the bespoke worker is ~250 lines you fully understand), no MJML/react-email (a string template with inline styles), no separate email service. Most queue posts implicitly assume a team and a Datadog account; this assumes one person and a systemd unit.
4. Failure taxonomy worth stating plainly
- 4xx = your request is wrong β dead-letter immediately (retrying won't help)β¦ except 429, which is a rate limit β retry. The 429 carve-out is the detail that shows you've thought about it.
- 5xx / network error β re-queue with
2^attempts-minute backoff, dead-letter after max attempts. - Claiming is single-flight (
UPDATE β¦ WHERE id IN (SELECT β¦ FOR UPDATE SKIP LOCKED) RETURNING) so concurrent instances can't double-send a queued row. - Own the at-least-once tradeoff out loud: a crash between Postmark accepting and the done-mark re-sends the email. Acceptable, bounded, and stated β preempting the HN nitpick turns a gotcha into a strength. Exactly-once would cost 10x the code for a personal app.
Misc smaller bits
- Emails and tasks share one lifecycle vocabulary (
status/attempts/run_after/key) β the review that prompted this found the bespoke email path had none of the retry machinery the task path had; convergence was the fix. - Stale-work recovery runs inside the loop tick, not just at startup β a fast crash-restart (systemd, 5s) otherwise orphans in-flight rows for the process's whole lifetime because they're younger than the staleness window at boot.
- Lineage: distilled from FotoLog's
mailer.queue(key-based dedup, level/ttl dropped as YAGNI) β "second system that got smaller" is a nice arc if the post wants a narrative.