feat(crm): newsletter campaigns behind a newsletters flag (#1264)

Part B of #1264. Flag off by default, so an install that never enables it
gains no route, no nav entry and no way to mass-mail.

A campaign is a body plus a recipient rule. Queueing one writes ordinary
email_queue rows (email_type 'newsletter', origin 'campaign', new
campaign_id), so retry, rendered_html, sent_at and error_message all come
from the existing processor rather than a parallel sender. Throttling
staggers scheduled_at; the processor loop is untouched.

Two rules the service enforces: no raw HTML is ever stored (sanitized on
write and again on render, idempotently), and opt-out is checked at queue
time AND again at send time.

Migration 199 adds email_campaigns, email_campaign_recipients,
email_queue.campaign_id, customer_accounts.marketing_opt_out(_at), and
the newsletters.view / newsletters.send permissions.

Three rounds of external review are folded in, including several that
would otherwise have shipped broken:

- Campaign rows never came due on SQLite. queueEmail writes a Date, which
  the sqlite3 binding stores as epoch ms; ISO text in the same column
  compares as TEXT against an INTEGER, and SQLite orders every INTEGER
  below every TEXT. The feature silently sent nothing there.
- The flag had no Settings card and no sidebar entry, so it could not be
  enabled through the UI at all.
- Consent is per ADDRESS, not per row: two accounts sharing an inbox meant
  unsubscribing stopped one and not the other, at both queue and send time.
- The unsubscribe GET mutated consent, so a mail-security scanner walking
  a campaign could have unsubscribed much of the list. GET now confirms,
  POST acts.
- The rate ceiling is clamped to the queue's real throughput (10/min), so
  the composer's estimate stops being wrong by up to 12x.

Closes #1264
This commit is contained in:
Paul Nothaft
2026-09-04 14:32:31 +02:00
committed by GitHub
parent a7d0972b13
commit fc595409b4
32 changed files with 5044 additions and 28 deletions
@@ -72,6 +72,10 @@ export const DEFAULT_FLAGS: FeatureFlags = {
workflows: false,
// #1074 — off by default is the whole "zero behaviour change" guarantee.
faces: false,
// Newsletter campaigns (migration 199, #1264). Off by default — an
// install that never turns this on never gains a nav entry or a way to
// mass-mail its customers.
newsletters: false,
};
export const FEATURE_FLAGS_QUERY_KEY = ['feature-flags'] as const;
@@ -131,6 +135,9 @@ function applyDependencyRules(flags: FeatureFlags): FeatureFlags {
// NOTE: taxReport is intentionally NOT here anymore — the Tax export
// moved permanently into the Accounting section (its own master).
// future siblings: || out.messaging
// #1264 — newsletters is a Clients child; without it here the staged
// sidebar preview disagrees with the server until Save.
|| out.newsletters
);
return out;
}