diff --git a/MIGRATIONS.md b/MIGRATIONS.md index 73f8ce733..7960a3b08 100644 --- a/MIGRATIONS.md +++ b/MIGRATIONS.md @@ -4,6 +4,18 @@ Breaking changes and upgrade notes for downstream projects. --- +## `users`: email preferences, product mail, one-click unsubscribe (2026-10-03) + +New, fully optional `emailPreferences` sub-object on `User` (`onboarding`/`news`, both default `true`) plus two new `UserService` functions — `sendProductMail(user, { kind, template, subject, params, from, replyTo })` and `announce({ kind, template, subject, params, from, replyTo })` — for sending a product email (an onboarding series, an announcement) that respects a per-user, per-kind opt-out. Both skip silently (no error) for an unverified or opted-out user. Transactional email (`mailer.sendMail` call sites, password reset, email verification, invitations) is unaffected (#4162). + +A new public route, `POST /api/users/unsubscribe/:token`, lets a mail client's one-click unsubscribe (RFC 8058) turn a kind off with no login — `sendProductMail`/`announce` already attach the matching `List-Unsubscribe`/`List-Unsubscribe-Post` headers. The token is a stateless HMAC (userId + kind + the stack's existing `config.jwt.secret`), not stored anywhere, so it carries no expiry and no new collection. + +### Action for downstream + +None required — the field is optional (absent reads as "every kind on", no migration/backfill needed) and no existing call site changed shape. Opt in by calling `sendProductMail`/`announce` from your own cron/admin code with your own templates. + +--- + ## `test`/`test:unit` raise the heap ceiling to 8192 MB (2026-09-27) `--experimental-vm-modules` leaks ESM module registries between suites (upstream jest issue, present on jest 30); a large suite can hit Node's default ~4 GB old-space ceiling and OOM (not a test failure — the same suite passes clean at 8192). `NODE_OPTIONS` on every non-coverage test script (`test`, `test:unit`, `test:integration`, `test:e2e`, `test:all`, `test:watch`) now includes `--max-old-space-size=8192`; `:coverage` variants keep their own flags (#3948). diff --git a/config/defaults/production.config.js b/config/defaults/production.config.js index f5769e37d..40946bed1 100644 --- a/config/defaults/production.config.js +++ b/config/defaults/production.config.js @@ -72,6 +72,18 @@ const config = { standardHeaders: true, legacyHeaders: false, }, + // Public POST /api/users/unsubscribe/:token (#4162, RFC 8058 one-click). + // Unlike the profiles above, this is NOT tightened for prod — prod is exactly + // where mail providers' shared egress IPs send these POSTs from, so the cap + // stays generous (120/min/IP) rather than inheriting `auth`'s 10/15min, which + // would lock out genuine recipients after a handful of sends from one provider. + unsubscribe: { + windowMs: 60 * 1000, + max: 120, + message: { message: 'Too many requests, please try again later.' }, + standardHeaders: true, + legacyHeaders: false, + }, }, log: { format: 'custom', diff --git a/config/defaults/test.config.js b/config/defaults/test.config.js index f6f35572e..3a85aa371 100644 --- a/config/defaults/test.config.js +++ b/config/defaults/test.config.js @@ -46,6 +46,9 @@ const config = { invitationsCreate: { max: Number.MAX_SAFE_INTEGER, // disable rate limiting in tests }, + unsubscribe: { + max: Number.MAX_SAFE_INTEGER, // disable rate limiting in tests + }, }, uploads: { avatar: { diff --git a/lib/helpers/redactUrl.js b/lib/helpers/redactUrl.js index ff29d793e..fdb6055cc 100644 --- a/lib/helpers/redactUrl.js +++ b/lib/helpers/redactUrl.js @@ -14,7 +14,7 @@ import config from '../../config/index.js'; * degrade to exactly these defaults, never to nothing). */ const DEFAULT_SENSITIVE_QUERY_KEYS = ['inviteToken', 'code', 'state']; -const DEFAULT_SENSITIVE_PATH_MARKERS = ['reset', 'verify', 'verify-email']; +const DEFAULT_SENSITIVE_PATH_MARKERS = ['reset', 'verify', 'verify-email', 'unsubscribe']; /** * @desc Sanitize a config-provided redaction list so it is always an array @@ -61,9 +61,10 @@ const SENSITIVE_QUERY_KEYS = [...new Set([...DEFAULT_SENSITIVE_QUERY_KEYS, ...sa /** * Path segments that are immediately followed by a single-use secret carried as * a PATH parameter (not the query string). Routes such as - * `GET /api/auth/reset/:token`, `POST /api/auth/verify-email/:token` and - * `GET /api/*(auth/)?invitations/verify/:token` embed a still-valid token - * directly in the path, so the segment right after one of these markers must be + * `GET /api/auth/reset/:token`, `POST /api/auth/verify-email/:token`, + * `GET /api/*(auth/)?invitations/verify/:token` and + * `POST /api/users/unsubscribe/:token` embed a still-valid token directly in + * the path, so the segment right after one of these markers must be * redacted before the URL reaches any log/analytics store. * * Union of `DEFAULT_SENSITIVE_PATH_MARKERS` and `config.log.sensitivePathMarkers`: diff --git a/lib/helpers/tests/redactUrl.unit.tests.js b/lib/helpers/tests/redactUrl.unit.tests.js index 85ecbc66c..c3f448392 100644 --- a/lib/helpers/tests/redactUrl.unit.tests.js +++ b/lib/helpers/tests/redactUrl.unit.tests.js @@ -48,6 +48,11 @@ describe('redactUrl', () => { expect(redactUrl('/api/auth/invitations/verify/SECRETTOKEN?foo=1')).toBe('/api/auth/invitations/verify/REDACTED?foo=1'); }); + // #4162 — POST /api/users/unsubscribe/:token + test('redacts an unsubscribe-token path with no query string', () => { + expect(redactUrl('/api/users/unsubscribe/SECRETTOKEN')).toBe('/api/users/unsubscribe/REDACTED'); + }); + test('redacts both a path secret and a query secret in the same URL', () => { expect(redactUrl('/api/auth/reset/SECRETTOKEN?inviteToken=abc123')).toBe('/api/auth/reset/REDACTED?inviteToken=REDACTED'); }); @@ -100,6 +105,11 @@ describe('redactPathSecrets', () => { expect(redactPathSecrets('/api/invitations/verify/SECRETTOKEN')).toBe('/api/invitations/verify/REDACTED'); }); + // #4162 — POST /api/users/unsubscribe/:token + test('redacts the segment following /unsubscribe/ (one-click unsubscribe route)', () => { + expect(redactPathSecrets('/api/users/unsubscribe/SECRETTOKEN')).toBe('/api/users/unsubscribe/REDACTED'); + }); + test('leaves a path with no sensitive marker unchanged', () => { expect(redactPathSecrets('/api/tasks/123')).toBe('/api/tasks/123'); }); @@ -124,7 +134,7 @@ describe('redactPathSecrets', () => { }); test('exports the sensitive path markers', () => { - expect(SENSITIVE_PATH_MARKERS).toEqual(expect.arrayContaining(['reset', 'verify', 'verify-email'])); + expect(SENSITIVE_PATH_MARKERS).toEqual(expect.arrayContaining(['reset', 'verify', 'verify-email', 'unsubscribe'])); }); }); @@ -155,14 +165,16 @@ describe('redactUrl config sourcing (#3953)', () => { }; test('unions a module-extended path marker from config with the built-in defaults', async () => { + // 'promo-opt-out' (not 'unsubscribe', which is itself a built-in default — + // #4162) so this test genuinely exercises an EXTENSION beyond the defaults. const mod = await loadWithConfig({ - log: { sensitivePathMarkers: ['unsubscribe'] }, + log: { sensitivePathMarkers: ['promo-opt-out'] }, }); - expect(mod.SENSITIVE_PATH_MARKERS).toEqual(expect.arrayContaining(['reset', 'verify', 'verify-email', 'unsubscribe'])); + expect(mod.SENSITIVE_PATH_MARKERS).toEqual(expect.arrayContaining(['reset', 'verify', 'verify-email', 'unsubscribe', 'promo-opt-out'])); // built-in marker still redacts — extension never clobbers it expect(mod.redactPathSecrets('/api/auth/reset/SECRETTOKEN')).toBe('/api/auth/reset/REDACTED'); // config-added marker redacts too - expect(mod.redactPathSecrets('/api/newsletter/unsubscribe/SECRETTOKEN')).toBe('/api/newsletter/unsubscribe/REDACTED'); + expect(mod.redactPathSecrets('/api/newsletter/promo-opt-out/SECRETTOKEN')).toBe('/api/newsletter/promo-opt-out/REDACTED'); }); test('unions a module-extended query key from config with the built-in defaults', async () => { @@ -180,7 +192,7 @@ describe('redactUrl config sourcing (#3953)', () => { const mod = await loadWithConfig({ log: { sensitivePathMarkers: [], sensitiveQueryKeys: [] }, }); - expect(mod.SENSITIVE_PATH_MARKERS).toEqual(['reset', 'verify', 'verify-email']); + expect(mod.SENSITIVE_PATH_MARKERS).toEqual(['reset', 'verify', 'verify-email', 'unsubscribe']); expect(mod.SENSITIVE_QUERY_KEYS).toEqual(['inviteToken', 'code', 'state']); expect(mod.redactPathSecrets('/api/auth/reset/SECRETTOKEN')).toBe('/api/auth/reset/REDACTED'); expect(mod.default('/x?inviteToken=secret')).toBe('/x?inviteToken=REDACTED'); @@ -188,7 +200,7 @@ describe('redactUrl config sourcing (#3953)', () => { test('falls back to the built-in defaults when config.log is missing', async () => { const mod = await loadWithConfig({}); - expect(mod.SENSITIVE_PATH_MARKERS).toEqual(['reset', 'verify', 'verify-email']); + expect(mod.SENSITIVE_PATH_MARKERS).toEqual(['reset', 'verify', 'verify-email', 'unsubscribe']); expect(mod.SENSITIVE_QUERY_KEYS).toEqual(['inviteToken', 'code', 'state']); expect(mod.redactPathSecrets('/api/auth/reset/SECRETTOKEN')).toBe('/api/auth/reset/REDACTED'); expect(mod.default('/x?inviteToken=secret')).toBe('/x?inviteToken=REDACTED'); @@ -196,7 +208,7 @@ describe('redactUrl config sourcing (#3953)', () => { test('falls back to the built-in defaults when config itself is undefined', async () => { const mod = await loadWithConfig(undefined); - expect(mod.SENSITIVE_PATH_MARKERS).toEqual(['reset', 'verify', 'verify-email']); + expect(mod.SENSITIVE_PATH_MARKERS).toEqual(['reset', 'verify', 'verify-email', 'unsubscribe']); expect(mod.SENSITIVE_QUERY_KEYS).toEqual(['inviteToken', 'code', 'state']); }); @@ -213,7 +225,7 @@ describe('redactUrl config sourcing (#3953)', () => { const mod = await loadWithConfig({ log: { sensitivePathMarkers: { notAnArray: true } }, }); - expect(mod.SENSITIVE_PATH_MARKERS).toEqual(['reset', 'verify', 'verify-email']); + expect(mod.SENSITIVE_PATH_MARKERS).toEqual(['reset', 'verify', 'verify-email', 'unsubscribe']); expect(mod.redactPathSecrets('/api/auth/reset/SECRETTOKEN')).toBe('/api/auth/reset/REDACTED'); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('log.sensitivePathMarkers')); } finally { diff --git a/lib/middlewares/tests/rateLimiter.baseLayer.unit.tests.js b/lib/middlewares/tests/rateLimiter.baseLayer.unit.tests.js index 1644673b5..0517a7d8f 100644 --- a/lib/middlewares/tests/rateLimiter.baseLayer.unit.tests.js +++ b/lib/middlewares/tests/rateLimiter.baseLayer.unit.tests.js @@ -7,6 +7,7 @@ import billingDevConfig from '../../../modules/billing/config/billing.developmen import authDevConfig from '../../../modules/auth/config/auth.development.config.js'; import uploadsDevConfig from '../../../modules/uploads/config/uploads.development.config.js'; import invitationsDevConfig from '../../../modules/invitations/config/invitations.development.config.js'; +import usersDevConfig from '../../../modules/users/config/users.development.config.js'; /** * Config-layering regression guard for the rate-limiter env-gate defect. @@ -67,4 +68,11 @@ describe('rate-limiter base-layer profiles (env-gate config-layering):', () => { // be present under every NODE_ENV, not only prod. expectUsableProfile(invitationsDevConfig.rateLimit.invitationsCreate); }); + + test('unsubscribe profile lives in the users base layer (always merges)', () => { + // Guards the public POST /api/users/unsubscribe/:token (#4162, RFC 8058 + // one-click). The profile must be present under every NODE_ENV, not only + // prod — a missing profile means a no-op limiter. + expectUsableProfile(usersDevConfig.rateLimit.unsubscribe); + }); }); diff --git a/modules/users/config/users.development.config.js b/modules/users/config/users.development.config.js index 2f9411617..9602f2887 100644 --- a/modules/users/config/users.development.config.js +++ b/modules/users/config/users.development.config.js @@ -26,13 +26,34 @@ const config = { 'lastLoginAt', 'complementary', 'terms', + // #4162 — read-exposed like `complementary` (both self-editable AND + // reflected back), unlike `attribution`'s write-never/read-hidden pair. + 'emailPreferences', ], - update: ['firstName', 'lastName', 'bio', 'position', 'email', 'avatar', 'complementary'], + update: ['firstName', 'lastName', 'bio', 'position', 'email', 'avatar', 'complementary', 'emailPreferences'], updateAdmin: ['firstName', 'lastName', 'bio', 'position', 'email', 'avatar', 'roles', 'complementary'], recover: ['password', 'resetPasswordToken', 'resetPasswordExpires', 'emailVerified', 'emailVerificationToken', 'emailVerificationExpires', 'additionalProvidersData'], roles: ['user', 'admin'], }, }, + rateLimit: { + // POST /api/users/unsubscribe/:token (#4162) — public, token-authorized + // (RFC 8058) one-click unsubscribe. MUST NOT share `limiters.auth`: these + // POSTs come from mail providers' shared egress IPs, so auth's 10/15min-per-IP + // cap would lock out genuine recipients after a handful of sends from the + // same provider. Lives in this base layer so the profile is present — and + // the limiter active — under EVERY env, not only the literal `production`. + // A generous, deliberately-kept-generous cap is applied as an override in + // config/defaults/production.config.js (unlike the other profiles, prod is + // NOT stricter here — the shared-egress-IP problem is a production problem). + unsubscribe: { + windowMs: 60 * 1000, // 1 min + max: 1200, // lenient in dev + message: { message: 'Too many requests, please try again later.' }, + standardHeaders: true, + legacyHeaders: false, + }, + }, }; export default config; diff --git a/modules/users/controllers/users.account.controller.js b/modules/users/controllers/users.account.controller.js index 1f43ff487..44f170a1f 100644 --- a/modules/users/controllers/users.account.controller.js +++ b/modules/users/controllers/users.account.controller.js @@ -4,6 +4,7 @@ import errors from '../../../lib/helpers/errors.js'; import responses from '../../../lib/helpers/responses.js'; import UserService from '../services/users.service.js'; +import { verifyUnsubscribeToken } from '../utils/unsubscribeToken.js'; /** * @desc Endpoint to ask the service to update a user @@ -73,6 +74,9 @@ const me = (req, res) => { firstName: req.user.firstName, // others complementary: req.user.complementary, + // #4162 — self-editable, so (like complementary) always reflected back; + // undefined for a user who never touched it (absent = every kind on). + emailPreferences: req.user.emailPreferences, }; if (req.user.bio) user.bio = req.user.bio; if (req.user.position) user.position = req.user.position; @@ -82,6 +86,31 @@ const me = (req, res) => { return responses.success(res, 'user get')(user); }; +/** + * @desc Public one-click unsubscribe endpoint (#4162, RFC 8058). No auth — + * the token itself (verified via HMAC, see unsubscribeToken.js) IS the + * authorization, so a mail client's automated one-click POST works with no + * session and no page. Responds the same way (200/400) whether the token was + * tampered with, expired in format, or simply never existed — nothing here + * lets a caller distinguish those cases — barring an unexpected server error + * (422). + * @param {Object} req - Express request object (req.params.token) + * @param {Object} res - Express response object + */ +const unsubscribe = async (req, res) => { + try { + const parsed = verifyUnsubscribeToken(req.params.token); + if (!parsed) return responses.error(res, 400, 'Bad Request', 'Unsubscribe token is invalid.')(); + + const result = await UserService.setEmailPreference(parsed.userId, parsed.kind, false); + if (!result) return responses.error(res, 400, 'Bad Request', 'Unsubscribe token is invalid.')(); + + return responses.success(res, 'Unsubscribed successfully')({ kind: parsed.kind }); + } catch (err) { + return responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err); + } +}; + /** * @desc Endpoint to get stats of users and return data * @param {Object} req - Express request object @@ -102,4 +131,5 @@ export default { remove, me, stats, + unsubscribe, }; diff --git a/modules/users/doc/guides/02-email-preferences.md b/modules/users/doc/guides/02-email-preferences.md new file mode 100644 index 000000000..04d964126 --- /dev/null +++ b/modules/users/doc/guides/02-email-preferences.md @@ -0,0 +1,29 @@ +# Email Preferences & Unsubscribe + +Every user has an `emailPreferences` setting with two independent switches: `onboarding` and `news`. Both default to **on** — a user who has never touched this setting receives every kind of product email. + +This only affects **product email** (an onboarding series, an announcement). Transactional email — password reset, email verification, invitations — always goes through regardless of these switches. + +## Updating preferences + +Authenticated users can update their own preferences the same way they update the rest of their profile: + +```http +PUT /api/users +Authorization: Bearer +Content-Type: application/json + +{ "emailPreferences": { "onboarding": true, "news": false } } +``` + +Send both fields together: this call replaces the whole `emailPreferences` object, so a partial object resets the field you omit back to its default. + +## One-click unsubscribe + +Every product email sent through `sendProductMail`/`announce` carries a `List-Unsubscribe` header (plus `List-Unsubscribe-Post: List-Unsubscribe=One-Click`, per RFC 8058). Mail clients that support one-click unsubscribe can turn a kind off with no login and no page — the header points directly at: + +```http +POST /api/users/unsubscribe/:token +``` + +The token is specific to one user and one kind, has no expiry, and can be posted more than once safely (turning the kind off again is a no-op). A request with an invalid or tampered token returns `400`; a successful one returns `200` with the kind that was turned off. diff --git a/modules/users/models/users.emailPreferences.schema.js b/modules/users/models/users.emailPreferences.schema.js new file mode 100644 index 000000000..08671f1f2 --- /dev/null +++ b/modules/users/models/users.emailPreferences.schema.js @@ -0,0 +1,36 @@ +/** + * Module dependencies + */ +import { z } from 'zod'; + +/** + * Per-kind product-email opt-out (#4162). Same pattern as `Attribution` in + * users.schema.js: a fixed-shape, `.strict()` sub-object so an unknown key is + * REJECTED instead of silently accepted. Both kinds default `true` (opt-OUT + * model) so an existing user with no `emailPreferences` at all reads as + * "every kind on" — no migration needed. A write is a WHOLE-OBJECT replace + * (same semantics `complementary` already has on `users.schema.js`) — a + * caller that wants to flip one kind without touching the other must send + * both. + * + * Deliberately its OWN file, with no `config` import: `users.schema.js`'s + * `User` reads `config.whitelists.users.roles` at module-evaluation time, so + * any module that imports it — even for an unrelated export — inherits that + * coupling. `users.service.js` needs `EmailKind` at runtime (to validate + * `sendProductMail`/`announce`/`setEmailPreference`'s `kind` argument) without + * pulling that whole chain in; `users.schema.js` re-exports both from here so + * the HTTP-facing `User`/`UserUpdate` schemas keep working unchanged. + */ +const EmailPreferences = z.object({ + onboarding: z.boolean().optional().default(true), + news: z.boolean().optional().default(true), +}).strict(); + +/** + * The product-email kinds `sendProductMail`/`announce` (users.service.js) + * accept, and the only keys `EmailPreferences` declares — kept side by side + * so the two can never drift apart. + */ +const EmailKind = z.enum(['onboarding', 'news']); + +export { EmailPreferences, EmailKind }; diff --git a/modules/users/models/users.model.mongoose.js b/modules/users/models/users.model.mongoose.js index 53d898fcb..87dbdde52 100644 --- a/modules/users/models/users.model.mongoose.js +++ b/modules/users/models/users.model.mongoose.js @@ -78,6 +78,15 @@ const UserMongoose = new Schema( utmTerm: String, utmContent: String, }, + // Per-kind product-email opt-out (#4162). Both default true (opt-OUT + // model, matches the Zod schema) — a user document that never touched + // this field reads as emailPreferences: undefined, which the service + // layer's `=== false` skip check already treats as "every kind on", so + // no migration is needed for existing users. + emailPreferences: { + onboarding: { type: Boolean, default: true }, + news: { type: Boolean, default: true }, + }, }, { timestamps: true, diff --git a/modules/users/models/users.schema.js b/modules/users/models/users.schema.js index 74d337bdf..4e888ea3b 100644 --- a/modules/users/models/users.schema.js +++ b/modules/users/models/users.schema.js @@ -5,6 +5,7 @@ import { z } from 'zod'; import config from '../../../config/index.js'; import zodHelpers from '../../../lib/helpers/zod.js'; +import { EmailPreferences, EmailKind } from './users.emailPreferences.schema.js'; const names = /^[a-zA-ZàáâäãåąčćęèéêëėįìíîïłńòóôöõøùúûüųūÿýżźñçčšžÀÁÂÄÃÅĄĆČĖĘÈÉÊËÌÍÎÏĮŁŃÒÓÔÖÕØÙÚÛÜŲŪŸÝŻŹÑßÇŒÆČŠŽ∂ð ,.'-]+$/u; @@ -25,6 +26,12 @@ const Attribution = z.object({ utmContent: z.string().max(256).trim().optional(), }).strict(); +// `EmailPreferences` / `EmailKind` (#4162) live in their own file, imported +// above, and are re-exported below so `usersSchema.EmailPreferences` / +// `usersSchema.EmailKind` keep working for any existing caller — see that +// file's doc comment for why: `users.service.js` needs `EmailKind` without +// pulling in this file's `config.whitelists.users.roles` coupling. + /** * User Data Schema */ @@ -91,6 +98,11 @@ const User = z.object({ // client must never be able to overwrite its own first-touch attribution // after the fact via the profile-update surface. attribution: Attribution.optional(), + // Per-kind product-email opt-out (#4162) — unlike `attribution`, this one + // IS self-editable (see `UserUpdate` below and the `update` whitelist in + // modules/users/config/users.development.config.js); a user must be able + // to change their own email preferences. + emailPreferences: EmailPreferences.optional(), }); // `attribution` is EXCLUDED here (not just left off the `update`/`updateAdmin` @@ -153,4 +165,6 @@ export default { UserUpdate, SignupUser, Attribution, + EmailPreferences, + EmailKind, }; diff --git a/modules/users/repositories/users.repository.js b/modules/users/repositories/users.repository.js index f24f4445d..b0c5815fa 100644 --- a/modules/users/repositories/users.repository.js +++ b/modules/users/repositories/users.repository.js @@ -137,6 +137,27 @@ const consumeEmailVerificationToken = (token) => { ).exec(); }; +/** + * @desc Atomically set one `emailPreferences` kind for a user (#4162). Used + * by the public one-click unsubscribe route — the caller has already + * verified the HMAC token and only needs the write. Tolerant of an + * invalid/unknown id: returns null rather than letting an invalid ObjectId + * string reach Mongoose and throw a CastError. + * @param {String} userId - the user id (from a verified unsubscribe token) + * @param {String} kind - email kind ('onboarding' | 'news') + * @param {Boolean} value - the new preference value + * @returns {Promise} the updated user document, or null when + * the id is invalid or no document matched it. + */ +const setEmailPreference = (userId, kind, value) => { + if (!mongoose.Types.ObjectId.isValid(userId)) return Promise.resolve(null); + return User.findOneAndUpdate( + { _id: userId }, + { $set: { [`emailPreferences.${kind}`]: value } }, + { returnDocument: 'after', runValidators: true }, + ).exec(); +}; + /** * @desc Function to remove a user from db by id or email * @param {Object} user @@ -245,6 +266,32 @@ const findByIdAndUpdatePopulated = (id, data, populateFields) => */ const findWithFilter = (filter, select) => User.find(filter).select(select || '').exec(); +/** + * @desc Fetch one `_id`-ordered page of users matching a filter, projected down + * to `select` (#4162 — UserService.announce's recipient fan-out). Each page is + * its own query, re-filtered by `_id: { $gt: afterId }` — no server-side cursor + * state — so a caller that does something slow (e.g. a sequential mail send) + * between pages can never hit a MongoDB cursor idle-timeout. Returns `[]` once + * the walk is exhausted. + * @param {Object} filter - Mongoose filter (ANDed with the `_id` page bound) + * @param {Object} [options] + * @param {String} [options.afterId] - exclusive lower bound (`_id` of the last + * row from the previous page); omit for the first page + * @param {Number} [options.limit] - page size (default 200) + * @param {String} [options.select] - fields to select; callers MUST limit this + * to what they actually read (e.g. never password/providerData) + * @returns {Promise} up to `limit` plain (lean) user objects, `_id`-ascending + */ +const findPage = (filter, { afterId, limit = 200, select } = {}) => { + const pageFilter = afterId ? { ...filter, _id: { $gt: afterId } } : filter; + return User.find(pageFilter) + .select(select || '') + .sort({ _id: 1 }) + .limit(limit) + .lean() + .exec(); +}; + /** * @desc Function to update multiple users matching a filter * @param {Object} filter - Mongoose filter @@ -283,6 +330,7 @@ export default { search, update, consumeEmailVerificationToken, + setEmailPreference, remove, stats, count, @@ -292,6 +340,7 @@ export default { updateById, findByIdAndUpdatePopulated, findWithFilter, + findPage, updateMany, linkProviderByEmail, }; diff --git a/modules/users/routes/users.routes.js b/modules/users/routes/users.routes.js index 8bb25e046..5991a9642 100644 --- a/modules/users/routes/users.routes.js +++ b/modules/users/routes/users.routes.js @@ -7,6 +7,7 @@ import multer from '../../../lib/services/multer.js'; import model from '../../../lib/middlewares/model.js'; import config from '../../../config/index.js'; import policy from '../../../lib/middlewares/policy.js'; +import limiters from '../../../lib/middlewares/rateLimiter.js'; import usersSchema from '../models/users.schema.js'; import users from '../controllers/users.account.controller.js'; import usersImage from '../controllers/users.images.controller.js'; @@ -15,6 +16,14 @@ import authPassword from '../../auth/controllers/auth.password.controller.js'; export default (app) => { app.route('/api/users/stats').all(policy.isAllowed).get(users.stats); + // One-click unsubscribe (#4162) — public, token-authorized (RFC 8058), no + // passport/policy middleware. Its OWN rate-limit profile (`unsubscribe`, see + // users.development.config.js / production.config.js) — NOT `limiters.auth`: + // these POSTs come from mail providers' shared egress IPs, so auth's + // 10/15min-per-IP cap would lock out genuine recipients after a handful of + // sends from the same provider. + app.route('/api/users/unsubscribe/:token').post(limiters.unsubscribe, users.unsubscribe); + app.route('/api/users/me').get(passport.authenticate('jwt', { session: false }), policy.isAllowed, users.me); app.route('/api/users/terms').get(passport.authenticate('jwt', { session: false }), policy.isAllowed, users.terms); diff --git a/modules/users/services/users.service.js b/modules/users/services/users.service.js index 4017ed852..42e0abded 100644 --- a/modules/users/services/users.service.js +++ b/modules/users/services/users.service.js @@ -14,6 +14,10 @@ import MembershipService from '../../organizations/services/organizations.member import MembershipRepository from '../../organizations/repositories/organizations.membership.repository.js'; import { MEMBERSHIP_ROLES, MEMBERSHIP_STATUSES } from '../../organizations/lib/constants.js'; import { removeSensitive } from '../utils/sanitizeUser.js'; +import { createUnsubscribeToken } from '../utils/unsubscribeToken.js'; +// `EmailKind` only — NOT `users.schema.js`, which reads `config.whitelists.users.roles` +// at module-evaluation time (see users.emailPreferences.schema.js's doc comment). +import { EmailKind } from '../models/users.emailPreferences.schema.js'; /** * @function normalizeEmail @@ -160,6 +164,129 @@ const update = async (user, body, option) => { */ const consumeEmailVerificationToken = (token) => UserRepository.consumeEmailVerificationToken(token); +/** + * @desc Send a product email (onboarding series / announcement) to one user, + * respecting their per-kind opt-out (#4162). Mirrors the existing + * `mailer.sendMail` contract (template/subject/params/from/replyTo) and adds + * the one-click unsubscribe machinery on top: a signed, stateless token is + * minted for this exact (user, kind) pair, exposed to the template as + * `params.unsubscribeUrl`, and carried on the `List-Unsubscribe` / + * `List-Unsubscribe-Post` headers (RFC 8058) so a mail client can offer a + * true one-click unsubscribe with no page and no login. + * + * Skipped silently (no error, no send) when the user opted out of this kind, + * or hasn't verified their email — product mail is never pushed at an + * address the account owner hasn't proven. An unconfigured mailer degrades + * the same way `mailer.sendMail` already does elsewhere (resolves to null). + * @param {Object} user - user document (`_id`/`id`, `email`, `emailVerified`, + * `emailPreferences`) + * @param {Object} mail + * @param {'onboarding'|'news'} mail.kind - must match `EmailKind` (users.emailPreferences.schema.js) + * @param {string} mail.template - handlebars template key (see mailer/index.js) + * @param {string} mail.subject + * @param {Object} [mail.params] - template params; `unsubscribeUrl` is added on top + * @param {string} [mail.from] + * @param {string|string[]} [mail.replyTo] + * @returns {Promise} the mailer result, or null when skipped or + * the mailer isn't configured + */ +const sendProductMail = async (user, { kind, template, subject, params, from, replyTo }) => { + const parsedKind = EmailKind.parse(kind); + if (!user?.emailVerified) return null; + if (user.emailPreferences?.[parsedKind] === false) return null; + + const userId = String(user._id || user.id); + const unsubscribeUrl = `${getBaseUrl()}/api/users/unsubscribe/${createUnsubscribeToken(userId, parsedKind)}`; + + return mailer.sendMail({ + template, + to: user.email, + subject, + params: { ...params, unsubscribeUrl }, + from, + replyTo, + headers: { + 'List-Unsubscribe': `<${unsubscribeUrl}>`, + 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', + }, + }); +}; + +// Page size for `announce`'s _id-ordered recipient walk (#4162) — see UserRepository.findPage. +const ANNOUNCE_PAGE_SIZE = 200; + +/** + * @desc Send a product email to every verified user who has not opted out of + * `kind` (#4162). Recipients are walked in `_id`-ordered pages of + * `ANNOUNCE_PAGE_SIZE` (UserRepository.findPage) instead of one unbounded + * array load, so a large user base can't exhaust process memory; each page's + * projection is limited to exactly what `sendProductMail` reads (`_id`, + * `email`, `emailVerified`, `emailPreferences`) — never the password hash or + * OAuth provider tokens. Pages, not a server-side cursor, on purpose: the + * sequential per-recipient sends below can be slow enough between pages to + * outlive a MongoDB cursor's idle timeout. The opt-out/verified filter is + * applied per page query — a user who flips their preference mid-run is not + * re-checked at send time, only the already-fetched snapshot is used. + * Sequential sends — never parallel — so a large user base can't burst the + * mailer provider's rate limit. A per-recipient send failure is logged and + * does NOT stop the loop; the rest of the list still gets mailed. + * @param {Object} mail + * @param {'onboarding'|'news'} mail.kind + * @param {string} mail.template + * @param {string} mail.subject + * @param {Object} [mail.params] + * @param {string} [mail.from] + * @param {string|string[]} [mail.replyTo] + * @returns {Promise<{sent: number}>} count of recipients actually mailed + * (excludes per-recipient skips and failures) + */ +const announce = async ({ kind, template, subject, params, from, replyTo }) => { + const parsedKind = EmailKind.parse(kind); + const filter = { + emailVerified: true, + [`emailPreferences.${parsedKind}`]: { $ne: false }, + }; + const select = '_id email emailVerified emailPreferences'; + + let sent = 0; + let afterId; + let page; + do { + page = await UserRepository.findPage(filter, { afterId, limit: ANNOUNCE_PAGE_SIZE, select }); + + for (const recipient of page) { + try { + // Sequential on purpose — see the JSDoc above (never parallel: avoid bursting the mailer provider). + const result = await sendProductMail(recipient, { kind: parsedKind, template, subject, params, from, replyTo }); + if (result) sent += 1; + } catch (err) { + // recipient is a lean object (no `id` virtual) — _id is the only identifier available. + logger.warn('users.announce: send failed for one recipient', { userId: String(recipient._id), kind: parsedKind, message: err?.message, stack: err?.stack }); + } + } + + if (page.length > 0) afterId = page[page.length - 1]._id; + } while (page.length === ANNOUNCE_PAGE_SIZE); + + return { sent }; +}; + +/** + * @desc Set one `emailPreferences` kind for a user by id (#4162). Used by the + * public one-click unsubscribe route, after the caller has already verified + * the request's HMAC token — this function does no token handling itself. + * @param {String} userId - the user id (from a verified unsubscribe token) + * @param {String} kind - email kind ('onboarding' | 'news') + * @param {Boolean} value - the new preference value + * @returns {Promise} sanitized updated user, or null when the + * id is invalid or no user matched it + */ +const setEmailPreference = async (userId, kind, value) => { + const parsedKind = EmailKind.parse(kind); + const result = await UserRepository.setEmailPreference(userId, parsedKind, value); + return result ? removeSensitive(result) : null; +}; + /** * @desc Function to ask repository to sign terms for current user * @param {Object} user - original user document @@ -301,6 +428,9 @@ export default { getBrut, update, consumeEmailVerificationToken, + sendProductMail, + announce, + setEmailPreference, terms, remove, stats, diff --git a/modules/users/tests/user.account.integration.tests.js b/modules/users/tests/user.account.integration.tests.js index 493e7fb50..dceb324fd 100644 --- a/modules/users/tests/user.account.integration.tests.js +++ b/modules/users/tests/user.account.integration.tests.js @@ -337,6 +337,47 @@ describe('User integration tests:', () => { } }); + // #4162 — emailPreferences is on the self-editable profile allowlist: a client + // CAN change its own product-email opt-out via the same PUT /api/users surface. + test('should be able to update own emailPreferences via PUT /api/users', async () => { + try { + const result = await agent.put('/api/users').send({ emailPreferences: { onboarding: true, news: false } }).expect(200); + expect(result.body.data.emailPreferences).toEqual({ onboarding: true, news: false }); + } catch (err) { + console.log(err); + expect(err).toBeFalsy(); + } + + try { + const raw = await UserService.getBrut({ id: user.id }); + expect(raw.emailPreferences.onboarding).toBe(true); + expect(raw.emailPreferences.news).toBe(false); + } catch (err) { + console.log(err); + expect(err).toBeFalsy(); + } + }); + + // #4162 — emailPreferences is writable, but that must not open the door to + // anything else: roles stays mass-assignment-blocked even in the SAME request. + test('mass-assignment is still blocked for other fields when emailPreferences is also sent', async () => { + try { + await agent.put('/api/users').send({ emailPreferences: { onboarding: true, news: true }, roles: ['admin'] }).expect(200); + } catch (err) { + console.log(err); + expect(err).toBeFalsy(); + } + + try { + const raw = await UserService.getBrut({ id: user.id }); + expect(raw.roles).toEqual(['user']); + expect(raw.emailPreferences.news).toBe(true); + } catch (err) { + console.log(err); + expect(err).toBeFalsy(); + } + }); + // P8a / E20: referredBy is a server-only referral field — a client must never be // able to self-assign a referrer via the self-update endpoint. It is absent from // config.whitelists.users.update, so removeSensitive strips it. This proves the diff --git a/modules/users/tests/users.repository.findPage.integration.tests.js b/modules/users/tests/users.repository.findPage.integration.tests.js new file mode 100644 index 000000000..f8118219b --- /dev/null +++ b/modules/users/tests/users.repository.findPage.integration.tests.js @@ -0,0 +1,75 @@ +/** + * Module dependencies. + */ +import mongoose from 'mongoose'; +import { describe, beforeAll, afterEach, afterAll, test, expect } from '@jest/globals'; + +import mongooseService from '../../../lib/services/mongoose.js'; + +/** + * Integration tests for UserRepository.findPage (#4162 — UserService.announce's + * `_id`-ordered recipient walk). Real Mongo, not mocked: the one caller in this + * codebase (`announce`) only ever exercises this through a mocked repository + * (see users.service.sendProductMail.unit.tests.js), so the actual query — + * the `afterId` page bound and the projection — needs its own direct coverage. + */ +describe('UserRepository.findPage integration tests (#4162):', () => { + let UserRepository; + let User; + let insertedIds = []; + + beforeAll(async () => { + await mongooseService.loadModels(); + await mongooseService.connect(); + User = mongoose.model('User'); + UserRepository = (await import('../repositories/users.repository.js')).default; + }); + + afterEach(async () => { + if (insertedIds.length) { + await User.deleteMany({ _id: { $in: insertedIds } }); + insertedIds = []; + } + }); + + afterAll(async () => { + await mongooseService.disconnect(); + }); + + test('walks all matching rows across pages in ascending _id order with no gap or overlap, then returns [] once exhausted', async () => { + const docs = await User.insertMany([ + { email: 'findpage-1@test.com', emailVerified: true }, + { email: 'findpage-2@test.com', emailVerified: true }, + { email: 'findpage-3@test.com', emailVerified: true }, + ]); + insertedIds = docs.map((doc) => doc._id); + const filter = { _id: { $in: insertedIds } }; + + // First page — no afterId: exercises the `pageFilter = filter` branch. + const page1 = await UserRepository.findPage(filter, { afterId: undefined, limit: 2, select: '_id email' }); + expect(page1).toHaveLength(2); + expect(page1[0]).not.toHaveProperty('password'); + + // Second page — afterId set: exercises the `{ ...filter, _id: { $gt: afterId } }` branch. + const page2 = await UserRepository.findPage(filter, { afterId: page1[page1.length - 1]._id, limit: 2, select: '_id email' }); + expect(page2).toHaveLength(1); + + const walked = [...page1, ...page2].map((doc) => String(doc._id)); + expect(walked).toEqual([...walked].sort()); // ascending _id order, start to finish + expect(new Set(walked).size).toBe(3); // no row repeated across pages + + // Exhausted: asking for anything past the last row's _id returns []. + const page3 = await UserRepository.findPage(filter, { afterId: page2[0]._id, limit: 2, select: '_id email' }); + expect(page3).toEqual([]); + }); + + test('defaults to a 200-row limit and projects only the requested fields', async () => { + const [doc] = await User.insertMany([{ email: 'findpage-default@test.com', emailVerified: true, firstName: 'Should not leak' }]); + insertedIds = [doc._id]; + + const page = await UserRepository.findPage({ _id: doc._id }, { select: '_id email emailVerified' }); + expect(page).toHaveLength(1); + expect(page[0].email).toBe('findpage-default@test.com'); + expect(page[0]).not.toHaveProperty('firstName'); + }); +}); diff --git a/modules/users/tests/users.service.sendProductMail.unit.tests.js b/modules/users/tests/users.service.sendProductMail.unit.tests.js new file mode 100644 index 000000000..3035130ad --- /dev/null +++ b/modules/users/tests/users.service.sendProductMail.unit.tests.js @@ -0,0 +1,273 @@ +/** + * Unit tests — UserService.sendProductMail / announce / setEmailPreference (#4162). + * + * Mocks the repository + mailer + organizations seam (same pattern as + * `users.service.count.unit.tests.js` — mocking the organizations + * repositories directly, not just their services, keeps `mongoose.model('Organization')` + * / `mongoose.model('Membership')` from ever being called at module-evaluation + * time). `config/index.js` and `users.schema.js` are left REAL: `EmailKind` + * validation and `getBaseUrl()` both need the real, fully-merged test config. + */ +import { jest, describe, test, expect, beforeEach } from '@jest/globals'; + +const mockFindWithFilter = jest.fn(); +const mockFindPage = jest.fn(); +const mockSetEmailPreference = jest.fn(); + +jest.unstable_mockModule('../repositories/users.repository.js', () => ({ + default: { + list: jest.fn(), + create: jest.fn(), + search: jest.fn(), + get: jest.fn(), + update: jest.fn(), + consumeEmailVerificationToken: jest.fn(), + setEmailPreference: mockSetEmailPreference, + remove: jest.fn(), + stats: jest.fn(), + count: jest.fn(), + push: jest.fn(), + searchByNameOrEmail: jest.fn(), + findByEmail: jest.fn(), + updateById: jest.fn(), + findByIdAndUpdatePopulated: jest.fn(), + findWithFilter: mockFindWithFilter, + findPage: mockFindPage, + updateMany: jest.fn(), + linkProviderByEmail: jest.fn(), + }, +})); + +const mockSendMail = jest.fn(); + +jest.unstable_mockModule('../../../lib/helpers/mailer/index.js', () => ({ + default: { sendMail: mockSendMail, isConfigured: jest.fn().mockReturnValue(true), render: jest.fn(), getBrand: jest.fn() }, +})); + +const mockLoggerWarn = jest.fn(); + +jest.unstable_mockModule('../../../lib/services/logger.js', () => ({ + default: { info: jest.fn(), warn: mockLoggerWarn, error: jest.fn(), debug: jest.fn() }, +})); + +jest.unstable_mockModule('../utils/sanitizeUser.js', () => ({ + removeSensitive: jest.fn((u) => u), +})); + +jest.unstable_mockModule('../../organizations/services/organizations.membership.service.js', () => ({ + default: { listByUser: jest.fn(), create: jest.fn(), remove: jest.fn() }, +})); + +jest.unstable_mockModule('../../organizations/services/organizations.crud.service.js', () => ({ + default: { create: jest.fn(), get: jest.fn(), remove: jest.fn() }, +})); + +jest.unstable_mockModule('../../organizations/repositories/organizations.repository.js', () => ({ + default: { + list: jest.fn(), + create: jest.fn(), + get: jest.fn(), + remove: jest.fn(), + removeById: jest.fn(), + findOne: jest.fn(), + exists: jest.fn(), + update: jest.fn(), + updateById: jest.fn(), + setPlan: jest.fn(), + deleteMany: jest.fn(), + }, +})); + +jest.unstable_mockModule('../../organizations/repositories/organizations.membership.repository.js', () => ({ + default: { + list: jest.fn(), + create: jest.fn(), + get: jest.fn(), + remove: jest.fn(), + count: jest.fn(), + deleteMany: jest.fn(), + aggregateCountByOrganizations: jest.fn(), + }, +})); + +jest.unstable_mockModule('../../organizations/lib/constants.js', () => ({ + MEMBERSHIP_ROLES: { OWNER: 'owner', MEMBER: 'member' }, + MEMBERSHIP_STATUSES: { ACTIVE: 'active', PENDING: 'pending' }, +})); + +const { default: UserService } = await import('../services/users.service.js'); +const { verifyUnsubscribeToken } = await import('../utils/unsubscribeToken.js'); + +const verifiedUser = { _id: '64b2f0000000000000000abc', id: '64b2f0000000000000000abc', email: 'a@test.com', emailVerified: true }; + +beforeEach(() => { + mockFindWithFilter.mockReset(); + mockFindPage.mockReset(); + mockSetEmailPreference.mockReset(); + mockSendMail.mockReset().mockResolvedValue({ accepted: ['a@test.com'], rejected: [] }); + mockLoggerWarn.mockReset(); +}); + +describe('UserService.sendProductMail', () => { + test('rejects an unknown kind (defense in depth — EmailKind enum)', async () => { + await expect(UserService.sendProductMail(verifiedUser, { kind: 'spam', template: 't', subject: 's' })).rejects.toThrow(); + expect(mockSendMail).not.toHaveBeenCalled(); + }); + + test('skips (no send, no error) when the user has not verified their email', async () => { + const result = await UserService.sendProductMail({ ...verifiedUser, emailVerified: false }, { kind: 'news', template: 't', subject: 's' }); + expect(result).toBeNull(); + expect(mockSendMail).not.toHaveBeenCalled(); + }); + + test('skips when the user explicitly opted out of this kind', async () => { + const optedOut = { ...verifiedUser, emailPreferences: { news: false } }; + const result = await UserService.sendProductMail(optedOut, { kind: 'news', template: 't', subject: 's' }); + expect(result).toBeNull(); + expect(mockSendMail).not.toHaveBeenCalled(); + }); + + test('sends when emailPreferences is absent entirely (absent = on, no migration needed)', async () => { + const result = await UserService.sendProductMail(verifiedUser, { kind: 'news', template: 't', subject: 's' }); + expect(result).toEqual({ accepted: ['a@test.com'], rejected: [] }); + expect(mockSendMail).toHaveBeenCalledTimes(1); + }); + + test('sends when the user opted out of the OTHER kind but not this one', async () => { + const partialOptOut = { ...verifiedUser, emailPreferences: { onboarding: false } }; + const result = await UserService.sendProductMail(partialOptOut, { kind: 'news', template: 't', subject: 's' }); + expect(result).not.toBeNull(); + expect(mockSendMail).toHaveBeenCalledTimes(1); + }); + + test('attaches the List-Unsubscribe / List-Unsubscribe-Post headers (RFC 8058)', async () => { + await UserService.sendProductMail(verifiedUser, { kind: 'news', template: 't', subject: 's' }); + const call = mockSendMail.mock.calls[0][0]; + expect(call.headers['List-Unsubscribe-Post']).toBe('List-Unsubscribe=One-Click'); + expect(call.headers['List-Unsubscribe']).toMatch(/^$/); + }); + + test('the unsubscribe URL embeds a token that verifies back to this user and kind', async () => { + await UserService.sendProductMail(verifiedUser, { kind: 'news', template: 't', subject: 's' }); + const call = mockSendMail.mock.calls[0][0]; + const url = call.headers['List-Unsubscribe'].slice(1, -1); // strip < > + const token = url.split('/').pop(); + expect(verifyUnsubscribeToken(token)).toEqual({ userId: '64b2f0000000000000000abc', kind: 'news' }); + }); + + test('passes template/subject/from/replyTo through, and merges unsubscribeUrl into params without dropping caller params', async () => { + await UserService.sendProductMail(verifiedUser, { + kind: 'onboarding', + template: 'onboarding-day-1', + subject: 'Welcome', + params: { displayName: 'Ada' }, + from: 'Team ', + replyTo: 'support@test.com', + }); + const call = mockSendMail.mock.calls[0][0]; + expect(call.template).toBe('onboarding-day-1'); + expect(call.subject).toBe('Welcome'); + expect(call.from).toBe('Team '); + expect(call.replyTo).toBe('support@test.com'); + expect(call.params.displayName).toBe('Ada'); + expect(call.params.unsubscribeUrl).toMatch(/^https?:\/\//); + }); + + test('degrades to null when the mailer itself is unconfigured (same contract as mailer.sendMail)', async () => { + mockSendMail.mockResolvedValue(null); + const result = await UserService.sendProductMail(verifiedUser, { kind: 'news', template: 't', subject: 's' }); + expect(result).toBeNull(); + }); +}); + +describe('UserService.announce', () => { + // Recipients are deliberately LEAN-shaped (no `id` virtual) — matches what + // UserRepository.findPage actually returns in production (#4162). A mock with + // a convenience `id` field would hide a regression that reads `recipient.id`. + test('queries only verified, not-opted-out-for-this-kind users, projected to what sendProductMail reads', async () => { + mockFindPage.mockResolvedValue([]); + await UserService.announce({ kind: 'news', template: 't', subject: 's' }); + expect(mockFindPage).toHaveBeenCalledWith( + { emailVerified: true, 'emailPreferences.news': { $ne: false } }, + { afterId: undefined, limit: 200, select: '_id email emailVerified emailPreferences' }, + ); + }); + + test('sends to every recipient the first page returns and counts them', async () => { + mockFindPage + .mockResolvedValueOnce([ + { _id: '1', email: 'one@test.com', emailVerified: true }, + { _id: '2', email: 'two@test.com', emailVerified: true }, + ]); + const result = await UserService.announce({ kind: 'news', template: 't', subject: 's' }); + expect(mockSendMail).toHaveBeenCalledTimes(2); + expect(result).toEqual({ sent: 2 }); + // page smaller than the page size (2 < 200) — walk stops, no extra query + expect(mockFindPage).toHaveBeenCalledTimes(1); + }); + + test('walks a second _id-ordered page when the first page is full, and stops on a short page', async () => { + const fullPage = Array.from({ length: 200 }, (_, i) => ({ _id: String(i + 1), email: `u${i + 1}@test.com`, emailVerified: true })); + mockFindPage + .mockResolvedValueOnce(fullPage) + .mockResolvedValueOnce([{ _id: '201', email: 'u201@test.com', emailVerified: true }]); + + const result = await UserService.announce({ kind: 'news', template: 't', subject: 's' }); + + expect(mockFindPage).toHaveBeenCalledTimes(2); + expect(mockFindPage).toHaveBeenNthCalledWith( + 1, + { emailVerified: true, 'emailPreferences.news': { $ne: false } }, + { afterId: undefined, limit: 200, select: '_id email emailVerified emailPreferences' }, + ); + expect(mockFindPage).toHaveBeenNthCalledWith( + 2, + { emailVerified: true, 'emailPreferences.news': { $ne: false } }, + { afterId: '200', limit: 200, select: '_id email emailVerified emailPreferences' }, + ); + expect(mockSendMail).toHaveBeenCalledTimes(201); + expect(result).toEqual({ sent: 201 }); + }); + + test('a per-recipient send failure is logged by _id (no `id` virtual on a lean doc) and does NOT stop the loop', async () => { + mockFindPage + .mockResolvedValueOnce([ + { _id: '1', email: 'one@test.com', emailVerified: true }, + { _id: '2', email: 'two@test.com', emailVerified: true }, + ]); + mockSendMail.mockRejectedValueOnce(new Error('provider down')).mockResolvedValueOnce({ accepted: ['two@test.com'], rejected: [] }); + + const result = await UserService.announce({ kind: 'news', template: 't', subject: 's' }); + + expect(mockSendMail).toHaveBeenCalledTimes(2); // second recipient still attempted + expect(result).toEqual({ sent: 1 }); // only the successful send counts + expect(mockLoggerWarn).toHaveBeenCalledWith(expect.stringContaining('announce'), expect.objectContaining({ userId: '1' })); + }); + + test('returns { sent: 0 } with no recipients', async () => { + mockFindPage.mockResolvedValue([]); + const result = await UserService.announce({ kind: 'onboarding', template: 't', subject: 's' }); + expect(result).toEqual({ sent: 0 }); + expect(mockSendMail).not.toHaveBeenCalled(); + }); +}); + +describe('UserService.setEmailPreference', () => { + test('delegates to the repository and returns the sanitized result', async () => { + mockSetEmailPreference.mockResolvedValue({ id: '64b2f0000000000000000abc', emailPreferences: { news: false } }); + const result = await UserService.setEmailPreference('64b2f0000000000000000abc', 'news', false); + expect(mockSetEmailPreference).toHaveBeenCalledWith('64b2f0000000000000000abc', 'news', false); + expect(result).toEqual({ id: '64b2f0000000000000000abc', emailPreferences: { news: false } }); + }); + + test('returns null when the repository finds no match', async () => { + mockSetEmailPreference.mockResolvedValue(null); + const result = await UserService.setEmailPreference('000000000000000000000000', 'news', false); + expect(result).toBeNull(); + }); + + test('rejects an unknown kind before ever calling the repository', async () => { + await expect(UserService.setEmailPreference('64b2f0000000000000000abc', 'spam', false)).rejects.toThrow(); + expect(mockSetEmailPreference).not.toHaveBeenCalled(); + }); +}); diff --git a/modules/users/tests/users.unsubscribe.integration.tests.js b/modules/users/tests/users.unsubscribe.integration.tests.js new file mode 100644 index 000000000..ac71bcf67 --- /dev/null +++ b/modules/users/tests/users.unsubscribe.integration.tests.js @@ -0,0 +1,135 @@ +/** + * Module dependencies. + */ +import request from 'supertest'; +import path from 'path'; + +import { bootstrap } from '../../../lib/app.js'; +import mongooseService from '../../../lib/services/mongoose.js'; + +/** + * Public one-click unsubscribe route (#4162, RFC 8058): + * POST /api/users/unsubscribe/:token — no auth, the token itself authorizes + * the write. Covers the full round trip: a real `sendProductMail` call mints + * the token, the route consumes it, and the persisted `emailPreferences` + * reflects the change. + */ +describe('Unsubscribe integration tests:', () => { + let UserService = null; + let createUnsubscribeToken = null; + let app; + let agent; + let user; + + beforeAll(async () => { + try { + const init = await bootstrap(); + UserService = (await import(path.resolve('./modules/users/services/users.service.js'))).default; + ({ createUnsubscribeToken } = await import(path.resolve('./modules/users/utils/unsubscribeToken.js'))); + app = init.app; + agent = request.agent(app); + } catch (err) { + console.log(err); + expect(err).toBeFalsy(); + } + }); + + beforeEach(async () => { + try { + const result = await agent.post('/api/auth/signup').send({ + firstName: 'First', + lastName: 'Last', + email: 'unsubscribe@test.com', + password: 'W@os.jsI$Aw3$0m3', + provider: 'local', + }).expect(200); + user = result.body.user; + } catch (err) { + console.log(err); + expect(err).toBeFalsy(); + } + }); + + afterEach(async () => { + try { + await UserService.remove(user); + } catch (err) { + console.log(err); + } + }); + + test('a valid token turns the targeted kind off and leaves the other kind untouched', async () => { + const token = createUnsubscribeToken(String(user.id), 'news'); + + const result = await request(app).post(`/api/users/unsubscribe/${token}`).expect(200); + expect(result.body.type).toBe('success'); + expect(result.body.data).toEqual({ kind: 'news' }); + + const raw = await UserService.getBrut({ id: user.id }); + expect(raw.emailPreferences.news).toBe(false); + expect(raw.emailPreferences.onboarding).toBe(true); + }); + + test('is idempotent — posting the same token twice is a no-op the second time, not an error', async () => { + const token = createUnsubscribeToken(String(user.id), 'onboarding'); + await request(app).post(`/api/users/unsubscribe/${token}`).expect(200); + await request(app).post(`/api/users/unsubscribe/${token}`).expect(200); + + const raw = await UserService.getBrut({ id: user.id }); + expect(raw.emailPreferences.onboarding).toBe(false); + }); + + test('rejects a tampered token (wrong kind swapped in) with 400, and does NOT write anything', async () => { + const token = createUnsubscribeToken(String(user.id), 'news'); + const [userId, , sig] = token.split('.'); + const tampered = `${userId}.onboarding.${sig}`; + + const before = await UserService.getBrut({ id: user.id }); + + const result = await request(app).post(`/api/users/unsubscribe/${tampered}`).expect(400); + expect(result.body.type).toBe('error'); + + const after = await UserService.getBrut({ id: user.id }); + // unchanged — a rejected token never reaches the write (both kinds still + // at their signup-time default, whatever that was) + expect(after.emailPreferences).toEqual(before.emailPreferences); + }); + + test('rejects a well-formed but unsigned token with 400', async () => { + const result = await request(app).post(`/api/users/unsubscribe/${user.id}.news.${'0'.repeat(64)}`).expect(400); + expect(result.body.type).toBe('error'); + }); + + test('rejects a token for a user id that does not exist with 400', async () => { + const token = createUnsubscribeToken('000000000000000000000000', 'news'); + const result = await request(app).post(`/api/users/unsubscribe/${token}`).expect(400); + expect(result.body.type).toBe('error'); + }); + + test('requires no authentication — works from an unauthenticated client', async () => { + const token = createUnsubscribeToken(String(user.id), 'news'); + // a fresh, cookie-less client (not the signed-in `agent`) + await request(app).post(`/api/users/unsubscribe/${token}`).expect(200); + }); + + test('a token with a valid signature but a kind outside the EmailKind enum hits the generic 422 path (defense in depth)', async () => { + // The HMAC only binds (userId, kind) together — it has no opinion on which kind + // strings are legal, so a token minted for a bogus kind still VERIFIES. The + // rejection happens one layer up, in UserService.setEmailPreference's Zod parse, + // which throws and is caught by the route's generic error handler (422), not the + // route's own 400 "invalid token" branch. + const token = createUnsubscribeToken(String(user.id), 'spam'); + const result = await request(app).post(`/api/users/unsubscribe/${token}`).expect(422); + expect(result.body.type).toBe('error'); + }); + + // Mongoose disconnect + afterAll(async () => { + try { + await mongooseService.disconnect(); + } catch (err) { + console.log(err); + expect(err).toBeFalsy(); + } + }); +}); diff --git a/modules/users/tests/users.unsubscribeToken.unit.tests.js b/modules/users/tests/users.unsubscribeToken.unit.tests.js new file mode 100644 index 000000000..8b3571d78 --- /dev/null +++ b/modules/users/tests/users.unsubscribeToken.unit.tests.js @@ -0,0 +1,82 @@ +/** + * Unit tests — unsubscribeToken.js (#4162). + * + * Mocks `config/index.js` so these tests are independent of the real + * `config.jwt.secret` value, then re-imports the module fresh per test file + * run (same pattern as `lib/helpers/tests/redactUrl.unit.tests.js`'s config + * sourcing tests). + */ +import { jest, describe, test, expect, beforeAll } from '@jest/globals'; + +let createUnsubscribeToken; +let verifyUnsubscribeToken; + +beforeAll(async () => { + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { jwt: { secret: 'test-unsubscribe-secret' } }, + })); + ({ createUnsubscribeToken, verifyUnsubscribeToken } = await import('../utils/unsubscribeToken.js')); +}); + +describe('unsubscribeToken', () => { + test('a freshly created token verifies back to the same userId and kind', () => { + const token = createUnsubscribeToken('64b2f0000000000000000abc', 'news'); + expect(verifyUnsubscribeToken(token)).toEqual({ userId: '64b2f0000000000000000abc', kind: 'news' }); + }); + + test('two different kinds for the same user produce different signatures (domain separation)', () => { + const newsToken = createUnsubscribeToken('64b2f0000000000000000abc', 'news'); + const onboardingToken = createUnsubscribeToken('64b2f0000000000000000abc', 'onboarding'); + expect(newsToken).not.toBe(onboardingToken); + // and neither verifies as the other kind + expect(verifyUnsubscribeToken(newsToken)).toEqual({ userId: '64b2f0000000000000000abc', kind: 'news' }); + expect(verifyUnsubscribeToken(onboardingToken)).toEqual({ userId: '64b2f0000000000000000abc', kind: 'onboarding' }); + }); + + test('two different users produce different signatures for the same kind', () => { + const tokenA = createUnsubscribeToken('64b2f0000000000000000aaa', 'news'); + const tokenB = createUnsubscribeToken('64b2f0000000000000000bbb', 'news'); + expect(tokenA).not.toBe(tokenB); + }); + + test('rejects a token whose kind was swapped without re-signing (signature no longer matches)', () => { + const token = createUnsubscribeToken('64b2f0000000000000000abc', 'news'); + const [userId, , sig] = token.split('.'); + const tampered = `${userId}.onboarding.${sig}`; + expect(verifyUnsubscribeToken(tampered)).toBeNull(); + }); + + test('rejects a token whose userId was swapped without re-signing', () => { + const token = createUnsubscribeToken('64b2f0000000000000000abc', 'news'); + const [, kind, sig] = token.split('.'); + const tampered = `64b2f0000000000000000bad.${kind}.${sig}`; + expect(verifyUnsubscribeToken(tampered)).toBeNull(); + }); + + test('rejects a token with a flipped signature byte', () => { + const token = createUnsubscribeToken('64b2f0000000000000000abc', 'news'); + const [userId, kind, sig] = token.split('.'); + const flipped = (sig[0] === 'a' ? 'b' : 'a') + sig.slice(1); + expect(verifyUnsubscribeToken(`${userId}.${kind}.${flipped}`)).toBeNull(); + }); + + test('rejects malformed tokens', () => { + expect(verifyUnsubscribeToken('')).toBeNull(); + expect(verifyUnsubscribeToken('not-a-token')).toBeNull(); + expect(verifyUnsubscribeToken('only.two')).toBeNull(); + expect(verifyUnsubscribeToken('way.too.many.parts.here')).toBeNull(); + expect(verifyUnsubscribeToken('..')).toBeNull(); + expect(verifyUnsubscribeToken('a.b.')).toBeNull(); + }); + + test('rejects a non-hex signature without throwing', () => { + expect(verifyUnsubscribeToken('64b2f0000000000000000abc.news.not-hex-at-all')).toBeNull(); + }); + + test('is tolerant of non-string / nullish input', () => { + expect(verifyUnsubscribeToken(undefined)).toBeNull(); + expect(verifyUnsubscribeToken(null)).toBeNull(); + expect(verifyUnsubscribeToken(42)).toBeNull(); + expect(verifyUnsubscribeToken({})).toBeNull(); + }); +}); diff --git a/modules/users/utils/unsubscribeToken.js b/modules/users/utils/unsubscribeToken.js new file mode 100644 index 000000000..2f8754998 --- /dev/null +++ b/modules/users/utils/unsubscribeToken.js @@ -0,0 +1,64 @@ +/** + * Module dependencies + */ +import crypto from 'crypto'; + +import config from '../../../config/index.js'; + +/** + * HMAC domain separation prefix (#4162). Binds the signature to "this is an + * unsubscribe token" so it can never be replayed as, or confused with, any + * other HMAC this stack might sign in the future with the same secret. + */ +const DOMAIN = 'unsubscribe:'; + +/** + * @desc Compute the HMAC-SHA256 signature for a (userId, kind) pair. + * @param {string} userId - stringified user id + * @param {string} kind - email kind ('onboarding' | 'news') + * @returns {string} hex-encoded signature + */ +const sign = (userId, kind) => crypto.createHmac('sha256', config.jwt.secret).update(`${DOMAIN}${userId}:${kind}`).digest('hex'); + +/** + * @desc Build a stateless, self-verifying one-click unsubscribe token. No DB + * storage, no expiry (#4162): the token encodes the (userId, kind) pair it + * authorizes, signed with the stack's existing JWT secret so it can't be + * forged without it. + * @param {string} userId - stringified user id + * @param {string} kind - email kind ('onboarding' | 'news') + * @returns {string} token in the form `userId.kind.sig` + */ +const createUnsubscribeToken = (userId, kind) => `${userId}.${kind}.${sign(userId, kind)}`; + +/** + * @desc Verify a token produced by `createUnsubscribeToken` and recover the + * (userId, kind) pair it authorizes. Constant-time signature comparison + * (`crypto.timingSafeEqual`) so a byte-by-byte timing side-channel can't be + * used to guess a valid signature. Tolerant of any malformed input — never + * throws, always returns null on anything that doesn't parse or verify. + * @param {string} token - raw token from the request path + * @returns {{userId: string, kind: string}|null} the authorized pair, or + * null when the token is malformed, tampered with, or unsignable. + */ +const verifyUnsubscribeToken = (token) => { + if (typeof token !== 'string' || token === '') return null; + + const parts = token.split('.'); + if (parts.length !== 3) return null; + const [userId, kind, sig] = parts; + if (!userId || !kind || !sig) return null; + + // Buffer.from(str, 'hex') never throws on bad input — it silently truncates + // at the first non-hex character (possibly to 0 bytes) — so a length + // mismatch against the expected 32-byte SHA-256 digest is the only check + // needed to reject a malformed/truncated signature. + const expectedBuf = Buffer.from(sign(userId, kind), 'hex'); + const sigBuf = Buffer.from(sig, 'hex'); + if (expectedBuf.length === 0 || expectedBuf.length !== sigBuf.length) return null; + if (!crypto.timingSafeEqual(expectedBuf, sigBuf)) return null; + + return { userId, kind }; +}; + +export { createUnsubscribeToken, verifyUnsubscribeToken };