diff --git a/backend/__tests__/integration/businessProfileSignature.test.js b/backend/__tests__/integration/businessProfileSignature.test.js new file mode 100644 index 00000000..9a7a3c19 --- /dev/null +++ b/backend/__tests__/integration/businessProfileSignature.test.js @@ -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('

x

', '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('

x

', 'S')).toContain('Bahnhofstrasse 9'); + }); +}); diff --git a/backend/__tests__/integration/emailSignatureFooter.test.js b/backend/__tests__/integration/emailSignatureFooter.test.js new file mode 100644 index 00000000..9b7c8be3 --- /dev/null +++ b/backend/__tests__/integration/emailSignatureFooter.test.js @@ -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: 'hello@example.com', + 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('

Body

', 'Subject'); + + expect(html).not.toContain('Bahnhofstrasse 1'); + expect(html).not.toContain('hello@example.com'); + 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('

Body

', 'Subject'); + await enable({ email_signature_enabled: false }); + const withDisabledSignature = await wrapEmailHtml('

Body

', 'Subject'); + + expect(withDisabledSignature).toBe(withEmptyProfile); + }); + + it('renders address, contacts, VAT id and the legal line when enabled', async () => { + await enable(); + + const html = await wrapEmailHtml('

Body

', '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
FL-0002.123.456-7'); + }); + + it('links phone, mobile, email and website with safe schemes', async () => { + await enable(); + + const html = await wrapEmailHtml('

Body

', '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:hello@example.com"'); + // 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('

Body

', '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('

Body

', '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: '', + address_line1: 'Rue "des" Fleurs & Co', + vat_id: '', + email_signature_extra: '

', + }); + + const html = await wrapEmailHtml('

Body

', 'Subject'); + + // Escaped, so the markup is inert text — the tags never open. + expect(html).not.toContain(''); + expect(html).not.toContain(''); + expect(html).not.toContain(' { + // 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('

Body

', '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('

Body

', '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('

Body

', 'Subject', 'de'); + const en = await wrapEmailHtml('

Body

', '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('

Body

', 'Subject'); + + expect(html).toContain('hello@example.com'); + 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('

Body

', 'Subject'); + + // The signature
is the only element carrying this margin. + expect(html).not.toContain('
(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, diff --git a/backend/src/services/emailProcessor.js b/backend/src/services/emailProcessor.js index 6d0aab60..a291c0a9 100644 --- a/backend/src/services/emailProcessor.js +++ b/backend/src/services/emailProcessor.js @@ -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 `${escapeHtml(text)}`; +} + +/** + * @param {object|null} signature businessProfileService.getEmailSignature() + * @param {object} opts { mutedTextColor, brandingCompanyName, language } + * @returns {string} HTML rows for the footer , 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(`

${escapeHtml(signature.companyName)}

`); + } + + // 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(`

${signature.addressLines.map(escapeHtml).join(' \u00b7 ')}

`); + } + + 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(`

${contact.join(' \u00b7 ')}

`); + } + + if (signature.vatId) { + const label = SIGNATURE_VAT_LABELS[language] || SIGNATURE_VAT_LABELS.en; + rows.push(`

${escapeHtml(label)}: ${escapeHtml(signature.vatId)}

`); + } + + // Free text (Handelsregister line, disclaimer, …). Plain text, never + // HTML — escaped, then newlines become
so a pasted 3-line legal + // notice keeps its shape. + if (signature.extra) { + const extra = escapeHtml(signature.extra).replace(/\r\n|\r|\n/g, '
'); + rows.push(`

${extra}

`); + } + + if (!rows.length) return ''; + + return ` +
+ ${rows.join('\n ')} +
`; +} + +/** + * 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