Newsletter Module Overview
Double opt-in mailing list and admin promotional campaigns — consent, one-click unsubscribe, and a send that resumes instead of re-sending.
Audience: Product owners, QA, frontend and backend developers Scope: The subscriber list, the campaign composer, and the worker that delivers a campaign
Newsletter Module - Overview
1. What the module is
Two things that share a table and almost nothing else: a public mailing list anyone can join from the storefront footer, and an admin campaign composer that sends one promotional email to everyone who confirmed.
The list is double opt-in. A row exists from the moment somebody types an address into a form,
and that row means nothing on its own — anyone can type anyone's address. Only confirmed_at means
consent, and every send filters on it.
2. The routes
| Surface | Route | Permission |
|---|---|---|
| Customer | POST /api/newsletter/subscribe | — (public) |
| Customer | GET /api/newsletter/confirm?token=… | — (public) |
| Customer | GET /api/newsletter/unsubscribe?token=… | — (public) |
| Admin | GET /api/admin/newsletter/campaigns | Newsletter_READ |
| Admin | GET /api/admin/newsletter/campaigns/:publicId | Newsletter_READ |
| Admin | POST /api/admin/newsletter/campaigns | Newsletter_CREATE |
| Admin | PATCH /api/admin/newsletter/campaigns/:publicId | Newsletter_UPDATE |
| Admin | POST /api/admin/newsletter/campaigns/:publicId/send | Newsletter_UPDATE |
The customer routes are also served under /api/mobile/newsletter/....
There is no customer route that reads the list, and no admin route that deletes a subscriber. Both absences are deliberate — see rule 5 below.
3. The rules that matter
-
confirmed_atis the only thing that counts as consent. It is a timestamp rather than a boolean because "when did they agree" is the question a regulator asks, and a boolean cannot answer it. Every send predicate isconfirmed_at IS NOT NULL AND unsubscribed_at IS NULL, and it is written in exactly one place. -
Unsubscribe works without a login, and never fails loudly.
unsubscribe_tokenis 32 bytes of CSPRNG output carried in every email's one-click link. Requiring authentication would be a compliance failure, not a UX preference — most recipients have no account at all. An unknown token returns success: scanners, previewers and prefetchers follow one-click links, and an error page would be useless to the recipient and a probe oracle for everyone else. -
The subscribe response is deliberately uninformative. It says the same thing whether the address was new, already subscribed, or previously unsubscribed. Anything else turns a public, unauthenticated form into an oracle for "is this person on this shop's list" — a disclosure about a third party, made to whoever typed their address.
-
Sending is RESUMABLE, not idempotent-by-payload. Delivery through the outbox is at-least-once and BullMQ retries on failure, so the send job runs more than once for one campaign as a matter of routine. One
newsletter_deliveryrow per recipient, anduq_newsletter_delivery_campaign_subscriber, are what make a re-run skip whatever already went out. Without them a worker dying at recipient 900 of 1,000 could only resume by sending all 1,000 again. -
A subscriber row is never deleted. Unsubscribing is a timestamp, and the suppression is the record — deleting the row would let a later re-subscribe silently resurrect somebody who asked to be left alone. The unique index on
emailis deliberately not partial onunsubscribed_atfor the same reason: a second row would shadow the suppression. -
recipient_countis frozen at send time. "How many people did this go to" must answer with the number at the moment of sending; recomputing it later from the subscriber table gives a different answer every time somebody joins or leaves, which makes the figure useless for exactly the reporting it exists for. -
The campaign body is not markdown, despite the column name.
body_markdownis rendered block-by-block: blank-line-separated blocks become paragraphs, a block starting##becomes a subheading, and everything is escaped. Running a real markdown parser would emit admin-authored HTML into an email going to every subscriber on the list — a stored-XSS surface with the widest possible blast radius.
4. What it is not
- Not a transactional mailer. Order confirmations, password resets and receipts go through
notifications. This module owns marketing consent, which is a different legal question. - Not a segmentation engine. A campaign goes to the whole confirmed list. There is no audience builder, no tags, and no per-subscriber personalisation beyond the unsubscribe link.
- Not a scheduler.
POST /sendsends now. A future-dated campaign would need a scheduled outbox row, which the outbox already supports but no route exposes.
Page guide
| Page | Read it for |
|---|---|
| Features and flows | The subscribe journey, the campaign lifecycle, and every edge case the design turns on |
| Backend | The schema, the three-phase send, the claim protocol, and why sending is outside the transaction |
| API | All eight endpoints, DTOs and error codes |