feat(crm): newsletter campaigns behind a newsletters flag (#1264)

Part B of #1264. Flag off by default, so an install that never enables it
gains no route, no nav entry and no way to mass-mail.

A campaign is a body plus a recipient rule. Queueing one writes ordinary
email_queue rows (email_type 'newsletter', origin 'campaign', new
campaign_id), so retry, rendered_html, sent_at and error_message all come
from the existing processor rather than a parallel sender. Throttling
staggers scheduled_at; the processor loop is untouched.

Two rules the service enforces: no raw HTML is ever stored (sanitized on
write and again on render, idempotently), and opt-out is checked at queue
time AND again at send time.

Migration 199 adds email_campaigns, email_campaign_recipients,
email_queue.campaign_id, customer_accounts.marketing_opt_out(_at), and
the newsletters.view / newsletters.send permissions.

Three rounds of external review are folded in, including several that
would otherwise have shipped broken:

- Campaign rows never came due on SQLite. queueEmail writes a Date, which
  the sqlite3 binding stores as epoch ms; ISO text in the same column
  compares as TEXT against an INTEGER, and SQLite orders every INTEGER
  below every TEXT. The feature silently sent nothing there.
- The flag had no Settings card and no sidebar entry, so it could not be
  enabled through the UI at all.
- Consent is per ADDRESS, not per row: two accounts sharing an inbox meant
  unsubscribing stopped one and not the other, at both queue and send time.
- The unsubscribe GET mutated consent, so a mail-security scanner walking
  a campaign could have unsubscribed much of the list. GET now confirms,
  POST acts.
- The rate ceiling is clamped to the queue's real throughput (10/min), so
  the composer's estimate stops being wrong by up to 12x.

Closes #1264
This commit is contained in:
Paul Nothaft
2026-09-04 14:32:31 +02:00
committed by GitHub
parent a7d0972b13
commit fc595409b4
32 changed files with 5044 additions and 28 deletions
@@ -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 &amp; 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('&lt;script&gt;');
});
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();
}
}
};
+5
View File
@@ -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/system-health', require('./src/routes/adminSystemHealth'));
app.use('/api/admin/dev', require('./src/routes/adminDev')); app.use('/api/admin/dev', require('./src/routes/adminDev'));
app.use('/api/admin/transfers', require('./src/routes/adminTransfers')); 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/quotes', require('./src/routes/publicQuotes'));
app.use('/api/public/contracts', require('./src/routes/publicContracts')); app.use('/api/public/contracts', require('./src/routes/publicContracts'));
// PicTransfer (#997): recipient download + client upload, token-authenticated. // PicTransfer (#997): recipient download + client upload, token-authenticated.
app.use('/api/public/transfer', require('./src/routes/publicTransfer')); app.use('/api/public/transfer', require('./src/routes/publicTransfer'));
app.use('/api/public/transfer-upload', require('./src/routes/publicTransferUpload')); app.use('/api/public/transfer-upload', require('./src/routes/publicTransferUpload'));
app.use('/api/public/payment-check', require('./src/routes/publicPaymentCheck')); 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/public/workflow-approvals', require('./src/routes/publicWorkflowApprovals'));
app.use('/api/admin/event-types', require('./src/routes/adminEventTypes')); app.use('/api/admin/event-types', require('./src/routes/adminEventTypes'));
app.use('/api/admin/api-tokens', require('./src/routes/adminApiTokens')); app.use('/api/admin/api-tokens', require('./src/routes/adminApiTokens'));
+7
View File
@@ -63,6 +63,11 @@ function transformCustomer(c) {
billingCycleDay: c.billing_cycle_day == null ? 1 : Number(c.billing_cycle_day), billingCycleDay: c.billing_cycle_day == null ? 1 : Number(c.billing_cycle_day),
notes: c.notes, notes: c.notes,
isActive: c.is_active, 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 // Passive customers (admin-only, no portal access) are identified
// by a null password_hash. We never expose the hash itself — // by a null password_hash. We never expose the hash itself —
// this boolean is the only thing the frontend ever sees, and it // 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('preferred_language').optional({ nullable: true }).isString().isLength({ max: 8 }),
body('notes').optional({ nullable: true }).isString(), body('notes').optional({ nullable: true }).isString(),
body('is_active').optional().isBoolean(), body('is_active').optional().isBoolean(),
// Newsletter consent (migration 199, #1264).
body('marketing_opt_out').optional().isBoolean(),
body('feature_calendar').optional().isBoolean(), body('feature_calendar').optional().isBoolean(),
body('feature_quotes').optional().isBoolean(), body('feature_quotes').optional().isBoolean(),
body('feature_bills').optional().isBoolean(), body('feature_bills').optional().isBoolean(),
+8
View File
@@ -110,6 +110,11 @@ const KNOWN_FLAGS = [
// the first of two deliberate actions — detection still has to be enabled // the first of two deliberate actions — detection still has to be enabled
// per event. Strictly opt-in. // per event. Strictly opt-in.
'faces', '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 // 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, workflows: false,
// #1074 — off by default is the whole "zero behaviour change" guarantee. // #1074 — off by default is the whole "zero behaviour change" guarantee.
faces: false, faces: false,
newsletters: false,
}; };
async function readAllFlags() { async function readAllFlags() {
@@ -207,6 +213,8 @@ function applyDependencyRules(flags) {
// (calendarBooking is gated behind `calendar` so adding the parent // (calendarBooking is gated behind `calendar` so adding the parent
// is sufficient.) // is sufficient.)
|| out.calendar || out.calendar
// Migration 199 (#1264) — newsletter campaigns live under Clients.
|| out.newsletters
// future siblings (out.messaging) go here // future siblings (out.messaging) go here
); );
return out; return out;
+314
View File
@@ -0,0 +1,314 @@
/**
* Admin → Newsletter campaigns (issue #1264, Part B).
*
* Mounted at /api/admin/newsletters. Every route is behind, in order:
* adminAuth → requireFeatureFlag('newsletters') → requirePermission(...)
*
* The flag gate sits ahead of the permission gate on purpose: with the
* feature off, the answer is "this feature is disabled", not "you may not",
* and no permission configuration should change that.
*
* `newsletters.send` is separate from `newsletters.view` because mass mail is
* the one CRM action that cannot be undone once the queue drains.
*/
const express = require('express');
const rateLimit = require('express-rate-limit');
const { body, param, query } = require('express-validator');
const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { requireFeatureFlag } = require('../middleware/requireFeatureFlag');
const {
handleAsync, validateRequest, successResponse, getPagination, paginatedResponse,
} = require('../utils/routeHelpers');
const newsletterService = require('../services/newsletterService');
const router = express.Router();
router.use(adminAuth, requireFeatureFlag('newsletters'));
// A test send goes straight out over SMTP with no queue in between, so it is
// the one route here that can be turned into an outbound mail cannon. Own
// bucket, per admin.
const testLimiter = rateLimit({
windowMs: 60 * 1000,
max: 5,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => `newsletter-test:${req.admin?.id || req.ip}`,
});
/** DB shape → API shape. Narrow, so a new column can't leak by accident. */
function transformCampaign(c) {
if (!c) return null;
return {
id: c.id,
name: c.name,
subject: c.subject,
bodyHtml: c.body_html || '',
bodyCss: c.body_css || '',
language: c.language || 'en',
status: c.status,
recipientMode: c.recipient_mode,
customerIds: parseCustomerIds(c.recipient_filter),
recipientCount: Number(c.recipient_count || 0),
sentCount: Number(c.sent_count || 0),
failedCount: Number(c.failed_count || 0),
sendRatePerMinute: Number(c.send_rate_per_minute || 20),
createdByAdminId: c.created_by_admin_id,
testSentAt: c.test_sent_at,
queuedAt: c.queued_at,
completedAt: c.completed_at,
createdAt: c.created_at,
updatedAt: c.updated_at,
};
}
function parseCustomerIds(raw) {
if (!raw) return [];
try {
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
const ids = Array.isArray(parsed) ? parsed : parsed?.customerIds;
return Array.isArray(ids) ? ids : [];
} catch (_) {
return [];
}
}
function transformRecipient(r) {
return {
id: r.id,
customerAccountId: r.customer_account_id,
email: r.email,
status: r.status,
errorMessage: r.error_message || null,
sentAt: r.sent_at,
createdAt: r.created_at,
};
}
// ---- list / read ----------------------------------------------------------
router.get(
'/',
requirePermission('newsletters.view'),
[query('status').optional().isIn(newsletterService.VALID_STATUSES)],
handleAsync(async (req, res) => {
validateRequest(req);
const q = db('email_campaigns').orderBy('created_at', 'desc').orderBy('id', 'desc');
if (req.query.status) q.where('status', req.query.status);
const rows = await q;
return successResponse(res, { campaigns: rows.map(transformCampaign) });
})
);
router.get(
'/:id',
requirePermission('newsletters.view'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
const campaign = await newsletterService.getCampaign(req.params.id);
const summary = await db('email_campaign_recipients')
.where({ campaign_id: campaign.id })
.select('status')
.count({ count: '*' })
.groupBy('status');
return successResponse(res, {
campaign: transformCampaign(campaign),
recipientSummary: summary.reduce((acc, r) => {
acc[r.status] = Number(r.count);
return acc;
}, {}),
});
})
);
router.get(
'/:id/recipients',
requirePermission('newsletters.view'),
[
param('id').isInt({ min: 1 }),
query('status').optional().isString().isLength({ max: 20 }),
],
handleAsync(async (req, res) => {
validateRequest(req);
await newsletterService.getCampaign(req.params.id); // 404s for an unknown id
const { page, limit, offset } = getPagination(req, { limit: 50 });
const base = () => {
const q = db('email_campaign_recipients').where({ campaign_id: req.params.id });
if (req.query.status) q.andWhere('status', req.query.status);
return q;
};
const [{ count }] = await base().count({ count: '*' });
const rows = await base()
.orderBy('id', 'asc')
.limit(limit)
.offset(offset);
return res.json(paginatedResponse(rows.map(transformRecipient), Number(count), page, limit));
})
);
// ---- write ----------------------------------------------------------------
// Shared body validators. The service re-validates and does the sanitizing —
// these exist to reject obvious garbage with a 400 before it gets there.
const campaignBodyValidators = [
body('name').optional().isString().isLength({ min: 1, max: 120 }),
body('subject').optional().isString().isLength({ min: 1, max: newsletterService.MAX_SUBJECT_LENGTH }),
body('bodyHtml').optional({ values: 'falsy' }).isString()
.isLength({ max: newsletterService.MAX_BODY_BYTES }),
body('bodyCss').optional({ values: 'falsy' }).isString().isLength({ max: 100 * 1024 }),
body('language').optional({ values: 'falsy' }).isString().isLength({ max: 8 }),
body('recipientMode').optional().isIn(newsletterService.VALID_RECIPIENT_MODES),
body('customerIds').optional().isArray(),
body('sendRatePerMinute').optional().isInt({
min: newsletterService.MIN_RATE_PER_MINUTE,
max: newsletterService.MAX_RATE_PER_MINUTE,
}),
];
router.post(
'/',
requirePermission('newsletters.send'),
[
body('name').isString().isLength({ min: 1, max: 120 }),
body('subject').isString().isLength({ min: 1, max: newsletterService.MAX_SUBJECT_LENGTH }),
...campaignBodyValidators,
],
handleAsync(async (req, res) => {
validateRequest(req);
const campaign = await newsletterService.createCampaign(req.body, req.admin.id);
return successResponse(res, { campaign: transformCampaign(campaign) }, 201, 'Campaign created');
})
);
router.put(
'/:id',
requirePermission('newsletters.send'),
[param('id').isInt({ min: 1 }), ...campaignBodyValidators],
handleAsync(async (req, res) => {
validateRequest(req);
const campaign = await newsletterService.updateCampaign(req.params.id, req.body, req.admin.id);
return successResponse(res, { campaign: transformCampaign(campaign) }, 200, 'Campaign updated');
})
);
router.delete(
'/:id',
requirePermission('newsletters.send'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
return successResponse(res, await newsletterService.deleteCampaign(req.params.id, req.admin.id));
})
);
// ---- preview / dry run ----------------------------------------------------
router.post(
'/:id/preview',
requirePermission('newsletters.view'),
[
param('id').isInt({ min: 1 }),
body('customerId').optional({ nullable: true }).isInt({ min: 1 }),
body('language').optional({ values: 'falsy' }).isString().isLength({ max: 8 }),
],
handleAsync(async (req, res) => {
validateRequest(req);
const campaign = await newsletterService.getCampaign(req.params.id);
let customer = null;
if (req.body.customerId) {
customer = await db('customer_accounts').where({ id: req.body.customerId }).first();
}
// Sample data when no real customer is named, so the variables render as
// something legible instead of leaving `{{first_name}}` on screen.
const subject = campaign.subject;
const rendered = await newsletterService.renderForRecipient(
req.body.language ? { ...campaign, language: req.body.language } : campaign,
customer || {
id: null,
email: '[email protected]',
salutation: 'Ms.',
first_name: 'Alex',
last_name: 'Sample',
display_name: 'Alex Sample',
company_name: 'Sample & Co',
preferred_language: req.body.language || campaign.language,
},
{ unsubscribeUrl: '#preview-unsubscribe' }
);
return successResponse(res, {
subject: rendered.subject || subject,
html: rendered.html,
language: rendered.language,
isSample: !customer,
});
})
);
router.post(
'/:id/recipients/resolve',
requirePermission('newsletters.view'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
const campaign = await newsletterService.getCampaign(req.params.id);
const { recipients, skippedOptOut, skippedNoEmail } =
await newsletterService.resolveRecipients(campaign);
// Counts only — the composer needs the number, not 2 000 email addresses.
return successResponse(res, {
recipientCount: recipients.length,
skippedOptOut,
skippedNoEmail,
sendRatePerMinute: newsletterService.clampRate(campaign.send_rate_per_minute),
estimatedMinutes: Math.ceil(
recipients.length / newsletterService.clampRate(campaign.send_rate_per_minute)
),
});
})
);
// ---- send -----------------------------------------------------------------
router.post(
'/:id/test',
requirePermission('newsletters.send'),
testLimiter,
[param('id').isInt({ min: 1 }), body('to').isEmail()],
handleAsync(async (req, res) => {
validateRequest(req);
return successResponse(res,
await newsletterService.sendTest(req.params.id, req.body.to, req.admin.id));
})
);
router.post(
'/:id/queue',
requirePermission('newsletters.send'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
return successResponse(res,
await newsletterService.queueCampaign(req.params.id, req.admin.id), 200, 'Campaign queued');
})
);
router.post(
'/:id/cancel',
requirePermission('newsletters.send'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
return successResponse(res,
await newsletterService.cancel(req.params.id, req.admin.id), 200, 'Campaign cancelled');
})
);
module.exports = router;
+61
View File
@@ -93,6 +93,13 @@ function shapeProfile(row) {
state: row.state, state: row.state,
countryCode: row.country_code, countryCode: row.country_code,
preferredLanguage: row.preferred_language || 'en', 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 * POST /profile/password
* *
+144
View File
@@ -0,0 +1,144 @@
/**
* Public → Newsletter unsubscribe (issue #1264, Part B).
*
* Mounted at /api/public/newsletter. No authentication — the signed token in
* the email footer is the only credential, and it must work from a mail
* client with no session, on any device, forever.
*
* The security property this file exists to hold: **the response is identical
* whether or not the id exists.** A valid token, a tampered token, an unknown
* customer and an already-unsubscribed customer all render the same page with
* the same status. There is no lookup by email and no table of tokens, so the
* endpoint offers nothing to enumerate.
*
* Deliberately NOT behind the `newsletters` feature flag: turning the feature
* off must not break the unsubscribe links in mail that already went out.
*/
const express = require('express');
const rateLimit = require('express-rate-limit');
const { param } = require('express-validator');
const logger = require('../utils/logger');
const newsletterService = require('../services/newsletterService');
const router = express.Router();
// Own bucket — a shared limiter with the other public routes would let a
// scraper here eat the quote-preview budget, and vice versa.
const unsubscribeLimiter = rateLimit({
windowMs: 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false,
});
function escapeHtml(text) {
return String(text ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
/**
* A single self-contained page — no scripts, no external assets, no branding
* lookup. A confirmation page that needs the API to be healthy in order to
* render is a confirmation page that fails when it matters.
*/
function page(title, message, formAction) {
const action = formAction
? `
<form method="POST" action="${escapeHtml(formAction)}" style="margin-top:20px;">
<button type="submit" style="background:#5C8762;color:#fff;border:0;border-radius:6px;
padding:12px 24px;font-size:14px;cursor:pointer;">Yes, unsubscribe me</button>
</form>`
: '';
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>${escapeHtml(title)}</title>
<style>
body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center;
background:#f5f5f5; color:#333;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif; }
.card { max-width:480px; margin:20px; padding:40px 32px; background:#fff; border-radius:8px;
text-align:center; box-shadow:0 1px 3px rgba(0,0,0,.08); }
h1 { margin:0 0 12px; font-size:20px; }
p { margin:0; font-size:14px; line-height:22px; color:#666; }
</style>
</head>
<body>
<div class="card">
<h1>${escapeHtml(title)}</h1>
<p>${escapeHtml(message)}</p>${action}
</div>
</body>
</html>`;
}
const OK_TITLE = 'You have been unsubscribed';
const OK_MESSAGE = 'You will no longer receive newsletters from us. '
+ 'Transactional emails about your galleries, quotes and invoices are not affected.';
const CONFIRM_TITLE = 'Unsubscribe from our newsletter?';
const CONFIRM_MESSAGE = 'Confirm below and you will no longer receive newsletters from us. '
+ 'Transactional emails about your galleries, quotes and invoices are not affected.';
// The GET only ASKS. Mail-security scanners, link prefetchers and corporate
// gateways follow every URL in a message before a human ever sees it — a GET
// that mutated consent would unsubscribe much of a campaign's recipient list
// automatically, and the recipients would never know why the mail stopped.
// The state change lives on the POST below, which needs a real click.
router.get(
'/unsubscribe/:token',
unsubscribeLimiter,
[param('token').isString().isLength({ min: 1, max: 512 })],
(req, res) => {
// Rendered for ANY token, valid or not — see the file header. A scanner
// and a real recipient must not be able to tell the difference.
const action = `/api/public/newsletter/unsubscribe/${encodeURIComponent(req.params.token)}`;
return res
.status(200)
.type('html')
.set('Cache-Control', 'no-store')
.set('X-Robots-Tag', 'noindex, nofollow')
.send(page(CONFIRM_TITLE, CONFIRM_MESSAGE, action));
}
);
router.post(
'/unsubscribe/:token',
unsubscribeLimiter,
[param('token').isString().isLength({ min: 1, max: 512 })],
async (req, res) => {
// Every branch answers identically — the anti-enumeration property.
const respond = () => res
.status(200)
.type('html')
.set('Cache-Control', 'no-store')
.set('X-Robots-Tag', 'noindex, nofollow')
.send(page(OK_TITLE, OK_MESSAGE));
try {
const customerId = newsletterService.verifyUnsubscribeToken(req.params.token);
if (customerId === null) {
logger.debug('Newsletter unsubscribe: token rejected');
return respond();
}
await newsletterService.setMarketingOptOut(customerId, true, 'link', {
type: 'customer', id: customerId,
});
return respond();
} catch (error) {
// Even a DB failure answers the same way. Telling the visitor "an error
// occurred" for one id and "done" for another is exactly the oracle the
// identical-response rule removes — the failure goes to the log.
logger.error('Newsletter unsubscribe failed', { error: error.message });
return respond();
}
}
);
module.exports = router;
@@ -565,6 +565,9 @@ async function getCustomerById(id) {
* typo before the customer accepts. Uniqueness is enforced. * typo before the customer accepts. Uniqueness is enforced.
*/ */
async function updateCustomer(id, updates, updatedByAdminId) { 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(); const customer = await db('customer_accounts').where('id', id).first();
if (!customer) { if (!customer) {
throw new NotFoundError('Customer', id); 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 // in its own branch below so null survives (formatBoolean would coerce
// it to false and silently lose the "inherit" state). // it to false and silently lose the "inherit" state).
'rebill_attach_proof', '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) { for (const f of fields) {
if (updates[f] !== undefined) { if (updates[f] !== undefined) {
@@ -613,6 +620,25 @@ async function updateCustomer(id, updates, updatedByAdminId) {
|| f === 'skonto_disabled' || f === 'skonto_disabled'
) { ) {
allowed[f] = formatBoolean(updates[f]); 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') { } else if (f === 'rebill_attach_proof') {
// Tri-state override. null/'' → NULL (inherit global default); // Tri-state override. null/'' → NULL (inherit global default);
// otherwise a real boolean (coerced for SQLite). // otherwise a real boolean (coerced for SQLite).
@@ -680,6 +706,17 @@ async function updateCustomer(id, updates, updatedByAdminId) {
{ type: 'admin', id: updatedByAdminId, name: 'system' } { 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); return getCustomerById(id);
} }
+93 -5
View File
@@ -991,6 +991,41 @@ async function sendTemplateEmail(to, templateKey, variables) {
} }
} }
/**
* Send one queued newsletter-campaign row (#1264).
*
* Campaigns carry their own body, so there is no `email_templates` row to
* look up and `sendTemplateEmail` cannot be used. The body is rendered per
* recipient (variables, the recipient's own unsubscribe link, the campaign
* CSS) and handed to the same `sendRawEmail` transport the manual composer
* uses. Returns the `{ html }` shape the queue processor persists into
* `rendered_html`, so a campaign send is as inspectable afterwards as any
* transactional mail.
*/
async function sendCampaignEmail(queueRow, emailData) {
const newsletterService = require('./newsletterService');
const campaign = await db('email_campaigns').where({ id: queueRow.campaign_id }).first();
if (!campaign) {
throw new Error(`Newsletter campaign ${queueRow.campaign_id} not found`);
}
// The customer row may be gone (deleted between queue and send). Fall back
// to the address on the queue row so the mail still goes out addressed to
// someone, with empty personalisation rather than a crash.
const customer = emailData.customerId
? await db('customer_accounts').where({ id: emailData.customerId }).first()
: null;
const { subject, html } = await newsletterService.renderForRecipient(
campaign,
customer || { id: emailData.customerId || null, email: queueRow.recipient_email }
);
const info = await sendRawEmail({ to: queueRow.recipient_email, subject, html });
return { success: true, messageId: info.messageId, html };
}
/** /**
* Send a fully-composed email (subject + HTML the admin already edited in the * Send a fully-composed email (subject + HTML the admin already edited in the
* Messages composer) WITHOUT a template. Used for replies + human-sent document * 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; emailData.eventId = email.event_id;
} }
const sendResult = await sendTemplateEmail( // Newsletter campaigns (#1264) have no `email_templates` row — the
email.recipient_email, // body lives on the campaign. They also get the send-time opt-out
email.email_type, // re-check: a customer who unsubscribed after the campaign was
emailData // 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 // Mark as sent, persisting the actual rendered HTML for the Project
// Overview email preview (guarded — older installs without migration // Overview email preview (guarded — older installs without migration
@@ -1217,6 +1279,18 @@ async function processEmailQueue({ ignoreSchedule = false, limit = 10, onlyId =
.where('id', email.id) .where('id', email.id)
.update(sentUpdate); .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; result.sent += 1;
logger.info(`Email ${email.id} sent successfully`); logger.info(`Email ${email.id} sent successfully`);
} catch (error) { } 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); logger.error(`Failed to send email ${email.id}:`, error);
} }
} }
+952
View File
@@ -0,0 +1,952 @@
/**
* newsletterService — CRM newsletter campaigns (issue #1264, Part B).
*
* Design in one line: **a campaign is a body plus a recipient rule; queueing
* one writes ordinary `email_queue` rows.** Retry, `rendered_html`, `sent_at`
* and `error_message` therefore come from the existing queue processor rather
* than a parallel sender, and throttling is done by staggering `scheduled_at`
* — the processor loop is untouched.
*
* Two rules the rest of the file exists to enforce:
*
* 1. **No raw HTML is ever stored.** Bodies are sanitized on write and again
* on render. The second pass is cheap and idempotent, and it means a row
* written by an older/buggier version of the sanitizer can't reach a
* recipient unsanitized.
*
* 2. **Opt-out is checked twice** — at queue time and again at send time.
* A customer who unsubscribes in the hour between the two is skipped and
* recorded as `skipped_opt_out`, not mailed.
*/
const crypto = require('crypto');
const sanitizeHtml = require('sanitize-html');
const { db, logActivity } = require('../database/db');
const logger = require('../utils/logger');
const { AppError } = require('../utils/errors');
const { formatBoolean, isPostgreSQL } = require('../utils/dbCompat');
const { sanitizeCSS } = require('../utils/cssSanitizer');
const { timingSafeEqualStr } = require('../utils/timingSafe');
const { getFrontendBaseUrl, getApiBaseUrl } = require('../utils/frontendUrl');
// A 200 KB body is already an absurd newsletter; the cap exists so a paste
// from a WYSIWYG suite full of base64 images can't put a multi-megabyte row
// in front of the sanitizer (and then in every queue row it renders into).
const MAX_BODY_BYTES = 200 * 1024;
const MAX_SUBJECT_LENGTH = 255;
const VALID_STATUSES = ['draft', 'queued', 'sending', 'sent', 'cancelled', 'failed'];
const VALID_RECIPIENT_MODES = ['all_active', 'manual'];
// Rate bounds.
//
// The ceiling is not a policy choice — it is what the queue can actually do.
// `startEmailQueueProcessor` runs `processEmailQueue()` once every 60 s with
// its default `limit = 10`, GLOBALLY across all email types. A campaign
// staggered at 20/min therefore drained at 10/min, and the "about N minutes"
// the composer showed was wrong by up to 12x at the old 120 ceiling.
//
// Clamping to the real throughput makes the number honest. The control still
// earns its place below the ceiling: a shared host capped at 100 mails/hour
// needs ~1/min, which is the case this exists to serve.
const MIN_RATE_PER_MINUTE = 1;
const QUEUE_ROWS_PER_MINUTE = 10; // processEmailQueue: limit 10, every 60 s
const MAX_RATE_PER_MINUTE = QUEUE_ROWS_PER_MINUTE;
const DEFAULT_RATE_PER_MINUTE = QUEUE_ROWS_PER_MINUTE;
// ---------------------------------------------------------------------------
// Sanitizers
// ---------------------------------------------------------------------------
/**
* The email-safe tag/attribute allowlist.
*
* Starts from the allowlist the manual composer already uses
* (`adminEmail.js` POST /send) and adds what a newsletter layout actually
* needs: table tags, `<center>`/`<font>`, and the presentational attributes
* email clients still require because they don't do flexbox.
*
* Not present, on purpose: `script`, `iframe`, `object`, `embed`, `form`,
* `input`, `style` (the tag — a campaign's CSS goes through `body_css`), and
* every `on*` handler. sanitize-html drops unknown attributes, so event
* handlers never need an explicit deny.
*/
const CAMPAIGN_ALLOWED_TAGS = sanitizeHtml.defaults.allowedTags.concat([
'img', 'center', 'font',
'table', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th', 'colgroup', 'col',
]);
const PRESENTATIONAL_ATTRS = [
'align', 'valign', 'width', 'height', 'bgcolor', 'border',
'cellpadding', 'cellspacing', 'colspan', 'rowspan',
];
const CAMPAIGN_ALLOWED_ATTRIBUTES = {
...sanitizeHtml.defaults.allowedAttributes,
a: ['href', 'name', 'target', 'rel', 'style', 'class'],
// No `srcset`: it takes a comma-separated URL list that the scheme filter
// below does not police, which would be a way back to an http: or data:
// source after `src` had been cleaned.
img: ['src', 'alt', 'width', 'height', 'style', 'class', 'align', 'border'],
table: [...PRESENTATIONAL_ATTRS, 'style', 'class', 'role'],
td: [...PRESENTATIONAL_ATTRS, 'style', 'class'],
th: [...PRESENTATIONAL_ATTRS, 'style', 'class'],
tr: [...PRESENTATIONAL_ATTRS, 'style', 'class'],
font: ['color', 'face', 'size'],
'*': ['style', 'class'],
};
/**
* Sanitize a campaign body. Idempotent — safe to run on already-clean HTML,
* which is what lets the render path re-run it as a second line of defence.
*
* @param {string} html raw admin input
* @returns {string} storable HTML
*/
function sanitizeCampaignBody(html) {
if (html === null || html === undefined) return '';
const input = String(html);
if (Buffer.byteLength(input, 'utf8') > MAX_BODY_BYTES) {
throw new AppError(
`Newsletter body exceeds the ${Math.round(MAX_BODY_BYTES / 1024)} KB limit`,
400
);
}
return sanitizeHtml(input, {
allowedTags: CAMPAIGN_ALLOWED_TAGS,
allowedAttributes: CAMPAIGN_ALLOWED_ATTRIBUTES,
// `cid:` is kept for parity with the manual composer (inline attachments).
// `data:` is NOT allowed — a data: image is how an HTML-ish payload gets
// smuggled past a tag allowlist in the clients that render it.
allowedSchemes: ['http', 'https', 'mailto', 'cid'],
allowedSchemesAppliedToAttributes: ['href', 'src'],
// A relative URL in an email is broken anyway (there is no base), and
// allowing it would let `//evil.example` through as protocol-relative.
allowProtocolRelative: false,
// Style attributes survive the tag pass; run their declarations through
// the same CSS sanitizer the <style> block uses so `expression(`,
// `behavior:` and external `url()` are stripped there too.
transformTags: {
a: (tagName, attribs) => ({
tagName,
attribs: {
...attribs,
// Mail clients open links in a browser; noopener/noreferrer costs
// nothing and closes window.opener on the ones that use a tab.
...(attribs.href ? { rel: 'noopener noreferrer' } : {}),
},
}),
},
})
// sanitize-html keeps the style ATTRIBUTE contents verbatim. Clean each.
.replace(/style="([^"]*)"/gi, (match, css) => {
const { sanitized } = sanitizeCSS(css);
const cleaned = stripRemoteCssUrls(sanitized);
return cleaned ? `style="${cleaned.replace(/"/g, '')}"` : '';
});
}
/**
* Remove every `url(...)` that is not an inline data: image.
*
* The shared `sanitizeCSS` *detects* a remote url() and prefixes it with a
* `/* BLOCKED URL *\/` comment — but a CSS comment is stripped during
* tokenization, so the declaration a mail client actually parses still
* carries the live URL. Verified:
*
* sanitizeCSS('.a{background:url(https://x/p.gif)}').sanitized
* → '.a{background:/* BLOCKED URL *\/ url(https://x/p.gif)}'
*
* In a newsletter that is a tracking pixel delivered to every recipient, so
* this pass actually removes the token. Scoped to the newsletter path on
* purpose: the same weakness affects gallery custom CSS, but changing shared
* sanitizer behaviour is a separate change with its own blast radius.
*/
function stripRemoteCssUrls(css) {
if (!css) return '';
return String(css)
.replace(/\/\*\s*BLOCKED URL\s*\*\//gi, '')
.replace(/url\s*\(\s*(['"]?)([^)'"]*)\1\s*\)/gi, (match, _quote, target) =>
(/^data:image\/(?:jpeg|jpg|png|gif|webp)/i.test(target.trim()) ? match : 'none'));
}
/**
* Sanitize a campaign's optional `<style>` block. Delegates to the shared
* cssSanitizer, which already blocks `@import`, `expression(`, `behavior:`,
* `javascript:` and every `url()` that is not a `data:` image.
*
* That is STRICTER than the issue's "https: images only" note — the shared
* sanitizer allows no remote `url()` at all. Kept as-is rather than loosened:
* a remote CSS url() in mail is a tracking pixel by another name, and a
* campaign's images belong in `<img>` tags where the scheme filter sees them.
*
* @returns {{ css: string, warnings: string[] }}
*/
function sanitizeCampaignCss(css) {
if (!css) return { css: '', warnings: [] };
const { sanitized, warnings } = sanitizeCSS(String(css));
return { css: stripRemoteCssUrls(sanitized), warnings };
}
// ---------------------------------------------------------------------------
// Unsubscribe tokens
// ---------------------------------------------------------------------------
const UNSUB_PREFIX = 'newsletter-unsub:';
function unsubSecret() {
const secret = process.env.JWT_SECRET;
if (!secret) throw new AppError('JWT_SECRET is not configured', 500);
return secret;
}
function unsubSignature(customerId) {
return crypto
.createHmac('sha256', unsubSecret())
.update(`${UNSUB_PREFIX}${customerId}`)
.digest('hex');
}
/**
* A signed, non-expiring handle on one customer id.
*
* No table and no lookup by email, so the link carries no enumeration
* surface: an attacker who changes the id gets a signature mismatch, and the
* route answers identically either way.
*/
function unsubscribeToken(customerId) {
const id = Number(customerId);
if (!Number.isInteger(id) || id <= 0) throw new AppError('Invalid customer id', 400);
return Buffer.from(`${id}.${unsubSignature(id)}`, 'utf8').toString('base64url');
}
/** @returns {number|null} the customer id, or null for anything tampered. */
function verifyUnsubscribeToken(token) {
if (typeof token !== 'string' || !token) return null;
let decoded;
try {
decoded = Buffer.from(token, 'base64url').toString('utf8');
} catch (_) {
return null;
}
const dot = decoded.indexOf('.');
if (dot <= 0) return null;
const idPart = decoded.slice(0, dot);
const sigPart = decoded.slice(dot + 1);
if (!/^\d+$/.test(idPart)) return null;
const id = Number(idPart);
if (!Number.isSafeInteger(id) || id <= 0) return null;
return timingSafeEqualStr(sigPart, unsubSignature(id)) ? id : null;
}
async function unsubscribeUrl(customerId) {
// The API base, not the frontend origin: `/public/newsletter/...` is served
// by the backend, and on a split-origin deployment that path does not exist
// on the frontend host.
//
// getApiBaseUrl already ENDS IN /api — it returns `<origin>/api` when
// API_URL is unset, and the documented API_URL values
// (https://photos.example.com/api) include it too. Appending another
// `/api/...` here produced `<origin>/api/api/public/...`, so every
// unsubscribe link 404'd on both same-origin and split-origin installs.
const base = (await getApiBaseUrl()) || `${(await getFrontendBaseUrl()) || ''}/api`;
return `${base}/public/newsletter/unsubscribe/${unsubscribeToken(customerId)}`;
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
/** Minimal attribute escaping for a server-generated URL. */
function escapeAttribute(value) {
return String(value ?? '')
.replace(/&/g, '&amp;').replace(/"/g, '&quot;')
.replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/** Everything a campaign body may interpolate. Absent keys stay literal. */
function recipientVariables(customer, unsubUrl, supportEmail) {
const first = customer.first_name || '';
const last = customer.last_name || '';
const display = customer.display_name || [first, last].filter(Boolean).join(' ').trim();
return {
customer_name: display || customer.company_name || customer.email || '',
first_name: first,
last_name: last,
salutation: customer.salutation || '',
company_name: customer.company_name || '',
support_email: supportEmail || '',
unsubscribe_url: unsubUrl,
};
}
/**
* Render one campaign for one recipient.
*
* Language order is customer → campaign → 'en': `preferred_language` is the
* customer's own setting and beats the campaign default, matching how
* getRecipientLanguage resolves transactional mail.
*
* @returns {{ subject: string, html: string, language: string }}
*/
async function renderForRecipient(campaign, customer, options = {}) {
// Required lazily: emailProcessor requires businessProfileService, and
// pulling it at module scope from here would make the require graph depend
// on load order for no benefit.
const { safeTemplateReplace, wrapEmailHtml, getSupportEmail } = require('./emailProcessor');
const language = customer.preferred_language || campaign.language || 'en';
const unsubUrl = options.unsubscribeUrl
?? (customer.id ? await unsubscribeUrl(customer.id) : '');
const supportEmail = options.supportEmail ?? await getSupportEmail();
const variables = recipientVariables(customer, unsubUrl, supportEmail);
// Second sanitize pass — see the file header. Idempotent, so a body stored
// by an older sanitizer is cleaned again on the way out.
const safeBody = sanitizeCampaignBody(campaign.body_html || '');
// Substitution happens AFTER sanitizing, with escaping on: a customer's own
// company name is untrusted text and must not be able to inject markup by
// riding in through a variable the sanitizer never saw.
const body = safeTemplateReplace(safeBody, variables, { escapeHtml: true });
const subject = safeTemplateReplace(campaign.subject || '', variables);
const { css } = sanitizeCampaignCss(campaign.body_css);
// Inline <style> ahead of the body. wrapEmailHtml emits its own <style> in
// <head>; this one sits in the content cell, which is where the clients
// that keep <style> at all will honour it. Clients that strip it fall back
// to the inline style attributes the sanitizer preserved.
// Every campaign carries an unsubscribe link — that is the promise the
// opt-out design rests on, and a body that simply omits {{unsubscribe_url}}
// must not be able to break it. Appended only when the author did not place
// it themselves, so a deliberate placement still wins.
// The URL is printed as TEXT beside the link, not only as an href: the
// plain-text alternative is derived with htmlToText, which drops <a> tags
// and their href entirely — a text-only recipient would have been left
// with the words "Unsubscribe from these emails" and no way to do it.
const withUnsubscribe = safeBody.includes('{{unsubscribe_url}}')
? body
: `${body}\n<p style="font-size:11px;color:#888888;margin-top:16px;">`
+ `<a href="${escapeAttribute(unsubUrl)}" style="color:#888888;">`
+ `Unsubscribe from these emails</a><br />${escapeAttribute(unsubUrl)}</p>`;
const styled = css ? `<style type="text/css">${css}</style>\n${withUnsubscribe}` : withUnsubscribe;
const html = await wrapEmailHtml(styled, subject, language);
return { subject, html, language };
}
// ---------------------------------------------------------------------------
// Recipients
// ---------------------------------------------------------------------------
const RECIPIENT_COLUMNS = [
'id', 'email', 'salutation', 'first_name', 'last_name',
'display_name', 'company_name', 'preferred_language',
];
/**
* Who this campaign would actually reach.
*
* `skippedOptOut` is reported rather than silently dropped — an operator
* about to mail 2 000 people should see that 43 of them said no.
*
* @returns {{ recipients: object[], skippedOptOut: number, skippedNoEmail: number }}
*/
async function resolveRecipients(campaign, conn = db) {
const ids = parseRecipientIds(campaign);
if (campaign.recipient_mode === 'manual' && ids.length === 0) {
return { recipients: [], skippedOptOut: 0, skippedNoEmail: 0 };
}
const base = () => {
const q = conn('customer_accounts').where('is_active', formatBoolean(true));
if (campaign.recipient_mode === 'manual') q.whereIn('id', ids);
return q;
};
const all = await base().select(RECIPIENT_COLUMNS.concat(['marketing_opt_out']));
const recipients = [];
const seen = new Set();
let skippedOptOut = 0;
let skippedNoEmail = 0;
// Opt-out is decided per ADDRESS, not per row. Two active customer rows can
// share an inbox, and unsubscribing only flips the row whose token was in
// the mail — so filtering row-by-row would skip that one and still deliver
// to the same person through the other. Clicking unsubscribe would appear
// to do nothing.
// Queried across EVERY active customer, not just `all`. In manual mode
// `all` is already narrowed to the selected ids, so an unselected account
// that unsubscribed would not appear — and picking its opted-in twin would
// mail the address that opted out.
const optedOutRows = await conn('customer_accounts')
.where('is_active', formatBoolean(true))
.select('email', 'marketing_opt_out');
const optedOutAddresses = new Set(
optedOutRows.filter(isOptedOut)
.map((row) => (row.email || '').trim().toLowerCase())
.filter(Boolean)
);
for (const row of all) {
const email = (row.email || '').trim().toLowerCase();
if (!email) { skippedNoEmail += 1; continue; }
if (optedOutAddresses.has(email)) {
// Count the address once, however many rows carry it.
if (!seen.has(email)) { skippedOptOut += 1; seen.add(email); }
continue;
}
// Two customer rows can legitimately share a billing address; the same
// person must still receive the newsletter once.
if (seen.has(email)) continue;
seen.add(email);
recipients.push({ ...row, email });
}
return { recipients, skippedOptOut, skippedNoEmail };
}
function isOptedOut(row) {
const v = row.marketing_opt_out;
return v === true || v === 1 || v === '1' || v === 't';
}
function parseRecipientIds(campaign) {
if (campaign.recipient_mode !== 'manual') return [];
const raw = campaign.recipient_filter;
if (!raw) return [];
let parsed;
try {
parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
} catch (_) {
return [];
}
const ids = Array.isArray(parsed) ? parsed : parsed?.customerIds;
if (!Array.isArray(ids)) return [];
return [...new Set(ids.map(Number).filter((n) => Number.isInteger(n) && n > 0))];
}
/**
* A timestamp in the shape `email_queue` comparisons actually use.
*
* `processEmailQueue` selects with `scheduled_at <= now`, binding a JS Date.
* On Postgres that is a timestamp comparison. On SQLite the native binding
* turns a Date into EPOCH MS — which is what `queueEmail` has always written
* and what utils/queueTimestamps.toMillis documents reading back.
*
* Writing an ISO STRING instead put TEXT in a column the processor compares
* against an INTEGER, and SQLite orders every INTEGER below every TEXT — so
* `'2026-09-04T…' <= 1757000000000` is false and a campaign row never came
* due. The whole feature silently sent nothing on SQLite installs, with the
* rows sitting in the queue looking perfectly correct.
*
* A raw number (rather than a Date) on SQLite also sidesteps the jest/sqlite3
* binding landmine documented in CLAUDE.md, where a sandbox-created Date is
* stored as the literal string "[object Object]".
*/
function queueTimestamp(ms) {
return isPostgreSQL() ? new Date(ms) : ms;
}
// ---------------------------------------------------------------------------
// Queueing
// ---------------------------------------------------------------------------
function clampRate(rate) {
const n = parseInt(rate, 10);
if (!Number.isFinite(n)) return DEFAULT_RATE_PER_MINUTE;
return Math.max(MIN_RATE_PER_MINUTE, Math.min(MAX_RATE_PER_MINUTE, n));
}
/**
* Queue a draft campaign: one `email_queue` row per recipient, with
* `scheduled_at` staggered so the send never bursts a provider.
*
* The whole thing is one transaction. A partial queue is the worst possible
* outcome — half a customer list mailed, a campaign stuck in `queued`, and no
* safe way to retry — so either every row lands or none does.
*/
async function queueCampaign(campaignId, adminId) {
const campaign = await getCampaign(campaignId);
if (campaign.status !== 'draft') {
throw new AppError(`Campaign is ${campaign.status}, only a draft can be queued`, 409);
}
if (!campaign.subject || !String(campaign.body_html || '').trim()) {
throw new AppError('Campaign needs a subject and a body before it can be queued', 400);
}
const { recipients, skippedOptOut } = await resolveRecipients(campaign);
if (recipients.length === 0) {
throw new AppError('Campaign has no recipients', 400);
}
const rate = clampRate(campaign.send_rate_per_minute);
const now = Date.now();
const queuedAt = new Date(now).toISOString();
await db.transaction(async (trx) => {
for (let i = 0; i < recipients.length; i += 1) {
const customer = recipients[i];
// Stagger: recipient N goes out in minute floor(N / rate). Everything
// in the first minute is due immediately, so a small campaign behaves
// exactly like any other queued mail.
const scheduledMs = now + Math.floor(i / rate) * 60 * 1000;
const inserted = await trx('email_queue').insert({
recipient_email: customer.email,
email_type: 'newsletter',
email_data: JSON.stringify({ campaignId: campaign.id, customerId: customer.id }),
status: 'pending',
origin: 'campaign',
campaign_id: campaign.id,
// Engine-shaped, not ISO — see queueTimestamp. These two columns are
// the ones processEmailQueue filters and orders on.
created_at: queueTimestamp(now),
scheduled_at: queueTimestamp(scheduledMs),
}).returning('id');
const queueId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
await trx('email_campaign_recipients').insert({
campaign_id: campaign.id,
customer_account_id: customer.id,
email: customer.email,
email_queue_id: queueId,
status: 'queued',
created_at: queuedAt,
});
}
await trx('email_campaigns').where({ id: campaign.id }).update({
status: 'queued',
recipient_count: recipients.length,
sent_count: 0,
failed_count: 0,
send_rate_per_minute: rate,
queued_at: queuedAt,
updated_at: queuedAt,
});
});
await logActivity('newsletter_queued', {
campaignId: campaign.id,
name: campaign.name,
recipients: recipients.length,
skippedOptOut,
sendRatePerMinute: rate,
}, null, { type: 'admin', id: adminId });
logger.info('Newsletter campaign queued', {
campaignId: campaign.id, recipients: recipients.length, rate, adminId,
});
return { queued: recipients.length, skippedOptOut, sendRatePerMinute: rate };
}
/**
* Cancel a campaign: drop the queue rows that have not gone out yet.
*
* Already-sent rows stay exactly as they are — cancelling a campaign cannot
* un-send mail, and pretending otherwise in the counts would be a lie the
* operator might act on.
*/
async function cancel(campaignId, adminId) {
const campaign = await getCampaign(campaignId);
if (!['queued', 'sending'].includes(campaign.status)) {
throw new AppError(`Campaign is ${campaign.status} and cannot be cancelled`, 409);
}
const result = await db.transaction(async (trx) => {
const pending = await trx('email_queue')
.where({ campaign_id: campaign.id, status: 'pending' })
.select('id');
const pendingIds = pending.map((r) => r.id);
if (pendingIds.length > 0) {
await trx('email_queue').whereIn('id', pendingIds).del();
await trx('email_campaign_recipients')
.where({ campaign_id: campaign.id })
.whereIn('email_queue_id', pendingIds)
// Only rows still waiting. A recipient that already exhausted its
// retries has status 'failed' while its queue row sits 'pending' —
// rewriting that to 'cancelled' erased the failure from the audit
// rows while `failed_count`, computed from them, kept counting it.
.whereIn('status', ['queued'])
.update({ status: 'cancelled' });
}
// Counters are derived from the recipient rows, so recompute them here
// rather than leaving a campaign whose failed_count disagrees with its
// own audit trail.
const remaining = await trx('email_campaign_recipients')
.where({ campaign_id: campaign.id })
.select('status');
await trx('email_campaigns').where({ id: campaign.id }).update({
status: 'cancelled',
sent_count: remaining.filter((r) => r.status === 'sent').length,
failed_count: remaining.filter((r) => r.status === 'failed').length,
completed_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
});
return { cancelled: pendingIds.length };
});
await logActivity('newsletter_cancelled', {
campaignId: campaign.id, name: campaign.name, cancelledRows: result.cancelled,
}, null, { type: 'admin', id: adminId });
return result;
}
// ---------------------------------------------------------------------------
// Queue-processor hook
// ---------------------------------------------------------------------------
/**
* Called by `processEmailQueue` for a row carrying `campaign_id`, after the
* send succeeded or failed. Updates the recipient row and rolls the campaign
* counters.
*
* Best-effort by contract: a failure here must never turn a delivered email
* into a failed queue row, so the caller swallows what this throws.
*/
async function recordRecipientResult(queueRow, { status, errorMessage = null } = {}) {
const update = { status };
if (status === 'sent') update.sent_at = new Date().toISOString();
if (errorMessage) update.error_message = String(errorMessage).slice(0, 1000);
await db('email_campaign_recipients')
.where({ campaign_id: queueRow.campaign_id, email_queue_id: queueRow.id })
.update(update);
await recomputeCounts(queueRow.campaign_id);
}
/**
* Roll `sent_count` / `failed_count` from the recipient rows and move the
* campaign to a terminal status once nothing is pending.
*
* Counts are recomputed from the rows rather than incremented, so a retried
* row or a concurrent processor pass can't double-count.
*/
async function recomputeCounts(campaignId) {
const rows = await db('email_campaign_recipients')
.where({ campaign_id: campaignId })
.select('status');
if (rows.length === 0) return null;
const sent = rows.filter((r) => r.status === 'sent').length;
const failed = rows.filter((r) => r.status === 'failed').length;
const stillQueued = rows.filter((r) => r.status === 'queued').length;
const update = {
sent_count: sent,
failed_count: failed,
updated_at: new Date().toISOString(),
};
const campaign = await db('email_campaigns').where({ id: campaignId }).first();
if (!campaign) return null;
if (stillQueued > 0) {
// First result in: the campaign is visibly working.
if (campaign.status === 'queued') update.status = 'sending';
} else if (['queued', 'sending', 'failed'].includes(campaign.status)) {
// `failed` is included so a System Health retry that finally succeeds can
// move the campaign back to `sent`. Without it a campaign stayed marked
// failed even once every recipient had been delivered.
// Everything resolved. `failed` only when NOTHING got through — a
// campaign that reached 1 990 of 2 000 people is a sent campaign with
// ten failures, and calling it "failed" would misdirect the operator.
update.status = sent > 0 ? 'sent' : 'failed';
update.completed_at = new Date().toISOString();
}
await db('email_campaigns').where({ id: campaignId }).update(update);
if (update.status === 'sent' || update.status === 'failed') {
await logActivity('newsletter_completed', {
campaignId, name: campaign.name, sent, failed,
});
}
return { sent, failed, stillQueued, status: update.status || campaign.status };
}
/**
* The send-time opt-out re-check (design rule 2).
*
* @returns {boolean} true when this row must NOT be sent.
*/
async function shouldSkipForOptOut(customerId, recipientEmail = null) {
const row = customerId
? await db('customer_accounts')
.where({ id: customerId })
.select('email', 'marketing_opt_out', 'is_active')
.first()
: null;
if (row) {
if (isOptedOut(row)) return true;
const active = row.is_active;
if (!(active === true || active === 1 || active === '1' || active === 't')) return true;
}
// Consent belongs to the ADDRESS. Another active account sharing this
// inbox may have unsubscribed after the campaign was queued, and that
// click has to stop this mail too — otherwise the person who
// unsubscribed still receives it.
const address = (recipientEmail || row?.email || '').trim().toLowerCase();
if (!address) return false;
const optedOutTwin = await db('customer_accounts')
.whereRaw('LOWER(TRIM(email)) = ?', [address])
.select('marketing_opt_out')
.then((rows) => rows.some(isOptedOut));
return optedOutTwin;
}
/** Mark a row the processor refused to send because consent was withdrawn. */
async function markSkippedOptOut(queueRow) {
await db('email_campaign_recipients')
.where({ campaign_id: queueRow.campaign_id, email_queue_id: queueRow.id })
.update({ status: 'skipped_opt_out' });
await db('email_queue').where({ id: queueRow.id }).update({
status: 'cancelled',
error_message: 'Recipient opted out of marketing email after the campaign was queued',
});
await recomputeCounts(queueRow.campaign_id);
}
// ---------------------------------------------------------------------------
// Opt-out
// ---------------------------------------------------------------------------
/**
* Flip a customer's marketing consent.
*
* @param {'link'|'portal'|'admin'} source where the change came from
* @returns {boolean} whether a row was actually updated
*/
async function setMarketingOptOut(customerId, optOut, source, actor = null) {
const current = await db('customer_accounts')
.where({ id: customerId })
.first('marketing_opt_out');
if (!current) return false;
// Only a real transition counts. An unsubscribe link is followed by mail
// scanners, by prefetchers and by the customer refreshing the page — each
// of which would otherwise overwrite `marketing_opt_out_at` with a later
// time and file another activity row, burying the moment consent was
// actually withdrawn under its own confirmations.
if (isOptedOut(current) === Boolean(optOut)) return false;
await db('customer_accounts').where({ id: customerId }).update({
marketing_opt_out: formatBoolean(Boolean(optOut)),
marketing_opt_out_at: optOut ? new Date().toISOString() : null,
});
await logActivity('customer_marketing_opt_out', {
customerId, optOut: Boolean(optOut), source,
}, null, actor);
return true;
}
// ---------------------------------------------------------------------------
// CRUD
// ---------------------------------------------------------------------------
async function getCampaign(id, conn = db) {
const campaign = await conn('email_campaigns').where({ id }).first();
if (!campaign) throw new AppError('Campaign not found', 404);
return campaign;
}
/** Shape an admin-supplied payload into storable columns. */
function sanitiseCampaignPayload(payload = {}) {
const out = {};
if (payload.name !== undefined) {
const name = String(payload.name || '').trim();
if (!name) throw new AppError('Campaign name is required', 400);
out.name = name.slice(0, 120);
}
if (payload.subject !== undefined) {
const subject = String(payload.subject || '').trim();
if (!subject) throw new AppError('Subject is required', 400);
// CR/LF in a subject is header injection. nodemailer encodes it, but a
// subject with a newline in it is malformed regardless — reject rather
// than silently strip, so the admin sees what happened.
if (/[\r\n]/.test(subject)) throw new AppError('Subject cannot contain line breaks', 400);
if (subject.length > MAX_SUBJECT_LENGTH) {
throw new AppError(`Subject cannot exceed ${MAX_SUBJECT_LENGTH} characters`, 400);
}
out.subject = subject;
}
if (payload.bodyHtml !== undefined) {
out.body_html = sanitizeCampaignBody(payload.bodyHtml);
}
if (payload.bodyCss !== undefined) {
out.body_css = sanitizeCampaignCss(payload.bodyCss).css;
}
if (payload.language !== undefined) {
out.language = String(payload.language || 'en').trim().slice(0, 8) || 'en';
}
if (payload.recipientMode !== undefined) {
const mode = String(payload.recipientMode || '');
if (!VALID_RECIPIENT_MODES.includes(mode)) {
throw new AppError('recipientMode must be all_active or manual', 400);
}
out.recipient_mode = mode;
}
if (payload.customerIds !== undefined) {
const ids = Array.isArray(payload.customerIds)
? [...new Set(payload.customerIds.map(Number).filter((n) => Number.isInteger(n) && n > 0))]
: [];
out.recipient_filter = ids.length ? JSON.stringify({ customerIds: ids }) : null;
}
if (payload.sendRatePerMinute !== undefined) {
out.send_rate_per_minute = clampRate(payload.sendRatePerMinute);
}
return out;
}
async function createCampaign(payload, adminId) {
const data = sanitiseCampaignPayload(payload);
if (!data.name) throw new AppError('Campaign name is required', 400);
if (!data.subject) throw new AppError('Subject is required', 400);
const nowIso = new Date().toISOString();
const inserted = await db('email_campaigns').insert({
status: 'draft',
recipient_mode: 'all_active',
send_rate_per_minute: DEFAULT_RATE_PER_MINUTE,
...data,
created_by_admin_id: adminId || null,
created_at: nowIso,
updated_at: nowIso,
}).returning('id');
const id = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
await logActivity('newsletter_created', { campaignId: id, name: data.name },
null, { type: 'admin', id: adminId });
return await getCampaign(id);
}
async function updateCampaign(id, payload, adminId) {
const campaign = await getCampaign(id);
if (campaign.status !== 'draft') {
throw new AppError('Only a draft campaign can be edited', 409);
}
const data = sanitiseCampaignPayload(payload);
if (Object.keys(data).length === 0) return campaign;
data.updated_at = new Date().toISOString();
await db('email_campaigns').where({ id }).update(data);
await logActivity('newsletter_updated', {
campaignId: id, fields: Object.keys(data).filter((k) => k !== 'updated_at'),
}, null, { type: 'admin', id: adminId });
return await getCampaign(id);
}
async function deleteCampaign(id, adminId) {
const campaign = await getCampaign(id);
if (!['draft', 'cancelled'].includes(campaign.status)) {
throw new AppError(`A ${campaign.status} campaign cannot be deleted`, 409);
}
// A cancelled campaign may still have reached people before it was stopped.
// email_campaign_recipients cascades on delete, so removing the campaign
// would erase the only durable record of who received it — the record that
// outlives queue pruning and answers "did this person get that mail?".
const [{ delivered }] = await db('email_campaign_recipients')
.where({ campaign_id: id, status: 'sent' })
.count({ delivered: '*' });
if (Number(delivered) > 0) {
throw new AppError(
`This campaign already reached ${delivered} recipient(s) and cannot be deleted`,
409
);
}
// Recipient rows cascade; queue rows for a cancelled campaign were already
// deleted by cancel(), and sent ones are history that stays in the queue.
await db('email_campaigns').where({ id }).del();
await logActivity('newsletter_deleted', { campaignId: id, name: campaign.name },
null, { type: 'admin', id: adminId });
return { deleted: true };
}
/**
* Send one test copy, rendered with sample data, without touching the queue
* or the recipient table. `test_sent_at` is stamped so the list can show that
* a campaign was proofed before it went out.
*/
async function sendTest(campaignId, toEmail, adminId) {
const campaign = await getCampaign(campaignId);
const { sendRawEmail } = require('./emailProcessor');
const sample = {
id: null,
email: toEmail,
salutation: 'Ms.',
first_name: 'Alex',
last_name: 'Sample',
display_name: 'Alex Sample',
company_name: 'Sample & Co',
preferred_language: campaign.language,
};
// No real customer id, so no real unsubscribe token — the test mail gets a
// dead link rather than one that would opt a stranger out.
const { subject, html } = await renderForRecipient(campaign, sample, {
unsubscribeUrl: `${(await getFrontendBaseUrl()) || ''}/api/public/newsletter/unsubscribe/test`,
});
await sendRawEmail({ to: toEmail, subject: `[Test] ${subject}`, html });
await db('email_campaigns').where({ id: campaignId }).update({
test_sent_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
});
await logActivity('newsletter_test_sent', { campaignId, to: toEmail },
null, { type: 'admin', id: adminId });
return { sent: true };
}
module.exports = {
sanitizeCampaignBody,
sanitizeCampaignCss,
unsubscribeToken,
verifyUnsubscribeToken,
unsubscribeUrl,
renderForRecipient,
resolveRecipients,
queueCampaign,
cancel,
recordRecipientResult,
recomputeCounts,
shouldSkipForOptOut,
markSkippedOptOut,
setMarketingOptOut,
getCampaign,
createCampaign,
updateCampaign,
deleteCampaign,
sendTest,
// Exported for the routes' validators and the tests.
clampRate,
MAX_BODY_BYTES,
MAX_SUBJECT_LENGTH,
MIN_RATE_PER_MINUTE,
MAX_RATE_PER_MINUTE,
DEFAULT_RATE_PER_MINUTE,
VALID_STATUSES,
VALID_RECIPIENT_MODES,
};
+26 -7
View File
@@ -36,6 +36,11 @@ import {
BillDetailPage, BillDetailPage,
} from './pages/admin'; } from './pages/admin';
import { CrmDevelopmentPage } from './pages/admin/clients/CrmDevelopmentPage'; import { CrmDevelopmentPage } from './pages/admin/clients/CrmDevelopmentPage';
// Newsletter campaigns (#1264). Gated by the `newsletters` flag inside the
// Clients block; the API refuses these routes independently when it is off.
import { NewsletterListPage } from './pages/admin/newsletters/NewsletterListPage';
import { NewsletterComposerPage } from './pages/admin/newsletters/NewsletterComposerPage';
import { NewsletterDetailPage } from './pages/admin/newsletters/NewsletterDetailPage';
import { TaxReportPage } from './pages/admin/clients/TaxReportPage'; import { TaxReportPage } from './pages/admin/clients/TaxReportPage';
import { HoursLoggingPage } from './pages/admin/clients/HoursLoggingPage'; import { HoursLoggingPage } from './pages/admin/clients/HoursLoggingPage';
// E.6 — Calendar page lazy-loaded so the ~200 KB FullCalendar bundle // E.6 — Calendar page lazy-loaded so the ~200 KB FullCalendar bundle
@@ -272,7 +277,12 @@ function App() {
independently. */} independently. */}
<Route element={<RequireFeature flag="clients" />}> <Route element={<RequireFeature flag="clients" />}>
<Route path="clients" element={<ClientsLayout />}> <Route path="clients" element={<ClientsLayout />}>
<Route element={<RequireFeature flag="customerPortal" />}> {/* Newsletters also lives here: the customer detail
page hosts the newsletter consent control, so a
newsletter-only install (portal off) must still
be able to open a customer to record a phone
opt-out (#1264). */}
<Route element={<RequireFeature anyOf={['customerPortal', 'newsletters']} />}>
<Route path="accounts" element={<CustomerManagementPage />} /> <Route path="accounts" element={<CustomerManagementPage />} />
<Route path="accounts/:id" element={<CustomerDetailPage />} /> <Route path="accounts/:id" element={<CustomerDetailPage />} />
</Route> </Route>
@@ -334,16 +344,25 @@ function App() {
section. Keep this path as a redirect so old section. Keep this path as a redirect so old
bookmarks / links don't 404. */} bookmarks / links don't 404. */}
<Route path="tax-report" element={<Navigate to="/admin/accounting/tax-report" replace />} /> <Route path="tax-report" element={<Navigate to="/admin/accounting/tax-report" replace />} />
{/* Newsletter campaigns (#1264) — gated by
`newsletters`. Mass marketing mail to customer
accounts, with per-customer opt-out. */}
<Route element={<RequireFeature flag="newsletters" />}>
<Route path="newsletters" element={<NewsletterListPage />} />
<Route path="newsletters/:id" element={<NewsletterDetailPage />} />
<Route path="newsletters/:id/edit" element={<NewsletterComposerPage />} />
</Route>
{/* Developer tools — gated by `crmDevelopment`. */} {/* Developer tools — gated by `crmDevelopment`. */}
<Route element={<RequireFeature flag="crmDevelopment" />}> <Route element={<RequireFeature flag="crmDevelopment" />}>
<Route path="development" element={<CrmDevelopmentPage />} /> <Route path="development" element={<CrmDevelopmentPage />} />
</Route> </Route>
{/* Default: send /admin/clients (no sub-path) to {/* No index redirect here: ClientsLayout picks the
the first enabled sub-feature. accounts comes first sub-feature this user can actually reach,
first because it predates the others. The empty which a fixed /accounts target could not — a
state inside ClientsLayout handles "parent on, newsletters-only role has no customers.view and
all children off". */} would bounce straight back out (#1264). It also
<Route index element={<Navigate to="/admin/clients/accounts" replace />} /> owns the "parent on, all children off" empty
state. */}
</Route> </Route>
</Route> </Route>
+15 -1
View File
@@ -52,6 +52,13 @@ interface NavItem {
* constraint. * constraint.
*/ */
featureFlagsAny?: FeatureKey[]; featureFlagsAny?: FeatureKey[];
/**
* Alternative permissions, any ONE of which reveals the entry. For a
* section whose sub-features are gated independently server-side — Clients
* hosts both customer accounts and newsletters, and the backend supports a
* role holding `newsletters.view` without `customers.view` (#1264).
*/
permissionAny?: string[];
} }
// Sidebar shape after the Settings reorg (#feature-flags-settings-reorg). // Sidebar shape after the Settings reorg (#feature-flags-settings-reorg).
@@ -93,7 +100,9 @@ export const adminNavigation: NavItem[] = [
// their own permission keys and the gate here grows into an OR. // their own permission keys and the gate here grows into an OR.
{ {
nameKey: 'navigation.clients', href: '/admin/clients', icon: Briefcase, nameKey: 'navigation.clients', href: '/admin/clients', icon: Briefcase,
permission: 'customers.view', // Any of these opens the section; each sub-page is gated on its own
// permission once inside.
permissionAny: ['customers.view', 'newsletters.view'],
featureFlag: 'clients', featureFlag: 'clients',
// Hide the entry when the parent is on but no sub-feature is — // Hide the entry when the parent is on but no sub-feature is —
// there's nothing inside ClientsLayout to link to. Mirror the same // there's nothing inside ClientsLayout to link to. Mirror the same
@@ -108,6 +117,9 @@ export const adminNavigation: NavItem[] = [
featureFlagsAny: [ featureFlagsAny: [
'customerPortal', 'crmDevelopment', 'quotes', 'bills', 'customerPortal', 'crmDevelopment', 'quotes', 'bills',
'hoursLogging', 'contracts', 'calendar', 'projects', 'hoursLogging', 'contracts', 'calendar', 'projects',
// #1264 — newsletters is a Clients child and must light up the entry,
// or a newsletter-only install has no way into the section.
'newsletters',
], ],
}, },
// Accounting section (migration 122) — inbound supplier invoices, // Accounting section (migration 122) — inbound supplier invoices,
@@ -161,6 +173,8 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose, col
const filteredNavigation = adminNavigation.filter((item) => { const filteredNavigation = adminNavigation.filter((item) => {
if (item.permission && !hasPermission(item.permission as string)) return false; if (item.permission && !hasPermission(item.permission as string)) return false;
if (item.permissionAny?.length
&& !item.permissionAny.some((p) => hasPermission(p))) return false;
if (item.featureFlag && !flags[item.featureFlag]) return false; if (item.featureFlag && !flags[item.featureFlag]) return false;
// featureFlagsAny: entry is hidden when none of the listed // featureFlagsAny: entry is hidden when none of the listed
// sub-flags are on, even if the parent flag IS on. Used by // sub-flags are on, even if the parent flag IS on. Used by
@@ -12,11 +12,12 @@
* active item with white icon + label. * active item with white icon + label.
*/ */
import React from 'react'; import React from 'react';
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'; import { NavLink, Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Briefcase, UserCog, FileText, Receipt, Wrench, Clock, ScrollText, Calendar, FolderKanban } from 'lucide-react'; import { Briefcase, UserCog, FileText, Receipt, Wrench, Clock, ScrollText, Calendar, FolderKanban, Megaphone } from 'lucide-react';
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext'; import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext';
import { usePermissions } from '../../contexts/PermissionsContext';
interface NavItem { interface NavItem {
key: string; key: string;
@@ -30,6 +31,12 @@ interface NavItem {
* to declare their own sub-flag here. * to declare their own sub-flag here.
*/ */
featureFlag: FeatureKey; featureFlag: FeatureKey;
/**
* Permission required to reach the page behind this entry. Without it the
* item still rendered for anyone who could enter Clients at all, and the
* click landed on a backend 403 (#1264 review).
*/
permission?: string;
} }
export const ClientsLayout: React.FC = () => { export const ClientsLayout: React.FC = () => {
@@ -52,6 +59,7 @@ export const ClientsLayout: React.FC = () => {
label: t('clients.subnav.accounts', 'Accounts'), label: t('clients.subnav.accounts', 'Accounts'),
icon: UserCog, icon: UserCog,
featureFlag: 'customerPortal', featureFlag: 'customerPortal',
permission: 'customers.view',
}, },
{ {
key: 'calendar', key: 'calendar',
@@ -92,6 +100,14 @@ export const ClientsLayout: React.FC = () => {
// longer a CRM sub-feature). See AccountingLayout. // longer a CRM sub-feature). See AccountingLayout.
// Future sub-features: // Future sub-features:
// { key: 'messaging', ... featureFlag: 'messaging' } // { key: 'messaging', ... featureFlag: 'messaging' }
{
key: 'newsletters',
to: '/admin/clients/newsletters',
label: t('clients.subnav.newsletters', 'Newsletters'),
icon: Megaphone,
featureFlag: 'newsletters',
permission: 'newsletters.view',
},
{ {
key: 'development', key: 'development',
to: '/admin/clients/development', to: '/admin/clients/development',
@@ -101,7 +117,17 @@ export const ClientsLayout: React.FC = () => {
}, },
]; ];
const enabledItems = navItems.filter((item) => flags[item.featureFlag]); const { hasPermission } = usePermissions();
const enabledItems = navItems.filter((item) =>
flags[item.featureFlag] && (!item.permission || hasPermission(item.permission)));
// /admin/clients has no page of its own. Rather than a hard-coded redirect
// to Accounts — which a newsletters-only role cannot open — land on the
// first entry this user can actually reach.
const isSectionRoot = location.pathname.replace(/\/+$/, '') === '/admin/clients';
if (isSectionRoot && enabledItems.length > 0) {
return <Navigate to={enabledItems[0].to} replace />;
}
// When the parent `clients` flag is on but no sub-feature is enabled, // When the parent `clients` flag is on but no sub-feature is enabled,
// there's nothing to render. Settings → Features is one click away // there's nothing to render. Settings → Features is one click away
@@ -3,7 +3,16 @@ import { Navigate, Outlet } from 'react-router-dom';
import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext'; import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext';
interface RequireFeatureProps { interface RequireFeatureProps {
flag: FeatureKey; /** Single required flag. Mutually exclusive with `anyOf`. */
flag?: FeatureKey;
/**
* Pass when a surface belongs to more than one feature and should stay
* reachable while ANY of them is on. The customer detail page is the case
* this exists for: it hosts both the portal account fields and the
* newsletter consent control, so a newsletter-only install must still be
* able to open it (#1264).
*/
anyOf?: FeatureKey[];
fallback?: string; fallback?: string;
} }
@@ -15,11 +24,18 @@ interface RequireFeatureProps {
* Mounted as the `element` of a parent <Route>, with the gated routes as * Mounted as the `element` of a parent <Route>, with the gated routes as
* children — see App.tsx. * children — see App.tsx.
*/ */
export const RequireFeature: React.FC<RequireFeatureProps> = ({ flag, fallback = '/admin/dashboard' }) => { export const RequireFeature: React.FC<RequireFeatureProps> = ({
flag,
anyOf,
fallback = '/admin/dashboard',
}) => {
const { flags, isLoading } = useFeatureFlags(); const { flags, isLoading } = useFeatureFlags();
// Wait for the first fetch — otherwise we'd briefly fall back to the // Wait for the first fetch — otherwise we'd briefly fall back to the
// default-flags object and could redirect on a transient false. // default-flags object and could redirect on a transient false.
if (isLoading) return null; if (isLoading) return null;
if (!flags[flag]) return <Navigate to={fallback} replace />; const required = anyOf?.length ? anyOf : (flag ? [flag] : []);
if (required.length > 0 && !required.some((key) => flags[key])) {
return <Navigate to={fallback} replace />;
}
return <Outlet />; return <Outlet />;
}; };
@@ -72,6 +72,10 @@ export const DEFAULT_FLAGS: FeatureFlags = {
workflows: false, workflows: false,
// #1074 — off by default is the whole "zero behaviour change" guarantee. // #1074 — off by default is the whole "zero behaviour change" guarantee.
faces: false, faces: false,
// Newsletter campaigns (migration 199, #1264). Off by default — an
// install that never turns this on never gains a nav entry or a way to
// mass-mail its customers.
newsletters: false,
}; };
export const FEATURE_FLAGS_QUERY_KEY = ['feature-flags'] as const; export const FEATURE_FLAGS_QUERY_KEY = ['feature-flags'] as const;
@@ -131,6 +135,9 @@ function applyDependencyRules(flags: FeatureFlags): FeatureFlags {
// NOTE: taxReport is intentionally NOT here anymore — the Tax export // NOTE: taxReport is intentionally NOT here anymore — the Tax export
// moved permanently into the Accounting section (its own master). // moved permanently into the Accounting section (its own master).
// future siblings: || out.messaging // future siblings: || out.messaging
// #1264 — newsletters is a Clients child; without it here the staged
// sidebar preview disagrees with the server until Save.
|| out.newsletters
); );
return out; return out;
} }
@@ -25,6 +25,7 @@ import {
MonitorPlay, MonitorPlay,
Send, Send,
Workflow, Workflow,
Megaphone,
} from 'lucide-react'; } from 'lucide-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -383,6 +384,22 @@ export const FeaturesTab: React.FC = () => {
onToggle={(next) => setFlag('bills', next)} onToggle={(next) => setFlag('bills', next)}
/> />
{/* Newsletter campaigns (#1264). Clients child. Mass marketing mail
to customer accounts, so the copy leads with consent. */}
<FeatureCard
icon={Megaphone}
title={t('settings.features.newsletters.title', 'Newsletters')}
description={t(
'settings.features.newsletters.description',
'Send a marketing campaign to your customer accounts. Compose the body with the rich-text editor, preview it, send yourself a test, then queue it — sends are spread over time so your mail provider does not rate-limit you. Every customer can be opted out individually, opted-out customers are skipped automatically, and every campaign carries an unsubscribe link. Emails about galleries, quotes and invoices are never affected.',
)}
status="new"
statusLabel={statusLabel('new')}
sidebarLabel={t('settings.features.newsletters.sidebar', 'Newsletters')}
enabled={staged.newsletters}
onToggle={(next) => setFlag('newsletters', next)}
/>
<FeatureCard <FeatureCard
icon={Briefcase} icon={Briefcase}
title={t('settings.features.hoursLogging.title', 'Hours logging')} title={t('settings.features.hoursLogging.title', 'Hours logging')}
+104 -3
View File
@@ -3431,6 +3431,14 @@
"feature_flags_summary_one": "{{count}} Funktion aktualisiert: {{summary}}", "feature_flags_summary_one": "{{count}} Funktion aktualisiert: {{summary}}",
"feature_flags_summary_other": "{{count}} Funktionen aktualisiert: {{summary}}", "feature_flags_summary_other": "{{count}} Funktionen aktualisiert: {{summary}}",
"feature_flags_updated": "Funktionseinstellungen aktualisiert", "feature_flags_updated": "Funktionseinstellungen aktualisiert",
"newsletter_created": "Newsletter-Kampagne erstellt",
"newsletter_updated": "Newsletter-Kampagne aktualisiert",
"newsletter_test_sent": "Newsletter-Test-E-Mail gesendet",
"newsletter_queued": "Newsletter an {{recipients}} Empfänger eingereiht",
"newsletter_cancelled": "Newsletter-Kampagne abgebrochen",
"newsletter_completed": "Newsletter abgeschlossen — {{sent}} gesendet, {{failed}} fehlgeschlagen",
"newsletter_deleted": "Newsletter-Kampagne gelöscht",
"customer_marketing_opt_out": "Newsletter-Einwilligung des Kunden geändert",
"css_template_remote_urls_removed": "Externe URLs aus {{count}} CSS-Vorlage(n) entfernt — siehe Release Notes" "css_template_remote_urls_removed": "Externe URLs aus {{count}} CSS-Vorlage(n) entfernt — siehe Release Notes"
}, },
"people": { "people": {
@@ -4807,7 +4815,10 @@
"field": { "field": {
"featureHoursLogging": "Stundenerfassung", "featureHoursLogging": "Stundenerfassung",
"hourlyRate": "Standard-Stundensatz", "hourlyRate": "Standard-Stundensatz",
"hourlyRateHint": "Haupteinheiten (z. B. 150.00 für {{currency}} 150). Leer lassen, um pro Eintrag einen Satz zu verlangen." "hourlyRateHint": "Haupteinheiten (z. B. 150.00 für {{currency}} 150). Leer lassen, um pro Eintrag einen Satz zu verlangen.",
"marketingOptOut": "Vom Newsletter abgemeldet",
"marketingOptOutHelp": "Wenn aktiv, wird dieser Kunde von jeder Newsletter-Kampagne übersprungen. E-Mails zu Galerien, Offerten und Rechnungen sind nicht betroffen.",
"marketingOptOutSince": "Seit {{date}}"
}, },
"hours": { "hours": {
"section": "Stunden", "section": "Stunden",
@@ -5012,7 +5023,8 @@
"noBills": "Noch keine Rechnungen für diesen Kunden.", "noBills": "Noch keine Rechnungen für diesen Kunden.",
"rebillsSection": "Weiterverrechnungen & Durchlaufposten", "rebillsSection": "Weiterverrechnungen & Durchlaufposten",
"noRebills": "Noch keine weiterverrechneten oder durchlaufenden Lieferantenrechnungen für diesen Kunden.", "noRebills": "Noch keine weiterverrechneten oder durchlaufenden Lieferantenrechnungen für diesen Kunden.",
"companyName": "Firmenname" "companyName": "Firmenname",
"marketingSection": "Newsletter-Einwilligung"
}, },
"billing": { "billing": {
"section": "Abrechnungsrhythmus", "section": "Abrechnungsrhythmus",
@@ -5102,7 +5114,8 @@
"calendar": "Kalender", "calendar": "Kalender",
"bills": "Rechnungen", "bills": "Rechnungen",
"taxReport": "Steuer", "taxReport": "Steuer",
"development": "Entwicklung" "development": "Entwicklung",
"newsletters": "Newsletter"
} }
}, },
"accounting": { "accounting": {
@@ -6873,5 +6886,93 @@
"admin": "Admin", "admin": "Admin",
"super_admin": "Super-Admin" "super_admin": "Super-Admin"
} }
},
"newsletters": {
"title": "Newsletter",
"subtitle": "Senden Sie eine Kampagne an Ihre Kundenkonten. Abgemeldete Kunden werden automatisch übersprungen, und jede Sendung enthält einen Abmeldelink.",
"new": "Neue Kampagne",
"untitled": "Unbenannte Kampagne",
"untitledSubject": "Newsletter",
"empty": "Noch keine Kampagnen.",
"allStatuses": "Alle Status",
"filterByStatus": "Nach Status filtern",
"backToList": "Alle Kampagnen",
"recipientsTitle": "Empfänger",
"notEditable": "Diese Kampagne wurde bereits eingereiht und kann nicht mehr bearbeitet werden.",
"viewCampaign": "Kampagne ansehen",
"status": {
"draft": "Entwurf",
"queued": "Eingereiht",
"sending": "Wird gesendet",
"sent": "Gesendet",
"cancelled": "Abgebrochen",
"failed": "Fehlgeschlagen"
},
"recipientStatus": {
"queued": "Eingereiht",
"sent": "Gesendet",
"failed": "Fehlgeschlagen",
"cancelled": "Abgebrochen",
"skipped_opt_out": "Übersprungen (abgemeldet)"
},
"col": {
"name": "Name",
"status": "Status",
"recipients": "Empfänger",
"sent": "Gesendet",
"failed": "Fehlgeschlagen",
"created": "Erstellt"
},
"section": {
"content": "Inhalt",
"recipients": "Empfänger & Versand",
"preview": "Vorschau"
},
"field": {
"name": "Kampagnenname (intern)",
"subject": "Betreff",
"body": "Inhalt",
"rate": "Senderate (E-Mails pro Minute)",
"testTo": "Test senden an"
},
"mode": {
"allActive": "Alle aktiven Kunden",
"manual": "Kunden auswählen"
},
"showCss": "Eigenes CSS (optional)",
"hideCss": "Eigenes CSS ausblenden",
"cssHelp": "Viele E-Mail-Programme entfernen einen <style>-Block — halten Sie wichtige Gestaltung in Inline-Attributen. Externe Bilder und @import werden entfernt.",
"rateHelp": "Sendungen werden gestreckt, damit Ihr Mailanbieter nicht drosselt. Prüfen Sie das Stundenlimit Ihres Anbieters, bevor Sie diesen Wert erhöhen.",
"recipientCount": "{{count}} Empfänger",
"skippedOptOut": "{{count}} übersprungen (abgemeldet)",
"saveToRefresh": "Speichern, um diese Zahl zu aktualisieren.",
"refreshPreview": "Vorschau aktualisieren",
"previewTitle": "Newsletter-Vorschau",
"sendTest": "Test",
"queueButton": "Kampagne einreihen",
"queueBlocked": "Betreff, Inhalt und mindestens ein Empfänger werden zum Senden benötigt.",
"queueTitle": "Diese Kampagne senden?",
"queueBody": "Damit werden {{count}} Kunden mit {{rate}} pro Minute angeschrieben (rund {{minutes}} Min.). Sobald der Versand läuft, lässt sich das nicht rückgängig machen.",
"queueConfirm": "An {{count}} Kunden senden",
"cancel": "Kampagne abbrechen",
"cancelTitle": "Diese Kampagne abbrechen?",
"cancelBody": "Noch nicht versendete E-Mails werden verworfen. Bereits versendete E-Mails lassen sich nicht zurückholen.",
"cancelled": "{{count}} ausstehende E-Mails abgebrochen.",
"sendingAt": "Versand mit {{rate}} E-Mails pro Minute.",
"deleteTitle": "Kampagne löschen?",
"deleteBody": "\"{{name}}\" wird gelöscht. Das lässt sich nicht rückgängig machen.",
"deleteAria": "{{name}} löschen",
"saved": "Kampagne gespeichert.",
"queued": "Kampagne eingereiht.",
"deleted": "Kampagne gelöscht.",
"testSent": "Test-E-Mail an {{to}} gesendet.",
"createFailed": "Kampagne konnte nicht erstellt werden.",
"saveFailed": "Kampagne konnte nicht gespeichert werden.",
"deleteFailed": "Kampagne konnte nicht gelöscht werden.",
"previewFailed": "Vorschau konnte nicht erzeugt werden.",
"testFailed": "Test-E-Mail konnte nicht gesendet werden.",
"queueFailed": "Kampagne konnte nicht eingereiht werden.",
"cancelFailed": "Kampagne konnte nicht abgebrochen werden.",
"previewEmpty": "Aktualisieren Sie die Vorschau, um die E-Mail so zu sehen, wie ein Kunde sie erhält."
} }
} }
+104 -3
View File
@@ -2963,6 +2963,14 @@
"feature_flags_summary_one": "{{count}} features updated: {{summary}}", "feature_flags_summary_one": "{{count}} features updated: {{summary}}",
"feature_flags_summary_other": "{{count}} features updated: {{summary}}", "feature_flags_summary_other": "{{count}} features updated: {{summary}}",
"feature_flags_updated": "Feature settings updated", "feature_flags_updated": "Feature settings updated",
"newsletter_created": "Newsletter campaign created",
"newsletter_updated": "Newsletter campaign updated",
"newsletter_test_sent": "Newsletter test email sent",
"newsletter_queued": "Newsletter queued to {{recipients}} recipients",
"newsletter_cancelled": "Newsletter campaign cancelled",
"newsletter_completed": "Newsletter finished — {{sent}} sent, {{failed}} failed",
"newsletter_deleted": "Newsletter campaign deleted",
"customer_marketing_opt_out": "Customer newsletter consent changed",
"css_template_remote_urls_removed": "Remote URLs removed from {{count}} CSS template(s) — see the release notes" "css_template_remote_urls_removed": "Remote URLs removed from {{count}} CSS template(s) — see the release notes"
}, },
"people": { "people": {
@@ -4807,7 +4815,10 @@
"field": { "field": {
"featureHoursLogging": "Hours logging", "featureHoursLogging": "Hours logging",
"hourlyRate": "Default hourly rate", "hourlyRate": "Default hourly rate",
"hourlyRateHint": "Major units (e.g. 150.00 for {{currency}} 150). Leave blank to require a per-entry override on every block." "hourlyRateHint": "Major units (e.g. 150.00 for {{currency}} 150). Leave blank to require a per-entry override on every block.",
"marketingOptOut": "Unsubscribed from newsletters",
"marketingOptOutHelp": "When on, this customer is skipped by every newsletter campaign. Emails about their galleries, quotes and invoices are not affected.",
"marketingOptOutSince": "Since {{date}}"
}, },
"hours": { "hours": {
"section": "Hours", "section": "Hours",
@@ -5012,7 +5023,8 @@
"manageEvents": "Manage galleries", "manageEvents": "Manage galleries",
"rebillsSection": "Re-bills & passthrough", "rebillsSection": "Re-bills & passthrough",
"noRebills": "No re-billed or passed-through supplier invoices for this customer yet.", "noRebills": "No re-billed or passed-through supplier invoices for this customer yet.",
"companyName": "Company name" "companyName": "Company name",
"marketingSection": "Newsletter consent"
}, },
"billing": { "billing": {
"section": "Billing cadence", "section": "Billing cadence",
@@ -5102,7 +5114,8 @@
"calendar": "Calendar", "calendar": "Calendar",
"bills": "Invoices", "bills": "Invoices",
"taxReport": "Tax", "taxReport": "Tax",
"development": "Development" "development": "Development",
"newsletters": "Newsletters"
} }
}, },
"accounting": { "accounting": {
@@ -6872,5 +6885,93 @@
"admin": "Admin", "admin": "Admin",
"super_admin": "Super Admin" "super_admin": "Super Admin"
} }
},
"newsletters": {
"title": "Newsletters",
"subtitle": "Send a campaign to your customer accounts. Everyone who has opted out is skipped automatically, and every send carries an unsubscribe link.",
"new": "New campaign",
"untitled": "Untitled campaign",
"untitledSubject": "Newsletter",
"empty": "No campaigns yet.",
"allStatuses": "All statuses",
"filterByStatus": "Filter by status",
"backToList": "All campaigns",
"recipientsTitle": "Recipients",
"notEditable": "This campaign has already been queued and can no longer be edited.",
"viewCampaign": "View campaign",
"status": {
"draft": "Draft",
"queued": "Queued",
"sending": "Sending",
"sent": "Sent",
"cancelled": "Cancelled",
"failed": "Failed"
},
"recipientStatus": {
"queued": "Queued",
"sent": "Sent",
"failed": "Failed",
"cancelled": "Cancelled",
"skipped_opt_out": "Skipped (opted out)"
},
"col": {
"name": "Name",
"status": "Status",
"recipients": "Recipients",
"sent": "Sent",
"failed": "Failed",
"created": "Created"
},
"section": {
"content": "Content",
"recipients": "Recipients & send",
"preview": "Preview"
},
"field": {
"name": "Campaign name (internal)",
"subject": "Subject",
"body": "Body",
"rate": "Send rate (emails per minute)",
"testTo": "Send a test to"
},
"mode": {
"allActive": "All active customers",
"manual": "Pick customers"
},
"showCss": "Custom CSS (optional)",
"hideCss": "Hide custom CSS",
"cssHelp": "Many email clients drop a <style> block — keep the important styling on inline attributes. Remote images and @import are stripped.",
"rateHelp": "Sends are spread out so your mail provider does not rate-limit you. Check your provider's hourly cap before raising this.",
"recipientCount": "{{count}} recipients",
"skippedOptOut": "{{count}} skipped (opted out)",
"saveToRefresh": "Save to refresh this count.",
"refreshPreview": "Refresh preview",
"previewTitle": "Newsletter preview",
"sendTest": "Test",
"queueButton": "Queue campaign",
"queueBlocked": "A subject, a body and at least one recipient are needed before sending.",
"queueTitle": "Send this campaign?",
"queueBody": "This will email {{count}} customers at {{rate}} per minute (roughly {{minutes}} min). It cannot be undone once messages start going out.",
"queueConfirm": "Send to {{count}} customers",
"cancel": "Cancel campaign",
"cancelTitle": "Cancel this campaign?",
"cancelBody": "Emails that have not gone out yet will be dropped. Emails already sent cannot be recalled.",
"cancelled": "{{count}} pending emails cancelled.",
"sendingAt": "Sending at {{rate}} emails per minute.",
"deleteTitle": "Delete campaign?",
"deleteBody": "\"{{name}}\" will be deleted. This cannot be undone.",
"deleteAria": "Delete {{name}}",
"saved": "Campaign saved.",
"queued": "Campaign queued.",
"deleted": "Campaign deleted.",
"testSent": "Test email sent to {{to}}.",
"createFailed": "Could not create the campaign.",
"saveFailed": "Could not save the campaign.",
"deleteFailed": "Could not delete the campaign.",
"previewFailed": "Could not render the preview.",
"testFailed": "Could not send the test email.",
"queueFailed": "Could not queue the campaign.",
"cancelFailed": "Could not cancel the campaign.",
"previewEmpty": "Refresh the preview to see the email as a customer will."
} }
} }
@@ -15,7 +15,7 @@ import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { import {
ArrowLeft, Mail, MapPin, Phone, Building2, Save, Trash2, AlertTriangle, ArrowLeft, Mail, MapPin, Phone, Building2, Save, Trash2, AlertTriangle,
CheckCircle2, X, FileText, Calendar, KeyRound, ToggleLeft, Settings as SettingsIcon, CheckCircle2, X, FileText, Calendar, KeyRound, ToggleLeft, Settings as SettingsIcon, Megaphone,
} from 'lucide-react'; } from 'lucide-react';
import { Button, Card, CountrySelect, Input, Loading } from '../../components/common'; import { Button, Card, CountrySelect, Input, Loading } from '../../components/common';
@@ -40,7 +40,8 @@ type EditableFields =
| 'addressLine1' | 'addressLine2' | 'postalCode' | 'city' | 'state' | 'addressLine1' | 'addressLine2' | 'postalCode' | 'city' | 'state'
| 'countryCode' | 'countryName' | 'preferredLanguage' | 'notes' | 'countryCode' | 'countryName' | 'preferredLanguage' | 'notes'
| 'featureCalendar' | 'featureQuotes' | 'featureBills' | 'featureHoursLogging' | 'featureContracts' | 'featureCalendar' | 'featureQuotes' | 'featureBills' | 'featureHoursLogging' | 'featureContracts'
| 'hourlyRateMinor' | 'billingCadence' | 'billingCycleDay' | 'skontoDisabled' | 'rebillAttachProof'; | 'hourlyRateMinor' | 'billingCadence' | 'billingCycleDay' | 'skontoDisabled' | 'rebillAttachProof'
| 'marketingOptOut';
// `fmtDate` (from useLocalizedDate, below) is the single canonical date // `fmtDate` (from useLocalizedDate, below) is the single canonical date
// formatter. It honors the admin's `general_date_format` setting AND // formatter. It honors the admin's `general_date_format` setting AND
@@ -147,6 +148,8 @@ export const CustomerDetailPage: React.FC = () => {
// Tri-state (null = inherit global). Kept as-is so the select can show // Tri-state (null = inherit global). Kept as-is so the select can show
// "Inherit" distinctly from an explicit on/off (#866). // "Inherit" distinctly from an explicit on/off (#866).
rebillAttachProof: customer.rebillAttachProof ?? null, rebillAttachProof: customer.rebillAttachProof ?? null,
// Newsletter consent (#1264). Opt-OUT, so the default is false.
marketingOptOut: customer.marketingOptOut ?? false,
} as any); } as any);
} }
}, [customer, form]); }, [customer, form]);
@@ -569,6 +572,46 @@ export const CustomerDetailPage: React.FC = () => {
toggle inside it is OFF — an empty "Customer features" card toggle inside it is OFF — an empty "Customer features" card
with just a title + hint reads as broken. The Card reappears with just a title + hint reads as broken. The Card reappears
the moment any master flag is re-enabled. */} the moment any master flag is re-enabled. */}
{/* Newsletter consent (migration 199, #1264). Its OWN card, gated
only by `newsletters`: a consent record is not a per-customer
feature override, and the features card above hides itself when
the other CRM flags are off — which would make this unreachable on
an install that runs newsletters alone. Admin-settable so a
customer who unsubscribes by phone can be honoured immediately. */}
{flags.newsletters && (
<Card padding="lg">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
<Megaphone className="w-5 h-5" />
{t('customers.detail.marketingSection', 'Newsletter consent')}
</h2>
<label className="flex items-start justify-between gap-3 cursor-pointer">
<span className="text-sm">
<span className="font-medium text-neutral-900 dark:text-neutral-100">
{t('customers.field.marketingOptOut', 'Unsubscribed from newsletters')}
</span>
<span className="block text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">
{t('customers.field.marketingOptOutHelp',
'When on, this customer is skipped by every newsletter campaign. Emails about their galleries, quotes and invoices are not affected.')}
</span>
{form.marketingOptOut && customer?.marketingOptOutAt && (
<span className="block text-xs text-neutral-400 dark:text-neutral-500 mt-1">
{t('customers.field.marketingOptOutSince', 'Since {{date}}',
{ date: fmtDate(customer.marketingOptOutAt) })}
</span>
)}
</span>
<input
type="checkbox"
className="mt-1 h-4 w-4 shrink-0"
checked={!!form.marketingOptOut}
onChange={(e) => setForm((prev) => ({
...prev, marketingOptOut: e.target.checked,
} as any))}
/>
</label>
</Card>
)}
{(flags.calendar || flags.quotes || flags.bills || flags.hoursLogging || flags.contracts) && ( {(flags.calendar || flags.quotes || flags.bills || flags.hoursLogging || flags.contracts) && (
<Card padding="lg"> <Card padding="lg">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1 flex items-center gap-2"> <h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1 flex items-center gap-2">
@@ -677,6 +720,7 @@ export const CustomerDetailPage: React.FC = () => {
</p> </p>
</div> </div>
)} )}
</Card> </Card>
)} )}
@@ -87,6 +87,61 @@ describe('dashboard activity feed interpolation', () => {
}); });
}); });
describe('newsletter activity strings (#1264)', () => {
beforeAll(async () => {
await i18n.changeLanguage('en');
});
// The exact metadata newsletterService writes for each type. Anything that
// drifts between the log call and the i18n string surfaces as a raw
// `{{token}}` in the dashboard feed and the bell.
const CASES: Array<[string, Record<string, unknown>]> = [
['newsletter_created', { campaignId: 1, name: 'Spring' }],
['newsletter_updated', { campaignId: 1, fields: ['subject'] }],
['newsletter_test_sent', { campaignId: 1, to: '[email protected]' }],
['newsletter_queued', { campaignId: 1, name: 'Spring', recipients: 42, skippedOptOut: 3, sendRatePerMinute: 10 }],
['newsletter_cancelled', { campaignId: 1, name: 'Spring', cancelledRows: 12 }],
['newsletter_completed', { campaignId: 1, name: 'Spring', sent: 40, failed: 2 }],
['newsletter_deleted', { campaignId: 1, name: 'Spring' }],
['customer_marketing_opt_out', { customerId: 7, optOut: true, source: 'link' }],
];
it.each(CASES)('renders %s without a raw placeholder', (type, metadata) => {
const msg = render(activity(type, metadata));
expect(msg).not.toContain('{{');
// A missing key would render the key path itself.
expect(msg).not.toContain('admin.activities.');
});
it('interpolates the recipient count into newsletter_queued', () => {
const msg = render(activity('newsletter_queued', { name: 'Spring', recipients: 42 }));
expect(msg).toContain('42');
});
it('interpolates both counts into newsletter_completed', () => {
const msg = render(activity('newsletter_completed', { name: 'Spring', sent: 40, failed: 2 }));
expect(msg).toContain('40');
expect(msg).toContain('2');
});
it('renders every newsletter string in German too', async () => {
await i18n.changeLanguage('de');
for (const [type, metadata] of CASES) {
const msg = render(activity(type, metadata));
expect(msg).not.toContain('{{');
expect(msg).not.toContain('admin.activities.');
}
await i18n.changeLanguage('en');
});
it('renders in the notification bell as well as the dashboard feed', () => {
for (const [type, metadata] of CASES) {
const msg = notificationsService.formatNotificationMessage(notification(type, metadata));
expect(msg).not.toContain('{{');
}
});
});
describe('notification bell interpolation', () => { describe('notification bell interpolation', () => {
beforeAll(async () => { beforeAll(async () => {
await i18n.changeLanguage('en'); await i18n.changeLanguage('en');
@@ -0,0 +1,460 @@
/**
* Clients → Newsletters → composer (#1264).
*
* Three columns: content, recipients, preview & send.
*
* The recipient count is a live server-side dry run rather than a
* client-side estimate — the number in the confirm dialog has to be the
* number the server will actually mail, including its opt-out filtering, or
* the confirmation is theatre.
*
* The preview renders in a `sandbox`-ed iframe with no `allow-scripts`. The
* body is already sanitized server-side; this is defence in depth, and it is
* the only place campaign HTML is ever put in a DOM.
*/
import React, { useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Save, Send, TestTube2, Users, Eye, ArrowLeft } from 'lucide-react';
import { toast } from 'react-toastify';
import { Button, Card, Input, Loading, useConfirm } from '../../../components/common';
import { EmailTemplateEditor } from '../../../components/admin/EmailTemplateEditor';
import {
newslettersService, type Campaign, type RecipientMode,
} from '../../../services/newsletters.service';
import { customerAdminService } from '../../../services/customerAdmin.service';
import { usePermissions } from '../../../contexts/PermissionsContext';
/** Variables the server substitutes per recipient. */
const VARIABLES = [
'customer_name', 'first_name', 'last_name', 'salutation',
'company_name', 'support_email', 'unsubscribe_url',
];
export const NewsletterComposerPage: React.FC = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const campaignId = Number(id);
const navigate = useNavigate();
const confirm = useConfirm();
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['newsletter', campaignId],
queryFn: () => newslettersService.get(campaignId),
enabled: Number.isFinite(campaignId),
});
const [draft, setDraft] = useState<Campaign | null>(null);
useEffect(() => { if (data?.campaign) setDraft(data.campaign); }, [data]);
// The manual picker reads /admin/customers, which is gated on
// `customers.view` — a role holding only the newsletter permissions would
// get an empty list with no explanation (#1264 review).
const { hasPermission } = usePermissions();
const canPickCustomers = hasPermission('customers.view');
const [testEmail, setTestEmail] = useState('');
const [previewHtml, setPreviewHtml] = useState('');
const [showCss, setShowCss] = useState(false);
const patch = (changes: Partial<Campaign>) =>
setDraft((prev) => (prev ? { ...prev, ...changes } : prev));
// ---- recipients dry run -------------------------------------------------
// Re-runs whenever the recipient rule changes, so the count on screen and
// the count in the confirm dialog are always the server's own answer.
const { data: resolution, refetch: refetchRecipients } = useQuery({
queryKey: ['newsletter-recipients', campaignId],
queryFn: () => newslettersService.resolveRecipients(campaignId),
enabled: Number.isFinite(campaignId) && !!draft,
});
const { data: customers } = useQuery({
queryKey: ['customers-for-newsletter'],
queryFn: () => customerAdminService.list(),
enabled: draft?.recipientMode === 'manual' && canPickCustomers,
});
const save = useMutation({
mutationFn: async () => {
if (!draft) throw new Error('no draft');
return newslettersService.update(campaignId, {
name: draft.name,
subject: draft.subject,
bodyHtml: draft.bodyHtml,
bodyCss: draft.bodyCss,
language: draft.language,
recipientMode: draft.recipientMode,
customerIds: draft.customerIds,
sendRatePerMinute: draft.sendRatePerMinute,
});
},
onSuccess: (campaign) => {
setDraft(campaign);
queryClient.invalidateQueries({ queryKey: ['newsletter', campaignId] });
queryClient.invalidateQueries({ queryKey: ['newsletters'] });
refetchRecipients();
},
});
// Every server-side action below renders or sends the STORED campaign, but
// the editor's state lives in `draft` until Save runs. Previewing, testing
// or queueing straight after an edit therefore acted on the previous
// version — the operator would proof one body and mail another. Persist
// first, always, so what is checked is what goes out.
const persistDraft = () => save.mutateAsync();
const loadPreview = async () => {
try {
await persistDraft();
const res = await newslettersService.preview(campaignId, {});
setPreviewHtml(res.html);
} catch {
toast.error(t('newsletters.previewFailed', 'Could not render the preview.'));
}
};
const sendTest = async () => {
try {
await persistDraft();
await newslettersService.sendTest(campaignId, testEmail);
toast.success(t('newsletters.testSent', 'Test email sent to {{to}}.', { to: testEmail }));
} catch {
toast.error(t('newsletters.testFailed', 'Could not send the test email.'));
}
};
const queueCampaign = async () => {
// Save BEFORE resolving the count and confirming: the dialog must quote
// the recipient rule that is about to be used, not the one from before
// the operator's last edit.
let fresh;
try {
await persistDraft();
// Use what the refetch RETURNS. `resolution` is captured from the
// render that produced this callback, so reading it here quotes the
// count from before the operator's last recipient change — the dialog
// would promise "all active customers" while the backend queues the
// manual selection just saved.
fresh = (await refetchRecipients()).data;
} catch {
toast.error(t('newsletters.saveFailed', 'Could not save the campaign.'));
return;
}
const count = fresh?.recipientCount ?? 0;
const ok = await confirm({
title: t('newsletters.queueTitle', 'Send this campaign?') as string,
message: t('newsletters.queueBody',
'This will email {{count}} customers at {{rate}} per minute (roughly {{minutes}} min). It cannot be undone once messages start going out.',
{
count,
rate: fresh?.sendRatePerMinute ?? draft?.sendRatePerMinute ?? 10,
minutes: fresh?.estimatedMinutes ?? 1,
}) as string,
confirmLabel: t('newsletters.queueConfirm', 'Send to {{count}} customers', { count }) as string,
variant: 'danger',
});
if (!ok) return;
try {
await newslettersService.queue(campaignId);
queryClient.invalidateQueries({ queryKey: ['newsletters'] });
toast.success(t('newsletters.queued', 'Campaign queued.'));
navigate(`/admin/clients/newsletters/${campaignId}`);
} catch {
toast.error(t('newsletters.queueFailed', 'Could not queue the campaign.'));
}
};
// A campaign with no subject, no body or nobody to send to must not be
// sendable — the button is the last place to catch that before 2 000
// people get a blank email.
const canQueue = useMemo(() => Boolean(
draft
&& draft.status === 'draft'
&& draft.subject.trim()
&& draft.bodyHtml.trim()
&& (resolution?.recipientCount ?? 0) > 0
), [draft, resolution]);
if (isLoading || !draft) return <Loading />;
if (draft.status !== 'draft') {
return (
<Card>
<p className="text-neutral-700 dark:text-neutral-300">
{t('newsletters.notEditable',
'This campaign has already been queued and can no longer be edited.')}
</p>
<Button
variant="outline"
className="mt-4"
onClick={() => navigate(`/admin/clients/newsletters/${campaignId}`)}
>
{t('newsletters.viewCampaign', 'View campaign')}
</Button>
</Card>
);
}
return (
<div>
<div className="flex items-center justify-between mb-6 gap-4">
<button
type="button"
onClick={() => navigate('/admin/clients/newsletters')}
className="flex items-center gap-1 text-sm text-neutral-600 dark:text-neutral-400 hover:underline"
>
<ArrowLeft className="w-4 h-4" />
{t('newsletters.backToList', 'All campaigns')}
</button>
<Button
onClick={async () => {
try {
await persistDraft();
toast.success(t('newsletters.saved', 'Campaign saved.'));
} catch {
toast.error(t('newsletters.saveFailed', 'Could not save the campaign.'));
}
}}
isLoading={save.isPending}
leftIcon={<Save className="w-4 h-4" />}
>
{t('common.save', 'Save')}
</Button>
</div>
{/* Two columns, not three. An email body is 600px wide and the editor
toolbar has ~14 controls; giving each of the three panels an equal
third left the toolbar wrapping onto seven rows and the body being
composed in a box narrower than a phone, while the Recipients panel —
two radios, a count and one number field — sat mostly empty. Compose
gets the width, the send settings get the rail, and the preview moves
full-width below where it can render at true email size. */}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
{/* ---- 1. Content ---- */}
<Card className="xl:col-span-2">
<h3 className="font-semibold mb-4 text-neutral-900 dark:text-neutral-100">
{t('newsletters.section.content', 'Content')}
</h3>
<div className="space-y-4">
<Input
label={t('newsletters.field.name', 'Campaign name (internal)') as string}
value={draft.name}
onChange={(e) => patch({ name: e.target.value })}
/>
<Input
label={t('newsletters.field.subject', 'Subject') as string}
value={draft.subject}
maxLength={255}
onChange={(e) => patch({ subject: e.target.value })}
/>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('newsletters.field.body', 'Body')}
</label>
<EmailTemplateEditor
content={draft.bodyHtml}
onChange={(html) => patch({ bodyHtml: html })}
variables={VARIABLES}
/>
</div>
<div>
<button
type="button"
onClick={() => setShowCss((v) => !v)}
className="text-sm hover:underline"
style={{ color: 'var(--color-accent)' }}
>
{showCss
? t('newsletters.hideCss', 'Hide custom CSS')
: t('newsletters.showCss', 'Custom CSS (optional)')}
</button>
{showCss && (
<>
<textarea
rows={6}
value={draft.bodyCss}
onChange={(e) => patch({ bodyCss: e.target.value })}
placeholder=".cta { background: #5C8762; color: #fff; }"
className="mt-2 w-full font-mono text-xs rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2"
/>
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
{t('newsletters.cssHelp',
'Many email clients drop a <style> block — keep the important styling on inline attributes. Remote images and @import are stripped.')}
</p>
</>
)}
</div>
</div>
</Card>
{/* ---- 2. Recipients ---- */}
<Card>
<div className="flex items-center gap-2 mb-4">
<Users className="w-5 h-5 text-neutral-500" />
<h3 className="font-semibold text-neutral-900 dark:text-neutral-100">
{t('newsletters.section.recipients', 'Recipients & send')}
</h3>
</div>
<div className="space-y-2 mb-4">
{(['all_active', 'manual'] as RecipientMode[])
.filter((mode) => mode === 'all_active' || canPickCustomers)
.map((mode) => (
<label key={mode} className="flex items-start gap-2 cursor-pointer">
<input
type="radio"
name="recipientMode"
className="mt-1"
checked={draft.recipientMode === mode}
onChange={() => patch({ recipientMode: mode })}
/>
<span className="text-sm">
<span className="font-medium text-neutral-900 dark:text-neutral-100">
{mode === 'all_active'
? t('newsletters.mode.allActive', 'All active customers')
: t('newsletters.mode.manual', 'Pick customers')}
</span>
</span>
</label>
))}
</div>
{draft.recipientMode === 'manual' && (
<div className="mb-4 max-h-64 overflow-y-auto border border-neutral-200 dark:border-neutral-700 rounded-md p-2">
{(customers ?? []).map((c) => (
<label key={c.id} className="flex items-center gap-2 py-1 cursor-pointer text-sm">
<input
type="checkbox"
checked={draft.customerIds.includes(c.id)}
onChange={(e) => patch({
customerIds: e.target.checked
? [...draft.customerIds, c.id]
: draft.customerIds.filter((x) => x !== c.id),
})}
/>
<span className="text-neutral-800 dark:text-neutral-200">
{c.displayName || c.email}
</span>
</label>
))}
</div>
)}
{/* The server's own count, not a local estimate. */}
<div
data-testid="recipient-summary"
className="rounded-md bg-neutral-50 dark:bg-neutral-800/60 p-3 text-sm"
>
<p className="font-medium text-neutral-900 dark:text-neutral-100">
{t('newsletters.recipientCount', '{{count}} recipients',
{ count: resolution?.recipientCount ?? 0 })}
</p>
{(resolution?.skippedOptOut ?? 0) > 0 && (
<p className="text-neutral-600 dark:text-neutral-400 mt-1">
{t('newsletters.skippedOptOut', '{{count}} skipped (opted out)',
{ count: resolution?.skippedOptOut ?? 0 })}
</p>
)}
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-2">
{t('newsletters.saveToRefresh', 'Save to refresh this count.')}
</p>
</div>
<div className="mt-4">
<Input
type="number"
min={1}
// 10 is what the queue can actually deliver: the processor takes
// 10 rows once a minute, globally. Anything higher was rejected
// server-side after passing this control.
max={10}
label={t('newsletters.field.rate', 'Send rate (emails per minute)') as string}
value={String(draft.sendRatePerMinute)}
onChange={(e) => patch({ sendRatePerMinute: Number(e.target.value) })}
/>
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
{t('newsletters.rateHelp',
'Sends are spread out so your mail provider does not rate-limit you. Check your provider\'s hourly cap before raising this.')}
</p>
</div>
{/* Test + queue live with the recipient rule they act on. */}
<div className="mt-6 pt-4 border-t border-neutral-200 dark:border-neutral-700 space-y-3">
<div className="flex gap-2 items-end">
<div className="flex-1">
<Input
type="email"
label={t('newsletters.field.testTo', 'Send a test to') as string}
value={testEmail}
onChange={(e) => setTestEmail(e.target.value)}
placeholder="[email protected]"
/>
</div>
<Button
variant="outline"
onClick={sendTest}
disabled={!testEmail}
leftIcon={<TestTube2 className="w-4 h-4" />}
>
{t('newsletters.sendTest', 'Test')}
</Button>
</div>
<Button
onClick={queueCampaign}
disabled={!canQueue}
className="w-full"
leftIcon={<Send className="w-4 h-4" />}
>
{t('newsletters.queueButton', 'Queue campaign')}
</Button>
{!canQueue && (
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('newsletters.queueBlocked',
'A subject, a body and at least one recipient are needed before sending.')}
</p>
)}
</div>
</Card>
</div>
{/* ---- Preview, full width ---- */}
<Card className="mt-6">
<div className="flex items-center justify-between gap-4 mb-4">
<div className="flex items-center gap-2">
<Eye className="w-5 h-5 text-neutral-500" />
<h3 className="font-semibold text-neutral-900 dark:text-neutral-100">
{t('newsletters.section.preview', 'Preview')}
</h3>
</div>
<Button variant="outline" onClick={loadPreview}>
{t('newsletters.refreshPreview', 'Refresh preview')}
</Button>
</div>
{previewHtml ? (
<iframe
data-testid="newsletter-preview"
title={t('newsletters.previewTitle', 'Newsletter preview') as string}
// No allow-scripts. The body is sanitized server-side; this is
// the second line of defence, and it is the only DOM campaign
// HTML ever reaches.
sandbox=""
srcDoc={previewHtml}
// 680px: the 600px email plus its wrapper padding, so it renders
// at the width a recipient sees instead of side-scrolling.
className="w-full max-w-[680px] mx-auto h-[640px] border border-neutral-200 dark:border-neutral-700 rounded-md bg-white"
/>
) : (
<div className="max-w-[680px] mx-auto h-[240px] rounded-md border border-dashed border-neutral-300 dark:border-neutral-600 flex items-center justify-center text-sm text-neutral-500 dark:text-neutral-400">
{t('newsletters.previewEmpty', 'Refresh the preview to see the email as a customer will.')}
</div>
)}
</Card>
</div>
);
};
@@ -0,0 +1,218 @@
/**
* Clients → Newsletters → campaign detail (#1264).
*
* The delivery record. While a campaign is sending this polls so the
* operator can watch it drain — and, more to the point, so they can hit
* Cancel while there are still pending rows to cancel.
*
* Per-recipient errors are shown verbatim: a bounced address is the one
* thing the operator can actually act on afterwards.
*/
import React, { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, Ban } from 'lucide-react';
import { toast } from 'react-toastify';
import { Button, Card, Loading, useConfirm } from '../../../components/common';
import { usePermissions } from '../../../contexts/PermissionsContext';
import {
newslettersService, type RecipientStatus,
} from '../../../services/newsletters.service';
import { StatusChip } from './NewsletterListPage';
const RECIPIENT_STATUS_STYLES: Record<RecipientStatus, string> = {
queued: 'text-neutral-600 dark:text-neutral-400',
sent: 'text-green-700 dark:text-green-400',
failed: 'text-red-700 dark:text-red-400',
cancelled: 'text-neutral-400 dark:text-neutral-500',
skipped_opt_out: 'text-amber-700 dark:text-amber-400',
};
export const NewsletterDetailPage: React.FC = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const campaignId = Number(id);
const navigate = useNavigate();
const confirm = useConfirm();
const queryClient = useQueryClient();
// Cancel stops a live send, so it is a `send` action. Progress and the
// recipient table stay visible to a view-only role (#1264 review).
const { hasPermission } = usePermissions();
const canSend = hasPermission('newsletters.send');
const [statusFilter, setStatusFilter] = useState<RecipientStatus | ''>('');
const [page, setPage] = useState(1);
const { data, isLoading } = useQuery({
queryKey: ['newsletter', campaignId],
queryFn: () => newslettersService.get(campaignId),
enabled: Number.isFinite(campaignId),
// Poll only while it is actually moving. A finished campaign is a
// static record and does not need to be re-fetched every few seconds.
refetchInterval: (query) => {
const status = query.state.data?.campaign.status;
return status === 'queued' || status === 'sending' ? 5000 : false;
},
});
const { data: recipients } = useQuery({
queryKey: ['newsletter-recipients-list', campaignId, statusFilter, page],
queryFn: () => newslettersService.recipients(campaignId, {
page, limit: 25, status: statusFilter || undefined,
}),
enabled: Number.isFinite(campaignId),
// Poll alongside the summary while the campaign is draining — otherwise
// every row sat at "queued" until the operator reloaded, while the
// counters above them climbed.
refetchInterval: data?.campaign.status === 'queued' || data?.campaign.status === 'sending'
? 5000
: false,
});
const cancelCampaign = async () => {
const ok = await confirm({
title: t('newsletters.cancelTitle', 'Cancel this campaign?') as string,
message: t('newsletters.cancelBody',
'Emails that have not gone out yet will be dropped. Emails already sent cannot be recalled.') as string,
variant: 'danger',
});
if (!ok) return;
try {
const res = await newslettersService.cancel(campaignId);
queryClient.invalidateQueries({ queryKey: ['newsletter', campaignId] });
queryClient.invalidateQueries({ queryKey: ['newsletter-recipients-list'] });
toast.success(t('newsletters.cancelled', '{{count}} pending emails cancelled.',
{ count: res.cancelled }));
} catch {
toast.error(t('newsletters.cancelFailed', 'Could not cancel the campaign.'));
}
};
if (isLoading || !data) return <Loading />;
const { campaign } = data;
const inFlight = campaign.status === 'queued' || campaign.status === 'sending';
const progress = campaign.recipientCount > 0
? Math.round(((campaign.sentCount + campaign.failedCount) / campaign.recipientCount) * 100)
: 0;
return (
<div>
<div className="flex items-center justify-between mb-6 gap-4">
<button
type="button"
onClick={() => navigate('/admin/clients/newsletters')}
className="flex items-center gap-1 text-sm text-neutral-600 dark:text-neutral-400 hover:underline"
>
<ArrowLeft className="w-4 h-4" />
{t('newsletters.backToList', 'All campaigns')}
</button>
{inFlight && canSend && (
<Button variant="outline" onClick={cancelCampaign} leftIcon={<Ban className="w-4 h-4" />}>
{t('newsletters.cancel', 'Cancel campaign')}
</Button>
)}
</div>
<Card className="mb-6">
<div className="flex items-start justify-between gap-4 mb-4">
<div>
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
{campaign.name}
</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400">{campaign.subject}</p>
</div>
<StatusChip status={campaign.status} />
</div>
<div className="grid grid-cols-3 gap-4 text-center">
{([
['recipients', campaign.recipientCount, ''],
['sent', campaign.sentCount, 'text-green-700 dark:text-green-400'],
['failed', campaign.failedCount, campaign.failedCount > 0 ? 'text-red-700 dark:text-red-400' : ''],
] as const).map(([key, value, cls]) => (
<div key={key} className="rounded-md bg-neutral-50 dark:bg-neutral-800/60 p-3">
<div className={`text-2xl font-semibold tabular-nums ${cls}`}>{value}</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400 uppercase tracking-wide mt-1">
{t(`newsletters.col.${key}`, key)}
</div>
</div>
))}
</div>
{inFlight && (
<div className="mt-4">
<div className="h-2 rounded-full bg-neutral-200 dark:bg-neutral-700 overflow-hidden">
<div
data-testid="newsletter-progress"
className="h-full rounded-full transition-all"
style={{ width: `${progress}%`, backgroundColor: 'var(--color-accent)' }}
/>
</div>
<p className="mt-2 text-xs text-neutral-500 dark:text-neutral-400">
{t('newsletters.sendingAt', 'Sending at {{rate}} emails per minute.',
{ rate: campaign.sendRatePerMinute })}
</p>
</div>
)}
</Card>
<Card padding="none">
<div className="p-4 flex items-center justify-between gap-4 border-b border-neutral-200 dark:border-neutral-700">
<h3 className="font-semibold text-neutral-900 dark:text-neutral-100">
{t('newsletters.recipientsTitle', 'Recipients')}
</h3>
<select
aria-label={t('newsletters.filterByStatus', 'Filter by status') as string}
value={statusFilter}
onChange={(e) => { setStatusFilter(e.target.value as RecipientStatus | ''); setPage(1); }}
className="rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-1.5 text-sm"
>
<option value="">{t('newsletters.allStatuses', 'All statuses')}</option>
{(['queued', 'sent', 'failed', 'cancelled', 'skipped_opt_out'] as RecipientStatus[])
.map((s) => (
<option key={s} value={s}>{t(`newsletters.recipientStatus.${s}`, s)}</option>
))}
</select>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<tbody>
{(recipients?.data ?? []).map((r) => (
<tr key={r.id} className="border-b border-neutral-100 dark:border-neutral-800 last:border-0">
<td className="px-4 py-2 text-neutral-800 dark:text-neutral-200">{r.email}</td>
<td className={`px-4 py-2 ${RECIPIENT_STATUS_STYLES[r.status]}`}>
{t(`newsletters.recipientStatus.${r.status}`, r.status)}
</td>
<td className="px-4 py-2 text-xs text-neutral-500 dark:text-neutral-400">
{r.errorMessage || (r.sentAt ? new Date(r.sentAt).toLocaleString() : '')}
</td>
</tr>
))}
</tbody>
</table>
</div>
{recipients && recipients.pagination.totalPages > 1 && (
<div className="p-4 flex items-center justify-between border-t border-neutral-200 dark:border-neutral-700">
<Button variant="outline" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
{t('common.previous', 'Previous')}
</Button>
<span className="text-sm text-neutral-500 dark:text-neutral-400">
{t('common.pageOf', 'Page {{page}} of {{total}}',
{ page, total: recipients.pagination.totalPages })}
</span>
<Button
variant="outline"
disabled={!recipients.pagination.hasMore}
onClick={() => setPage((p) => p + 1)}
>
{t('common.next', 'Next')}
</Button>
</div>
)}
</Card>
</div>
);
};
@@ -0,0 +1,196 @@
/**
* Clients → Newsletters — campaign list (#1264).
*
* The list is the safety surface as much as the index: status, how many
* people a campaign reached, and how many failed, all visible without
* opening anything. A campaign that half-delivered should be obvious here.
*/
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus, Megaphone, Trash2 } from 'lucide-react';
import { toast } from 'react-toastify';
import { Button, Card, Loading, useConfirm } from '../../../components/common';
import { usePermissions } from '../../../contexts/PermissionsContext';
import {
newslettersService, type Campaign, type CampaignStatus,
} from '../../../services/newsletters.service';
const STATUS_STYLES: Record<CampaignStatus, string> = {
draft: 'bg-neutral-100 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-200',
queued: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200',
sending: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200',
sent: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-200',
cancelled: 'bg-neutral-100 text-neutral-500 dark:bg-neutral-800 dark:text-neutral-400',
failed: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-200',
};
export const StatusChip: React.FC<{ status: CampaignStatus }> = ({ status }) => {
const { t } = useTranslation();
return (
<span
data-testid={`status-${status}`}
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_STYLES[status]}`}
>
{t(`newsletters.status.${status}`, status)}
</span>
);
};
export const NewsletterListPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const confirm = useConfirm();
const queryClient = useQueryClient();
// The backend deliberately splits view from send, so a role can read
// campaigns without being able to mail anyone. Showing New/Delete to such a
// role only produces a 403 after the click (#1264 review).
const { hasPermission } = usePermissions();
const canSend = hasPermission('newsletters.send');
const [statusFilter, setStatusFilter] = useState<CampaignStatus | ''>('');
const { data: campaigns, isLoading } = useQuery({
queryKey: ['newsletters', statusFilter],
queryFn: () => newslettersService.list(statusFilter || undefined),
});
const createDraft = async () => {
try {
const campaign = await newslettersService.create({
name: t('newsletters.untitled', 'Untitled campaign'),
subject: t('newsletters.untitledSubject', 'Newsletter'),
});
navigate(`/admin/clients/newsletters/${campaign.id}/edit`);
} catch {
toast.error(t('newsletters.createFailed', 'Could not create the campaign.'));
}
};
const remove = async (campaign: Campaign) => {
const ok = await confirm({
title: t('newsletters.deleteTitle', 'Delete campaign?') as string,
message: t('newsletters.deleteBody',
'"{{name}}" will be deleted. This cannot be undone.', { name: campaign.name }) as string,
variant: 'danger',
});
if (!ok) return;
try {
await newslettersService.remove(campaign.id);
queryClient.invalidateQueries({ queryKey: ['newsletters'] });
toast.success(t('newsletters.deleted', 'Campaign deleted.'));
} catch {
toast.error(t('newsletters.deleteFailed', 'Could not delete the campaign.'));
}
};
if (isLoading) return <Loading />;
return (
<div>
<div className="flex items-start justify-between mb-6 gap-4">
<div>
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
{t('newsletters.title', 'Newsletters')}
</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1">
{t('newsletters.subtitle',
'Send a campaign to your customer accounts. Everyone who has opted out is skipped automatically, and every send carries an unsubscribe link.')}
</p>
</div>
{canSend && (
<Button onClick={createDraft} leftIcon={<Plus className="w-4 h-4" />}>
{t('newsletters.new', 'New campaign')}
</Button>
)}
</div>
<div className="mb-4">
<select
aria-label={t('newsletters.filterByStatus', 'Filter by status') as string}
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as CampaignStatus | '')}
className="rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm"
>
<option value="">{t('newsletters.allStatuses', 'All statuses')}</option>
{(['draft', 'queued', 'sending', 'sent', 'cancelled', 'failed'] as CampaignStatus[])
.map((s) => <option key={s} value={s}>{t(`newsletters.status.${s}`, s)}</option>)}
</select>
</div>
{!campaigns || campaigns.length === 0 ? (
<Card>
<div className="py-12 text-center">
<Megaphone className="w-10 h-10 mx-auto text-neutral-300 dark:text-neutral-600 mb-3" />
<p className="text-neutral-600 dark:text-neutral-400">
{t('newsletters.empty', 'No campaigns yet.')}
</p>
</div>
</Card>
) : (
<Card padding="none">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b border-neutral-200 dark:border-neutral-700">
<tr className="text-left text-neutral-600 dark:text-neutral-400">
<th className="px-4 py-3 font-medium">{t('newsletters.col.name', 'Name')}</th>
<th className="px-4 py-3 font-medium">{t('newsletters.col.status', 'Status')}</th>
<th className="px-4 py-3 font-medium text-right">{t('newsletters.col.recipients', 'Recipients')}</th>
<th className="px-4 py-3 font-medium text-right">{t('newsletters.col.sent', 'Sent')}</th>
<th className="px-4 py-3 font-medium text-right">{t('newsletters.col.failed', 'Failed')}</th>
<th className="px-4 py-3 font-medium">{t('newsletters.col.created', 'Created')}</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody>
{campaigns.map((c) => (
<tr key={c.id} className="border-b border-neutral-100 dark:border-neutral-800 last:border-0">
<td className="px-4 py-3">
<Link
// A draft opens straight in the composer: the detail
// page has no edit action, so linking a draft there
// left the operator with no way to resume it.
to={c.status === 'draft' && canSend
? `/admin/clients/newsletters/${c.id}/edit`
: `/admin/clients/newsletters/${c.id}`}
className="font-medium hover:underline"
style={{ color: 'var(--color-accent)' }}
>
{c.name}
</Link>
<div className="text-xs text-neutral-500 dark:text-neutral-400">{c.subject}</div>
</td>
<td className="px-4 py-3"><StatusChip status={c.status} /></td>
<td className="px-4 py-3 text-right tabular-nums">{c.recipientCount}</td>
<td className="px-4 py-3 text-right tabular-nums">{c.sentCount}</td>
<td className={`px-4 py-3 text-right tabular-nums ${c.failedCount > 0 ? 'text-red-600 dark:text-red-400 font-medium' : ''}`}>
{c.failedCount}
</td>
<td className="px-4 py-3 text-neutral-500 dark:text-neutral-400">
{new Date(c.createdAt).toLocaleDateString()}
</td>
<td className="px-4 py-3 text-right">
{/* Only a draft or a cancelled campaign can be deleted —
a sent one is a delivery record. */}
{canSend && (c.status === 'draft' || c.status === 'cancelled') && (
<button
type="button"
onClick={() => remove(c)}
aria-label={t('newsletters.deleteAria', 'Delete {{name}}', { name: c.name }) as string}
className="text-neutral-400 hover:text-red-600 dark:hover:text-red-400"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
</div>
);
};
@@ -0,0 +1,253 @@
/**
* Newsletter composer (#1264).
*
* Two things are worth pinning here, and they are both about not mailing
* 2 000 people by accident:
*
* 1. The queue button is inert until there is a subject, a body and at
* least one recipient, and the confirm dialog repeats the SERVER's
* recipient count — not a locally-guessed one.
* 2. The preview iframe is sandboxed with no allow-scripts. The body is
* sanitized server-side; this is the second line of defence and the only
* DOM campaign HTML ever reaches.
*/
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import type { Campaign } from '../../../../services/newsletters.service';
vi.mock('react-i18next', async () => {
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next');
return {
...actual,
useTranslation: () => ({
t: (k: string, fb?: unknown, opts?: Record<string, unknown>) => {
const base = typeof fb === 'string' ? fb : k;
if (!opts) return base;
return base.replace(/\{\{(\w+)\}\}/g, (_m, key) => String(opts[key] ?? ''));
},
i18n: { language: 'en' },
}),
};
});
vi.mock('react-toastify', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
// TipTap pulls a large editor bundle and contenteditable behaviour we don't
// need here — a plain textarea is enough to drive the body field.
vi.mock('../../../../components/admin/EmailTemplateEditor', () => ({
EmailTemplateEditor: ({ content, onChange }: { content: string; onChange: (v: string) => void }) => (
<textarea aria-label="Body" value={content} onChange={(e) => onChange(e.target.value)} />
),
}));
// The composer gates manual recipient mode on `customers.view` (#1264
// review), so it now consults PermissionsContext.
let grantedPermissions = ['newsletters.view', 'newsletters.send', 'customers.view'];
vi.mock('../../../../contexts/PermissionsContext', () => ({
usePermissions: () => ({
hasPermission: (p: string) => grantedPermissions.includes(p),
isLoading: false,
}),
}));
const confirmSpy = vi.fn(async () => true);
vi.mock('../../../../components/common', async () => {
const actual = await vi.importActual<any>('../../../../components/common');
return { ...actual, useConfirm: () => confirmSpy };
});
const baseCampaign: Campaign = {
id: 7,
name: 'Spring news',
subject: 'Our spring offers',
bodyHtml: '<p>Hi {{first_name}}</p>',
bodyCss: '',
language: 'en',
status: 'draft',
recipientMode: 'all_active',
customerIds: [],
recipientCount: 0,
sentCount: 0,
failedCount: 0,
sendRatePerMinute: 20,
createdByAdminId: 1,
testSentAt: null,
queuedAt: null,
completedAt: null,
createdAt: '2026-09-01T00:00:00Z',
updatedAt: '2026-09-01T00:00:00Z',
};
let campaignFixture: Campaign = baseCampaign;
let resolution = {
recipientCount: 42, skippedOptOut: 3, skippedNoEmail: 0,
sendRatePerMinute: 20, estimatedMinutes: 3,
};
const queueSpy = vi.fn(async () => ({ queued: 42, skippedOptOut: 3, sendRatePerMinute: 20 }));
const resolveSpy = vi.fn(async () => resolution);
vi.mock('../../../../services/newsletters.service', () => ({
newslettersService: {
get: vi.fn(async () => ({ campaign: campaignFixture, recipientSummary: {} })),
update: vi.fn(async () => campaignFixture),
preview: vi.fn(async () => ({
subject: 'Our spring offers',
html: '<html><body><p>Hi Alex</p></body></html>',
language: 'en',
isSample: true,
})),
resolveRecipients: (...a: unknown[]) => resolveSpy(...(a as [])),
queue: (...a: unknown[]) => queueSpy(...(a as [])),
sendTest: vi.fn(async () => undefined),
},
}));
vi.mock('../../../../services/customerAdmin.service', () => ({
customerAdminService: {
list: vi.fn(async () => [
{ id: 1, email: '[email protected]', displayName: 'Ada', isActive: true, createdAt: '', lastLogin: null,
firstName: null, lastName: null, salutation: null, companyName: null },
]),
},
}));
import { NewsletterComposerPage } from '../NewsletterComposerPage';
function renderComposer() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={['/admin/clients/newsletters/7/edit']}>
<Routes>
<Route path="/admin/clients/newsletters/:id/edit" element={<NewsletterComposerPage />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
);
}
describe('newsletter composer', () => {
beforeEach(() => {
grantedPermissions = ['newsletters.view', 'newsletters.send', 'customers.view'];
campaignFixture = { ...baseCampaign };
resolution = {
recipientCount: 42, skippedOptOut: 3, skippedNoEmail: 0,
sendRatePerMinute: 20, estimatedMinutes: 3,
};
confirmSpy.mockClear();
queueSpy.mockClear();
resolveSpy.mockClear();
});
it("shows the server's recipient count and opt-out skips", async () => {
renderComposer();
const summary = await screen.findByTestId('recipient-summary');
// The count starts at 0 and is replaced by the server's dry run.
await waitFor(() => expect(summary).toHaveTextContent('42 recipients'));
expect(summary).toHaveTextContent('3 skipped (opted out)');
});
it('renders the preview in a sandboxed iframe with no allow-scripts', async () => {
renderComposer();
await screen.findByTestId('recipient-summary');
await userEvent.click(screen.getByRole('button', { name: /Refresh preview/i }));
const iframe = await screen.findByTestId('newsletter-preview');
// Empty sandbox = every restriction on, scripts included.
expect(iframe.getAttribute('sandbox')).toBe('');
expect(iframe.getAttribute('sandbox')).not.toContain('allow-scripts');
// srcdoc, not src — the HTML never becomes a navigable same-origin doc.
expect(iframe).toHaveAttribute('srcdoc');
});
it('disables the queue button when there are no recipients', async () => {
resolution = { ...resolution, recipientCount: 0 };
renderComposer();
await screen.findByTestId('recipient-summary');
await waitFor(() => expect(resolveSpy).toHaveBeenCalled());
expect(screen.getByRole('button', { name: /Queue campaign/i })).toBeDisabled();
});
it('disables the queue button when the body is empty', async () => {
campaignFixture = { ...baseCampaign, bodyHtml: '' };
renderComposer();
const summary = await screen.findByTestId('recipient-summary');
// 42 recipients resolved — so the button can only be disabled by the
// missing bodyHtml, not by an empty recipient list.
await waitFor(() => expect(summary).toHaveTextContent('42 recipients'));
expect(screen.getByRole('button', { name: /Queue campaign/i })).toBeDisabled();
});
it('disables the queue button when the subject is empty', async () => {
campaignFixture = { ...baseCampaign, subject: '' };
renderComposer();
const summary = await screen.findByTestId('recipient-summary');
// 42 recipients resolved — so the button can only be disabled by the
// missing subject, not by an empty recipient list.
await waitFor(() => expect(summary).toHaveTextContent('42 recipients'));
expect(screen.getByRole('button', { name: /Queue campaign/i })).toBeDisabled();
});
it('confirms with the recipient count and rate before queueing', async () => {
renderComposer();
await screen.findByTestId('recipient-summary');
await userEvent.click(screen.getByRole('button', { name: /Queue campaign/i }));
await waitFor(() => expect(confirmSpy).toHaveBeenCalled());
const opts = confirmSpy.mock.calls[0][0] as { message: string; confirmLabel: string };
expect(opts.message).toContain('42 customers');
expect(opts.message).toContain('20 per minute');
expect(opts.message).toContain('roughly 3 min');
expect(opts.confirmLabel).toContain('42');
expect(queueSpy).toHaveBeenCalledWith(7);
});
it('does not queue when the confirm is declined', async () => {
confirmSpy.mockResolvedValueOnce(false);
renderComposer();
await screen.findByTestId('recipient-summary');
await userEvent.click(screen.getByRole('button', { name: /Queue campaign/i }));
await waitFor(() => expect(confirmSpy).toHaveBeenCalled());
expect(queueSpy).not.toHaveBeenCalled();
});
it('switching to manual mode reveals the customer picker', async () => {
renderComposer();
await screen.findByTestId('recipient-summary');
await userEvent.click(screen.getByRole('radio', { name: /Pick customers/i }));
expect(await screen.findByText('Ada')).toBeInTheDocument();
});
it('refuses to edit a campaign that is already queued', async () => {
campaignFixture = { ...baseCampaign, status: 'queued' };
renderComposer();
expect(await screen.findByText(/can no longer be edited/i)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Queue campaign/i })).not.toBeInTheDocument();
});
it('hides manual mode from a role that cannot read customers', async () => {
// The picker reads /admin/customers, which needs `customers.view`. Showing
// the radio to a newsletters-only role produced an empty list with no
// explanation (#1264 review).
grantedPermissions = ['newsletters.view', 'newsletters.send'];
renderComposer();
await screen.findByTestId('recipient-summary');
expect(screen.queryByRole('radio', { name: /Pick customers/i })).not.toBeInTheDocument();
expect(screen.getByRole('radio', { name: /All active customers/i })).toBeInTheDocument();
});
});
@@ -0,0 +1,204 @@
/**
* Newsletter list (#1264).
*
* The list is a safety surface: status and failure counts have to be legible
* at a glance, and delete must not be offered for a campaign that has
* already reached people — a sent campaign is a delivery record.
*/
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import type { Campaign, CampaignStatus } from '../../../../services/newsletters.service';
// Resolve against the REAL en.json rather than returning fallbacks. That
// makes these assertions double as a check that the `newsletters.*` keys
// actually exist — a missing key shows up as a failing label, not a silent
// fallback that looks right.
vi.mock('react-i18next', async () => {
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next');
const en = (await import('../../../../i18n/locales/en.json')).default as Record<string, unknown>;
const lookup = (key: string): string | undefined =>
key.split('.').reduce<unknown>(
(node, part) => (node && typeof node === 'object'
? (node as Record<string, unknown>)[part] : undefined),
en
) as string | undefined;
return {
...actual,
useTranslation: () => ({
t: (k: string, fb?: unknown, opts?: Record<string, unknown>) => {
const base = lookup(k) ?? (typeof fb === 'string' ? fb : k);
if (!opts) return base;
return base.replace(/\{\{(\w+)\}\}/g, (_m, key) => String(opts[key] ?? ''));
},
i18n: { language: 'en' },
}),
};
});
vi.mock('react-toastify', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
const confirmSpy = vi.fn(async () => true);
vi.mock('../../../../components/common', async () => {
const actual = await vi.importActual<any>('../../../../components/common');
return { ...actual, useConfirm: () => confirmSpy };
});
// The list gates New/Delete on `newsletters.send` (#1264 review), so the page
// now consults PermissionsContext. Default to a full-permission admin; the
// view-only case gets its own describe below.
let grantedPermissions = ['newsletters.view', 'newsletters.send'];
vi.mock('../../../../contexts/PermissionsContext', () => ({
usePermissions: () => ({
hasPermission: (p: string) => grantedPermissions.includes(p),
isLoading: false,
}),
}));
const makeCampaign = (over: Partial<Campaign>): Campaign => ({
id: 1, name: 'Spring', subject: 'Spring offers', bodyHtml: '<p>x</p>', bodyCss: '',
language: 'en', status: 'draft', recipientMode: 'all_active', customerIds: [],
recipientCount: 0, sentCount: 0, failedCount: 0, sendRatePerMinute: 20,
createdByAdminId: 1, testSentAt: null, queuedAt: null, completedAt: null,
createdAt: '2026-09-01T00:00:00Z', updatedAt: '2026-09-01T00:00:00Z',
...over,
});
let listFixture: Campaign[] = [];
const listSpy = vi.fn(async () => listFixture);
const removeSpy = vi.fn(async () => undefined);
vi.mock('../../../../services/newsletters.service', () => ({
newslettersService: {
list: (...a: unknown[]) => listSpy(...(a as [])),
remove: (...a: unknown[]) => removeSpy(...(a as [])),
create: vi.fn(async () => makeCampaign({ id: 99 })),
},
}));
import { NewsletterListPage } from '../NewsletterListPage';
function renderList() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<MemoryRouter><NewsletterListPage /></MemoryRouter>
</QueryClientProvider>
);
}
describe('newsletter list', () => {
beforeEach(() => {
listFixture = [];
grantedPermissions = ['newsletters.view', 'newsletters.send'];
confirmSpy.mockClear();
listSpy.mockClear();
removeSpy.mockClear();
});
it('shows an empty state when there are no campaigns', async () => {
renderList();
expect(await screen.findByText('No campaigns yet.')).toBeInTheDocument();
});
it.each<[CampaignStatus, string]>([
['draft', 'Draft'],
['queued', 'Queued'],
['sending', 'Sending'],
['sent', 'Sent'],
['cancelled', 'Cancelled'],
['failed', 'Failed'],
])('renders a %s chip', async (status, label) => {
listFixture = [makeCampaign({ status })];
renderList();
expect(await screen.findByTestId(`status-${status}`)).toHaveTextContent(label);
});
it('shows recipient, sent and failed counts', async () => {
listFixture = [makeCampaign({ recipientCount: 120, sentCount: 118, failedCount: 2 })];
renderList();
const row = (await screen.findByText('Spring')).closest('tr')!;
expect(within(row).getByText('120')).toBeInTheDocument();
expect(within(row).getByText('118')).toBeInTheDocument();
expect(within(row).getByText('2')).toBeInTheDocument();
});
it.each<CampaignStatus>(['draft', 'cancelled'])(
'offers delete for a %s campaign', async (status) => {
listFixture = [makeCampaign({ status })];
renderList();
expect(await screen.findByRole('button', { name: /Delete Spring/i })).toBeInTheDocument();
}
);
it.each<CampaignStatus>(['queued', 'sending', 'sent', 'failed'])(
'does not offer delete for a %s campaign', async (status) => {
listFixture = [makeCampaign({ status })];
renderList();
await screen.findByText('Spring');
expect(screen.queryByRole('button', { name: /Delete Spring/i })).not.toBeInTheDocument();
}
);
it('confirms before deleting', async () => {
listFixture = [makeCampaign({ status: 'draft' })];
renderList();
await userEvent.click(await screen.findByRole('button', { name: /Delete Spring/i }));
expect(confirmSpy).toHaveBeenCalled();
expect(removeSpy).toHaveBeenCalledWith(1);
});
it('does not delete when the confirm is declined', async () => {
confirmSpy.mockResolvedValueOnce(false);
listFixture = [makeCampaign({ status: 'draft' })];
renderList();
await userEvent.click(await screen.findByRole('button', { name: /Delete Spring/i }));
expect(removeSpy).not.toHaveBeenCalled();
});
it('passes the status filter through to the API', async () => {
listFixture = [makeCampaign({})];
renderList();
await screen.findByText('Spring');
await userEvent.selectOptions(
screen.getByLabelText('Filter by status'), 'sent'
);
expect(listSpy).toHaveBeenLastCalledWith('sent');
});
describe('a role with newsletters.view but not newsletters.send', () => {
// The backend supports this split deliberately. Showing write controls to
// such a role only produces a 403 after the click (#1264 review).
beforeEach(() => { grantedPermissions = ['newsletters.view']; });
it('hides the New campaign button', async () => {
listFixture = [makeCampaign({})];
renderList();
await screen.findByText('Spring');
expect(screen.queryByRole('button', { name: /New campaign/i })).not.toBeInTheDocument();
});
it('hides the delete control on a draft', async () => {
listFixture = [makeCampaign({ status: 'draft' })];
renderList();
await screen.findByText('Spring');
expect(screen.queryByRole('button', { name: /Delete Spring/i })).not.toBeInTheDocument();
});
it('still shows the campaigns themselves', async () => {
listFixture = [makeCampaign({ recipientCount: 5, sentCount: 5 })];
renderList();
expect(await screen.findByText('Spring')).toBeInTheDocument();
});
});
});
@@ -37,6 +37,12 @@ export interface CustomerAccountSummary {
* null when admin hasn't set one — each entry then requires a * null when admin hasn't set one — each entry then requires a
* per-block override. */ * per-block override. */
hourlyRateMinor?: number | null; hourlyRateMinor?: number | null;
/** Newsletter consent (migration 199, #1264). Opt-OUT: false means the
* customer still receives campaigns. Transactional mail — galleries,
* quotes, invoices — ignores this entirely. */
marketingOptOut?: boolean;
/** When the customer opted out. null while they are still subscribed. */
marketingOptOutAt?: string | null;
} }
export interface CustomerAccountDetail extends CustomerAccountSummary { export interface CustomerAccountDetail extends CustomerAccountSummary {
@@ -174,6 +180,9 @@ export const customerAdminService = {
skontoDisabled: 'skonto_disabled', skontoDisabled: 'skonto_disabled',
// Per-customer re-bill proof-attachment override (#866). null clears it. // Per-customer re-bill proof-attachment override (#866). null clears it.
rebillAttachProof: 'rebill_attach_proof', rebillAttachProof: 'rebill_attach_proof',
// Newsletter consent (migration 199, #1264). Admin-settable so a
// customer who unsubscribes by phone can be honoured immediately.
marketingOptOut: 'marketing_opt_out',
}; };
for (const [k, v] of Object.entries(payload)) { for (const [k, v] of Object.entries(payload)) {
if (k in map) snake[map[k]] = v; if (k in map) snake[map[k]] = v;
@@ -85,7 +85,11 @@ export type FeatureKey =
// sidecar. Face embeddings are biometric data (GDPR Art. 9); turning this // sidecar. Face embeddings are biometric data (GDPR Art. 9); turning this
// on is only the first of two deliberate actions, since detection is still // on is only the first of two deliberate actions, since detection is still
// enabled per event. // enabled per event.
| 'faces'; | '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.
| 'newsletters';
export type FeatureFlags = Record<FeatureKey, boolean>; export type FeatureFlags = Record<FeatureKey, boolean>;
@@ -0,0 +1,142 @@
/**
* Admin Newsletter campaigns API client (#1264).
*
* Every route behind this client is gated server-side by the `newsletters`
* feature flag AND a `newsletters.view` / `newsletters.send` permission, so a
* 403 here is a legitimate answer rather than a bug the UI hides the
* surface, and the API refuses it independently.
*/
import { api } from '../config/api';
export type CampaignStatus =
| 'draft' | 'queued' | 'sending' | 'sent' | 'cancelled' | 'failed';
export type RecipientMode = 'all_active' | 'manual';
export type RecipientStatus =
| 'queued' | 'sent' | 'failed' | 'cancelled' | 'skipped_opt_out';
export interface Campaign {
id: number;
name: string;
subject: string;
/** Stored already-sanitized by the server. Never render outside the
* sandboxed preview iframe. */
bodyHtml: string;
bodyCss: string;
language: string;
status: CampaignStatus;
recipientMode: RecipientMode;
/** Only meaningful when recipientMode is 'manual'. */
customerIds: number[];
recipientCount: number;
sentCount: number;
failedCount: number;
/** Recipients per minute. Server clamps to 1..120. */
sendRatePerMinute: number;
createdByAdminId: number | null;
testSentAt: string | null;
queuedAt: string | null;
completedAt: string | null;
createdAt: string;
updatedAt: string;
}
export interface CampaignRecipient {
id: number;
customerAccountId: number | null;
email: string;
status: RecipientStatus;
errorMessage: string | null;
sentAt: string | null;
createdAt: string;
}
/** Counts only — the composer never pulls 2 000 addresses to show a number. */
export interface RecipientResolution {
recipientCount: number;
skippedOptOut: number;
skippedNoEmail: number;
sendRatePerMinute: number;
estimatedMinutes: number;
}
export interface CampaignPayload {
name?: string;
subject?: string;
bodyHtml?: string;
bodyCss?: string;
language?: string;
recipientMode?: RecipientMode;
customerIds?: number[];
sendRatePerMinute?: number;
}
export interface Pagination {
page: number;
limit: number;
total: number;
totalPages: number;
hasMore: boolean;
}
const BASE = '/admin/newsletters';
export const newslettersService = {
async list(status?: CampaignStatus): Promise<Campaign[]> {
const { data } = await api.get(BASE, { params: status ? { status } : undefined });
return data.campaigns || [];
},
async get(id: number): Promise<{ campaign: Campaign; recipientSummary: Record<string, number> }> {
const { data } = await api.get(`${BASE}/${id}`);
return data;
},
async create(payload: CampaignPayload): Promise<Campaign> {
const { data } = await api.post(BASE, payload);
return data.campaign;
},
async update(id: number, payload: CampaignPayload): Promise<Campaign> {
const { data } = await api.put(`${BASE}/${id}`, payload);
return data.campaign;
},
async remove(id: number): Promise<void> {
await api.delete(`${BASE}/${id}`);
},
/** Rendered HTML for the sandboxed preview iframe. */
async preview(id: number, opts: { customerId?: number; language?: string } = {}):
Promise<{ subject: string; html: string; language: string; isSample: boolean }> {
const { data } = await api.post(`${BASE}/${id}/preview`, opts);
return data;
},
/** Dry run: how many people this would reach, and how many said no. */
async resolveRecipients(id: number): Promise<RecipientResolution> {
const { data } = await api.post(`${BASE}/${id}/recipients/resolve`);
return data;
},
async sendTest(id: number, to: string): Promise<void> {
await api.post(`${BASE}/${id}/test`, { to });
},
async queue(id: number): Promise<{ queued: number; skippedOptOut: number; sendRatePerMinute: number }> {
const { data } = await api.post(`${BASE}/${id}/queue`);
return data;
},
async cancel(id: number): Promise<{ cancelled: number }> {
const { data } = await api.post(`${BASE}/${id}/cancel`);
return data;
},
async recipients(id: number, opts: { page?: number; limit?: number; status?: RecipientStatus } = {}):
Promise<{ data: CampaignRecipient[]; pagination: Pagination }> {
const { data } = await api.get(`${BASE}/${id}/recipients`, { params: opts });
return data;
},
};