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>');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Unit tests for formatters.js — focused on the HTML-escape behaviour added
|
||||
* so admin-supplied welcome messages can't inject markup into customer mail.
|
||||
*/
|
||||
|
||||
const { escapeHtml, nl2br, formatWelcomeMessage } = require('../../src/utils/formatters');
|
||||
|
||||
describe('escapeHtml', () => {
|
||||
it('escapes the five HTML metacharacters', () => {
|
||||
expect(escapeHtml('& < > " \'')).toBe('& < > " '');
|
||||
});
|
||||
|
||||
it('returns empty string for null/undefined', () => {
|
||||
expect(escapeHtml(null)).toBe('');
|
||||
expect(escapeHtml(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('coerces non-string values', () => {
|
||||
expect(escapeHtml(42)).toBe('42');
|
||||
});
|
||||
|
||||
it('escapes & before introducing new entities', () => {
|
||||
expect(escapeHtml('<&>')).toBe('<&>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('nl2br', () => {
|
||||
it('joins non-empty lines with <br />', () => {
|
||||
expect(nl2br('a\nb\nc')).toBe('a<br />b<br />c');
|
||||
});
|
||||
|
||||
it('normalises CRLF and CR', () => {
|
||||
expect(nl2br('a\r\nb\rc')).toBe('a<br />b<br />c');
|
||||
});
|
||||
|
||||
it('drops empty lines', () => {
|
||||
expect(nl2br('a\n\n\nb')).toBe('a<br />b');
|
||||
});
|
||||
|
||||
it('returns empty for empty input', () => {
|
||||
expect(nl2br('')).toBe('');
|
||||
expect(nl2br(null)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatWelcomeMessage', () => {
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(formatWelcomeMessage('')).toBe('');
|
||||
expect(formatWelcomeMessage(' ')).toBe('');
|
||||
});
|
||||
|
||||
it('escapes HTML metacharacters before nl2br', () => {
|
||||
expect(formatWelcomeMessage('Hello <b>world</b>'))
|
||||
.toBe('Hello <b>world</b>');
|
||||
});
|
||||
|
||||
it('renders newlines as <br /> while keeping content escaped', () => {
|
||||
expect(formatWelcomeMessage('line 1\n<script>x</script>\nline 3'))
|
||||
.toBe('line 1<br /><script>x</script><br />line 3');
|
||||
});
|
||||
|
||||
it('escapes ampersands and quotes that would otherwise break HTML', () => {
|
||||
expect(formatWelcomeMessage('Tom & Jerry\'s "show"'))
|
||||
.toBe('Tom & Jerry's "show"');
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ const path = require('path');
|
||||
const os = require('os');
|
||||
const crypto = require('crypto');
|
||||
const { db } = require('../database/db');
|
||||
const { queueEmail } = require('./emailProcessor');
|
||||
const { queueEmail, getSupportEmail } = require('./emailProcessor');
|
||||
const logger = require('../utils/logger');
|
||||
const feedbackService = require('./feedbackService');
|
||||
const { getStorage } = require('./storage');
|
||||
@@ -146,10 +146,20 @@ async function archiveEvent(event) {
|
||||
|
||||
// Queue completion email — admin_email is nullable on events (migration 073);
|
||||
// skip queueing rather than violating email_queue.recipient_email NOT NULL.
|
||||
//
|
||||
// The shipped EN/DE templates (legacy 028) and NL/PT/RU (core 075) reference
|
||||
// {{host_name}}, {{photo_count}}, {{archive_date}} and {{support_email}};
|
||||
// without these the recipient saw literal {{...}} placeholders.
|
||||
if (event.admin_email) {
|
||||
const supportEmail = await getSupportEmail();
|
||||
await queueEmail(event.id, event.admin_email, 'archive_complete', {
|
||||
host_name: event.customer_name || event.host_name || 'Admin',
|
||||
event_name: event.event_name,
|
||||
event_date: event.event_date,
|
||||
photo_count: photoEntries.length,
|
||||
archive_size: (totalBytes / 1024 / 1024).toFixed(2) + ' MB',
|
||||
archive_date: new Date(),
|
||||
support_email: supportEmail
|
||||
});
|
||||
} else {
|
||||
logger.info(`Skipping archive_complete email for event ${event.slug}: no admin_email set`);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
const nodemailer = require('nodemailer');
|
||||
const Handlebars = require('handlebars');
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
|
||||
@@ -64,6 +63,39 @@ async function initializeTransporter(forceReinit = false) {
|
||||
}
|
||||
}
|
||||
|
||||
// Read the support contact email used in customer-facing notifications
|
||||
// (gallery_expired, archive_complete, …). Looks up `branding_support_email`
|
||||
// from app_settings (JSON-encoded), falling back to the SMTP from-address
|
||||
// so templates that reference {{support_email}} never render the literal
|
||||
// placeholder. Returns '' when neither is configured — templates should
|
||||
// degrade by hiding the line via a {{#if support_email}} block.
|
||||
async function getSupportEmail() {
|
||||
try {
|
||||
const row = await db('app_settings')
|
||||
.where('setting_key', 'branding_support_email')
|
||||
.first();
|
||||
if (row && row.setting_value) {
|
||||
try {
|
||||
const parsed = JSON.parse(row.setting_value);
|
||||
if (typeof parsed === 'string' && parsed.trim()) return parsed.trim();
|
||||
} catch (e) {
|
||||
if (typeof row.setting_value === 'string' && row.setting_value.trim()) {
|
||||
return row.setting_value.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug('getSupportEmail: app_settings lookup failed', { error: err.message });
|
||||
}
|
||||
try {
|
||||
const config = await db('email_configs').first();
|
||||
if (config && config.from_email) return config.from_email;
|
||||
} catch (err) {
|
||||
logger.debug('getSupportEmail: email_configs lookup failed', { error: err.message });
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// Get the appropriate language for a recipient
|
||||
async function getRecipientLanguage(email, eventId = null) {
|
||||
// First priority: Check event language setting if eventId is provided
|
||||
@@ -302,6 +334,83 @@ async function wrapEmailHtml(htmlBody, subject, language = 'en') {
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// Convert an HTML body to a plain-text fallback. The naive
|
||||
// `html.replace(/<[^>]*>/g, '')` strips angle-bracket tags but leaves the
|
||||
// *contents* of <style> and <script> 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; … }`.
|
||||
// Strip those blocks first, then drop the rest of the markup, then collapse
|
||||
// runs of whitespace so the result is presentable in a plain-text reader.
|
||||
function htmlToText(html) {
|
||||
if (typeof html !== 'string' || html.length === 0) return '';
|
||||
return html
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<\/p>\s*<p[^>]*>/gi, '\n\n')
|
||||
.replace(/<[^>]*>/g, '')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, '\'')
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Keys whose values are already HTML or are server-generated URLs and so
|
||||
// must NOT be HTML-escaped on substitution into the HTML body. Everything
|
||||
// else (event_name, host_name, customer_name, …) is admin-supplied free
|
||||
// text and gets escaped to prevent stored-HTML injection in customer mail.
|
||||
const HTML_PASSTHROUGH_KEYS = new Set([
|
||||
'welcome_message', // already HTML (formatWelcomeMessage escapes + nl2br)
|
||||
'gallery_link', // server-generated URL (adminEvents.js)
|
||||
'client_link', // server-generated URL (adminEvents.js)
|
||||
]);
|
||||
|
||||
const { escapeHtml } = require('../utils/formatters');
|
||||
|
||||
// Render a template string against a flat variables map.
|
||||
// Supports two constructs only — no code execution:
|
||||
// - {{var}} → variables[var] if defined, else left as-is
|
||||
// - {{#if var}}…{{/if}} → inner content if variables[var] is truthy,
|
||||
// else dropped entirely
|
||||
//
|
||||
// Conditionals are resolved before variable substitution so {{var}} inside
|
||||
// a kept block still gets filled in. Nested {{#if}} blocks are not
|
||||
// supported — the non-greedy match closes on the first {{/if}} and the
|
||||
// outer block would be left malformed; switch to a real template engine
|
||||
// if nesting is ever needed.
|
||||
//
|
||||
// Pass `{ escapeHtml: true }` for the HTML body so admin-supplied text is
|
||||
// HTML-escaped on substitution; subject/textBody bodies should leave it off.
|
||||
function safeTemplateReplace(template, variables, options = {}) {
|
||||
if (typeof template !== 'string' || template.length === 0) {
|
||||
return template;
|
||||
}
|
||||
const escapeOnSubstitute = options.escapeHtml === true;
|
||||
const conditionalsResolved = template.replace(
|
||||
/\{\{#if\s+(\w+)\s*\}\}([\s\S]*?)\{\{\/if\}\}/g,
|
||||
(_match, key, inner) => {
|
||||
const v = variables ? variables[key] : undefined;
|
||||
const truthy = v !== undefined && v !== null && v !== '' && v !== false && v !== 0;
|
||||
return truthy ? inner : '';
|
||||
}
|
||||
);
|
||||
return conditionalsResolved.replace(/\{\{(\w+)\}\}/g, (match, key) => {
|
||||
if (!variables || !Object.prototype.hasOwnProperty.call(variables, key)) {
|
||||
return match;
|
||||
}
|
||||
const raw = String(variables[key]);
|
||||
if (escapeOnSubstitute && !HTML_PASSTHROUGH_KEYS.has(key)) {
|
||||
return escapeHtml(raw);
|
||||
}
|
||||
return raw;
|
||||
});
|
||||
}
|
||||
|
||||
// Process email template with variables
|
||||
async function processTemplate(template, variables, language = 'en') {
|
||||
// Import date formatter and text formatters
|
||||
@@ -368,6 +477,17 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
pt: 'Nenhuma senha necessária',
|
||||
ru: 'Пароль не требуется',
|
||||
};
|
||||
// Sent by the publish-from-draft flow (adminEvents.js): by the time the
|
||||
// event is published, only the bcrypt hash is stored, so the plaintext
|
||||
// password can't be re-included in the email. The route emits the literal
|
||||
// sentinel '(set at creation)' which we localise here.
|
||||
const passwordSetAtCreationI18n = {
|
||||
en: 'The password you set when creating the gallery',
|
||||
de: 'Das bei der Erstellung der Galerie gesetzte Passwort',
|
||||
nl: 'Het wachtwoord dat u bij het aanmaken van de galerij hebt ingesteld',
|
||||
pt: 'A senha definida ao criar a galeria',
|
||||
ru: 'Пароль, заданный при создании галереи',
|
||||
};
|
||||
|
||||
if (processedVariables.gallery_password === '{{password_security_message}}') {
|
||||
processedVariables.gallery_password = passwordSecurityI18n[language] || passwordSecurityI18n.en;
|
||||
@@ -377,6 +497,10 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
processedVariables.gallery_password = noPasswordI18n[language] || noPasswordI18n.en;
|
||||
}
|
||||
|
||||
if (processedVariables.gallery_password === '(set at creation)') {
|
||||
processedVariables.gallery_password = passwordSetAtCreationI18n[language] || passwordSetAtCreationI18n.en;
|
||||
}
|
||||
|
||||
// Format dates if they exist
|
||||
if (processedVariables.event_date) {
|
||||
processedVariables.event_date = await formatDate(processedVariables.event_date, language);
|
||||
@@ -396,15 +520,8 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
processedVariables.welcome_message = formatWelcomeMessage(processedVariables.welcome_message);
|
||||
}
|
||||
|
||||
// Safe template replacement (no code execution, only simple variable substitution)
|
||||
function safeTemplateReplace(template, variables) {
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (match, key) =>
|
||||
variables.hasOwnProperty(key) ? String(variables[key]) : match
|
||||
);
|
||||
}
|
||||
|
||||
subject = safeTemplateReplace(subject, processedVariables);
|
||||
htmlBody = safeTemplateReplace(htmlBody, processedVariables);
|
||||
htmlBody = safeTemplateReplace(htmlBody, processedVariables, { escapeHtml: true });
|
||||
textBody = safeTemplateReplace(textBody, processedVariables);
|
||||
|
||||
// Inject client access section if client_link is provided (#172)
|
||||
@@ -415,49 +532,55 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
desc: 'Fotos überprüfen und deren Sichtbarkeit festlegen, bevor die Galerie geteilt wird:',
|
||||
link: 'Kundenzugang öffnen',
|
||||
warning: 'Diesen Link nicht teilen — er ermöglicht das Ausblenden von Fotos in der Gästegalerie.',
|
||||
pin: 'PIN',
|
||||
},
|
||||
ru: {
|
||||
label: 'Доступ клиента (Личный)',
|
||||
desc: 'Просмотрите и управляйте видимостью фотографий перед тем, как поделиться галереей с гостями:',
|
||||
link: 'Открыть доступ клиента',
|
||||
warning: 'Не делитесь этой ссылкой — она позволяет скрывать фотографии из гостевой галереи.',
|
||||
pin: 'ПИН-код',
|
||||
},
|
||||
nl: {
|
||||
label: 'Klanttoegang (Privé)',
|
||||
desc: 'Bekijk en beheer de zichtbaarheid van foto\'s voordat u deelt met gasten:',
|
||||
link: 'Klanttoegang openen',
|
||||
warning: 'Deel deze link niet — hiermee kunnen foto\'s worden verborgen in de gastengalerij.',
|
||||
pin: 'PIN',
|
||||
},
|
||||
pt: {
|
||||
label: 'Acesso do Cliente (Privado)',
|
||||
desc: 'Revise e gerencie a visibilidade das fotos antes de compartilhar com os convidados:',
|
||||
link: 'Abrir Acesso do Cliente',
|
||||
warning: 'Não compartilhe este link — ele permite ocultar fotos da galeria de convidados.',
|
||||
pin: 'PIN',
|
||||
},
|
||||
en: {
|
||||
label: 'Client Access (Private)',
|
||||
desc: 'Review and manage photo visibility before sharing with guests:',
|
||||
link: 'Open Client Access',
|
||||
warning: 'Do not share this link — it allows hiding photos from the guest gallery.',
|
||||
pin: 'PIN',
|
||||
},
|
||||
};
|
||||
const ci18n = clientAccessI18n[language] || clientAccessI18n.en;
|
||||
const clientAccessLabel = ci18n.label;
|
||||
const clientAccessDesc = ci18n.desc;
|
||||
const clientAccessLink = ci18n.link;
|
||||
const clientAccessWarning = ci18n.warning;
|
||||
const pinLabel = 'PIN';
|
||||
|
||||
htmlBody += `
|
||||
<div style="margin-top: 24px; padding: 20px; background: #fff3cd; border-left: 4px solid #ffc107; border-radius: 4px;">
|
||||
<strong style="font-size: 15px;">🔒 ${clientAccessLabel}</strong>
|
||||
<p style="margin: 10px 0 8px;">${clientAccessDesc}</p>
|
||||
<strong style="font-size: 15px;">🔒 ${ci18n.label}</strong>
|
||||
<p style="margin: 10px 0 8px;">${ci18n.desc}</p>
|
||||
<p style="margin: 8px 0;">
|
||||
<a href="${processedVariables.client_link}" style="display: inline-block; padding: 10px 20px; background-color: #5C8762; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 600;">${clientAccessLink}</a>
|
||||
<a href="${processedVariables.client_link}" style="display: inline-block; padding: 10px 20px; background-color: #5C8762; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 600;">${ci18n.link}</a>
|
||||
</p>
|
||||
<p style="margin: 8px 0;">${pinLabel}: <strong>${processedVariables.client_password}</strong></p>
|
||||
<p style="color: #856404; font-size: 12px; margin: 8px 0 0;">⚠️ ${clientAccessWarning}</p>
|
||||
<p style="margin: 8px 0;">${ci18n.pin}: <strong>${processedVariables.client_password}</strong></p>
|
||||
<p style="color: #856404; font-size: 12px; margin: 8px 0 0;">⚠️ ${ci18n.warning}</p>
|
||||
</div>`;
|
||||
|
||||
// Mirror the same section in the plain-text body — without this, recipients
|
||||
// on a text-only mail client never saw the client link or PIN.
|
||||
if (textBody) {
|
||||
textBody += `\n\n${ci18n.label}\n${ci18n.desc}\n${processedVariables.client_link}\n${ci18n.pin}: ${processedVariables.client_password}\n${ci18n.warning}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap HTML body in styled template
|
||||
@@ -502,7 +625,7 @@ async function sendTemplateEmail(to, templateKey, variables) {
|
||||
to: to,
|
||||
subject: subject,
|
||||
html: htmlBody,
|
||||
text: textBody || htmlBody.replace(/<[^>]*>/g, '') // Strip HTML if no text version
|
||||
text: textBody || htmlToText(htmlBody)
|
||||
});
|
||||
|
||||
logger.info(`Email sent successfully: ${info.messageId} (${language})`);
|
||||
@@ -683,5 +806,8 @@ module.exports = {
|
||||
queueEmail,
|
||||
stopEmailQueueProcessor,
|
||||
testEmailConnection,
|
||||
wrapEmailHtml
|
||||
wrapEmailHtml,
|
||||
safeTemplateReplace,
|
||||
getSupportEmail,
|
||||
htmlToText
|
||||
};
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
const cron = require('node-cron');
|
||||
const { db } = require('../database/db');
|
||||
const { archiveEvent } = require('./archiveService');
|
||||
const { queueEmail } = require('./emailProcessor');
|
||||
const { queueEmail, getSupportEmail } = require('./emailProcessor');
|
||||
const logger = require('../utils/logger');
|
||||
const { formatDate } = require('../utils/dateFormatter');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
|
||||
function startExpirationChecker() {
|
||||
@@ -60,23 +59,30 @@ async function checkExpirations() {
|
||||
|
||||
async function queueExpirationWarning(event) {
|
||||
const daysRemaining = Math.ceil((new Date(event.expires_at) - new Date()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
// Determine language based on email domain
|
||||
|
||||
const recipientEmail = event.customer_email || event.host_email;
|
||||
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
|
||||
const emailLang = recipientEmail && recipientEmail.endsWith('.de') ? 'de' : 'en';
|
||||
|
||||
// Queue email to customer
|
||||
|
||||
// Date formatting + language detection happen inside processTemplate using
|
||||
// the recipient's resolved language — pass the raw ISO date and let the
|
||||
// processor format it. Don't pre-format here with a hard-coded `.de`/`en`
|
||||
// sniff (that helper got the wrong language for nl/pt/ru recipients).
|
||||
//
|
||||
// gallery_password is sent as the security-message sentinel because by the
|
||||
// time the warning fires we no longer have the plaintext (only the bcrypt
|
||||
// hash); the processor localises this to "(Not shown for security reasons)".
|
||||
await queueEmail(event.id, recipientEmail, 'expiration_warning', {
|
||||
customer_name: recipientName,
|
||||
customer_email: recipientEmail,
|
||||
host_name: recipientName,
|
||||
event_name: event.event_name,
|
||||
event_date: event.event_date,
|
||||
days_remaining: daysRemaining.toString(),
|
||||
expiration_date: await formatDate(event.expires_at, emailLang),
|
||||
gallery_link: event.share_link
|
||||
expiry_date: event.expires_at,
|
||||
gallery_link: event.share_link,
|
||||
gallery_password: '{{password_security_message}}'
|
||||
});
|
||||
|
||||
|
||||
logger.info(`Queued expiration warning for event ${event.slug}`);
|
||||
}
|
||||
|
||||
@@ -109,22 +115,37 @@ async function handleExpiredEvent(event) {
|
||||
});
|
||||
} catch (e) { /* non-fatal */ }
|
||||
|
||||
// Queue expiration emails
|
||||
// Queue expiration emails. The shipped templates (EN/DE in legacy 028,
|
||||
// NL/PT/RU in core 075) reference {{host_name}}, {{event_date}},
|
||||
// {{expiry_date}} and {{support_email}}. Without these, customers used
|
||||
// to literally see "Hello {{host_name}}, your gallery expired on
|
||||
// {{expiry_date}}…" — fill them all here.
|
||||
const recipientEmail = event.customer_email || event.host_email;
|
||||
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
|
||||
const supportEmail = await getSupportEmail();
|
||||
|
||||
await queueEmail(event.id, recipientEmail, 'gallery_expired', {
|
||||
event_name: event.event_name,
|
||||
admin_email: event.admin_email,
|
||||
const customerVars = {
|
||||
customer_name: recipientName,
|
||||
customer_email: recipientEmail
|
||||
});
|
||||
|
||||
// Also notify admin
|
||||
await queueEmail(event.id, event.admin_email, 'gallery_expired', {
|
||||
customer_email: recipientEmail,
|
||||
host_name: recipientName,
|
||||
event_name: event.event_name,
|
||||
admin_email: event.admin_email
|
||||
});
|
||||
event_date: event.event_date,
|
||||
expiry_date: event.expires_at,
|
||||
admin_email: event.admin_email,
|
||||
support_email: supportEmail
|
||||
};
|
||||
|
||||
if (recipientEmail) {
|
||||
await queueEmail(event.id, recipientEmail, 'gallery_expired', customerVars);
|
||||
}
|
||||
|
||||
// Also notify admin (when configured).
|
||||
if (event.admin_email && event.admin_email !== recipientEmail) {
|
||||
await queueEmail(event.id, event.admin_email, 'gallery_expired', {
|
||||
...customerVars,
|
||||
host_name: 'Admin'
|
||||
});
|
||||
}
|
||||
|
||||
// Start archiving process
|
||||
await archiveEvent(event);
|
||||
|
||||
@@ -2,6 +2,19 @@
|
||||
* Formatters for email content and other text transformations
|
||||
*/
|
||||
|
||||
// HTML-escape user-supplied text so it can be safely embedded in an email
|
||||
// HTML body. Used by formatWelcomeMessage so the resulting <br />-separated
|
||||
// HTML is safe even when the message contains < or > characters.
|
||||
function escapeHtml(text) {
|
||||
if (text === null || text === undefined) return '';
|
||||
return String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert plain text line breaks to HTML line breaks
|
||||
* @param {string} text - The text to format
|
||||
@@ -9,10 +22,10 @@
|
||||
*/
|
||||
function nl2br(text) {
|
||||
if (!text) return '';
|
||||
|
||||
|
||||
// Normalize line endings
|
||||
text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
|
||||
|
||||
// Convert newlines to <br> tags
|
||||
return text
|
||||
.split('\n')
|
||||
@@ -22,19 +35,22 @@ function nl2br(text) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Format welcome message for email templates
|
||||
* Format welcome message for email templates. Escapes the input first so
|
||||
* HTML metacharacters in admin-supplied text never reach the recipient's
|
||||
* mail client unescaped, then converts newlines to <br /> for rendering.
|
||||
* @param {string} message - The welcome message
|
||||
* @returns {string} - Formatted message for HTML emails
|
||||
* @returns {string} - Escaped HTML with <br /> line breaks
|
||||
*/
|
||||
function formatWelcomeMessage(message) {
|
||||
if (!message || message.trim() === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return nl2br(message);
|
||||
|
||||
return nl2br(escapeHtml(message));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
escapeHtml,
|
||||
nl2br,
|
||||
formatWelcomeMessage
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user