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:
@@ -916,12 +916,17 @@ app.use('/api/admin/vat-codes', require('./src/routes/adminVatCodes'));
|
||||
app.use('/api/admin/system-health', require('./src/routes/adminSystemHealth'));
|
||||
app.use('/api/admin/dev', require('./src/routes/adminDev'));
|
||||
app.use('/api/admin/transfers', require('./src/routes/adminTransfers'));
|
||||
// Newsletter campaigns (#1264). Flag-gated inside the router.
|
||||
app.use('/api/admin/newsletters', require('./src/routes/adminNewsletters'));
|
||||
app.use('/api/public/quotes', require('./src/routes/publicQuotes'));
|
||||
app.use('/api/public/contracts', require('./src/routes/publicContracts'));
|
||||
// PicTransfer (#997): recipient download + client upload, token-authenticated.
|
||||
app.use('/api/public/transfer', require('./src/routes/publicTransfer'));
|
||||
app.use('/api/public/transfer-upload', require('./src/routes/publicTransferUpload'));
|
||||
app.use('/api/public/payment-check', require('./src/routes/publicPaymentCheck'));
|
||||
// Newsletter unsubscribe (#1264). Deliberately NOT flag-gated: turning the
|
||||
// feature off must not break the links in mail that already went out.
|
||||
app.use('/api/public/newsletter', require('./src/routes/publicNewsletter'));
|
||||
app.use('/api/public/workflow-approvals', require('./src/routes/publicWorkflowApprovals'));
|
||||
app.use('/api/admin/event-types', require('./src/routes/adminEventTypes'));
|
||||
app.use('/api/admin/api-tokens', require('./src/routes/adminApiTokens'));
|
||||
|
||||
Reference in New Issue
Block a user