From fc595409b48960ed2988f4bea1a07260f96e24b6 Mon Sep 17 00:00:00 2001 From: Paul Nothaft <53005142+the-luap@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:32:31 +0200 Subject: [PATCH] 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 --- .../integration/newsletterCampaigns.test.js | 658 ++++++++++++ .../__tests__/routes/adminNewsletters.test.js | 308 ++++++ .../routes/publicUnsubscribe.test.js | 179 ++++ .../newsletterService.sanitize.test.js | 161 +++ .../core/199_newsletter_campaigns.js | 208 ++++ backend/server.js | 5 + backend/src/routes/adminCustomers.js | 7 + backend/src/routes/adminFeatureFlags.js | 8 + backend/src/routes/adminNewsletters.js | 314 ++++++ backend/src/routes/customer.js | 61 ++ backend/src/routes/publicNewsletter.js | 144 +++ .../src/services/customerAccountsService.js | 37 + backend/src/services/emailProcessor.js | 98 +- backend/src/services/newsletterService.js | 952 ++++++++++++++++++ frontend/src/App.tsx | 33 +- .../src/components/admin/AdminSidebar.tsx | 16 +- .../src/components/admin/ClientsLayout.tsx | 32 +- .../src/components/admin/RequireFeature.tsx | 22 +- frontend/src/contexts/FeatureFlagsContext.tsx | 7 + .../features/settings/tabs/FeaturesTab.tsx | 17 + frontend/src/i18n/locales/de.json | 107 +- frontend/src/i18n/locales/en.json | 107 +- .../src/pages/admin/CustomerDetailPage.tsx | 48 +- .../__tests__/activityInterpolation.test.ts | 55 + .../newsletters/NewsletterComposerPage.tsx | 460 +++++++++ .../newsletters/NewsletterDetailPage.tsx | 218 ++++ .../admin/newsletters/NewsletterListPage.tsx | 196 ++++ .../__tests__/newsletterComposer.test.tsx | 253 +++++ .../__tests__/newsletterList.test.tsx | 204 ++++ .../src/services/customerAdmin.service.ts | 9 + frontend/src/services/featureFlags.service.ts | 6 +- frontend/src/services/newsletters.service.ts | 142 +++ 32 files changed, 5044 insertions(+), 28 deletions(-) create mode 100644 backend/__tests__/integration/newsletterCampaigns.test.js create mode 100644 backend/__tests__/routes/adminNewsletters.test.js create mode 100644 backend/__tests__/routes/publicUnsubscribe.test.js create mode 100644 backend/__tests__/services/newsletterService.sanitize.test.js create mode 100644 backend/migrations/core/199_newsletter_campaigns.js create mode 100644 backend/src/routes/adminNewsletters.js create mode 100644 backend/src/routes/publicNewsletter.js create mode 100644 backend/src/services/newsletterService.js create mode 100644 frontend/src/pages/admin/newsletters/NewsletterComposerPage.tsx create mode 100644 frontend/src/pages/admin/newsletters/NewsletterDetailPage.tsx create mode 100644 frontend/src/pages/admin/newsletters/NewsletterListPage.tsx create mode 100644 frontend/src/pages/admin/newsletters/__tests__/newsletterComposer.test.tsx create mode 100644 frontend/src/pages/admin/newsletters/__tests__/newsletterList.test.tsx create mode 100644 frontend/src/services/newsletters.service.ts diff --git a/backend/__tests__/integration/newsletterCampaigns.test.js b/backend/__tests__/integration/newsletterCampaigns.test.js new file mode 100644 index 00000000..313b819e --- /dev/null +++ b/backend/__tests__/integration/newsletterCampaigns.test.js @@ -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: '

Hi {{first_name}}, welcome!

