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:
Paul Nothaft
2026-09-04 14:24:02 +02:00
committed by GitHub
parent 90da797e3f
commit b6e40b9a2a
14 changed files with 1155 additions and 16 deletions
@@ -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('&lt;script&gt;alert(1)&lt;/script&gt;');
expect(html).toContain('&lt;img src=x onerror=alert(1)&gt;');
expect(html).toContain('Rue &quot;des&quot; Fleurs &amp; 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(/&middot;\s*&middot;/);
});
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('&middot;');
expect(text).not.toContain('&amp;');
});
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('&middot;');
// 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)) {
+22 -4
View File
@@ -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');
+116 -4
View File
@@ -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,
+173 -2
View File
@@ -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 `&middot;`: the plain-text part of every mail
// is derived from this HTML by htmlToText, which decodes only the five
// core entities — an `&middot;` 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
+4 -1
View File
@@ -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);
+17 -2
View File
@@ -3855,7 +3855,10 @@
"requiredFields": "Host, Port und Benutzername sind erforderlich.",
"testOk": "Verbunden mit {{folder}} — {{messages}} Nachrichten, {{unseen}} ungelesen.",
"testFailed": "Verbindung fehlgeschlagen."
}
},
"signatureOn": "Fusszeilen-Signatur ist an — Ihre Geschäftsadresse wird an automatische E-Mails angehängt. Antworten, die Sie unter Nachrichten schreiben, werden unverändert gesendet.",
"signatureOff": "Fusszeilen-Signatur ist aus — E-Mails zeigen nur Logo und Firmenname.",
"signatureEdit": "Im Geschäftsprofil bearbeiten"
},
"cms": {
"title": "CMS-Seiten",
@@ -6113,7 +6116,19 @@
"logoUploadedToast": "PDF-Logo hochgeladen.",
"logoConfirmClear": "PDF-Logo entfernen? Der Renderer greift dann auf das Branding-Logo zurück, sofern eines hinterlegt ist.",
"logoClearedToast": "PDF-Logo entfernt.",
"bankUpdatedToast": "Bankverbindung aktualisiert."
"bankUpdatedToast": "Bankverbindung aktualisiert.",
"emailSignature": {
"title": "E-Mail-Signatur",
"subtitle": "Hängt Ihre Firmendaten an die Fusszeile jeder E-Mail an, die diese Installation versendet. Wird aus den Adress- und Kontaktfeldern oben gebildet — nichts wird doppelt erfasst.",
"toggle": "Signatur in E-Mail-Fusszeilen anzeigen",
"toggleHelp": "Wenn aus, behalten E-Mails die schlichte Fusszeile mit Logo und Firmenname. Gilt für jede automatische E-Mail — Galerie-Benachrichtigungen, Offerten, Rechnungen, Erinnerungen — sowie für Testversand und Vorschau. Antworten, die Sie selbst unter Nachrichten schreiben, werden unverändert gesendet und erhalten keine Signatur.",
"extra": "Rechtliche Zeile (optional)",
"extraPlaceholder": "Handelsregister Vaduz FL-0002.123.456-7",
"extraHelp": "Registernummer, Haftungsausschluss oder jede Zeile ohne eigenes Feld. Reiner Text, maximal 500 Zeichen.",
"previewTitle": "Vorschau der Fusszeile",
"previewOff": "Signatur ist aus — E-Mails zeigen nur Logo und Firmenname.",
"previewEmpty": "Signatur ist an, aber alle Felder oben sind leer — es wird nichts an die Fusszeile angehängt."
}
},
"crmSettings": {
"savedToast": "CRM-Einstellungen gespeichert.",
+17 -2
View File
@@ -3368,7 +3368,10 @@
"requiredFields": "Host, port and username are required.",
"testOk": "Connected to {{folder}} — {{messages}} messages, {{unseen}} unread.",
"testFailed": "Connection failed."
}
},
"signatureOn": "Footer signature is on — your business address is appended to automatic emails. Replies you write in Messages are sent as typed.",
"signatureOff": "Footer signature is off — emails show the logo and company name only.",
"signatureEdit": "Edit in Business profile"
},
"cms": {
"title": "CMS Pages",
@@ -6112,7 +6115,19 @@
"logoUploadedToast": "PDF logo uploaded.",
"logoConfirmClear": "Remove the PDF logo? The renderer will fall back to the Branding logo if one is set.",
"logoClearedToast": "PDF logo removed.",
"bankUpdatedToast": "Bank account updated."
"bankUpdatedToast": "Bank account updated.",
"emailSignature": {
"title": "Email signature",
"subtitle": "Append your company details to the footer of every email this installation sends. Built from the address and contact fields above — nothing is duplicated.",
"toggle": "Show signature in email footers",
"toggleHelp": "When off, emails keep the plain logo + company name footer. Applies to every automatic email — gallery notices, quotes, invoices, reminders — plus test sends and previews. Replies you write yourself in Messages are sent as typed and do not get the signature.",
"extra": "Legal line (optional)",
"extraPlaceholder": "Handelsregister Vaduz FL-0002.123.456-7",
"extraHelp": "Registration number, disclaimer or any line with no field of its own. Plain text, up to 500 characters.",
"previewTitle": "Footer preview",
"previewOff": "Signature is off — emails show the logo and company name only.",
"previewEmpty": "Signature is on but every field above is blank — nothing will be added to the footer."
}
},
"crmSettings": {
"savedToast": "CRM settings saved.",
@@ -23,10 +23,12 @@ import { ReceivedEmailsPanel } from '../../components/admin/ReceivedEmailsPanel'
import { IncomingMailConfigCard } from '../../components/admin/IncomingMailConfigCard';
import { CustomerMailboxCard } from '../../components/admin/CustomerMailboxCard';
import { Palette, RefreshCw, Info } from 'lucide-react';
import { Link } from 'react-router-dom';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useModal, useMutationWithToast } from '../../hooks';
import { emailService, type EmailConfig, type EmailTemplate, type EmailTemplateTranslation } from '../../services/email.service';
import { settingsService } from '../../services/settings.service';
import { businessProfileService } from '../../services/businessProfile.service';
import { useTranslation } from 'react-i18next';
import { SUPPORTED_LANGUAGES } from "../../components/common/LanguageSelector.tsx";
import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext';
@@ -210,6 +212,27 @@ export const EmailConfigPage: React.FC = () => {
tls_reject_unauthorized: true
});
// Migration 198 — whether the global footer signature is on. Read-only
// here; the toggle itself lives on Settings → Business profile.
//
// This tab is reachable with `email.view`, but GET /admin/business-profile
// requires `settings.view` / `settings.banking`. An email-only role gets a
// 403, and reporting that as "signature is off" would be stating something
// false about a mail they are about to send — so an unreadable profile
// renders nothing at all rather than a guess (#1264 review).
const { data: businessProfile, isError, isPending } = useQuery({
queryKey: ['business-profile'],
queryFn: () => businessProfileService.get(),
enabled: activeTab === 'smtp',
retry: false,
});
// Pending counts as unknown too. The other queries on this tab are often
// cached and paint first, so `?? false` announced "signature is off" for
// as long as this request was in flight — a wrong statement about a mail
// the admin is about to send, not merely a slow one.
const signatureUnknown = isError || isPending || !businessProfile;
const signatureEnabled = businessProfile?.profile?.emailSignatureEnabled ?? false;
// Fetch SMTP config
const { isLoading: configLoading } = useQuery({
queryKey: ['email-config'],
@@ -536,6 +559,24 @@ export const EmailConfigPage: React.FC = () => {
{/* SMTP Settings Tab */}
{activeTab === 'smtp' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* The footer signature (migration 198) is applied by the email
wrapper to every send from this page, but it's configured on
the Business profile — point at it from where the mail is set
up rather than making the operator hunt for it. */}
{!signatureUnknown && (
<div className="lg:col-span-2 flex items-start gap-2 rounded-md border border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800/60 p-3 text-sm text-neutral-600 dark:text-neutral-400">
<Info className="w-4 h-4 mt-0.5 shrink-0" />
<span>
{signatureEnabled
? t('email.signatureOn', 'Footer signature is on — your business address is appended to automatic emails. Replies you write in Messages are sent as typed.')
: t('email.signatureOff', 'Footer signature is off — emails show the logo and company name only.')}
{' '}
<Link to="/admin/settings?tab=businessProfile" className="underline hover:no-underline" style={{ color: 'var(--color-accent)' }}>
{t('email.signatureEdit', 'Edit in Business profile')}
</Link>
</span>
</div>
)}
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('email.smtpConfiguration')}</h2>
@@ -8,7 +8,7 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus, Trash2, Star, Pencil, Save, Clock, Copy } from 'lucide-react';
import { Plus, Trash2, Star, Pencil, Save, Clock, Copy, Mail } from 'lucide-react';
import {
businessProfileService,
type BusinessProfile,
@@ -305,6 +305,58 @@ export const SettingsBusinessProfilePage: React.FC = () => {
</div>
</Card>
{/* Global email footer signature (migration 198, issue #1264).
Rendered by wrapEmailHtml only, so flipping this toggle changes
the footer of EVERY outgoing mail transactional, preview, test
and manual without any per-template edit. Every value except
the legal line below comes from the fields already on this page. */}
<Card>
<div className="flex items-center gap-2 mb-1">
<Mail className="w-5 h-5 text-neutral-500" />
<h3 className="font-semibold text-neutral-900 dark:text-neutral-100">
{t('businessProfile.emailSignature.title', 'Email signature')}
</h3>
</div>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{t('businessProfile.emailSignature.subtitle',
'Append your company details to the footer of every email this installation sends. Built from the address and contact fields above — nothing is duplicated.')}
</p>
<PdfToggleRow
label={t('businessProfile.emailSignature.toggle', 'Show signature in email footers') as string}
description={t('businessProfile.emailSignature.toggleHelp',
'When off, emails keep the plain logo + company name footer. Applies to every automatic email — gallery notices, quotes, invoices, reminders — plus test sends and previews. Replies you write yourself in Messages are sent as typed and do not get the signature.') as string}
enabled={profile.emailSignatureEnabled}
onChange={(v) => setProfile({ ...profile, emailSignatureEnabled: v })}
/>
<div className="mt-4">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('businessProfile.emailSignature.extra', 'Legal line (optional)')}
</label>
<textarea
rows={3}
maxLength={500}
value={profile.emailSignatureExtra}
onChange={(e) => setProfile({ ...profile, emailSignatureExtra: e.target.value })}
placeholder={t('businessProfile.emailSignature.extraPlaceholder',
'Handelsregister Vaduz FL-0002.123.456-7') as string}
className="w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-neutral-900 dark:text-neutral-100"
/>
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
{t('businessProfile.emailSignature.extraHelp',
'Registration number, disclaimer or any line with no field of its own. Plain text, up to 500 characters.')}
</p>
</div>
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
<p className="text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-2 uppercase tracking-wide">
{t('businessProfile.emailSignature.previewTitle', 'Footer preview')}
</p>
<EmailSignaturePreview profile={profile} />
</div>
</Card>
{/* Disclaimer banner for QR-bill / IBAN data. picpeak renders
what the operator types it cannot validate IBAN/BIC, QR-IID
or scan-compatibility with any specific bank's e-banking app.
@@ -361,6 +413,64 @@ const PdfToggleRow: React.FC<PdfToggleRowProps> = ({ label, description, enabled
</label>
);
/**
* Read-only render of the email footer signature (migration 198).
*
* Deliberately mirrors `renderEmailSignature` in the backend's
* emailProcessor: same field order, same middle-dot separators, same
* omit-when-empty rule. It is a preview, not a second renderer the mail
* itself is always built server-side.
*/
const EmailSignaturePreview: React.FC<{ profile: BusinessProfile }> = ({ profile }) => {
const { t, i18n } = useTranslation();
if (!profile.emailSignatureEnabled) {
return (
<p className="text-sm text-neutral-500 dark:text-neutral-400 italic">
{t('businessProfile.emailSignature.previewOff', 'Signature is off — emails show the logo and company name only.')}
</p>
);
}
// "LI-9494 Schaan / Liechtenstein" — the same shape the PDF issuer
// block uses, assembled identically on the backend.
const cc = profile.countryCode ? profile.countryCode.toUpperCase() : '';
const pc = profile.postalCode || '';
const left = [cc && pc ? `${cc}-${pc}` : (pc || cc), profile.city || ''].filter(Boolean).join(' ');
const cityCountry = [left, profile.countryName || ''].filter(Boolean).join(' / ');
const addressLines = [profile.addressLine1, profile.addressLine2, cityCountry]
.map((l) => (l || '').trim())
.filter(Boolean);
const contacts = [profile.phone, profile.mobile, profile.email, profile.website]
.map((c) => (c || '').trim())
.filter(Boolean);
const vatLabel = i18n.language?.startsWith('de') ? 'USt-IdNr.' : 'VAT ID';
const isEmpty = !profile.companyName && !addressLines.length && !contacts.length
&& !profile.vatId && !profile.emailSignatureExtra;
if (isEmpty) {
return (
<p className="text-sm text-amber-700 dark:text-amber-400">
{t('businessProfile.emailSignature.previewEmpty',
'Signature is on but every field above is blank — nothing will be added to the footer.')}
</p>
);
}
return (
<div data-testid="email-signature-preview" className="rounded-md bg-neutral-50 dark:bg-neutral-800/60 p-3 text-center text-xs text-neutral-600 dark:text-neutral-400 space-y-1">
{profile.companyName && <p>{profile.companyName}</p>}
{addressLines.length > 0 && <p>{addressLines.join(' \u00b7 ')}</p>}
{contacts.length > 0 && <p>{contacts.join(' \u00b7 ')}</p>}
{profile.vatId && <p>{vatLabel}: {profile.vatId}</p>}
{profile.emailSignatureExtra && (
<p className="text-[11px] whitespace-pre-line">{profile.emailSignatureExtra}</p>
)}
</div>
);
};
/**
* Per-weekday business-hours editor (migration 114). Google-style: each
* weekday holds zero or more {start,end} blocks, so a day can be closed
@@ -0,0 +1,164 @@
/**
* Settings Business profile email signature card (migration 198, #1264).
*
* The preview here mirrors the backend's `renderEmailSignature`, so the
* cases worth pinning are the ones where the two could silently drift:
* the "LI-9494 Schaan / Liechtenstein" city line, the middle-dot joins,
* and the omit-when-empty rule. Plus the two states an operator can land
* in and misread signature off, and signature on with a blank profile.
*/
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { BusinessProfile } from '../../../../services/businessProfile.service';
vi.mock('react-i18next', async () => {
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next');
return {
...actual,
useTranslation: () => ({
t: (k: string, fb?: unknown) => (typeof fb === 'string' ? fb : k),
i18n: { language: 'en' },
}),
};
});
vi.mock('react-toastify', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
const baseProfile: BusinessProfile = {
id: 1,
companyName: 'Müller Fotografie GmbH',
addressLine1: 'Bahnhofstrasse 1',
addressLine2: '',
postalCode: '9494',
city: 'Schaan',
state: '',
countryCode: 'LI',
countryName: 'Liechtenstein',
phone: '+41 79 123 45 67',
mobile: '',
email: '[email protected]',
website: 'example.com',
vatId: 'CHE-123.456.789',
taxId: '',
vatLabel: 'MwSt.',
vatRateDefault: null,
defaultHourlyRateMinor: null,
defaultCurrency: 'CHF',
defaultLocale: 'de',
defaultQrFormat: 'none',
footerLine: '',
logoPath: '',
pdfFontTtfPath: '',
pdfFontFamily: null,
pdfShowLogo: true,
pdfShowCompanyName: true,
pdfCompanyNameInline: false,
pdfLogoHeight: 56,
pdfFoldingMarks: 'none',
pdfQuoteShowNetDays: false,
pdfQuoteShowSkonto: false,
timezone: null,
businessHours: null,
scheduledEmailFloorEnabled: true,
emailSignatureEnabled: true,
emailSignatureExtra: 'Handelsregister Vaduz',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
};
let profileFixture: BusinessProfile = baseProfile;
vi.mock('../../../../services/businessProfile.service', () => ({
businessProfileService: {
get: vi.fn(async () => ({ profile: profileFixture, bankAccounts: [] })),
update: vi.fn(async () => ({ profile: profileFixture, bankAccounts: [] })),
listBankAccounts: vi.fn(async () => ({ bankAccounts: [] })),
createBankAccount: vi.fn(),
updateBankAccount: vi.fn(),
deleteBankAccount: vi.fn(),
uploadLogo: vi.fn(),
clearLogo: vi.fn(),
},
}));
import { SettingsBusinessProfilePage } from '../SettingsBusinessProfilePage';
function renderPage() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<SettingsBusinessProfilePage />
</QueryClientProvider>
);
}
describe('email signature card', () => {
beforeEach(() => {
profileFixture = { ...baseProfile };
});
it('renders the signature preview from the address fields above', async () => {
renderPage();
// Scoped to the preview block — the legal line also lives in the
// textarea above it, and the company name in the form fields.
const preview = within(await screen.findByTestId('email-signature-preview'));
expect(preview.getByText('Müller Fotografie GmbH')).toBeInTheDocument();
// City line uses the PDF issuer shape, not a raw "9494 Schaan".
expect(preview.getByText('Bahnhofstrasse 1 · LI-9494 Schaan / Liechtenstein')).toBeInTheDocument();
// Empty mobile is omitted rather than producing a doubled separator.
expect(preview.getByText('+41 79 123 45 67 · [email protected] · example.com')).toBeInTheDocument();
expect(preview.getByText('VAT ID: CHE-123.456.789')).toBeInTheDocument();
expect(preview.getByText('Handelsregister Vaduz')).toBeInTheDocument();
});
it('explains the off state instead of showing a stale preview', async () => {
profileFixture = { ...baseProfile, emailSignatureEnabled: false };
renderPage();
expect(
await screen.findByText('Signature is off — emails show the logo and company name only.')
).toBeInTheDocument();
expect(screen.queryByTestId('email-signature-preview')).not.toBeInTheDocument();
});
it('warns when the signature is on but every field is blank', async () => {
profileFixture = {
...baseProfile,
companyName: '', addressLine1: '', addressLine2: '', postalCode: '', city: '',
countryCode: '', countryName: '', phone: '', mobile: '', email: '', website: '',
vatId: '', emailSignatureExtra: '',
};
renderPage();
expect(
await screen.findByText(
'Signature is on but every field above is blank — nothing will be added to the footer.'
)
).toBeInTheDocument();
});
it('toggling off updates the preview without a save', async () => {
renderPage();
await screen.findByText('Footer preview');
await userEvent.click(screen.getByRole('switch', { name: /Show signature in email footers/i }));
expect(
screen.getByText('Signature is off — emails show the logo and company name only.')
).toBeInTheDocument();
});
it('caps the legal line at 500 characters', async () => {
renderPage();
const textarea = (await screen.findByPlaceholderText(
'Handelsregister Vaduz FL-0002.123.456-7'
)) as HTMLTextAreaElement;
expect(textarea.maxLength).toBe(500);
});
});
@@ -94,6 +94,16 @@ export interface BusinessProfile {
* (migration 114). Defaults true. When off, scheduled emails send at
* their requested instant regardless of `businessHours`. */
scheduledEmailFloorEnabled: boolean;
/** Global email footer signature master switch (migration 198). When
* on, `wrapEmailHtml` appends the issuer block below address,
* contacts, VAT id and `emailSignatureExtra` to the footer of EVERY
* outgoing mail: transactional, preview, test and manual alike.
* Defaults false so an upgraded install's footer is unchanged. */
emailSignatureEnabled: boolean;
/** Free-text legal line under the signature (Handelsregister /
* registration number / disclaimer). Plain text, max 500 chars
* rendered escaped with newlines turned into <br>. Migration 198. */
emailSignatureExtra: string;
createdAt: string;
updatedAt: string;
}