diff --git a/src/components/__tests__/org-signature-wiring.test.ts b/src/components/__tests__/org-signature-wiring.test.ts new file mode 100644 index 00000000..bb3b1416 --- /dev/null +++ b/src/components/__tests__/org-signature-wiring.test.ts @@ -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+ 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+/); + 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(); + }); +}); diff --git a/src/components/email/QuickReplyBox.tsx b/src/components/email/QuickReplyBox.tsx index b182403b..8ea8330c 100644 --- a/src/components/email/QuickReplyBox.tsx +++ b/src/components/email/QuickReplyBox.tsx @@ -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, }, diff --git a/src/lib/__tests__/org-signature.test.ts b/src/lib/__tests__/org-signature.test.ts new file mode 100644 index 00000000..e8d39905 --- /dev/null +++ b/src/lib/__tests__/org-signature.test.ts @@ -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 ', + 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 & ' })).toBe('A & '); + }); +}); + +describe('fillHtmlTemplate', () => { + it('HTML-escapes the values, in text and in attributes', () => { + const html = fillHtmlTemplate('

{name}

call', { + name: ' & co', + phone: '1" onclick="x', + }); + expect(html).toContain('

<img src=x onerror=alert(1)> & co

'); + expect(html).toContain('href="tel:1" onclick="x"'); + expect(html).not.toContain('s whose placeholders are all empty, with their line break', () => { + const template = '

{name}
{title}
Phone: {phone}
Mobile: {mobile}

ACME Ltd

'; + expect(fillHtmlTemplate(template, FULL)) + .toBe('

Alex Example
CEO
Phone: +49 30 1
Mobile: +49 170 2

ACME Ltd

'); + expect(fillHtmlTemplate(template, NO_DETAILS)).toBe('

Alex Example

ACME Ltd

'); + expect(fillHtmlTemplate(template, { ...NO_DETAILS, mobile: '9' })) + .toBe('

Alex Example
Mobile: 9

ACME Ltd

'); + }); + + it('drops a paragraph and a table row whose placeholders are all empty', () => { + const template = '

{name}

Phone: {phone}

' + + '
Mobile{mobile}
Webexample.org
'; + expect(fillHtmlTemplate(template, NO_DETAILS)) + .toBe('

Alex Example

Webexample.org
'); + }); + + it('drops a line whose only placeholder is in a link', () => { + expect(fillHtmlTemplate('{name}
Call me', NO_DETAILS)).toBe('Alex Example'); + }); + + it('keeps lines and blocks without placeholders, empty ones included', () => { + expect(fillHtmlTemplate('

{mobile}



Static

', NO_DETAILS)) + .toBe('



Static

'); + }); + + it('finds lines inside inline wrappers, the way Outlook and Gmail write signatures', () => { + const outlook = '
{name}
Phone: {phone}
Mobile: {mobile}
'; + expect(fillHtmlTemplate(outlook, { ...NO_DETAILS, phone: '1' })) + .toBe('
Alex Example
Phone: 1
'); + const gmail = '
{name}
T: {phone}
ACME
'; + expect(fillHtmlTemplate(gmail, NO_DETAILS)) + .toBe('
Alex Example
ACME
'); + expect(fillHtmlTemplate('

{name}
Mobile: {mobile}
ACME

', NO_DETAILS)).toBe('

Alex Example
ACME

'); + }); + + it('keeps a logo on a line whose placeholders are empty, and an image that took a placeholder goes', () => { + expect(fillHtmlTemplate('
ACME {phone}
{name}
', NO_DETAILS)) + .toBe('
ACME
Alex Example
'); + expect(fillHtmlTemplate('

{name}

', NO_DETAILS)).toBe('

Alex Example

'); + }); +}); + +describe('buildOrgSignature', () => { + it('is null without a template, and fills both templates otherwise', () => { + expect(buildOrgSignature({ html: ' ', text: '' }, FULL)).toBeNull(); + expect(buildOrgSignature({ html: '

{name}

', text: '' }, FULL)).toEqual({ html: '

Alex Example

', 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: 'Mine', textSignature: 'Mine' }; + const TEAM = { id: 'team', name: 'Team', email: 'team@example.org', htmlSignature: '', textSignature: '' }; + const ORG = { html: '

Org

', 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: '

Org

', 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: '

Org

', textSignature: 'Org' }); + expect(effectiveSignatureIdentity(ME, ME, ORG, { locked: false, hidden: true })?.htmlSignature).toBe('

Org

'); + }); +}); + +function policyWith(raw: Record): 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: '

{name}

', text: '{name}' }, disclaimer: { html: '', text: 'Confidential {domain}' } }, + }); + expect(orgSignatureSetup(policy, ALICE, null, '')).toEqual({ + signature: { html: '

Alice

', 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: '

{name}
Phone: {phone}

', 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: '

Hi

', text: 'Hi' }, lockedSetup(), SEPARATOR); + expect(out.html).toBe('

Hi



--

Alice
Phone: +49 1

'); + 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 = '

Hi

--

Alice
Phone: +49 1


'; + 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 = '

Hi

Alice
Phone: +49 1

'; + 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: '

Hi

', 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: '

Confidential {domain}

', text: 'Confidential {domain}' }); + const out = finalizeOrgOutgoing({ html: '

Hi

quote
', text: 'Hi\n\n> quote\n' }, setup, SEPARATOR); + expect(out.html).toBe('

Hi

quote

Confidential example.org

'); + 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: '

Line one

Line two

', text: '' }); + expect(finalizeOrgOutgoing({ html: '
Hi
', 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: '
Hi
', text: 'Hi' }, setup, SEPARATOR); + expect(once.html).toBe('
Hi

Confidential.
'); + 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: '

Thanks

Hi

Confidential.

', 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'); + }); +}); diff --git a/src/lib/org-policy.ts b/src/lib/org-policy.ts index 0e25e29f..3d167746 100644 --- a/src/lib/org-policy.ts +++ b/src/lib/org-policy.ts @@ -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; } diff --git a/src/lib/org-signature.ts b/src/lib/org-signature.ts new file mode 100644 index 00000000..df8ce259 --- /dev/null +++ b/src/lib/org-signature.ts @@ -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 (`
` 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; + +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>; + +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 { + 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(); + 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 = /|<\/([a-zA-Z][a-zA-Z0-9-]*)\s*>|<([a-zA-Z][a-zA-Z0-9-]*)((?:\s+[^\s"'>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'>]+))?)*)\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('