feat(email): global signature footer from the business profile (#1264)
The business profile already carried the operator's full issuer block —
address, phone, email, website, VAT id — but none of it reached an email.
Those columns only fed the quote/invoice PDF renderer, so every outgoing
mail footer was the fixed logo + company name + copyright line.
The signature is rendered by wrapEmailHtml and nowhere else, so no
template, no per-type send path and no queue row needed a change. Two new
columns on business_profile (migration 198) carry the toggle and one
free-text legal line; everything else is read from the address fields the
operator already maintains.
Default off, with a test pinning that the disabled path is byte-identical
to a no-profile install.
Includes three rounds of external review fixes: the plain-text MIME part
also carries the signature; string booleans ('false'/'0') no longer
invert the toggle; the status line stays silent rather than asserting
"off" while unauthorised or loading; and the preview's Text tab mirrors
the send path's htmlToText fallback.
Manual Messages replies deliberately keep no signature — they bypass the
wrapper by design — and the UI copy names that exception.
Closes #1264 (Part A)
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* PUT /api/admin/business-profile — email signature fields (migration 198).
|
||||
*
|
||||
* The two new columns are boolean + free text, which is exactly the shape
|
||||
* that goes wrong quietly: `optional({ values: 'falsy' })` on the boolean
|
||||
* would silently drop `false`, leaving the admin unable to switch the
|
||||
* signature back off. The round-trip below is what pins that.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-bpsig-test-'));
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'bpsig-route-test-secret';
|
||||
|
||||
const request = require('supertest');
|
||||
const {
|
||||
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
|
||||
} = require('./helpers/crmDb');
|
||||
|
||||
describe('business profile — email signature round-trip', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
let token;
|
||||
|
||||
const put = (payload) => request(app)
|
||||
.put('/api/admin/business-profile')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send(payload);
|
||||
|
||||
const get = () => request(app)
|
||||
.get('/api/admin/business-profile')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
// GET returns the snapshot at the top level; PUT wraps it in
|
||||
// successResponse's `data` envelope. Read either.
|
||||
const profileOf = (res) => (res.body.data || res.body).profile;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
const { adminId } = await seedMinimal(db);
|
||||
await assignAdminRole(db, adminId, 'super_admin');
|
||||
token = mintAdminToken(adminId);
|
||||
app = buildRouteApp('/api/admin/business-profile', require('../../src/routes/adminBusinessProfile'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
it('defaults to off with an empty legal line', async () => {
|
||||
const res = await get();
|
||||
expect(res.status).toBe(200);
|
||||
const profile = profileOf(res);
|
||||
expect(profile.emailSignatureEnabled).toBe(false);
|
||||
expect(profile.emailSignatureExtra).toBe('');
|
||||
});
|
||||
|
||||
it('persists the toggle and the legal line', async () => {
|
||||
const res = await put({
|
||||
emailSignatureEnabled: true,
|
||||
emailSignatureExtra: 'Handelsregister Vaduz FL-0002.123.456-7',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const profile = profileOf(await get());
|
||||
expect(profile.emailSignatureEnabled).toBe(true);
|
||||
expect(profile.emailSignatureExtra).toBe('Handelsregister Vaduz FL-0002.123.456-7');
|
||||
});
|
||||
|
||||
it('switches the toggle back off — `false` is not dropped as falsy', async () => {
|
||||
await put({ emailSignatureEnabled: true });
|
||||
const res = await put({ emailSignatureEnabled: false });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(profileOf(await get()).emailSignatureEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('clears the legal line with an empty string', async () => {
|
||||
await put({ emailSignatureExtra: 'something' });
|
||||
const res = await put({ emailSignatureExtra: '' });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(profileOf(await get()).emailSignatureExtra).toBe('');
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace off the legal line', async () => {
|
||||
await put({ emailSignatureExtra: ' Registered in Vaduz ' });
|
||||
expect(profileOf(await get()).emailSignatureExtra).toBe('Registered in Vaduz');
|
||||
});
|
||||
|
||||
// Codex review: express-validator's isBoolean() accepts the STRINGS
|
||||
// 'false' and '0', and Boolean('false') is true — so a form-encoded client
|
||||
// trying to switch the signature OFF switched it on instead.
|
||||
it.each([['false'], ['0']])('treats the string %s as off, not on', async (value) => {
|
||||
await put({ emailSignatureEnabled: true });
|
||||
expect(profileOf(await get()).emailSignatureEnabled).toBe(true);
|
||||
|
||||
const res = await put({ emailSignatureEnabled: value });
|
||||
expect(res.status).toBe(200);
|
||||
expect(profileOf(await get()).emailSignatureEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it.each([['true'], ['1']])('treats the string %s as on', async (value) => {
|
||||
await put({ emailSignatureEnabled: false });
|
||||
const res = await put({ emailSignatureEnabled: value });
|
||||
expect(res.status).toBe(200);
|
||||
expect(profileOf(await get()).emailSignatureEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a non-boolean toggle and a legal line over 500 chars', async () => {
|
||||
expect((await put({ emailSignatureEnabled: 'yes please' })).status).toBe(400);
|
||||
expect((await put({ emailSignatureExtra: 'x'.repeat(501) })).status).toBe(400);
|
||||
});
|
||||
|
||||
it('does not let an unmapped column ride in on the payload', async () => {
|
||||
// ALLOWED_PROFILE_FIELDS is the whitelist; the route's camel→snake map
|
||||
// is the second gate. Neither should pass a raw snake_case key through.
|
||||
const before = await db('business_profile').where({ id: 1 }).first();
|
||||
await put({ email_signature_enabled: true, id: 999 });
|
||||
const after = await db('business_profile').where({ id: 1 }).first();
|
||||
|
||||
expect(after.id).toBe(before.id);
|
||||
expect(after.email_signature_enabled).toBe(before.email_signature_enabled);
|
||||
});
|
||||
|
||||
it('invalidates the wrapper signature cache on write', async () => {
|
||||
const { wrapEmailHtml } = require('../../src/services/emailProcessor');
|
||||
|
||||
await put({ emailSignatureEnabled: false });
|
||||
expect(await wrapEmailHtml('<p>x</p>', 'S')).not.toContain('Bahnhofstrasse 9');
|
||||
|
||||
// Same request cycle, well inside the 60 s memo window: the PUT must
|
||||
// clear the cache or the operator sees a stale footer for a minute.
|
||||
await put({ emailSignatureEnabled: true, addressLine1: 'Bahnhofstrasse 9' });
|
||||
expect(await wrapEmailHtml('<p>x</p>', 'S')).toContain('Bahnhofstrasse 9');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* Global email footer signature (migration 198, issue #1264).
|
||||
*
|
||||
* The signature is rendered by `wrapEmailHtml` and nowhere else, which is
|
||||
* the whole point of the design: every template, preview, test mail and
|
||||
* manual send passes through that one wrapper, so none of them needed a
|
||||
* per-template change. These tests pin that contract at the wrapper.
|
||||
*
|
||||
* The load-bearing case is the DISABLED one — an upgraded install must keep
|
||||
* a byte-identical footer until an admin opts in.
|
||||
*/
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
describe('wrapEmailHtml — business-profile signature footer', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let wrapEmailHtml;
|
||||
let renderEmailSignatureText;
|
||||
let businessProfileService;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ wrapEmailHtml, renderEmailSignatureText } = require('../../src/services/emailProcessor'));
|
||||
businessProfileService = require('../../src/services/businessProfileService');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('business_profile').where({ id: 1 }).update({
|
||||
company_name: null,
|
||||
address_line1: null,
|
||||
address_line2: null,
|
||||
postal_code: null,
|
||||
city: null,
|
||||
country_code: null,
|
||||
country_name: null,
|
||||
phone: null,
|
||||
mobile: null,
|
||||
email: null,
|
||||
website: null,
|
||||
vat_id: null,
|
||||
email_signature_enabled: false,
|
||||
email_signature_extra: null,
|
||||
});
|
||||
businessProfileService.invalidateEmailSignatureCache();
|
||||
});
|
||||
|
||||
const fullProfile = {
|
||||
company_name: 'Müller Fotografie GmbH',
|
||||
address_line1: 'Bahnhofstrasse 1',
|
||||
address_line2: 'Postfach 42',
|
||||
postal_code: '9494',
|
||||
city: 'Schaan',
|
||||
country_code: 'li',
|
||||
country_name: 'Liechtenstein',
|
||||
phone: '+41 79 123 45 67',
|
||||
mobile: '+41 78 000 11 22',
|
||||
email: '[email protected]',
|
||||
website: 'example.com',
|
||||
vat_id: 'CHE-123.456.789',
|
||||
email_signature_enabled: true,
|
||||
email_signature_extra: 'Handelsregister Vaduz\nFL-0002.123.456-7',
|
||||
};
|
||||
|
||||
async function enable(overrides = {}) {
|
||||
await db('business_profile').where({ id: 1 }).update({ ...fullProfile, ...overrides });
|
||||
businessProfileService.invalidateEmailSignatureCache();
|
||||
}
|
||||
|
||||
it('renders nothing extra when the toggle is off', async () => {
|
||||
// Profile fully populated, signature switched OFF: the operator's
|
||||
// address must not leak into mail just because they filled in the
|
||||
// invoice issuer block.
|
||||
await enable({ email_signature_enabled: false });
|
||||
|
||||
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||
|
||||
expect(html).not.toContain('Bahnhofstrasse 1');
|
||||
expect(html).not.toContain('[email protected]');
|
||||
expect(html).not.toContain('CHE-123.456.789');
|
||||
});
|
||||
|
||||
it('produces a byte-identical footer to a no-profile install when disabled', async () => {
|
||||
const withEmptyProfile = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||
await enable({ email_signature_enabled: false });
|
||||
const withDisabledSignature = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||
|
||||
expect(withDisabledSignature).toBe(withEmptyProfile);
|
||||
});
|
||||
|
||||
it('renders address, contacts, VAT id and the legal line when enabled', async () => {
|
||||
await enable();
|
||||
|
||||
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||
|
||||
expect(html).toContain('Müller Fotografie GmbH');
|
||||
expect(html).toContain('Bahnhofstrasse 1');
|
||||
expect(html).toContain('Postfach 42');
|
||||
// "LI-9494 Schaan / Liechtenstein" — same shape as the PDF issuer block.
|
||||
expect(html).toContain('LI-9494 Schaan / Liechtenstein');
|
||||
expect(html).toContain('VAT ID: CHE-123.456.789');
|
||||
expect(html).toContain('Handelsregister Vaduz<br />FL-0002.123.456-7');
|
||||
});
|
||||
|
||||
it('links phone, mobile, email and website with safe schemes', async () => {
|
||||
await enable();
|
||||
|
||||
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||
|
||||
// Separators stripped from the tel: href, kept in the visible text.
|
||||
expect(html).toContain('href="tel:+41791234567"');
|
||||
expect(html).toContain('href="tel:+41780001122"');
|
||||
expect(html).toContain('href="mailto:[email protected]"');
|
||||
// A bare hostname is promoted to https:// rather than left relative.
|
||||
expect(html).toContain('href="https://example.com"');
|
||||
});
|
||||
|
||||
it('keeps an already-absolute website URL as typed', async () => {
|
||||
await enable({ website: 'http://legacy.example.org/studio' });
|
||||
|
||||
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||
|
||||
expect(html).toContain('href="http://legacy.example.org/studio"');
|
||||
});
|
||||
|
||||
it('neutralises a javascript: website into an inert https URL', async () => {
|
||||
await enable({ website: 'javascript:alert(1)' });
|
||||
|
||||
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||
|
||||
expect(html).not.toContain('href="javascript:');
|
||||
expect(html).toContain('href="https://javascript:alert(1)"');
|
||||
});
|
||||
|
||||
it('HTML-escapes every signature field', async () => {
|
||||
await enable({
|
||||
company_name: '<script>alert(1)</script>',
|
||||
address_line1: 'Rue "des" Fleurs & Co',
|
||||
vat_id: '<img src=x onerror=alert(1)>',
|
||||
email_signature_extra: '</p><script>alert(2)</script>',
|
||||
});
|
||||
|
||||
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||
|
||||
// Escaped, so the markup is inert text — the tags never open.
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
expect(html).not.toContain('<script>alert(2)</script>');
|
||||
expect(html).not.toContain('<img src=x');
|
||||
expect(html).toContain('<script>alert(1)</script>');
|
||||
expect(html).toContain('<img src=x onerror=alert(1)>');
|
||||
expect(html).toContain('Rue "des" Fleurs & Co');
|
||||
});
|
||||
|
||||
it('does not repeat the branding company name', async () => {
|
||||
// Footer already prints the branding name; the profile name is only
|
||||
// added when the operator gave a different legal name.
|
||||
await db('app_settings')
|
||||
.insert({ setting_key: 'branding_company_name', setting_value: JSON.stringify('Müller Fotografie GmbH'), setting_type: 'branding' })
|
||||
.onConflict('setting_key')
|
||||
.merge();
|
||||
await enable();
|
||||
|
||||
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||
|
||||
expect(html.match(/Müller Fotografie GmbH/g).length).toBe(
|
||||
// header alt, footer alt, footer name line, copyright line — the
|
||||
// signature must not add a fifth.
|
||||
(await wrapEmailHtml('<p>Body</p>', 'Subject', 'en')).match(/Müller Fotografie GmbH/g).length
|
||||
);
|
||||
expect(html.match(/Müller Fotografie GmbH/g).length).toBe(4);
|
||||
|
||||
await db('app_settings').where({ setting_key: 'branding_company_name' }).del();
|
||||
});
|
||||
|
||||
it('uses the German VAT label for a German mail', async () => {
|
||||
await enable();
|
||||
|
||||
const de = await wrapEmailHtml('<p>Body</p>', 'Subject', 'de');
|
||||
const en = await wrapEmailHtml('<p>Body</p>', 'Subject', 'en');
|
||||
|
||||
expect(de).toContain('USt-IdNr.: CHE-123.456.789');
|
||||
expect(en).toContain('VAT ID: CHE-123.456.789');
|
||||
});
|
||||
|
||||
it('omits empty fields instead of rendering blank rows', async () => {
|
||||
await enable({
|
||||
address_line2: null, mobile: null, website: null, vat_id: null, email_signature_extra: null,
|
||||
});
|
||||
|
||||
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||
|
||||
expect(html).toContain('[email protected]');
|
||||
expect(html).not.toContain('VAT ID:');
|
||||
expect(html).not.toMatch(/·\s*·/);
|
||||
});
|
||||
|
||||
it('renders no signature block when enabled but the profile is blank', async () => {
|
||||
await db('business_profile').where({ id: 1 }).update({ email_signature_enabled: true });
|
||||
businessProfileService.invalidateEmailSignatureCache();
|
||||
|
||||
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||
|
||||
// The signature <div> is the only element carrying this margin.
|
||||
expect(html).not.toContain('<div style="margin:15px 0 5px;');
|
||||
});
|
||||
|
||||
// Codex review: sendTemplateEmail uses a template's own body_text when it
|
||||
// has one — every seeded template does — so the text/plain alternative is
|
||||
// NOT derived from the wrapped HTML and carried no signature at all.
|
||||
describe('plain-text alternative', () => {
|
||||
it('renders the signature as plain text', async () => {
|
||||
await enable();
|
||||
const signature = await businessProfileService.getEmailSignature();
|
||||
|
||||
const text = renderEmailSignatureText(signature, { brandingCompanyName: 'PicPeak', language: 'en' });
|
||||
|
||||
expect(text).toContain('Bahnhofstrasse 1');
|
||||
expect(text).toContain('[email protected]');
|
||||
expect(text).toContain('VAT ID: CHE-123.456.789');
|
||||
expect(text).toContain('Handelsregister Vaduz');
|
||||
// A separator, the text equivalent of the footer's top border.
|
||||
expect(text).toMatch(/^\n\n--\n/);
|
||||
});
|
||||
|
||||
it('carries no HTML markup or entities', async () => {
|
||||
await enable();
|
||||
const signature = await businessProfileService.getEmailSignature();
|
||||
|
||||
const text = renderEmailSignatureText(signature, { brandingCompanyName: 'PicPeak', language: 'en' });
|
||||
|
||||
expect(text).not.toContain('<');
|
||||
expect(text).not.toContain('·');
|
||||
expect(text).not.toContain('&');
|
||||
});
|
||||
|
||||
it('is empty when the signature is disabled', () => {
|
||||
expect(renderEmailSignatureText(null, {})).toBe('');
|
||||
});
|
||||
|
||||
it('uses the German VAT label for a German mail', async () => {
|
||||
await enable();
|
||||
const signature = await businessProfileService.getEmailSignature();
|
||||
|
||||
expect(renderEmailSignatureText(signature, { language: 'de' })).toContain('USt-IdNr.');
|
||||
expect(renderEmailSignatureText(signature, { language: 'en' })).toContain('VAT ID');
|
||||
});
|
||||
|
||||
it('does not repeat the branding company name', async () => {
|
||||
await enable();
|
||||
const signature = await businessProfileService.getEmailSignature();
|
||||
|
||||
const text = renderEmailSignatureText(signature, {
|
||||
brandingCompanyName: 'Müller Fotografie GmbH', language: 'en',
|
||||
});
|
||||
expect(text).not.toContain('Müller Fotografie GmbH');
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves the plain-text part free of signature markup', async () => {
|
||||
const { htmlToText } = require('../../src/services/emailProcessor');
|
||||
await enable();
|
||||
|
||||
const text = htmlToText(await wrapEmailHtml('<p>Body</p>', 'Subject'));
|
||||
|
||||
expect(text).toContain('Bahnhofstrasse 1');
|
||||
expect(text).not.toContain('<p');
|
||||
expect(text).not.toContain('style=');
|
||||
expect(text).not.toContain('·');
|
||||
// The separator survives as a real character, not an entity.
|
||||
expect(text).toContain('Bahnhofstrasse 1 \u00b7 Postfach 42');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* `business_profile.email_signature_enabled` + `email_signature_extra` —
|
||||
* the global email footer signature (issue #1264).
|
||||
*
|
||||
* The business profile already carries the full issuer block (address,
|
||||
* phone, email, website, VAT id) but none of it ever reached an email:
|
||||
* those columns only feed the quote/invoice PDF renderer and the public
|
||||
* quote page. Every outgoing mail footer was the fixed logo + company
|
||||
* name + copyright line built inside `wrapEmailHtml`.
|
||||
*
|
||||
* Rather than copy the address into a second place, the wrapper now reads
|
||||
* this profile row. These two columns are the only new state: a master
|
||||
* toggle and one free-text line for the legal notice (Handelsregister /
|
||||
* registration number / disclaimer) that has no dedicated column.
|
||||
*
|
||||
* Default FALSE on purpose: an upgraded install keeps a byte-identical
|
||||
* footer until an admin turns the signature on.
|
||||
*/
|
||||
|
||||
exports.up = async function (knex) {
|
||||
if (!(await knex.schema.hasTable('business_profile'))) return;
|
||||
|
||||
if (!(await knex.schema.hasColumn('business_profile', 'email_signature_enabled'))) {
|
||||
await knex.schema.alterTable('business_profile', (table) => {
|
||||
table.boolean('email_signature_enabled').notNullable().defaultTo(false);
|
||||
});
|
||||
}
|
||||
if (!(await knex.schema.hasColumn('business_profile', 'email_signature_extra'))) {
|
||||
await knex.schema.alterTable('business_profile', (table) => {
|
||||
// Plain text, rendered escaped and <br>-separated. Not HTML.
|
||||
table.text('email_signature_extra');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
if (!(await knex.schema.hasTable('business_profile'))) return;
|
||||
|
||||
for (const column of ['email_signature_enabled', 'email_signature_extra']) {
|
||||
if (await knex.schema.hasColumn('business_profile', column)) {
|
||||
await knex.schema.alterTable('business_profile', (table) => {
|
||||
table.dropColumn(column);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -179,6 +179,12 @@ function transformProfile(p) {
|
||||
: (p.scheduled_email_floor_enabled === true
|
||||
|| p.scheduled_email_floor_enabled === 1
|
||||
|| p.scheduled_email_floor_enabled === '1'),
|
||||
// Migration 198 — global email footer signature. Defaults FALSE so an
|
||||
// upgraded install's footer stays byte-identical until an admin opts in.
|
||||
emailSignatureEnabled: p.email_signature_enabled === true
|
||||
|| p.email_signature_enabled === 1
|
||||
|| p.email_signature_enabled === '1',
|
||||
emailSignatureExtra: p.email_signature_extra || '',
|
||||
createdAt: p.created_at,
|
||||
updatedAt: p.updated_at,
|
||||
};
|
||||
@@ -456,6 +462,11 @@ router.put(
|
||||
}),
|
||||
// Migration 114 — scheduled-email floor master switch.
|
||||
body('scheduledEmailFloorEnabled').optional().isBoolean(),
|
||||
// Migration 198 — global email footer signature. Boolean uses the
|
||||
// explicit-undefined form so `false` reaches the service and the
|
||||
// toggle can actually be switched off.
|
||||
body('emailSignatureEnabled').optional().isBoolean(),
|
||||
body('emailSignatureExtra').optional({ values: 'falsy' }).isString().isLength({ max: 500 }),
|
||||
],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
@@ -497,6 +508,9 @@ router.put(
|
||||
// Migration 114 — business hours + scheduled-email floor switch.
|
||||
businessHours: 'business_hours',
|
||||
scheduledEmailFloorEnabled: 'scheduled_email_floor_enabled',
|
||||
// Migration 198 — global email footer signature.
|
||||
emailSignatureEnabled: 'email_signature_enabled',
|
||||
emailSignatureExtra: 'email_signature_extra',
|
||||
};
|
||||
for (const [api, db] of Object.entries(map)) {
|
||||
if (Object.prototype.hasOwnProperty.call(req.body, api)) {
|
||||
|
||||
@@ -8,8 +8,9 @@ const { requirePermission } = require('../middleware/permissions');
|
||||
// /email mount — the pre-existing config/queue/received endpoints stay ungated).
|
||||
const { requireFeatureFlag } = require('../middleware/requireFeatureFlag');
|
||||
const messagingGate = requireFeatureFlag('messaging');
|
||||
const { wrapEmailHtml, processEmailQueue, resolveFromIdentity } = require('../services/emailProcessor');
|
||||
const { wrapEmailHtml, processEmailQueue, resolveFromIdentity, buildSignatureTextFor, htmlToText } = require('../services/emailProcessor');
|
||||
const emailWebhookTransport = require('../services/emailWebhookTransport');
|
||||
const businessProfileService = require('../services/businessProfileService');
|
||||
const { errorResponse, safeValidationErrors } = require('../utils/routeHelpers');
|
||||
const logger = require('../utils/logger');
|
||||
const router = express.Router();
|
||||
@@ -494,7 +495,10 @@ router.post('/test', adminAuth, requirePermission('email.send'), async (req, res
|
||||
to: test_email,
|
||||
subject: webhookSubject,
|
||||
html: webhookHtml,
|
||||
text: 'Test Email Successful! Delivered through the configured email webhook.',
|
||||
// The HTML goes through wrapEmailHtml and gains the signature; the
|
||||
// text alternative has to be given it explicitly (#1264 review).
|
||||
text: 'Test Email Successful! Delivered through the configured email webhook.'
|
||||
+ await buildSignatureTextFor('en'),
|
||||
});
|
||||
} catch (webhookError) {
|
||||
// Handled here, not by the outer catch: that one maps ECONNREFUSED and
|
||||
@@ -580,6 +584,7 @@ router.post('/test', adminAuth, requirePermission('email.send'), async (req, res
|
||||
subject,
|
||||
html: wrappedHtml,
|
||||
text: 'Test Email Successful! Your email configuration is working correctly.'
|
||||
+ await buildSignatureTextFor('en')
|
||||
});
|
||||
|
||||
res.json({ message: 'Test email sent successfully' });
|
||||
@@ -1233,8 +1238,21 @@ router.post('/templates/:key/preview', adminAuth, requirePermission('email.view'
|
||||
res.json({
|
||||
subject,
|
||||
body_html: wrappedHtml,
|
||||
body_text: textContent,
|
||||
language
|
||||
// The Text tab has to show what a text-only client will receive, which
|
||||
// includes the signature the HTML tab already displays (#1264 review).
|
||||
//
|
||||
// The `|| htmlToText(...)` half mirrors sendTemplateEmail: a
|
||||
// translation may legitimately have HTML and an EMPTY body_text, and
|
||||
// the real send derives the text part from the HTML in that case.
|
||||
// Concatenating the signature onto '' produced a non-empty string, so
|
||||
// the Text tab rendered a footer with no message above it.
|
||||
body_text: (textContent || htmlToText(wrappedHtml))
|
||||
+ await buildSignatureTextFor(language),
|
||||
language,
|
||||
// Migration 198 — the wrapper above already rendered the global
|
||||
// signature into body_html when it's on. This flag just lets the
|
||||
// preview UI say so, and point at Business profile when it's off.
|
||||
signature: (await businessProfileService.getEmailSignature()) !== null
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to preview email template');
|
||||
|
||||
@@ -85,6 +85,11 @@ const ALLOWED_PROFILE_FIELDS = [
|
||||
'business_hours',
|
||||
// Master switch for the scheduled-email business-hours floor (mig 114).
|
||||
'scheduled_email_floor_enabled',
|
||||
// Global email footer signature (migration 198). The toggle plus one
|
||||
// free-text legal line; every other value in the signature is read from
|
||||
// the address/contact columns above, so there is no second copy.
|
||||
'email_signature_enabled',
|
||||
'email_signature_extra',
|
||||
];
|
||||
|
||||
const ALLOWED_BANK_FIELDS = [
|
||||
@@ -125,6 +130,28 @@ function normaliseCountryCode(cc) {
|
||||
return String(cc).trim().toUpperCase().slice(0, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce an API boolean, honouring the string forms express-validator's
|
||||
* `isBoolean()` accepts.
|
||||
*
|
||||
* `Boolean('false')` and `Boolean('0')` are both TRUE, so a URL-encoded or
|
||||
* form-encoded client sending `emailSignatureEnabled=false` passed validation
|
||||
* and then stored the toggle as ENABLED — the one value it was trying to
|
||||
* clear. JSON clients were unaffected, which is why it was easy to miss.
|
||||
*
|
||||
* Applied to every boolean in this file, not just the new one: the PDF
|
||||
* visibility toggles, the scheduled-email floor and the bank-account default
|
||||
* flag all shared the coercion and therefore the bug.
|
||||
*/
|
||||
function toBoolean(value) {
|
||||
if (typeof value === 'string') {
|
||||
const v = value.trim().toLowerCase();
|
||||
if (v === 'false' || v === '0') return false;
|
||||
if (v === 'true' || v === '1') return true;
|
||||
}
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
function sanitiseProfilePayload(payload) {
|
||||
const updates = pickFields(payload, ALLOWED_PROFILE_FIELDS);
|
||||
|
||||
@@ -142,7 +169,8 @@ function sanitiseProfilePayload(payload) {
|
||||
// when the admin pastes from a printed letterhead.
|
||||
for (const field of ['company_name', 'address_line1', 'address_line2',
|
||||
'city', 'state', 'country_name', 'phone', 'mobile', 'email', 'website',
|
||||
'vat_id', 'tax_id', 'vat_label', 'footer_line', 'logo_path']) {
|
||||
'vat_id', 'tax_id', 'vat_label', 'footer_line', 'logo_path',
|
||||
'email_signature_extra']) {
|
||||
if (typeof updates[field] === 'string') {
|
||||
updates[field] = updates[field].trim();
|
||||
}
|
||||
@@ -154,7 +182,7 @@ function sanitiseProfilePayload(payload) {
|
||||
'pdf_quote_show_net_days', 'pdf_quote_show_skonto',
|
||||
]) {
|
||||
if (updates[field] !== undefined) {
|
||||
updates[field] = formatBoolean(Boolean(updates[field]));
|
||||
updates[field] = formatBoolean(toBoolean(updates[field]));
|
||||
}
|
||||
}
|
||||
// Folding-mark enum — whitelisted set. Garbage values fall back to
|
||||
@@ -195,7 +223,12 @@ function sanitiseProfilePayload(payload) {
|
||||
}
|
||||
}
|
||||
if (updates.scheduled_email_floor_enabled !== undefined) {
|
||||
updates.scheduled_email_floor_enabled = formatBoolean(Boolean(updates.scheduled_email_floor_enabled));
|
||||
updates.scheduled_email_floor_enabled = formatBoolean(toBoolean(updates.scheduled_email_floor_enabled));
|
||||
}
|
||||
// Migration 198 — email footer signature master switch. Same
|
||||
// explicit-undefined shape as the PDF toggles so `false` persists.
|
||||
if (updates.email_signature_enabled !== undefined) {
|
||||
updates.email_signature_enabled = formatBoolean(toBoolean(updates.email_signature_enabled));
|
||||
}
|
||||
|
||||
return updates;
|
||||
@@ -214,7 +247,7 @@ function sanitiseBankPayload(payload) {
|
||||
updates.currency = normaliseCurrency(updates.currency);
|
||||
}
|
||||
if (updates.is_default !== undefined) {
|
||||
updates.is_default = formatBoolean(Boolean(updates.is_default));
|
||||
updates.is_default = formatBoolean(toBoolean(updates.is_default));
|
||||
}
|
||||
for (const field of ['label', 'account_holder']) {
|
||||
if (typeof updates[field] === 'string') {
|
||||
@@ -264,6 +297,10 @@ async function updateProfile(payload, adminId) {
|
||||
await db('business_profile').where({ id: 1 }).update(updates);
|
||||
});
|
||||
|
||||
// The email footer signature is built from these same columns, so any
|
||||
// profile write invalidates it — not just the two signature fields.
|
||||
invalidateEmailSignatureCache();
|
||||
|
||||
logger.info('Business profile updated', {
|
||||
adminId,
|
||||
fields: Object.keys(updates).filter((k) => k !== 'updated_at'),
|
||||
@@ -369,9 +406,84 @@ async function resolveBankAccountForCurrency(currency, overrideId = null, conn =
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The global email footer signature (migration 198).
|
||||
*
|
||||
* `wrapEmailHtml` calls this on every single outgoing mail — transactional,
|
||||
* preview, test and manual alike — so it is memoised for 60 s and cleared
|
||||
* on any profile write. A queue tick sending 10 mails hits the DB once.
|
||||
*
|
||||
* Returns `null` when the toggle is off (or the table/column is missing on
|
||||
* an install that hasn't migrated yet), which is the wrapper's signal to
|
||||
* render exactly the footer it rendered before this feature existed.
|
||||
*
|
||||
* Every value is raw text — escaping is the renderer's job.
|
||||
*/
|
||||
const SIGNATURE_CACHE_TTL_MS = 60 * 1000;
|
||||
let signatureCache = { value: undefined, expiresAt: 0 };
|
||||
|
||||
function invalidateEmailSignatureCache() {
|
||||
signatureCache = { value: undefined, expiresAt: 0 };
|
||||
}
|
||||
|
||||
function truthy(v) {
|
||||
return v === true || v === 1 || v === '1' || v === 't' || v === 'true';
|
||||
}
|
||||
|
||||
async function getEmailSignature() {
|
||||
const now = Date.now();
|
||||
if (signatureCache.value !== undefined && signatureCache.expiresAt > now) {
|
||||
return signatureCache.value;
|
||||
}
|
||||
|
||||
let signature = null;
|
||||
try {
|
||||
const profile = await withRetry(async () =>
|
||||
db('business_profile').where({ id: 1 }).first()
|
||||
);
|
||||
|
||||
if (profile && truthy(profile.email_signature_enabled)) {
|
||||
// Same "PC City / Country" shape the PDF issuer block uses
|
||||
// (pdfService.js:361). The locale-aware country lookup lives in the
|
||||
// PDF renderer; an email footer takes the free-text `country_name`
|
||||
// when the admin set one and the ISO code otherwise.
|
||||
const cc = profile.country_code ? String(profile.country_code).toUpperCase() : '';
|
||||
const pc = profile.postal_code || '';
|
||||
const left = [cc && pc ? `${cc}-${pc}` : (pc || cc), profile.city || ''].filter(Boolean).join(' ');
|
||||
const country = profile.country_name || '';
|
||||
const cityCountry = [left, country].filter(Boolean).join(' / ');
|
||||
|
||||
signature = {
|
||||
companyName: profile.company_name || '',
|
||||
addressLines: [profile.address_line1, profile.address_line2, cityCountry]
|
||||
.map((l) => (l == null ? '' : String(l).trim()))
|
||||
.filter(Boolean),
|
||||
phone: profile.phone || '',
|
||||
mobile: profile.mobile || '',
|
||||
email: profile.email || '',
|
||||
website: profile.website || '',
|
||||
vatId: profile.vat_id || '',
|
||||
extra: profile.email_signature_extra || '',
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
// A missing table/column (pre-198 install mid-upgrade) must never break
|
||||
// the mail itself — fall through to "no signature".
|
||||
logger.warn('Could not read email signature from business profile', {
|
||||
error: error.message,
|
||||
});
|
||||
signature = null;
|
||||
}
|
||||
|
||||
signatureCache = { value: signature, expiresAt: now + SIGNATURE_CACHE_TTL_MS };
|
||||
return signature;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getProfile,
|
||||
updateProfile,
|
||||
getEmailSignature,
|
||||
invalidateEmailSignatureCache,
|
||||
createBankAccount,
|
||||
updateBankAccount,
|
||||
deleteBankAccount,
|
||||
|
||||
@@ -8,6 +8,9 @@ const {
|
||||
} = require('../utils/businessHours');
|
||||
const { hasColumnCached } = require('../utils/schemaCache');
|
||||
const emailWebhookTransport = require('./emailWebhookTransport');
|
||||
// Migration 198 — the global email footer signature is read from the
|
||||
// business profile. No cycle: businessProfileService only pulls db + utils.
|
||||
const businessProfileService = require('./businessProfileService');
|
||||
|
||||
/**
|
||||
* The From identity for an outbound message (#1225).
|
||||
@@ -230,6 +233,137 @@ function darkenColor(hex, amount = 0.15) {
|
||||
return `#${(1 << 24 | Math.round(r) << 16 | Math.round(g) << 8 | Math.round(b)).toString(16).slice(1)}`;
|
||||
}
|
||||
|
||||
// ---- global email footer signature (migration 198, issue #1264) --------
|
||||
//
|
||||
// Built from the business_profile issuer block — the address, contact rows
|
||||
// and legal line the operator already maintains for their invoices — so it
|
||||
// appears under EVERY mail this install sends without a single template
|
||||
// being touched. Returns '' when the admin has not enabled it, which keeps
|
||||
// the footer byte-identical to what pre-198 installs render.
|
||||
|
||||
// VAT is the one value that needs a label to mean anything. en/de only;
|
||||
// every other locale falls back to the English label, same as the rest of
|
||||
// the wrapper chrome ("All rights reserved").
|
||||
const SIGNATURE_VAT_LABELS = { en: 'VAT ID', de: 'USt-IdNr.' };
|
||||
|
||||
// tel: hrefs take digits and a leading +; strip everything else so a pasted
|
||||
// "+41 79 123 45 67 (mobile only)" can't smuggle a scheme or a quote into
|
||||
// the attribute.
|
||||
function signatureTelHref(raw) {
|
||||
const cleaned = String(raw || '').replace(/[^\d+]/g, '');
|
||||
return cleaned ? `tel:${cleaned}` : null;
|
||||
}
|
||||
|
||||
// Admins type "example.com" as often as "https://example.com". Anything not
|
||||
// already http(s) gets an https:// prefix — which also means a pasted
|
||||
// `javascript:` value becomes an inert https URL instead of a live scheme.
|
||||
function signatureWebsiteHref(raw) {
|
||||
const trimmed = String(raw || '').trim();
|
||||
if (!trimmed) return null;
|
||||
return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
||||
}
|
||||
|
||||
function renderSignatureLink(href, text, color) {
|
||||
return `<a href="${escapeHtml(href)}" style="color:${color};text-decoration:none;">${escapeHtml(text)}</a>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object|null} signature businessProfileService.getEmailSignature()
|
||||
* @param {object} opts { mutedTextColor, brandingCompanyName, language }
|
||||
* @returns {string} HTML rows for the footer <td>, or '' when disabled.
|
||||
*/
|
||||
function renderEmailSignature(signature, { mutedTextColor, brandingCompanyName, language }) {
|
||||
if (!signature) return '';
|
||||
|
||||
const lineStyle = `color:${mutedTextColor};font-size:12px;line-height:18px;margin:4px 0;`;
|
||||
const rows = [];
|
||||
|
||||
// The footer above already prints the BRANDING company name. Only repeat
|
||||
// the profile's when the operator has actually given it a different legal
|
||||
// name ("Foto Müller" vs "Müller Fotografie GmbH").
|
||||
if (signature.companyName && signature.companyName !== brandingCompanyName) {
|
||||
rows.push(`<p style="${lineStyle}">${escapeHtml(signature.companyName)}</p>`);
|
||||
}
|
||||
|
||||
// A literal middle dot, not `·`: the plain-text part of every mail
|
||||
// is derived from this HTML by htmlToText, which decodes only the five
|
||||
// core entities — an `·` would survive verbatim into the text body.
|
||||
if (signature.addressLines.length) {
|
||||
rows.push(`<p style="${lineStyle}">${signature.addressLines.map(escapeHtml).join(' \u00b7 ')}</p>`);
|
||||
}
|
||||
|
||||
const contact = [];
|
||||
for (const number of [signature.phone, signature.mobile]) {
|
||||
const href = signatureTelHref(number);
|
||||
if (href) contact.push(renderSignatureLink(href, number, mutedTextColor));
|
||||
}
|
||||
if (signature.email) {
|
||||
contact.push(renderSignatureLink(`mailto:${signature.email}`, signature.email, mutedTextColor));
|
||||
}
|
||||
const website = signatureWebsiteHref(signature.website);
|
||||
if (website) contact.push(renderSignatureLink(website, signature.website, mutedTextColor));
|
||||
if (contact.length) {
|
||||
rows.push(`<p style="${lineStyle}">${contact.join(' \u00b7 ')}</p>`);
|
||||
}
|
||||
|
||||
if (signature.vatId) {
|
||||
const label = SIGNATURE_VAT_LABELS[language] || SIGNATURE_VAT_LABELS.en;
|
||||
rows.push(`<p style="${lineStyle}">${escapeHtml(label)}: ${escapeHtml(signature.vatId)}</p>`);
|
||||
}
|
||||
|
||||
// Free text (Handelsregister line, disclaimer, …). Plain text, never
|
||||
// HTML — escaped, then newlines become <br> so a pasted 3-line legal
|
||||
// notice keeps its shape.
|
||||
if (signature.extra) {
|
||||
const extra = escapeHtml(signature.extra).replace(/\r\n|\r|\n/g, '<br />');
|
||||
rows.push(`<p style="${lineStyle}font-size:11px;">${extra}</p>`);
|
||||
}
|
||||
|
||||
if (!rows.length) return '';
|
||||
|
||||
return `
|
||||
<div style="margin:15px 0 5px;padding-top:15px;border-top:1px solid #eeeeee;">
|
||||
${rows.join('\n ')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The signature as plain text, for the text/plain MIME alternative.
|
||||
*
|
||||
* `sendTemplateEmail` uses a template's own `body_text` when it has one — and
|
||||
* the seeded templates all do — so the text part is NOT derived from the
|
||||
* wrapped HTML and would otherwise carry no signature at all. A text-only
|
||||
* client, and the preview's Text tab, then showed a mail with no address and
|
||||
* no legal line while the HTML part had both.
|
||||
*
|
||||
* Returns '' when the signature is disabled, so callers can append
|
||||
* unconditionally.
|
||||
*/
|
||||
function renderEmailSignatureText(signature, { brandingCompanyName, language } = {}) {
|
||||
if (!signature) return '';
|
||||
|
||||
const lines = [];
|
||||
if (signature.companyName && signature.companyName !== brandingCompanyName) {
|
||||
lines.push(signature.companyName);
|
||||
}
|
||||
if (signature.addressLines.length) {
|
||||
lines.push(signature.addressLines.join(' \u00b7 '));
|
||||
}
|
||||
const contact = [signature.phone, signature.mobile, signature.email, signature.website]
|
||||
.map((v) => (v || '').trim())
|
||||
.filter(Boolean);
|
||||
if (contact.length) lines.push(contact.join(' \u00b7 '));
|
||||
if (signature.vatId) {
|
||||
const label = SIGNATURE_VAT_LABELS[language] || SIGNATURE_VAT_LABELS.en;
|
||||
lines.push(`${label}: ${signature.vatId}`);
|
||||
}
|
||||
if (signature.extra) lines.push(signature.extra);
|
||||
|
||||
if (!lines.length) return '';
|
||||
// A visual separator, the plain-text equivalent of the footer's top border.
|
||||
return `\n\n--\n${lines.join('\n')}`;
|
||||
}
|
||||
|
||||
// Wrap HTML body in the styled email template with header, footer, and logo
|
||||
async function wrapEmailHtml(htmlBody, subject, language = 'en') {
|
||||
// Email colour palette. The two original settings (email_primary_color and
|
||||
@@ -292,6 +426,14 @@ async function wrapEmailHtml(htmlBody, subject, language = 'en') {
|
||||
const logoFullUrl = `${frontendUrl}${logoPath.startsWith('/') ? '' : '/'}${logoPath}`;
|
||||
logger.debug('Email logo URL:', { frontendUrl, logoPath, logoFullUrl });
|
||||
|
||||
// Migration 198 — global footer signature from the business profile.
|
||||
// Memoised for 60 s in the service, so a queue tick sending ten mails
|
||||
// reads the row once. Never throws; returns null when disabled.
|
||||
const signatureHtml = renderEmailSignature(
|
||||
await businessProfileService.getEmailSignature(),
|
||||
{ mutedTextColor, brandingCompanyName: companyName, language }
|
||||
);
|
||||
|
||||
const year = new Date().getFullYear();
|
||||
// PR review follow-up — Outlook (Word engine) and Apple Mail under some
|
||||
// configs STRIP the <head><style>, so any element styled only by a class
|
||||
@@ -438,7 +580,7 @@ async function wrapEmailHtml(htmlBody, subject, language = 'en') {
|
||||
<tr>
|
||||
<td align="center" bgcolor="${secondaryColor}" class="email-footer" style="background-color:${secondaryColor};padding:30px;text-align:center;border-top:1px solid #eeeeee;">
|
||||
<img src="${logoFullUrl}" alt="${companyName}" width="120" style="max-width:120px;height:auto;opacity:0.8;margin-bottom:15px;border:0;">
|
||||
<p style="color:${mutedTextColor};font-size:14px;margin:5px 0;">${companyName}</p>
|
||||
<p style="color:${mutedTextColor};font-size:14px;margin:5px 0;">${companyName}</p>${signatureHtml}
|
||||
<p style="font-size:12px;color:#999999;margin:5px 0;">© ${year} ${companyName}. All rights reserved.</p>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -736,6 +878,28 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
}
|
||||
|
||||
// Send email using template
|
||||
/**
|
||||
* Resolve the signature and render its plain-text form for `language`.
|
||||
* Never throws — a footer must not be able to fail a send.
|
||||
*/
|
||||
async function buildSignatureTextFor(language) {
|
||||
try {
|
||||
const signature = await businessProfileService.getEmailSignature();
|
||||
if (!signature) return '';
|
||||
let brandingCompanyName = 'PicPeak';
|
||||
try {
|
||||
const row = await db('app_settings').where('setting_key', 'branding_company_name').first();
|
||||
if (row && row.setting_value) {
|
||||
try { brandingCompanyName = JSON.parse(row.setting_value); } catch (_) { brandingCompanyName = row.setting_value; }
|
||||
}
|
||||
} catch (_) { /* fall back to the default name */ }
|
||||
return renderEmailSignatureText(signature, { brandingCompanyName, language });
|
||||
} catch (error) {
|
||||
logger.warn('Could not render the plain-text email signature', { error: error.message });
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async function sendTemplateEmail(to, templateKey, variables) {
|
||||
try {
|
||||
// Webhook transport (#1225) replaces SMTP entirely when configured, so an
|
||||
@@ -805,7 +969,12 @@ async function sendTemplateEmail(to, templateKey, variables) {
|
||||
cc: ccList,
|
||||
subject: subject,
|
||||
html: htmlBody,
|
||||
text: textBody || htmlToText(htmlBody),
|
||||
// When the template supplies its own body_text the text part is not
|
||||
// derived from the wrapped HTML, so the signature has to be appended
|
||||
// here or the text/plain alternative silently omits it (#1264 review).
|
||||
text: textBody
|
||||
? textBody + await buildSignatureTextFor(language)
|
||||
: htmlToText(htmlBody),
|
||||
attachments,
|
||||
};
|
||||
const info = viaWebhook
|
||||
@@ -1274,6 +1443,8 @@ module.exports = {
|
||||
stopEmailQueueProcessor,
|
||||
testEmailConnection,
|
||||
wrapEmailHtml,
|
||||
renderEmailSignatureText,
|
||||
buildSignatureTextFor,
|
||||
safeTemplateReplace,
|
||||
getSupportEmail,
|
||||
htmlToText
|
||||
|
||||
@@ -85,7 +85,10 @@ async function sendRecoveryEmail(toEmail, code, eventName = 'your gallery') {
|
||||
to: toEmail,
|
||||
subject,
|
||||
html: styledHtml,
|
||||
text: `Your verification code is ${code}. It expires in 15 minutes.`,
|
||||
// wrapEmailHtml gives the HTML part the signature; the text
|
||||
// alternative needs it appended explicitly (#1264 review).
|
||||
text: `Your verification code is ${code}. It expires in 15 minutes.`
|
||||
+ await require('./emailProcessor').buildSignatureTextFor('en'),
|
||||
};
|
||||
if (viaWebhook) {
|
||||
await emailWebhookTransport.send(mail);
|
||||
|
||||
Reference in New Issue
Block a user