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