', + ...overrides, + }, adminId); + } + + // ---- recipients -------------------------------------------------------- + + describe('resolveRecipients', () => { + it('selects active, opted-in customers with an email', async () => { + await seedCustomer({ email: 'in@example.com' }); + const campaign = await seedCampaign(); + + const { recipients, skippedOptOut } = await newsletterService.resolveRecipients(campaign); + + expect(recipients.map((r) => r.email)).toEqual(['in@example.com']); + expect(skippedOptOut).toBe(0); + }); + + it('skips opted-out customers and counts them', async () => { + await seedCustomer({ email: 'in@example.com' }); + await seedCustomer({ email: 'out@example.com', marketing_opt_out: 1 }); + const campaign = await seedCampaign(); + + const { recipients, skippedOptOut } = await newsletterService.resolveRecipients(campaign); + + expect(recipients.map((r) => r.email)).toEqual(['in@example.com']); + expect(skippedOptOut).toBe(1); + }); + + it('skips inactive customers', async () => { + await seedCustomer({ email: 'in@example.com' }); + await seedCustomer({ email: 'gone@example.com', is_active: 0 }); + const campaign = await seedCampaign(); + + const { recipients } = await newsletterService.resolveRecipients(campaign); + expect(recipients.map((r) => r.email)).toEqual(['in@example.com']); + }); + + it('collapses duplicate addresses so one person is mailed once', async () => { + await seedCustomer({ email: 'same@example.com' }); + await seedCustomer({ email: 'SAME@example.com' }); + 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: 'a@example.com' }); + await seedCustomer({ email: 'b@example.com' }); + const campaign = await seedCampaign({ + recipientMode: 'manual', customerIds: [a.id], + }); + + const { recipients } = await newsletterService.resolveRecipients(campaign); + expect(recipients.map((r) => r.email)).toEqual(['a@example.com']); + }); + + it('still honours opt-out inside a manual id list', async () => { + const a = await seedCustomer({ email: 'a@example.com', 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: 'shared@example.com', marketing_opt_out: 1 }); + await seedCustomer({ email: 'SHARED@example.com', marketing_opt_out: 0 }); + await seedCustomer({ email: 'other@example.com' }); + const campaign = await seedCampaign(); + + const { recipients, skippedOptOut } = await newsletterService.resolveRecipients(campaign); + + expect(recipients.map((r) => r.email)).toEqual(['other@example.com']); + // 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: 'twin@example.com', marketing_opt_out: 1 }); + const selected = await seedCustomer({ email: 'TWIN@example.com', 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: 'j@example.com' }); + const campaign = await seedCampaign({ bodyHtml: '

Hi {{first_name}} at {{company_name}}

' }); + + const { html } = await newsletterService.renderForRecipient(campaign, customer); + + expect(html).toContain('Hi Jamie at Sample & Co'); + }); + + it('escapes customer data on substitution', async () => { + // A customer's own company name is untrusted text — it must not be + // able to inject markup by riding in through a variable. + const customer = await seedCustomer({ company_name: '' }); + const campaign = await seedCampaign({ bodyHtml: '

{{company_name}}

' }); + + const { html } = await newsletterService.renderForRecipient(campaign, customer); + + expect(html).not.toContain(''); + expect(html).toContain('<script>'); + }); + + it('resolves {{#if}} blocks', async () => { + const withCompany = await seedCustomer({ company_name: 'Acme' }); + const without = await seedCustomer({ company_name: null }); + const campaign = await seedCampaign({ + bodyHtml: '

Hi{{#if company_name}} from {{company_name}}{{/if}}!

', + }); + + 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: '

Stop

' }); + + 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: '

No link in here

' }); + + 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: '

Stop

', + }); + + 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: '

hi

' }); + 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(''); + 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: 'a@example.com' }); + await seedCustomer({ email: 'b@example.com' }); + 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: 'a@example.com' }); + 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: 'a@example.com' }); + await seedCustomer({ email: 'b@example.com' }); + 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: 'a@example.com', 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: 'a@example.com' }); + await seedCustomer({ email: 'b@example.com' }); + 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: 'a@example.com' }); + await seedCustomer({ email: 'b@example.com' }); + 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: 'a@example.com' }); + 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: 'a@example.com' }); + await seedCustomer({ email: 'b@example.com' }); + 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: 'a@example.com' }); + await seedCustomer({ email: 'b@example.com' }); + 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: 'a@example.com' }); + 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: 'a@example.com' }); + 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: 'a@example.com' }); + 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: 'shared@example.com', marketing_opt_out: 0 }); + await seedCustomer({ email: 'SHARED@example.com', marketing_opt_out: 1 }); + + expect(await newsletterService.shouldSkipForOptOut(queued.id, 'shared@example.com')) + .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'); + }); + }); +}); diff --git a/backend/__tests__/routes/adminNewsletters.test.js b/backend/__tests__/routes/adminNewsletters.test.js new file mode 100644 index 00000000..4d0e0000 --- /dev/null +++ b/backend/__tests__/routes/adminNewsletters.test.js @@ -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: 'viewer@example.com', + 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: '

Hi {{first_name}}

', ...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: '

ok

