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
+17 -2
View File
@@ -3855,7 +3855,10 @@
"requiredFields": "Host, Port und Benutzername sind erforderlich.",
"testOk": "Verbunden mit {{folder}} — {{messages}} Nachrichten, {{unseen}} ungelesen.",
"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": {
"title": "CMS-Seiten",
@@ -6113,7 +6116,19 @@
"logoUploadedToast": "PDF-Logo hochgeladen.",
"logoConfirmClear": "PDF-Logo entfernen? Der Renderer greift dann auf das Branding-Logo zurück, sofern eines hinterlegt ist.",
"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": {
"savedToast": "CRM-Einstellungen gespeichert.",
+17 -2
View File
@@ -3368,7 +3368,10 @@
"requiredFields": "Host, port and username are required.",
"testOk": "Connected to {{folder}} — {{messages}} messages, {{unseen}} unread.",
"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": {
"title": "CMS Pages",
@@ -6112,7 +6115,19 @@
"logoUploadedToast": "PDF logo uploaded.",
"logoConfirmClear": "Remove the PDF logo? The renderer will fall back to the Branding logo if one is set.",
"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": {
"savedToast": "CRM settings saved.",
@@ -23,10 +23,12 @@ import { ReceivedEmailsPanel } from '../../components/admin/ReceivedEmailsPanel'
import { IncomingMailConfigCard } from '../../components/admin/IncomingMailConfigCard';
import { CustomerMailboxCard } from '../../components/admin/CustomerMailboxCard';
import { Palette, RefreshCw, Info } from 'lucide-react';
import { Link } from 'react-router-dom';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useModal, useMutationWithToast } from '../../hooks';
import { emailService, type EmailConfig, type EmailTemplate, type EmailTemplateTranslation } from '../../services/email.service';
import { settingsService } from '../../services/settings.service';
import { businessProfileService } from '../../services/businessProfile.service';
import { useTranslation } from 'react-i18next';
import { SUPPORTED_LANGUAGES } from "../../components/common/LanguageSelector.tsx";
import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext';
@@ -210,6 +212,27 @@ export const EmailConfigPage: React.FC = () => {
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
const { isLoading: configLoading } = useQuery({
queryKey: ['email-config'],
@@ -536,6 +559,24 @@ export const EmailConfigPage: React.FC = () => {
{/* SMTP Settings Tab */}
{activeTab === 'smtp' && (
<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">
<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 { useTranslation } from 'react-i18next';
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 {
businessProfileService,
type BusinessProfile,
@@ -305,6 +305,58 @@ export const SettingsBusinessProfilePage: React.FC = () => {
</div>
</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
what the operator types — it cannot validate IBAN/BIC, QR-IID
or scan-compatibility with any specific bank's e-banking app.
@@ -361,6 +413,64 @@ const PdfToggleRow: React.FC<PdfToggleRowProps> = ({ label, description, enabled
</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
* 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
* their requested instant regardless of `businessHours`. */
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;
updatedAt: string;
}