Files
picpeak/backend/src/routes/publicNewsletter.js
T
Paul Nothaft fc595409b4 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
2026-09-04 14:32:31 +02:00

145 lines
5.5 KiB
JavaScript

/**
* Public → Newsletter unsubscribe (issue #1264, Part B).
*
* Mounted at /api/public/newsletter. No authentication — the signed token in
* the email footer is the only credential, and it must work from a mail
* client with no session, on any device, forever.
*
* The security property this file exists to hold: **the response is identical
* whether or not the id exists.** A valid token, a tampered token, an unknown
* customer and an already-unsubscribed customer all render the same page with
* the same status. There is no lookup by email and no table of tokens, so the
* endpoint offers nothing to enumerate.
*
* Deliberately NOT behind the `newsletters` feature flag: turning the feature
* off must not break the unsubscribe links in mail that already went out.
*/
const express = require('express');
const rateLimit = require('express-rate-limit');
const { param } = require('express-validator');
const logger = require('../utils/logger');
const newsletterService = require('../services/newsletterService');
const router = express.Router();
// Own bucket — a shared limiter with the other public routes would let a
// scraper here eat the quote-preview budget, and vice versa.
const unsubscribeLimiter = rateLimit({
windowMs: 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false,
});
function escapeHtml(text) {
return String(text ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
/**
* A single self-contained page — no scripts, no external assets, no branding
* lookup. A confirmation page that needs the API to be healthy in order to
* render is a confirmation page that fails when it matters.
*/
function page(title, message, formAction) {
const action = formAction
? `
<form method="POST" action="${escapeHtml(formAction)}" style="margin-top:20px;">
<button type="submit" style="background:#5C8762;color:#fff;border:0;border-radius:6px;
padding:12px 24px;font-size:14px;cursor:pointer;">Yes, unsubscribe me</button>
</form>`
: '';
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>${escapeHtml(title)}</title>
<style>
body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center;
background:#f5f5f5; color:#333;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif; }
.card { max-width:480px; margin:20px; padding:40px 32px; background:#fff; border-radius:8px;
text-align:center; box-shadow:0 1px 3px rgba(0,0,0,.08); }
h1 { margin:0 0 12px; font-size:20px; }
p { margin:0; font-size:14px; line-height:22px; color:#666; }
</style>
</head>
<body>
<div class="card">
<h1>${escapeHtml(title)}</h1>
<p>${escapeHtml(message)}</p>${action}
</div>
</body>
</html>`;
}
const OK_TITLE = 'You have been unsubscribed';
const OK_MESSAGE = 'You will no longer receive newsletters from us. '
+ 'Transactional emails about your galleries, quotes and invoices are not affected.';
const CONFIRM_TITLE = 'Unsubscribe from our newsletter?';
const CONFIRM_MESSAGE = 'Confirm below and you will no longer receive newsletters from us. '
+ 'Transactional emails about your galleries, quotes and invoices are not affected.';
// The GET only ASKS. Mail-security scanners, link prefetchers and corporate
// gateways follow every URL in a message before a human ever sees it — a GET
// that mutated consent would unsubscribe much of a campaign's recipient list
// automatically, and the recipients would never know why the mail stopped.
// The state change lives on the POST below, which needs a real click.
router.get(
'/unsubscribe/:token',
unsubscribeLimiter,
[param('token').isString().isLength({ min: 1, max: 512 })],
(req, res) => {
// Rendered for ANY token, valid or not — see the file header. A scanner
// and a real recipient must not be able to tell the difference.
const action = `/api/public/newsletter/unsubscribe/${encodeURIComponent(req.params.token)}`;
return res
.status(200)
.type('html')
.set('Cache-Control', 'no-store')
.set('X-Robots-Tag', 'noindex, nofollow')
.send(page(CONFIRM_TITLE, CONFIRM_MESSAGE, action));
}
);
router.post(
'/unsubscribe/:token',
unsubscribeLimiter,
[param('token').isString().isLength({ min: 1, max: 512 })],
async (req, res) => {
// Every branch answers identically — the anti-enumeration property.
const respond = () => res
.status(200)
.type('html')
.set('Cache-Control', 'no-store')
.set('X-Robots-Tag', 'noindex, nofollow')
.send(page(OK_TITLE, OK_MESSAGE));
try {
const customerId = newsletterService.verifyUnsubscribeToken(req.params.token);
if (customerId === null) {
logger.debug('Newsletter unsubscribe: token rejected');
return respond();
}
await newsletterService.setMarketingOptOut(customerId, true, 'link', {
type: 'customer', id: customerId,
});
return respond();
} catch (error) {
// Even a DB failure answers the same way. Telling the visitor "an error
// occurred" for one id and "done" for another is exactly the oracle the
// identical-response rule removes — the failure goes to the log.
logger.error('Newsletter unsubscribe failed', { error: error.message });
return respond();
}
}
);
module.exports = router;