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
+7
View File
@@ -63,6 +63,11 @@ function transformCustomer(c) {
billingCycleDay: c.billing_cycle_day == null ? 1 : Number(c.billing_cycle_day),
notes: c.notes,
isActive: c.is_active,
// Newsletter consent (migration 199, #1264). Opt-OUT: false means the
// customer still receives campaigns. Transactional mail is unaffected.
marketingOptOut: c.marketing_opt_out === true || c.marketing_opt_out === 1
|| c.marketing_opt_out === '1',
marketingOptOutAt: c.marketing_opt_out_at || null,
// Passive customers (admin-only, no portal access) are identified
// by a null password_hash. We never expose the hash itself —
// this boolean is the only thing the frontend ever sees, and it
@@ -404,6 +409,8 @@ router.put('/:id', [
body('preferred_language').optional({ nullable: true }).isString().isLength({ max: 8 }),
body('notes').optional({ nullable: true }).isString(),
body('is_active').optional().isBoolean(),
// Newsletter consent (migration 199, #1264).
body('marketing_opt_out').optional().isBoolean(),
body('feature_calendar').optional().isBoolean(),
body('feature_quotes').optional().isBoolean(),
body('feature_bills').optional().isBoolean(),
+8
View File
@@ -110,6 +110,11 @@ const KNOWN_FLAGS = [
// the first of two deliberate actions — detection still has to be enabled
// per event. Strictly opt-in.
'faces',
// Newsletter campaigns (migration 199, #1264). Child of `clients` — mass
// marketing mail to customer accounts, with per-customer opt-out and an
// unsubscribe link on every send. Strictly opt-in: an install that never
// turns this on never gains a route, a nav entry or a way to mass-mail.
'newsletters',
];
// Spec defaults for any flag missing from the DB (e.g. a row added by a
@@ -143,6 +148,7 @@ const DEFAULT_FLAGS = {
workflows: false,
// #1074 — off by default is the whole "zero behaviour change" guarantee.
faces: false,
newsletters: false,
};
async function readAllFlags() {
@@ -207,6 +213,8 @@ function applyDependencyRules(flags) {
// (calendarBooking is gated behind `calendar` so adding the parent
// is sufficient.)
|| out.calendar
// Migration 199 (#1264) — newsletter campaigns live under Clients.
|| out.newsletters
// future siblings (out.messaging) go here
);
return out;
+314
View File
@@ -0,0 +1,314 @@
/**
* Admin → Newsletter campaigns (issue #1264, Part B).
*
* Mounted at /api/admin/newsletters. Every route is behind, in order:
* adminAuth → requireFeatureFlag('newsletters') → requirePermission(...)
*
* The flag gate sits ahead of the permission gate on purpose: with the
* feature off, the answer is "this feature is disabled", not "you may not",
* and no permission configuration should change that.
*
* `newsletters.send` is separate from `newsletters.view` because mass mail is
* the one CRM action that cannot be undone once the queue drains.
*/
const express = require('express');
const rateLimit = require('express-rate-limit');
const { body, param, query } = require('express-validator');
const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { requireFeatureFlag } = require('../middleware/requireFeatureFlag');
const {
handleAsync, validateRequest, successResponse, getPagination, paginatedResponse,
} = require('../utils/routeHelpers');
const newsletterService = require('../services/newsletterService');
const router = express.Router();
router.use(adminAuth, requireFeatureFlag('newsletters'));
// A test send goes straight out over SMTP with no queue in between, so it is
// the one route here that can be turned into an outbound mail cannon. Own
// bucket, per admin.
const testLimiter = rateLimit({
windowMs: 60 * 1000,
max: 5,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => `newsletter-test:${req.admin?.id || req.ip}`,
});
/** DB shape → API shape. Narrow, so a new column can't leak by accident. */
function transformCampaign(c) {
if (!c) return null;
return {
id: c.id,
name: c.name,
subject: c.subject,
bodyHtml: c.body_html || '',
bodyCss: c.body_css || '',
language: c.language || 'en',
status: c.status,
recipientMode: c.recipient_mode,
customerIds: parseCustomerIds(c.recipient_filter),
recipientCount: Number(c.recipient_count || 0),
sentCount: Number(c.sent_count || 0),
failedCount: Number(c.failed_count || 0),
sendRatePerMinute: Number(c.send_rate_per_minute || 20),
createdByAdminId: c.created_by_admin_id,
testSentAt: c.test_sent_at,
queuedAt: c.queued_at,
completedAt: c.completed_at,
createdAt: c.created_at,
updatedAt: c.updated_at,
};
}
function parseCustomerIds(raw) {
if (!raw) return [];
try {
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
const ids = Array.isArray(parsed) ? parsed : parsed?.customerIds;
return Array.isArray(ids) ? ids : [];
} catch (_) {
return [];
}
}
function transformRecipient(r) {
return {
id: r.id,
customerAccountId: r.customer_account_id,
email: r.email,
status: r.status,
errorMessage: r.error_message || null,
sentAt: r.sent_at,
createdAt: r.created_at,
};
}
// ---- list / read ----------------------------------------------------------
router.get(
'/',
requirePermission('newsletters.view'),
[query('status').optional().isIn(newsletterService.VALID_STATUSES)],
handleAsync(async (req, res) => {
validateRequest(req);
const q = db('email_campaigns').orderBy('created_at', 'desc').orderBy('id', 'desc');
if (req.query.status) q.where('status', req.query.status);
const rows = await q;
return successResponse(res, { campaigns: rows.map(transformCampaign) });
})
);
router.get(
'/:id',
requirePermission('newsletters.view'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
const campaign = await newsletterService.getCampaign(req.params.id);
const summary = await db('email_campaign_recipients')
.where({ campaign_id: campaign.id })
.select('status')
.count({ count: '*' })
.groupBy('status');
return successResponse(res, {
campaign: transformCampaign(campaign),
recipientSummary: summary.reduce((acc, r) => {
acc[r.status] = Number(r.count);
return acc;
}, {}),
});
})
);
router.get(
'/:id/recipients',
requirePermission('newsletters.view'),
[
param('id').isInt({ min: 1 }),
query('status').optional().isString().isLength({ max: 20 }),
],
handleAsync(async (req, res) => {
validateRequest(req);
await newsletterService.getCampaign(req.params.id); // 404s for an unknown id
const { page, limit, offset } = getPagination(req, { limit: 50 });
const base = () => {
const q = db('email_campaign_recipients').where({ campaign_id: req.params.id });
if (req.query.status) q.andWhere('status', req.query.status);
return q;
};
const [{ count }] = await base().count({ count: '*' });
const rows = await base()
.orderBy('id', 'asc')
.limit(limit)
.offset(offset);
return res.json(paginatedResponse(rows.map(transformRecipient), Number(count), page, limit));
})
);
// ---- write ----------------------------------------------------------------
// Shared body validators. The service re-validates and does the sanitizing —
// these exist to reject obvious garbage with a 400 before it gets there.
const campaignBodyValidators = [
body('name').optional().isString().isLength({ min: 1, max: 120 }),
body('subject').optional().isString().isLength({ min: 1, max: newsletterService.MAX_SUBJECT_LENGTH }),
body('bodyHtml').optional({ values: 'falsy' }).isString()
.isLength({ max: newsletterService.MAX_BODY_BYTES }),
body('bodyCss').optional({ values: 'falsy' }).isString().isLength({ max: 100 * 1024 }),
body('language').optional({ values: 'falsy' }).isString().isLength({ max: 8 }),
body('recipientMode').optional().isIn(newsletterService.VALID_RECIPIENT_MODES),
body('customerIds').optional().isArray(),
body('sendRatePerMinute').optional().isInt({
min: newsletterService.MIN_RATE_PER_MINUTE,
max: newsletterService.MAX_RATE_PER_MINUTE,
}),
];
router.post(
'/',
requirePermission('newsletters.send'),
[
body('name').isString().isLength({ min: 1, max: 120 }),
body('subject').isString().isLength({ min: 1, max: newsletterService.MAX_SUBJECT_LENGTH }),
...campaignBodyValidators,
],
handleAsync(async (req, res) => {
validateRequest(req);
const campaign = await newsletterService.createCampaign(req.body, req.admin.id);
return successResponse(res, { campaign: transformCampaign(campaign) }, 201, 'Campaign created');
})
);
router.put(
'/:id',
requirePermission('newsletters.send'),
[param('id').isInt({ min: 1 }), ...campaignBodyValidators],
handleAsync(async (req, res) => {
validateRequest(req);
const campaign = await newsletterService.updateCampaign(req.params.id, req.body, req.admin.id);
return successResponse(res, { campaign: transformCampaign(campaign) }, 200, 'Campaign updated');
})
);
router.delete(
'/:id',
requirePermission('newsletters.send'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
return successResponse(res, await newsletterService.deleteCampaign(req.params.id, req.admin.id));
})
);
// ---- preview / dry run ----------------------------------------------------
router.post(
'/:id/preview',
requirePermission('newsletters.view'),
[
param('id').isInt({ min: 1 }),
body('customerId').optional({ nullable: true }).isInt({ min: 1 }),
body('language').optional({ values: 'falsy' }).isString().isLength({ max: 8 }),
],
handleAsync(async (req, res) => {
validateRequest(req);
const campaign = await newsletterService.getCampaign(req.params.id);
let customer = null;
if (req.body.customerId) {
customer = await db('customer_accounts').where({ id: req.body.customerId }).first();
}
// Sample data when no real customer is named, so the variables render as
// something legible instead of leaving `{{first_name}}` on screen.
const subject = campaign.subject;
const rendered = await newsletterService.renderForRecipient(
req.body.language ? { ...campaign, language: req.body.language } : campaign,
customer || {
id: null,
email: '[email protected]',
salutation: 'Ms.',
first_name: 'Alex',
last_name: 'Sample',
display_name: 'Alex Sample',
company_name: 'Sample & Co',
preferred_language: req.body.language || campaign.language,
},
{ unsubscribeUrl: '#preview-unsubscribe' }
);
return successResponse(res, {
subject: rendered.subject || subject,
html: rendered.html,
language: rendered.language,
isSample: !customer,
});
})
);
router.post(
'/:id/recipients/resolve',
requirePermission('newsletters.view'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
const campaign = await newsletterService.getCampaign(req.params.id);
const { recipients, skippedOptOut, skippedNoEmail } =
await newsletterService.resolveRecipients(campaign);
// Counts only — the composer needs the number, not 2 000 email addresses.
return successResponse(res, {
recipientCount: recipients.length,
skippedOptOut,
skippedNoEmail,
sendRatePerMinute: newsletterService.clampRate(campaign.send_rate_per_minute),
estimatedMinutes: Math.ceil(
recipients.length / newsletterService.clampRate(campaign.send_rate_per_minute)
),
});
})
);
// ---- send -----------------------------------------------------------------
router.post(
'/:id/test',
requirePermission('newsletters.send'),
testLimiter,
[param('id').isInt({ min: 1 }), body('to').isEmail()],
handleAsync(async (req, res) => {
validateRequest(req);
return successResponse(res,
await newsletterService.sendTest(req.params.id, req.body.to, req.admin.id));
})
);
router.post(
'/:id/queue',
requirePermission('newsletters.send'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
return successResponse(res,
await newsletterService.queueCampaign(req.params.id, req.admin.id), 200, 'Campaign queued');
})
);
router.post(
'/:id/cancel',
requirePermission('newsletters.send'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
return successResponse(res,
await newsletterService.cancel(req.params.id, req.admin.id), 200, 'Campaign cancelled');
})
);
module.exports = router;
+61
View File
@@ -93,6 +93,13 @@ function shapeProfile(row) {
state: row.state,
countryCode: row.country_code,
preferredLanguage: row.preferred_language || 'en',
// Newsletter consent (migration 199, #1264). Read-only here — it is
// changed through /profile/marketing, which logs the consent change
// with its own activity entry rather than burying it in a generic
// profile update.
marketingOptOut: row.marketing_opt_out === true
|| row.marketing_opt_out === 1
|| row.marketing_opt_out === '1',
};
}
@@ -318,6 +325,60 @@ router.put('/profile', [
}
});
/**
* GET /profile/marketing
*
* Newsletter consent, on its own endpoint (migration 199, #1264).
*
* Not folded into PUT /profile because a consent change is an auditable
* event: it needs its own `customer_marketing_opt_out` activity entry with
* the source recorded, and burying it in a 14-field profile update would
* lose that. Transactional mail is unaffected either way, which the response
* says explicitly so the UI never has to guess.
*/
router.get('/profile/marketing', customerAuth, async (req, res) => {
try {
const row = await db('customer_accounts')
.where('id', req.customer.id)
.select('marketing_opt_out', 'marketing_opt_out_at')
.first();
if (!row) return res.status(404).json({ error: 'Profile not found' });
res.json({
marketingOptOut: row.marketing_opt_out === true
|| row.marketing_opt_out === 1
|| row.marketing_opt_out === '1',
marketingOptOutAt: row.marketing_opt_out_at || null,
});
} catch (error) {
errorResponse(res, error, 500, 'Failed to load marketing preferences');
}
});
/**
* PUT /profile/marketing { optOut: boolean }
*/
router.put('/profile/marketing', [
customerAuth,
body('optOut').isBoolean(),
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
const newsletterService = require('../services/newsletterService');
await newsletterService.setMarketingOptOut(
req.customer.id,
Boolean(req.body.optOut),
'portal',
{ type: 'customer', id: req.customer.id, name: req.customer.email }
);
res.json({ marketingOptOut: Boolean(req.body.optOut) });
} catch (error) {
errorResponse(res, error, 500, 'Failed to update marketing preferences');
}
});
/**
* POST /profile/password
*
+144
View File
@@ -0,0 +1,144 @@
/**
* Public → Newsletter unsubscribe (issue #1264, Part B).
*
* Mounted at /api/public/newsletter. No authentication — the signed token in
* the email footer is the only credential, and it must work from a mail
* client with no session, on any device, forever.
*
* The security property this file exists to hold: **the response is identical
* whether or not the id exists.** A valid token, a tampered token, an unknown
* customer and an already-unsubscribed customer all render the same page with
* the same status. There is no lookup by email and no table of tokens, so the
* endpoint offers nothing to enumerate.
*
* Deliberately NOT behind the `newsletters` feature flag: turning the feature
* off must not break the unsubscribe links in mail that already went out.
*/
const express = require('express');
const rateLimit = require('express-rate-limit');
const { param } = require('express-validator');
const logger = require('../utils/logger');
const newsletterService = require('../services/newsletterService');
const router = express.Router();
// Own bucket — a shared limiter with the other public routes would let a
// scraper here eat the quote-preview budget, and vice versa.
const unsubscribeLimiter = rateLimit({
windowMs: 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false,
});
function escapeHtml(text) {
return String(text ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
/**
* A single self-contained page — no scripts, no external assets, no branding
* lookup. A confirmation page that needs the API to be healthy in order to
* render is a confirmation page that fails when it matters.
*/
function page(title, message, formAction) {
const action = formAction
? `
<form method="POST" action="${escapeHtml(formAction)}" style="margin-top:20px;">
<button type="submit" style="background:#5C8762;color:#fff;border:0;border-radius:6px;
padding:12px 24px;font-size:14px;cursor:pointer;">Yes, unsubscribe me</button>
</form>`
: '';
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>${escapeHtml(title)}</title>
<style>
body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center;
background:#f5f5f5; color:#333;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif; }
.card { max-width:480px; margin:20px; padding:40px 32px; background:#fff; border-radius:8px;
text-align:center; box-shadow:0 1px 3px rgba(0,0,0,.08); }
h1 { margin:0 0 12px; font-size:20px; }
p { margin:0; font-size:14px; line-height:22px; color:#666; }
</style>
</head>
<body>
<div class="card">
<h1>${escapeHtml(title)}</h1>
<p>${escapeHtml(message)}</p>${action}
</div>
</body>
</html>`;
}
const OK_TITLE = 'You have been unsubscribed';
const OK_MESSAGE = 'You will no longer receive newsletters from us. '
+ 'Transactional emails about your galleries, quotes and invoices are not affected.';
const CONFIRM_TITLE = 'Unsubscribe from our newsletter?';
const CONFIRM_MESSAGE = 'Confirm below and you will no longer receive newsletters from us. '
+ 'Transactional emails about your galleries, quotes and invoices are not affected.';
// The GET only ASKS. Mail-security scanners, link prefetchers and corporate
// gateways follow every URL in a message before a human ever sees it — a GET
// that mutated consent would unsubscribe much of a campaign's recipient list
// automatically, and the recipients would never know why the mail stopped.
// The state change lives on the POST below, which needs a real click.
router.get(
'/unsubscribe/:token',
unsubscribeLimiter,
[param('token').isString().isLength({ min: 1, max: 512 })],
(req, res) => {
// Rendered for ANY token, valid or not — see the file header. A scanner
// and a real recipient must not be able to tell the difference.
const action = `/api/public/newsletter/unsubscribe/${encodeURIComponent(req.params.token)}`;
return res
.status(200)
.type('html')
.set('Cache-Control', 'no-store')
.set('X-Robots-Tag', 'noindex, nofollow')
.send(page(CONFIRM_TITLE, CONFIRM_MESSAGE, action));
}
);
router.post(
'/unsubscribe/:token',
unsubscribeLimiter,
[param('token').isString().isLength({ min: 1, max: 512 })],
async (req, res) => {
// Every branch answers identically — the anti-enumeration property.
const respond = () => res
.status(200)
.type('html')
.set('Cache-Control', 'no-store')
.set('X-Robots-Tag', 'noindex, nofollow')
.send(page(OK_TITLE, OK_MESSAGE));
try {
const customerId = newsletterService.verifyUnsubscribeToken(req.params.token);
if (customerId === null) {
logger.debug('Newsletter unsubscribe: token rejected');
return respond();
}
await newsletterService.setMarketingOptOut(customerId, true, 'link', {
type: 'customer', id: customerId,
});
return respond();
} catch (error) {
// Even a DB failure answers the same way. Telling the visitor "an error
// occurred" for one id and "done" for another is exactly the oracle the
// identical-response rule removes — the failure goes to the log.
logger.error('Newsletter unsubscribe failed', { error: error.message });
return respond();
}
}
);
module.exports = router;
@@ -565,6 +565,9 @@ async function getCustomerById(id) {
* typo before the customer accepts. Uniqueness is enforced.
*/
async function updateCustomer(id, updates, updatedByAdminId) {
// Set when marketing_opt_out actually flips, so the dedicated consent
// event can be logged after the write lands.
let marketingConsentTransition = null;
const customer = await db('customer_accounts').where('id', id).first();
if (!customer) {
throw new NotFoundError('Customer', id);
@@ -597,6 +600,10 @@ async function updateCustomer(id, updates, updatedByAdminId) {
// in its own branch below so null survives (formatBoolean would coerce
// it to false and silently lose the "inherit" state).
'rebill_attach_proof',
// Newsletter consent (migration 199, #1264). Admin-settable so a
// customer who unsubscribes by phone can be honoured without waiting
// for them to click a link. Transactional mail ignores it entirely.
'marketing_opt_out',
];
for (const f of fields) {
if (updates[f] !== undefined) {
@@ -613,6 +620,25 @@ async function updateCustomer(id, updates, updatedByAdminId) {
|| f === 'skonto_disabled'
) {
allowed[f] = formatBoolean(updates[f]);
} else if (f === 'marketing_opt_out') {
// Only stamp on an actual transition. The customer form submits this
// field on every full-profile save, so saving an unrelated field
// while the customer stayed opted out would move
// marketing_opt_out_at to now — overwriting the moment consent was
// actually withdrawn with the moment someone edited a phone number.
const wasOptedOut = customer.marketing_opt_out === true
|| customer.marketing_opt_out === 1
|| customer.marketing_opt_out === '1';
const nowOptedOut = Boolean(updates[f]);
allowed[f] = formatBoolean(nowOptedOut);
if (wasOptedOut !== nowOptedOut) {
allowed.marketing_opt_out_at = nowOptedOut ? new Date().toISOString() : null;
// Consent changes are designed to be auditable in their own right.
// The generic `customer_updated` entry records only that a field
// named marketing_opt_out was touched — not the new value, and not
// that an admin made the change on the customer's behalf.
marketingConsentTransition = nowOptedOut;
}
} else if (f === 'rebill_attach_proof') {
// Tri-state override. null/'' → NULL (inherit global default);
// otherwise a real boolean (coerced for SQLite).
@@ -680,6 +706,17 @@ async function updateCustomer(id, updates, updatedByAdminId) {
{ type: 'admin', id: updatedByAdminId, name: 'system' }
);
// The dedicated consent event, alongside the generic one. It is what the
// newsletter audit trail reads: the new VALUE and the source, rather than
// just the fact that a field with that name was written (#1264).
if (marketingConsentTransition !== null) {
await logActivity('customer_marketing_opt_out',
{ customerId: id, optOut: marketingConsentTransition, source: 'admin' },
null,
{ type: 'admin', id: updatedByAdminId, name: 'system' }
);
}
return getCustomerById(id);
}
+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);
}
}
+952
View File
@@ -0,0 +1,952 @@
/**
* newsletterService — CRM newsletter campaigns (issue #1264, Part B).
*
* Design in one line: **a campaign is a body plus a recipient rule; queueing
* one writes ordinary `email_queue` rows.** Retry, `rendered_html`, `sent_at`
* and `error_message` therefore come from the existing queue processor rather
* than a parallel sender, and throttling is done by staggering `scheduled_at`
* — the processor loop is untouched.
*
* Two rules the rest of the file exists to enforce:
*
* 1. **No raw HTML is ever stored.** Bodies are sanitized on write and again
* on render. The second pass is cheap and idempotent, and it means a row
* written by an older/buggier version of the sanitizer can't reach a
* recipient unsanitized.
*
* 2. **Opt-out is checked twice** — at queue time and again at send time.
* A customer who unsubscribes in the hour between the two is skipped and
* recorded as `skipped_opt_out`, not mailed.
*/
const crypto = require('crypto');
const sanitizeHtml = require('sanitize-html');
const { db, logActivity } = require('../database/db');
const logger = require('../utils/logger');
const { AppError } = require('../utils/errors');
const { formatBoolean, isPostgreSQL } = require('../utils/dbCompat');
const { sanitizeCSS } = require('../utils/cssSanitizer');
const { timingSafeEqualStr } = require('../utils/timingSafe');
const { getFrontendBaseUrl, getApiBaseUrl } = require('../utils/frontendUrl');
// A 200 KB body is already an absurd newsletter; the cap exists so a paste
// from a WYSIWYG suite full of base64 images can't put a multi-megabyte row
// in front of the sanitizer (and then in every queue row it renders into).
const MAX_BODY_BYTES = 200 * 1024;
const MAX_SUBJECT_LENGTH = 255;
const VALID_STATUSES = ['draft', 'queued', 'sending', 'sent', 'cancelled', 'failed'];
const VALID_RECIPIENT_MODES = ['all_active', 'manual'];
// Rate bounds.
//
// The ceiling is not a policy choice — it is what the queue can actually do.
// `startEmailQueueProcessor` runs `processEmailQueue()` once every 60 s with
// its default `limit = 10`, GLOBALLY across all email types. A campaign
// staggered at 20/min therefore drained at 10/min, and the "about N minutes"
// the composer showed was wrong by up to 12x at the old 120 ceiling.
//
// Clamping to the real throughput makes the number honest. The control still
// earns its place below the ceiling: a shared host capped at 100 mails/hour
// needs ~1/min, which is the case this exists to serve.
const MIN_RATE_PER_MINUTE = 1;
const QUEUE_ROWS_PER_MINUTE = 10; // processEmailQueue: limit 10, every 60 s
const MAX_RATE_PER_MINUTE = QUEUE_ROWS_PER_MINUTE;
const DEFAULT_RATE_PER_MINUTE = QUEUE_ROWS_PER_MINUTE;
// ---------------------------------------------------------------------------
// Sanitizers
// ---------------------------------------------------------------------------
/**
* The email-safe tag/attribute allowlist.
*
* Starts from the allowlist the manual composer already uses
* (`adminEmail.js` POST /send) and adds what a newsletter layout actually
* needs: table tags, `<center>`/`<font>`, and the presentational attributes
* email clients still require because they don't do flexbox.
*
* Not present, on purpose: `script`, `iframe`, `object`, `embed`, `form`,
* `input`, `style` (the tag — a campaign's CSS goes through `body_css`), and
* every `on*` handler. sanitize-html drops unknown attributes, so event
* handlers never need an explicit deny.
*/
const CAMPAIGN_ALLOWED_TAGS = sanitizeHtml.defaults.allowedTags.concat([
'img', 'center', 'font',
'table', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th', 'colgroup', 'col',
]);
const PRESENTATIONAL_ATTRS = [
'align', 'valign', 'width', 'height', 'bgcolor', 'border',
'cellpadding', 'cellspacing', 'colspan', 'rowspan',
];
const CAMPAIGN_ALLOWED_ATTRIBUTES = {
...sanitizeHtml.defaults.allowedAttributes,
a: ['href', 'name', 'target', 'rel', 'style', 'class'],
// No `srcset`: it takes a comma-separated URL list that the scheme filter
// below does not police, which would be a way back to an http: or data:
// source after `src` had been cleaned.
img: ['src', 'alt', 'width', 'height', 'style', 'class', 'align', 'border'],
table: [...PRESENTATIONAL_ATTRS, 'style', 'class', 'role'],
td: [...PRESENTATIONAL_ATTRS, 'style', 'class'],
th: [...PRESENTATIONAL_ATTRS, 'style', 'class'],
tr: [...PRESENTATIONAL_ATTRS, 'style', 'class'],
font: ['color', 'face', 'size'],
'*': ['style', 'class'],
};
/**
* Sanitize a campaign body. Idempotent — safe to run on already-clean HTML,
* which is what lets the render path re-run it as a second line of defence.
*
* @param {string} html raw admin input
* @returns {string} storable HTML
*/
function sanitizeCampaignBody(html) {
if (html === null || html === undefined) return '';
const input = String(html);
if (Buffer.byteLength(input, 'utf8') > MAX_BODY_BYTES) {
throw new AppError(
`Newsletter body exceeds the ${Math.round(MAX_BODY_BYTES / 1024)} KB limit`,
400
);
}
return sanitizeHtml(input, {
allowedTags: CAMPAIGN_ALLOWED_TAGS,
allowedAttributes: CAMPAIGN_ALLOWED_ATTRIBUTES,
// `cid:` is kept for parity with the manual composer (inline attachments).
// `data:` is NOT allowed — a data: image is how an HTML-ish payload gets
// smuggled past a tag allowlist in the clients that render it.
allowedSchemes: ['http', 'https', 'mailto', 'cid'],
allowedSchemesAppliedToAttributes: ['href', 'src'],
// A relative URL in an email is broken anyway (there is no base), and
// allowing it would let `//evil.example` through as protocol-relative.
allowProtocolRelative: false,
// Style attributes survive the tag pass; run their declarations through
// the same CSS sanitizer the <style> block uses so `expression(`,
// `behavior:` and external `url()` are stripped there too.
transformTags: {
a: (tagName, attribs) => ({
tagName,
attribs: {
...attribs,
// Mail clients open links in a browser; noopener/noreferrer costs
// nothing and closes window.opener on the ones that use a tab.
...(attribs.href ? { rel: 'noopener noreferrer' } : {}),
},
}),
},
})
// sanitize-html keeps the style ATTRIBUTE contents verbatim. Clean each.
.replace(/style="([^"]*)"/gi, (match, css) => {
const { sanitized } = sanitizeCSS(css);
const cleaned = stripRemoteCssUrls(sanitized);
return cleaned ? `style="${cleaned.replace(/"/g, '')}"` : '';
});
}
/**
* Remove every `url(...)` that is not an inline data: image.
*
* The shared `sanitizeCSS` *detects* a remote url() and prefixes it with a
* `/* BLOCKED URL *\/` comment — but a CSS comment is stripped during
* tokenization, so the declaration a mail client actually parses still
* carries the live URL. Verified:
*
* sanitizeCSS('.a{background:url(https://x/p.gif)}').sanitized
* → '.a{background:/* BLOCKED URL *\/ url(https://x/p.gif)}'
*
* In a newsletter that is a tracking pixel delivered to every recipient, so
* this pass actually removes the token. Scoped to the newsletter path on
* purpose: the same weakness affects gallery custom CSS, but changing shared
* sanitizer behaviour is a separate change with its own blast radius.
*/
function stripRemoteCssUrls(css) {
if (!css) return '';
return String(css)
.replace(/\/\*\s*BLOCKED URL\s*\*\//gi, '')
.replace(/url\s*\(\s*(['"]?)([^)'"]*)\1\s*\)/gi, (match, _quote, target) =>
(/^data:image\/(?:jpeg|jpg|png|gif|webp)/i.test(target.trim()) ? match : 'none'));
}
/**
* Sanitize a campaign's optional `<style>` block. Delegates to the shared
* cssSanitizer, which already blocks `@import`, `expression(`, `behavior:`,
* `javascript:` and every `url()` that is not a `data:` image.
*
* That is STRICTER than the issue's "https: images only" note — the shared
* sanitizer allows no remote `url()` at all. Kept as-is rather than loosened:
* a remote CSS url() in mail is a tracking pixel by another name, and a
* campaign's images belong in `<img>` tags where the scheme filter sees them.
*
* @returns {{ css: string, warnings: string[] }}
*/
function sanitizeCampaignCss(css) {
if (!css) return { css: '', warnings: [] };
const { sanitized, warnings } = sanitizeCSS(String(css));
return { css: stripRemoteCssUrls(sanitized), warnings };
}
// ---------------------------------------------------------------------------
// Unsubscribe tokens
// ---------------------------------------------------------------------------
const UNSUB_PREFIX = 'newsletter-unsub:';
function unsubSecret() {
const secret = process.env.JWT_SECRET;
if (!secret) throw new AppError('JWT_SECRET is not configured', 500);
return secret;
}
function unsubSignature(customerId) {
return crypto
.createHmac('sha256', unsubSecret())
.update(`${UNSUB_PREFIX}${customerId}`)
.digest('hex');
}
/**
* A signed, non-expiring handle on one customer id.
*
* No table and no lookup by email, so the link carries no enumeration
* surface: an attacker who changes the id gets a signature mismatch, and the
* route answers identically either way.
*/
function unsubscribeToken(customerId) {
const id = Number(customerId);
if (!Number.isInteger(id) || id <= 0) throw new AppError('Invalid customer id', 400);
return Buffer.from(`${id}.${unsubSignature(id)}`, 'utf8').toString('base64url');
}
/** @returns {number|null} the customer id, or null for anything tampered. */
function verifyUnsubscribeToken(token) {
if (typeof token !== 'string' || !token) return null;
let decoded;
try {
decoded = Buffer.from(token, 'base64url').toString('utf8');
} catch (_) {
return null;
}
const dot = decoded.indexOf('.');
if (dot <= 0) return null;
const idPart = decoded.slice(0, dot);
const sigPart = decoded.slice(dot + 1);
if (!/^\d+$/.test(idPart)) return null;
const id = Number(idPart);
if (!Number.isSafeInteger(id) || id <= 0) return null;
return timingSafeEqualStr(sigPart, unsubSignature(id)) ? id : null;
}
async function unsubscribeUrl(customerId) {
// The API base, not the frontend origin: `/public/newsletter/...` is served
// by the backend, and on a split-origin deployment that path does not exist
// on the frontend host.
//
// getApiBaseUrl already ENDS IN /api — it returns `<origin>/api` when
// API_URL is unset, and the documented API_URL values
// (https://photos.example.com/api) include it too. Appending another
// `/api/...` here produced `<origin>/api/api/public/...`, so every
// unsubscribe link 404'd on both same-origin and split-origin installs.
const base = (await getApiBaseUrl()) || `${(await getFrontendBaseUrl()) || ''}/api`;
return `${base}/public/newsletter/unsubscribe/${unsubscribeToken(customerId)}`;
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
/** Minimal attribute escaping for a server-generated URL. */
function escapeAttribute(value) {
return String(value ?? '')
.replace(/&/g, '&amp;').replace(/"/g, '&quot;')
.replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/** Everything a campaign body may interpolate. Absent keys stay literal. */
function recipientVariables(customer, unsubUrl, supportEmail) {
const first = customer.first_name || '';
const last = customer.last_name || '';
const display = customer.display_name || [first, last].filter(Boolean).join(' ').trim();
return {
customer_name: display || customer.company_name || customer.email || '',
first_name: first,
last_name: last,
salutation: customer.salutation || '',
company_name: customer.company_name || '',
support_email: supportEmail || '',
unsubscribe_url: unsubUrl,
};
}
/**
* Render one campaign for one recipient.
*
* Language order is customer → campaign → 'en': `preferred_language` is the
* customer's own setting and beats the campaign default, matching how
* getRecipientLanguage resolves transactional mail.
*
* @returns {{ subject: string, html: string, language: string }}
*/
async function renderForRecipient(campaign, customer, options = {}) {
// Required lazily: emailProcessor requires businessProfileService, and
// pulling it at module scope from here would make the require graph depend
// on load order for no benefit.
const { safeTemplateReplace, wrapEmailHtml, getSupportEmail } = require('./emailProcessor');
const language = customer.preferred_language || campaign.language || 'en';
const unsubUrl = options.unsubscribeUrl
?? (customer.id ? await unsubscribeUrl(customer.id) : '');
const supportEmail = options.supportEmail ?? await getSupportEmail();
const variables = recipientVariables(customer, unsubUrl, supportEmail);
// Second sanitize pass — see the file header. Idempotent, so a body stored
// by an older sanitizer is cleaned again on the way out.
const safeBody = sanitizeCampaignBody(campaign.body_html || '');
// Substitution happens AFTER sanitizing, with escaping on: a customer's own
// company name is untrusted text and must not be able to inject markup by
// riding in through a variable the sanitizer never saw.
const body = safeTemplateReplace(safeBody, variables, { escapeHtml: true });
const subject = safeTemplateReplace(campaign.subject || '', variables);
const { css } = sanitizeCampaignCss(campaign.body_css);
// Inline <style> ahead of the body. wrapEmailHtml emits its own <style> in
// <head>; this one sits in the content cell, which is where the clients
// that keep <style> at all will honour it. Clients that strip it fall back
// to the inline style attributes the sanitizer preserved.
// Every campaign carries an unsubscribe link — that is the promise the
// opt-out design rests on, and a body that simply omits {{unsubscribe_url}}
// must not be able to break it. Appended only when the author did not place
// it themselves, so a deliberate placement still wins.
// The URL is printed as TEXT beside the link, not only as an href: the
// plain-text alternative is derived with htmlToText, which drops <a> tags
// and their href entirely — a text-only recipient would have been left
// with the words "Unsubscribe from these emails" and no way to do it.
const withUnsubscribe = safeBody.includes('{{unsubscribe_url}}')
? body
: `${body}\n<p style="font-size:11px;color:#888888;margin-top:16px;">`
+ `<a href="${escapeAttribute(unsubUrl)}" style="color:#888888;">`
+ `Unsubscribe from these emails</a><br />${escapeAttribute(unsubUrl)}</p>`;
const styled = css ? `<style type="text/css">${css}</style>\n${withUnsubscribe}` : withUnsubscribe;
const html = await wrapEmailHtml(styled, subject, language);
return { subject, html, language };
}
// ---------------------------------------------------------------------------
// Recipients
// ---------------------------------------------------------------------------
const RECIPIENT_COLUMNS = [
'id', 'email', 'salutation', 'first_name', 'last_name',
'display_name', 'company_name', 'preferred_language',
];
/**
* Who this campaign would actually reach.
*
* `skippedOptOut` is reported rather than silently dropped — an operator
* about to mail 2 000 people should see that 43 of them said no.
*
* @returns {{ recipients: object[], skippedOptOut: number, skippedNoEmail: number }}
*/
async function resolveRecipients(campaign, conn = db) {
const ids = parseRecipientIds(campaign);
if (campaign.recipient_mode === 'manual' && ids.length === 0) {
return { recipients: [], skippedOptOut: 0, skippedNoEmail: 0 };
}
const base = () => {
const q = conn('customer_accounts').where('is_active', formatBoolean(true));
if (campaign.recipient_mode === 'manual') q.whereIn('id', ids);
return q;
};
const all = await base().select(RECIPIENT_COLUMNS.concat(['marketing_opt_out']));
const recipients = [];
const seen = new Set();
let skippedOptOut = 0;
let skippedNoEmail = 0;
// Opt-out is decided per ADDRESS, not per row. Two active customer rows can
// share an inbox, and unsubscribing only flips the row whose token was in
// the mail — so filtering row-by-row would skip that one and still deliver
// to the same person through the other. Clicking unsubscribe would appear
// to do nothing.
// Queried across EVERY active customer, not just `all`. In manual mode
// `all` is already narrowed to the selected ids, so an unselected account
// that unsubscribed would not appear — and picking its opted-in twin would
// mail the address that opted out.
const optedOutRows = await conn('customer_accounts')
.where('is_active', formatBoolean(true))
.select('email', 'marketing_opt_out');
const optedOutAddresses = new Set(
optedOutRows.filter(isOptedOut)
.map((row) => (row.email || '').trim().toLowerCase())
.filter(Boolean)
);
for (const row of all) {
const email = (row.email || '').trim().toLowerCase();
if (!email) { skippedNoEmail += 1; continue; }
if (optedOutAddresses.has(email)) {
// Count the address once, however many rows carry it.
if (!seen.has(email)) { skippedOptOut += 1; seen.add(email); }
continue;
}
// Two customer rows can legitimately share a billing address; the same
// person must still receive the newsletter once.
if (seen.has(email)) continue;
seen.add(email);
recipients.push({ ...row, email });
}
return { recipients, skippedOptOut, skippedNoEmail };
}
function isOptedOut(row) {
const v = row.marketing_opt_out;
return v === true || v === 1 || v === '1' || v === 't';
}
function parseRecipientIds(campaign) {
if (campaign.recipient_mode !== 'manual') return [];
const raw = campaign.recipient_filter;
if (!raw) return [];
let parsed;
try {
parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
} catch (_) {
return [];
}
const ids = Array.isArray(parsed) ? parsed : parsed?.customerIds;
if (!Array.isArray(ids)) return [];
return [...new Set(ids.map(Number).filter((n) => Number.isInteger(n) && n > 0))];
}
/**
* A timestamp in the shape `email_queue` comparisons actually use.
*
* `processEmailQueue` selects with `scheduled_at <= now`, binding a JS Date.
* On Postgres that is a timestamp comparison. On SQLite the native binding
* turns a Date into EPOCH MS — which is what `queueEmail` has always written
* and what utils/queueTimestamps.toMillis documents reading back.
*
* Writing an ISO STRING instead put TEXT in a column the processor compares
* against an INTEGER, and SQLite orders every INTEGER below every TEXT — so
* `'2026-09-04T…' <= 1757000000000` is false and a campaign row never came
* due. The whole feature silently sent nothing on SQLite installs, with the
* rows sitting in the queue looking perfectly correct.
*
* A raw number (rather than a Date) on SQLite also sidesteps the jest/sqlite3
* binding landmine documented in CLAUDE.md, where a sandbox-created Date is
* stored as the literal string "[object Object]".
*/
function queueTimestamp(ms) {
return isPostgreSQL() ? new Date(ms) : ms;
}
// ---------------------------------------------------------------------------
// Queueing
// ---------------------------------------------------------------------------
function clampRate(rate) {
const n = parseInt(rate, 10);
if (!Number.isFinite(n)) return DEFAULT_RATE_PER_MINUTE;
return Math.max(MIN_RATE_PER_MINUTE, Math.min(MAX_RATE_PER_MINUTE, n));
}
/**
* Queue a draft campaign: one `email_queue` row per recipient, with
* `scheduled_at` staggered so the send never bursts a provider.
*
* The whole thing is one transaction. A partial queue is the worst possible
* outcome — half a customer list mailed, a campaign stuck in `queued`, and no
* safe way to retry — so either every row lands or none does.
*/
async function queueCampaign(campaignId, adminId) {
const campaign = await getCampaign(campaignId);
if (campaign.status !== 'draft') {
throw new AppError(`Campaign is ${campaign.status}, only a draft can be queued`, 409);
}
if (!campaign.subject || !String(campaign.body_html || '').trim()) {
throw new AppError('Campaign needs a subject and a body before it can be queued', 400);
}
const { recipients, skippedOptOut } = await resolveRecipients(campaign);
if (recipients.length === 0) {
throw new AppError('Campaign has no recipients', 400);
}
const rate = clampRate(campaign.send_rate_per_minute);
const now = Date.now();
const queuedAt = new Date(now).toISOString();
await db.transaction(async (trx) => {
for (let i = 0; i < recipients.length; i += 1) {
const customer = recipients[i];
// Stagger: recipient N goes out in minute floor(N / rate). Everything
// in the first minute is due immediately, so a small campaign behaves
// exactly like any other queued mail.
const scheduledMs = now + Math.floor(i / rate) * 60 * 1000;
const inserted = await trx('email_queue').insert({
recipient_email: customer.email,
email_type: 'newsletter',
email_data: JSON.stringify({ campaignId: campaign.id, customerId: customer.id }),
status: 'pending',
origin: 'campaign',
campaign_id: campaign.id,
// Engine-shaped, not ISO — see queueTimestamp. These two columns are
// the ones processEmailQueue filters and orders on.
created_at: queueTimestamp(now),
scheduled_at: queueTimestamp(scheduledMs),
}).returning('id');
const queueId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
await trx('email_campaign_recipients').insert({
campaign_id: campaign.id,
customer_account_id: customer.id,
email: customer.email,
email_queue_id: queueId,
status: 'queued',
created_at: queuedAt,
});
}
await trx('email_campaigns').where({ id: campaign.id }).update({
status: 'queued',
recipient_count: recipients.length,
sent_count: 0,
failed_count: 0,
send_rate_per_minute: rate,
queued_at: queuedAt,
updated_at: queuedAt,
});
});
await logActivity('newsletter_queued', {
campaignId: campaign.id,
name: campaign.name,
recipients: recipients.length,
skippedOptOut,
sendRatePerMinute: rate,
}, null, { type: 'admin', id: adminId });
logger.info('Newsletter campaign queued', {
campaignId: campaign.id, recipients: recipients.length, rate, adminId,
});
return { queued: recipients.length, skippedOptOut, sendRatePerMinute: rate };
}
/**
* Cancel a campaign: drop the queue rows that have not gone out yet.
*
* Already-sent rows stay exactly as they are — cancelling a campaign cannot
* un-send mail, and pretending otherwise in the counts would be a lie the
* operator might act on.
*/
async function cancel(campaignId, adminId) {
const campaign = await getCampaign(campaignId);
if (!['queued', 'sending'].includes(campaign.status)) {
throw new AppError(`Campaign is ${campaign.status} and cannot be cancelled`, 409);
}
const result = await db.transaction(async (trx) => {
const pending = await trx('email_queue')
.where({ campaign_id: campaign.id, status: 'pending' })
.select('id');
const pendingIds = pending.map((r) => r.id);
if (pendingIds.length > 0) {
await trx('email_queue').whereIn('id', pendingIds).del();
await trx('email_campaign_recipients')
.where({ campaign_id: campaign.id })
.whereIn('email_queue_id', pendingIds)
// Only rows still waiting. A recipient that already exhausted its
// retries has status 'failed' while its queue row sits 'pending' —
// rewriting that to 'cancelled' erased the failure from the audit
// rows while `failed_count`, computed from them, kept counting it.
.whereIn('status', ['queued'])
.update({ status: 'cancelled' });
}
// Counters are derived from the recipient rows, so recompute them here
// rather than leaving a campaign whose failed_count disagrees with its
// own audit trail.
const remaining = await trx('email_campaign_recipients')
.where({ campaign_id: campaign.id })
.select('status');
await trx('email_campaigns').where({ id: campaign.id }).update({
status: 'cancelled',
sent_count: remaining.filter((r) => r.status === 'sent').length,
failed_count: remaining.filter((r) => r.status === 'failed').length,
completed_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
});
return { cancelled: pendingIds.length };
});
await logActivity('newsletter_cancelled', {
campaignId: campaign.id, name: campaign.name, cancelledRows: result.cancelled,
}, null, { type: 'admin', id: adminId });
return result;
}
// ---------------------------------------------------------------------------
// Queue-processor hook
// ---------------------------------------------------------------------------
/**
* Called by `processEmailQueue` for a row carrying `campaign_id`, after the
* send succeeded or failed. Updates the recipient row and rolls the campaign
* counters.
*
* Best-effort by contract: a failure here must never turn a delivered email
* into a failed queue row, so the caller swallows what this throws.
*/
async function recordRecipientResult(queueRow, { status, errorMessage = null } = {}) {
const update = { status };
if (status === 'sent') update.sent_at = new Date().toISOString();
if (errorMessage) update.error_message = String(errorMessage).slice(0, 1000);
await db('email_campaign_recipients')
.where({ campaign_id: queueRow.campaign_id, email_queue_id: queueRow.id })
.update(update);
await recomputeCounts(queueRow.campaign_id);
}
/**
* Roll `sent_count` / `failed_count` from the recipient rows and move the
* campaign to a terminal status once nothing is pending.
*
* Counts are recomputed from the rows rather than incremented, so a retried
* row or a concurrent processor pass can't double-count.
*/
async function recomputeCounts(campaignId) {
const rows = await db('email_campaign_recipients')
.where({ campaign_id: campaignId })
.select('status');
if (rows.length === 0) return null;
const sent = rows.filter((r) => r.status === 'sent').length;
const failed = rows.filter((r) => r.status === 'failed').length;
const stillQueued = rows.filter((r) => r.status === 'queued').length;
const update = {
sent_count: sent,
failed_count: failed,
updated_at: new Date().toISOString(),
};
const campaign = await db('email_campaigns').where({ id: campaignId }).first();
if (!campaign) return null;
if (stillQueued > 0) {
// First result in: the campaign is visibly working.
if (campaign.status === 'queued') update.status = 'sending';
} else if (['queued', 'sending', 'failed'].includes(campaign.status)) {
// `failed` is included so a System Health retry that finally succeeds can
// move the campaign back to `sent`. Without it a campaign stayed marked
// failed even once every recipient had been delivered.
// Everything resolved. `failed` only when NOTHING got through — a
// campaign that reached 1 990 of 2 000 people is a sent campaign with
// ten failures, and calling it "failed" would misdirect the operator.
update.status = sent > 0 ? 'sent' : 'failed';
update.completed_at = new Date().toISOString();
}
await db('email_campaigns').where({ id: campaignId }).update(update);
if (update.status === 'sent' || update.status === 'failed') {
await logActivity('newsletter_completed', {
campaignId, name: campaign.name, sent, failed,
});
}
return { sent, failed, stillQueued, status: update.status || campaign.status };
}
/**
* The send-time opt-out re-check (design rule 2).
*
* @returns {boolean} true when this row must NOT be sent.
*/
async function shouldSkipForOptOut(customerId, recipientEmail = null) {
const row = customerId
? await db('customer_accounts')
.where({ id: customerId })
.select('email', 'marketing_opt_out', 'is_active')
.first()
: null;
if (row) {
if (isOptedOut(row)) return true;
const active = row.is_active;
if (!(active === true || active === 1 || active === '1' || active === 't')) return true;
}
// Consent belongs to the ADDRESS. Another active account sharing this
// inbox may have unsubscribed after the campaign was queued, and that
// click has to stop this mail too — otherwise the person who
// unsubscribed still receives it.
const address = (recipientEmail || row?.email || '').trim().toLowerCase();
if (!address) return false;
const optedOutTwin = await db('customer_accounts')
.whereRaw('LOWER(TRIM(email)) = ?', [address])
.select('marketing_opt_out')
.then((rows) => rows.some(isOptedOut));
return optedOutTwin;
}
/** Mark a row the processor refused to send because consent was withdrawn. */
async function markSkippedOptOut(queueRow) {
await db('email_campaign_recipients')
.where({ campaign_id: queueRow.campaign_id, email_queue_id: queueRow.id })
.update({ status: 'skipped_opt_out' });
await db('email_queue').where({ id: queueRow.id }).update({
status: 'cancelled',
error_message: 'Recipient opted out of marketing email after the campaign was queued',
});
await recomputeCounts(queueRow.campaign_id);
}
// ---------------------------------------------------------------------------
// Opt-out
// ---------------------------------------------------------------------------
/**
* Flip a customer's marketing consent.
*
* @param {'link'|'portal'|'admin'} source where the change came from
* @returns {boolean} whether a row was actually updated
*/
async function setMarketingOptOut(customerId, optOut, source, actor = null) {
const current = await db('customer_accounts')
.where({ id: customerId })
.first('marketing_opt_out');
if (!current) return false;
// Only a real transition counts. An unsubscribe link is followed by mail
// scanners, by prefetchers and by the customer refreshing the page — each
// of which would otherwise overwrite `marketing_opt_out_at` with a later
// time and file another activity row, burying the moment consent was
// actually withdrawn under its own confirmations.
if (isOptedOut(current) === Boolean(optOut)) return false;
await db('customer_accounts').where({ id: customerId }).update({
marketing_opt_out: formatBoolean(Boolean(optOut)),
marketing_opt_out_at: optOut ? new Date().toISOString() : null,
});
await logActivity('customer_marketing_opt_out', {
customerId, optOut: Boolean(optOut), source,
}, null, actor);
return true;
}
// ---------------------------------------------------------------------------
// CRUD
// ---------------------------------------------------------------------------
async function getCampaign(id, conn = db) {
const campaign = await conn('email_campaigns').where({ id }).first();
if (!campaign) throw new AppError('Campaign not found', 404);
return campaign;
}
/** Shape an admin-supplied payload into storable columns. */
function sanitiseCampaignPayload(payload = {}) {
const out = {};
if (payload.name !== undefined) {
const name = String(payload.name || '').trim();
if (!name) throw new AppError('Campaign name is required', 400);
out.name = name.slice(0, 120);
}
if (payload.subject !== undefined) {
const subject = String(payload.subject || '').trim();
if (!subject) throw new AppError('Subject is required', 400);
// CR/LF in a subject is header injection. nodemailer encodes it, but a
// subject with a newline in it is malformed regardless — reject rather
// than silently strip, so the admin sees what happened.
if (/[\r\n]/.test(subject)) throw new AppError('Subject cannot contain line breaks', 400);
if (subject.length > MAX_SUBJECT_LENGTH) {
throw new AppError(`Subject cannot exceed ${MAX_SUBJECT_LENGTH} characters`, 400);
}
out.subject = subject;
}
if (payload.bodyHtml !== undefined) {
out.body_html = sanitizeCampaignBody(payload.bodyHtml);
}
if (payload.bodyCss !== undefined) {
out.body_css = sanitizeCampaignCss(payload.bodyCss).css;
}
if (payload.language !== undefined) {
out.language = String(payload.language || 'en').trim().slice(0, 8) || 'en';
}
if (payload.recipientMode !== undefined) {
const mode = String(payload.recipientMode || '');
if (!VALID_RECIPIENT_MODES.includes(mode)) {
throw new AppError('recipientMode must be all_active or manual', 400);
}
out.recipient_mode = mode;
}
if (payload.customerIds !== undefined) {
const ids = Array.isArray(payload.customerIds)
? [...new Set(payload.customerIds.map(Number).filter((n) => Number.isInteger(n) && n > 0))]
: [];
out.recipient_filter = ids.length ? JSON.stringify({ customerIds: ids }) : null;
}
if (payload.sendRatePerMinute !== undefined) {
out.send_rate_per_minute = clampRate(payload.sendRatePerMinute);
}
return out;
}
async function createCampaign(payload, adminId) {
const data = sanitiseCampaignPayload(payload);
if (!data.name) throw new AppError('Campaign name is required', 400);
if (!data.subject) throw new AppError('Subject is required', 400);
const nowIso = new Date().toISOString();
const inserted = await db('email_campaigns').insert({
status: 'draft',
recipient_mode: 'all_active',
send_rate_per_minute: DEFAULT_RATE_PER_MINUTE,
...data,
created_by_admin_id: adminId || null,
created_at: nowIso,
updated_at: nowIso,
}).returning('id');
const id = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
await logActivity('newsletter_created', { campaignId: id, name: data.name },
null, { type: 'admin', id: adminId });
return await getCampaign(id);
}
async function updateCampaign(id, payload, adminId) {
const campaign = await getCampaign(id);
if (campaign.status !== 'draft') {
throw new AppError('Only a draft campaign can be edited', 409);
}
const data = sanitiseCampaignPayload(payload);
if (Object.keys(data).length === 0) return campaign;
data.updated_at = new Date().toISOString();
await db('email_campaigns').where({ id }).update(data);
await logActivity('newsletter_updated', {
campaignId: id, fields: Object.keys(data).filter((k) => k !== 'updated_at'),
}, null, { type: 'admin', id: adminId });
return await getCampaign(id);
}
async function deleteCampaign(id, adminId) {
const campaign = await getCampaign(id);
if (!['draft', 'cancelled'].includes(campaign.status)) {
throw new AppError(`A ${campaign.status} campaign cannot be deleted`, 409);
}
// A cancelled campaign may still have reached people before it was stopped.
// email_campaign_recipients cascades on delete, so removing the campaign
// would erase the only durable record of who received it — the record that
// outlives queue pruning and answers "did this person get that mail?".
const [{ delivered }] = await db('email_campaign_recipients')
.where({ campaign_id: id, status: 'sent' })
.count({ delivered: '*' });
if (Number(delivered) > 0) {
throw new AppError(
`This campaign already reached ${delivered} recipient(s) and cannot be deleted`,
409
);
}
// Recipient rows cascade; queue rows for a cancelled campaign were already
// deleted by cancel(), and sent ones are history that stays in the queue.
await db('email_campaigns').where({ id }).del();
await logActivity('newsletter_deleted', { campaignId: id, name: campaign.name },
null, { type: 'admin', id: adminId });
return { deleted: true };
}
/**
* Send one test copy, rendered with sample data, without touching the queue
* or the recipient table. `test_sent_at` is stamped so the list can show that
* a campaign was proofed before it went out.
*/
async function sendTest(campaignId, toEmail, adminId) {
const campaign = await getCampaign(campaignId);
const { sendRawEmail } = require('./emailProcessor');
const sample = {
id: null,
email: toEmail,
salutation: 'Ms.',
first_name: 'Alex',
last_name: 'Sample',
display_name: 'Alex Sample',
company_name: 'Sample & Co',
preferred_language: campaign.language,
};
// No real customer id, so no real unsubscribe token — the test mail gets a
// dead link rather than one that would opt a stranger out.
const { subject, html } = await renderForRecipient(campaign, sample, {
unsubscribeUrl: `${(await getFrontendBaseUrl()) || ''}/api/public/newsletter/unsubscribe/test`,
});
await sendRawEmail({ to: toEmail, subject: `[Test] ${subject}`, html });
await db('email_campaigns').where({ id: campaignId }).update({
test_sent_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
});
await logActivity('newsletter_test_sent', { campaignId, to: toEmail },
null, { type: 'admin', id: adminId });
return { sent: true };
}
module.exports = {
sanitizeCampaignBody,
sanitizeCampaignCss,
unsubscribeToken,
verifyUnsubscribeToken,
unsubscribeUrl,
renderForRecipient,
resolveRecipients,
queueCampaign,
cancel,
recordRecipientResult,
recomputeCounts,
shouldSkipForOptOut,
markSkippedOptOut,
setMarketingOptOut,
getCampaign,
createCampaign,
updateCampaign,
deleteCampaign,
sendTest,
// Exported for the routes' validators and the tests.
clampRate,
MAX_BODY_BYTES,
MAX_SUBJECT_LENGTH,
MIN_RATE_PER_MINUTE,
MAX_RATE_PER_MINUTE,
DEFAULT_RATE_PER_MINUTE,
VALID_STATUSES,
VALID_RECIPIENT_MODES,
};