' }); + + 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: evil@example.com' }); + 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: 'a@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).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: 'a@example.com', 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: 'a@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)); + + 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: 'a@example.com', is_active: 1, marketing_opt_out: 0, created_at: new Date().toISOString() }, + { email: 'b@example.com', 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: 'real@example.com', 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: 'a@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)); + + 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); + }); +}); diff --git a/backend/__tests__/routes/publicUnsubscribe.test.js b/backend/__tests__/routes/publicUnsubscribe.test.js new file mode 100644 index 00000000..72218923 --- /dev/null +++ b/backend/__tests__/routes/publicUnsubscribe.test.js @@ -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: 'sub@example.com', 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(''); + expect(res.text).not.toContain(' { + 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(/]+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="[^"]*"/, '') + ); + }); + }); +}); diff --git a/backend/__tests__/services/newsletterService.sanitize.test.js b/backend/__tests__/services/newsletterService.sanitize.test.js new file mode 100644 index 00000000..c2e026be --- /dev/null +++ b/backend/__tests__/services/newsletterService.sanitize.test.js @@ -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([ + ['', 'alert(1)'], + ['', 'iframe'], + ['', '

hi

', 'object'], + ['
', '
', 'form'], + ['

hi

', 'body{x:1}'], + ])('strips %s', (_label, input, forbidden) => { + expect(sanitizeCampaignBody(input)).not.toContain(forbidden); + }); + + it('strips event handlers', () => { + const out = sanitizeCampaignBody('

hi

'); + expect(out).not.toContain('onclick'); + expect(out).not.toContain('onerror'); + expect(out).toContain('hi'); + }); + + it('strips javascript: and data: URLs', () => { + const out = sanitizeCampaignBody( + 'x' + ); + 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( + '' + ); + expect(out).not.toContain('srcset'); + expect(out).toContain('https://ok.example/a.png'); + }); + + it('rejects protocol-relative URLs', () => { + expect(sanitizeCampaignBody('x')).not.toContain('//evil.example'); + }); + + it('keeps the table layout tags an email actually needs', () => { + const html = '' + + '
Cell
'; + const out = sanitizeCampaignBody(html); + expect(out).toContain(' { + const out = sanitizeCampaignBody( + 'Bookx' + ); + 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('mail'); + expect(out).toContain('mailto:a@b.com'); + expect(out).toContain('cid:logo'); + }); + + it('cleans dangerous declarations out of inline style attributes', () => { + const out = sanitizeCampaignBody( + '

hi

' + ); + expect(out).toContain('color:red'); + expect(out).not.toContain('http://evil.example'); + }); + + it('strips expression() out of an inline style', () => { + const out = sanitizeCampaignBody('

hi

'); + expect(out).not.toContain('expression('); + }); + + it('leaves the {{variable}} syntax intact for the render pass', () => { + const out = sanitizeCampaignBody('

Hi {{first_name}}, {{#if company_name}}({{company_name}}){{/if}}

'); + 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 = '

Hi {{first_name}}

' + + 'go' + + '
c
'; + const once = sanitizeCampaignBody(messy); + expect(sanitizeCampaignBody(once)).toBe(once); + }); + + it('rejects a body over the size cap instead of silently truncating', () => { + const huge = `

${'x'.repeat(MAX_BODY_BYTES + 1)}

`; + expect(() => sanitizeCampaignBody(huge)).toThrow(/exceeds/i); + }); + + it('accepts a body just under the cap', () => { + const big = `

${'x'.repeat(MAX_BODY_BYTES - 100)}

`; + 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 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}'); + expect(css).not.toContain(''); + }); +}); diff --git a/backend/migrations/core/199_newsletter_campaigns.js b/backend/migrations/core/199_newsletter_campaigns.js new file mode 100644 index 00000000..0893fee1 --- /dev/null +++ b/backend/migrations/core/199_newsletter_campaigns.js @@ -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(); + } + } +}; diff --git a/backend/server.js b/backend/server.js index 4ceff39c..09c3f744 100644 --- a/backend/server.js +++ b/backend/server.js @@ -916,12 +916,17 @@ app.use('/api/admin/vat-codes', require('./src/routes/adminVatCodes')); app.use('/api/admin/system-health', require('./src/routes/adminSystemHealth')); app.use('/api/admin/dev', require('./src/routes/adminDev')); app.use('/api/admin/transfers', require('./src/routes/adminTransfers')); +// Newsletter campaigns (#1264). Flag-gated inside the router. +app.use('/api/admin/newsletters', require('./src/routes/adminNewsletters')); app.use('/api/public/quotes', require('./src/routes/publicQuotes')); app.use('/api/public/contracts', require('./src/routes/publicContracts')); // PicTransfer (#997): recipient download + client upload, token-authenticated. app.use('/api/public/transfer', require('./src/routes/publicTransfer')); app.use('/api/public/transfer-upload', require('./src/routes/publicTransferUpload')); app.use('/api/public/payment-check', require('./src/routes/publicPaymentCheck')); +// Newsletter unsubscribe (#1264). Deliberately NOT flag-gated: turning the +// feature off must not break the links in mail that already went out. +app.use('/api/public/newsletter', require('./src/routes/publicNewsletter')); app.use('/api/public/workflow-approvals', require('./src/routes/publicWorkflowApprovals')); app.use('/api/admin/event-types', require('./src/routes/adminEventTypes')); app.use('/api/admin/api-tokens', require('./src/routes/adminApiTokens')); diff --git a/backend/src/routes/adminCustomers.js b/backend/src/routes/adminCustomers.js index 52b132f5..891875fb 100644 --- a/backend/src/routes/adminCustomers.js +++ b/backend/src/routes/adminCustomers.js @@ -63,6 +63,11 @@ function transformCustomer(c) { billingCycleDay: c.billing_cycle_day == null ? 1 : Number(c.billing_cycle_day), notes: c.notes, isActive: c.is_active, + // Newsletter consent (migration 199, #1264). Opt-OUT: false means the + // customer still receives campaigns. Transactional mail is unaffected. + marketingOptOut: c.marketing_opt_out === true || c.marketing_opt_out === 1 + || c.marketing_opt_out === '1', + marketingOptOutAt: c.marketing_opt_out_at || null, // Passive customers (admin-only, no portal access) are identified // by a null password_hash. We never expose the hash itself — // this boolean is the only thing the frontend ever sees, and it @@ -404,6 +409,8 @@ router.put('/:id', [ body('preferred_language').optional({ nullable: true }).isString().isLength({ max: 8 }), body('notes').optional({ nullable: true }).isString(), body('is_active').optional().isBoolean(), + // Newsletter consent (migration 199, #1264). + body('marketing_opt_out').optional().isBoolean(), body('feature_calendar').optional().isBoolean(), body('feature_quotes').optional().isBoolean(), body('feature_bills').optional().isBoolean(), diff --git a/backend/src/routes/adminFeatureFlags.js b/backend/src/routes/adminFeatureFlags.js index d93603d2..00669291 100644 --- a/backend/src/routes/adminFeatureFlags.js +++ b/backend/src/routes/adminFeatureFlags.js @@ -110,6 +110,11 @@ const KNOWN_FLAGS = [ // the first of two deliberate actions — detection still has to be enabled // per event. Strictly opt-in. 'faces', + // Newsletter campaigns (migration 199, #1264). Child of `clients` — mass + // marketing mail to customer accounts, with per-customer opt-out and an + // unsubscribe link on every send. Strictly opt-in: an install that never + // turns this on never gains a route, a nav entry or a way to mass-mail. + 'newsletters', ]; // Spec defaults for any flag missing from the DB (e.g. a row added by a @@ -143,6 +148,7 @@ const DEFAULT_FLAGS = { workflows: false, // #1074 — off by default is the whole "zero behaviour change" guarantee. faces: false, + newsletters: false, }; async function readAllFlags() { @@ -207,6 +213,8 @@ function applyDependencyRules(flags) { // (calendarBooking is gated behind `calendar` so adding the parent // is sufficient.) || out.calendar + // Migration 199 (#1264) — newsletter campaigns live under Clients. + || out.newsletters // future siblings (out.messaging) go here ); return out; diff --git a/backend/src/routes/adminNewsletters.js b/backend/src/routes/adminNewsletters.js new file mode 100644 index 00000000..6253e2a0 --- /dev/null +++ b/backend/src/routes/adminNewsletters.js @@ -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: 'alex@example.com', + 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; diff --git a/backend/src/routes/customer.js b/backend/src/routes/customer.js index b43c818c..166eb943 100644 --- a/backend/src/routes/customer.js +++ b/backend/src/routes/customer.js @@ -93,6 +93,13 @@ function shapeProfile(row) { state: row.state, countryCode: row.country_code, preferredLanguage: row.preferred_language || 'en', + // Newsletter consent (migration 199, #1264). Read-only here — it is + // changed through /profile/marketing, which logs the consent change + // with its own activity entry rather than burying it in a generic + // profile update. + marketingOptOut: row.marketing_opt_out === true + || row.marketing_opt_out === 1 + || row.marketing_opt_out === '1', }; } @@ -318,6 +325,60 @@ router.put('/profile', [ } }); +/** + * GET /profile/marketing + * + * Newsletter consent, on its own endpoint (migration 199, #1264). + * + * Not folded into PUT /profile because a consent change is an auditable + * event: it needs its own `customer_marketing_opt_out` activity entry with + * the source recorded, and burying it in a 14-field profile update would + * lose that. Transactional mail is unaffected either way, which the response + * says explicitly so the UI never has to guess. + */ +router.get('/profile/marketing', customerAuth, async (req, res) => { + try { + const row = await db('customer_accounts') + .where('id', req.customer.id) + .select('marketing_opt_out', 'marketing_opt_out_at') + .first(); + if (!row) return res.status(404).json({ error: 'Profile not found' }); + res.json({ + marketingOptOut: row.marketing_opt_out === true + || row.marketing_opt_out === 1 + || row.marketing_opt_out === '1', + marketingOptOutAt: row.marketing_opt_out_at || null, + }); + } catch (error) { + errorResponse(res, error, 500, 'Failed to load marketing preferences'); + } +}); + +/** + * PUT /profile/marketing { optOut: boolean } + */ +router.put('/profile/marketing', [ + customerAuth, + body('optOut').isBoolean(), +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: safeValidationErrors(errors) }); + } + const newsletterService = require('../services/newsletterService'); + await newsletterService.setMarketingOptOut( + req.customer.id, + Boolean(req.body.optOut), + 'portal', + { type: 'customer', id: req.customer.id, name: req.customer.email } + ); + res.json({ marketingOptOut: Boolean(req.body.optOut) }); + } catch (error) { + errorResponse(res, error, 500, 'Failed to update marketing preferences'); + } +}); + /** * POST /profile/password * diff --git a/backend/src/routes/publicNewsletter.js b/backend/src/routes/publicNewsletter.js new file mode 100644 index 00000000..12d543ce --- /dev/null +++ b/backend/src/routes/publicNewsletter.js @@ -0,0 +1,144 @@ +/** + * Public → Newsletter unsubscribe (issue #1264, Part B). + * + * Mounted at /api/public/newsletter. No authentication — the signed token in + * the email footer is the only credential, and it must work from a mail + * client with no session, on any device, forever. + * + * The security property this file exists to hold: **the response is identical + * whether or not the id exists.** A valid token, a tampered token, an unknown + * customer and an already-unsubscribed customer all render the same page with + * the same status. There is no lookup by email and no table of tokens, so the + * endpoint offers nothing to enumerate. + * + * Deliberately NOT behind the `newsletters` feature flag: turning the feature + * off must not break the unsubscribe links in mail that already went out. + */ + +const express = require('express'); +const rateLimit = require('express-rate-limit'); +const { param } = require('express-validator'); + +const logger = require('../utils/logger'); +const newsletterService = require('../services/newsletterService'); + +const router = express.Router(); + +// Own bucket — a shared limiter with the other public routes would let a +// scraper here eat the quote-preview budget, and vice versa. +const unsubscribeLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 30, + standardHeaders: true, + legacyHeaders: false, +}); + +function escapeHtml(text) { + return String(text ?? '') + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); +} + +/** + * 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 + ? ` +
+ +
` + : ''; + return ` + + + + + +${escapeHtml(title)} + + + +
+

${escapeHtml(title)}

+

${escapeHtml(message)}

${action} +
+ +`; +} + +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; diff --git a/backend/src/services/customerAccountsService.js b/backend/src/services/customerAccountsService.js index bcc0e6fe..dc2623a9 100644 --- a/backend/src/services/customerAccountsService.js +++ b/backend/src/services/customerAccountsService.js @@ -565,6 +565,9 @@ async function getCustomerById(id) { * typo before the customer accepts. Uniqueness is enforced. */ async function updateCustomer(id, updates, updatedByAdminId) { + // Set when marketing_opt_out actually flips, so the dedicated consent + // event can be logged after the write lands. + let marketingConsentTransition = null; const customer = await db('customer_accounts').where('id', id).first(); if (!customer) { throw new NotFoundError('Customer', id); @@ -597,6 +600,10 @@ async function updateCustomer(id, updates, updatedByAdminId) { // in its own branch below so null survives (formatBoolean would coerce // it to false and silently lose the "inherit" state). 'rebill_attach_proof', + // Newsletter consent (migration 199, #1264). Admin-settable so a + // customer who unsubscribes by phone can be honoured without waiting + // for them to click a link. Transactional mail ignores it entirely. + 'marketing_opt_out', ]; for (const f of fields) { if (updates[f] !== undefined) { @@ -613,6 +620,25 @@ async function updateCustomer(id, updates, updatedByAdminId) { || f === 'skonto_disabled' ) { allowed[f] = formatBoolean(updates[f]); + } else if (f === 'marketing_opt_out') { + // Only stamp on an actual transition. The customer form submits this + // field on every full-profile save, so saving an unrelated field + // while the customer stayed opted out would move + // marketing_opt_out_at to now — overwriting the moment consent was + // actually withdrawn with the moment someone edited a phone number. + const wasOptedOut = customer.marketing_opt_out === true + || customer.marketing_opt_out === 1 + || customer.marketing_opt_out === '1'; + const nowOptedOut = Boolean(updates[f]); + allowed[f] = formatBoolean(nowOptedOut); + if (wasOptedOut !== nowOptedOut) { + allowed.marketing_opt_out_at = nowOptedOut ? new Date().toISOString() : null; + // Consent changes are designed to be auditable in their own right. + // The generic `customer_updated` entry records only that a field + // named marketing_opt_out was touched — not the new value, and not + // that an admin made the change on the customer's behalf. + marketingConsentTransition = nowOptedOut; + } } else if (f === 'rebill_attach_proof') { // Tri-state override. null/'' → NULL (inherit global default); // otherwise a real boolean (coerced for SQLite). @@ -680,6 +706,17 @@ async function updateCustomer(id, updates, updatedByAdminId) { { type: 'admin', id: updatedByAdminId, name: 'system' } ); + // The dedicated consent event, alongside the generic one. It is what the + // newsletter audit trail reads: the new VALUE and the source, rather than + // just the fact that a field with that name was written (#1264). + if (marketingConsentTransition !== null) { + await logActivity('customer_marketing_opt_out', + { customerId: id, optOut: marketingConsentTransition, source: 'admin' }, + null, + { type: 'admin', id: updatedByAdminId, name: 'system' } + ); + } + return getCustomerById(id); } diff --git a/backend/src/services/emailProcessor.js b/backend/src/services/emailProcessor.js index a291c0a9..8652500b 100644 --- a/backend/src/services/emailProcessor.js +++ b/backend/src/services/emailProcessor.js @@ -991,6 +991,41 @@ async function sendTemplateEmail(to, templateKey, variables) { } } +/** + * Send one queued newsletter-campaign row (#1264). + * + * Campaigns carry their own body, so there is no `email_templates` row to + * look up and `sendTemplateEmail` cannot be used. The body is rendered per + * recipient (variables, the recipient's own unsubscribe link, the campaign + * CSS) and handed to the same `sendRawEmail` transport the manual composer + * uses. Returns the `{ html }` shape the queue processor persists into + * `rendered_html`, so a campaign send is as inspectable afterwards as any + * transactional mail. + */ +async function sendCampaignEmail(queueRow, emailData) { + const newsletterService = require('./newsletterService'); + + const campaign = await db('email_campaigns').where({ id: queueRow.campaign_id }).first(); + if (!campaign) { + throw new Error(`Newsletter campaign ${queueRow.campaign_id} not found`); + } + + // The customer row may be gone (deleted between queue and send). Fall back + // to the address on the queue row so the mail still goes out addressed to + // someone, with empty personalisation rather than a crash. + const customer = emailData.customerId + ? await db('customer_accounts').where({ id: emailData.customerId }).first() + : null; + + const { subject, html } = await newsletterService.renderForRecipient( + campaign, + customer || { id: emailData.customerId || null, email: queueRow.recipient_email } + ); + + const info = await sendRawEmail({ to: queueRow.recipient_email, subject, html }); + return { success: true, messageId: info.messageId, html }; +} + /** * Send a fully-composed email (subject + HTML the admin already edited in the * Messages composer) WITHOUT a template. Used for replies + human-sent document @@ -1198,11 +1233,38 @@ async function processEmailQueue({ ignoreSchedule = false, limit = 10, onlyId = emailData.eventId = email.event_id; } - const sendResult = await sendTemplateEmail( - email.recipient_email, - email.email_type, - emailData - ); + // Newsletter campaigns (#1264) have no `email_templates` row — the + // body lives on the campaign. They also get the send-time opt-out + // re-check: a customer who unsubscribed after the campaign was + // queued is skipped here, not mailed. + let sendResult; + if (email.email_type === 'newsletter' && email.campaign_id) { + const newsletterService = require('./newsletterService'); + // The batch above was materialised before this loop started. A + // cancel that lands in between deletes the pending rows, but this + // worker still holds them in memory — so without re-reading, up to + // a full batch goes out after the UI says the campaign is + // cancelled. Re-check the row still exists and is still pending. + const stillPending = await db('email_queue') + .where({ id: email.id, status: 'pending' }) + .first('id'); + if (!stillPending) { + logger.info(`Email ${email.id} skipped — cancelled after the batch was fetched`); + continue; + } + if (await newsletterService.shouldSkipForOptOut(emailData.customerId, email.recipient_email)) { + await newsletterService.markSkippedOptOut(email); + logger.info(`Email ${email.id} skipped — recipient opted out after queueing`); + continue; + } + sendResult = await sendCampaignEmail(email, emailData); + } else { + sendResult = await sendTemplateEmail( + email.recipient_email, + email.email_type, + emailData + ); + } // Mark as sent, persisting the actual rendered HTML for the Project // Overview email preview (guarded — older installs without migration @@ -1217,6 +1279,18 @@ async function processEmailQueue({ ignoreSchedule = false, limit = 10, onlyId = .where('id', email.id) .update(sentUpdate); + // Campaign bookkeeping (#1264). Best-effort by contract — a failure + // in the audit trail must never turn a delivered email into a + // failed one, so it is logged and swallowed. + if (email.campaign_id) { + try { + await require('./newsletterService') + .recordRecipientResult(email, { status: 'sent' }); + } catch (hookError) { + logger.error(`Campaign bookkeeping failed for email ${email.id}:`, hookError); + } + } + result.sent += 1; logger.info(`Email ${email.id} sent successfully`); } catch (error) { @@ -1241,6 +1315,20 @@ async function processEmailQueue({ ignoreSchedule = false, limit = 10, onlyId = } } + // Campaign bookkeeping (#1264). Only record a FAILURE once the row + // has exhausted its retries — the same cap the pending query uses. + // Recording it on attempt 1 would mark the recipient failed while + // the queue is still going to retry them, and could flip the whole + // campaign terminal on a transient SMTP blip. + if (email.campaign_id && email.retry_count + 1 >= 3) { + try { + await require('./newsletterService') + .recordRecipientResult(email, { status: 'failed', errorMessage: error.message }); + } catch (hookError) { + logger.error(`Campaign bookkeeping failed for email ${email.id}:`, hookError); + } + } + logger.error(`Failed to send email ${email.id}:`, error); } } diff --git a/backend/src/services/newsletterService.js b/backend/src/services/newsletterService.js new file mode 100644 index 00000000..46da3528 --- /dev/null +++ b/backend/src/services/newsletterService.js @@ -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, `
`/``, 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 \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, +}; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 51f32475..5119dcab 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -36,6 +36,11 @@ import { BillDetailPage, } from './pages/admin'; 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 { HoursLoggingPage } from './pages/admin/clients/HoursLoggingPage'; // E.6 — Calendar page lazy-loaded so the ~200 KB FullCalendar bundle @@ -272,7 +277,12 @@ function App() { independently. */} }> }> - }> + {/* 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). */} + }> } /> } /> @@ -334,16 +344,25 @@ function App() { section. Keep this path as a redirect so old bookmarks / links don't 404. */} } /> + {/* Newsletter campaigns (#1264) — gated by + `newsletters`. Mass marketing mail to customer + accounts, with per-customer opt-out. */} + }> + } /> + } /> + } /> + {/* Developer tools — gated by `crmDevelopment`. */} }> } /> - {/* Default: send /admin/clients (no sub-path) to - the first enabled sub-feature. accounts comes - first because it predates the others. The empty - state inside ClientsLayout handles "parent on, - all children off". */} - } /> + {/* No index redirect here: ClientsLayout picks the + first sub-feature this user can actually reach, + which a fixed /accounts target could not — a + newsletters-only role has no customers.view and + would bounce straight back out (#1264). It also + owns the "parent on, all children off" empty + state. */} diff --git a/frontend/src/components/admin/AdminSidebar.tsx b/frontend/src/components/admin/AdminSidebar.tsx index be14135c..d87573c0 100644 --- a/frontend/src/components/admin/AdminSidebar.tsx +++ b/frontend/src/components/admin/AdminSidebar.tsx @@ -52,6 +52,13 @@ interface NavItem { * constraint. */ 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). @@ -93,7 +100,9 @@ export const adminNavigation: NavItem[] = [ // their own permission keys and the gate here grows into an OR. { 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', // Hide the entry when the parent is on but no sub-feature is — // there's nothing inside ClientsLayout to link to. Mirror the same @@ -108,6 +117,9 @@ export const adminNavigation: NavItem[] = [ featureFlagsAny: [ 'customerPortal', 'crmDevelopment', 'quotes', 'bills', '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, @@ -161,6 +173,8 @@ export const AdminSidebar: React.FC = ({ isOpen, onClose, col const filteredNavigation = adminNavigation.filter((item) => { 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; // featureFlagsAny: entry is hidden when none of the listed // sub-flags are on, even if the parent flag IS on. Used by diff --git a/frontend/src/components/admin/ClientsLayout.tsx b/frontend/src/components/admin/ClientsLayout.tsx index e258a58d..28bd2ee4 100644 --- a/frontend/src/components/admin/ClientsLayout.tsx +++ b/frontend/src/components/admin/ClientsLayout.tsx @@ -12,11 +12,12 @@ * active item with white icon + label. */ 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 { 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 { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext'; +import { usePermissions } from '../../contexts/PermissionsContext'; interface NavItem { key: string; @@ -30,6 +31,12 @@ interface NavItem { * to declare their own sub-flag here. */ 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 = () => { @@ -52,6 +59,7 @@ export const ClientsLayout: React.FC = () => { label: t('clients.subnav.accounts', 'Accounts'), icon: UserCog, featureFlag: 'customerPortal', + permission: 'customers.view', }, { key: 'calendar', @@ -92,6 +100,14 @@ export const ClientsLayout: React.FC = () => { // longer a CRM sub-feature). See AccountingLayout. // Future sub-features: // { 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', 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 ; + } // When the parent `clients` flag is on but no sub-feature is enabled, // there's nothing to render. Settings → Features is one click away diff --git a/frontend/src/components/admin/RequireFeature.tsx b/frontend/src/components/admin/RequireFeature.tsx index a0a87f7d..5df7235b 100644 --- a/frontend/src/components/admin/RequireFeature.tsx +++ b/frontend/src/components/admin/RequireFeature.tsx @@ -3,7 +3,16 @@ import { Navigate, Outlet } from 'react-router-dom'; import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext'; 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; } @@ -15,11 +24,18 @@ interface RequireFeatureProps { * Mounted as the `element` of a parent , with the gated routes as * children — see App.tsx. */ -export const RequireFeature: React.FC = ({ flag, fallback = '/admin/dashboard' }) => { +export const RequireFeature: React.FC = ({ + flag, + anyOf, + fallback = '/admin/dashboard', +}) => { const { flags, isLoading } = useFeatureFlags(); // Wait for the first fetch — otherwise we'd briefly fall back to the // default-flags object and could redirect on a transient false. if (isLoading) return null; - if (!flags[flag]) return ; + const required = anyOf?.length ? anyOf : (flag ? [flag] : []); + if (required.length > 0 && !required.some((key) => flags[key])) { + return ; + } return ; }; diff --git a/frontend/src/contexts/FeatureFlagsContext.tsx b/frontend/src/contexts/FeatureFlagsContext.tsx index f0ee9265..b5e7dfce 100644 --- a/frontend/src/contexts/FeatureFlagsContext.tsx +++ b/frontend/src/contexts/FeatureFlagsContext.tsx @@ -72,6 +72,10 @@ export const DEFAULT_FLAGS: FeatureFlags = { workflows: false, // #1074 — off by default is the whole "zero behaviour change" guarantee. 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; @@ -131,6 +135,9 @@ function applyDependencyRules(flags: FeatureFlags): FeatureFlags { // NOTE: taxReport is intentionally NOT here anymore — the Tax export // moved permanently into the Accounting section (its own master). // 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; } diff --git a/frontend/src/features/settings/tabs/FeaturesTab.tsx b/frontend/src/features/settings/tabs/FeaturesTab.tsx index 521c7d1f..698ef229 100644 --- a/frontend/src/features/settings/tabs/FeaturesTab.tsx +++ b/frontend/src/features/settings/tabs/FeaturesTab.tsx @@ -25,6 +25,7 @@ import { MonitorPlay, Send, Workflow, + Megaphone, } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; @@ -383,6 +384,22 @@ export const FeaturesTab: React.FC = () => { onToggle={(next) => setFlag('bills', next)} /> + {/* Newsletter campaigns (#1264). Clients child. Mass marketing mail + to customer accounts, so the copy leads with consent. */} + setFlag('newsletters', next)} + /> + -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." } } diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index c31ac7da..75957a58 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2963,6 +2963,14 @@ "feature_flags_summary_one": "{{count}} features updated: {{summary}}", "feature_flags_summary_other": "{{count}} features updated: {{summary}}", "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" }, "people": { @@ -4807,7 +4815,10 @@ "field": { "featureHoursLogging": "Hours logging", "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": { "section": "Hours", @@ -5012,7 +5023,8 @@ "manageEvents": "Manage galleries", "rebillsSection": "Re-bills & passthrough", "noRebills": "No re-billed or passed-through supplier invoices for this customer yet.", - "companyName": "Company name" + "companyName": "Company name", + "marketingSection": "Newsletter consent" }, "billing": { "section": "Billing cadence", @@ -5102,7 +5114,8 @@ "calendar": "Calendar", "bills": "Invoices", "taxReport": "Tax", - "development": "Development" + "development": "Development", + "newsletters": "Newsletters" } }, "accounting": { @@ -6872,5 +6885,93 @@ "admin": "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