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: '', text: 'Call us: {phone}', fields: [] };
+
+ it('leaves a body that carries it alone, also when finalised twice', () => {
+ setOrganisation({ signature });
+ const html = '
--
';
+ 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: '', 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 = '
--
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 = '';
+ 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 {