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;
}