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>');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user