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
+93 -5
View File
@@ -991,6 +991,41 @@ async function sendTemplateEmail(to, templateKey, variables) {
}
}
/**
* Send one queued newsletter-campaign row (#1264).
*
* Campaigns carry their own body, so there is no `email_templates` row to
* look up and `sendTemplateEmail` cannot be used. The body is rendered per
* recipient (variables, the recipient's own unsubscribe link, the campaign
* CSS) and handed to the same `sendRawEmail` transport the manual composer
* uses. Returns the `{ html }` shape the queue processor persists into
* `rendered_html`, so a campaign send is as inspectable afterwards as any
* transactional mail.
*/
async function sendCampaignEmail(queueRow, emailData) {
const newsletterService = require('./newsletterService');
const campaign = await db('email_campaigns').where({ id: queueRow.campaign_id }).first();
if (!campaign) {
throw new Error(`Newsletter campaign ${queueRow.campaign_id} not found`);
}
// The customer row may be gone (deleted between queue and send). Fall back
// to the address on the queue row so the mail still goes out addressed to
// someone, with empty personalisation rather than a crash.
const customer = emailData.customerId
? await db('customer_accounts').where({ id: emailData.customerId }).first()
: null;
const { subject, html } = await newsletterService.renderForRecipient(
campaign,
customer || { id: emailData.customerId || null, email: queueRow.recipient_email }
);
const info = await sendRawEmail({ to: queueRow.recipient_email, subject, html });
return { success: true, messageId: info.messageId, html };
}
/**
* Send a fully-composed email (subject + HTML the admin already edited in the
* Messages composer) WITHOUT a template. Used for replies + human-sent document
@@ -1198,11 +1233,38 @@ async function processEmailQueue({ ignoreSchedule = false, limit = 10, onlyId =
emailData.eventId = email.event_id;
}
const sendResult = await sendTemplateEmail(
email.recipient_email,
email.email_type,
emailData
);
// Newsletter campaigns (#1264) have no `email_templates` row — the
// body lives on the campaign. They also get the send-time opt-out
// re-check: a customer who unsubscribed after the campaign was
// queued is skipped here, not mailed.
let sendResult;
if (email.email_type === 'newsletter' && email.campaign_id) {
const newsletterService = require('./newsletterService');
// The batch above was materialised before this loop started. A
// cancel that lands in between deletes the pending rows, but this
// worker still holds them in memory — so without re-reading, up to
// a full batch goes out after the UI says the campaign is
// cancelled. Re-check the row still exists and is still pending.
const stillPending = await db('email_queue')
.where({ id: email.id, status: 'pending' })
.first('id');
if (!stillPending) {
logger.info(`Email ${email.id} skipped — cancelled after the batch was fetched`);
continue;
}
if (await newsletterService.shouldSkipForOptOut(emailData.customerId, email.recipient_email)) {
await newsletterService.markSkippedOptOut(email);
logger.info(`Email ${email.id} skipped — recipient opted out after queueing`);
continue;
}
sendResult = await sendCampaignEmail(email, emailData);
} else {
sendResult = await sendTemplateEmail(
email.recipient_email,
email.email_type,
emailData
);
}
// Mark as sent, persisting the actual rendered HTML for the Project
// Overview email preview (guarded — older installs without migration
@@ -1217,6 +1279,18 @@ async function processEmailQueue({ ignoreSchedule = false, limit = 10, onlyId =
.where('id', email.id)
.update(sentUpdate);
// Campaign bookkeeping (#1264). Best-effort by contract — a failure
// in the audit trail must never turn a delivered email into a
// failed one, so it is logged and swallowed.
if (email.campaign_id) {
try {
await require('./newsletterService')
.recordRecipientResult(email, { status: 'sent' });
} catch (hookError) {
logger.error(`Campaign bookkeeping failed for email ${email.id}:`, hookError);
}
}
result.sent += 1;
logger.info(`Email ${email.id} sent successfully`);
} catch (error) {
@@ -1241,6 +1315,20 @@ async function processEmailQueue({ ignoreSchedule = false, limit = 10, onlyId =
}
}
// Campaign bookkeeping (#1264). Only record a FAILURE once the row
// has exhausted its retries — the same cap the pending query uses.
// Recording it on attempt 1 would mark the recipient failed while
// the queue is still going to retry them, and could flip the whole
// campaign terminal on a transient SMTP blip.
if (email.campaign_id && email.retry_count + 1 >= 3) {
try {
await require('./newsletterService')
.recordRecipientResult(email, { status: 'failed', errorMessage: error.message });
} catch (hookError) {
logger.error(`Campaign bookkeeping failed for email ${email.id}:`, hookError);
}
}
logger.error(`Failed to send email ${email.id}:`, error);
}
}