fix(email): render conditionals, localise password placeholders, fix caller/template variable drift
Bundle of email-renderer and email-caller fixes triggered by a
reproducer on picpeak.nothaft.cloud (gallery_created mail showing
literal `{{#if welcome_message}}` markers and `Passwort: (set at
creation)`). The audit that followed surfaced six more user-visible
defects in the same surface; all are fixed here so customer-facing
mail renders cleanly.
Renderer (`backend/src/services/emailProcessor.js`)
- `safeTemplateReplace` now resolves `{{#if VAR}}…{{/if}}` blocks
before flat `{{var}}` substitution. The shipped templates have used
Handlebars-style conditionals since migration 026; the renderer
ignored them, so the markers leaked verbatim into every mail with
an empty welcome_message. Lifted to module scope and exported so
the conditional contract is unit-testable. Single-pass, non-nested
(commented).
- Added `passwordSetAtCreationI18n` next to the existing two i18n
password sentinels so `(set at creation)` (sent by the publish-
from-draft flow when only the bcrypt hash remains) is localised
to "Das bei der Erstellung der Galerie gesetzte Passwort" /
equivalent in EN/DE/NL/PT/RU instead of the raw English string.
- Added an opt-in `{ escapeHtml: true }` mode to `safeTemplateReplace`
so admin-supplied free text (`event_name`, `host_name`, …) is
HTML-escaped on substitution into the HTML body. Allowlist of
passthrough keys (`welcome_message` already-HTML, server-generated
URLs `gallery_link` / `client_link`). Subject and text body keep
the legacy unescaped behaviour. `formatWelcomeMessage` now escapes
before nl2br so the welcome_message allowlist is safe.
- New `htmlToText()` strips `<style>` and `<script>` blocks (and
their content) before tag-stripping, decodes common entities, and
collapses whitespace. Used by the textBody fallback in
`sendTemplateEmail` — without this, every template missing a
`body_text` produced a "plain-text" mail starting with the 100+
lines of CSS embedded by `wrapEmailHtml()`.
- The client-access section (#172) now mirrors its HTML block into
`textBody` using the same per-language strings, so plain-text
recipients see the link / PIN / warning. `pinLabel = 'PIN'` moved
into `clientAccessI18n` (RU uses ПИН-код).
- Added `getSupportEmail()` exported helper that reads
`branding_support_email` from `app_settings` (JSON-decoded), with
the SMTP from-address as fallback. Used by the gallery_expired and
archive_complete callers below.
- Removed dead `require('handlebars')` (unused since the regex
renderer landed; pre-existing lint error in this file).
Callers (data the templates already reference)
- `expirationChecker.js queueExpirationWarning`: send `expiry_date`
(templates use this, the old code sent `expiration_date` —
typo'd key, never read), drop the hard-coded `.de`/`en` sniff
(the processor formats with the recipient's resolved language),
add the `{{password_security_message}}` sentinel for
`gallery_password` (plaintext is gone by warning time, so
customers used to see literal `{{gallery_password}}` in the mail).
- `expirationChecker.js handleExpiredEvent`: both queueEmail calls
now supply `host_name`, `event_date`, `expiry_date`,
`support_email` so the EN/DE/NL/PT/RU `gallery_expired` template
doesn't render literal `{{host_name}}, your gallery expired on
{{expiry_date}}`. Skip the duplicate admin send when
admin_email == customer_email.
- `archiveService.js`: `archive_complete` queue now supplies
`host_name`, `photo_count` (from `photoEntries.length`),
`archive_date`, `support_email` — the previous payload had only
`event_name` and `archive_size`, so most of the mail was
unfilled placeholders.
Tests
- `__tests__/services/emailProcessor.safeTemplateReplace.test.js`:
16 cases — flat substitution, conditional truthy/falsy/missing/
multi-line/sibling/numeric-0, plus 5 cases for the new
`escapeHtml` option (default off, escape on, allowlist
passthrough for welcome_message and gallery_link).
- `__tests__/services/emailProcessor.htmlToText.test.js`: 7 cases —
the regression scenario (full wrapped body with embedded `<style>`
block), tag-stripping, entity decoding, paragraph spacing.
- `__tests__/utils/formatters.test.js`: 12 cases for `escapeHtml`,
`nl2br`, and the now-escaping `formatWelcomeMessage`.
35 cases total, all green. Lint clean on every touched file
(also fixes a pre-existing `no-prototype-builtins` warning in the
process). Pre-existing failures in
`__tests__/services/backupService.enhanced.test.js` are unrelated
and pre-date this branch.
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Unit tests for emailProcessor.htmlToText.
|
||||
*
|
||||
* Regression: when a template ships without a body_text, sendTemplateEmail
|
||||
* used `htmlBody.replace(/<[^>]*>/g, '')` to derive the plain-text fallback.
|
||||
* That regex strips angle-bracket tags but leaves the *contents* of <style>
|
||||
* and <script> blocks intact — so any HTML wrapped by wrapEmailHtml() (which
|
||||
* embeds a 100+ line <style> block) produced a "plain-text" email starting
|
||||
* with `body { margin: 0; padding: 0; … }`. htmlToText fixes that.
|
||||
*/
|
||||
|
||||
jest.mock('../../src/database/db', () => ({ db: jest.fn() }));
|
||||
|
||||
const { htmlToText } = require('../../src/services/emailProcessor');
|
||||
|
||||
describe('htmlToText', () => {
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(htmlToText('')).toBe('');
|
||||
expect(htmlToText(null)).toBe('');
|
||||
expect(htmlToText(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('strips <style> blocks and their contents', () => {
|
||||
const html = '<html><head><style>body { margin: 0; color: red; }</style></head><body>Hello</body></html>';
|
||||
const out = htmlToText(html);
|
||||
expect(out).toBe('Hello');
|
||||
expect(out).not.toMatch(/margin/);
|
||||
expect(out).not.toMatch(/color/);
|
||||
});
|
||||
|
||||
it('strips <script> blocks and their contents', () => {
|
||||
const html = '<body><script>alert("x")</script>Hi</body>';
|
||||
expect(htmlToText(html)).toBe('Hi');
|
||||
});
|
||||
|
||||
it('converts <br> tags to newlines', () => {
|
||||
expect(htmlToText('a<br>b<br />c<BR/>d')).toBe('a\nb\nc\nd');
|
||||
});
|
||||
|
||||
it('keeps a paragraph break between adjacent <p> tags', () => {
|
||||
expect(htmlToText('<p>one</p><p>two</p>')).toBe('one\n\ntwo');
|
||||
});
|
||||
|
||||
it('decodes the common HTML entities', () => {
|
||||
expect(htmlToText('Tom & Jerry <3 "hi"'))
|
||||
.toBe('Tom & Jerry <3 "hi"');
|
||||
});
|
||||
|
||||
it('handles a fully-wrapped email body without leaking CSS rules', () => {
|
||||
// Shape mirrors what wrapEmailHtml() produces: a <style> block with many
|
||||
// CSS rules followed by the actual content.
|
||||
const wrapped = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { margin: 0; padding: 0; font-family: sans-serif; background-color: #f5f5f5; }
|
||||
.email-container { max-width: 600px; }
|
||||
.button { background-color: #5C8762; color: white !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Galerie erfolgreich erstellt</h2>
|
||||
<p>Liebe(r) Natalie,</p>
|
||||
</body>
|
||||
</html>`;
|
||||
const out = htmlToText(wrapped);
|
||||
expect(out).toContain('Galerie erfolgreich erstellt');
|
||||
expect(out).toContain('Liebe(r) Natalie');
|
||||
expect(out).not.toMatch(/margin/);
|
||||
expect(out).not.toMatch(/font-family/);
|
||||
expect(out).not.toMatch(/background-color/);
|
||||
expect(out).not.toMatch(/\.button/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Unit tests for emailProcessor.safeTemplateReplace.
|
||||
*
|
||||
* Covers the two regressions that hit picpeak.nothaft.cloud on the
|
||||
* 3.32.x betas:
|
||||
* - {{#if VAR}}…{{/if}} blocks rendered as literal text in the email
|
||||
* because the renderer only handled {{var}} substitution and the
|
||||
* shipped templates use Handlebars-style conditionals.
|
||||
* - {{var}} substitution inside a kept conditional block.
|
||||
*
|
||||
* The publish-from-draft password localisation lives inside the wider
|
||||
* processTemplate() pipeline (DB-backed), so it isn't covered here — the
|
||||
* sentinel string '(set at creation)' is asserted only at the i18n-map
|
||||
* level by integration in adminEvents.js.
|
||||
*/
|
||||
|
||||
jest.mock('../../src/database/db', () => ({ db: jest.fn() }));
|
||||
|
||||
const { safeTemplateReplace } = require('../../src/services/emailProcessor');
|
||||
|
||||
describe('safeTemplateReplace', () => {
|
||||
describe('flat variable substitution', () => {
|
||||
it('replaces {{var}} with the variable value', () => {
|
||||
expect(safeTemplateReplace('Hello {{name}}!', { name: 'Paul' }))
|
||||
.toBe('Hello Paul!');
|
||||
});
|
||||
|
||||
it('leaves unknown variables untouched', () => {
|
||||
expect(safeTemplateReplace('Hello {{name}}!', {}))
|
||||
.toBe('Hello {{name}}!');
|
||||
});
|
||||
|
||||
it('coerces non-string values to string', () => {
|
||||
expect(safeTemplateReplace('Count: {{n}}', { n: 42 }))
|
||||
.toBe('Count: 42');
|
||||
});
|
||||
|
||||
it('handles empty templates and missing variables map', () => {
|
||||
expect(safeTemplateReplace('', { x: 1 })).toBe('');
|
||||
expect(safeTemplateReplace('plain text', undefined)).toBe('plain text');
|
||||
expect(safeTemplateReplace(null, {})).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('{{#if VAR}}…{{/if}} blocks', () => {
|
||||
it('strips the block when the variable is missing', () => {
|
||||
const tpl = 'before {{#if welcome}}HELLO {{welcome}}{{/if}} after';
|
||||
expect(safeTemplateReplace(tpl, {})).toBe('before after');
|
||||
});
|
||||
|
||||
it('strips the block when the variable is an empty string', () => {
|
||||
const tpl = 'before {{#if welcome}}HELLO {{welcome}}{{/if}} after';
|
||||
expect(safeTemplateReplace(tpl, { welcome: '' })).toBe('before after');
|
||||
});
|
||||
|
||||
it('strips the block when the variable is null', () => {
|
||||
const tpl = '{{#if x}}kept{{/if}}';
|
||||
expect(safeTemplateReplace(tpl, { x: null })).toBe('');
|
||||
});
|
||||
|
||||
it('keeps the block and substitutes inside it when truthy', () => {
|
||||
const tpl = 'before {{#if welcome}}HELLO {{welcome}}{{/if}} after';
|
||||
expect(safeTemplateReplace(tpl, { welcome: 'world' }))
|
||||
.toBe('before HELLO world after');
|
||||
});
|
||||
|
||||
it('handles multi-line conditional blocks', () => {
|
||||
const tpl = [
|
||||
'Liebe(r) {{host_name}},',
|
||||
'',
|
||||
'{{#if welcome_message}}',
|
||||
'Persönliche Nachricht:',
|
||||
'{{welcome_message}}',
|
||||
'{{/if}}',
|
||||
'Galerie-Details:',
|
||||
].join('\n');
|
||||
|
||||
const withMsg = safeTemplateReplace(tpl, {
|
||||
host_name: 'Natalie',
|
||||
welcome_message: 'Schön, dass ihr da seid!',
|
||||
});
|
||||
expect(withMsg).toContain('Persönliche Nachricht:');
|
||||
expect(withMsg).toContain('Schön, dass ihr da seid!');
|
||||
expect(withMsg).not.toContain('{{#if');
|
||||
expect(withMsg).not.toContain('{{/if');
|
||||
|
||||
const withoutMsg = safeTemplateReplace(tpl, {
|
||||
host_name: 'Natalie',
|
||||
welcome_message: '',
|
||||
});
|
||||
expect(withoutMsg).not.toContain('Persönliche Nachricht');
|
||||
expect(withoutMsg).not.toContain('{{#if');
|
||||
expect(withoutMsg).not.toContain('{{/if');
|
||||
expect(withoutMsg).toContain('Liebe(r) Natalie,');
|
||||
expect(withoutMsg).toContain('Galerie-Details:');
|
||||
});
|
||||
|
||||
it('handles multiple sibling conditionals independently', () => {
|
||||
const tpl = '{{#if a}}A{{/if}}|{{#if b}}B{{/if}}|{{#if c}}C{{/if}}';
|
||||
expect(safeTemplateReplace(tpl, { a: 1, c: 'yes' })).toBe('A||C');
|
||||
});
|
||||
|
||||
it('treats numeric 0 as falsy', () => {
|
||||
expect(safeTemplateReplace('{{#if n}}has-n{{/if}}', { n: 0 })).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTML escaping (escapeHtml: true)', () => {
|
||||
it('does not escape by default', () => {
|
||||
const tpl = 'Welcome to {{event_name}}';
|
||||
expect(safeTemplateReplace(tpl, { event_name: 'Test <script>' }))
|
||||
.toBe('Welcome to Test <script>');
|
||||
});
|
||||
|
||||
it('escapes admin-supplied values when opted in', () => {
|
||||
const tpl = 'Welcome to {{event_name}}';
|
||||
expect(safeTemplateReplace(tpl, { event_name: 'Test <script>alert(1)</script>' }, { escapeHtml: true }))
|
||||
.toBe('Welcome to Test <script>alert(1)</script>');
|
||||
});
|
||||
|
||||
it('escapes both the < > and & characters and quotes', () => {
|
||||
expect(safeTemplateReplace('{{x}}', { x: '<a href="evil">A & B\'s</a>' }, { escapeHtml: true }))
|
||||
.toBe('<a href="evil">A & B's</a>');
|
||||
});
|
||||
|
||||
it('passes welcome_message through unescaped (already HTML from formatWelcomeMessage)', () => {
|
||||
const tpl = '<p>{{welcome_message}}</p>';
|
||||
expect(safeTemplateReplace(tpl, { welcome_message: 'Hi<br />there' }, { escapeHtml: true }))
|
||||
.toBe('<p>Hi<br />there</p>');
|
||||
});
|
||||
|
||||
it('passes server-generated URLs through unescaped', () => {
|
||||
const tpl = '<a href="{{gallery_link}}">link</a>';
|
||||
expect(safeTemplateReplace(tpl, { gallery_link: 'https://example.com/g/abc?token=xyz&u=1' }, { escapeHtml: true }))
|
||||
.toBe('<a href="https://example.com/g/abc?token=xyz&u=1">link</a>');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user