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:
@@ -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,
|
||||
|
||||
@@ -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 `·`: 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
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user