mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
feat: add the organisation's signature and disclaimer to new mail and hide templates when they are off
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { webmailLinksFromConfig } from '../../lib/org-policy';
|
||||
|
||||
// The organisation's templates gate, signature and disclaimer in the
|
||||
// composer and quick reply (webmail email-composer.tsx, hooks/use-org-signature.ts,
|
||||
// lib/org/outgoing.ts, lib/outgoing/quick-reply.ts). No React Native
|
||||
// renderer in the test env, so this checks the wiring; the pure parts have
|
||||
// unit tests in lib/__tests__/org-signature.test.ts.
|
||||
|
||||
const read = (rel: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', rel), 'utf8');
|
||||
|
||||
describe('organisation signature wiring', () => {
|
||||
it('hides the template picker when the organisation turns templates off', () => {
|
||||
const src = read('screens/ComposeScreen.tsx');
|
||||
expect(src).toMatch(/const templatesEnabled = usePolicyFeature\('templatesEnabled'\);/);
|
||||
expect(src).toMatch(/\{templatesEnabled && \(\s+<ToolbarButton onPress=\{\(\) => setTemplateSheetOpen\(true\)\}/);
|
||||
});
|
||||
|
||||
it('signs with the organisation signature filled in for the sending identity', () => {
|
||||
const src = read('screens/ComposeScreen.tsx');
|
||||
expect(src).toMatch(/orgSignatureSetup\(orgPolicy, primaryIdentity, null, orgCompany\)/);
|
||||
expect(src).toMatch(/const signatureIdentity = React\.useMemo\(\s+\(\) => effectiveSignatureIdentity\(ownSignatureIdentity, primaryIdentity, orgSetup\.signature, orgSetup\.restriction\)/);
|
||||
});
|
||||
|
||||
it('adds the locked signature and the disclaimer on send, not to drafts', () => {
|
||||
const src = read('screens/ComposeScreen.tsx');
|
||||
expect(src).toMatch(/if \(!opts\.forDraft\) \{\s+const setup = orgSignatureSetup\(usePolicyStore\.getState\(\)\.effective, identity,[^\n]*\n\s+const finished = finalizeOrgOutgoing\(\{ html: htmlBody, text: textBody \}, setup,/);
|
||||
expect(src).toMatch(/htmlBody = finished\.html;\s+textBody = finished\.text;/);
|
||||
});
|
||||
|
||||
it('shows the disclaimer under the editor', () => {
|
||||
const src = read('screens/ComposeScreen.tsx');
|
||||
expect(src).toMatch(/\{!!orgDisclaimerText && \(\s+<View style=\{styles\.orgDisclaimer\} testID="org-disclaimer-preview">/);
|
||||
expect(src).toMatch(/t\('org_signature\.disclaimer_added_on_send', 'Added when you send'\)/);
|
||||
});
|
||||
|
||||
it('signs a quick reply the same way', () => {
|
||||
const src = read('components/email/QuickReplyBox.tsx');
|
||||
expect(src).toMatch(/const signer = effectiveSignatureIdentity\(signatureIdentityFor\(identity, identities\), identity, orgSetup\.signature, orgSetup\.restriction\);/);
|
||||
expect(src).toMatch(/textBody: finalizeOrgOutgoing\(\{\s+text: signPlainTextReply\(body, `\$\{header\.text\}\$\{quoted\}`, signer,/);
|
||||
});
|
||||
|
||||
it('reads the company name for {company} from the webmail config', () => {
|
||||
expect(webmailLinksFromConfig({ loginCompanyName: ' ACME Ltd ' }).companyName).toBe('ACME Ltd');
|
||||
expect(webmailLinksFromConfig({ loginCompanyName: '' }).companyName).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,8 @@ import { buildReplySubject } from '../../lib/subject-prefix';
|
||||
import { computeReplyThreadingHeaders } from '../../lib/email-threading';
|
||||
import { resolveReplyIdentity } from '../../lib/reply-identity';
|
||||
import { signatureIdentityFor, signPlainTextReply } from '../../lib/signature-utils';
|
||||
import { effectiveSignatureIdentity, finalizeOrgOutgoing, orgSignatureSetup } from '../../lib/org-signature';
|
||||
import { usePolicyStore } from '../../stores/policy-store';
|
||||
import { pickEmailBody, plainTextBody } from '../../lib/email-body';
|
||||
import { htmlToPlainText } from '../../lib/compose-html';
|
||||
import { sendTargetFor } from '../../lib/compose-send-account';
|
||||
@@ -193,6 +195,11 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent, onF
|
||||
labels: quoteHeaderLabels(t),
|
||||
});
|
||||
const threading = computeReplyThreadingHeaders(email);
|
||||
// The organisation signature and disclaimer, as the composer adds them
|
||||
// (webmail lib/outgoing/quick-reply.ts, lib/org/outgoing.ts).
|
||||
const policy = usePolicyStore.getState();
|
||||
const orgSetup = orgSignatureSetup(policy.effective, identity, null, policy.links.companyName ?? '');
|
||||
const signer = effectiveSignatureIdentity(signatureIdentityFor(identity, identities), identity, orgSetup.signature, orgSetup.restriction);
|
||||
const holdFor = jmapClient.undoSendHold(sendDelaySeconds, target.accountId);
|
||||
// Where the reply goes, read before it goes: Undo must reach that login
|
||||
// and account whatever the app switches to meanwhile.
|
||||
@@ -205,10 +212,12 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent, onF
|
||||
subject: buildReplySubject(email.subject, t('email_composer.prefix.reply', 'Re:')),
|
||||
// Signed as the composer signs a plain-text reply; an alias without a
|
||||
// signature of its own carries the primary identity's.
|
||||
textBody: signPlainTextReply(body, `${header.text}${quoted}`, signatureIdentityFor(identity, identities), {
|
||||
position: signaturePosition,
|
||||
separator: signatureSeparatorEnabled,
|
||||
}),
|
||||
textBody: finalizeOrgOutgoing({
|
||||
text: signPlainTextReply(body, `${header.text}${quoted}`, signer, {
|
||||
position: signaturePosition,
|
||||
separator: signatureSeparatorEnabled,
|
||||
}),
|
||||
}, orgSetup, { separator: signatureSeparatorEnabled }).text,
|
||||
inReplyTo: threading?.inReplyTo,
|
||||
references: threading?.references,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
buildOrgSignature,
|
||||
effectiveSignatureIdentity,
|
||||
fillHtmlTemplate,
|
||||
fillTextTemplate,
|
||||
finalizeOrgOutgoing,
|
||||
orgSignatureSetup,
|
||||
placeholdersIn,
|
||||
signatureValuesFor,
|
||||
textCarriesDisclaimer,
|
||||
htmlCarriesDisclaimer,
|
||||
type OrgSignatureSetup,
|
||||
} from '../org-signature';
|
||||
import { sanitizePolicy, type OrgPolicy } from '../org-policy';
|
||||
|
||||
// Ported from the webmail's lib/__tests__/org-signature.test.ts and
|
||||
// lib/org/__tests__/outgoing.test.ts.
|
||||
|
||||
const FULL = signatureValuesFor(
|
||||
{ name: 'Alex Example', email: 'alex@Example.org' },
|
||||
{ title: 'CEO', department: 'Sales', phone: '+49 30 1', mobile: '+49 170 2' },
|
||||
'ACME Ltd',
|
||||
);
|
||||
const NO_DETAILS = signatureValuesFor({ name: 'Alex Example', email: 'alex@example.org' }, null, 'ACME Ltd');
|
||||
|
||||
describe('placeholder values', () => {
|
||||
it('derives identity, domain and company from the identity and the config', () => {
|
||||
expect(FULL).toMatchObject({
|
||||
name: 'Alex Example',
|
||||
email: 'alex@Example.org',
|
||||
identity: 'Alex Example <alex@Example.org>',
|
||||
domain: 'example.org',
|
||||
company: 'ACME Ltd',
|
||||
title: 'CEO',
|
||||
});
|
||||
expect(signatureValuesFor({ name: '', email: 'x@y.test' }, null, '').identity).toBe('x@y.test');
|
||||
expect(placeholdersIn('{name} {phone} {unknown} {phone}')).toEqual(['name', 'phone']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fillTextTemplate', () => {
|
||||
it('fills in every placeholder and leaves unknown braces alone', () => {
|
||||
expect(fillTextTemplate('{name}, {title}\n{company} ({domain}) {other}', FULL))
|
||||
.toBe('Alex Example, CEO\nACME Ltd (example.org) {other}');
|
||||
});
|
||||
|
||||
it('drops a line whose placeholders are all empty, and keeps one with some value', () => {
|
||||
const template = '{name}\n{title}, {department}\nPhone: {phone}\nMobile: {mobile}\nACME Ltd';
|
||||
expect(fillTextTemplate(template, NO_DETAILS)).toBe('Alex Example\nACME Ltd');
|
||||
expect(fillTextTemplate(template, { ...NO_DETAILS, department: 'Sales' })).toBe('Alex Example\n, Sales\nACME Ltd');
|
||||
});
|
||||
|
||||
it('does not escape in plain text', () => {
|
||||
expect(fillTextTemplate('{name}', { name: 'A & <B>' })).toBe('A & <B>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fillHtmlTemplate', () => {
|
||||
it('HTML-escapes the values, in text and in attributes', () => {
|
||||
const html = fillHtmlTemplate('<p>{name}</p><a href="tel:{phone}">call</a>', {
|
||||
name: '<img src=x onerror=alert(1)> & co',
|
||||
phone: '1" onclick="x',
|
||||
});
|
||||
expect(html).toContain('<p><img src=x onerror=alert(1)> & co</p>');
|
||||
expect(html).toContain('href="tel:1" onclick="x"');
|
||||
expect(html).not.toContain('<img');
|
||||
});
|
||||
|
||||
it('drops lines between <br>s whose placeholders are all empty, with their line break', () => {
|
||||
const template = '<p><b>{name}</b><br>{title}<br>Phone: {phone}<br>Mobile: {mobile}</p><p>ACME Ltd</p>';
|
||||
expect(fillHtmlTemplate(template, FULL))
|
||||
.toBe('<p><b>Alex Example</b><br>CEO<br>Phone: +49 30 1<br>Mobile: +49 170 2</p><p>ACME Ltd</p>');
|
||||
expect(fillHtmlTemplate(template, NO_DETAILS)).toBe('<p><b>Alex Example</b></p><p>ACME Ltd</p>');
|
||||
expect(fillHtmlTemplate(template, { ...NO_DETAILS, mobile: '9' }))
|
||||
.toBe('<p><b>Alex Example</b><br>Mobile: 9</p><p>ACME Ltd</p>');
|
||||
});
|
||||
|
||||
it('drops a paragraph and a table row whose placeholders are all empty', () => {
|
||||
const template = '<p>{name}</p><p>Phone: {phone}</p><table><tbody><tr><td>Mobile</td><td>{mobile}</td></tr>'
|
||||
+ '<tr><td>Web</td><td>example.org</td></tr></tbody></table>';
|
||||
expect(fillHtmlTemplate(template, NO_DETAILS))
|
||||
.toBe('<p>Alex Example</p><table><tbody><tr><td>Web</td><td>example.org</td></tr></tbody></table>');
|
||||
});
|
||||
|
||||
it('drops a line whose only placeholder is in a link', () => {
|
||||
expect(fillHtmlTemplate('{name}<br><a href="tel:{phone}">Call me</a>', NO_DETAILS)).toBe('Alex Example');
|
||||
});
|
||||
|
||||
it('keeps lines and blocks without placeholders, empty ones included', () => {
|
||||
expect(fillHtmlTemplate('<p>{mobile}</p><p><br></p><hr><p>Static</p>', NO_DETAILS))
|
||||
.toBe('<p><br></p><hr><p>Static</p>');
|
||||
});
|
||||
|
||||
it('finds lines inside inline wrappers, the way Outlook and Gmail write signatures', () => {
|
||||
const outlook = '<div><span style="font-family:Arial"><b>{name}</b><br>Phone: {phone}<br>Mobile: {mobile}</span></div>';
|
||||
expect(fillHtmlTemplate(outlook, { ...NO_DETAILS, phone: '1' }))
|
||||
.toBe('<div><span style="font-family:Arial"><b>Alex Example</b><br>Phone: 1</span></div>');
|
||||
const gmail = '<div dir="ltr"><font face="arial"><span>{name}<br></span><span>T: {phone}<br></span><span>ACME</span></font></div>';
|
||||
expect(fillHtmlTemplate(gmail, NO_DETAILS))
|
||||
.toBe('<div dir="ltr"><font face="arial"><span>Alex Example<br></span><span>ACME</span></font></div>');
|
||||
expect(fillHtmlTemplate('<p>{name}<br><b>Mobile:</b> <i>{mobile}</i><br>ACME</p>', NO_DETAILS)).toBe('<p>Alex Example<br>ACME</p>');
|
||||
});
|
||||
|
||||
it('keeps a logo on a line whose placeholders are empty, and an image that took a placeholder goes', () => {
|
||||
expect(fillHtmlTemplate('<div><img src="https://acme.test/logo.png" alt="ACME"> {phone}</div><div>{name}</div>', NO_DETAILS))
|
||||
.toBe('<div><img src="https://acme.test/logo.png" alt="ACME"></div><div>Alex Example</div>');
|
||||
expect(fillHtmlTemplate('<p>{name}<br><img src="https://acme.test/{title}.png"></p>', NO_DETAILS)).toBe('<p>Alex Example</p>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildOrgSignature', () => {
|
||||
it('is null without a template, and fills both templates otherwise', () => {
|
||||
expect(buildOrgSignature({ html: ' ', text: '' }, FULL)).toBeNull();
|
||||
expect(buildOrgSignature({ html: '<p>{name}</p>', text: '' }, FULL)).toEqual({ html: '<p>Alex Example</p>', text: '' });
|
||||
expect(buildOrgSignature({ html: '', text: '{name}' }, FULL)).toEqual({ html: '', text: 'Alex Example' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('effectiveSignatureIdentity', () => {
|
||||
const ME = { id: 'me', name: 'Me', email: 'me@example.org', htmlSignature: '<b>Mine</b>', textSignature: 'Mine' };
|
||||
const TEAM = { id: 'team', name: 'Team', email: 'team@example.org', htmlSignature: '', textSignature: '' };
|
||||
const ORG = { html: '<p>Org</p>', text: 'Org' };
|
||||
const NONE = { locked: false, hidden: false };
|
||||
|
||||
it('changes nothing without an organisation signature', () => {
|
||||
expect(effectiveSignatureIdentity(ME, ME, null, { locked: true, hidden: false })).toBe(ME);
|
||||
});
|
||||
|
||||
it('is the default for an identity without its own signature', () => {
|
||||
expect(effectiveSignatureIdentity(ME, ME, ORG, NONE)).toBe(ME);
|
||||
expect(effectiveSignatureIdentity(null, TEAM, ORG, NONE)).toEqual({ ...TEAM, htmlSignature: '<p>Org</p>', textSignature: 'Org' });
|
||||
});
|
||||
|
||||
it('replaces every signature when locked or hidden, as the sending identity', () => {
|
||||
expect(effectiveSignatureIdentity(ME, TEAM, ORG, { locked: true, hidden: false }))
|
||||
.toEqual({ ...TEAM, htmlSignature: '<p>Org</p>', textSignature: 'Org' });
|
||||
expect(effectiveSignatureIdentity(ME, ME, ORG, { locked: false, hidden: true })?.htmlSignature).toBe('<p>Org</p>');
|
||||
});
|
||||
});
|
||||
|
||||
function policyWith(raw: Record<string, unknown>): OrgPolicy {
|
||||
const policy = sanitizePolicy({ features: {}, restrictions: {}, defaults: {}, ...raw });
|
||||
if (!policy) throw new Error('policy did not sanitize');
|
||||
return policy;
|
||||
}
|
||||
|
||||
const ALICE = { name: 'Alice', email: 'alice@example.org' };
|
||||
const DETAILS = { title: '', department: '', phone: '+49 1', mobile: '' };
|
||||
|
||||
describe('orgSignatureSetup', () => {
|
||||
it('reads the templates and the signature restriction from the policy', () => {
|
||||
const policy = policyWith({
|
||||
restrictions: { signature: { locked: true } },
|
||||
organisation: { signature: { html: '<p>{name}</p>', text: '{name}' }, disclaimer: { html: '', text: 'Confidential {domain}' } },
|
||||
});
|
||||
expect(orgSignatureSetup(policy, ALICE, null, '')).toEqual({
|
||||
signature: { html: '<p>Alice</p>', text: 'Alice' },
|
||||
disclaimer: { html: '', text: 'Confidential example.org' },
|
||||
restriction: { locked: true, hidden: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('has nothing without a policy or without templates', () => {
|
||||
expect(orgSignatureSetup(null, ALICE, null, '')).toEqual({ signature: null, disclaimer: null, restriction: { locked: false, hidden: false } });
|
||||
expect(orgSignatureSetup(policyWith({ organisation: { signature: 'nope' } }), ALICE, null, '').signature).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
const SEPARATOR = { separator: true };
|
||||
|
||||
function lockedSetup(): OrgSignatureSetup {
|
||||
const policy = policyWith({
|
||||
restrictions: { signature: { locked: true } },
|
||||
organisation: { signature: { html: '<p><b>{name}</b><br>Phone: {phone}</p>', text: '{name}\nPhone: {phone}' } },
|
||||
});
|
||||
return orgSignatureSetup(policy, ALICE, DETAILS, '');
|
||||
}
|
||||
|
||||
describe('locked organisation signature on send', () => {
|
||||
it('puts it back once when the user deleted it from an HTML mail', () => {
|
||||
const out = finalizeOrgOutgoing({ html: '<div><p>Hi</p></div>', text: 'Hi' }, lockedSetup(), SEPARATOR);
|
||||
expect(out.html).toBe('<div><p>Hi</p></div><br><br>-- <br><p><b>Alice</b><br>Phone: +49 1</p>');
|
||||
expect(out.text).toBe('Hi\n\n-- \nAlice\nPhone: +49 1');
|
||||
expect(finalizeOrgOutgoing(out, lockedSetup(), SEPARATOR)).toEqual(out);
|
||||
});
|
||||
|
||||
it('puts it back in a plain-text mail', () => {
|
||||
expect(finalizeOrgOutgoing({ text: 'Hi' }, lockedSetup(), SEPARATOR)).toEqual({ html: undefined, text: 'Hi\n\n-- \nAlice\nPhone: +49 1' });
|
||||
});
|
||||
|
||||
it('leaves a mail that carries it alone', () => {
|
||||
const html = '<div><p>Hi</p><p data-signature-block="separator">-- </p><div data-signature-block="body"><p><b>Alice</b><br>Phone: +49 1</p></div><p data-signature-block="end"><br></p></div>';
|
||||
const bodies = { html, text: 'Hi\n\n-- \nAlice\nPhone: +49 1' };
|
||||
expect(finalizeOrgOutgoing(bodies, lockedSetup(), SEPARATOR)).toEqual(bodies);
|
||||
const text = { text: 'Hi\n\n-- \nAlice\nPhone: +49 1\n\nOn Monday Bob wrote:\n> question' };
|
||||
expect(finalizeOrgOutgoing(text, lockedSetup(), SEPARATOR)).toEqual(text);
|
||||
});
|
||||
|
||||
it('does not count the copy in the quoted message', () => {
|
||||
const html = '<div><p>Hi</p><blockquote><p><b>Alice</b><br>Phone: +49 1</p></blockquote></div>';
|
||||
const out = finalizeOrgOutgoing({ html, text: 'Hi\n\n> Alice\n> Phone: +49 1' }, lockedSetup(), SEPARATOR);
|
||||
expect(out.html!.split('Phone: +49 1').length - 1).toBe(2);
|
||||
expect(out.text.endsWith('-- \nAlice\nPhone: +49 1')).toBe(true);
|
||||
});
|
||||
|
||||
it('does nothing when the signature is not locked', () => {
|
||||
const setup = { ...lockedSetup(), restriction: { locked: false, hidden: false } };
|
||||
const bodies = { html: '<div><p>Hi</p></div>', text: 'Hi' };
|
||||
expect(finalizeOrgOutgoing(bodies, setup, SEPARATOR)).toEqual(bodies);
|
||||
});
|
||||
});
|
||||
|
||||
function disclaimerSetup(disclaimer: { html: string; text: string }): OrgSignatureSetup {
|
||||
return orgSignatureSetup(policyWith({ organisation: { disclaimer } }), ALICE, null, '');
|
||||
}
|
||||
|
||||
describe('organisation disclaimer on send', () => {
|
||||
it('goes below everything: a line and the HTML, a blank line and the text', () => {
|
||||
const setup = disclaimerSetup({ html: '<p>Confidential <b>{domain}</b></p>', text: 'Confidential {domain}' });
|
||||
const out = finalizeOrgOutgoing({ html: '<div><p>Hi</p><blockquote>quote</blockquote></div>', text: 'Hi\n\n> quote\n' }, setup, SEPARATOR);
|
||||
expect(out.html).toBe('<div><p>Hi</p><blockquote>quote</blockquote></div><hr><div data-org-disclaimer=""><p>Confidential <b>example.org</b></p></div>');
|
||||
expect(out.text).toBe('Hi\n\n> quote\n\nConfidential example.org');
|
||||
});
|
||||
|
||||
it('makes the text from the HTML when there is none, and only text for a plain-text mail', () => {
|
||||
// The app's htmlToPlainText keeps a blank line between paragraphs.
|
||||
const setup = disclaimerSetup({ html: '<p>Line one</p><p>Line two</p>', text: '' });
|
||||
expect(finalizeOrgOutgoing({ html: '<div>Hi</div>', text: 'Hi' }, setup, SEPARATOR).text).toBe('Hi\n\nLine one\n\nLine two');
|
||||
expect(finalizeOrgOutgoing({ text: 'Hi' }, setup, SEPARATOR)).toEqual({ html: undefined, text: 'Hi\n\nLine one\n\nLine two' });
|
||||
});
|
||||
|
||||
it('is added once: not again to a body that ends with it, but also to a reply quoting it', () => {
|
||||
const setup = disclaimerSetup({ html: '', text: 'Confidential.' });
|
||||
const once = finalizeOrgOutgoing({ html: '<div>Hi</div>', text: 'Hi' }, setup, SEPARATOR);
|
||||
expect(once.html).toBe('<div>Hi</div><hr><div data-org-disclaimer="">Confidential.</div>');
|
||||
expect(finalizeOrgOutgoing(once, setup, SEPARATOR)).toEqual(once);
|
||||
expect(htmlCarriesDisclaimer(once.html!, setup.disclaimer!)).toBe(true);
|
||||
const reply = finalizeOrgOutgoing({ text: 'Thanks\n\n> Hi\n>\n> Confidential.' }, setup, SEPARATOR);
|
||||
expect(reply.text).toBe('Thanks\n\n> Hi\n>\n> Confidential.\n\nConfidential.');
|
||||
expect(textCarriesDisclaimer(reply.text, setup.disclaimer!)).toBe(true);
|
||||
const htmlReply = finalizeOrgOutgoing(
|
||||
{ html: '<div><p>Thanks</p><blockquote><p>Hi</p><p>Confidential.</p></blockquote></div>', text: 'Thanks\n\nHi\n\nConfidential.' },
|
||||
setup, SEPARATOR,
|
||||
);
|
||||
expect(htmlReply.html!.split('Confidential.').length - 1).toBe(2);
|
||||
expect(htmlReply.text).toBe('Thanks\n\nHi\n\nConfidential.\n\nConfidential.');
|
||||
});
|
||||
|
||||
it('comes after a locked signature that was put back', () => {
|
||||
const policy = policyWith({
|
||||
restrictions: { signature: { locked: true } },
|
||||
organisation: { signature: { html: '', text: 'Sig {name}' }, disclaimer: { html: '', text: 'Disclaimer' } },
|
||||
});
|
||||
const out = finalizeOrgOutgoing({ text: 'Hi' }, orgSignatureSetup(policy, ALICE, null, ''), SEPARATOR);
|
||||
expect(out.text).toBe('Hi\n\n-- \nSig Alice\n\nDisclaimer');
|
||||
});
|
||||
});
|
||||
@@ -478,6 +478,8 @@ export interface WebmailLinks {
|
||||
helpUrl?: string;
|
||||
easyReadUrl?: string;
|
||||
signLanguageUrl?: string;
|
||||
/** The config's company name (`loginCompanyName`), for the organisation signature's {company}. */
|
||||
companyName?: string;
|
||||
}
|
||||
|
||||
/** The accessibility and help links, in the webmail's fixed order (lib/help-links.ts). */
|
||||
@@ -501,6 +503,9 @@ export function webmailLinksFromConfig(raw: unknown): WebmailLinks {
|
||||
const value = raw[key];
|
||||
if (typeof value === 'string' && LINK_RE.test(value.trim())) out[key] = value.trim();
|
||||
}
|
||||
if (typeof raw.loginCompanyName === 'string' && raw.loginCompanyName.trim()) {
|
||||
out.companyName = raw.loginCompanyName.trim().slice(0, 200);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,569 @@
|
||||
/**
|
||||
* The organisation signature and disclaimer (policy `organisation.signature`
|
||||
* and `organisation.disclaimer`). Port of the webmail's lib/org-signature.ts,
|
||||
* lib/org/signature-state.ts (orgSignatureSetup), lib/org/body-match.ts and
|
||||
* lib/org/outgoing.ts, without a DOM: HTML is handled as strings through a
|
||||
* small tree parser.
|
||||
*
|
||||
* - The templates are filled in per sending identity with its name and
|
||||
* address, the company name and the details each user fills in once
|
||||
* (title, department, phone, mobile). Values go in as text, HTML-escaped
|
||||
* in the HTML template (attributes included). A line whose placeholders
|
||||
* are all empty is dropped; a table row counts as one line.
|
||||
* - Which signature the composer inserts (effectiveSignatureIdentity): with
|
||||
* no restriction, the organisation signature stands in for an identity
|
||||
* without a signature of its own; with `signature` locked or hidden it
|
||||
* replaces every identity's.
|
||||
* - On send (never on a draft): a locked organisation signature the user
|
||||
* deleted is appended again once, and the disclaimer goes below
|
||||
* everything, quote included (`<hr>` and the HTML in the HTML part, a
|
||||
* blank line and the text in the text part). A part that already carries
|
||||
* it keeps the one it has.
|
||||
*/
|
||||
|
||||
import type { OrgPolicy, SettingRestriction } from './org-policy';
|
||||
import { appendHtmlSignature, appendPlainTextSignature, getPlainTextSignature, sanitizeSignatureHtml, SIGNATURE_RANGE_MARKER } from './signature-utils';
|
||||
import { extractUserAuthoredText, htmlToPlainText, QUOTED_BLOCK_ATTR } from './compose-html';
|
||||
|
||||
export const SIGNATURE_FIELDS = ['title', 'department', 'phone', 'mobile'] as const;
|
||||
export type SignatureField = (typeof SIGNATURE_FIELDS)[number];
|
||||
export type SignatureDetails = Record<SignatureField, string>;
|
||||
|
||||
export const SIGNATURE_PLACEHOLDERS = [
|
||||
'name', 'email', 'identity', 'company', 'domain', 'title', 'department', 'phone', 'mobile',
|
||||
] as const;
|
||||
export type SignaturePlaceholder = (typeof SIGNATURE_PLACEHOLDERS)[number];
|
||||
export type SignatureValues = Partial<Record<SignaturePlaceholder, string>>;
|
||||
|
||||
export interface FilledSignature {
|
||||
html: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface Template {
|
||||
html: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const MAX_TEXT = 64 * 1024;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function templateOf(raw: unknown): Template {
|
||||
const section = isRecord(raw) ? raw : {};
|
||||
const text = (value: unknown) => (typeof value === 'string' ? value.slice(0, MAX_TEXT) : '');
|
||||
return { html: text(section.html), text: text(section.text) };
|
||||
}
|
||||
|
||||
/** The signature and disclaimer templates of a policy's organisation part (webmail sanitizeOrganisationPartial). */
|
||||
export function organisationTemplates(organisation: unknown): { signature: Template; disclaimer: Template } {
|
||||
const org = isRecord(organisation) ? organisation : {};
|
||||
return { signature: templateOf(org.signature), disclaimer: templateOf(org.disclaimer) };
|
||||
}
|
||||
|
||||
const PLACEHOLDER_RE = /\{(name|email|identity|company|domain|title|department|phone|mobile)\}/g;
|
||||
|
||||
/** The placeholders a template uses, each once, in the order they appear. */
|
||||
export function placeholdersIn(template: string): SignaturePlaceholder[] {
|
||||
const found = new Set<SignaturePlaceholder>();
|
||||
for (const match of template.matchAll(PLACEHOLDER_RE)) found.add(match[1] as SignaturePlaceholder);
|
||||
return [...found];
|
||||
}
|
||||
|
||||
const valueOf = (values: SignatureValues, key: string) => (values[key as SignaturePlaceholder] ?? '').trim();
|
||||
|
||||
function fillString(template: string, values: SignatureValues, escape: (value: string) => string = (v) => v): string {
|
||||
return template.replace(PLACEHOLDER_RE, (_, key: string) => escape(valueOf(values, key)));
|
||||
}
|
||||
|
||||
function allPlaceholdersEmpty(keys: string[], values: SignatureValues): boolean {
|
||||
return keys.length > 0 && keys.every((key) => !valueOf(values, key));
|
||||
}
|
||||
|
||||
/** Fills a plain-text template; lines whose placeholders are all empty are dropped. */
|
||||
export function fillTextTemplate(template: string, values: SignatureValues): string {
|
||||
return template
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.split('\n')
|
||||
.filter((line) => !allPlaceholdersEmpty(placeholdersIn(line), values))
|
||||
.map((line) => fillString(line, values))
|
||||
.join('\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// ─── A small HTML tree (no DOM in React Native) ─────────────────────────
|
||||
|
||||
interface TextNode { type: 'text'; text: string; parent: El | null }
|
||||
interface RawNode { type: 'raw'; text: string; parent: El | null }
|
||||
interface Attr { name: string; value: string }
|
||||
interface El { type: 'el'; tag: string; attrs: Attr[]; children: TreeNode[]; parent: El | null }
|
||||
type TreeNode = TextNode | RawNode | El;
|
||||
|
||||
const VOID_TAGS = new Set(['AREA', 'BASE', 'BR', 'COL', 'EMBED', 'HR', 'IMG', 'INPUT', 'LINK', 'META', 'SOURCE', 'TRACK', 'WBR']);
|
||||
const BLOCK_TAGS = new Set([
|
||||
'ADDRESS', 'ARTICLE', 'ASIDE', 'BLOCKQUOTE', 'CENTER', 'DD', 'DIV', 'DL', 'DT', 'FOOTER', 'H1', 'H2', 'H3',
|
||||
'H4', 'H5', 'H6', 'HEADER', 'HR', 'LI', 'OL', 'P', 'PRE', 'SECTION', 'TABLE', 'TBODY', 'TD', 'TFOOT', 'TH',
|
||||
'THEAD', 'TR', 'UL',
|
||||
]);
|
||||
/** Content that shows without text: kept when its line's placeholders are empty. */
|
||||
const MEDIA_TAGS = new Set(['IMG', 'SVG', 'PICTURE', 'VIDEO']);
|
||||
const VISIBLE_TAGS = new Set(['IMG', 'HR', 'SVG', 'PICTURE', 'VIDEO']);
|
||||
|
||||
const TOKEN_RE = /<!--[\s\S]*?-->|<\/([a-zA-Z][a-zA-Z0-9-]*)\s*>|<([a-zA-Z][a-zA-Z0-9-]*)((?:\s+[^\s"'>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'>]+))?)*)\s*\/?>|[^<]+|</g;
|
||||
const ATTR_RE = /([^\s"'>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
|
||||
|
||||
function parseAttrs(raw: string): Attr[] {
|
||||
const attrs: Attr[] = [];
|
||||
for (const m of raw.matchAll(ATTR_RE)) attrs.push({ name: m[1].toLowerCase(), value: m[2] ?? m[3] ?? m[4] ?? '' });
|
||||
return attrs;
|
||||
}
|
||||
|
||||
function parseHtml(html: string): El {
|
||||
const root: El = { type: 'el', tag: 'BODY', attrs: [], children: [], parent: null };
|
||||
let current = root;
|
||||
for (const m of html.matchAll(TOKEN_RE)) {
|
||||
const token = m[0];
|
||||
if (token.startsWith('<!--')) {
|
||||
current.children.push({ type: 'raw', text: token, parent: current });
|
||||
} else if (m[1]) {
|
||||
const tag = m[1].toUpperCase();
|
||||
let open: El | null = current;
|
||||
while (open && open !== root && open.tag !== tag) open = open.parent;
|
||||
if (open && open !== root) current = open.parent ?? root;
|
||||
} else if (m[2]) {
|
||||
const el: El = { type: 'el', tag: m[2].toUpperCase(), attrs: parseAttrs(m[3] ?? ''), children: [], parent: current };
|
||||
current.children.push(el);
|
||||
if (!VOID_TAGS.has(el.tag) && !/\/\s*>$/.test(token)) current = el;
|
||||
} else {
|
||||
current.children.push({ type: 'text', text: token, parent: current });
|
||||
}
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
// Attribute values are kept as written; filled-in values are escaped as they go in.
|
||||
const escapeAttr = (value: string) => value.replace(/"/g, '"');
|
||||
const escapeAttrValue = (value: string) => value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
const escapeText = (value: string) => value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
function serialize(nodes: TreeNode[]): string {
|
||||
return nodes.map((node) => {
|
||||
if (node.type !== 'el') return node.text;
|
||||
const tag = node.tag.toLowerCase();
|
||||
const attrs = node.attrs.map((a) => ` ${a.name}="${escapeAttr(a.value)}"`).join('');
|
||||
return VOID_TAGS.has(node.tag) ? `<${tag}${attrs}>` : `<${tag}${attrs}>${serialize(node.children)}</${tag}>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
const elementChildren = (el: El) => el.children.filter((child): child is El => child.type === 'el');
|
||||
|
||||
function textContent(node: TreeNode): string {
|
||||
if (node.type === 'text') return node.text;
|
||||
if (node.type === 'raw') return '';
|
||||
return node.children.map(textContent).join('');
|
||||
}
|
||||
|
||||
function hasDescendant(el: El, test: (child: El) => boolean): boolean {
|
||||
return elementChildren(el).some((child) => test(child) || hasDescendant(child, test));
|
||||
}
|
||||
|
||||
function removeNode(node: TreeNode): void {
|
||||
const parent = node.parent;
|
||||
if (!parent) return;
|
||||
const index = parent.children.indexOf(node);
|
||||
if (index >= 0) parent.children.splice(index, 1);
|
||||
node.parent = null;
|
||||
}
|
||||
|
||||
const hasVisibleContent = (el: El) =>
|
||||
textContent(el).replace(/ | | /gi, ' ').trim().length > 0 || hasDescendant(el, (child) => VISIBLE_TAGS.has(child.tag));
|
||||
|
||||
function placeholdersOfNodes(nodes: TreeNode[]): string[] {
|
||||
const keys: string[] = [];
|
||||
const visit = (node: TreeNode) => {
|
||||
if (node.type === 'text') {
|
||||
keys.push(...placeholdersIn(node.text));
|
||||
return;
|
||||
}
|
||||
if (node.type !== 'el') return;
|
||||
for (const attr of node.attrs) keys.push(...placeholdersIn(attr.value));
|
||||
node.children.forEach(visit);
|
||||
};
|
||||
nodes.forEach(visit);
|
||||
return keys;
|
||||
}
|
||||
|
||||
interface Line {
|
||||
leaves: TreeNode[];
|
||||
br: El | null;
|
||||
endsAtBlock: boolean;
|
||||
}
|
||||
|
||||
function linesOf(block: El): Line[] {
|
||||
const lines: Line[] = [];
|
||||
let leaves: TreeNode[] = [];
|
||||
const end = (br: El | null, endsAtBlock: boolean) => {
|
||||
lines.push({ leaves, br, endsAtBlock });
|
||||
leaves = [];
|
||||
};
|
||||
const walk = (node: TreeNode) => {
|
||||
if (node.type === 'text') {
|
||||
leaves.push(node);
|
||||
return;
|
||||
}
|
||||
if (node.type !== 'el') return;
|
||||
if (BLOCK_TAGS.has(node.tag)) end(null, true);
|
||||
else if (node.tag === 'BR') end(node, false);
|
||||
else if (MEDIA_TAGS.has(node.tag)) leaves.push(node);
|
||||
else node.children.forEach(walk);
|
||||
};
|
||||
block.children.forEach(walk);
|
||||
end(null, false);
|
||||
return lines;
|
||||
}
|
||||
|
||||
function placeholdersOfLine(line: Line, block: El): string[] {
|
||||
const keys = placeholdersOfNodes(line.leaves);
|
||||
const wrappers = new Set<El>();
|
||||
for (const leaf of line.leaves) {
|
||||
for (let parent = leaf.parent; parent && parent !== block; parent = parent.parent) wrappers.add(parent);
|
||||
}
|
||||
for (const wrapper of wrappers) {
|
||||
for (const attr of wrapper.attrs) keys.push(...placeholdersIn(attr.value));
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function removeWithEmptyWrappers(node: TreeNode, block: El): void {
|
||||
let parent = node.parent;
|
||||
removeNode(node);
|
||||
while (parent && parent !== block && !hasVisibleContent(parent) && !hasDescendant(parent, (child) => child.tag === 'BR')) {
|
||||
const next: El | null = parent.parent;
|
||||
removeNode(parent);
|
||||
parent = next;
|
||||
}
|
||||
}
|
||||
|
||||
function dropEmptyLines(el: El, values: SignatureValues, isRoot: boolean): void {
|
||||
if (placeholdersOfNodes([el]).length === 0) return;
|
||||
// A table row is one line: "Phone: | {phone}" goes as a whole.
|
||||
if (el.tag === 'TR' && allPlaceholdersEmpty(placeholdersOfNodes([el]), values)) {
|
||||
removeNode(el);
|
||||
return;
|
||||
}
|
||||
const nested: El[] = [];
|
||||
const collect = (node: El) => {
|
||||
for (const child of elementChildren(node)) {
|
||||
if (BLOCK_TAGS.has(child.tag)) nested.push(child);
|
||||
else collect(child);
|
||||
}
|
||||
};
|
||||
collect(el);
|
||||
nested.forEach((child) => dropEmptyLines(child, values, false));
|
||||
|
||||
const lines = linesOf(el);
|
||||
lines.forEach((line, index) => {
|
||||
if (!allPlaceholdersEmpty(placeholdersOfLine(line, el), values)) return;
|
||||
let kept = false;
|
||||
for (const leaf of line.leaves) {
|
||||
const media = leaf.type === 'el' && MEDIA_TAGS.has(leaf.tag);
|
||||
if (media && placeholdersOfNodes([leaf]).length === 0) kept = true;
|
||||
else removeWithEmptyWrappers(leaf, el);
|
||||
}
|
||||
if (kept) return;
|
||||
if (line.br) {
|
||||
removeWithEmptyWrappers(line.br, el);
|
||||
return;
|
||||
}
|
||||
for (let j = index - 1; j >= 0 && !lines[j].endsAtBlock; j--) {
|
||||
const previous = lines[j].br;
|
||||
if (previous?.parent) {
|
||||
removeWithEmptyWrappers(previous, el);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!isRoot && !hasVisibleContent(el)) removeNode(el);
|
||||
}
|
||||
|
||||
function fillNodes(root: El, values: SignatureValues): void {
|
||||
const walk = (node: TreeNode) => {
|
||||
if (node.type === 'text') {
|
||||
if (placeholdersIn(node.text).length > 0) node.text = fillString(node.text, values, escapeText);
|
||||
return;
|
||||
}
|
||||
if (node.type !== 'el') return;
|
||||
for (const attr of node.attrs) {
|
||||
if (placeholdersIn(attr.value).length > 0) attr.value = fillString(attr.value, values, escapeAttrValue);
|
||||
}
|
||||
node.children.forEach(walk);
|
||||
};
|
||||
root.children.forEach(walk);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills an HTML template. Values go in as text (escaped); lines whose
|
||||
* placeholders are all empty are dropped. The result still goes through
|
||||
* the signature sanitizer wherever it is inserted.
|
||||
*/
|
||||
export function fillHtmlTemplate(template: string, values: SignatureValues): string {
|
||||
const root = parseHtml(template);
|
||||
dropEmptyLines(root, values, true);
|
||||
fillNodes(root, values);
|
||||
return serialize(root.children).trim();
|
||||
}
|
||||
|
||||
/** The placeholder values for one identity. */
|
||||
export function signatureValuesFor(
|
||||
identity: { name?: string | null; email?: string | null } | null | undefined,
|
||||
details: Partial<SignatureDetails> | null | undefined,
|
||||
company: string,
|
||||
): SignatureValues {
|
||||
const email = identity?.email?.trim() ?? '';
|
||||
const name = identity?.name?.trim() ?? '';
|
||||
const at = email.lastIndexOf('@');
|
||||
return {
|
||||
name,
|
||||
email,
|
||||
identity: name && email ? `${name} <${email}>` : name || email,
|
||||
company: company.trim(),
|
||||
domain: at >= 0 ? email.slice(at + 1).toLowerCase() : '',
|
||||
title: details?.title ?? '',
|
||||
department: details?.department ?? '',
|
||||
phone: details?.phone ?? '',
|
||||
mobile: details?.mobile ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
/** A template filled in, or null when the admin set none. */
|
||||
export function buildOrgSignature(template: Template | null | undefined, values: SignatureValues): FilledSignature | null {
|
||||
const html = template?.html?.trim() ?? '';
|
||||
const text = template?.text?.trim() ?? '';
|
||||
if (!html && !text) return null;
|
||||
return {
|
||||
html: html ? fillHtmlTemplate(html, values) : '',
|
||||
text: text ? fillTextTemplate(text, values) : '',
|
||||
};
|
||||
}
|
||||
|
||||
export interface SignatureRestriction {
|
||||
locked: boolean;
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
export function signatureRestriction(restriction: SettingRestriction | undefined): SignatureRestriction {
|
||||
return { locked: restriction?.locked === true, hidden: restriction?.hidden === true };
|
||||
}
|
||||
|
||||
export const isSignatureEnforced = (r: SignatureRestriction) => r.locked || r.hidden;
|
||||
|
||||
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 and disclaimer for mail from `sending` (webmail orgSignatureSetup). */
|
||||
export function orgSignatureSetup(
|
||||
policy: OrgPolicy | null | undefined,
|
||||
sending: { name?: string | null; email?: string | null } | null | undefined,
|
||||
details: Partial<SignatureDetails> | null | undefined,
|
||||
company: string,
|
||||
): OrgSignatureSetup {
|
||||
const templates = organisationTemplates(policy?.organisation);
|
||||
const values = signatureValuesFor(sending, details, company);
|
||||
return {
|
||||
signature: buildOrgSignature(templates.signature, values),
|
||||
disclaimer: buildOrgSignature(templates.disclaimer, values),
|
||||
restriction: signatureRestriction(policy?.restrictions?.signature),
|
||||
};
|
||||
}
|
||||
|
||||
type SignedIdentity = { textSignature?: string; htmlSignature?: string };
|
||||
|
||||
const identityHasSignature = (identity: SignedIdentity | null | undefined) =>
|
||||
!!(identity?.htmlSignature?.trim() || identity?.textSignature?.trim());
|
||||
|
||||
/**
|
||||
* The identity whose signature the composer inserts, with the organisation
|
||||
* signature applied. `own` is what the composer would sign with on its own
|
||||
* (signatureIdentityFor); `sending` is the identity the message goes out
|
||||
* from. Returns `own` itself when the organisation signature doesn't apply.
|
||||
*/
|
||||
export function effectiveSignatureIdentity<T extends SignedIdentity>(
|
||||
own: T | null | undefined,
|
||||
sending: T | null | undefined,
|
||||
orgSignature: FilledSignature | null,
|
||||
restriction: SignatureRestriction,
|
||||
): T | null | undefined {
|
||||
if (!orgSignature) return own;
|
||||
if (!isSignatureEnforced(restriction) && identityHasSignature(own)) return own;
|
||||
const base = sending ?? own;
|
||||
if (!base) return own;
|
||||
return { ...base, htmlSignature: orgSignature.html, textSignature: orgSignature.text };
|
||||
}
|
||||
|
||||
// ─── Matching an outgoing body (webmail lib/org/body-match.ts) ──────────
|
||||
|
||||
/** The disclaimer's wrapper in the HTML part. */
|
||||
export const DISCLAIMER_MARKER = 'data-org-disclaimer';
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
const asSignature = (filled: FilledSignature) => ({ htmlSignature: filled.html, textSignature: filled.text });
|
||||
|
||||
/** The lines a filled template shows in an HTML body. */
|
||||
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. */
|
||||
function textLinesOf(filled: FilledSignature): string[] {
|
||||
return textLines(getPlainTextSignature(asSignature(filled)));
|
||||
}
|
||||
|
||||
/** The body as a tree without its quotes. */
|
||||
function withoutQuotes(html: string): El {
|
||||
const root = parseHtml(html);
|
||||
const drop = (el: El) => {
|
||||
for (const child of elementChildren(el)) {
|
||||
if (child.tag === 'BLOCKQUOTE' || child.attrs.some((a) => a.name === QUOTED_BLOCK_ATTR)) removeNode(child);
|
||||
else drop(child);
|
||||
}
|
||||
};
|
||||
drop(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function findAll(el: El, test: (child: El) => boolean, out: El[] = []): El[] {
|
||||
for (const child of elementChildren(el)) {
|
||||
if (test(child)) out.push(child);
|
||||
findAll(child, test, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an HTML body carries the signature: the composer's signature
|
||||
* block holding it, its lines in the user's part, or its lines at the very
|
||||
* end.
|
||||
*/
|
||||
export function htmlCarriesSignature(html: string, signature: FilledSignature, forwardedSeparator?: string): boolean {
|
||||
const lines = htmlLinesOf(signature);
|
||||
if (lines.length === 0) return true;
|
||||
const root = withoutQuotes(html);
|
||||
const blocks = findAll(root, (el) => el.attrs.some((a) => a.name === SIGNATURE_RANGE_MARKER && a.value === 'body'));
|
||||
for (const block of blocks) {
|
||||
if (containsLines(textLines(htmlToPlainText(serialize(block.children))), lines)) return true;
|
||||
}
|
||||
const own = extractUserAuthoredText(html, { plainTextMode: false, forwardedSeparator });
|
||||
return containsLines(textLines(own), lines) || endsWithLines(textLines(htmlToPlainText(serialize(root.children))), lines);
|
||||
}
|
||||
|
||||
/** Whether a text body carries the signature, 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 (its wrapper outside the quote, or its lines at the end). */
|
||||
export function htmlCarriesDisclaimer(html: string, disclaimer: FilledSignature): boolean {
|
||||
const root = withoutQuotes(html);
|
||||
if (findAll(root, (el) => el.attrs.some((a) => a.name === DISCLAIMER_MARKER)).length > 0) return true;
|
||||
return endsWithLines(textLines(htmlToPlainText(serialize(root.children))), htmlLinesOf(disclaimer));
|
||||
}
|
||||
|
||||
/** Whether a text body ends with the disclaimer. */
|
||||
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));
|
||||
}
|
||||
|
||||
// ─── On send (webmail lib/org/outgoing.ts) ───────────────────────────────
|
||||
|
||||
export interface OutgoingBodies {
|
||||
/** Undefined for a plain-text message. */
|
||||
html?: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** The disclaimer as it goes into the HTML part. */
|
||||
export function disclaimerHtml(disclaimer: FilledSignature): string {
|
||||
const inner = disclaimer.html.trim()
|
||||
? sanitizeSignatureHtml(disclaimer.html)
|
||||
: escapeText(disclaimer.text).replace(/\n/g, '<br>');
|
||||
return `<hr><div ${DISCLAIMER_MARKER}="">${inner}</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts a locked organisation signature back when the user deleted or
|
||||
* changed it; adds it once to the part that lacks it.
|
||||
*/
|
||||
export function withOrgSignature(
|
||||
bodies: OutgoingBodies,
|
||||
setup: OrgSignatureSetup,
|
||||
options: { separator: boolean; forwardedSeparator?: string },
|
||||
): OutgoingBodies {
|
||||
const signature = setup.signature;
|
||||
if (!signature || !isSignatureEnforced(setup.restriction)) return bodies;
|
||||
const htmlMissing = bodies.html !== undefined && !htmlCarriesSignature(bodies.html, signature, options.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, options.forwardedSeparator);
|
||||
if (!htmlMissing && !textMissing) return bodies;
|
||||
const separator = { separator: options.separator };
|
||||
return {
|
||||
html: htmlMissing ? appendHtmlSignature(bodies.html!, asSignature(signature), separator) : bodies.html,
|
||||
text: textMissing ? appendPlainTextSignature(bodies.text, asSignature(signature), separator) : bodies.text,
|
||||
};
|
||||
}
|
||||
|
||||
/** Adds the disclaimer below everything, quote included, unless the part already ends with it. */
|
||||
export function withOrgDisclaimer(bodies: OutgoingBodies, disclaimer: FilledSignature | null): OutgoingBodies {
|
||||
if (!disclaimer) return bodies;
|
||||
const text = getPlainTextSignature(asSignature(disclaimer));
|
||||
if (!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: htmlMissing ? `${bodies.html}${disclaimerHtml(disclaimer)}` : bodies.html,
|
||||
text: textMissing ? `${bodies.text.replace(/\s+$/, '')}\n\n${text}` : bodies.text,
|
||||
};
|
||||
}
|
||||
|
||||
/** Both send-time changes, signature first, the disclaimer last. */
|
||||
export function finalizeOrgOutgoing(
|
||||
bodies: OutgoingBodies,
|
||||
setup: OrgSignatureSetup,
|
||||
options: { separator: boolean; forwardedSeparator?: string },
|
||||
): OutgoingBodies {
|
||||
return withOrgDisclaimer(withOrgSignature(bodies, setup, options), setup.disclaimer);
|
||||
}
|
||||
@@ -35,6 +35,8 @@ import { useSheetBottomInset } from '../lib/sheet-bottom-inset';
|
||||
import { createAfterDismissQueue } from '../lib/after-modal-dismiss';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { usePolicyFeature, usePolicyStore } from '../stores/policy-store';
|
||||
import { effectiveSignatureIdentity, finalizeOrgOutgoing, orgSignatureSetup, htmlCarriesDisclaimer, textCarriesDisclaimer } from '../lib/org-signature';
|
||||
import { useHasContacts } from '../lib/capabilities';
|
||||
import { isTrustedSendersSyncOn } from '../lib/trusted-senders';
|
||||
import { useAccountStore } from '../stores/account-store';
|
||||
@@ -1039,10 +1041,37 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
const [identityPickedByUser, setIdentityPickedByUser] = React.useState(false);
|
||||
// An alias without a signature falls back to the primary identity's,
|
||||
// unless the user picked it.
|
||||
const signatureIdentity = React.useMemo(
|
||||
const ownSignatureIdentity = React.useMemo(
|
||||
() => signatureIdentityFor(primaryIdentity, identities, { pickedByUser: identityPickedByUser }),
|
||||
[primaryIdentity, identities, identityPickedByUser],
|
||||
);
|
||||
// The organisation signature and disclaimer of the webmail's policy, filled
|
||||
// in for the identity the message goes out from (webmail
|
||||
// hooks/use-org-signature.ts). The signature stands in for an identity
|
||||
// without one, or replaces every identity's when locked or hidden.
|
||||
const orgPolicy = usePolicyStore((s) => s.effective);
|
||||
const orgCompany = usePolicyStore((s) => s.links.companyName ?? '');
|
||||
const orgSetup = React.useMemo(
|
||||
() => orgSignatureSetup(orgPolicy, primaryIdentity, null, orgCompany),
|
||||
[orgPolicy, primaryIdentity, orgCompany],
|
||||
);
|
||||
const signatureIdentity = React.useMemo(
|
||||
() => effectiveSignatureIdentity(ownSignatureIdentity, primaryIdentity, orgSetup.signature, orgSetup.restriction) ?? null,
|
||||
[ownSignatureIdentity, primaryIdentity, orgSetup],
|
||||
);
|
||||
// The disclaimer under the editor, read-only: it is added on send, never
|
||||
// part of the body or a draft. Not shown when the body already ends with
|
||||
// it (a message back from Undo), which gets no second copy (webmail
|
||||
// org-disclaimer-preview.tsx).
|
||||
const disclaimerBody = React.useDeferredValue(plainTextMode ? plainBody : bodyHtml);
|
||||
const orgDisclaimerText = React.useMemo(() => {
|
||||
const disclaimer = orgSetup.disclaimer;
|
||||
if (!disclaimer) return '';
|
||||
const carried = !!disclaimerBody && (plainTextMode
|
||||
? textCarriesDisclaimer(disclaimerBody, disclaimer)
|
||||
: htmlCarriesDisclaimer(disclaimerBody, disclaimer));
|
||||
return carried ? '' : getPlainTextSignature({ textSignature: disclaimer.text, htmlSignature: disclaimer.html });
|
||||
}, [orgSetup, disclaimerBody, plainTextMode]);
|
||||
|
||||
const openIdentityPicker = () => {
|
||||
if (identities.length <= 1) return;
|
||||
@@ -1422,6 +1451,16 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
? { inReplyTo: draft.inReplyTo ?? [], references: draft.references ?? draft.inReplyTo ?? [] }
|
||||
: null;
|
||||
|
||||
// On send, never on a draft: a locked organisation signature the user
|
||||
// removed goes back in, and the disclaimer goes below everything
|
||||
// (webmail lib/org/outgoing.ts).
|
||||
if (!opts.forDraft) {
|
||||
const setup = orgSignatureSetup(usePolicyStore.getState().effective, identity, null, usePolicyStore.getState().links.companyName ?? '');
|
||||
const finished = finalizeOrgOutgoing({ html: htmlBody, text: textBody }, setup, { separator: signatureSeparatorEnabled });
|
||||
htmlBody = finished.html;
|
||||
textBody = finished.text;
|
||||
}
|
||||
|
||||
const identityBcc = opts.forDraft ? [] : (identity.bcc ?? []).filter((r) => !!r.email);
|
||||
const bccAll = [...expandRecipients(finalBcc).map(toAddress), ...identityBcc];
|
||||
|
||||
@@ -1443,7 +1482,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
priority: priority !== 'normal' ? priority : undefined,
|
||||
envelopeMailFrom,
|
||||
};
|
||||
}, [senderAddress, plainTextMode, plainBody, attachments, replyTo, draft, mode, finalTo, finalCc, finalBcc, subject, requestReadReceipt, priority, t]);
|
||||
}, [senderAddress, plainTextMode, plainBody, attachments, replyTo, draft, mode, finalTo, finalCc, finalBcc, subject, requestReadReceipt, priority, signatureSeparatorEnabled, t]);
|
||||
|
||||
// Save one draft version (create, then destroy the previous one - #849).
|
||||
const saveDraftOnce = async (opts: { live: boolean }): Promise<string | null> => {
|
||||
@@ -2122,6 +2161,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
];
|
||||
|
||||
// ── Templates ────────────────────────────────────────────────────────
|
||||
// The organisation can turn templates off (webmail email-composer.tsx).
|
||||
const templatesEnabled = usePolicyFeature('templatesEnabled');
|
||||
const [templateSheetOpen, setTemplateSheetOpen] = React.useState(false);
|
||||
const [placeholderPrompt, setPlaceholderPrompt] = React.useState<{
|
||||
template: EmailTemplate;
|
||||
@@ -3053,6 +3094,12 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
onMentionQuery={setMentionQuery}
|
||||
/>
|
||||
)}
|
||||
{!!orgDisclaimerText && (
|
||||
<View style={styles.orgDisclaimer} testID="org-disclaimer-preview">
|
||||
<Text style={styles.orgDisclaimerTitle}>{t('org_signature.disclaimer_added_on_send', 'Added when you send')}</Text>
|
||||
<Text style={styles.orgDisclaimerText}>{orgDisclaimerText}</Text>
|
||||
</View>
|
||||
)}
|
||||
{/* The empty space under the body looks like part of it (on a
|
||||
tablet, most of the panel): a tap there starts typing in it. */}
|
||||
<Pressable
|
||||
@@ -3079,9 +3126,11 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
contentContainerStyle={styles.formatActions}
|
||||
keyboardShouldPersistTaps="always"
|
||||
>
|
||||
<ToolbarButton onPress={() => setTemplateSheetOpen(true)}
|
||||
label={t('email_composer.use_template', 'Template')}
|
||||
icon={<LayoutTemplate size={18} color={c.textSecondary} />} />
|
||||
{templatesEnabled && (
|
||||
<ToolbarButton onPress={() => setTemplateSheetOpen(true)}
|
||||
label={t('email_composer.use_template', 'Template')}
|
||||
icon={<LayoutTemplate size={18} color={c.textSecondary} />} />
|
||||
)}
|
||||
<ToolbarButton active={requestReadReceipt} onPress={() => setRequestReadReceipt((v) => !v)}
|
||||
label={t('email_composer.read_receipt_off', 'Request a read receipt')}
|
||||
icon={<MailCheck size={18} color={requestReadReceipt ? c.primary : c.textSecondary} />} />
|
||||
@@ -3662,6 +3711,15 @@ function makeStyles(c: ThemePalette) {
|
||||
},
|
||||
scrollContent: { flexGrow: 1 },
|
||||
bodyFiller: { flexGrow: 1, minHeight: 48 },
|
||||
orgDisclaimer: {
|
||||
marginHorizontal: spacing.md,
|
||||
marginBottom: spacing.sm,
|
||||
paddingTop: spacing.sm,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: c.border,
|
||||
},
|
||||
orgDisclaimerTitle: { ...typography.captionMedium, color: c.textMuted, marginBottom: spacing.xs },
|
||||
orgDisclaimerText: { ...typography.caption, color: c.textMuted },
|
||||
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
|
||||
Reference in New Issue
Block a user