Newsletter Backend
The schema, the three-phase send, the claim protocol, and why the sending happens outside the transaction.
Audience: Backend developers Scope: Implementation. Behaviour is on the features page.
Newsletter - Backend
1. Documentation evidence
| Claim source | Path |
|---|---|
| Schema | packages/db/src/schema/newsletter/newsletter.ts |
| Migrations | packages/db/src/migrations/0025_newsletter.sql, 0026_newsletter_delivery_claim.sql, 0027_newsletter_suppression_history.sql |
| Constraint probes | packages/db/src/scripts/probe-0025-newsletter.sql (16/16), probe-0027-newsletter-suppression.sql (10/10) |
| Job contract | packages/jobs/src/index.ts — NewsletterJob, QueueName.NEWSLETTER |
| Integration coverage | newsletter-send-campaign.int.spec.ts (19), newsletter-customer.int.spec.ts (15) |
2. Scope and boundaries
Owns
newsletter_subscriber,newsletter_campaign,newsletter_delivery- The consent predicate, in exactly one place
- The three jobs on
QueueName.NEWSLETTER - Two email templates, composed from the shared design system
Does not own
- Email delivery.
EmailNotificationService(notifications) owns the provider. This module hands it a rendered message and records what happened. - The email design system.
common/email/owns the layout, the components and every hex literal. A module owns its copy, never its styling. - Scheduling. The outbox and BullMQ own dispatch; this module writes intent.
3. File and directory map
apps/api/src/modules/newsletter/
├── newsletter.module.ts root — imports the two leaves
├── newsletter-worker.module.ts the async surface, registered separately
├── admin/campaign/ compose, edit, send
├── customer/ subscribe, confirm, unsubscribe
├── emails/ the two templates
├── shared/ link builder, constants
└── workers/
├── newsletter-queue.processor.ts THE only @Processor on this queue
├── newsletter-send-campaign.processor.ts
├── newsletter-send-confirmation.processor.ts
├── newsletter-sweep-stalled.processor.ts
└── newsletter-maintenance.scheduler.tsNewsletterWorkerModule is registered separately from NewsletterModule in app.module.ts, so the
HTTP surface and the worker surface can be deployed and reasoned about independently — matching
PromotionWorkerModule and OutboxWorkerModule.
4. Data model
4.1 newsletter_subscriber
| Column | Notes |
|---|---|
email | Lowercased and trimmed by the service. chk_..._email_lowercase makes forgetting it a write failure rather than a duplicate |
confirmed_at | Consent. A timestamp, not a boolean — "when did they agree" is the question asked after a complaint |
unsubscribed_at | Current state. Cleared by a revive |
last_unsubscribed_at, unsubscribe_count | Append-only. The revive path does not clear them |
confirmation_token | Single-use, cleared on confirmation |
unsubscribe_token | Stable, permanent, notNull. 32 bytes of CSPRNG output |
uq_newsletter_subscriber_email is deliberately not partial on unsubscribed_at. A partial
index would let a second row shadow an unsubscribe; the re-subscribe must find and revive the
existing row, because that row carries the original creation date and the suppression history.
Why the history columns exist separately from unsubscribed_at. The revive path is reachable by
any unauthenticated caller who knows an address, and it clears unsubscribed_at. On its own that
column answers "are they currently unsubscribed", never "did they ever ask to be left alone, and
when" — and only the second question is asked after a complaint. A CHECK enforces that a currently
unsubscribed row carries its history, so the two cannot drift into "unsubscribed now, never
unsubscribed".
The email format check is deliberately loose: strict email regexes reject valid addresses, and the authoritative check is that the confirmation email arrives.
4.2 newsletter_campaign
recipient_count is frozen at send time. Two CHECKs make the counts self-consistent:
sent_count + failed_count <= recipient_count
status <> 'sent' OR sent_at IS NOT NULLThe first one is load-bearing far beyond tidiness — see §6.
4.3 newsletter_delivery
One row per (campaign, subscriber), and
uq_newsletter_delivery_campaign_subscriber is the entire resume strategy. Without it a worker
that dies at recipient 900 of 1,000 can only resume by sending all 1,000 again.
| Column | Notes |
|---|---|
status | pending → sent | failed | suppressed |
claimed_at | NULL means unclaimed. See §5 |
failure_reason | Provider text, truncated. chk_... rejects it on any status but failed |
idx_newsletter_delivery_pending on (campaign_id, id) WHERE status = 'pending' serves both the
claim query and the sweep.
5. The claim protocol
Sending is network I/O against a third party, and it must not happen inside a transaction.
┌─ claim transaction (short) ─────────────────────────────┐
│ SELECT id … WHERE status='pending' │
│ AND (claimed_at IS NULL OR claimed_at < now() - 15min) │
│ ORDER BY id LIMIT 50 FOR UPDATE SKIP LOCKED │
│ UPDATE … SET claimed_at = now() │
│ SELECT the recipients (no lock on subscriber) │
└─────────────────────── COMMIT ───────────────────────────┘
│
▼ outside any transaction
send, then settle each row individuallyWhy not just hold FOR UPDATE across the send. Two options, both wrong. One transaction for the
whole batch means a failure at recipient 40 rolls back the status of the 39 emails that were
genuinely delivered — and the retry sends them again. One transaction per recipient holds a lock
across every send anyway.
Why the subscriber rows are not locked. The claim reads recipient details after the claim, in
a plain select. Locking them would block unsubscribe, which is the one operation that must never
wait on a send in progress.
The bias is toward not sending twice. A crash between the provider accepting a message and the
row reaching sent leaves it claimed and pending; the reclaim only happens after the stale window.
A duplicate promotional email is a complaint; a missing one is not.
6. The three phases
Materialise
One INSERT … SELECT … ON CONFLICT DO NOTHING per batch, inside one transaction holding
pg_advisory_xact_lock on the campaign id, with the remaining allowance re-read from the live row
count inside the same statement.
Both of those are the fix for the same defect, and it is worth stating precisely because it survived
a fully green gate. The cap used to be a local counter decremented by each pass's own insert count,
which is not a cap once two runs overlap. With recipient_count frozen at 1,000 and the confirmed
set since grown to 1,500:
- A inserts subscribers 1–1000, its local counter hits zero, it stops.
- B read
existing = 0before A committed. Its first batch conflicts entirely, so its insert count is 0, its counter never moves, and its cursor advances past 1000 — batch two inserts 1001–1500.
finalise would then write sent + failed = 1500 against recipient_count = 1000, which
chk_newsletter_campaign_counts_within_recipients rejects as a 23514, on every retry, forever.
The campaign wedges in sending and 500 people outside the frozen set have been mailed.
The cursor is seeded from max(subscriber_id) already materialised rather than restarting at 0,
and the scan limit is the full batch rather than the remaining allowance. Conflating those two meant
a resume with one row left scanned one already-inserted candidate per statement — roughly 200,000
round trips on a list that size.
Claim and send
Per §5. Consent is re-asserted per recipient — both halves, confirmed_at IS NOT NULL AND unsubscribed_at IS NULL. Checking only the second half is exploitable: subscribe clears both
timestamps on revive and is public, so anyone who knows an address can move a pending delivery's
subscriber from "unsubscribed" to "unconfirmed" mid-campaign.
A provider rejection is recorded on the row and the loop continues. The catch narrows with
error instanceof Error — a thrown string or null would otherwise raise a TypeError that escapes
the try/catch that exists precisely so one bad address cannot abort the send. The log carries the
delivery id only; provider text routinely echoes the recipient address, and the application log has
different retention from the subscriber table.
Finalise
Counts the terminal rows and closes the campaign — only once nothing is pending. Two workers can
be sending the same campaign after a stale reclaim, and the first to run out of claimable rows would
otherwise freeze sent_count at a wrong number and tell an admin the campaign is finished.
7. BullMQ and async work
| Job | Enqueued by | Notes |
|---|---|---|
SEND_CAMPAIGN | The outbox, in the send transaction | Resumable. Dedupe key newsletter-send:{publicId} |
SEND_CONFIRMATION | The outbox, in the subscribe transaction | Dedupe key is a sha256 fingerprint of the token |
SWEEP_STALLED_CAMPAIGNS | A five-minute cron | The recovery path |
Exactly one class carries @Processor(QueueName.NEWSLETTER). Two decorated classes on one queue
name are two independent workers racing for every job, and the loser's handler returns quietly on a
name mismatch while BullMQ marks the job completed. That happened on QueueName.INVENTORY with four
decorated classes; roughly three of four jobs were dropped and every gate stayed green.
Why the confirmation dedupe key is a token fingerprint. Keying on the subscriber would dedupe
forever: somebody who never received the first email could ask again and again and the outbox would
swallow every attempt. Keying on the raw token would copy a bearer secret into outbox_events.
Why the sweep exists. The send job is resumable, which is only useful if something resumes it. A
job that exhausts its BullMQ attempts, or returns after its own pass ceiling, leaves the campaign in
sending with pending rows and nothing scheduled — and the admin surface cannot help, because
send refuses a non-draft by design. The sweep calls the send handler directly rather than
implementing a second resume path.
The cron enqueues directly rather than through the outbox: a tick has no accompanying database write, which is the first of the two stated outbox exemptions.
8. Security and abuse controls
| Surface | Control |
|---|---|
POST /subscribe | AUTH_OTP_REQUEST_GUEST — 3/hour, keyed on ip+device. Every accepted request can cost a real send |
GET /confirm, GET /unsubscribe | PUBLIC_READ |
| Tokens in URLs | LoggingInterceptor redacts token from logged query strings |
| Campaign body | Escaped block-by-block; no markdown parser, so no admin-authored HTML reaches an inbox |
| Enumeration | Subscribe and unsubscribe answer identically for every input state |
9. Testing and validation
| Command | Covers |
|---|---|
pnpm --filter @happy-shop/api test | Email builders, the link service, block rendering and its escaping |
pnpm --filter @happy-shop/api test:int | The send worker and the subscribe flow against a real PostgreSQL |
psql … -f probe-0025-newsletter.sql | 16 assertions, both directions |
psql … -f probe-0027-newsletter-suppression.sql | 10 assertions, both directions |
The integration suites cover, among others: two concurrent materialisation passes respecting the
frozen cap, a resume that does not rescan, consent cleared mid-campaign, a provider throwing a
non-Error, stale-claim reclaim while leaving a fresh claim alone, and the unique index in both
directions — the accept case matters, because a unique index on subscriber_id alone would pass
every rejection test and make it impossible to ever mail anyone twice.
10. Operational runbook
A campaign is stuck in sending. Check for pending deliveries:
SELECT status, count(*) FROM newsletter_delivery
WHERE campaign_id = (SELECT id FROM newsletter_campaign WHERE public_id = '…')
GROUP BY status;If rows are pending and unclaimed for over fifteen minutes, the sweep will resume it within five.
If they are freshly claimed, a worker is on it — wait.
Nobody is receiving anything. Almost always zero confirmed subscribers:
SELECT count(*) FROM newsletter_subscriber
WHERE confirmed_at IS NOT NULL AND unsubscribed_at IS NULL;If that is zero and rows exist, the confirmation email is not arriving — check RESEND_API_KEY.
Without it EmailNotificationService logs instead of delivering, which is deliberate and is what
makes the whole path testable before anyone supplies a key.
A complaint about an unwanted email. source says how they got on the list, confirmed_at says
when they agreed, and unsubscribe_count / last_unsubscribed_at say whether they had ever asked
to leave before.