fix: check the signature and disclaimer in each part on its own so neither goes out twice

This commit is contained in:
Linus Rath
2026-10-28 04:00:25 +01:00
parent 893ecb5b16
commit 3fbd5a7818
12 changed files with 318 additions and 101 deletions
@@ -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 = '<p>Hi Bob</p><p data-signature-block="separator">-- </p><div data-signature-block-node=""><b>Alice</b></div>'
+ '<p data-signature-block="end"></p><hr><p>Confidential, from me@example.com</p>';
render(<EmailComposer initialData={{ ...DRAFT, body }} onSend={onSend} />);
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);
});
});
+4 -1
View File
@@ -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. */}
<OrgDisclaimerPreview sending={currentIdentity} plainText={plainTextMode} />
<OrgDisclaimerPreview sending={currentIdentity} plainText={plainTextMode} body={body} />
{wantsPluginDraft && (
<PluginSlot name="composer-below-body" className="w-full" extraProps={pluginDraftProps} />
)}
+17 -3
View File
@@ -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<Identity, "name" | "email"> | 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 (
<div
-2
View File
@@ -113,8 +113,6 @@ import { WopiEditor } from "@/components/files/wopi-editor";
import { useWopiStatus, canWopiOpen } from "@/hooks/use-wopi-status";
import { isFilePreviewable } from "@/lib/file-preview";
import { prepareQuickReply } from "@/lib/outgoing/quick-reply";
// Registers the organisation signature check and the disclaimer on the send path.
import "@/lib/org/outgoing";
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
import { findDraftIdentityId, findReplyIdentityId, resolveComposeAccountEmail } from "@/lib/reply-identity";
+3 -15
View File
@@ -1,7 +1,6 @@
import { useMemo } from 'react';
import type { Identity } from '@/lib/jmap/types';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
import { buildOrgSignature, effectiveSignatureIdentity, signatureValuesFor, type FilledSignature } from '@/lib/org-signature';
import { effectiveSignatureIdentity, type FilledSignature } from '@/lib/org-signature';
import { companyName, orgSignatureSetup, policyForSender, type OrgSignatureSetup } from '@/lib/org/signature-state';
import { usePolicyStore } from '@/stores/policy-store';
import { useSettingsStore } from '@/stores/settings-store';
@@ -19,7 +18,7 @@ function useSenderPolicy(email: string | undefined) {
);
}
/** The organisation signature filled in for mail from `sending`, and how it is restricted. */
/** The organisation signature and disclaimer filled in for mail from `sending`, and how the signature is restricted. */
export function useOrgSignatureSetup(sending: Sender): OrgSignatureSetup {
const policy = useSenderPolicy(sending?.email);
const details = useSettingsStore((s) => s.signatureDetails);
@@ -50,16 +49,5 @@ export function useOrgSignatureIdentity<T extends Identity>(
/** 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;
}
+108
View File
@@ -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<OrganisationPolicy>, 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: '<table><tr><td>Phone</td><td>{phone}</td></tr></table>', text: 'Call us: {phone}', fields: [] };
it('leaves a body that carries it alone, also when finalised twice', () => {
setOrganisation({ signature });
const html = '<div><p>Hi</p></div><br><br>-- <br><table><tr><td>Phone</td><td>+49 1</td></tr></table>';
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: '<div><p>Hi</p></div>', text: 'Hi' }, CONTEXT);
expect(count(once.html, '<td>+49 1</td>')).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: '<p>{name}<br><a href="https://acme.test">acme.test</a></p>', text: '', fields: [] } });
const html = '<div><p>Hi</p></div><br><br>-- <br><p>Alice<br><a href="https://acme.test">acme.test</a></p>';
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: '<p>Confidential. <a href="https://x.test/imprint">Imprint</a></p>', 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: '<div>Hi</div>', 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 = '<div><p>Hi</p><hr><p>Confidential. <a href="https://x.test/imprint">Imprint</a></p></div>';
const bodies = { html, text: 'Hi\n\nConfidential. Imprint <https://x.test/imprint>' };
expect(finalizeOutgoing(bodies, CONTEXT)).toEqual(bodies);
});
});
+105
View File
@@ -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(`<!doctype html><html><body>${html}</body></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));
}
+38 -58
View File
@@ -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: `<hr>` 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<Identity, 'name' | 'email'> |
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(`<!doctype html><html><body>${bodies.html}</body></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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>');
}
/** 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 `<hr><div data-org-disclaimer="">${inner}</div>`;
return `<hr><div ${DISCLAIMER_MARKER}="">${signatureAsHtml(asSignature(disclaimer))}</div>`;
}
/**
* 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,
};
}
+8 -14
View File
@@ -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<Identity, 'name' | 'email'> | 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<T extends Identity>(
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<Identity, 'name' | 'email'> | 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()),
);
}
+2
View File
@@ -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
+6
View File
@@ -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 {
+12 -8
View File
@@ -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) ? '<br><br>-- <br>' : '<br><br>';
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>');
return `${htmlBody}${sep}${escaped}`;
}
return htmlBody;
return '';
}
export function hasMeaningfulHtmlBody(html: string): boolean {