diff --git a/backend/__tests__/routes/adminEmail.queueRedaction.test.js b/backend/__tests__/routes/adminEmail.queueRedaction.test.js new file mode 100644 index 00000000..98d88b33 --- /dev/null +++ b/backend/__tests__/routes/adminEmail.queueRedaction.test.js @@ -0,0 +1,56 @@ +/** + * The Messages reading pane must not serve passwords (see + * utils/emailSecretRedaction.js). Rows sent before the processor learned to + * scrub still carry the gallery password in email_data and rendered_html; + * the route redacts them on read. + */ +const path = require('path'); +const fs = require('fs'); +const os = require('os'); + +process.env.NODE_ENV = 'test'; +process.env.TEST_DATABASE_PATH = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-paneredact-')), 'db.sqlite'); +process.env.JWT_SECRET = process.env.JWT_SECRET || 'paneredact-test-secret'; +process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-paneredact-storage-')); + +const request = require('supertest'); +const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp } = require('../integration/helpers/crmDb'); +const { invalidateFeatureFlagCache } = require('../../src/middleware/requireFeatureFlag'); +const { MASK } = require('../../src/utils/emailSecretRedaction'); + +describe('GET /admin/email/queue/:id redacts secrets from legacy rows', () => { + let db; let cleanup; let app; let token; let rowId; + const PASSWORD = 'Sunset-42!'; const PIN = 'Tom & Ada\'s 7788'; + + beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + const { adminId } = await seedMinimal(db); + await assignAdminRole(db, adminId, 'super_admin'); + token = mintAdminToken(adminId); + await db('feature_flags').insert({ key: 'messaging', value: true }).onConflict('key').merge({ value: true }); + invalidateFeatureFlagCache(); + const ins = await db('email_queue').insert({ + recipient_email: 'client@example.com', email_type: 'gallery_created', status: 'sent', + created_at: new Date().toISOString(), sent_at: new Date().toISOString(), retry_count: 0, + email_data: JSON.stringify({ customer_name: 'Ada', gallery_password: PASSWORD, client_password: PIN, cc: ['second@example.com'] }), + rendered_html: ``, + }).returning('id'); + rowId = ins[0]?.id ?? ins[0]; + app = buildRouteApp('/api/admin/email', require('../../src/routes/adminEmail')); + }, 120000); + afterAll(async () => { if (cleanup) await cleanup(); }); + + it('masks the password and the PIN in the rendered body, keeps the rest', async () => { + const res = await request(app).get(`/api/admin/email/queue/${rowId}`).set('Authorization', `Bearer ${token}`); + expect(res.status).toBe(200); + expect(res.body.renderedHtml).not.toContain(PASSWORD); + expect(res.body.renderedHtml).not.toContain('Ada's 7788'); + expect(res.body.renderedHtml).toContain(`Password: ${MASK}`); + expect(res.body.renderedHtml).toContain(`PIN: ${MASK}`); + expect(res.body.cc).toBe('second@example.com'); + expect(JSON.stringify(res.body)).not.toContain(PASSWORD); + // the stored row is untouched by a read + const row = await db('email_queue').where('id', rowId).first(); + expect(row.rendered_html).toContain(PASSWORD); + }); +}); diff --git a/backend/__tests__/services/emailProcessor.secretRedaction.test.js b/backend/__tests__/services/emailProcessor.secretRedaction.test.js new file mode 100644 index 00000000..50717e30 --- /dev/null +++ b/backend/__tests__/services/emailProcessor.secretRedaction.test.js @@ -0,0 +1,121 @@ +/** + * Passwords leave the email archive once a row is final (see + * utils/emailSecretRedaction.js). The gallery-created email carries the + * gallery password and the client PIN; after the mail is out — or after the + * row is out of retries — neither survives in email_data or rendered_html. + */ +const path = require('path'); +const fs = require('fs'); +const os = require('os'); + +process.env.NODE_ENV = 'test'; +process.env.TEST_DATABASE_PATH = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-mailredact-')), 'db.sqlite'); +process.env.JWT_SECRET = process.env.JWT_SECRET || 'mailredact-test-secret'; +process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-mailredact-storage-')); + +const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb'); +const { MASK } = require('../../src/utils/emailSecretRedaction'); + +function stubWebhookTransport(impl) { + const transport = require('../../src/services/emailWebhookTransport'); + const savedFrom = process.env.EMAIL_FROM; + process.env.EMAIL_FROM = 'noreply@example.com'; + const mails = []; + const enabled = jest.spyOn(transport, 'isEnabled').mockReturnValue(true); + // mockRestore() wipes mock.calls, so keep our own copy of what went out + const send = jest.spyOn(transport, 'send').mockImplementation(async (mail) => { mails.push(mail); return impl(mail); }); + return { mails, restore() { enabled.mockRestore(); send.mockRestore(); if (savedFrom === undefined) delete process.env.EMAIL_FROM; else process.env.EMAIL_FROM = savedFrom; } }; +} + +describe('email archive redaction', () => { + let db; let cleanup; let eventId; + const PASSWORD = 'Sunset-42!'; const PIN = '7788'; + const queue = (extra = {}) => db('email_queue').insert({ + event_id: eventId, recipient_email: 'client@example.com', email_type: 'gallery_created', + email_data: JSON.stringify({ + customer_name: 'Ada', host_name: 'Ada', event_name: 'Redaction Wedding', event_date: '2026-09-07', + gallery_link: 'https://photos.example/gallery/redaction-wedding/tok', gallery_password: PASSWORD, + client_link: 'https://photos.example/gallery/redaction-wedding/client-access?token=abc', client_password: PIN, + expiry_date: null, welcome_message: '', + }), + status: 'pending', created_at: new Date().toISOString(), scheduled_at: new Date().toISOString(), retry_count: 0, + ...extra, + }).returning('id').then((r) => r[0]?.id ?? r[0]); + + beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + await seedMinimal(db); + const ins = await db('events').insert({ + slug: 'redaction-wedding', event_type: 'wedding', event_name: 'Redaction Wedding', event_date: '2026-09-07', + customer_email: 'client@example.com', customer_name: 'Ada', password_hash: 'x', share_link: '/gallery/redaction-wedding/tok', + share_token: 'tok', expires_at: new Date(Date.now() + 86400000).toISOString(), is_active: true, created_at: new Date().toISOString(), + }).returning('id'); + eventId = ins[0]?.id ?? ins[0]; + }, 120000); + afterAll(async () => { if (cleanup) await cleanup(); }); + + it('scrubs the variables and the rendered body once the mail is out', async () => { + const id = await queue(); + const stub = stubWebhookTransport(async () => ({ messageId: 'sent-1' })); + try { + const { processEmailQueue } = require('../../src/services/emailProcessor'); + await processEmailQueue({ ignoreSchedule: true, onlyId: id }); + } finally { stub.restore(); } + const row = await db('email_queue').where('id', id).first(); + expect(row.status).toBe('sent'); + const data = JSON.parse(row.email_data); + expect(data.gallery_password).toBe(MASK); + expect(data.client_password).toBe(MASK); + expect(data.customer_name).toBe('Ada'); + expect(row.rendered_html).toBeTruthy(); + expect(row.rendered_html).not.toContain(PASSWORD); + expect(row.rendered_html).not.toContain(PIN); + expect(row.rendered_html).toContain(MASK); + // the mail itself went out with the real password; only the archive lost it + expect(stub.mails).toHaveLength(1); + expect(String(stub.mails[0].html)).toContain(PASSWORD); + expect(String(stub.mails[0].html)).not.toContain(MASK); + + // Messages "resend" copies the archived variables into a new pending + // row and "retry" re-queues the row itself: both mails must say the + // password is not shown rather than print the mask (or the password). + const { resendEmail, retryEmail } = require('../../src/services/projectService'); + const resent = await resendEmail(id); + await retryEmail(id); + for (const rowId of [resent.id, id]) { + const stub2 = stubWebhookTransport(async () => ({ messageId: `sent-again-${rowId}` })); + try { + const { processEmailQueue } = require('../../src/services/emailProcessor'); + await processEmailQueue({ ignoreSchedule: true, onlyId: rowId }); + } finally { stub2.restore(); } + expect(stub2.mails).toHaveLength(1); + const html = String(stub2.mails[0].html); + expect(html).not.toContain(MASK); + expect(html).not.toContain(PASSWORD); + expect(html).not.toContain(PIN); + expect(html).not.toContain('{{password_security_message}}'); + expect(html).toContain('security'); + // archived again without the password (mask or sentinel, never the value) + expect(JSON.parse((await db('email_queue').where('id', rowId).first()).email_data).gallery_password).not.toBe(PASSWORD); + } + }); + + it('keeps the variables in the clear while the row can still be retried', async () => { + const id = await queue({ retry_count: 1 }); + const stub = stubWebhookTransport(async () => { throw new Error('transport down'); }); + try { + const { processEmailQueue } = require('../../src/services/emailProcessor'); + await processEmailQueue({ ignoreSchedule: true, onlyId: id }); + let row = await db('email_queue').where('id', id).first(); + expect(row.retry_count).toBe(2); + expect(JSON.parse(row.email_data).gallery_password).toBe(PASSWORD); + // out of retries — a Messages "retry" resets the counter and this row + // must still be able to send the real password + await processEmailQueue({ ignoreSchedule: true, onlyId: id }); + row = await db('email_queue').where('id', id).first(); + expect(row.retry_count).toBe(3); + expect(row.status).not.toBe('sent'); + expect(JSON.parse(row.email_data).gallery_password).toBe(PASSWORD); + } finally { stub.restore(); } + }); +}); diff --git a/backend/__tests__/utils/emailSecretRedaction.test.js b/backend/__tests__/utils/emailSecretRedaction.test.js new file mode 100644 index 00000000..a4d3e3ca --- /dev/null +++ b/backend/__tests__/utils/emailSecretRedaction.test.js @@ -0,0 +1,90 @@ +/** + * Passwords must not survive in the email archive (see the module header of + * utils/emailSecretRedaction.js). + */ +const { secretValues, redactEmailData, redactRenderedHtml, replaceMaskedSecrets, parseEmailData, MASK } = require('../../src/utils/emailSecretRedaction'); + +describe('email secret redaction', () => { + const data = { + customer_name: 'Ada', gallery_link: 'https://p.example/gallery/x/tok', + gallery_password: 'Sunset-42!', client_password: '1234', welcome_message: 'hi', + attachments: [{ filename: 'a.pdf', password: 'zip-secret' }], + }; + + it('finds the secrets by key name, nested included, and skips sentinels', () => { + expect(secretValues(data).sort()).toEqual(['1234', 'Sunset-42!', 'zip-secret']); + expect(secretValues({ gallery_password: '{{password_security_message}}' })).toEqual([]); + expect(secretValues({ gallery_password: 'No password required' })).toEqual([]); + expect(secretValues({ gallery_password: '(set at creation)' })).toEqual([]); + expect(secretValues({ gallery_password: '' })).toEqual([]); + }); + + it('masks the secrets in the variables and leaves everything else alone', () => { + const redacted = redactEmailData(data); + expect(redacted.gallery_password).toBe(MASK); + expect(redacted.client_password).toBe(MASK); + expect(redacted.attachments[0].password).toBe(MASK); + expect(redacted.attachments[0].filename).toBe('a.pdf'); + expect(redacted.customer_name).toBe('Ada'); + expect(redacted.gallery_link).toBe(data.gallery_link); + // sentinels stay readable + expect(redactEmailData({ gallery_password: 'No password required' }).gallery_password).toBe('No password required'); + // the input is not mutated + expect(data.gallery_password).toBe('Sunset-42!'); + }); + + it('strips the secrets from the rendered HTML, plain and HTML-escaped', () => { + const html = '
  • Password: Sunset-42!
  • PIN: 1234
  • Tom & Ada's day

    '; + const out = redactRenderedHtml(html, secretValues({ ...data, client_password: 'Tom & Ada\'s day' })); + expect(out).toContain(`Password: ${MASK}`); + expect(out).not.toContain('Sunset-42!'); + expect(out).toContain(`

    ${MASK}

    `); + expect(redactRenderedHtml(html, [])).toBe(html); + expect(redactRenderedHtml(null, ['x'])).toBeNull(); + }); + + it('parses stored email_data leniently', () => { + expect(parseEmailData('{"a":1}')).toEqual({ a: 1 }); + expect(parseEmailData({ a: 1 })).toEqual({ a: 1 }); + expect(parseEmailData('not json')).toEqual({}); + expect(parseEmailData(null)).toEqual({}); + }); + + it('ignores the pipeline sentinels but not a brace-wrapped real password', () => { + expect(secretValues({ gallery_password: '(set at creation)', client_password: 'No password required' })).toEqual([]); + expect(secretValues({ gallery_password: '{{Sunset-42!}}' })).toEqual(['{{Sunset-42!}}']); + }); + + it('replaceMaskedSecrets swaps archive masks for the security sentinel, leaves the rest', () => { + const out = replaceMaskedSecrets({ customer_name: 'Ada', gallery_password: MASK, client_password: MASK, nested: { pin: MASK, note: MASK } }); + expect(out).toEqual({ + customer_name: 'Ada', + gallery_password: '{{password_security_message}}', + client_password: '{{password_security_message}}', + nested: { pin: '{{password_security_message}}', note: MASK }, + }); + }); + + it('masks a raw secret that the template turned into markup', () => { + const html = '

    PIN: SePin42! and Se<cr3t>Pin42!

    '; + expect(redactRenderedHtml(html, ['SePin42!'])).toBe(`

    PIN: ${MASK} and ${MASK}

    `); + }); + + it('masks a secret in a quoted attribute value that contains ">"', () => { + const html = 'Sunset42'; + expect(redactRenderedHtml(html, ['Sunset42'])).toBe(`${MASK}`); + // unbalanced quote: the broken tag is treated as text, still scrubbed + expect(redactRenderedHtml('${MASK}`); + }); + + it('masks a secret inside an HTML comment, ">" included', () => { + const html = '

    x

    '; + expect(redactRenderedHtml(html, ['7788'])).toBe(`

    x

    `); + }); + + it('masks overlapping secrets completely and leaves markup alone', () => { + const html = '

    Password: Sunset-42! PIN: Sunset-42!7788

    href'; + const out = redactRenderedHtml(html, ['Sunset-42!', 'Sunset-42!7788', 'href', 'style', '7788']); + expect(out).toBe(`

    Password: ${MASK} PIN: ${MASK}

    ${MASK}`); + }); +}); diff --git a/backend/src/routes/adminEmail.js b/backend/src/routes/adminEmail.js index 1a30b528..440335bb 100644 --- a/backend/src/routes/adminEmail.js +++ b/backend/src/routes/adminEmail.js @@ -14,6 +14,7 @@ const emailWebhookTransport = require('../services/emailWebhookTransport'); const businessProfileService = require('../services/businessProfileService'); const { errorResponse, safeValidationErrors } = require('../utils/routeHelpers'); const logger = require('../utils/logger'); +const { parseEmailData, secretValues, redactRenderedHtml } = require('../utils/emailSecretRedaction'); const router = express.Router(); // Get email configuration @@ -792,8 +793,12 @@ router.get('/queue/:id', adminAuth, messagingGate, requirePermission('email.view let cc = null; let attachments = []; + // Rows sent before the processor learned to scrub still carry the + // gallery password / client PIN in their variables and body. Redact on + // read from the same rule, so the pane never serves a password. + const data = parseEmailData(row.email_data); + const renderedHtml = redactRenderedHtml(row.rendered_html || null, secretValues(data)); try { - const data = row.email_data ? JSON.parse(row.email_data) : {}; if (data.cc) cc = Array.isArray(data.cc) ? data.cc.join(', ') : String(data.cc); if (Array.isArray(data.attachments)) { attachments = data.attachments @@ -815,7 +820,7 @@ router.get('/queue/:id', adminAuth, messagingGate, requirePermission('email.view eventId: row.event_id, eventName: row.event_name || null, eventSlug: row.event_slug || null, - renderedHtml: row.rendered_html || null, + renderedHtml, cc, attachments, }); diff --git a/backend/src/services/emailProcessor.js b/backend/src/services/emailProcessor.js index 59459f7b..513467c3 100644 --- a/backend/src/services/emailProcessor.js +++ b/backend/src/services/emailProcessor.js @@ -1,3 +1,4 @@ +const { secretValues, redactEmailData, redactRenderedHtml, replaceMaskedSecrets, isSecretKey } = require('../utils/emailSecretRedaction'); const nodemailer = require('nodemailer'); const { db } = require('../database/db'); const logger = require('../utils/logger'); @@ -755,8 +756,13 @@ async function processTemplate(template, variables, language = 'en') { es: 'La contraseña que estableciste al crear la galería', }; - if (processedVariables.gallery_password === '{{password_security_message}}') { - processedVariables.gallery_password = passwordSecurityI18n[language] || passwordSecurityI18n.en; + // Every secret variable, not only gallery_password: a resent copy of an + // archived mail carries the sentinel in client_password or new_password + // too (see emailSecretRedaction.replaceMaskedSecrets). + for (const [key, value] of Object.entries(processedVariables)) { + if (value === '{{password_security_message}}' && isSecretKey(key)) { + processedVariables[key] = passwordSecurityI18n[language] || passwordSecurityI18n.en; + } } if (processedVariables.gallery_password === 'No password required') { @@ -1219,10 +1225,17 @@ async function processEmailQueue({ ignoreSchedule = false, limit = 10, onlyId = result.processed = pendingEmails.length; for (const email of pendingEmails) { + // Declared outside the try: the failure branch redacts the variables + // once the row is out of retries, so it needs them too. + let emailData = {}; try { - const emailData = typeof email.email_data === 'string' + emailData = typeof email.email_data === 'string' ? JSON.parse(email.email_data || '{}') : email.email_data || {}; + // A re-queued row (Messages resend / retry / send now) may carry the + // archive mask where its passwords used to be; the sentinel makes + // the template say "not shown" instead of mailing the mask. + emailData = replaceMaskedSecrets(emailData); // Language is resolved from emailData.eventId (event.language is the top // priority). queueEmail injects it, but direct email_queue inserts (e.g. @@ -1270,9 +1283,15 @@ async function processEmailQueue({ ignoreSchedule = false, limit = 10, onlyId = // Overview email preview (guarded — older installs without migration // 119 just skip it). const sentUpdate = { status: 'sent', sent_at: new Date().toISOString() }; + // The mail is out: this is the last moment the variables were needed + // in the clear. Gallery passwords and client PINs are bcrypt-hashed + // everywhere else; without this the archive kept them readable for + // the life of the event, and the Messages pane served them back. + const secrets = secretValues(emailData); + sentUpdate.email_data = JSON.stringify(redactEmailData(emailData)); try { if (sendResult && sendResult.html && await hasColumnCached('email_queue', 'rendered_html')) { - sentUpdate.rendered_html = sendResult.html; + sentUpdate.rendered_html = redactRenderedHtml(sendResult.html, secrets); } } catch (_) { /* best-effort — never block the send on the preview */ } await db('email_queue') @@ -1295,7 +1314,10 @@ async function processEmailQueue({ ignoreSchedule = false, limit = 10, onlyId = logger.info(`Email ${email.id} sent successfully`); } catch (error) { result.failed += 1; - // Increment retry count + // Increment retry count. The variables stay in the clear on + // failure: a row past the cap can still be re-queued (Messages + // "retry" resets retry_count, ignoreSchedule skips the cap) and a + // masked password would then be mailed out as the real one. try { await db('email_queue') .where('id', email.id) diff --git a/backend/src/utils/emailSecretRedaction.js b/backend/src/utils/emailSecretRedaction.js new file mode 100644 index 00000000..c6dfbcc5 --- /dev/null +++ b/backend/src/utils/emailSecretRedaction.js @@ -0,0 +1,132 @@ +/** + * Keep passwords out of the email archive. + * + * email_queue rows carry the template variables (`email_data`) and, since + * migration 119, the exact HTML that went out (`rendered_html`). The + * gallery-created email includes the gallery password and the client PIN, + * so both columns held those in clear text for the life of the event, and + * the Messages reading pane returned them to any admin with email.view. + * Gallery passwords are bcrypt-hashed everywhere else; this was the one + * place they survived in plain text. + * + * The variables have to stay intact until the mail is out — the processor + * renders from them, and a retry needs them again — so the scrub runs when + * a row reaches a final state (sent, or out of retries). Rows written before + * that are redacted on read from the same rule. + */ + +const SECRET_KEY_RE = /password|passcode|\bpin\b|_pin$/i; +const MASK = '••••••'; +// Template sentinels the email pipeline uses in place of a real password. +// They are not secrets and masking them would hide what the email said. +const SENTINELS = new Set(['{{password_security_message}}', 'No password required', '(set at creation)', MASK]); + +function isSecretKey(key) { + return SECRET_KEY_RE.test(String(key)); +} + +function isRealSecret(value) { + return typeof value === 'string' && value.length > 0 && !SENTINELS.has(value); +} + +/** The secret strings inside a template-variable object, deduplicated. */ +function secretValues(emailData) { + const out = new Set(); + const walk = (obj) => { + if (!obj || typeof obj !== 'object') return; + for (const [key, value] of Object.entries(obj)) { + if (value && typeof value === 'object') walk(value); + else if (isSecretKey(key) && isRealSecret(value)) out.add(value); + } + }; + walk(emailData); + return [...out]; +} + +/** A copy of the template variables with every secret replaced by the mask. */ +function redactEmailData(emailData) { + if (!emailData || typeof emailData !== 'object') return emailData; + const copy = Array.isArray(emailData) ? [] : {}; + for (const [key, value] of Object.entries(emailData)) { + if (value && typeof value === 'object') copy[key] = redactEmailData(value); + else copy[key] = isSecretKey(key) && isRealSecret(value) ? MASK : value; + } + return copy; +} + +function escapeRegExp(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function escapeHtml(s) { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); +} + +/** + * Replace every occurrence of the given secrets in a rendered body — as + * typed, and as the template engine would have HTML-escaped it. + */ +function redactRenderedHtml(html, secrets) { + if (!html || !secrets || !secrets.length) return html; + // One pass per group, longest form first: when one secret contains + // another ('Sunset-42!' inside 'Sunset-42!7788'), replacing the short one + // first would leave the tail of the long one readable. + const forms = [...new Set(secrets.flatMap((secret) => [secret, escapeHtml(secret)]))] + .filter((form) => form.length > 0) + .sort((a, b) => b.length - a.length); + const alternation = (list) => new RegExp(list.map(escapeRegExp).join('|'), 'g'); + // A word-like secret ('href', 'style', '7788') could also be a tag or + // attribute name, so it is only replaced in text and quoted attribute + // values. Anything else cannot be markup and is replaced wherever it + // occurs — including a raw 'SePin' that the splitter would cut. + const wordLike = forms.filter((form) => /^[\w-]+$/.test(form)); + const other = forms.filter((form) => !/^[\w-]+$/.test(form)); + let out = String(html); + if (other.length) out = out.replace(alternation(other), MASK); + if (!wordLike.length) return out; + const scrub = (text) => text.replace(alternation(wordLike), MASK); + // A '>' inside a quoted attribute value (title="{{gallery_password}} > more") + // must not end the tag, or the value is cut off and never scrubbed. A tag + // with an unbalanced quote does not match and is scrubbed as text instead. + // A comment () is one segment and its body + // is scrubbed whole: it holds no tag or attribute names. + return out.split(/(|<(?:[^>"']|"[^"]*"|'[^']*')*>)/).map((segment, index) => { + if (index % 2 === 0) return scrub(segment); + if (segment.startsWith('`; + // attribute values, quoted or not; never the tag or attribute names + return segment.replace(/(=\s*)("[^"]*"|'[^']*'|[^\s"'>]+)/g, (_, eq, value) => eq + scrub(value)); + }).join(''); +} + +/** Parse a stored email_data column leniently (string or already-parsed). */ +function parseEmailData(raw) { + if (!raw) return {}; + if (typeof raw !== 'string') return raw; + try { return JSON.parse(raw); } catch (_) { return {}; } +} + +/** + * Undo the archive mask for a row that is about to be SENT again (Messages + * "resend" copies a sent row's variables into a new pending row, "retry" + * and "send now" re-queue the row itself). The real value is gone; the + * pipeline's security sentinel makes the template say so instead of + * mailing six dots as the password. Applied by processEmailQueue, so every + * requeue path is covered. + */ +function replaceMaskedSecrets(emailData, sentinel = '{{password_security_message}}') { + const walk = (obj) => { + if (!obj || typeof obj !== 'object') return obj; + if (Array.isArray(obj)) return obj.map(walk); + const out = {}; + for (const [key, value] of Object.entries(obj)) { + if (value && typeof value === 'object') out[key] = walk(value); + else if (isSecretKey(key) && value === MASK) out[key] = sentinel; + else out[key] = value; + } + return out; + }; + return walk(emailData); +} + +module.exports = { + replaceMaskedSecrets, MASK, isSecretKey, secretValues, redactEmailData, redactRenderedHtml, parseEmailData };