feat(email): global signature footer from the business profile (#1264)

The business profile already carried the operator's full issuer block —
address, phone, email, website, VAT id — but none of it reached an email.
Those columns only fed the quote/invoice PDF renderer, so every outgoing
mail footer was the fixed logo + company name + copyright line.

The signature is rendered by wrapEmailHtml and nowhere else, so no
template, no per-type send path and no queue row needed a change. Two new
columns on business_profile (migration 198) carry the toggle and one
free-text legal line; everything else is read from the address fields the
operator already maintains.

Default off, with a test pinning that the disabled path is byte-identical
to a no-profile install.

Includes three rounds of external review fixes: the plain-text MIME part
also carries the signature; string booleans ('false'/'0') no longer
invert the toggle; the status line stays silent rather than asserting
"off" while unauthorised or loading; and the preview's Text tab mirrors
the send path's htmlToText fallback.

Manual Messages replies deliberately keep no signature — they bypass the
wrapper by design — and the UI copy names that exception.

Closes #1264 (Part A)
This commit is contained in:
Paul Nothaft
2026-09-04 14:24:02 +02:00
committed by GitHub
parent 90da797e3f
commit b6e40b9a2a
14 changed files with 1155 additions and 16 deletions
@@ -179,6 +179,12 @@ function transformProfile(p) {
: (p.scheduled_email_floor_enabled === true
|| p.scheduled_email_floor_enabled === 1
|| p.scheduled_email_floor_enabled === '1'),
// Migration 198 — global email footer signature. Defaults FALSE so an
// upgraded install's footer stays byte-identical until an admin opts in.
emailSignatureEnabled: p.email_signature_enabled === true
|| p.email_signature_enabled === 1
|| p.email_signature_enabled === '1',
emailSignatureExtra: p.email_signature_extra || '',
createdAt: p.created_at,
updatedAt: p.updated_at,
};
@@ -456,6 +462,11 @@ router.put(
}),
// Migration 114 — scheduled-email floor master switch.
body('scheduledEmailFloorEnabled').optional().isBoolean(),
// Migration 198 — global email footer signature. Boolean uses the
// explicit-undefined form so `false` reaches the service and the
// toggle can actually be switched off.
body('emailSignatureEnabled').optional().isBoolean(),
body('emailSignatureExtra').optional({ values: 'falsy' }).isString().isLength({ max: 500 }),
],
handleAsync(async (req, res) => {
validateRequest(req);
@@ -497,6 +508,9 @@ router.put(
// Migration 114 — business hours + scheduled-email floor switch.
businessHours: 'business_hours',
scheduledEmailFloorEnabled: 'scheduled_email_floor_enabled',
// Migration 198 — global email footer signature.
emailSignatureEnabled: 'email_signature_enabled',
emailSignatureExtra: 'email_signature_extra',
};
for (const [api, db] of Object.entries(map)) {
if (Object.prototype.hasOwnProperty.call(req.body, api)) {
+22 -4
View File
@@ -8,8 +8,9 @@ const { requirePermission } = require('../middleware/permissions');
// /email mount — the pre-existing config/queue/received endpoints stay ungated).
const { requireFeatureFlag } = require('../middleware/requireFeatureFlag');
const messagingGate = requireFeatureFlag('messaging');
const { wrapEmailHtml, processEmailQueue, resolveFromIdentity } = require('../services/emailProcessor');
const { wrapEmailHtml, processEmailQueue, resolveFromIdentity, buildSignatureTextFor, htmlToText } = require('../services/emailProcessor');
const emailWebhookTransport = require('../services/emailWebhookTransport');
const businessProfileService = require('../services/businessProfileService');
const { errorResponse, safeValidationErrors } = require('../utils/routeHelpers');
const logger = require('../utils/logger');
const router = express.Router();
@@ -494,7 +495,10 @@ router.post('/test', adminAuth, requirePermission('email.send'), async (req, res
to: test_email,
subject: webhookSubject,
html: webhookHtml,
text: 'Test Email Successful! Delivered through the configured email webhook.',
// The HTML goes through wrapEmailHtml and gains the signature; the
// text alternative has to be given it explicitly (#1264 review).
text: 'Test Email Successful! Delivered through the configured email webhook.'
+ await buildSignatureTextFor('en'),
});
} catch (webhookError) {
// Handled here, not by the outer catch: that one maps ECONNREFUSED and
@@ -580,6 +584,7 @@ router.post('/test', adminAuth, requirePermission('email.send'), async (req, res
subject,
html: wrappedHtml,
text: 'Test Email Successful! Your email configuration is working correctly.'
+ await buildSignatureTextFor('en')
});
res.json({ message: 'Test email sent successfully' });
@@ -1233,8 +1238,21 @@ router.post('/templates/:key/preview', adminAuth, requirePermission('email.view'
res.json({
subject,
body_html: wrappedHtml,
body_text: textContent,
language
// The Text tab has to show what a text-only client will receive, which
// includes the signature the HTML tab already displays (#1264 review).
//
// The `|| htmlToText(...)` half mirrors sendTemplateEmail: a
// translation may legitimately have HTML and an EMPTY body_text, and
// the real send derives the text part from the HTML in that case.
// Concatenating the signature onto '' produced a non-empty string, so
// the Text tab rendered a footer with no message above it.
body_text: (textContent || htmlToText(wrappedHtml))
+ await buildSignatureTextFor(language),
language,
// Migration 198 — the wrapper above already rendered the global
// signature into body_html when it's on. This flag just lets the
// preview UI say so, and point at Business profile when it's off.
signature: (await businessProfileService.getEmailSignature()) !== null
});
} catch (error) {
errorResponse(res, error, 500, 'Failed to preview email template');
+116 -4
View File
@@ -85,6 +85,11 @@ const ALLOWED_PROFILE_FIELDS = [
'business_hours',
// Master switch for the scheduled-email business-hours floor (mig 114).
'scheduled_email_floor_enabled',
// Global email footer signature (migration 198). The toggle plus one
// free-text legal line; every other value in the signature is read from
// the address/contact columns above, so there is no second copy.
'email_signature_enabled',
'email_signature_extra',
];
const ALLOWED_BANK_FIELDS = [
@@ -125,6 +130,28 @@ function normaliseCountryCode(cc) {
return String(cc).trim().toUpperCase().slice(0, 2);
}
/**
* Coerce an API boolean, honouring the string forms express-validator's
* `isBoolean()` accepts.
*
* `Boolean('false')` and `Boolean('0')` are both TRUE, so a URL-encoded or
* form-encoded client sending `emailSignatureEnabled=false` passed validation
* and then stored the toggle as ENABLED — the one value it was trying to
* clear. JSON clients were unaffected, which is why it was easy to miss.
*
* Applied to every boolean in this file, not just the new one: the PDF
* visibility toggles, the scheduled-email floor and the bank-account default
* flag all shared the coercion and therefore the bug.
*/
function toBoolean(value) {
if (typeof value === 'string') {
const v = value.trim().toLowerCase();
if (v === 'false' || v === '0') return false;
if (v === 'true' || v === '1') return true;
}
return Boolean(value);
}
function sanitiseProfilePayload(payload) {
const updates = pickFields(payload, ALLOWED_PROFILE_FIELDS);
@@ -142,7 +169,8 @@ function sanitiseProfilePayload(payload) {
// when the admin pastes from a printed letterhead.
for (const field of ['company_name', 'address_line1', 'address_line2',
'city', 'state', 'country_name', 'phone', 'mobile', 'email', 'website',
'vat_id', 'tax_id', 'vat_label', 'footer_line', 'logo_path']) {
'vat_id', 'tax_id', 'vat_label', 'footer_line', 'logo_path',
'email_signature_extra']) {
if (typeof updates[field] === 'string') {
updates[field] = updates[field].trim();
}
@@ -154,7 +182,7 @@ function sanitiseProfilePayload(payload) {
'pdf_quote_show_net_days', 'pdf_quote_show_skonto',
]) {
if (updates[field] !== undefined) {
updates[field] = formatBoolean(Boolean(updates[field]));
updates[field] = formatBoolean(toBoolean(updates[field]));
}
}
// Folding-mark enum — whitelisted set. Garbage values fall back to
@@ -195,7 +223,12 @@ function sanitiseProfilePayload(payload) {
}
}
if (updates.scheduled_email_floor_enabled !== undefined) {
updates.scheduled_email_floor_enabled = formatBoolean(Boolean(updates.scheduled_email_floor_enabled));
updates.scheduled_email_floor_enabled = formatBoolean(toBoolean(updates.scheduled_email_floor_enabled));
}
// Migration 198 — email footer signature master switch. Same
// explicit-undefined shape as the PDF toggles so `false` persists.
if (updates.email_signature_enabled !== undefined) {
updates.email_signature_enabled = formatBoolean(toBoolean(updates.email_signature_enabled));
}
return updates;
@@ -214,7 +247,7 @@ function sanitiseBankPayload(payload) {
updates.currency = normaliseCurrency(updates.currency);
}
if (updates.is_default !== undefined) {
updates.is_default = formatBoolean(Boolean(updates.is_default));
updates.is_default = formatBoolean(toBoolean(updates.is_default));
}
for (const field of ['label', 'account_holder']) {
if (typeof updates[field] === 'string') {
@@ -264,6 +297,10 @@ async function updateProfile(payload, adminId) {
await db('business_profile').where({ id: 1 }).update(updates);
});
// The email footer signature is built from these same columns, so any
// profile write invalidates it — not just the two signature fields.
invalidateEmailSignatureCache();
logger.info('Business profile updated', {
adminId,
fields: Object.keys(updates).filter((k) => k !== 'updated_at'),
@@ -369,9 +406,84 @@ async function resolveBankAccountForCurrency(currency, overrideId = null, conn =
});
}
/**
* The global email footer signature (migration 198).
*
* `wrapEmailHtml` calls this on every single outgoing mail — transactional,
* preview, test and manual alike — so it is memoised for 60 s and cleared
* on any profile write. A queue tick sending 10 mails hits the DB once.
*
* Returns `null` when the toggle is off (or the table/column is missing on
* an install that hasn't migrated yet), which is the wrapper's signal to
* render exactly the footer it rendered before this feature existed.
*
* Every value is raw text — escaping is the renderer's job.
*/
const SIGNATURE_CACHE_TTL_MS = 60 * 1000;
let signatureCache = { value: undefined, expiresAt: 0 };
function invalidateEmailSignatureCache() {
signatureCache = { value: undefined, expiresAt: 0 };
}
function truthy(v) {
return v === true || v === 1 || v === '1' || v === 't' || v === 'true';
}
async function getEmailSignature() {
const now = Date.now();
if (signatureCache.value !== undefined && signatureCache.expiresAt > now) {
return signatureCache.value;
}
let signature = null;
try {
const profile = await withRetry(async () =>
db('business_profile').where({ id: 1 }).first()
);
if (profile && truthy(profile.email_signature_enabled)) {
// Same "PC City / Country" shape the PDF issuer block uses
// (pdfService.js:361). The locale-aware country lookup lives in the
// PDF renderer; an email footer takes the free-text `country_name`
// when the admin set one and the ISO code otherwise.
const cc = profile.country_code ? String(profile.country_code).toUpperCase() : '';
const pc = profile.postal_code || '';
const left = [cc && pc ? `${cc}-${pc}` : (pc || cc), profile.city || ''].filter(Boolean).join(' ');
const country = profile.country_name || '';
const cityCountry = [left, country].filter(Boolean).join(' / ');
signature = {
companyName: profile.company_name || '',
addressLines: [profile.address_line1, profile.address_line2, cityCountry]
.map((l) => (l == null ? '' : String(l).trim()))
.filter(Boolean),
phone: profile.phone || '',
mobile: profile.mobile || '',
email: profile.email || '',
website: profile.website || '',
vatId: profile.vat_id || '',
extra: profile.email_signature_extra || '',
};
}
} catch (error) {
// A missing table/column (pre-198 install mid-upgrade) must never break
// the mail itself — fall through to "no signature".
logger.warn('Could not read email signature from business profile', {
error: error.message,
});
signature = null;
}
signatureCache = { value: signature, expiresAt: now + SIGNATURE_CACHE_TTL_MS };
return signature;
}
module.exports = {
getProfile,
updateProfile,
getEmailSignature,
invalidateEmailSignatureCache,
createBankAccount,
updateBankAccount,
deleteBankAccount,
+173 -2
View File
@@ -8,6 +8,9 @@ const {
} = require('../utils/businessHours');
const { hasColumnCached } = require('../utils/schemaCache');
const emailWebhookTransport = require('./emailWebhookTransport');
// Migration 198 — the global email footer signature is read from the
// business profile. No cycle: businessProfileService only pulls db + utils.
const businessProfileService = require('./businessProfileService');
/**
* The From identity for an outbound message (#1225).
@@ -230,6 +233,137 @@ function darkenColor(hex, amount = 0.15) {
return `#${(1 << 24 | Math.round(r) << 16 | Math.round(g) << 8 | Math.round(b)).toString(16).slice(1)}`;
}
// ---- global email footer signature (migration 198, issue #1264) --------
//
// Built from the business_profile issuer block — the address, contact rows
// and legal line the operator already maintains for their invoices — so it
// appears under EVERY mail this install sends without a single template
// being touched. Returns '' when the admin has not enabled it, which keeps
// the footer byte-identical to what pre-198 installs render.
// VAT is the one value that needs a label to mean anything. en/de only;
// every other locale falls back to the English label, same as the rest of
// the wrapper chrome ("All rights reserved").
const SIGNATURE_VAT_LABELS = { en: 'VAT ID', de: 'USt-IdNr.' };
// tel: hrefs take digits and a leading +; strip everything else so a pasted
// "+41 79 123 45 67 (mobile only)" can't smuggle a scheme or a quote into
// the attribute.
function signatureTelHref(raw) {
const cleaned = String(raw || '').replace(/[^\d+]/g, '');
return cleaned ? `tel:${cleaned}` : null;
}
// Admins type "example.com" as often as "https://example.com". Anything not
// already http(s) gets an https:// prefix — which also means a pasted
// `javascript:` value becomes an inert https URL instead of a live scheme.
function signatureWebsiteHref(raw) {
const trimmed = String(raw || '').trim();
if (!trimmed) return null;
return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
}
function renderSignatureLink(href, text, color) {
return `<a href="${escapeHtml(href)}" style="color:${color};text-decoration:none;">${escapeHtml(text)}</a>`;
}
/**
* @param {object|null} signature businessProfileService.getEmailSignature()
* @param {object} opts { mutedTextColor, brandingCompanyName, language }
* @returns {string} HTML rows for the footer <td>, or '' when disabled.
*/
function renderEmailSignature(signature, { mutedTextColor, brandingCompanyName, language }) {
if (!signature) return '';
const lineStyle = `color:${mutedTextColor};font-size:12px;line-height:18px;margin:4px 0;`;
const rows = [];
// The footer above already prints the BRANDING company name. Only repeat
// the profile's when the operator has actually given it a different legal
// name ("Foto Müller" vs "Müller Fotografie GmbH").
if (signature.companyName && signature.companyName !== brandingCompanyName) {
rows.push(`<p style="${lineStyle}">${escapeHtml(signature.companyName)}</p>`);
}
// A literal middle dot, not `&middot;`: the plain-text part of every mail
// is derived from this HTML by htmlToText, which decodes only the five
// core entities — an `&middot;` would survive verbatim into the text body.
if (signature.addressLines.length) {
rows.push(`<p style="${lineStyle}">${signature.addressLines.map(escapeHtml).join(' \u00b7 ')}</p>`);
}
const contact = [];
for (const number of [signature.phone, signature.mobile]) {
const href = signatureTelHref(number);
if (href) contact.push(renderSignatureLink(href, number, mutedTextColor));
}
if (signature.email) {
contact.push(renderSignatureLink(`mailto:${signature.email}`, signature.email, mutedTextColor));
}
const website = signatureWebsiteHref(signature.website);
if (website) contact.push(renderSignatureLink(website, signature.website, mutedTextColor));
if (contact.length) {
rows.push(`<p style="${lineStyle}">${contact.join(' \u00b7 ')}</p>`);
}
if (signature.vatId) {
const label = SIGNATURE_VAT_LABELS[language] || SIGNATURE_VAT_LABELS.en;
rows.push(`<p style="${lineStyle}">${escapeHtml(label)}: ${escapeHtml(signature.vatId)}</p>`);
}
// Free text (Handelsregister line, disclaimer, …). Plain text, never
// HTML — escaped, then newlines become <br> so a pasted 3-line legal
// notice keeps its shape.
if (signature.extra) {
const extra = escapeHtml(signature.extra).replace(/\r\n|\r|\n/g, '<br />');
rows.push(`<p style="${lineStyle}font-size:11px;">${extra}</p>`);
}
if (!rows.length) return '';
return `
<div style="margin:15px 0 5px;padding-top:15px;border-top:1px solid #eeeeee;">
${rows.join('\n ')}
</div>`;
}
/**
* The signature as plain text, for the text/plain MIME alternative.
*
* `sendTemplateEmail` uses a template's own `body_text` when it has one — and
* the seeded templates all do — so the text part is NOT derived from the
* wrapped HTML and would otherwise carry no signature at all. A text-only
* client, and the preview's Text tab, then showed a mail with no address and
* no legal line while the HTML part had both.
*
* Returns '' when the signature is disabled, so callers can append
* unconditionally.
*/
function renderEmailSignatureText(signature, { brandingCompanyName, language } = {}) {
if (!signature) return '';
const lines = [];
if (signature.companyName && signature.companyName !== brandingCompanyName) {
lines.push(signature.companyName);
}
if (signature.addressLines.length) {
lines.push(signature.addressLines.join(' \u00b7 '));
}
const contact = [signature.phone, signature.mobile, signature.email, signature.website]
.map((v) => (v || '').trim())
.filter(Boolean);
if (contact.length) lines.push(contact.join(' \u00b7 '));
if (signature.vatId) {
const label = SIGNATURE_VAT_LABELS[language] || SIGNATURE_VAT_LABELS.en;
lines.push(`${label}: ${signature.vatId}`);
}
if (signature.extra) lines.push(signature.extra);
if (!lines.length) return '';
// A visual separator, the plain-text equivalent of the footer's top border.
return `\n\n--\n${lines.join('\n')}`;
}
// Wrap HTML body in the styled email template with header, footer, and logo
async function wrapEmailHtml(htmlBody, subject, language = 'en') {
// Email colour palette. The two original settings (email_primary_color and
@@ -292,6 +426,14 @@ async function wrapEmailHtml(htmlBody, subject, language = 'en') {
const logoFullUrl = `${frontendUrl}${logoPath.startsWith('/') ? '' : '/'}${logoPath}`;
logger.debug('Email logo URL:', { frontendUrl, logoPath, logoFullUrl });
// Migration 198 — global footer signature from the business profile.
// Memoised for 60 s in the service, so a queue tick sending ten mails
// reads the row once. Never throws; returns null when disabled.
const signatureHtml = renderEmailSignature(
await businessProfileService.getEmailSignature(),
{ mutedTextColor, brandingCompanyName: companyName, language }
);
const year = new Date().getFullYear();
// PR review follow-up — Outlook (Word engine) and Apple Mail under some
// configs STRIP the <head><style>, so any element styled only by a class
@@ -438,7 +580,7 @@ async function wrapEmailHtml(htmlBody, subject, language = 'en') {
<tr>
<td align="center" bgcolor="${secondaryColor}" class="email-footer" style="background-color:${secondaryColor};padding:30px;text-align:center;border-top:1px solid #eeeeee;">
<img src="${logoFullUrl}" alt="${companyName}" width="120" style="max-width:120px;height:auto;opacity:0.8;margin-bottom:15px;border:0;">
<p style="color:${mutedTextColor};font-size:14px;margin:5px 0;">${companyName}</p>
<p style="color:${mutedTextColor};font-size:14px;margin:5px 0;">${companyName}</p>${signatureHtml}
<p style="font-size:12px;color:#999999;margin:5px 0;">© ${year} ${companyName}. All rights reserved.</p>
</td>
</tr>
@@ -736,6 +878,28 @@ async function processTemplate(template, variables, language = 'en') {
}
// Send email using template
/**
* Resolve the signature and render its plain-text form for `language`.
* Never throws — a footer must not be able to fail a send.
*/
async function buildSignatureTextFor(language) {
try {
const signature = await businessProfileService.getEmailSignature();
if (!signature) return '';
let brandingCompanyName = 'PicPeak';
try {
const row = await db('app_settings').where('setting_key', 'branding_company_name').first();
if (row && row.setting_value) {
try { brandingCompanyName = JSON.parse(row.setting_value); } catch (_) { brandingCompanyName = row.setting_value; }
}
} catch (_) { /* fall back to the default name */ }
return renderEmailSignatureText(signature, { brandingCompanyName, language });
} catch (error) {
logger.warn('Could not render the plain-text email signature', { error: error.message });
return '';
}
}
async function sendTemplateEmail(to, templateKey, variables) {
try {
// Webhook transport (#1225) replaces SMTP entirely when configured, so an
@@ -805,7 +969,12 @@ async function sendTemplateEmail(to, templateKey, variables) {
cc: ccList,
subject: subject,
html: htmlBody,
text: textBody || htmlToText(htmlBody),
// When the template supplies its own body_text the text part is not
// derived from the wrapped HTML, so the signature has to be appended
// here or the text/plain alternative silently omits it (#1264 review).
text: textBody
? textBody + await buildSignatureTextFor(language)
: htmlToText(htmlBody),
attachments,
};
const info = viaWebhook
@@ -1274,6 +1443,8 @@ module.exports = {
stopEmailQueueProcessor,
testEmailConnection,
wrapEmailHtml,
renderEmailSignatureText,
buildSignatureTextFor,
safeTemplateReplace,
getSupportEmail,
htmlToText
+4 -1
View File
@@ -85,7 +85,10 @@ async function sendRecoveryEmail(toEmail, code, eventName = 'your gallery') {
to: toEmail,
subject,
html: styledHtml,
text: `Your verification code is ${code}. It expires in 15 minutes.`,
// wrapEmailHtml gives the HTML part the signature; the text
// alternative needs it appended explicitly (#1264 review).
text: `Your verification code is ${code}. It expires in 15 minutes.`
+ await require('./emailProcessor').buildSignatureTextFor('en'),
};
if (viaWebhook) {
await emailWebhookTransport.send(mail);