feat: add the organisation's signature and disclaimer to new mail and hide templates when they are off

This commit is contained in:
Linus Rath
2026-10-31 16:01:16 +01:00
parent 13dcbdc086
commit fe3f860e73
6 changed files with 957 additions and 9 deletions
@@ -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();
});
});
+13 -4
View File
@@ -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,
},
+258
View File
@@ -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>&lt;img src=x onerror=alert(1)&gt; &amp; co</p>');
expect(html).toContain('href="tel:1&quot; onclick=&quot;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');
});
});
+5
View File
@@ -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;
}
+569
View File
@@ -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, '&quot;');
const escapeAttrValue = (value: string) => value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const escapeText = (value: string) => value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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(/&nbsp;|&#160;|&#xa0;/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);
}
+63 -5
View File
@@ -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',