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:
@@ -0,0 +1,658 @@
|
||||
/**
|
||||
* Newsletter campaigns — render, recipients, queue, processor hook (#1264).
|
||||
*
|
||||
* These run against a real (temp SQLite) DB because the interesting parts of
|
||||
* this feature are all row-level: which customers are selected, what
|
||||
* `scheduled_at` values get written, whether the transaction rolls back, and
|
||||
* whether an opt-out that lands AFTER queueing still stops the send.
|
||||
*/
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
describe('newsletter campaigns', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let adminId;
|
||||
let newsletterService;
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'newsletter-test-secret';
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId } = await seedMinimal(db));
|
||||
newsletterService = require('../../src/services/newsletterService');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('email_campaign_recipients').del();
|
||||
await db('email_queue').del();
|
||||
await db('email_campaigns').del();
|
||||
await db('customer_accounts').del();
|
||||
});
|
||||
|
||||
async function seedCustomer(overrides = {}) {
|
||||
const row = {
|
||||
email: `c${Math.random().toString(36).slice(2, 10)}@example.com`,
|
||||
first_name: 'Alex',
|
||||
last_name: 'Sample',
|
||||
display_name: 'Alex Sample',
|
||||
company_name: 'Sample & Co',
|
||||
preferred_language: 'en',
|
||||
is_active: 1,
|
||||
marketing_opt_out: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
const [id] = await db('customer_accounts').insert(row).returning('id');
|
||||
return { id: typeof id === 'object' ? id.id : id, ...row };
|
||||
}
|
||||
|
||||
async function seedCampaign(overrides = {}) {
|
||||
return await newsletterService.createCampaign({
|
||||
name: 'Spring news',
|
||||
subject: 'Our spring offers',
|
||||
bodyHtml: '<p>Hi {{first_name}}, welcome!</p>',
|
||||
...overrides,
|
||||
}, adminId);
|
||||
}
|
||||
|
||||
// ---- recipients --------------------------------------------------------
|
||||
|
||||
describe('resolveRecipients', () => {
|
||||
it('selects active, opted-in customers with an email', async () => {
|
||||
await seedCustomer({ email: '[email protected]' });
|
||||
const campaign = await seedCampaign();
|
||||
|
||||
const { recipients, skippedOptOut } = await newsletterService.resolveRecipients(campaign);
|
||||
|
||||
expect(recipients.map((r) => r.email)).toEqual(['[email protected]']);
|
||||
expect(skippedOptOut).toBe(0);
|
||||
});
|
||||
|
||||
it('skips opted-out customers and counts them', async () => {
|
||||
await seedCustomer({ email: '[email protected]' });
|
||||
await seedCustomer({ email: '[email protected]', marketing_opt_out: 1 });
|
||||
const campaign = await seedCampaign();
|
||||
|
||||
const { recipients, skippedOptOut } = await newsletterService.resolveRecipients(campaign);
|
||||
|
||||
expect(recipients.map((r) => r.email)).toEqual(['[email protected]']);
|
||||
expect(skippedOptOut).toBe(1);
|
||||
});
|
||||
|
||||
it('skips inactive customers', async () => {
|
||||
await seedCustomer({ email: '[email protected]' });
|
||||
await seedCustomer({ email: '[email protected]', is_active: 0 });
|
||||
const campaign = await seedCampaign();
|
||||
|
||||
const { recipients } = await newsletterService.resolveRecipients(campaign);
|
||||
expect(recipients.map((r) => r.email)).toEqual(['[email protected]']);
|
||||
});
|
||||
|
||||
it('collapses duplicate addresses so one person is mailed once', async () => {
|
||||
await seedCustomer({ email: '[email protected]' });
|
||||
await seedCustomer({ email: '[email protected]' });
|
||||
const campaign = await seedCampaign();
|
||||
|
||||
const { recipients } = await newsletterService.resolveRecipients(campaign);
|
||||
expect(recipients).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('scopes a manual campaign to the named ids only', async () => {
|
||||
const a = await seedCustomer({ email: '[email protected]' });
|
||||
await seedCustomer({ email: '[email protected]' });
|
||||
const campaign = await seedCampaign({
|
||||
recipientMode: 'manual', customerIds: [a.id],
|
||||
});
|
||||
|
||||
const { recipients } = await newsletterService.resolveRecipients(campaign);
|
||||
expect(recipients.map((r) => r.email)).toEqual(['[email protected]']);
|
||||
});
|
||||
|
||||
it('still honours opt-out inside a manual id list', async () => {
|
||||
const a = await seedCustomer({ email: '[email protected]', marketing_opt_out: 1 });
|
||||
const campaign = await seedCampaign({ recipientMode: 'manual', customerIds: [a.id] });
|
||||
|
||||
const { recipients, skippedOptOut } = await newsletterService.resolveRecipients(campaign);
|
||||
expect(recipients).toHaveLength(0);
|
||||
expect(skippedOptOut).toBe(1);
|
||||
});
|
||||
|
||||
it('skips every row sharing an opted-out address', async () => {
|
||||
// #1285 review: unsubscribing flips only the row whose token was in the
|
||||
// mail. Filtering row-by-row skipped that one and still delivered to
|
||||
// the same inbox through the duplicate — so the link appeared to do
|
||||
// nothing.
|
||||
await seedCustomer({ email: '[email protected]', marketing_opt_out: 1 });
|
||||
await seedCustomer({ email: '[email protected]', marketing_opt_out: 0 });
|
||||
await seedCustomer({ email: '[email protected]' });
|
||||
const campaign = await seedCampaign();
|
||||
|
||||
const { recipients, skippedOptOut } = await newsletterService.resolveRecipients(campaign);
|
||||
|
||||
expect(recipients.map((r) => r.email)).toEqual(['[email protected]']);
|
||||
// Counted once for the address, not once per row.
|
||||
expect(skippedOptOut).toBe(1);
|
||||
});
|
||||
|
||||
it('honours an opted-out twin that is NOT in the manual selection', async () => {
|
||||
// The opted-out set is queried across every active customer, not just
|
||||
// the selected ids — otherwise picking the opted-in twin of an
|
||||
// unsubscribed account mails the address that opted out.
|
||||
await seedCustomer({ email: '[email protected]', marketing_opt_out: 1 });
|
||||
const selected = await seedCustomer({ email: '[email protected]', marketing_opt_out: 0 });
|
||||
const campaign = await seedCampaign({
|
||||
recipientMode: 'manual', customerIds: [selected.id],
|
||||
});
|
||||
|
||||
const { recipients } = await newsletterService.resolveRecipients(campaign);
|
||||
expect(recipients).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns nobody for a manual campaign with no ids', async () => {
|
||||
await seedCustomer();
|
||||
const campaign = await seedCampaign({ recipientMode: 'manual', customerIds: [] });
|
||||
|
||||
const { recipients } = await newsletterService.resolveRecipients(campaign);
|
||||
expect(recipients).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- rendering ---------------------------------------------------------
|
||||
|
||||
describe('renderForRecipient', () => {
|
||||
it('substitutes the customer variables', async () => {
|
||||
const customer = await seedCustomer({ first_name: 'Jamie', email: '[email protected]' });
|
||||
const campaign = await seedCampaign({ bodyHtml: '<p>Hi {{first_name}} at {{company_name}}</p>' });
|
||||
|
||||
const { html } = await newsletterService.renderForRecipient(campaign, customer);
|
||||
|
||||
expect(html).toContain('Hi Jamie at Sample & Co');
|
||||
});
|
||||
|
||||
it('escapes customer data on substitution', async () => {
|
||||
// A customer's own company name is untrusted text — it must not be
|
||||
// able to inject markup by riding in through a variable.
|
||||
const customer = await seedCustomer({ company_name: '<script>alert(1)</script>' });
|
||||
const campaign = await seedCampaign({ bodyHtml: '<p>{{company_name}}</p>' });
|
||||
|
||||
const { html } = await newsletterService.renderForRecipient(campaign, customer);
|
||||
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
expect(html).toContain('<script>');
|
||||
});
|
||||
|
||||
it('resolves {{#if}} blocks', async () => {
|
||||
const withCompany = await seedCustomer({ company_name: 'Acme' });
|
||||
const without = await seedCustomer({ company_name: null });
|
||||
const campaign = await seedCampaign({
|
||||
bodyHtml: '<p>Hi{{#if company_name}} from {{company_name}}{{/if}}!</p>',
|
||||
});
|
||||
|
||||
expect((await newsletterService.renderForRecipient(campaign, withCompany)).html)
|
||||
.toContain('Hi from Acme!');
|
||||
expect((await newsletterService.renderForRecipient(campaign, without)).html)
|
||||
.toContain('Hi!');
|
||||
});
|
||||
|
||||
it('includes a working unsubscribe URL for the recipient', async () => {
|
||||
const customer = await seedCustomer();
|
||||
const campaign = await seedCampaign({ bodyHtml: '<p><a href="{{unsubscribe_url}}">Stop</a></p>' });
|
||||
|
||||
const { html } = await newsletterService.renderForRecipient(campaign, customer);
|
||||
|
||||
const match = html.match(/\/api\/public\/newsletter\/unsubscribe\/([A-Za-z0-9_-]+)/);
|
||||
expect(match).not.toBeNull();
|
||||
expect(newsletterService.verifyUnsubscribeToken(match[1])).toBe(customer.id);
|
||||
});
|
||||
|
||||
it('appends an unsubscribe link when the body omits the placeholder', async () => {
|
||||
// The opt-out design rests on every campaign carrying the link; a body
|
||||
// that simply leaves out {{unsubscribe_url}} must not break it.
|
||||
const customer = await seedCustomer();
|
||||
const campaign = await seedCampaign({ bodyHtml: '<p>No link in here</p>' });
|
||||
|
||||
const { html } = await newsletterService.renderForRecipient(campaign, customer);
|
||||
|
||||
const match = html.match(/\/api\/public\/newsletter\/unsubscribe\/([A-Za-z0-9_-]+)/);
|
||||
expect(match).not.toBeNull();
|
||||
expect(newsletterService.verifyUnsubscribeToken(match[1])).toBe(customer.id);
|
||||
});
|
||||
|
||||
it('does not double up when the body places the link itself', async () => {
|
||||
const customer = await seedCustomer();
|
||||
const campaign = await seedCampaign({
|
||||
bodyHtml: '<p><a href="{{unsubscribe_url}}">Stop</a></p>',
|
||||
});
|
||||
|
||||
const { html } = await newsletterService.renderForRecipient(campaign, customer);
|
||||
|
||||
const links = html.match(/\/api\/public\/newsletter\/unsubscribe\//g) || [];
|
||||
expect(links).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('prefers the customer language over the campaign language', async () => {
|
||||
const german = await seedCustomer({ preferred_language: 'de' });
|
||||
const campaign = await seedCampaign({ language: 'en' });
|
||||
|
||||
expect((await newsletterService.renderForRecipient(campaign, german)).language).toBe('de');
|
||||
});
|
||||
|
||||
it('falls back to the campaign language when the customer has none', async () => {
|
||||
const customer = await seedCustomer({ preferred_language: null });
|
||||
const campaign = await seedCampaign({ language: 'de' });
|
||||
|
||||
expect((await newsletterService.renderForRecipient(campaign, customer)).language).toBe('de');
|
||||
});
|
||||
|
||||
it('inlines the sanitized CSS into the body', async () => {
|
||||
const customer = await seedCustomer();
|
||||
const campaign = await seedCampaign({ bodyCss: '.cta { color: #fff; }' });
|
||||
|
||||
const { html } = await newsletterService.renderForRecipient(campaign, customer);
|
||||
expect(html).toContain('.cta { color: #fff; }');
|
||||
});
|
||||
|
||||
it('re-sanitizes a body that was stored unsanitized', async () => {
|
||||
// Simulates a row written by an older/buggier version: the render path
|
||||
// must not trust what is in the column.
|
||||
const customer = await seedCustomer();
|
||||
const campaign = await seedCampaign();
|
||||
await db('email_campaigns').where({ id: campaign.id })
|
||||
.update({ body_html: '<p>hi</p><script>alert(1)</script>' });
|
||||
const tainted = await newsletterService.getCampaign(campaign.id);
|
||||
|
||||
const { html } = await newsletterService.renderForRecipient(tainted, customer);
|
||||
expect(html).not.toContain('alert(1)');
|
||||
});
|
||||
|
||||
it('wraps the body in the standard email chrome', async () => {
|
||||
const customer = await seedCustomer();
|
||||
const campaign = await seedCampaign();
|
||||
|
||||
const { html } = await newsletterService.renderForRecipient(campaign, customer);
|
||||
expect(html).toContain('<!DOCTYPE html>');
|
||||
expect(html).toContain('email-footer');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- unsubscribe tokens ------------------------------------------------
|
||||
|
||||
describe('unsubscribe tokens', () => {
|
||||
it('round-trips a customer id', () => {
|
||||
const token = newsletterService.unsubscribeToken(4242);
|
||||
expect(newsletterService.verifyUnsubscribeToken(token)).toBe(4242);
|
||||
});
|
||||
|
||||
it('rejects a tampered signature', () => {
|
||||
const token = newsletterService.unsubscribeToken(1);
|
||||
const decoded = Buffer.from(token, 'base64url').toString('utf8');
|
||||
const tampered = Buffer.from(decoded.replace(/.$/, 'f'), 'utf8').toString('base64url');
|
||||
expect(newsletterService.verifyUnsubscribeToken(tampered)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects another customer's id spliced onto a valid signature", () => {
|
||||
const token = newsletterService.unsubscribeToken(1);
|
||||
const sig = Buffer.from(token, 'base64url').toString('utf8').split('.')[1];
|
||||
const forged = Buffer.from(`2.${sig}`, 'utf8').toString('base64url');
|
||||
expect(newsletterService.verifyUnsubscribeToken(forged)).toBeNull();
|
||||
});
|
||||
|
||||
it.each([['', 'empty'], ['not-a-token', 'garbage'], ['!!!', 'non-base64']])
|
||||
('rejects %s (%s)', (token) => {
|
||||
expect(newsletterService.verifyUnsubscribeToken(token)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects null and non-strings', () => {
|
||||
expect(newsletterService.verifyUnsubscribeToken(null)).toBeNull();
|
||||
expect(newsletterService.verifyUnsubscribeToken(undefined)).toBeNull();
|
||||
expect(newsletterService.verifyUnsubscribeToken(123)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- queueing ----------------------------------------------------------
|
||||
|
||||
describe('queueCampaign', () => {
|
||||
it('writes one queue row and one recipient row per recipient', async () => {
|
||||
await seedCustomer({ email: '[email protected]' });
|
||||
await seedCustomer({ email: '[email protected]' });
|
||||
const campaign = await seedCampaign();
|
||||
|
||||
const result = await newsletterService.queueCampaign(campaign.id, adminId);
|
||||
|
||||
expect(result.queued).toBe(2);
|
||||
const queue = await db('email_queue').where({ campaign_id: campaign.id });
|
||||
expect(queue).toHaveLength(2);
|
||||
expect(queue.every((r) => r.email_type === 'newsletter')).toBe(true);
|
||||
expect(queue.every((r) => r.origin === 'campaign')).toBe(true);
|
||||
expect(queue.every((r) => r.status === 'pending')).toBe(true);
|
||||
expect(await db('email_campaign_recipients').where({ campaign_id: campaign.id }))
|
||||
.toHaveLength(2);
|
||||
});
|
||||
|
||||
it('staggers scheduled_at by the send rate', async () => {
|
||||
for (let i = 0; i < 5; i += 1) await seedCustomer({ email: `r${i}@example.com` });
|
||||
const campaign = await seedCampaign({ sendRatePerMinute: 2 });
|
||||
|
||||
await newsletterService.queueCampaign(campaign.id, adminId);
|
||||
|
||||
const rows = await db('email_queue')
|
||||
.where({ campaign_id: campaign.id }).orderBy('id', 'asc');
|
||||
const minutes = rows.map((r) => Math.round(
|
||||
(new Date(r.scheduled_at).getTime() - new Date(rows[0].scheduled_at).getTime()) / 60000
|
||||
));
|
||||
// 2 per minute → minute 0, 0, 1, 1, 2.
|
||||
expect(minutes).toEqual([0, 0, 1, 1, 2]);
|
||||
});
|
||||
|
||||
it('writes queue timestamps in the engine shape the processor compares', async () => {
|
||||
// #1285 review: storing ISO TEXT in a column the processor compares
|
||||
// against a Date-bound value meant SQLite never matched the row — every
|
||||
// INTEGER sorts below every TEXT — so campaigns silently sent nothing
|
||||
// on SQLite installs.
|
||||
//
|
||||
// The due-predicate itself CANNOT be exercised here: under jest a
|
||||
// sandbox-created Date binds as a string (the landmine documented in
|
||||
// CLAUDE.md), so `INTEGER <= TEXT` is trivially true and every row
|
||||
// reads as due whatever the fix does. So assert the stored SHAPE
|
||||
// against the production shape utils/queueTimestamps documents for
|
||||
// this engine instead.
|
||||
await seedCustomer({ email: '[email protected]' });
|
||||
const campaign = await seedCampaign();
|
||||
await newsletterService.queueCampaign(campaign.id, adminId);
|
||||
|
||||
const [row] = await db('email_queue').where({ campaign_id: campaign.id });
|
||||
|
||||
// SQLite: epoch ms, exactly what queueEmail's Date becomes through the
|
||||
// native binding. Never an ISO string, which is what regressed.
|
||||
expect(typeof row.scheduled_at).toBe('number');
|
||||
expect(typeof row.created_at).toBe('number');
|
||||
expect(Number.isFinite(row.scheduled_at)).toBe(true);
|
||||
// Still a sane instant, not a truncated or NaN value.
|
||||
expect(Math.abs(row.scheduled_at - Date.now())).toBeLessThan(120000);
|
||||
});
|
||||
|
||||
it('clamps an absurd send rate', async () => {
|
||||
await seedCustomer();
|
||||
const campaign = await seedCampaign({ sendRatePerMinute: 100000 });
|
||||
|
||||
const result = await newsletterService.queueCampaign(campaign.id, adminId);
|
||||
expect(result.sendRatePerMinute).toBe(newsletterService.MAX_RATE_PER_MINUTE);
|
||||
});
|
||||
|
||||
it('moves the campaign to queued and records the recipient count', async () => {
|
||||
await seedCustomer();
|
||||
const campaign = await seedCampaign();
|
||||
|
||||
await newsletterService.queueCampaign(campaign.id, adminId);
|
||||
|
||||
const after = await newsletterService.getCampaign(campaign.id);
|
||||
expect(after.status).toBe('queued');
|
||||
expect(after.recipient_count).toBe(1);
|
||||
expect(after.queued_at).toBeTruthy();
|
||||
});
|
||||
|
||||
it('refuses to queue a campaign twice', async () => {
|
||||
await seedCustomer();
|
||||
const campaign = await seedCampaign();
|
||||
await newsletterService.queueCampaign(campaign.id, adminId);
|
||||
|
||||
await expect(newsletterService.queueCampaign(campaign.id, adminId))
|
||||
.rejects.toMatchObject({ statusCode: 409 });
|
||||
});
|
||||
|
||||
it('refuses to queue with no recipients', async () => {
|
||||
const campaign = await seedCampaign();
|
||||
await expect(newsletterService.queueCampaign(campaign.id, adminId))
|
||||
.rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('refuses to queue an empty body', async () => {
|
||||
await seedCustomer();
|
||||
const campaign = await seedCampaign({ bodyHtml: '' });
|
||||
await expect(newsletterService.queueCampaign(campaign.id, adminId))
|
||||
.rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('leaves nothing behind when the transaction fails', async () => {
|
||||
await seedCustomer({ email: '[email protected]' });
|
||||
await seedCustomer({ email: '[email protected]' });
|
||||
const campaign = await seedCampaign();
|
||||
|
||||
// Force the second insert to fail: a unique (campaign_id,
|
||||
// customer_account_id) row already exists for one of them.
|
||||
const [{ id: firstId }] = await db('customer_accounts').select('id').orderBy('id').limit(1);
|
||||
await db('email_campaign_recipients').insert({
|
||||
campaign_id: campaign.id, customer_account_id: firstId,
|
||||
email: '[email protected]', status: 'queued', created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await expect(newsletterService.queueCampaign(campaign.id, adminId)).rejects.toThrow();
|
||||
|
||||
// A partial queue — half a customer list mailed — is the outcome the
|
||||
// transaction exists to prevent.
|
||||
expect(await db('email_queue').where({ campaign_id: campaign.id })).toHaveLength(0);
|
||||
expect((await newsletterService.getCampaign(campaign.id)).status).toBe('draft');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- cancel ------------------------------------------------------------
|
||||
|
||||
describe('cancel', () => {
|
||||
it('removes pending rows and leaves sent ones alone', async () => {
|
||||
await seedCustomer({ email: '[email protected]' });
|
||||
await seedCustomer({ email: '[email protected]' });
|
||||
const campaign = await seedCampaign();
|
||||
await newsletterService.queueCampaign(campaign.id, adminId);
|
||||
|
||||
// Pretend the first one already went out.
|
||||
const rows = await db('email_queue').where({ campaign_id: campaign.id }).orderBy('id');
|
||||
await db('email_queue').where({ id: rows[0].id })
|
||||
.update({ status: 'sent', sent_at: new Date().toISOString() });
|
||||
await db('email_campaign_recipients')
|
||||
.where({ campaign_id: campaign.id, email_queue_id: rows[0].id })
|
||||
.update({ status: 'sent' });
|
||||
|
||||
const result = await newsletterService.cancel(campaign.id, adminId);
|
||||
|
||||
expect(result.cancelled).toBe(1);
|
||||
const remaining = await db('email_queue').where({ campaign_id: campaign.id });
|
||||
expect(remaining).toHaveLength(1);
|
||||
expect(remaining[0].status).toBe('sent');
|
||||
expect((await newsletterService.getCampaign(campaign.id)).status).toBe('cancelled');
|
||||
});
|
||||
|
||||
it('refuses to delete a cancelled campaign that already reached someone', async () => {
|
||||
// The recipient rows cascade, and they are the only durable record of
|
||||
// who received the mail once queue rows are pruned (#1285 review).
|
||||
await seedCustomer({ email: '[email protected]' });
|
||||
await seedCustomer({ email: '[email protected]' });
|
||||
const campaign = await seedCampaign();
|
||||
await newsletterService.queueCampaign(campaign.id, adminId);
|
||||
const rows = await db('email_queue').where({ campaign_id: campaign.id }).orderBy('id');
|
||||
await db('email_queue').where({ id: rows[0].id })
|
||||
.update({ status: 'sent', sent_at: new Date().toISOString() });
|
||||
await db('email_campaign_recipients')
|
||||
.where({ campaign_id: campaign.id, email_queue_id: rows[0].id })
|
||||
.update({ status: 'sent' });
|
||||
await newsletterService.cancel(campaign.id, adminId);
|
||||
|
||||
await expect(newsletterService.deleteCampaign(campaign.id, adminId))
|
||||
.rejects.toMatchObject({ statusCode: 409 });
|
||||
});
|
||||
|
||||
it('still deletes a cancelled campaign that reached nobody', async () => {
|
||||
await seedCustomer({ email: '[email protected]' });
|
||||
const campaign = await seedCampaign();
|
||||
await newsletterService.queueCampaign(campaign.id, adminId);
|
||||
await newsletterService.cancel(campaign.id, adminId);
|
||||
|
||||
await expect(newsletterService.deleteCampaign(campaign.id, adminId))
|
||||
.resolves.toEqual({ deleted: true });
|
||||
});
|
||||
|
||||
it('refuses to cancel a draft', async () => {
|
||||
const campaign = await seedCampaign();
|
||||
await expect(newsletterService.cancel(campaign.id, adminId))
|
||||
.rejects.toMatchObject({ statusCode: 409 });
|
||||
});
|
||||
});
|
||||
|
||||
// ---- processor bookkeeping --------------------------------------------
|
||||
|
||||
describe('recordRecipientResult / recomputeCounts', () => {
|
||||
it('moves the campaign to sending on the first result, then to sent', async () => {
|
||||
await seedCustomer({ email: '[email protected]' });
|
||||
await seedCustomer({ email: '[email protected]' });
|
||||
const campaign = await seedCampaign();
|
||||
await newsletterService.queueCampaign(campaign.id, adminId);
|
||||
const rows = await db('email_queue').where({ campaign_id: campaign.id }).orderBy('id');
|
||||
|
||||
await newsletterService.recordRecipientResult(rows[0], { status: 'sent' });
|
||||
expect((await newsletterService.getCampaign(campaign.id)).status).toBe('sending');
|
||||
|
||||
await newsletterService.recordRecipientResult(rows[1], { status: 'sent' });
|
||||
const done = await newsletterService.getCampaign(campaign.id);
|
||||
expect(done.status).toBe('sent');
|
||||
expect(done.sent_count).toBe(2);
|
||||
expect(done.completed_at).toBeTruthy();
|
||||
});
|
||||
|
||||
it('counts a partial failure as a sent campaign, not a failed one', async () => {
|
||||
await seedCustomer({ email: '[email protected]' });
|
||||
await seedCustomer({ email: '[email protected]' });
|
||||
const campaign = await seedCampaign();
|
||||
await newsletterService.queueCampaign(campaign.id, adminId);
|
||||
const rows = await db('email_queue').where({ campaign_id: campaign.id }).orderBy('id');
|
||||
|
||||
await newsletterService.recordRecipientResult(rows[0], { status: 'sent' });
|
||||
await newsletterService.recordRecipientResult(rows[1], {
|
||||
status: 'failed', errorMessage: 'mailbox full',
|
||||
});
|
||||
|
||||
const done = await newsletterService.getCampaign(campaign.id);
|
||||
expect(done.status).toBe('sent');
|
||||
expect(done.sent_count).toBe(1);
|
||||
expect(done.failed_count).toBe(1);
|
||||
});
|
||||
|
||||
it('marks the campaign failed only when nothing got through', async () => {
|
||||
await seedCustomer({ email: '[email protected]' });
|
||||
const campaign = await seedCampaign();
|
||||
await newsletterService.queueCampaign(campaign.id, adminId);
|
||||
const [row] = await db('email_queue').where({ campaign_id: campaign.id });
|
||||
|
||||
await newsletterService.recordRecipientResult(row, { status: 'failed', errorMessage: 'nope' });
|
||||
expect((await newsletterService.getCampaign(campaign.id)).status).toBe('failed');
|
||||
});
|
||||
|
||||
it('does not double-count a repeated result', async () => {
|
||||
await seedCustomer({ email: '[email protected]' });
|
||||
const campaign = await seedCampaign();
|
||||
await newsletterService.queueCampaign(campaign.id, adminId);
|
||||
const [row] = await db('email_queue').where({ campaign_id: campaign.id });
|
||||
|
||||
await newsletterService.recordRecipientResult(row, { status: 'sent' });
|
||||
await newsletterService.recordRecipientResult(row, { status: 'sent' });
|
||||
|
||||
expect((await newsletterService.getCampaign(campaign.id)).sent_count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- send-time opt-out re-check ---------------------------------------
|
||||
|
||||
describe('send-time opt-out', () => {
|
||||
it('skips a customer who unsubscribed after the campaign was queued', async () => {
|
||||
const customer = await seedCustomer({ email: '[email protected]' });
|
||||
const campaign = await seedCampaign();
|
||||
await newsletterService.queueCampaign(campaign.id, adminId);
|
||||
const [row] = await db('email_queue').where({ campaign_id: campaign.id });
|
||||
|
||||
// The gap this closes: consent withdrawn between queue and send.
|
||||
await newsletterService.setMarketingOptOut(customer.id, true, 'link');
|
||||
expect(await newsletterService.shouldSkipForOptOut(customer.id)).toBe(true);
|
||||
|
||||
await newsletterService.markSkippedOptOut(row);
|
||||
|
||||
const recipient = await db('email_campaign_recipients')
|
||||
.where({ campaign_id: campaign.id }).first();
|
||||
expect(recipient.status).toBe('skipped_opt_out');
|
||||
expect((await db('email_queue').where({ id: row.id }).first()).status).toBe('cancelled');
|
||||
});
|
||||
|
||||
it('skips a customer deactivated after queueing', async () => {
|
||||
const customer = await seedCustomer();
|
||||
await db('customer_accounts').where({ id: customer.id }).update({ is_active: 0 });
|
||||
expect(await newsletterService.shouldSkipForOptOut(customer.id)).toBe(true);
|
||||
});
|
||||
|
||||
it('skips when another account on the same address opted out', async () => {
|
||||
// Consent belongs to the address: a click by the twin has to stop this
|
||||
// mail too, or the person who unsubscribed still receives it.
|
||||
const queued = await seedCustomer({ email: '[email protected]', marketing_opt_out: 0 });
|
||||
await seedCustomer({ email: '[email protected]', marketing_opt_out: 1 });
|
||||
|
||||
expect(await newsletterService.shouldSkipForOptOut(queued.id, '[email protected]'))
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
it('does not skip an ordinary opted-in customer', async () => {
|
||||
const customer = await seedCustomer();
|
||||
expect(await newsletterService.shouldSkipForOptOut(customer.id)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- opt-out column ----------------------------------------------------
|
||||
|
||||
describe('setMarketingOptOut', () => {
|
||||
it('stamps a timestamp when opting out and clears it when opting back in', async () => {
|
||||
const customer = await seedCustomer();
|
||||
|
||||
await newsletterService.setMarketingOptOut(customer.id, true, 'link');
|
||||
let row = await db('customer_accounts').where({ id: customer.id }).first();
|
||||
expect(row.marketing_opt_out).toBeTruthy();
|
||||
expect(row.marketing_opt_out_at).toBeTruthy();
|
||||
|
||||
await newsletterService.setMarketingOptOut(customer.id, false, 'admin');
|
||||
row = await db('customer_accounts').where({ id: customer.id }).first();
|
||||
expect(row.marketing_opt_out).toBeFalsy();
|
||||
expect(row.marketing_opt_out_at).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores a repeated opt-out and preserves the original timestamp', async () => {
|
||||
// #1285 review: link scanners, prefetchers and refreshes all re-hit an
|
||||
// unsubscribe URL. Rewriting the timestamp each time buries the moment
|
||||
// consent was actually withdrawn, and files a duplicate activity row.
|
||||
const customer = await seedCustomer();
|
||||
await newsletterService.setMarketingOptOut(customer.id, true, 'link');
|
||||
const first = await db('customer_accounts').where({ id: customer.id }).first();
|
||||
|
||||
const second = await newsletterService.setMarketingOptOut(customer.id, true, 'link');
|
||||
|
||||
expect(second).toBe(false);
|
||||
const after = await db('customer_accounts').where({ id: customer.id }).first();
|
||||
expect(after.marketing_opt_out_at).toBe(first.marketing_opt_out_at);
|
||||
|
||||
const logs = (await db('activity_logs')
|
||||
.where({ activity_type: 'customer_marketing_opt_out' }))
|
||||
.filter((row) => JSON.parse(row.metadata).customerId === customer.id);
|
||||
expect(logs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reports no-op for an unknown customer', async () => {
|
||||
expect(await newsletterService.setMarketingOptOut(999999, true, 'link')).toBe(false);
|
||||
});
|
||||
|
||||
it('writes an activity log entry naming the source', async () => {
|
||||
const customer = await seedCustomer();
|
||||
await newsletterService.setMarketingOptOut(customer.id, true, 'portal');
|
||||
|
||||
const log = await db('activity_logs')
|
||||
.where({ activity_type: 'customer_marketing_opt_out' }).orderBy('id', 'desc').first();
|
||||
expect(JSON.parse(log.metadata).source).toBe('portal');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* HTTP contract for the newsletter admin surface (#1264).
|
||||
*
|
||||
* Three gates stack on every route, and the ORDER matters: auth, then the
|
||||
* feature flag, then the permission. With the feature off the answer must be
|
||||
* "disabled", not "forbidden" — otherwise an install that never enabled
|
||||
* newsletters leaks the fact that the endpoint exists and is permission-gated.
|
||||
*
|
||||
* `newsletters.send` is deliberately separate from `newsletters.view`: mass
|
||||
* mail is the one action here that cannot be taken back.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-newsletters-'));
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'newsletter-route-secret';
|
||||
|
||||
const request = require('supertest');
|
||||
const {
|
||||
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
|
||||
} = require('../integration/helpers/crmDb');
|
||||
|
||||
describe('admin newsletters routes', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
let adminId;
|
||||
let superToken;
|
||||
let viewerToken;
|
||||
|
||||
const MOUNT = '/api/admin/newsletters';
|
||||
const auth = (token) => ({ Authorization: `Bearer ${token}` });
|
||||
|
||||
async function setFlag(on) {
|
||||
await db('feature_flags')
|
||||
.insert({ key: 'newsletters', value: on ? 1 : 0 })
|
||||
.onConflict('key')
|
||||
.merge();
|
||||
// requireFeatureFlag memoises for 10 s — clear it so the test sees the flip.
|
||||
require('../../src/middleware/requireFeatureFlag').invalidateFeatureFlagCache();
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId } = await seedMinimal(db));
|
||||
await assignAdminRole(db, adminId, 'super_admin');
|
||||
superToken = mintAdminToken(adminId);
|
||||
|
||||
// A second admin holding newsletters.view but NOT newsletters.send.
|
||||
const [viewerId] = await db('admin_users').insert({
|
||||
username: 'viewer', email: '[email protected]',
|
||||
password_hash: 'x', is_active: 1, created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
const viewerAdminId = typeof viewerId === 'object' ? viewerId.id : viewerId;
|
||||
const [roleId] = await db('roles').insert({
|
||||
name: 'newsletter_viewer', display_name: 'Newsletter Viewer',
|
||||
is_system: 0, priority: 10,
|
||||
created_at: new Date().toISOString(), updated_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
const viewerRoleId = typeof roleId === 'object' ? roleId.id : roleId;
|
||||
const viewPerm = await db('permissions').where({ name: 'newsletters.view' }).first();
|
||||
await db('role_permissions').insert({ role_id: viewerRoleId, permission_id: viewPerm.id });
|
||||
await db('admin_users').where({ id: viewerAdminId }).update({ role_id: viewerRoleId });
|
||||
viewerToken = mintAdminToken(viewerAdminId);
|
||||
|
||||
app = buildRouteApp(MOUNT, require('../../src/routes/adminNewsletters'));
|
||||
await setFlag(true);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('email_campaign_recipients').del();
|
||||
await db('email_queue').del();
|
||||
await db('email_campaigns').del();
|
||||
await db('customer_accounts').del();
|
||||
await setFlag(true);
|
||||
});
|
||||
|
||||
const createDraft = (over = {}) => request(app)
|
||||
.post(MOUNT).set(auth(superToken))
|
||||
.send({ name: 'Spring', subject: 'Spring offers', bodyHtml: '<p>Hi {{first_name}}</p>', ...over });
|
||||
|
||||
// ---- gates -------------------------------------------------------------
|
||||
|
||||
it('rejects an unauthenticated request', async () => {
|
||||
expect((await request(app).get(MOUNT)).status).toBe(401);
|
||||
});
|
||||
|
||||
it('refuses every route while the feature flag is off', async () => {
|
||||
await setFlag(false);
|
||||
const res = await request(app).get(MOUNT).set(auth(superToken));
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('NEWSLETTERS_DISABLED');
|
||||
});
|
||||
|
||||
it('answers "disabled", not "forbidden", when the flag is off', async () => {
|
||||
// A super-admin holds every permission, so a 403 here can only come from
|
||||
// the flag — which is the gate that must win.
|
||||
await setFlag(false);
|
||||
expect((await request(app).get(MOUNT).set(auth(superToken))).body.code)
|
||||
.toBe('NEWSLETTERS_DISABLED');
|
||||
});
|
||||
|
||||
it('lets newsletters.view read but not create', async () => {
|
||||
expect((await request(app).get(MOUNT).set(auth(viewerToken))).status).toBe(200);
|
||||
|
||||
const res = await request(app).post(MOUNT).set(auth(viewerToken))
|
||||
.send({ name: 'x', subject: 'y' });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('refuses queue and cancel without newsletters.send', async () => {
|
||||
const { body } = await createDraft();
|
||||
const id = body.campaign.id;
|
||||
expect((await request(app).post(`${MOUNT}/${id}/queue`).set(auth(viewerToken))).status).toBe(403);
|
||||
expect((await request(app).post(`${MOUNT}/${id}/cancel`).set(auth(viewerToken))).status).toBe(403);
|
||||
});
|
||||
|
||||
// ---- CRUD --------------------------------------------------------------
|
||||
|
||||
it('creates a draft and sanitizes the body on write', async () => {
|
||||
const res = await createDraft({ bodyHtml: '<p>ok</p><script>alert(1)</script>' });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.campaign.status).toBe('draft');
|
||||
expect(res.body.campaign.bodyHtml).not.toContain('alert(1)');
|
||||
|
||||
// And it is stored sanitized, not merely rendered so.
|
||||
const row = await db('email_campaigns').where({ id: res.body.campaign.id }).first();
|
||||
expect(row.body_html).not.toContain('alert(1)');
|
||||
});
|
||||
|
||||
it('rejects a missing name or subject', async () => {
|
||||
expect((await request(app).post(MOUNT).set(auth(superToken)).send({ subject: 'x' })).status).toBe(400);
|
||||
expect((await request(app).post(MOUNT).set(auth(superToken)).send({ name: 'x' })).status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects a subject carrying a newline (header injection)', async () => {
|
||||
const res = await createDraft({ subject: 'Hi\r\nBcc: [email protected]' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects an over-long subject', async () => {
|
||||
expect((await createDraft({ subject: 'x'.repeat(256) })).status).toBe(400);
|
||||
});
|
||||
|
||||
it('edits a draft', async () => {
|
||||
const { body } = await createDraft();
|
||||
const res = await request(app).put(`${MOUNT}/${body.campaign.id}`)
|
||||
.set(auth(superToken)).send({ subject: 'Updated' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.campaign.subject).toBe('Updated');
|
||||
});
|
||||
|
||||
it('refuses to edit a campaign that is no longer a draft', async () => {
|
||||
await db('customer_accounts').insert({
|
||||
email: '[email protected]', is_active: 1, marketing_opt_out: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
const { body } = await createDraft();
|
||||
const id = body.campaign.id;
|
||||
await request(app).post(`${MOUNT}/${id}/queue`).set(auth(superToken));
|
||||
|
||||
const res = await request(app).put(`${MOUNT}/${id}`).set(auth(superToken)).send({ subject: 'x' });
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it('404s for an unknown campaign', async () => {
|
||||
expect((await request(app).get(`${MOUNT}/999999`).set(auth(superToken))).status).toBe(404);
|
||||
});
|
||||
|
||||
// ---- state machine -----------------------------------------------------
|
||||
|
||||
it('queues once and 409s on a second attempt', async () => {
|
||||
await db('customer_accounts').insert({
|
||||
email: '[email protected]', is_active: 1, marketing_opt_out: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
const { body } = await createDraft();
|
||||
const id = body.campaign.id;
|
||||
|
||||
expect((await request(app).post(`${MOUNT}/${id}/queue`).set(auth(superToken))).status).toBe(200);
|
||||
expect((await request(app).post(`${MOUNT}/${id}/queue`).set(auth(superToken))).status).toBe(409);
|
||||
});
|
||||
|
||||
it('refuses to delete a queued campaign', async () => {
|
||||
await db('customer_accounts').insert({
|
||||
email: '[email protected]', is_active: 1, marketing_opt_out: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
const { body } = await createDraft();
|
||||
const id = body.campaign.id;
|
||||
await request(app).post(`${MOUNT}/${id}/queue`).set(auth(superToken));
|
||||
|
||||
expect((await request(app).delete(`${MOUNT}/${id}`).set(auth(superToken))).status).toBe(409);
|
||||
});
|
||||
|
||||
it('deletes a draft', async () => {
|
||||
const { body } = await createDraft();
|
||||
expect((await request(app).delete(`${MOUNT}/${body.campaign.id}`).set(auth(superToken))).status)
|
||||
.toBe(200);
|
||||
});
|
||||
|
||||
// ---- dry run + preview -------------------------------------------------
|
||||
|
||||
it('reports recipient counts and opt-out skips without writing anything', async () => {
|
||||
await db('customer_accounts').insert([
|
||||
{ email: '[email protected]', is_active: 1, marketing_opt_out: 0, created_at: new Date().toISOString() },
|
||||
{ email: '[email protected]', is_active: 1, marketing_opt_out: 1, created_at: new Date().toISOString() },
|
||||
]);
|
||||
const { body } = await createDraft();
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${MOUNT}/${body.campaign.id}/recipients/resolve`).set(auth(superToken));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.recipientCount).toBe(1);
|
||||
expect(res.body.skippedOptOut).toBe(1);
|
||||
expect(res.body.estimatedMinutes).toBe(1);
|
||||
// Dry run — nothing queued.
|
||||
expect(await db('email_queue').count({ c: '*' })).toEqual([{ c: 0 }]);
|
||||
});
|
||||
|
||||
it('renders a preview with sample data', async () => {
|
||||
const { body } = await createDraft();
|
||||
const res = await request(app)
|
||||
.post(`${MOUNT}/${body.campaign.id}/preview`).set(auth(superToken)).send({});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.isSample).toBe(true);
|
||||
expect(res.body.html).toContain('Hi Alex');
|
||||
});
|
||||
|
||||
it('renders a preview for a named customer', async () => {
|
||||
const [id] = await db('customer_accounts').insert({
|
||||
email: '[email protected]', first_name: 'Robin', is_active: 1,
|
||||
marketing_opt_out: 0, created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
const customerId = typeof id === 'object' ? id.id : id;
|
||||
const { body } = await createDraft();
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${MOUNT}/${body.campaign.id}/preview`).set(auth(superToken))
|
||||
.send({ customerId });
|
||||
|
||||
expect(res.body.isSample).toBe(false);
|
||||
expect(res.body.html).toContain('Hi Robin');
|
||||
});
|
||||
|
||||
// ---- recipients listing ------------------------------------------------
|
||||
|
||||
it('paginates the recipients list', async () => {
|
||||
await db('customer_accounts').insert(
|
||||
Array.from({ length: 5 }, (_, i) => ({
|
||||
email: `r${i}@example.com`, is_active: 1, marketing_opt_out: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}))
|
||||
);
|
||||
const { body } = await createDraft();
|
||||
const id = body.campaign.id;
|
||||
await request(app).post(`${MOUNT}/${id}/queue`).set(auth(superToken));
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${MOUNT}/${id}/recipients?page=1&limit=2`).set(auth(superToken));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data).toHaveLength(2);
|
||||
expect(res.body.pagination.total).toBe(5);
|
||||
});
|
||||
|
||||
it('filters the recipients list by status', async () => {
|
||||
await db('customer_accounts').insert({
|
||||
email: '[email protected]', is_active: 1, marketing_opt_out: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
const { body } = await createDraft();
|
||||
const id = body.campaign.id;
|
||||
await request(app).post(`${MOUNT}/${id}/queue`).set(auth(superToken));
|
||||
|
||||
expect((await request(app).get(`${MOUNT}/${id}/recipients?status=queued`)
|
||||
.set(auth(superToken))).body.data).toHaveLength(1);
|
||||
expect((await request(app).get(`${MOUNT}/${id}/recipients?status=sent`)
|
||||
.set(auth(superToken))).body.data).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ---- list filter -------------------------------------------------------
|
||||
|
||||
it('filters the campaign list by status', async () => {
|
||||
await createDraft({ name: 'One' });
|
||||
expect((await request(app).get(`${MOUNT}?status=draft`).set(auth(superToken)))
|
||||
.body.campaigns).toHaveLength(1);
|
||||
expect((await request(app).get(`${MOUNT}?status=sent`).set(auth(superToken)))
|
||||
.body.campaigns).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects an unknown status filter', async () => {
|
||||
expect((await request(app).get(`${MOUNT}?status=bogus`).set(auth(superToken))).status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Public newsletter unsubscribe (#1264).
|
||||
*
|
||||
* The property under test is uniformity: a valid token, a forged one, an
|
||||
* unknown customer and an already-unsubscribed customer must be
|
||||
* indistinguishable from outside. Anything that varies — status, body,
|
||||
* headers, an error page — is an oracle that turns this endpoint into a way
|
||||
* to enumerate which customer ids exist.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-unsub-'));
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'unsub-route-secret';
|
||||
|
||||
const request = require('supertest');
|
||||
const { bootCrmDb, buildRouteApp } = require('../integration/helpers/crmDb');
|
||||
|
||||
describe('GET /api/public/newsletter/unsubscribe/:token', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
let newsletterService;
|
||||
let customerId;
|
||||
|
||||
const MOUNT = '/api/public/newsletter';
|
||||
const get = (token) => request(app).get(`${MOUNT}/unsubscribe/${token}`);
|
||||
const post = (token) => request(app).post(`${MOUNT}/unsubscribe/${token}`);
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
newsletterService = require('../../src/services/newsletterService');
|
||||
app = buildRouteApp(MOUNT, require('../../src/routes/publicNewsletter'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Cleared too: several cases assert on the presence or ABSENCE of a
|
||||
// consent entry, which earlier cases in this file also write.
|
||||
await db('activity_logs').del();
|
||||
await db('customer_accounts').del();
|
||||
const [id] = await db('customer_accounts').insert({
|
||||
email: '[email protected]', is_active: 1, marketing_opt_out: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
customerId = typeof id === 'object' ? id.id : id;
|
||||
});
|
||||
|
||||
it('opts the customer out and stamps the timestamp', async () => {
|
||||
const res = await post(newsletterService.unsubscribeToken(customerId));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const row = await db('customer_accounts').where({ id: customerId }).first();
|
||||
expect(row.marketing_opt_out).toBeTruthy();
|
||||
expect(row.marketing_opt_out_at).toBeTruthy();
|
||||
});
|
||||
|
||||
it('needs no authentication', async () => {
|
||||
// No cookie, no header, no session — a mail client on any device.
|
||||
expect((await post(newsletterService.unsubscribeToken(customerId))).status).toBe(200);
|
||||
});
|
||||
|
||||
it('is idempotent — clicking twice is not an error', async () => {
|
||||
const token = newsletterService.unsubscribeToken(customerId);
|
||||
const first = await post(token);
|
||||
const second = await post(token);
|
||||
|
||||
expect(second.status).toBe(first.status);
|
||||
expect(second.text).toBe(first.text);
|
||||
});
|
||||
|
||||
it('answers identically for a valid token, a forged one and an unknown id', async () => {
|
||||
const valid = await post(newsletterService.unsubscribeToken(customerId));
|
||||
const forged = await post('dGFtcGVyZWQtdG9rZW4');
|
||||
const unknown = await post(newsletterService.unsubscribeToken(987654));
|
||||
|
||||
for (const res of [forged, unknown]) {
|
||||
expect(res.status).toBe(valid.status);
|
||||
expect(res.text).toBe(valid.text);
|
||||
expect(res.headers['content-type']).toBe(valid.headers['content-type']);
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves other customers untouched when the token is forged', async () => {
|
||||
await post('bm90LWEtcmVhbC10b2tlbg');
|
||||
const row = await db('customer_accounts').where({ id: customerId }).first();
|
||||
expect(row.marketing_opt_out).toBeFalsy();
|
||||
});
|
||||
|
||||
it('rejects an id spliced onto another id\'s signature', async () => {
|
||||
const token = newsletterService.unsubscribeToken(customerId);
|
||||
const sig = Buffer.from(token, 'base64url').toString('utf8').split('.')[1];
|
||||
const forged = Buffer.from(`${customerId + 1}.${sig}`, 'utf8').toString('base64url');
|
||||
|
||||
await post(forged);
|
||||
|
||||
// Neither the target nor the spliced neighbour is changed.
|
||||
expect((await db('customer_accounts').where({ id: customerId }).first()).marketing_opt_out)
|
||||
.toBeFalsy();
|
||||
});
|
||||
|
||||
it('renders a script-free confirmation page', async () => {
|
||||
const res = await post(newsletterService.unsubscribeToken(customerId));
|
||||
|
||||
expect(res.headers['content-type']).toMatch(/text\/html/);
|
||||
expect(res.text).toContain('<!DOCTYPE html>');
|
||||
expect(res.text).not.toContain('<script');
|
||||
// Self-contained: no external asset can make this page fail to render.
|
||||
expect(res.text).not.toMatch(/<(?:link|img|iframe)\b/);
|
||||
});
|
||||
|
||||
it('tells the reader transactional mail is unaffected', async () => {
|
||||
const res = await post(newsletterService.unsubscribeToken(customerId));
|
||||
expect(res.text).toMatch(/transactional/i);
|
||||
});
|
||||
|
||||
it('is not indexable', async () => {
|
||||
const res = await post(newsletterService.unsubscribeToken(customerId));
|
||||
expect(res.headers['x-robots-tag']).toMatch(/noindex/);
|
||||
expect(res.headers['cache-control']).toMatch(/no-store/);
|
||||
});
|
||||
|
||||
it('writes an activity log entry sourced to the link', async () => {
|
||||
await post(newsletterService.unsubscribeToken(customerId));
|
||||
|
||||
const log = await db('activity_logs')
|
||||
.where({ activity_type: 'customer_marketing_opt_out' }).orderBy('id', 'desc').first();
|
||||
expect(log).toBeTruthy();
|
||||
expect(JSON.parse(log.metadata)).toMatchObject({ source: 'link', optOut: true });
|
||||
});
|
||||
|
||||
// A GET must not change consent. Mail-security scanners, link prefetchers
|
||||
// and corporate gateways follow every URL in a message before a human sees
|
||||
// it — a mutating GET would unsubscribe much of a campaign automatically.
|
||||
describe('GET only asks', () => {
|
||||
it('does not change consent', async () => {
|
||||
const res = await get(newsletterService.unsubscribeToken(customerId));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const row = await db('customer_accounts').where({ id: customerId }).first();
|
||||
expect(row.marketing_opt_out).toBeFalsy();
|
||||
expect(row.marketing_opt_out_at).toBeNull();
|
||||
});
|
||||
|
||||
it('offers a form that posts back to the same token', async () => {
|
||||
const token = newsletterService.unsubscribeToken(customerId);
|
||||
const res = await get(token);
|
||||
|
||||
expect(res.text).toMatch(/<form[^>]+method="POST"/i);
|
||||
expect(res.text).toContain(`/unsubscribe/${token}`);
|
||||
});
|
||||
|
||||
it('writes no activity entry', async () => {
|
||||
await get(newsletterService.unsubscribeToken(customerId));
|
||||
const logs = await db('activity_logs')
|
||||
.where({ activity_type: 'customer_marketing_opt_out' });
|
||||
expect(logs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('renders the same page for a forged token', async () => {
|
||||
const valid = await get(newsletterService.unsubscribeToken(customerId));
|
||||
const forged = await get('bm90LWEtdG9rZW4');
|
||||
expect(forged.status).toBe(valid.status);
|
||||
// Only the form action differs — it echoes the token back.
|
||||
expect(forged.text.replace(/action="[^"]*"/, '')).toBe(
|
||||
valid.text.replace(/action="[^"]*"/, '')
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* newsletterService sanitizers (#1264).
|
||||
*
|
||||
* The composer is the one place in the app where an admin types HTML that is
|
||||
* then mailed to every customer. Stored XSS here is the highest-severity bug
|
||||
* this feature can have, so these cases are the contract: what gets stripped,
|
||||
* what survives, and that running the sanitizer twice changes nothing (which
|
||||
* is what lets the render path re-sanitize as a second line of defence).
|
||||
*/
|
||||
|
||||
jest.mock('../../src/database/db', () => ({ db: jest.fn(), logActivity: jest.fn() }));
|
||||
|
||||
const {
|
||||
sanitizeCampaignBody, sanitizeCampaignCss, MAX_BODY_BYTES,
|
||||
} = require('../../src/services/newsletterService');
|
||||
|
||||
describe('sanitizeCampaignBody', () => {
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(sanitizeCampaignBody('')).toBe('');
|
||||
expect(sanitizeCampaignBody(null)).toBe('');
|
||||
expect(sanitizeCampaignBody(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['<script>', '<p>hi</p><script>alert(1)</script>', 'alert(1)'],
|
||||
['<iframe>', '<p>hi</p><iframe src="https://evil.example"></iframe>', 'iframe'],
|
||||
['<object>', '<p>hi</p><object data="x.swf"></object>', 'object'],
|
||||
['<form>', '<form action="https://evil.example"><input name="p"></form>', 'form'],
|
||||
['<style> tag', '<style>body{x:1}</style><p>hi</p>', 'body{x:1}'],
|
||||
])('strips %s', (_label, input, forbidden) => {
|
||||
expect(sanitizeCampaignBody(input)).not.toContain(forbidden);
|
||||
});
|
||||
|
||||
it('strips event handlers', () => {
|
||||
const out = sanitizeCampaignBody('<p onclick="alert(1)" onerror="alert(2)">hi</p>');
|
||||
expect(out).not.toContain('onclick');
|
||||
expect(out).not.toContain('onerror');
|
||||
expect(out).toContain('hi');
|
||||
});
|
||||
|
||||
it('strips javascript: and data: URLs', () => {
|
||||
const out = sanitizeCampaignBody(
|
||||
'<a href="javascript:alert(1)">x</a><img src="data:text/html,<script>alert(1)</script>">'
|
||||
);
|
||||
expect(out).not.toContain('javascript:');
|
||||
expect(out).not.toContain('data:text/html');
|
||||
});
|
||||
|
||||
it('strips srcset, which the scheme filter does not police', () => {
|
||||
const out = sanitizeCampaignBody(
|
||||
'<img src="https://ok.example/a.png" srcset="http://evil.example/b.png 2x">'
|
||||
);
|
||||
expect(out).not.toContain('srcset');
|
||||
expect(out).toContain('https://ok.example/a.png');
|
||||
});
|
||||
|
||||
it('rejects protocol-relative URLs', () => {
|
||||
expect(sanitizeCampaignBody('<a href="//evil.example">x</a>')).not.toContain('//evil.example');
|
||||
});
|
||||
|
||||
it('keeps the table layout tags an email actually needs', () => {
|
||||
const html = '<table border="0" cellpadding="8" width="600"><tbody><tr>'
|
||||
+ '<td align="center" bgcolor="#ffffff">Cell</td></tr></tbody></table>';
|
||||
const out = sanitizeCampaignBody(html);
|
||||
expect(out).toContain('<table');
|
||||
expect(out).toContain('<td');
|
||||
expect(out).toContain('cellpadding="8"');
|
||||
expect(out).toContain('bgcolor="#ffffff"');
|
||||
});
|
||||
|
||||
it('keeps safe images and links, and forces rel on links', () => {
|
||||
const out = sanitizeCampaignBody(
|
||||
'<a href="https://example.com">Book</a><img src="https://cdn.example/x.png" alt="x" width="600">'
|
||||
);
|
||||
expect(out).toContain('href="https://example.com"');
|
||||
expect(out).toContain('rel="noopener noreferrer"');
|
||||
expect(out).toContain('src="https://cdn.example/x.png"');
|
||||
expect(out).toContain('width="600"');
|
||||
});
|
||||
|
||||
it('keeps mailto and cid schemes', () => {
|
||||
const out = sanitizeCampaignBody('<a href="mailto:[email protected]">mail</a><img src="cid:logo">');
|
||||
expect(out).toContain('mailto:[email protected]');
|
||||
expect(out).toContain('cid:logo');
|
||||
});
|
||||
|
||||
it('cleans dangerous declarations out of inline style attributes', () => {
|
||||
const out = sanitizeCampaignBody(
|
||||
'<p style="color:red;background:url(http://evil.example/track.gif)">hi</p>'
|
||||
);
|
||||
expect(out).toContain('color:red');
|
||||
expect(out).not.toContain('http://evil.example');
|
||||
});
|
||||
|
||||
it('strips expression() out of an inline style', () => {
|
||||
const out = sanitizeCampaignBody('<p style="width:expression(alert(1))">hi</p>');
|
||||
expect(out).not.toContain('expression(');
|
||||
});
|
||||
|
||||
it('leaves the {{variable}} syntax intact for the render pass', () => {
|
||||
const out = sanitizeCampaignBody('<p>Hi {{first_name}}, {{#if company_name}}({{company_name}}){{/if}}</p>');
|
||||
expect(out).toContain('{{first_name}}');
|
||||
expect(out).toContain('{{#if company_name}}');
|
||||
expect(out).toContain('{{/if}}');
|
||||
});
|
||||
|
||||
it('is idempotent — a second pass changes nothing', () => {
|
||||
const messy = '<p style="color:red" onclick="x()">Hi {{first_name}}</p>'
|
||||
+ '<script>alert(1)</script><a href="https://e.com">go</a>'
|
||||
+ '<table><tr><td bgcolor="#eee">c</td></tr></table>';
|
||||
const once = sanitizeCampaignBody(messy);
|
||||
expect(sanitizeCampaignBody(once)).toBe(once);
|
||||
});
|
||||
|
||||
it('rejects a body over the size cap instead of silently truncating', () => {
|
||||
const huge = `<p>${'x'.repeat(MAX_BODY_BYTES + 1)}</p>`;
|
||||
expect(() => sanitizeCampaignBody(huge)).toThrow(/exceeds/i);
|
||||
});
|
||||
|
||||
it('accepts a body just under the cap', () => {
|
||||
const big = `<p>${'x'.repeat(MAX_BODY_BYTES - 100)}</p>`;
|
||||
expect(() => sanitizeCampaignBody(big)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeCampaignCss', () => {
|
||||
it('returns empty for empty input', () => {
|
||||
expect(sanitizeCampaignCss('').css).toBe('');
|
||||
expect(sanitizeCampaignCss(null).css).toBe('');
|
||||
});
|
||||
|
||||
it('keeps ordinary declarations', () => {
|
||||
const { css } = sanitizeCampaignCss('.btn { color: #fff; padding: 12px 24px; }');
|
||||
expect(css).toContain('color: #fff');
|
||||
expect(css).toContain('padding: 12px 24px');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['@import', '@import url("https://evil.example/x.css"); .a{color:red}'],
|
||||
['expression(', '.a { width: expression(alert(1)); }'],
|
||||
['behavior:', '.a { behavior: url(evil.htc); }'],
|
||||
['javascript:', '.a { background: url(javascript:alert(1)); }'],
|
||||
])('blocks %s', (needle, input) => {
|
||||
const { css, warnings } = sanitizeCampaignCss(input);
|
||||
expect(css).not.toContain(needle);
|
||||
expect(warnings.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('blocks remote url() — stricter than the issue asked for, on purpose', () => {
|
||||
// A remote url() in mail CSS is a tracking pixel by another name; images
|
||||
// belong in <img> where the scheme filter sees them.
|
||||
const { css } = sanitizeCampaignCss('.hero { background: url(https://cdn.example/x.png); }');
|
||||
expect(css).not.toContain('https://cdn.example/x.png');
|
||||
});
|
||||
|
||||
it('strips embedded markup', () => {
|
||||
const { css } = sanitizeCampaignCss('.a{color:red}</style><script>alert(1)</script>');
|
||||
expect(css).not.toContain('<script');
|
||||
expect(css).not.toContain('</style>');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Newsletter campaigns (issue #1264, Part B).
|
||||
*
|
||||
* A campaign is NOT a parallel sender. It is a body + a recipient rule, and
|
||||
* queueing one writes ordinary `email_queue` rows — so retry, `rendered_html`,
|
||||
* `sent_at`, `error_message` and the System Health queue view all come for
|
||||
* free from the existing processor. The only new column on the queue is
|
||||
* `campaign_id`, which follows the `origin` column added by migration 155.
|
||||
*
|
||||
* Two new tables:
|
||||
*
|
||||
* - `email_campaigns` — the campaign itself. `body_html` / `body_css` are
|
||||
* stored ALREADY SANITIZED (newsletterService.sanitizeCampaignBody /
|
||||
* sanitizeCampaignCss); raw HTML is never persisted.
|
||||
*
|
||||
* - `email_campaign_recipients` — the per-recipient audit trail. Deliberately
|
||||
* NOT derivable from `email_queue`: queue rows are pruned, and a campaign's
|
||||
* "who did this actually reach" record has to outlive that. It stores email
|
||||
* + status only, no rendered body.
|
||||
*
|
||||
* Plus:
|
||||
* - `customer_accounts.marketing_opt_out` — opt-out, per customer, one
|
||||
* column. Transactional mail ignores it entirely; it is checked at queue
|
||||
* time AND again at send time, so a customer who unsubscribes after a
|
||||
* campaign is queued is still skipped.
|
||||
* - `newsletters.view` / `newsletters.send` permissions, granted to
|
||||
* super_admin and the admin role (175-style idempotent grant). `send` is
|
||||
* separate from `view` on purpose: mass mail is the one CRM action a
|
||||
* compromised or careless account can't take back.
|
||||
*
|
||||
* Every step is hasTable/hasColumn-guarded and safe to re-run.
|
||||
*/
|
||||
|
||||
const NEW_PERMISSIONS = [
|
||||
{
|
||||
name: 'newsletters.view',
|
||||
display_name: 'View Newsletters',
|
||||
category: 'clients',
|
||||
description: 'Read newsletter campaigns, their recipients and delivery status.',
|
||||
},
|
||||
{
|
||||
name: 'newsletters.send',
|
||||
display_name: 'Send Newsletters',
|
||||
category: 'clients',
|
||||
description: 'Create, edit and queue newsletter campaigns to customers. Mass mail — grant deliberately.',
|
||||
},
|
||||
];
|
||||
|
||||
exports.up = async function (knex) {
|
||||
// ---- email_campaigns ------------------------------------------------
|
||||
if (!(await knex.schema.hasTable('email_campaigns'))) {
|
||||
await knex.schema.createTable('email_campaigns', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.string('name', 120).notNullable();
|
||||
t.string('subject', 255).notNullable();
|
||||
// Sanitized on write. Never raw admin input.
|
||||
t.text('body_html');
|
||||
t.text('body_css');
|
||||
t.string('language', 8).notNullable().defaultTo('en');
|
||||
// draft | queued | sending | sent | cancelled | failed
|
||||
t.string('status', 16).notNullable().defaultTo('draft');
|
||||
// all_active | manual
|
||||
t.string('recipient_mode', 16).notNullable().defaultTo('all_active');
|
||||
// Reserved for tags/segments (issue #1264 decision 9 keeps them out of
|
||||
// v1). Having the column now means adding them later is a service
|
||||
// change, not a migration on a table holding live campaigns.
|
||||
t.text('recipient_filter');
|
||||
t.integer('recipient_count').notNullable().defaultTo(0);
|
||||
t.integer('sent_count').notNullable().defaultTo(0);
|
||||
t.integer('failed_count').notNullable().defaultTo(0);
|
||||
// Staggering rate. Clamped 1..120 at the service layer — a provider
|
||||
// limit (SES 14/s, many shared hosts 100/h) is the real constraint.
|
||||
t.integer('send_rate_per_minute').notNullable().defaultTo(20);
|
||||
// SET NULL, matching the ownership-reference invariant elsewhere: on
|
||||
// Postgres the default NO ACTION would make deleteAdminUser() fail
|
||||
// permanently once that admin had created a campaign.
|
||||
t.integer('created_by_admin_id').unsigned()
|
||||
.references('id').inTable('admin_users').onDelete('SET NULL');
|
||||
t.timestamp('test_sent_at');
|
||||
t.timestamp('queued_at');
|
||||
t.timestamp('completed_at');
|
||||
t.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
t.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
t.index(['status']);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- email_campaign_recipients --------------------------------------
|
||||
if (!(await knex.schema.hasTable('email_campaign_recipients'))) {
|
||||
await knex.schema.createTable('email_campaign_recipients', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.integer('campaign_id').unsigned().notNullable()
|
||||
.references('id').inTable('email_campaigns').onDelete('CASCADE');
|
||||
// SET NULL, not CASCADE: deleting a customer must not erase the record
|
||||
// that a campaign reached their address.
|
||||
t.integer('customer_account_id').unsigned()
|
||||
.references('id').inTable('customer_accounts').onDelete('SET NULL');
|
||||
t.string('email', 255).notNullable();
|
||||
t.integer('email_queue_id').unsigned();
|
||||
// queued | sent | failed | cancelled | skipped_opt_out
|
||||
t.string('status', 20).notNullable().defaultTo('queued');
|
||||
t.text('error_message');
|
||||
t.timestamp('sent_at');
|
||||
t.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
t.index(['campaign_id', 'status']);
|
||||
// One row per customer per campaign — the guard against a double-queue
|
||||
// sending the same person the same newsletter twice.
|
||||
t.unique(['campaign_id', 'customer_account_id']);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- email_queue.campaign_id ----------------------------------------
|
||||
if (await knex.schema.hasTable('email_queue')) {
|
||||
if (!(await knex.schema.hasColumn('email_queue', 'campaign_id'))) {
|
||||
await knex.schema.alterTable('email_queue', (t) => {
|
||||
t.integer('campaign_id').unsigned();
|
||||
t.index(['campaign_id']);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---- customer_accounts marketing opt-out -----------------------------
|
||||
if (await knex.schema.hasTable('customer_accounts')) {
|
||||
if (!(await knex.schema.hasColumn('customer_accounts', 'marketing_opt_out'))) {
|
||||
await knex.schema.alterTable('customer_accounts', (t) => {
|
||||
t.boolean('marketing_opt_out').notNullable().defaultTo(false);
|
||||
});
|
||||
}
|
||||
if (!(await knex.schema.hasColumn('customer_accounts', 'marketing_opt_out_at'))) {
|
||||
await knex.schema.alterTable('customer_accounts', (t) => {
|
||||
t.timestamp('marketing_opt_out_at');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---- permissions ------------------------------------------------------
|
||||
const hasPermissions = await knex.schema.hasTable('permissions');
|
||||
const hasRoles = await knex.schema.hasTable('roles');
|
||||
const hasRolePermissions = await knex.schema.hasTable('role_permissions');
|
||||
if (!hasPermissions || !hasRoles || !hasRolePermissions) return;
|
||||
|
||||
const existing = await knex('permissions')
|
||||
.whereIn('name', NEW_PERMISSIONS.map((p) => p.name))
|
||||
.select('name');
|
||||
const have = new Set(existing.map((r) => r.name));
|
||||
const toInsert = NEW_PERMISSIONS.filter((p) => !have.has(p.name));
|
||||
if (toInsert.length > 0) {
|
||||
await knex('permissions').insert(toInsert);
|
||||
}
|
||||
|
||||
const permIds = (await knex('permissions')
|
||||
.whereIn('name', NEW_PERMISSIONS.map((p) => p.name))
|
||||
.select('id')).map((p) => p.id);
|
||||
if (permIds.length === 0) return;
|
||||
|
||||
// super_admin tracks all (the boot self-heal would grant these anyway —
|
||||
// doing it here means a fresh install is correct before first boot).
|
||||
// `admin` gets them too, matching the plan: newsletters are a day-to-day
|
||||
// operator capability, not an owner-only one. Every other role, including
|
||||
// the frozen presets, starts without them.
|
||||
for (const roleName of ['super_admin', 'admin']) {
|
||||
const role = await knex('roles').where({ name: roleName }).first();
|
||||
if (!role) continue;
|
||||
const granted = await knex('role_permissions')
|
||||
.where({ role_id: role.id })
|
||||
.whereIn('permission_id', permIds)
|
||||
.select('permission_id');
|
||||
const has = new Set(granted.map((r) => r.permission_id));
|
||||
const inserts = permIds
|
||||
.filter((id) => !has.has(id))
|
||||
.map((id) => ({ role_id: role.id, permission_id: id }));
|
||||
if (inserts.length > 0) {
|
||||
await knex('role_permissions').insert(inserts);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
if (await knex.schema.hasTable('email_campaign_recipients')) {
|
||||
await knex.schema.dropTable('email_campaign_recipients');
|
||||
}
|
||||
if (await knex.schema.hasTable('email_campaigns')) {
|
||||
await knex.schema.dropTable('email_campaigns');
|
||||
}
|
||||
if (await knex.schema.hasTable('email_queue')
|
||||
&& await knex.schema.hasColumn('email_queue', 'campaign_id')) {
|
||||
await knex.schema.alterTable('email_queue', (t) => t.dropColumn('campaign_id'));
|
||||
}
|
||||
if (await knex.schema.hasTable('customer_accounts')) {
|
||||
for (const column of ['marketing_opt_out', 'marketing_opt_out_at']) {
|
||||
if (await knex.schema.hasColumn('customer_accounts', column)) {
|
||||
await knex.schema.alterTable('customer_accounts', (t) => t.dropColumn(column));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (await knex.schema.hasTable('permissions')) {
|
||||
const perms = await knex('permissions')
|
||||
.whereIn('name', NEW_PERMISSIONS.map((p) => p.name))
|
||||
.select('id');
|
||||
const ids = perms.map((p) => p.id);
|
||||
if (ids.length > 0) {
|
||||
if (await knex.schema.hasTable('role_permissions')) {
|
||||
await knex('role_permissions').whereIn('permission_id', ids).del();
|
||||
}
|
||||
await knex('permissions').whereIn('id', ids).del();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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'));
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, '&').replace(/"/g, '"')
|
||||
.replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
/** 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,
|
||||
};
|
||||
Reference in New Issue
Block a user