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

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

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

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

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

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

Closes #1264 (Part A)
This commit is contained in:
Paul Nothaft
2026-09-04 14:24:02 +02:00
committed by GitHub
parent 90da797e3f
commit b6e40b9a2a
14 changed files with 1155 additions and 16 deletions
@@ -0,0 +1,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);
});
}
}
};