mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: check the signature and disclaimer in each part on its own so neither goes out twice
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\n/g, '<br>');
|
||||
return `${htmlBody}${sep}${escaped}`;
|
||||
}
|
||||
|
||||
return htmlBody;
|
||||
return '';
|
||||
}
|
||||
|
||||
export function hasMeaningfulHtmlBody(html: string): boolean {
|
||||
|
||||
Reference in New Issue
Block a user