feat(email): global signature footer from the business profile (#1264)
The business profile already carried the operator's full issuer block —
address, phone, email, website, VAT id — but none of it reached an email.
Those columns only fed the quote/invoice PDF renderer, so every outgoing
mail footer was the fixed logo + company name + copyright line.
The signature is rendered by wrapEmailHtml and nowhere else, so no
template, no per-type send path and no queue row needed a change. Two new
columns on business_profile (migration 198) carry the toggle and one
free-text legal line; everything else is read from the address fields the
operator already maintains.
Default off, with a test pinning that the disabled path is byte-identical
to a no-profile install.
Includes three rounds of external review fixes: the plain-text MIME part
also carries the signature; string booleans ('false'/'0') no longer
invert the toggle; the status line stays silent rather than asserting
"off" while unauthorised or loading; and the preview's Text tab mirrors
the send path's htmlToText fallback.
Manual Messages replies deliberately keep no signature — they bypass the
wrapper by design — and the UI copy names that exception.
Closes #1264 (Part A)
This commit is contained in:
@@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* PUT /api/admin/business-profile — email signature fields (migration 198).
|
||||||
|
*
|
||||||
|
* The two new columns are boolean + free text, which is exactly the shape
|
||||||
|
* that goes wrong quietly: `optional({ values: 'falsy' })` on the boolean
|
||||||
|
* would silently drop `false`, leaving the admin unable to switch the
|
||||||
|
* signature back off. The round-trip below is what pins that.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-bpsig-test-'));
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
|
||||||
|
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||||
|
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
|
||||||
|
process.env.JWT_SECRET = process.env.JWT_SECRET || 'bpsig-route-test-secret';
|
||||||
|
|
||||||
|
const request = require('supertest');
|
||||||
|
const {
|
||||||
|
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
|
||||||
|
} = require('./helpers/crmDb');
|
||||||
|
|
||||||
|
describe('business profile — email signature round-trip', () => {
|
||||||
|
let db;
|
||||||
|
let cleanup;
|
||||||
|
let app;
|
||||||
|
let token;
|
||||||
|
|
||||||
|
const put = (payload) => request(app)
|
||||||
|
.put('/api/admin/business-profile')
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.send(payload);
|
||||||
|
|
||||||
|
const get = () => request(app)
|
||||||
|
.get('/api/admin/business-profile')
|
||||||
|
.set('Authorization', `Bearer ${token}`);
|
||||||
|
|
||||||
|
// GET returns the snapshot at the top level; PUT wraps it in
|
||||||
|
// successResponse's `data` envelope. Read either.
|
||||||
|
const profileOf = (res) => (res.body.data || res.body).profile;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
({ db, cleanup } = await bootCrmDb());
|
||||||
|
const { adminId } = await seedMinimal(db);
|
||||||
|
await assignAdminRole(db, adminId, 'super_admin');
|
||||||
|
token = mintAdminToken(adminId);
|
||||||
|
app = buildRouteApp('/api/admin/business-profile', require('../../src/routes/adminBusinessProfile'));
|
||||||
|
}, 120000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (cleanup) await cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to off with an empty legal line', async () => {
|
||||||
|
const res = await get();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const profile = profileOf(res);
|
||||||
|
expect(profile.emailSignatureEnabled).toBe(false);
|
||||||
|
expect(profile.emailSignatureExtra).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists the toggle and the legal line', async () => {
|
||||||
|
const res = await put({
|
||||||
|
emailSignatureEnabled: true,
|
||||||
|
emailSignatureExtra: 'Handelsregister Vaduz FL-0002.123.456-7',
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const profile = profileOf(await get());
|
||||||
|
expect(profile.emailSignatureEnabled).toBe(true);
|
||||||
|
expect(profile.emailSignatureExtra).toBe('Handelsregister Vaduz FL-0002.123.456-7');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches the toggle back off — `false` is not dropped as falsy', async () => {
|
||||||
|
await put({ emailSignatureEnabled: true });
|
||||||
|
const res = await put({ emailSignatureEnabled: false });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
expect(profileOf(await get()).emailSignatureEnabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the legal line with an empty string', async () => {
|
||||||
|
await put({ emailSignatureExtra: 'something' });
|
||||||
|
const res = await put({ emailSignatureExtra: '' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
expect(profileOf(await get()).emailSignatureExtra).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims surrounding whitespace off the legal line', async () => {
|
||||||
|
await put({ emailSignatureExtra: ' Registered in Vaduz ' });
|
||||||
|
expect(profileOf(await get()).emailSignatureExtra).toBe('Registered in Vaduz');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Codex review: express-validator's isBoolean() accepts the STRINGS
|
||||||
|
// 'false' and '0', and Boolean('false') is true — so a form-encoded client
|
||||||
|
// trying to switch the signature OFF switched it on instead.
|
||||||
|
it.each([['false'], ['0']])('treats the string %s as off, not on', async (value) => {
|
||||||
|
await put({ emailSignatureEnabled: true });
|
||||||
|
expect(profileOf(await get()).emailSignatureEnabled).toBe(true);
|
||||||
|
|
||||||
|
const res = await put({ emailSignatureEnabled: value });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(profileOf(await get()).emailSignatureEnabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([['true'], ['1']])('treats the string %s as on', async (value) => {
|
||||||
|
await put({ emailSignatureEnabled: false });
|
||||||
|
const res = await put({ emailSignatureEnabled: value });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(profileOf(await get()).emailSignatureEnabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-boolean toggle and a legal line over 500 chars', async () => {
|
||||||
|
expect((await put({ emailSignatureEnabled: 'yes please' })).status).toBe(400);
|
||||||
|
expect((await put({ emailSignatureExtra: 'x'.repeat(501) })).status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let an unmapped column ride in on the payload', async () => {
|
||||||
|
// ALLOWED_PROFILE_FIELDS is the whitelist; the route's camel→snake map
|
||||||
|
// is the second gate. Neither should pass a raw snake_case key through.
|
||||||
|
const before = await db('business_profile').where({ id: 1 }).first();
|
||||||
|
await put({ email_signature_enabled: true, id: 999 });
|
||||||
|
const after = await db('business_profile').where({ id: 1 }).first();
|
||||||
|
|
||||||
|
expect(after.id).toBe(before.id);
|
||||||
|
expect(after.email_signature_enabled).toBe(before.email_signature_enabled);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalidates the wrapper signature cache on write', async () => {
|
||||||
|
const { wrapEmailHtml } = require('../../src/services/emailProcessor');
|
||||||
|
|
||||||
|
await put({ emailSignatureEnabled: false });
|
||||||
|
expect(await wrapEmailHtml('<p>x</p>', 'S')).not.toContain('Bahnhofstrasse 9');
|
||||||
|
|
||||||
|
// Same request cycle, well inside the 60 s memo window: the PUT must
|
||||||
|
// clear the cache or the operator sees a stale footer for a minute.
|
||||||
|
await put({ emailSignatureEnabled: true, addressLine1: 'Bahnhofstrasse 9' });
|
||||||
|
expect(await wrapEmailHtml('<p>x</p>', 'S')).toContain('Bahnhofstrasse 9');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
/**
|
||||||
|
* Global email footer signature (migration 198, issue #1264).
|
||||||
|
*
|
||||||
|
* The signature is rendered by `wrapEmailHtml` and nowhere else, which is
|
||||||
|
* the whole point of the design: every template, preview, test mail and
|
||||||
|
* manual send passes through that one wrapper, so none of them needed a
|
||||||
|
* per-template change. These tests pin that contract at the wrapper.
|
||||||
|
*
|
||||||
|
* The load-bearing case is the DISABLED one — an upgraded install must keep
|
||||||
|
* a byte-identical footer until an admin opts in.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { bootCrmDb } = require('./helpers/crmDb');
|
||||||
|
|
||||||
|
describe('wrapEmailHtml — business-profile signature footer', () => {
|
||||||
|
let db;
|
||||||
|
let cleanup;
|
||||||
|
let wrapEmailHtml;
|
||||||
|
let renderEmailSignatureText;
|
||||||
|
let businessProfileService;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
({ db, cleanup } = await bootCrmDb());
|
||||||
|
({ wrapEmailHtml, renderEmailSignatureText } = require('../../src/services/emailProcessor'));
|
||||||
|
businessProfileService = require('../../src/services/businessProfileService');
|
||||||
|
}, 120000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (cleanup) await cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await db('business_profile').where({ id: 1 }).update({
|
||||||
|
company_name: null,
|
||||||
|
address_line1: null,
|
||||||
|
address_line2: null,
|
||||||
|
postal_code: null,
|
||||||
|
city: null,
|
||||||
|
country_code: null,
|
||||||
|
country_name: null,
|
||||||
|
phone: null,
|
||||||
|
mobile: null,
|
||||||
|
email: null,
|
||||||
|
website: null,
|
||||||
|
vat_id: null,
|
||||||
|
email_signature_enabled: false,
|
||||||
|
email_signature_extra: null,
|
||||||
|
});
|
||||||
|
businessProfileService.invalidateEmailSignatureCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
const fullProfile = {
|
||||||
|
company_name: 'Müller Fotografie GmbH',
|
||||||
|
address_line1: 'Bahnhofstrasse 1',
|
||||||
|
address_line2: 'Postfach 42',
|
||||||
|
postal_code: '9494',
|
||||||
|
city: 'Schaan',
|
||||||
|
country_code: 'li',
|
||||||
|
country_name: 'Liechtenstein',
|
||||||
|
phone: '+41 79 123 45 67',
|
||||||
|
mobile: '+41 78 000 11 22',
|
||||||
|
email: '[email protected]',
|
||||||
|
website: 'example.com',
|
||||||
|
vat_id: 'CHE-123.456.789',
|
||||||
|
email_signature_enabled: true,
|
||||||
|
email_signature_extra: 'Handelsregister Vaduz\nFL-0002.123.456-7',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function enable(overrides = {}) {
|
||||||
|
await db('business_profile').where({ id: 1 }).update({ ...fullProfile, ...overrides });
|
||||||
|
businessProfileService.invalidateEmailSignatureCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders nothing extra when the toggle is off', async () => {
|
||||||
|
// Profile fully populated, signature switched OFF: the operator's
|
||||||
|
// address must not leak into mail just because they filled in the
|
||||||
|
// invoice issuer block.
|
||||||
|
await enable({ email_signature_enabled: false });
|
||||||
|
|
||||||
|
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||||
|
|
||||||
|
expect(html).not.toContain('Bahnhofstrasse 1');
|
||||||
|
expect(html).not.toContain('[email protected]');
|
||||||
|
expect(html).not.toContain('CHE-123.456.789');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces a byte-identical footer to a no-profile install when disabled', async () => {
|
||||||
|
const withEmptyProfile = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||||
|
await enable({ email_signature_enabled: false });
|
||||||
|
const withDisabledSignature = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||||
|
|
||||||
|
expect(withDisabledSignature).toBe(withEmptyProfile);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders address, contacts, VAT id and the legal line when enabled', async () => {
|
||||||
|
await enable();
|
||||||
|
|
||||||
|
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||||
|
|
||||||
|
expect(html).toContain('Müller Fotografie GmbH');
|
||||||
|
expect(html).toContain('Bahnhofstrasse 1');
|
||||||
|
expect(html).toContain('Postfach 42');
|
||||||
|
// "LI-9494 Schaan / Liechtenstein" — same shape as the PDF issuer block.
|
||||||
|
expect(html).toContain('LI-9494 Schaan / Liechtenstein');
|
||||||
|
expect(html).toContain('VAT ID: CHE-123.456.789');
|
||||||
|
expect(html).toContain('Handelsregister Vaduz<br />FL-0002.123.456-7');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links phone, mobile, email and website with safe schemes', async () => {
|
||||||
|
await enable();
|
||||||
|
|
||||||
|
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||||
|
|
||||||
|
// Separators stripped from the tel: href, kept in the visible text.
|
||||||
|
expect(html).toContain('href="tel:+41791234567"');
|
||||||
|
expect(html).toContain('href="tel:+41780001122"');
|
||||||
|
expect(html).toContain('href="mailto:[email protected]"');
|
||||||
|
// A bare hostname is promoted to https:// rather than left relative.
|
||||||
|
expect(html).toContain('href="https://example.com"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps an already-absolute website URL as typed', async () => {
|
||||||
|
await enable({ website: 'http://legacy.example.org/studio' });
|
||||||
|
|
||||||
|
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||||
|
|
||||||
|
expect(html).toContain('href="http://legacy.example.org/studio"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('neutralises a javascript: website into an inert https URL', async () => {
|
||||||
|
await enable({ website: 'javascript:alert(1)' });
|
||||||
|
|
||||||
|
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||||
|
|
||||||
|
expect(html).not.toContain('href="javascript:');
|
||||||
|
expect(html).toContain('href="https://javascript:alert(1)"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('HTML-escapes every signature field', async () => {
|
||||||
|
await enable({
|
||||||
|
company_name: '<script>alert(1)</script>',
|
||||||
|
address_line1: 'Rue "des" Fleurs & Co',
|
||||||
|
vat_id: '<img src=x onerror=alert(1)>',
|
||||||
|
email_signature_extra: '</p><script>alert(2)</script>',
|
||||||
|
});
|
||||||
|
|
||||||
|
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||||
|
|
||||||
|
// Escaped, so the markup is inert text — the tags never open.
|
||||||
|
expect(html).not.toContain('<script>alert(1)</script>');
|
||||||
|
expect(html).not.toContain('<script>alert(2)</script>');
|
||||||
|
expect(html).not.toContain('<img src=x');
|
||||||
|
expect(html).toContain('<script>alert(1)</script>');
|
||||||
|
expect(html).toContain('<img src=x onerror=alert(1)>');
|
||||||
|
expect(html).toContain('Rue "des" Fleurs & Co');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not repeat the branding company name', async () => {
|
||||||
|
// Footer already prints the branding name; the profile name is only
|
||||||
|
// added when the operator gave a different legal name.
|
||||||
|
await db('app_settings')
|
||||||
|
.insert({ setting_key: 'branding_company_name', setting_value: JSON.stringify('Müller Fotografie GmbH'), setting_type: 'branding' })
|
||||||
|
.onConflict('setting_key')
|
||||||
|
.merge();
|
||||||
|
await enable();
|
||||||
|
|
||||||
|
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||||
|
|
||||||
|
expect(html.match(/Müller Fotografie GmbH/g).length).toBe(
|
||||||
|
// header alt, footer alt, footer name line, copyright line — the
|
||||||
|
// signature must not add a fifth.
|
||||||
|
(await wrapEmailHtml('<p>Body</p>', 'Subject', 'en')).match(/Müller Fotografie GmbH/g).length
|
||||||
|
);
|
||||||
|
expect(html.match(/Müller Fotografie GmbH/g).length).toBe(4);
|
||||||
|
|
||||||
|
await db('app_settings').where({ setting_key: 'branding_company_name' }).del();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the German VAT label for a German mail', async () => {
|
||||||
|
await enable();
|
||||||
|
|
||||||
|
const de = await wrapEmailHtml('<p>Body</p>', 'Subject', 'de');
|
||||||
|
const en = await wrapEmailHtml('<p>Body</p>', 'Subject', 'en');
|
||||||
|
|
||||||
|
expect(de).toContain('USt-IdNr.: CHE-123.456.789');
|
||||||
|
expect(en).toContain('VAT ID: CHE-123.456.789');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits empty fields instead of rendering blank rows', async () => {
|
||||||
|
await enable({
|
||||||
|
address_line2: null, mobile: null, website: null, vat_id: null, email_signature_extra: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||||
|
|
||||||
|
expect(html).toContain('[email protected]');
|
||||||
|
expect(html).not.toContain('VAT ID:');
|
||||||
|
expect(html).not.toMatch(/·\s*·/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders no signature block when enabled but the profile is blank', async () => {
|
||||||
|
await db('business_profile').where({ id: 1 }).update({ email_signature_enabled: true });
|
||||||
|
businessProfileService.invalidateEmailSignatureCache();
|
||||||
|
|
||||||
|
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||||
|
|
||||||
|
// The signature <div> is the only element carrying this margin.
|
||||||
|
expect(html).not.toContain('<div style="margin:15px 0 5px;');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Codex review: sendTemplateEmail uses a template's own body_text when it
|
||||||
|
// has one — every seeded template does — so the text/plain alternative is
|
||||||
|
// NOT derived from the wrapped HTML and carried no signature at all.
|
||||||
|
describe('plain-text alternative', () => {
|
||||||
|
it('renders the signature as plain text', async () => {
|
||||||
|
await enable();
|
||||||
|
const signature = await businessProfileService.getEmailSignature();
|
||||||
|
|
||||||
|
const text = renderEmailSignatureText(signature, { brandingCompanyName: 'PicPeak', language: 'en' });
|
||||||
|
|
||||||
|
expect(text).toContain('Bahnhofstrasse 1');
|
||||||
|
expect(text).toContain('[email protected]');
|
||||||
|
expect(text).toContain('VAT ID: CHE-123.456.789');
|
||||||
|
expect(text).toContain('Handelsregister Vaduz');
|
||||||
|
// A separator, the text equivalent of the footer's top border.
|
||||||
|
expect(text).toMatch(/^\n\n--\n/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries no HTML markup or entities', async () => {
|
||||||
|
await enable();
|
||||||
|
const signature = await businessProfileService.getEmailSignature();
|
||||||
|
|
||||||
|
const text = renderEmailSignatureText(signature, { brandingCompanyName: 'PicPeak', language: 'en' });
|
||||||
|
|
||||||
|
expect(text).not.toContain('<');
|
||||||
|
expect(text).not.toContain('·');
|
||||||
|
expect(text).not.toContain('&');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is empty when the signature is disabled', () => {
|
||||||
|
expect(renderEmailSignatureText(null, {})).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the German VAT label for a German mail', async () => {
|
||||||
|
await enable();
|
||||||
|
const signature = await businessProfileService.getEmailSignature();
|
||||||
|
|
||||||
|
expect(renderEmailSignatureText(signature, { language: 'de' })).toContain('USt-IdNr.');
|
||||||
|
expect(renderEmailSignatureText(signature, { language: 'en' })).toContain('VAT ID');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not repeat the branding company name', async () => {
|
||||||
|
await enable();
|
||||||
|
const signature = await businessProfileService.getEmailSignature();
|
||||||
|
|
||||||
|
const text = renderEmailSignatureText(signature, {
|
||||||
|
brandingCompanyName: 'Müller Fotografie GmbH', language: 'en',
|
||||||
|
});
|
||||||
|
expect(text).not.toContain('Müller Fotografie GmbH');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the plain-text part free of signature markup', async () => {
|
||||||
|
const { htmlToText } = require('../../src/services/emailProcessor');
|
||||||
|
await enable();
|
||||||
|
|
||||||
|
const text = htmlToText(await wrapEmailHtml('<p>Body</p>', 'Subject'));
|
||||||
|
|
||||||
|
expect(text).toContain('Bahnhofstrasse 1');
|
||||||
|
expect(text).not.toContain('<p');
|
||||||
|
expect(text).not.toContain('style=');
|
||||||
|
expect(text).not.toContain('·');
|
||||||
|
// The separator survives as a real character, not an entity.
|
||||||
|
expect(text).toContain('Bahnhofstrasse 1 \u00b7 Postfach 42');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* `business_profile.email_signature_enabled` + `email_signature_extra` —
|
||||||
|
* the global email footer signature (issue #1264).
|
||||||
|
*
|
||||||
|
* The business profile already carries the full issuer block (address,
|
||||||
|
* phone, email, website, VAT id) but none of it ever reached an email:
|
||||||
|
* those columns only feed the quote/invoice PDF renderer and the public
|
||||||
|
* quote page. Every outgoing mail footer was the fixed logo + company
|
||||||
|
* name + copyright line built inside `wrapEmailHtml`.
|
||||||
|
*
|
||||||
|
* Rather than copy the address into a second place, the wrapper now reads
|
||||||
|
* this profile row. These two columns are the only new state: a master
|
||||||
|
* toggle and one free-text line for the legal notice (Handelsregister /
|
||||||
|
* registration number / disclaimer) that has no dedicated column.
|
||||||
|
*
|
||||||
|
* Default FALSE on purpose: an upgraded install keeps a byte-identical
|
||||||
|
* footer until an admin turns the signature on.
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = async function (knex) {
|
||||||
|
if (!(await knex.schema.hasTable('business_profile'))) return;
|
||||||
|
|
||||||
|
if (!(await knex.schema.hasColumn('business_profile', 'email_signature_enabled'))) {
|
||||||
|
await knex.schema.alterTable('business_profile', (table) => {
|
||||||
|
table.boolean('email_signature_enabled').notNullable().defaultTo(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!(await knex.schema.hasColumn('business_profile', 'email_signature_extra'))) {
|
||||||
|
await knex.schema.alterTable('business_profile', (table) => {
|
||||||
|
// Plain text, rendered escaped and <br>-separated. Not HTML.
|
||||||
|
table.text('email_signature_extra');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function (knex) {
|
||||||
|
if (!(await knex.schema.hasTable('business_profile'))) return;
|
||||||
|
|
||||||
|
for (const column of ['email_signature_enabled', 'email_signature_extra']) {
|
||||||
|
if (await knex.schema.hasColumn('business_profile', column)) {
|
||||||
|
await knex.schema.alterTable('business_profile', (table) => {
|
||||||
|
table.dropColumn(column);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -179,6 +179,12 @@ function transformProfile(p) {
|
|||||||
: (p.scheduled_email_floor_enabled === true
|
: (p.scheduled_email_floor_enabled === true
|
||||||
|| p.scheduled_email_floor_enabled === 1
|
|| p.scheduled_email_floor_enabled === 1
|
||||||
|| 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,
|
createdAt: p.created_at,
|
||||||
updatedAt: p.updated_at,
|
updatedAt: p.updated_at,
|
||||||
};
|
};
|
||||||
@@ -456,6 +462,11 @@ router.put(
|
|||||||
}),
|
}),
|
||||||
// Migration 114 — scheduled-email floor master switch.
|
// Migration 114 — scheduled-email floor master switch.
|
||||||
body('scheduledEmailFloorEnabled').optional().isBoolean(),
|
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) => {
|
handleAsync(async (req, res) => {
|
||||||
validateRequest(req);
|
validateRequest(req);
|
||||||
@@ -497,6 +508,9 @@ router.put(
|
|||||||
// Migration 114 — business hours + scheduled-email floor switch.
|
// Migration 114 — business hours + scheduled-email floor switch.
|
||||||
businessHours: 'business_hours',
|
businessHours: 'business_hours',
|
||||||
scheduledEmailFloorEnabled: 'scheduled_email_floor_enabled',
|
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)) {
|
for (const [api, db] of Object.entries(map)) {
|
||||||
if (Object.prototype.hasOwnProperty.call(req.body, api)) {
|
if (Object.prototype.hasOwnProperty.call(req.body, api)) {
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ const { requirePermission } = require('../middleware/permissions');
|
|||||||
// /email mount — the pre-existing config/queue/received endpoints stay ungated).
|
// /email mount — the pre-existing config/queue/received endpoints stay ungated).
|
||||||
const { requireFeatureFlag } = require('../middleware/requireFeatureFlag');
|
const { requireFeatureFlag } = require('../middleware/requireFeatureFlag');
|
||||||
const messagingGate = requireFeatureFlag('messaging');
|
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 emailWebhookTransport = require('../services/emailWebhookTransport');
|
||||||
|
const businessProfileService = require('../services/businessProfileService');
|
||||||
const { errorResponse, safeValidationErrors } = require('../utils/routeHelpers');
|
const { errorResponse, safeValidationErrors } = require('../utils/routeHelpers');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -494,7 +495,10 @@ router.post('/test', adminAuth, requirePermission('email.send'), async (req, res
|
|||||||
to: test_email,
|
to: test_email,
|
||||||
subject: webhookSubject,
|
subject: webhookSubject,
|
||||||
html: webhookHtml,
|
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) {
|
} catch (webhookError) {
|
||||||
// Handled here, not by the outer catch: that one maps ECONNREFUSED and
|
// 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,
|
subject,
|
||||||
html: wrappedHtml,
|
html: wrappedHtml,
|
||||||
text: 'Test Email Successful! Your email configuration is working correctly.'
|
text: 'Test Email Successful! Your email configuration is working correctly.'
|
||||||
|
+ await buildSignatureTextFor('en')
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ message: 'Test email sent successfully' });
|
res.json({ message: 'Test email sent successfully' });
|
||||||
@@ -1233,8 +1238,21 @@ router.post('/templates/:key/preview', adminAuth, requirePermission('email.view'
|
|||||||
res.json({
|
res.json({
|
||||||
subject,
|
subject,
|
||||||
body_html: wrappedHtml,
|
body_html: wrappedHtml,
|
||||||
body_text: textContent,
|
// The Text tab has to show what a text-only client will receive, which
|
||||||
language
|
// 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) {
|
} catch (error) {
|
||||||
errorResponse(res, error, 500, 'Failed to preview email template');
|
errorResponse(res, error, 500, 'Failed to preview email template');
|
||||||
|
|||||||
@@ -85,6 +85,11 @@ const ALLOWED_PROFILE_FIELDS = [
|
|||||||
'business_hours',
|
'business_hours',
|
||||||
// Master switch for the scheduled-email business-hours floor (mig 114).
|
// Master switch for the scheduled-email business-hours floor (mig 114).
|
||||||
'scheduled_email_floor_enabled',
|
'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 = [
|
const ALLOWED_BANK_FIELDS = [
|
||||||
@@ -125,6 +130,28 @@ function normaliseCountryCode(cc) {
|
|||||||
return String(cc).trim().toUpperCase().slice(0, 2);
|
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) {
|
function sanitiseProfilePayload(payload) {
|
||||||
const updates = pickFields(payload, ALLOWED_PROFILE_FIELDS);
|
const updates = pickFields(payload, ALLOWED_PROFILE_FIELDS);
|
||||||
|
|
||||||
@@ -142,7 +169,8 @@ function sanitiseProfilePayload(payload) {
|
|||||||
// when the admin pastes from a printed letterhead.
|
// when the admin pastes from a printed letterhead.
|
||||||
for (const field of ['company_name', 'address_line1', 'address_line2',
|
for (const field of ['company_name', 'address_line1', 'address_line2',
|
||||||
'city', 'state', 'country_name', 'phone', 'mobile', 'email', 'website',
|
'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') {
|
if (typeof updates[field] === 'string') {
|
||||||
updates[field] = updates[field].trim();
|
updates[field] = updates[field].trim();
|
||||||
}
|
}
|
||||||
@@ -154,7 +182,7 @@ function sanitiseProfilePayload(payload) {
|
|||||||
'pdf_quote_show_net_days', 'pdf_quote_show_skonto',
|
'pdf_quote_show_net_days', 'pdf_quote_show_skonto',
|
||||||
]) {
|
]) {
|
||||||
if (updates[field] !== undefined) {
|
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
|
// Folding-mark enum — whitelisted set. Garbage values fall back to
|
||||||
@@ -195,7 +223,12 @@ function sanitiseProfilePayload(payload) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (updates.scheduled_email_floor_enabled !== undefined) {
|
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;
|
return updates;
|
||||||
@@ -214,7 +247,7 @@ function sanitiseBankPayload(payload) {
|
|||||||
updates.currency = normaliseCurrency(updates.currency);
|
updates.currency = normaliseCurrency(updates.currency);
|
||||||
}
|
}
|
||||||
if (updates.is_default !== undefined) {
|
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']) {
|
for (const field of ['label', 'account_holder']) {
|
||||||
if (typeof updates[field] === 'string') {
|
if (typeof updates[field] === 'string') {
|
||||||
@@ -264,6 +297,10 @@ async function updateProfile(payload, adminId) {
|
|||||||
await db('business_profile').where({ id: 1 }).update(updates);
|
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', {
|
logger.info('Business profile updated', {
|
||||||
adminId,
|
adminId,
|
||||||
fields: Object.keys(updates).filter((k) => k !== 'updated_at'),
|
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 = {
|
module.exports = {
|
||||||
getProfile,
|
getProfile,
|
||||||
updateProfile,
|
updateProfile,
|
||||||
|
getEmailSignature,
|
||||||
|
invalidateEmailSignatureCache,
|
||||||
createBankAccount,
|
createBankAccount,
|
||||||
updateBankAccount,
|
updateBankAccount,
|
||||||
deleteBankAccount,
|
deleteBankAccount,
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ const {
|
|||||||
} = require('../utils/businessHours');
|
} = require('../utils/businessHours');
|
||||||
const { hasColumnCached } = require('../utils/schemaCache');
|
const { hasColumnCached } = require('../utils/schemaCache');
|
||||||
const emailWebhookTransport = require('./emailWebhookTransport');
|
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).
|
* 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)}`;
|
return `#${(1 << 24 | Math.round(r) << 16 | Math.round(g) << 8 | Math.round(b)).toString(16).slice(1)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- global email footer signature (migration 198, issue #1264) --------
|
||||||
|
//
|
||||||
|
// Built from the business_profile issuer block — the address, contact rows
|
||||||
|
// and legal line the operator already maintains for their invoices — so it
|
||||||
|
// appears under EVERY mail this install sends without a single template
|
||||||
|
// being touched. Returns '' when the admin has not enabled it, which keeps
|
||||||
|
// the footer byte-identical to what pre-198 installs render.
|
||||||
|
|
||||||
|
// VAT is the one value that needs a label to mean anything. en/de only;
|
||||||
|
// every other locale falls back to the English label, same as the rest of
|
||||||
|
// the wrapper chrome ("All rights reserved").
|
||||||
|
const SIGNATURE_VAT_LABELS = { en: 'VAT ID', de: 'USt-IdNr.' };
|
||||||
|
|
||||||
|
// tel: hrefs take digits and a leading +; strip everything else so a pasted
|
||||||
|
// "+41 79 123 45 67 (mobile only)" can't smuggle a scheme or a quote into
|
||||||
|
// the attribute.
|
||||||
|
function signatureTelHref(raw) {
|
||||||
|
const cleaned = String(raw || '').replace(/[^\d+]/g, '');
|
||||||
|
return cleaned ? `tel:${cleaned}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admins type "example.com" as often as "https://example.com". Anything not
|
||||||
|
// already http(s) gets an https:// prefix — which also means a pasted
|
||||||
|
// `javascript:` value becomes an inert https URL instead of a live scheme.
|
||||||
|
function signatureWebsiteHref(raw) {
|
||||||
|
const trimmed = String(raw || '').trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSignatureLink(href, text, color) {
|
||||||
|
return `<a href="${escapeHtml(href)}" style="color:${color};text-decoration:none;">${escapeHtml(text)}</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object|null} signature businessProfileService.getEmailSignature()
|
||||||
|
* @param {object} opts { mutedTextColor, brandingCompanyName, language }
|
||||||
|
* @returns {string} HTML rows for the footer <td>, or '' when disabled.
|
||||||
|
*/
|
||||||
|
function renderEmailSignature(signature, { mutedTextColor, brandingCompanyName, language }) {
|
||||||
|
if (!signature) return '';
|
||||||
|
|
||||||
|
const lineStyle = `color:${mutedTextColor};font-size:12px;line-height:18px;margin:4px 0;`;
|
||||||
|
const rows = [];
|
||||||
|
|
||||||
|
// The footer above already prints the BRANDING company name. Only repeat
|
||||||
|
// the profile's when the operator has actually given it a different legal
|
||||||
|
// name ("Foto Müller" vs "Müller Fotografie GmbH").
|
||||||
|
if (signature.companyName && signature.companyName !== brandingCompanyName) {
|
||||||
|
rows.push(`<p style="${lineStyle}">${escapeHtml(signature.companyName)}</p>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A literal middle dot, not `·`: the plain-text part of every mail
|
||||||
|
// is derived from this HTML by htmlToText, which decodes only the five
|
||||||
|
// core entities — an `·` would survive verbatim into the text body.
|
||||||
|
if (signature.addressLines.length) {
|
||||||
|
rows.push(`<p style="${lineStyle}">${signature.addressLines.map(escapeHtml).join(' \u00b7 ')}</p>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contact = [];
|
||||||
|
for (const number of [signature.phone, signature.mobile]) {
|
||||||
|
const href = signatureTelHref(number);
|
||||||
|
if (href) contact.push(renderSignatureLink(href, number, mutedTextColor));
|
||||||
|
}
|
||||||
|
if (signature.email) {
|
||||||
|
contact.push(renderSignatureLink(`mailto:${signature.email}`, signature.email, mutedTextColor));
|
||||||
|
}
|
||||||
|
const website = signatureWebsiteHref(signature.website);
|
||||||
|
if (website) contact.push(renderSignatureLink(website, signature.website, mutedTextColor));
|
||||||
|
if (contact.length) {
|
||||||
|
rows.push(`<p style="${lineStyle}">${contact.join(' \u00b7 ')}</p>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signature.vatId) {
|
||||||
|
const label = SIGNATURE_VAT_LABELS[language] || SIGNATURE_VAT_LABELS.en;
|
||||||
|
rows.push(`<p style="${lineStyle}">${escapeHtml(label)}: ${escapeHtml(signature.vatId)}</p>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Free text (Handelsregister line, disclaimer, …). Plain text, never
|
||||||
|
// HTML — escaped, then newlines become <br> so a pasted 3-line legal
|
||||||
|
// notice keeps its shape.
|
||||||
|
if (signature.extra) {
|
||||||
|
const extra = escapeHtml(signature.extra).replace(/\r\n|\r|\n/g, '<br />');
|
||||||
|
rows.push(`<p style="${lineStyle}font-size:11px;">${extra}</p>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rows.length) return '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div style="margin:15px 0 5px;padding-top:15px;border-top:1px solid #eeeeee;">
|
||||||
|
${rows.join('\n ')}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The signature as plain text, for the text/plain MIME alternative.
|
||||||
|
*
|
||||||
|
* `sendTemplateEmail` uses a template's own `body_text` when it has one — and
|
||||||
|
* the seeded templates all do — so the text part is NOT derived from the
|
||||||
|
* wrapped HTML and would otherwise carry no signature at all. A text-only
|
||||||
|
* client, and the preview's Text tab, then showed a mail with no address and
|
||||||
|
* no legal line while the HTML part had both.
|
||||||
|
*
|
||||||
|
* Returns '' when the signature is disabled, so callers can append
|
||||||
|
* unconditionally.
|
||||||
|
*/
|
||||||
|
function renderEmailSignatureText(signature, { brandingCompanyName, language } = {}) {
|
||||||
|
if (!signature) return '';
|
||||||
|
|
||||||
|
const lines = [];
|
||||||
|
if (signature.companyName && signature.companyName !== brandingCompanyName) {
|
||||||
|
lines.push(signature.companyName);
|
||||||
|
}
|
||||||
|
if (signature.addressLines.length) {
|
||||||
|
lines.push(signature.addressLines.join(' \u00b7 '));
|
||||||
|
}
|
||||||
|
const contact = [signature.phone, signature.mobile, signature.email, signature.website]
|
||||||
|
.map((v) => (v || '').trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (contact.length) lines.push(contact.join(' \u00b7 '));
|
||||||
|
if (signature.vatId) {
|
||||||
|
const label = SIGNATURE_VAT_LABELS[language] || SIGNATURE_VAT_LABELS.en;
|
||||||
|
lines.push(`${label}: ${signature.vatId}`);
|
||||||
|
}
|
||||||
|
if (signature.extra) lines.push(signature.extra);
|
||||||
|
|
||||||
|
if (!lines.length) return '';
|
||||||
|
// A visual separator, the plain-text equivalent of the footer's top border.
|
||||||
|
return `\n\n--\n${lines.join('\n')}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Wrap HTML body in the styled email template with header, footer, and logo
|
// Wrap HTML body in the styled email template with header, footer, and logo
|
||||||
async function wrapEmailHtml(htmlBody, subject, language = 'en') {
|
async function wrapEmailHtml(htmlBody, subject, language = 'en') {
|
||||||
// Email colour palette. The two original settings (email_primary_color and
|
// 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}`;
|
const logoFullUrl = `${frontendUrl}${logoPath.startsWith('/') ? '' : '/'}${logoPath}`;
|
||||||
logger.debug('Email logo URL:', { frontendUrl, logoPath, logoFullUrl });
|
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();
|
const year = new Date().getFullYear();
|
||||||
// PR review follow-up — Outlook (Word engine) and Apple Mail under some
|
// 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
|
// 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>
|
<tr>
|
||||||
<td align="center" bgcolor="${secondaryColor}" class="email-footer" style="background-color:${secondaryColor};padding:30px;text-align:center;border-top:1px solid #eeeeee;">
|
<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;">
|
<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>
|
<p style="font-size:12px;color:#999999;margin:5px 0;">© ${year} ${companyName}. All rights reserved.</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -736,6 +878,28 @@ async function processTemplate(template, variables, language = 'en') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Send email using template
|
// 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) {
|
async function sendTemplateEmail(to, templateKey, variables) {
|
||||||
try {
|
try {
|
||||||
// Webhook transport (#1225) replaces SMTP entirely when configured, so an
|
// Webhook transport (#1225) replaces SMTP entirely when configured, so an
|
||||||
@@ -805,7 +969,12 @@ async function sendTemplateEmail(to, templateKey, variables) {
|
|||||||
cc: ccList,
|
cc: ccList,
|
||||||
subject: subject,
|
subject: subject,
|
||||||
html: htmlBody,
|
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,
|
attachments,
|
||||||
};
|
};
|
||||||
const info = viaWebhook
|
const info = viaWebhook
|
||||||
@@ -1274,6 +1443,8 @@ module.exports = {
|
|||||||
stopEmailQueueProcessor,
|
stopEmailQueueProcessor,
|
||||||
testEmailConnection,
|
testEmailConnection,
|
||||||
wrapEmailHtml,
|
wrapEmailHtml,
|
||||||
|
renderEmailSignatureText,
|
||||||
|
buildSignatureTextFor,
|
||||||
safeTemplateReplace,
|
safeTemplateReplace,
|
||||||
getSupportEmail,
|
getSupportEmail,
|
||||||
htmlToText
|
htmlToText
|
||||||
|
|||||||
@@ -85,7 +85,10 @@ async function sendRecoveryEmail(toEmail, code, eventName = 'your gallery') {
|
|||||||
to: toEmail,
|
to: toEmail,
|
||||||
subject,
|
subject,
|
||||||
html: styledHtml,
|
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) {
|
if (viaWebhook) {
|
||||||
await emailWebhookTransport.send(mail);
|
await emailWebhookTransport.send(mail);
|
||||||
|
|||||||
@@ -3855,7 +3855,10 @@
|
|||||||
"requiredFields": "Host, Port und Benutzername sind erforderlich.",
|
"requiredFields": "Host, Port und Benutzername sind erforderlich.",
|
||||||
"testOk": "Verbunden mit {{folder}} — {{messages}} Nachrichten, {{unseen}} ungelesen.",
|
"testOk": "Verbunden mit {{folder}} — {{messages}} Nachrichten, {{unseen}} ungelesen.",
|
||||||
"testFailed": "Verbindung fehlgeschlagen."
|
"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": {
|
"cms": {
|
||||||
"title": "CMS-Seiten",
|
"title": "CMS-Seiten",
|
||||||
@@ -6113,7 +6116,19 @@
|
|||||||
"logoUploadedToast": "PDF-Logo hochgeladen.",
|
"logoUploadedToast": "PDF-Logo hochgeladen.",
|
||||||
"logoConfirmClear": "PDF-Logo entfernen? Der Renderer greift dann auf das Branding-Logo zurück, sofern eines hinterlegt ist.",
|
"logoConfirmClear": "PDF-Logo entfernen? Der Renderer greift dann auf das Branding-Logo zurück, sofern eines hinterlegt ist.",
|
||||||
"logoClearedToast": "PDF-Logo entfernt.",
|
"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": {
|
"crmSettings": {
|
||||||
"savedToast": "CRM-Einstellungen gespeichert.",
|
"savedToast": "CRM-Einstellungen gespeichert.",
|
||||||
|
|||||||
@@ -3368,7 +3368,10 @@
|
|||||||
"requiredFields": "Host, port and username are required.",
|
"requiredFields": "Host, port and username are required.",
|
||||||
"testOk": "Connected to {{folder}} — {{messages}} messages, {{unseen}} unread.",
|
"testOk": "Connected to {{folder}} — {{messages}} messages, {{unseen}} unread.",
|
||||||
"testFailed": "Connection failed."
|
"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": {
|
"cms": {
|
||||||
"title": "CMS Pages",
|
"title": "CMS Pages",
|
||||||
@@ -6112,7 +6115,19 @@
|
|||||||
"logoUploadedToast": "PDF logo uploaded.",
|
"logoUploadedToast": "PDF logo uploaded.",
|
||||||
"logoConfirmClear": "Remove the PDF logo? The renderer will fall back to the Branding logo if one is set.",
|
"logoConfirmClear": "Remove the PDF logo? The renderer will fall back to the Branding logo if one is set.",
|
||||||
"logoClearedToast": "PDF logo removed.",
|
"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": {
|
"crmSettings": {
|
||||||
"savedToast": "CRM settings saved.",
|
"savedToast": "CRM settings saved.",
|
||||||
|
|||||||
@@ -23,10 +23,12 @@ import { ReceivedEmailsPanel } from '../../components/admin/ReceivedEmailsPanel'
|
|||||||
import { IncomingMailConfigCard } from '../../components/admin/IncomingMailConfigCard';
|
import { IncomingMailConfigCard } from '../../components/admin/IncomingMailConfigCard';
|
||||||
import { CustomerMailboxCard } from '../../components/admin/CustomerMailboxCard';
|
import { CustomerMailboxCard } from '../../components/admin/CustomerMailboxCard';
|
||||||
import { Palette, RefreshCw, Info } from 'lucide-react';
|
import { Palette, RefreshCw, Info } from 'lucide-react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||||
import { useModal, useMutationWithToast } from '../../hooks';
|
import { useModal, useMutationWithToast } from '../../hooks';
|
||||||
import { emailService, type EmailConfig, type EmailTemplate, type EmailTemplateTranslation } from '../../services/email.service';
|
import { emailService, type EmailConfig, type EmailTemplate, type EmailTemplateTranslation } from '../../services/email.service';
|
||||||
import { settingsService } from '../../services/settings.service';
|
import { settingsService } from '../../services/settings.service';
|
||||||
|
import { businessProfileService } from '../../services/businessProfile.service';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { SUPPORTED_LANGUAGES } from "../../components/common/LanguageSelector.tsx";
|
import { SUPPORTED_LANGUAGES } from "../../components/common/LanguageSelector.tsx";
|
||||||
import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext';
|
import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext';
|
||||||
@@ -210,6 +212,27 @@ export const EmailConfigPage: React.FC = () => {
|
|||||||
tls_reject_unauthorized: true
|
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
|
// Fetch SMTP config
|
||||||
const { isLoading: configLoading } = useQuery({
|
const { isLoading: configLoading } = useQuery({
|
||||||
queryKey: ['email-config'],
|
queryKey: ['email-config'],
|
||||||
@@ -536,6 +559,24 @@ export const EmailConfigPage: React.FC = () => {
|
|||||||
{/* SMTP Settings Tab */}
|
{/* SMTP Settings Tab */}
|
||||||
{activeTab === 'smtp' && (
|
{activeTab === 'smtp' && (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<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">
|
<Card padding="md">
|
||||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('email.smtpConfiguration')}</h2>
|
<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 React, { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
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 {
|
import {
|
||||||
businessProfileService,
|
businessProfileService,
|
||||||
type BusinessProfile,
|
type BusinessProfile,
|
||||||
@@ -305,6 +305,58 @@ export const SettingsBusinessProfilePage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</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
|
{/* Disclaimer banner for QR-bill / IBAN data. picpeak renders
|
||||||
what the operator types — it cannot validate IBAN/BIC, QR-IID
|
what the operator types — it cannot validate IBAN/BIC, QR-IID
|
||||||
or scan-compatibility with any specific bank's e-banking app.
|
or scan-compatibility with any specific bank's e-banking app.
|
||||||
@@ -361,6 +413,64 @@ const PdfToggleRow: React.FC<PdfToggleRowProps> = ({ label, description, enabled
|
|||||||
</label>
|
</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
|
* Per-weekday business-hours editor (migration 114). Google-style: each
|
||||||
* weekday holds zero or more {start,end} blocks, so a day can be closed
|
* 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
|
* (migration 114). Defaults true. When off, scheduled emails send at
|
||||||
* their requested instant regardless of `businessHours`. */
|
* their requested instant regardless of `businessHours`. */
|
||||||
scheduledEmailFloorEnabled: boolean;
|
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;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user