Happy House - Ecommerce Docs
Developer ResourcesNewsletter

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

SurfaceRoutePermission
CustomerPOST /api/newsletter/subscribe— (public)
CustomerGET /api/newsletter/confirm?token=…— (public)
CustomerGET /api/newsletter/unsubscribe?token=…— (public)
AdminGET /api/admin/newsletter/campaignsNewsletter_READ
AdminGET /api/admin/newsletter/campaigns/:publicIdNewsletter_READ
AdminPOST /api/admin/newsletter/campaignsNewsletter_CREATE
AdminPATCH /api/admin/newsletter/campaigns/:publicIdNewsletter_UPDATE
AdminPOST /api/admin/newsletter/campaigns/:publicId/sendNewsletter_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

  1. confirmed_at is 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 is confirmed_at IS NOT NULL AND unsubscribed_at IS NULL, and it is written in exactly one place.

  2. Unsubscribe works without a login, and never fails loudly. unsubscribe_token is 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.

  3. 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.

  4. 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_delivery row per recipient, and uq_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.

  5. 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 email is deliberately not partial on unsubscribed_at for the same reason: a second row would shadow the suppression.

  6. recipient_count is 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.

  7. The campaign body is not markdown, despite the column name. body_markdown is 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 /send sends now. A future-dated campaign would need a scheduled outbox row, which the outbox already supports but no route exposes.

Page guide

PageRead it for
Features and flowsThe subscribe journey, the campaign lifecycle, and every edge case the design turns on
BackendThe schema, the three-phase send, the claim protocol, and why sending is outside the transaction
APIAll eight endpoints, DTOs and error codes