From 3fbd5a78184823238a6eebc604ffb9a7c5cc333e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 04:00:25 +0100 Subject: [PATCH] fix: check the signature and disclaimer in each part on its own so neither goes out twice --- .../__tests__/composer-org-signature.test.tsx | 15 +++ components/email/email-composer.tsx | 5 +- components/email/org-disclaimer-preview.tsx | 20 +++- components/mail/mail-app.tsx | 2 - hooks/use-org-signature.ts | 18 +-- lib/org/__tests__/outgoing-parts.test.ts | 108 ++++++++++++++++++ lib/org/body-match.ts | 105 +++++++++++++++++ lib/org/outgoing.ts | 96 ++++++---------- lib/org/signature-state.ts | 22 ++-- lib/outgoing/quick-reply.ts | 2 + lib/outgoing/send-pipeline.ts | 6 + lib/signature-utils.ts | 20 ++-- 12 files changed, 318 insertions(+), 101 deletions(-) create mode 100644 lib/org/__tests__/outgoing-parts.test.ts create mode 100644 lib/org/body-match.ts diff --git a/components/email/__tests__/composer-org-signature.test.tsx b/components/email/__tests__/composer-org-signature.test.tsx index 89513c637..29e346295 100644 --- a/components/email/__tests__/composer-org-signature.test.tsx +++ b/components/email/__tests__/composer-org-signature.test.tsx @@ -298,11 +298,14 @@ function expectOrgSignatureOnce(sent: { body: string; htmlBody?: string }, name: describe('organisation signature in the composer', () => { beforeEach(() => { + resetSendPipeline(); + registerOrgOutgoing(); useSettingsStore.setState({ signatureDetails: { title: 'CEO', department: '', phone: '+49 30 1', mobile: '' }, } as never); }); afterEach(() => { + resetSendPipeline(); useSettingsStore.setState({ plainTextMode: false, signaturePosition: 'below_quote' }); setOrgPolicy({ signature: null }); vi.clearAllMocks(); @@ -469,4 +472,16 @@ describe('organisation disclaimer in the composer', () => { expect(countOf(sent.body, 'Confidential')).toBe(1); expect(sent.body.endsWith('\n\nConfidential, from me@example.com')).toBe(true); }); + + it('shows no block and adds no second copy when a draft back from Undo already ends with it', async () => { + const onSend = vi.fn(); + // As the first send went out: the signature embedded, the disclaimer below it. + const body = '

Hi Bob

--

Alice
' + + '


Confidential, from me@example.com

'; + render(); + await waitFor(() => expect(screen.queryByTestId('org-disclaimer-preview')).toBeNull()); + const sent = await send(onSend); + expect(countOf(sent.htmlBody!, 'Confidential')).toBe(1); + expect(countOf(sent.body, 'Confidential')).toBe(1); + }); }); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index c28a03354..7fccade1f 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -88,6 +88,8 @@ import { notifyIdentitySelected } from "@/stores/identity-store"; import { usePluginSignature } from "@/hooks/use-plugin-signature"; import { useOrgSignatureIdentity } from "@/hooks/use-org-signature"; import { OrgDisclaimerPreview } from "@/components/email/org-disclaimer-preview"; +// Registers the organisation signature check and the disclaimer on the send path. +import "@/lib/org/outgoing"; import { SubAddressHelper } from "@/components/identity/sub-address-helper"; import { generateSubAddress } from "@/lib/sub-addressing"; import { substitutePlaceholders, spliceTemplateAboveSignature, composeBodyHasUserContent } from "@/lib/template-utils"; @@ -3300,6 +3302,7 @@ export function EmailComposer({ identityId: currentIdentity?.id, mode, plainText: plainTextMode, + forwardedSeparator: tQuote('forwarded_separator'), }; preSendCheckingRef.current = true; let checksPassed = false; @@ -4411,7 +4414,7 @@ export function EmailComposer({ /> ) : null} {/* The admin's disclaimer: added on send (lib/org/outgoing.ts), never part of the body. */} - + {wantsPluginDraft && ( )} diff --git a/components/email/org-disclaimer-preview.tsx b/components/email/org-disclaimer-preview.tsx index cf72d2642..9d540894a 100644 --- a/components/email/org-disclaimer-preview.tsx +++ b/components/email/org-disclaimer-preview.tsx @@ -1,23 +1,37 @@ "use client"; +import { useDeferredValue, useMemo } from "react"; import { useTranslations } from "next-intl"; import type { Identity } from "@/lib/jmap/types"; import { sanitizeSignatureHtmlForDisplay } from "@/lib/email-sanitization"; import { getPlainTextSignature } from "@/lib/signature-utils"; +import { htmlCarriesDisclaimer, textCarriesDisclaimer } from "@/lib/org/body-match"; import { useOrgDisclaimer } from "@/hooks/use-org-signature"; /** * The admin's disclaimer under the composer's editor, read-only, with * "Added when you send". It is added by the send finaliser - * (lib/org/outgoing.ts), so it is never part of the body or a draft. + * (lib/org/outgoing.ts), so it is never part of the body or a draft. A body + * that already ends with it (a message back from Undo) gets no second copy + * on send, so the block isn't shown then either. */ -export function OrgDisclaimerPreview({ sending, plainText }: { +export function OrgDisclaimerPreview({ sending, plainText, body = "" }: { sending: Pick | null | undefined; plainText: boolean; + /** The composer's body: HTML, or text in plain-text mode. */ + body?: string; }) { const t = useTranslations("org_signature"); const disclaimer = useOrgDisclaimer(sending); - if (!disclaimer) return null; + // Parsing the body on every keystroke isn't needed for a hint. + const deferredBody = useDeferredValue(body); + const carried = useMemo( + () => !!disclaimer && !!deferredBody && (plainText + ? textCarriesDisclaimer(deferredBody, disclaimer) + : htmlCarriesDisclaimer(deferredBody, disclaimer)), + [disclaimer, deferredBody, plainText], + ); + if (!disclaimer || carried) return null; const html = !plainText && disclaimer.html ? sanitizeSignatureHtmlForDisplay(disclaimer.html) : ""; return (
s.signatureDetails); @@ -50,16 +49,5 @@ export function useOrgSignatureIdentity( /** The disclaimer added on send, filled in for mail from `sending`; null when there is none. */ export function useOrgDisclaimer(sending: Sender): FilledSignature | null { - const policy = useSenderPolicy(sending?.email); - const details = useSettingsStore((s) => s.signatureDetails); - const company = companyName(); - const name = sending?.name; - const email = sending?.email; - return useMemo( - () => buildOrgSignature( - (policy.organisation ?? DEFAULT_ORGANISATION_POLICY).disclaimer, - signatureValuesFor({ name: name ?? '', email: email ?? '' }, details, company), - ), - [policy, name, email, details, company], - ); + return useOrgSignatureSetup(sending).disclaimer; } diff --git a/lib/org/__tests__/outgoing-parts.test.ts b/lib/org/__tests__/outgoing-parts.test.ts new file mode 100644 index 000000000..6036c4d7c --- /dev/null +++ b/lib/org/__tests__/outgoing-parts.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { finalizeOutgoing, resetSendPipeline, type OutgoingContext } from '@/lib/outgoing/send-pipeline'; +import { registerOrgOutgoing } from '@/lib/org/outgoing'; +import { DEFAULT_POLICY, type SettingsPolicy } from '@/lib/admin/types'; +import { DEFAULT_ORGANISATION_POLICY, type OrganisationPolicy } from '@/lib/admin/organisation-policy'; +import { usePolicyStore } from '@/stores/policy-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useIdentityStore } from '@/stores/identity-store'; +import type { Identity } from '@/lib/jmap/types'; + +// The finalisers check and fix the HTML and the text part each on its own, +// against the matching template, by whole lines. Running them twice (a +// message sent again after Undo) adds nothing. + +const CONTEXT: OutgoingContext = { + surface: 'composer', + to: ['bob@example.com'], + cc: [], + bcc: [], + fromEmail: 'alice@example.org', + identityId: 'id-alice', + mode: 'compose', + plainText: false, + forwardedSeparator: '---------- Forwarded message ----------', +}; + +const count = (haystack: string | undefined, needle: string) => (haystack ?? '').split(needle).length - 1; + +function setOrganisation(organisation: Partial, locked = true) { + const policy: SettingsPolicy = { + ...DEFAULT_POLICY, + restrictions: locked ? { signature: { locked: true } } : {}, + organisation: { ...DEFAULT_ORGANISATION_POLICY, ...organisation }, + }; + usePolicyStore.setState({ policy, rawPolicy: policy, subject: null }); +} + +beforeEach(() => { + resetSendPipeline(); + registerOrgOutgoing(); + useIdentityStore.setState({ identities: [{ id: 'id-alice', name: 'Alice', email: 'alice@example.org', mayDelete: false } as Identity] }); + useSettingsStore.setState({ signatureDetails: { title: '', department: '', phone: '+49 1', mobile: '' }, signatureSeparatorEnabled: true }); +}); +afterEach(() => { + resetSendPipeline(); + usePolicyStore.setState({ policy: DEFAULT_POLICY, rawPolicy: DEFAULT_POLICY }); +}); + +describe('locked signature, HTML and text templates that differ', () => { + const signature = { html: '
Phone{phone}
', text: 'Call us: {phone}', fields: [] }; + + it('leaves a body that carries it alone, also when finalised twice', () => { + setOrganisation({ signature }); + const html = '

Hi



--
Phone+49 1
'; + const bodies = { html, text: 'Hi\n\n-- \nPhone +49 1' }; + expect(finalizeOutgoing(bodies, CONTEXT)).toEqual(bodies); + const plain = { text: 'Hi\n\n-- \nCall us: +49 1' }; + expect(finalizeOutgoing(finalizeOutgoing(plain, { ...CONTEXT, plainText: true }), { ...CONTEXT, plainText: true })).toEqual(plain); + }); + + it('puts it back in each part with that part\'s template, once', () => { + setOrganisation({ signature }); + const once = finalizeOutgoing({ html: '

Hi

', text: 'Hi' }, CONTEXT); + expect(count(once.html, '+49 1')).toBe(1); + expect(once.text).toBe('Hi\n\n-- \nCall us: +49 1'); + expect(finalizeOutgoing(once, CONTEXT)).toEqual(once); + }); + + it('counts a signature with a link as there', () => { + setOrganisation({ signature: { html: '

{name}
acme.test

', text: '', fields: [] } }); + const html = '

Hi



--

Alice
acme.test

'; + expect(finalizeOutgoing({ html, text: 'Hi\n\n-- \nAlice\nacme.test' }, CONTEXT).html).toBe(html); + }); + + it('matches whole lines: a word the user typed is not the signature', () => { + setOrganisation({ signature: { html: '', text: 'ACME', fields: [] } }); + const out = finalizeOutgoing({ text: 'ACME rocks' }, { ...CONTEXT, plainText: true }); + expect(out.text).toBe('ACME rocks\n\n-- \nACME'); + }); + + it('does not count the copy in a forwarded message', () => { + setOrganisation({ signature: { html: '', text: '{name}\nACME', fields: [] } }); + // (The very end counts: that is where the composer appends it below a forward.) + const text = 'FYI\n\n---------- Forwarded message ----------\nFrom: Alice\n\nOld text\n\n-- \nAlice\nACME\n\nSent on the road'; + const out = finalizeOutgoing({ text }, { ...CONTEXT, plainText: true, mode: 'forward' }); + expect(count(out.text, 'ACME')).toBe(2); + }); +}); + +describe('disclaimer, HTML and text templates that differ', () => { + const disclaimer = { html: '

Confidential. Imprint

', text: 'Confidential. Imprint: https://x.test/imprint' }; + + it('is added once to each part, also when finalised twice', () => { + setOrganisation({ disclaimer }, false); + const once = finalizeOutgoing({ html: '
Hi
', text: 'Hi' }, CONTEXT); + expect(count(once.html, 'Confidential.')).toBe(1); + expect(once.text).toBe('Hi\n\nConfidential. Imprint: https://x.test/imprint'); + expect(finalizeOutgoing(once, CONTEXT)).toEqual(once); + }); + + it('is not added again to a draft that came back from Undo through the editor', () => { + setOrganisation({ disclaimer }, false); + // The editor drops the wrapper's marker; the text part is made from the HTML. + const html = '

Hi


Confidential. Imprint

'; + const bodies = { html, text: 'Hi\n\nConfidential. Imprint ' }; + expect(finalizeOutgoing(bodies, CONTEXT)).toEqual(bodies); + }); +}); diff --git a/lib/org/body-match.ts b/lib/org/body-match.ts new file mode 100644 index 000000000..4dd5377c0 --- /dev/null +++ b/lib/org/body-match.ts @@ -0,0 +1,105 @@ +/** + * Whether an outgoing body already carries the organisation signature or + * the disclaimer, part by part. Matching is by whole lines, so a user who + * typed the word "ACME" doesn't count as carrying the signature "ACME", and + * the HTML part is matched against the HTML template while the text part + * is matched against the text template (and the text made from the HTML, + * which is what the text part of an HTML message holds). + */ + +import { extractUserAuthoredText } from '@/lib/email-composer-utils'; +import { sanitizeSignatureHtml } from '@/lib/email-sanitization'; +import { htmlToPlainText } from '@/lib/html-to-text'; +import { getPlainTextSignature } from '@/lib/signature-utils'; +import { SIGNATURE_BLOCK_MARKER } from '@/components/email/signature-block'; +import type { FilledSignature } from '@/lib/org-signature'; + +/** The disclaimer's wrapper in the HTML part (lib/org/outgoing.ts). */ +export const DISCLAIMER_MARKER = 'data-org-disclaimer'; + +const QUOTES = '[data-quoted-html], blockquote'; + +/** Non-empty lines with their whitespace collapsed. */ +export function textLines(value: string): string[] { + return value.split(/\r?\n/).map((line) => line.replace(/\s+/g, ' ').trim()).filter(Boolean); +} + +function indexOfLines(haystack: string[], needle: string[]): number { + if (needle.length === 0 || needle.length > haystack.length) return -1; + for (let i = 0; i + needle.length <= haystack.length; i++) { + if (needle.every((line, j) => haystack[i + j] === line)) return i; + } + return -1; +} + +const containsLines = (haystack: string[], needle: string[]) => indexOfLines(haystack, needle) >= 0; + +function endsWithLines(haystack: string[], needle: string[]): boolean { + if (needle.length === 0 || needle.length > haystack.length) return false; + const start = haystack.length - needle.length; + return needle.every((line, j) => haystack[start + j] === line); +} + +/** The lines a filled template shows in an HTML body. */ +export function htmlLinesOf(filled: FilledSignature): string[] { + return filled.html.trim() + ? textLines(htmlToPlainText(sanitizeSignatureHtml(filled.html))) + : textLines(filled.text); +} + +/** The lines a filled template shows in a text body. */ +export function textLinesOf(filled: FilledSignature): string[] { + return textLines(getPlainTextSignature({ textSignature: filled.text, htmlSignature: filled.html })); +} + +function parse(html: string): Document { + return new DOMParser().parseFromString(`${html}`, 'text/html'); +} + +/** + * Whether an HTML body carries the signature: the composer's signature + * block holding it, its lines in the user's part (no quote, nothing from + * the "Forwarded message" line on), or its lines at the very end, where the + * composer appends it below a quoted or forwarded message. + */ +export function htmlCarriesSignature(html: string, signature: FilledSignature, forwardedSeparator?: string): boolean { + const lines = htmlLinesOf(signature); + if (lines.length === 0) return true; + const doc = parse(html); + doc.querySelectorAll(QUOTES).forEach((el) => el.remove()); + for (const block of Array.from(doc.querySelectorAll(`[${SIGNATURE_BLOCK_MARKER}]`))) { + if (containsLines(textLines(htmlToPlainText(block.innerHTML)), lines)) return true; + } + const own = extractUserAuthoredText(html, { plainTextMode: false, forwardedSeparator }); + return containsLines(textLines(own), lines) || endsWithLines(textLines(htmlToPlainText(doc.body.innerHTML)), lines); +} + +/** + * Whether a text body carries the signature, as the text template or as the + * text made from the HTML one: in the user's part, or at the very end. + */ +export function textCarriesSignature(text: string, signature: FilledSignature, forwardedSeparator?: string): boolean { + const variants = [textLinesOf(signature), htmlLinesOf(signature)].filter((lines) => lines.length > 0); + if (variants.length === 0) return true; + const own = textLines(extractUserAuthoredText(text, { plainTextMode: true, forwardedSeparator })); + const unquoted = textLines(text.split(/\r?\n/).filter((line) => !/^\s*>/.test(line)).join('\n')); + return variants.some((lines) => containsLines(own, lines) || endsWithLines(unquoted, lines)); +} + +/** + * Whether an HTML body already ends with the disclaimer (a message sent + * again after Undo): its wrapper outside the quote, or its lines at the end + * once the quote is left out. + */ +export function htmlCarriesDisclaimer(html: string, disclaimer: FilledSignature): boolean { + const doc = parse(html); + doc.querySelectorAll(QUOTES).forEach((el) => el.remove()); + if (doc.querySelector(`[${DISCLAIMER_MARKER}]`)) return true; + return endsWithLines(textLines(htmlToPlainText(doc.body.innerHTML)), htmlLinesOf(disclaimer)); +} + +/** Whether a text body ends with the disclaimer, as the text template or as the text made from the HTML one. */ +export function textCarriesDisclaimer(text: string, disclaimer: FilledSignature): boolean { + const own = textLines(text.split(/\r?\n/).filter((line) => !/^\s*>/.test(line)).join('\n')); + return endsWithLines(own, textLinesOf(disclaimer)) || endsWithLines(own, htmlLinesOf(disclaimer)); +} diff --git a/lib/org/outgoing.ts b/lib/org/outgoing.ts index 15b3954e1..82ad184f6 100644 --- a/lib/org/outgoing.ts +++ b/lib/org/outgoing.ts @@ -1,24 +1,32 @@ /** * The organisation's changes to outgoing mail, as send finalisers * (lib/outgoing/send-pipeline.ts): they run for the composer and quick - * reply, on sends only, never on drafts. Registered once when this module - * is imported (components/mail/mail-app.tsx); each reads the effective - * policy at send time. + * reply, on sends only, never on drafts. Registered when this module is + * first imported (the composer and lib/outgoing/quick-reply.ts import it); + * each reads the effective policy at send time. * * - org-signature: a locked (or hidden) organisation signature the user - * deleted in the editor is put back once, at the end of their part. + * deleted or changed in the editor is appended again once, at the end of + * the message (below the quote, where a reply's signature goes by + * default). Each part is checked and fixed on its own. * - org-disclaimer: the admin's disclaimer below everything, quote * included: `
` and the HTML in the HTML part, a blank line and the * text in the text part (text from the HTML when the admin wrote none). + * A part that already ends with it keeps the one it has. */ import type { Identity } from '@/lib/jmap/types'; import { registerOutgoingFinalizer, type OutgoingBodies, type OutgoingContext } from '@/lib/outgoing/send-pipeline'; import { isSignatureEnforced, type FilledSignature } from '@/lib/org-signature'; -import { currentDisclaimer, currentOrgSignatureSetup } from '@/lib/org/signature-state'; -import { appendHtmlSignature, appendPlainTextSignature, getPlainTextSignature } from '@/lib/signature-utils'; -import { sanitizeSignatureHtml } from '@/lib/email-sanitization'; -import { htmlToPlainText } from '@/lib/html-to-text'; +import { currentOrgSignatureSetup } from '@/lib/org/signature-state'; +import { + DISCLAIMER_MARKER, + htmlCarriesDisclaimer, + htmlCarriesSignature, + textCarriesDisclaimer, + textCarriesSignature, +} from '@/lib/org/body-match'; +import { appendHtmlSignature, appendPlainTextSignature, getPlainTextSignature, signatureAsHtml } from '@/lib/signature-utils'; import { useIdentityStore } from '@/stores/identity-store'; import { useSettingsStore } from '@/stores/settings-store'; @@ -38,78 +46,50 @@ function senderOf(context: OutgoingContext): Pick | return byEmail ?? (context.fromEmail ? { name: '', email: context.fromEmail } : null); } -/** Text without any whitespace, for "does it contain" checks that survive reflowing. */ -const squash = (value: string) => value.replace(/\s+/g, ''); - const asSignature = (filled: FilledSignature) => ({ htmlSignature: filled.html, textSignature: filled.text }); -/** The user's part of the message as text: the quoted or forwarded message left out. */ -function ownText(bodies: OutgoingBodies): string { - if (bodies.html !== undefined) { - const doc = new DOMParser().parseFromString(`${bodies.html}`, 'text/html'); - doc.querySelectorAll('[data-quoted-html], blockquote').forEach((el) => el.remove()); - return htmlToPlainText(doc.body.innerHTML); - } - return bodies.text.split(/\r?\n/).filter((line) => !/^\s*>/.test(line)).join('\n'); -} - -/** Whether the user's part of the message carries `signatureText`. */ -function carriesSignature(bodies: OutgoingBodies, signatureText: string): boolean { - return squash(ownText(bodies)).includes(squash(signatureText)); -} - -const lines = (value: string) => value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); - -/** Whether the user's part of the message ends with `text`, line by line. */ -function endsWithText(bodies: OutgoingBodies, text: string): boolean { - const body = lines(ownText(bodies)); - const tail = lines(text); - if (tail.length === 0 || tail.length > body.length) return false; - return tail.every((line, i) => line === body[body.length - tail.length + i]); -} - /** - * Puts a locked organisation signature back when the user deleted it in - * the editor. The composer and quick reply insert it, so this does nothing - * unless it is missing, and then adds it once. + * Puts a locked organisation signature back when the user deleted or + * changed it. The composer and quick reply insert it, so this does nothing + * unless it is missing, and then adds it once to the part that lacks it. */ export function orgSignatureFinalizer(bodies: OutgoingBodies, context: OutgoingContext): OutgoingBodies { const setup = currentOrgSignatureSetup(senderOf(context)); if (!setup.signature || !isSignatureEnforced(setup.restriction)) return bodies; - const signature = asSignature(setup.signature); - const signatureText = getPlainTextSignature(signature); - if (!signatureText || carriesSignature(bodies, signatureText)) return bodies; - const separator = useSettingsStore.getState().signatureSeparatorEnabled; + const signature = setup.signature; + const separator = { separator: useSettingsStore.getState().signatureSeparatorEnabled }; + const htmlMissing = bodies.html !== undefined && !htmlCarriesSignature(bodies.html, signature, context.forwardedSeparator); + // The text part of an HTML message is made from the HTML: what the HTML + // lacks, it lacks too. + const textMissing = htmlMissing || !textCarriesSignature(bodies.text, signature, context.forwardedSeparator); + if (!htmlMissing && !textMissing) return bodies; return { - html: bodies.html === undefined ? undefined : appendHtmlSignature(bodies.html, signature, { separator }), - text: appendPlainTextSignature(bodies.text, signature, { separator }), + html: htmlMissing ? appendHtmlSignature(bodies.html!, asSignature(signature), separator) : bodies.html, + text: textMissing ? appendPlainTextSignature(bodies.text, asSignature(signature), separator) : bodies.text, }; } -function escapeText(value: string): string { - return value.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
'); -} - /** The disclaimer as it goes into the HTML part. */ export function disclaimerHtml(disclaimer: FilledSignature): string { - const inner = disclaimer.html ? sanitizeSignatureHtml(disclaimer.html) : escapeText(disclaimer.text); - return `
${inner}
`; + return `
${signatureAsHtml(asSignature(disclaimer))}
`; } /** - * Adds the disclaimer below everything. A body whose own part already ends - * with it (a message sent again after Undo, where the first send's copy came - * back into the draft) keeps the one it has; a quoted copy doesn't count. + * Adds the disclaimer below everything, quote included. A part that already + * ends with it (a message sent again after Undo, where the first send's copy + * came back into the draft) keeps the one it has; a quoted copy doesn't count. */ export function orgDisclaimerFinalizer(bodies: OutgoingBodies, context: OutgoingContext): OutgoingBodies { - const disclaimer = currentDisclaimer(senderOf(context)); + const disclaimer = currentOrgSignatureSetup(senderOf(context)).disclaimer; if (!disclaimer) return bodies; const text = getPlainTextSignature(asSignature(disclaimer)); if (!text) return bodies; - if (endsWithText(bodies, text)) return bodies; + const htmlMissing = bodies.html !== undefined && !htmlCarriesDisclaimer(bodies.html, disclaimer); + const textMissing = htmlMissing || !textCarriesDisclaimer(bodies.text, disclaimer); + if (!htmlMissing && !textMissing) return bodies; return { - html: bodies.html === undefined ? undefined : `${bodies.html}${disclaimerHtml(disclaimer)}`, - text: `${bodies.text.replace(/\s+$/, '')}\n\n${text}`, + html: htmlMissing ? `${bodies.html}${disclaimerHtml(disclaimer)}` : bodies.html, + text: textMissing ? `${bodies.text.replace(/\s+$/, '')}\n\n${text}` : bodies.text, }; } diff --git a/lib/org/signature-state.ts b/lib/org/signature-state.ts index aa243a076..611d4e9b2 100644 --- a/lib/org/signature-state.ts +++ b/lib/org/signature-state.ts @@ -24,7 +24,7 @@ import { getCachedConfig } from '@/hooks/use-config'; import { usePolicyStore } from '@/stores/policy-store'; import { useSettingsStore } from '@/stores/settings-store'; -interface PolicyView { +export interface PolicyView { policy: SettingsPolicy; rawPolicy: SettingsPolicy; subject: PolicySubject | null; @@ -52,19 +52,23 @@ export function policyForSender(view: PolicyView, email: string | undefined): Se export interface OrgSignatureSetup { /** Filled for the sending identity; null when the admin set no signature. */ signature: FilledSignature | null; + /** The disclaimer, filled the same way; null when the admin set none. */ + disclaimer: FilledSignature | null; restriction: SignatureRestriction; } -/** The organisation signature for mail from `sending`, from the given policy. */ +/** The organisation signature and disclaimer for mail from `sending`, from the given policy. */ export function orgSignatureSetup( policy: SettingsPolicy, sending: Pick | null | undefined, - details: SignatureDetails, + details: SignatureDetails | null | undefined, company: string, ): OrgSignatureSetup { const organisation = policy.organisation ?? DEFAULT_ORGANISATION_POLICY; + const values = signatureValuesFor(sending, details, company); return { - signature: buildOrgSignature(organisation.signature, signatureValuesFor(sending, details, company)), + signature: buildOrgSignature(organisation.signature, values), + disclaimer: buildOrgSignature(organisation.disclaimer, values), restriction: signatureRestriction(policy.restrictions?.signature), }; } @@ -87,13 +91,3 @@ export function currentSignatureIdentity( const setup = currentOrgSignatureSetup(sending ?? own); return effectiveSignatureIdentity(own, sending, setup.signature, setup.restriction); } - -/** The disclaimer, filled in for mail from `sending`; null when there is none. */ -export function currentDisclaimer(sending: Pick | null | undefined): FilledSignature | null { - const policy = policyForSender(usePolicyStore.getState(), sending?.email); - const organisation = policy.organisation ?? DEFAULT_ORGANISATION_POLICY; - return buildOrgSignature( - organisation.disclaimer, - signatureValuesFor(sending, useSettingsStore.getState().signatureDetails, companyName()), - ); -} diff --git a/lib/outgoing/quick-reply.ts b/lib/outgoing/quick-reply.ts index e90aefc27..434050c37 100644 --- a/lib/outgoing/quick-reply.ts +++ b/lib/outgoing/quick-reply.ts @@ -2,6 +2,8 @@ import type { Identity } from '@/lib/jmap/types'; import { appendHtmlSignature, appendPlainTextSignature } from '@/lib/signature-utils'; import { currentSignatureIdentity } from '@/lib/org/signature-state'; import { finalizeOutgoing, runPreSendChecks, type OutgoingBodies } from './send-pipeline'; +// Registers the organisation signature check and the disclaimer. +import '@/lib/org/outgoing'; /** * The bodies a quick reply sends: the typed text with the signature, as diff --git a/lib/outgoing/send-pipeline.ts b/lib/outgoing/send-pipeline.ts index bfe97765c..fed624a1d 100644 --- a/lib/outgoing/send-pipeline.ts +++ b/lib/outgoing/send-pipeline.ts @@ -28,6 +28,12 @@ export interface OutgoingContext { mode: 'compose' | 'reply' | 'replyAll' | 'forward'; /** Plain-text-only message (no HTML part). */ plainText: boolean; + /** + * The composer's "Forwarded message" line in the UI language, where the + * forwarded original starts; finalisers looking for what the user wrote + * stop there. + */ + forwardedSeparator?: string; } export interface OutgoingBodies { diff --git a/lib/signature-utils.ts b/lib/signature-utils.ts index d130c0d11..5b2e135eb 100644 --- a/lib/signature-utils.ts +++ b/lib/signature-utils.ts @@ -100,22 +100,26 @@ export function appendHtmlSignature( signature?: SignatureSource | null, options: { separator?: boolean } = {}, ): string { + const html = signatureAsHtml(signature); + if (!html) return htmlBody; const sep = wantsSignatureSeparator(options.separator, signature) ? '

--
' : '

'; + return `${htmlBody}${sep}${html}`; +} - if (signature?.htmlSignature?.trim()) { - return `${htmlBody}${sep}${sanitizeSignatureHtml(signature.htmlSignature)}`; - } - +/** + * A signature as it goes into an HTML body: its HTML, sanitised, or its + * text, escaped, with line breaks. Empty without either. + */ +export function signatureAsHtml(signature?: SignatureSource | null): string { + if (signature?.htmlSignature?.trim()) return sanitizeSignatureHtml(signature.htmlSignature); if (signature?.textSignature?.trim()) { - const escaped = signature.textSignature + return signature.textSignature .replace(/&/g, '&') .replace(//g, '>') .replace(/\n/g, '
'); - return `${htmlBody}${sep}${escaped}`; } - - return htmlBody; + return ''; } export function hasMeaningfulHtmlBody(html: string): boolean {