From 42ab246cb97ea03f02cf24e8a0487578ec3e7e97 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 29 Aug 2026 18:12:15 +0200
Subject: [PATCH] feat: port recipient parsing, reply-identity, sub-addressing,
signature, quote-header and template helpers; drop the dead submission api
---
docs/parity/04-composer-send.md | 22 +-
src/api/submission.ts | 64 ----
src/lib/__tests__/compose-html.test.ts | 64 +++-
src/lib/__tests__/quote-header.test.ts | 41 +++
src/lib/__tests__/recipients.test.ts | 114 +++++++
src/lib/__tests__/reply-identity.test.ts | 56 ++++
src/lib/__tests__/signature-utils.test.ts | 81 +++++
src/lib/__tests__/sub-addressing.test.ts | 37 +++
src/lib/__tests__/template-utils.test.ts | 75 +++++
src/lib/compose-html.ts | 242 ++++++++++----
src/lib/quote-header.ts | 102 ++++++
src/lib/recipients.ts | 377 ++++++++++++++++++++++
src/lib/reply-identity.ts | 229 +++++++++++++
src/lib/signature-utils.ts | 228 +++++++++++++
src/lib/sub-addressing.ts | 167 ++++++++++
src/lib/template-utils.ts | 217 +++++++++++++
src/stores/templates-store.ts | 64 ++--
17 files changed, 2009 insertions(+), 171 deletions(-)
delete mode 100644 src/api/submission.ts
create mode 100644 src/lib/__tests__/quote-header.test.ts
create mode 100644 src/lib/__tests__/recipients.test.ts
create mode 100644 src/lib/__tests__/reply-identity.test.ts
create mode 100644 src/lib/__tests__/signature-utils.test.ts
create mode 100644 src/lib/__tests__/sub-addressing.test.ts
create mode 100644 src/lib/__tests__/template-utils.test.ts
create mode 100644 src/lib/quote-header.ts
create mode 100644 src/lib/recipients.ts
create mode 100644 src/lib/reply-identity.ts
create mode 100644 src/lib/signature-utils.ts
create mode 100644 src/lib/sub-addressing.ts
create mode 100644 src/lib/template-utils.ts
diff --git a/docs/parity/04-composer-send.md b/docs/parity/04-composer-send.md
index 168a9d7a..85f17144 100644
--- a/docs/parity/04-composer-send.md
+++ b/docs/parity/04-composer-send.md
@@ -26,17 +26,17 @@ Legend for refs: WEB paths are relative to `the webmail repo`, RN paths to `the
### Sending / EmailSubmission
-- [ ] **Message is filed into Sent before submission — a failed send leaves a fake "sent" copy** — `P1` — `bug`
+- [x] **Message is filed into Sent before submission — a failed send leaves a fake "sent" copy** — fixed in ccbe67c — `P1` — `bug`
- What WEB does: creates the email in Drafts with `$draft` and moves it to Sent via `onSuccessUpdateEmail` on the `EmailSubmission/set` (`lib/jmap/client.ts:3195-3196`, `:3234-3239`, #188); a `notCreated` on the submission throws and the draft simply stays in Drafts (`:3289-3312`).
- What RN does: `src/api/email.ts:794-796` sets `mailboxIds: { [sentMailboxId]: true }, keywords: { $seen: true }` on the `Email/set` create, and the `EmailSubmission/set` at `:848-855` has no `onSuccessUpdateEmail`. If the submission is rejected (`notCreated` handled at `:870-872`) the user gets a "Send failed" alert but the message is already sitting in Sent looking sent; a retry duplicates it.
- Fix hint: create into the Drafts mailbox with `$draft`, add `onSuccessUpdateEmail: { '#draft': { 'mailboxIds/ see docs thanks fyi attached Jane site Jane site al al Jane ACME hello hello hello x x x xline two');
});
@@ -52,7 +55,7 @@ describe('buildInitialHtml', () => {
receivedAt: '2026-04-27T10:00:00Z',
});
expect(html).toContain('Forwarded message');
- expect(html).toContain('From: Bob');
+ expect(html).toContain('From: Bob <b@c.com>');
expect(html).toContain('Subject: Original Subject');
expect(html).toContain('forwarded body');
});
@@ -133,3 +136,54 @@ describe('rewriteInlineImages', () => {
expect(out.usedCids).toEqual(['dup']);
});
});
+
+describe('htmlToPlainText links', () => {
+ it('renders a link as text
From: x
<\/div>$/);
+ expect(h.text).toMatch(/Alice wrote:\n$/);
+ });
+
+ it('uses localized labels', () => {
+ const h = buildQuoteHeader({
+ mode: 'forward',
+ email: { from: { email: 'a@b.com' }, subject: 'S' },
+ timeFormat: '24h',
+ unknownLabel: 'Unbekannt',
+ labels: {
+ replyLine: 'Am {date} schrieb {from}:',
+ forwardedSeparator: '---------- Weitergeleitete Nachricht ----------',
+ fromLabel: 'Von',
+ dateLabel: 'Datum',
+ subjectLabel: 'Betreff',
+ },
+ });
+ expect(h.wrapInBlockquote).toBe(false);
+ expect(h.html).toContain('Weitergeleitete Nachricht');
+ expect(h.html).toContain('Von: a@b.com
Datum:
Betreff: S');
+ });
+
+ it('collapses the date when unknown', () => {
+ const h = buildQuoteHeader({ mode: 'reply', email: { from: { email: 'a@b.com' } }, timeFormat: '24h', unknownLabel: 'U' });
+ expect(h.html).toBe('


Hi
')).toBe('Hix');
+ });
+});
+
+describe('plain-text signature helpers', () => {
+ const sig = { textSignature: 'Jane\r\nACME \n' };
+
+ it('normalizes line breaks', () => {
+ expect(getPlainTextSignature(sig)).toBe('Jane\nACME');
+ expect(getPlainTextSignature({ htmlSignature: '
c
x
', isHTML: true })).toBe('x
'); + }); +}); diff --git a/src/lib/compose-html.ts b/src/lib/compose-html.ts index 295c689f..9460d19b 100644 --- a/src/lib/compose-html.ts +++ b/src/lib/compose-html.ts @@ -3,6 +3,7 @@ // All values are HTML-escaped before insertion into the document. import { escapeHtml, stripDangerousTags } from './email-html'; +import { buildQuoteHeader, type QuoteHeaderLabels } from './quote-header'; import type { TimeFormat } from '../stores/settings-store'; export interface ReplyMeta { @@ -19,39 +20,23 @@ export interface QuoteHeaderOptions { timeFormat?: TimeFormat; locale?: string; unknownLabel?: string; + labels?: QuoteHeaderLabels; } function escapeForHtmlBody(text: string): string { return escapeHtml(text).replace(/\n/g, '${quotedBody}`, - ].join(''); + const quoted = header.wrapInBlockquote + ? `${header.html}
${quotedBody}` + : `${header.html}${quotedBody}`; + + return `
` with `` * (and dropping the data-cid attribute). Returns the rewritten HTML and the @@ -159,3 +227,69 @@ export function rewriteInlineImages(html: string): { html: string; usedCids: str ); return { html: out, usedCids: Array.from(used) }; } + +// Transparent 1x1 GIF used as a stand-in src while the real inline image is +// being fetched from JMAP (webmail #163). +export const INLINE_IMAGE_PLACEHOLDER = + 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; + +/** + * Rewrites `
` references into + * `
` so the editor can render the + * quoted original (a `cid:` URL would show a broken image) while still + * carrying the cid through edits. Returns the cids found. + */ +export function rewriteCidImagesForEditor(html: string): { html: string; cids: string[] } { + if (!html || html.indexOf('cid:') === -1) return { html, cids: [] }; + const cids = new Set
(); + const out = html.replace( + / ]*?)\ssrc=("cid:([^"]*)"|'cid:([^']*)')([^>]*)>/gi, + (_full, before: string, _q: string, dq: string | undefined, sq: string | undefined, after: string) => { + const cid = (dq ?? sq ?? '').trim(); + if (!cid) return _full; + cids.add(cid); + const rest = (before + after).replace(/\sdata-cid=("[^"]*"|'[^']*')/gi, '').trim(); + return `
`; + }, + ); + return { html: out, cids: Array.from(cids) }; +} + +/** + * Replace the placeholder src on `
` elements with the + * resolved data URL once the inline blob has been fetched. + */ +export function replaceInlineImagePlaceholders( + html: string, + cidToDataUrl: Map
, +): string { + if (!html || cidToDataUrl.size === 0 || html.indexOf('data-cid') === -1) return html; + return html.replace( + / ]*?)\sdata-cid=("([^"]*)"|'([^']*)')([^>]*)>/gi, + (full, before: string, _q: string, dq: string | undefined, sq: string | undefined, after: string) => { + const cid = dq ?? sq ?? ''; + const dataUrl = cidToDataUrl.get(cid); + if (!dataUrl) return full; + const rest = (before + after).replace(/\ssrc=("[^"]*"|'[^']*')/gi, '').trim(); + return `
`; + }, + ); +} + +/** + * Sniffs the real image MIME type from a blob's leading magic bytes, returning + * null when the bytes aren't a recognizable image (webmail #543). + */ +export function sniffImageMime(bytes: Uint8Array): string | null { + if (!bytes || bytes.length < 4) return null; + if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return 'image/png'; + if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg'; + if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) return 'image/gif'; + if ( + bytes.length >= 12 && + bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 && + bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50 + ) return 'image/webp'; + if (bytes[0] === 0x42 && bytes[1] === 0x4d) return 'image/bmp'; + return null; +} diff --git a/src/lib/quote-header.ts b/src/lib/quote-header.ts new file mode 100644 index 00000000..7c2a2703 --- /dev/null +++ b/src/lib/quote-header.ts @@ -0,0 +1,102 @@ +// Default reply/forward quote header. Port of the webmail's lib/quote-header.ts +// (without the plugin transform, which has no RN runtime). +// +// Both the forward "From:" line and the reply "On … wrote:" line show the +// full sender incl. address ("Name
"), like Gmail/Outlook (#482). The +// interpolated values are HTML-escaped: an unescaped " " would be parsed +// as a tag by the editor and silently dropped. + +import { escapeHtml } from './email-html'; +import type { TimeFormat } from '../stores/settings-store'; + +/** Localized label set the caller passes in (from the locale catalog). */ +export interface QuoteHeaderLabels { + /** Reply line template with `{date}` and `{from}` placeholders. */ + replyLine: string; + forwardedSeparator: string; + fromLabel: string; + dateLabel: string; + subjectLabel: string; +} + +export const DEFAULT_QUOTE_HEADER_LABELS: QuoteHeaderLabels = { + replyLine: 'On {date}, {from} wrote:', + forwardedSeparator: '---------- Forwarded message ----------', + fromLabel: 'From', + dateLabel: 'Date', + subjectLabel: 'Subject', +}; + +export interface QuoteHeader { + html: string; + text: string; + wrapInBlockquote: boolean; +} + +export interface QuoteHeaderArgs { + mode: 'reply' | 'replyAll' | 'forward'; + email: { + from?: { email?: string; name?: string }; + subject?: string; + receivedAt?: string; + }; + timeFormat: TimeFormat; + locale?: string; + unknownLabel: string; + labels?: QuoteHeaderLabels; +} + +/** Locale- and 12/24h-aware date with a short weekday (webmail formatDateTime). */ +export function formatQuoteDate(iso: string | undefined, timeFormat: TimeFormat, locale?: string): string { + if (!iso) return ''; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ''; + const intlLocale = !locale || locale === 'en' ? 'en-US' : locale; + try { + return d.toLocaleString(intlLocale, { + weekday: 'short', + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: timeFormat === '12h', + }); + } catch { + return d.toLocaleString(); + } +} + +/** "Name " when both are known and distinct, else whichever exists. */ +export function formatQuoteSender( + from: { email?: string; name?: string } | undefined, + unknownLabel: string, +): string { + if (!from) return unknownLabel; + if (from.name && from.email && from.name !== from.email) return `${from.name} <${from.email}>`; + return from.email || from.name || unknownLabel; +} + +function fillReplyLine(template: string, vars: { date: string; from: string }): string { + const line = template.replace('{date}', vars.date).replace('{from}', vars.from).replace('{sender}', vars.from); + // A missing date leaves "On , X wrote:" - collapse the dangling comma. + return vars.date ? line : line.replace(/^\S+\s*,\s*/, ''); +} + +export function buildQuoteHeader(args: QuoteHeaderArgs): QuoteHeader { + const { mode, email, timeFormat, locale, unknownLabel } = args; + const labels = args.labels ?? DEFAULT_QUOTE_HEADER_LABELS; + const date = formatQuoteDate(email.receivedAt, timeFormat, locale); + const fromStrFull = formatQuoteSender(email.from, unknownLabel); + const subject = email.subject || ''; + + if (mode === 'forward') { + const text = `${labels.forwardedSeparator}\n${labels.fromLabel}: ${fromStrFull}\n${labels.dateLabel}: ${date}\n${labels.subjectLabel}: ${subject}\n`; + const html = ` ${labels.forwardedSeparator}`; + return { html, text, wrapInBlockquote: false }; + } + + const text = `${fillReplyLine(labels.replyLine, { date, from: fromStrFull })}\n`; + const html = `
${labels.fromLabel}: ${escapeHtml(fromStrFull)}
${labels.dateLabel}: ${escapeHtml(date)}
${labels.subjectLabel}: ${escapeHtml(subject)}${fillReplyLine(labels.replyLine, { date: escapeHtml(date), from: escapeHtml(fromStrFull) })}`; + return { html, text, wrapInBlockquote: true }; +} diff --git a/src/lib/recipients.ts b/src/lib/recipients.ts new file mode 100644 index 00000000..21848ab2 --- /dev/null +++ b/src/lib/recipients.ts @@ -0,0 +1,377 @@ +// Recipient parsing / validation for the composer. Port of the webmail's +// lib/validation.ts (isValidEmail, parseMailtoUrl) and the recipient helpers +// in lib/email-composer-utils.ts (splitRecipients, parseRecipient, +// splitPastedRecipients, expandRecipients). All DOM-free. + +import { splitMailbox } from './rfc5322-mailbox'; + +/** + * A composer recipient. Display name is optional; email is required - except + * for contact-group chips, which carry their already-resolved members and an + * empty email. Group chips are expanded into their members when the message + * is sent or saved as a draft (see {@link expandRecipients}). + */ +export interface Recipient { + name?: string; + email: string; + group?: { members: Array<{ name?: string; email: string }> }; +} + +/** + * RFC 5322 compliant email validation with security enhancements. + */ +export function isValidEmail(email: string): boolean { + // Length check + if (!email || email.length > 254) return false; + + // Security: Block control characters and header injection + if (/[\r\n\0<>]/.test(email)) return false; + + // RFC 5322 compliant regex (simplified but secure) + const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; + + if (!emailRegex.test(email)) return false; + + // Additional checks + const [localPart, domain] = email.split('@'); + + // Local part max 64 chars + if (localPart.length > 64) return false; + + // Domain validation + if (domain.length > 255) return false; + if (domain.startsWith('.') || domain.endsWith('.')) return false; + if (domain.includes('..')) return false; + + return true; +} + +/** + * True when the angle run that is open at `from` closes before the next one + * opens. `from` is the index of the character under test (a separator, or the + * opening `<` itself); the scan starts after it either way. A `>` inside a + * quoted display name does not close anything, and a `<` with no `>` of its own + * (or whose `>` sits past a later `<`) can never be an address delimiter, so + * separators after it must stay separators. + */ +function angleRunCloses(value: string, from: number): boolean { + let inQuotes = false; + for (let i = from + 1; i < value.length; i++) { + const ch = value[i]; + if (ch === '"') inQuotes = !inQuotes; + else if (inQuotes) continue; + else if (ch === '>') return true; + else if (ch === '<') return false; + } + return false; +} + +/** + * Splits a recipient string into individual entries on any character in + * `separators`, treating those characters as literal when they sit inside a + * quoted display name (`"Doo, John"`) or angle brackets + * (``). Trims each part and drops empties. + * + * Defaults to comma-only, the (de)serialization boundary used by the composer + * state and mailto handling. Pasted lists pass a wider set (see + * {@link splitPastedRecipients}) because they also use `;` and line breaks. + */ +export function splitRecipients(value: string, separators = ','): string[] { + const result: string[] = []; + let current = ''; + let inQuotes = false; + let inAngle = false; + let inGroup = false; + for (let i = 0; i < value.length; i++) { + const ch = value[i]; + if (ch === '"') { + inQuotes = !inQuotes; + current += ch; + } else if (ch === '<' && !inQuotes) { + inAngle = true; + current += ch; + } else if (ch === '>' && !inQuotes) { + inAngle = false; + current += ch; + } else if (separators.includes(ch) && inAngle && !inQuotes && !inGroup && !angleRunCloses(value, i)) { + // An unclosed `<` would otherwise swallow every later separator, folding + // the whole list into one entry that is not an address at all. Inside a + // group the separators belong to the group, so `inGroup` still wins. + inAngle = false; + const trimmed = current.trim(); + if (trimmed) result.push(trimmed); + current = ''; + } else if (ch === ':' && !inQuotes && !inAngle) { + // RFC 5322 group syntax ("Team: a@x, b@y;") - keep the whole group, + // separators inside it included, as a single entry. A colon inside a + // display name is always quoted (see NAME_NEEDS_QUOTING), so a bare + // colon reliably opens a group. + inGroup = true; + current += ch; + } else if (ch === ';' && inGroup && !inQuotes && !inAngle) { + inGroup = false; + current += ch; + } else if (separators.includes(ch) && !inQuotes && !inAngle && !inGroup) { + const trimmed = current.trim(); + if (trimmed) result.push(trimmed); + current = ''; + } else { + current += ch; + } + } + const trimmed = current.trim(); + if (trimmed) result.push(trimmed); + return result; +} + +// Display names containing any of these must be wrapped in a quoted-string so +// they survive comma-splitting at the serialization boundary and round-trip. +const NAME_NEEDS_QUOTING = /[,<>"@;:]/; + +/** + * Formats a recipient as a string. Bare email when there's no distinct name; + * otherwise `Name `, RFC 5322 quoting the name when it contains a comma + * or other special character. + */ +export function formatRecipient(name: string | undefined, email: string): string { + const trimmedName = name?.trim(); + if (!trimmedName || trimmedName === email) return email; + const quoted = NAME_NEEDS_QUOTING.test(trimmedName) + ? `"${trimmedName.replace(/(["\\])/g, '\\$1')}"` + : trimmedName; + return `${quoted} <${email}>`; +} + +/** Strips a surrounding quoted-string (and its escapes) from a display name. */ +function unquoteName(name: string): string { + const trimmed = name.trim(); + if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) { + return trimmed.slice(1, -1).replace(/\\(["\\])/g, '$1'); + } + return trimmed; +} + +/** Index of the first colon outside quotes/angle brackets, or -1. */ +function findTopLevelColon(value: string): number { + let inQuotes = false; + let inAngle = false; + for (let i = 0; i < value.length; i++) { + const ch = value[i]; + if (ch === '"') inQuotes = !inQuotes; + else if (ch === '<' && !inQuotes) inAngle = true; + else if (ch === '>' && !inQuotes) inAngle = false; + else if (ch === ':' && !inQuotes && !inAngle) return i; + } + return -1; +} + +/** + * Parses a single recipient string (`Name `, `"Quoted, Name" `, + * or bare `email`) into a {@link Recipient}. The display name is unquoted. + * RFC 5322 group syntax (`Team: a@x, b@y;`) parses into a group chip - it is + * how contact groups round-trip through the composer's string boundaries. + */ +export function parseRecipient(s: string): Recipient { + const trimmed = s.trim(); + if (trimmed.endsWith(';')) { + const colon = findTopLevelColon(trimmed); + if (colon !== -1) { + const members = splitRecipients(trimmed.slice(colon + 1, -1)) + .map(parseRecipient) + .filter((m) => m.email && !m.group); + // Only accept the group form when it actually carries members - typed + // garbage like "Subject: hello;" stays a plain (invalid) recipient. + if (members.length > 0) { + return { name: unquoteName(trimmed.slice(0, colon)), email: '', group: { members } }; + } + } + } + const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/); + if (angleMatch) { + return { name: unquoteName(angleMatch[1]), email: angleMatch[2].trim() }; + } + return { email: trimmed }; +} + +/** Parses a serialized comma-separated recipient string into an array. */ +export function parseRecipientList(value: string): Recipient[] { + return splitRecipients(value).map(parseRecipient); +} + +/** + * Formats a single composer recipient, using RFC 5322 group syntax for + * contact-group chips so they survive the composer's string boundaries. + */ +export function formatRecipientEntry(r: Recipient): string { + if (r.group) { + const name = r.name?.trim() || 'Group'; + const quoted = NAME_NEEDS_QUOTING.test(name) + ? `"${name.replace(/(["\\])/g, '\\$1')}"` + : name; + const members = r.group.members.map((m) => formatRecipient(m.name, m.email)).join(', '); + return `${quoted}: ${members};`; + } + return formatRecipient(r.name, r.email); +} + +/** Serializes a recipient array into a comma-separated string. */ +export function formatRecipientList(recipients: Recipient[]): string { + return recipients.map(formatRecipientEntry).join(', '); +} + +/** + * Expands contact-group chips into their members for sending and + * draft-saving. Deduplicates case-insensitively by address across the whole + * list, keeping the first occurrence - an explicitly added individual wins + * over the same address arriving again via a group. + */ +export function expandRecipients(recipients: Recipient[]): Recipient[] { + const seen = new Set (); + const out: Recipient[] = []; + for (const r of recipients) { + for (const entry of r.group ? r.group.members : [r]) { + const key = entry.email.trim().toLowerCase(); + if (!key || seen.has(key)) continue; + seen.add(key); + out.push(entry.name ? { name: entry.name, email: entry.email } : { email: entry.email }); + } + } + return out; +} + +/** + * Top-level split of a pasted block into recipient entries on commas, + * semicolons and newlines (separators inside a quoted name or angle brackets + * stay literal). + */ +function splitPasteEntries(value: string): string[] { + return splitRecipients(value, ',;\n\r'); +} + +/** True when a whitespace/semicolon token of `value` is an address in its own right. */ +function carriesAddress(value: string): boolean { + return value.split(/[\s;]+/).some((t) => isValidEmail(t.trim().replace(/^<|>$/g, ''))); +} + +/** + * Splits typed or pasted text into recipient candidates and partitions them: + * valid email addresses become `Recipient`s (deduped case-insensitively + * against `existingEmails` and within the paste), and everything else is + * returned as `invalid` for the caller to drop back into the input field. + * + * Handles both structured and bare lists, preserving display names: + * - `"Name "` (the whole recipient quoted), `Name `, and + * `"Doe, John" ` entries are kept intact with their display name. + * - Bare-address dumps (`a@x.com b@y.com`, spreadsheet columns, comma/space/ + * semicolon/newline separated) split into one chip per address. + * - A token wrapped in angle brackets (``) is unwrapped before + * validating, so an `a ` fragment still yields the address. + * - `Name e.toLowerCase())); + const valid: Recipient[] = []; + const invalid: string[] = []; + + // Adds a recipient if its address is valid and unseen. Returns true when the + // entry is fully handled (valid or a known duplicate) so the caller can stop. + const tryAdd = (r: Recipient): boolean => { + if (!isValidEmail(r.email)) return false; + const key = r.email.toLowerCase(); + if (!seen.has(key)) { + seen.add(key); + valid.push(r.name ? { name: r.name, email: r.email } : { email: r.email }); + } + return true; + }; + + for (const entry of splitPasteEntries(text)) { + // 1. Structured: `Name `, a bare address, or the whole + // `Name ` wrapped in quotes (unwrap once and retry). + if (tryAdd(parseRecipient(entry))) continue; + const unwrapped = unquoteName(entry); + if (unwrapped !== entry && tryAdd(parseRecipient(unwrapped))) continue; + + // 2. `Name `, so the display name survives the paste. It keeps the + // last angle run only, so everything ahead of it would become display + // name: skip this step when that prefix carries an address of its own + // (`a@x.com ` fragment where only the is valid. + // Whitespace/semicolon-tokenize; leftover tokens stay behind. + for (const token of entry.split(/[\s;]+/).map((t) => t.trim()).filter(Boolean)) { + if (!tryAdd({ email: token.replace(/^<|>$/g, '') })) invalid.push(token); + } + } + + return { valid, invalid }; +} + +/** + * Parse a `mailto:` URL into composer prefill data. Returns null when the + * URL carries no valid recipient. Port of the webmail's parseMailtoUrl. + */ +export function parseMailtoUrl( + url: string, +): { to: string[]; cc?: string[]; bcc?: string[]; subject?: string; body?: string } | null { + if (!url || !/^mailto:/i.test(url)) return null; + + const rest = url.slice(7); + const queryIndex = rest.indexOf('?'); + const addressPart = queryIndex === -1 ? rest : rest.slice(0, queryIndex); + const query = queryIndex === -1 ? '' : rest.slice(queryIndex + 1); + + const decode = (value: string): string => { + try { + return decodeURIComponent(value.replace(/\+/g, '%20')); + } catch { + return value; + } + }; + + const to = addressPart + .split(',') + .map((a) => decode(a).trim()) + .filter((a) => isValidEmail(a)); + + const cc: string[] = []; + const bcc: string[] = []; + let subject: string | undefined; + let body: string | undefined; + for (const pair of query.split('&')) { + const eq = pair.indexOf('='); + if (eq === -1) continue; + const key = pair.slice(0, eq).toLowerCase(); + const value = decode(pair.slice(eq + 1)); + if (key === 'subject') subject = value; + else if (key === 'body') body = value; + else if (key === 'to') { + for (const a of value.split(',').map((s) => s.trim())) if (isValidEmail(a)) to.push(a); + } else if (key === 'cc') { + for (const a of value.split(',').map((s) => s.trim())) if (isValidEmail(a)) cc.push(a); + } else if (key === 'bcc') { + for (const a of value.split(',').map((s) => s.trim())) if (isValidEmail(a)) bcc.push(a); + } + } + + if (to.length === 0 && cc.length === 0 && bcc.length === 0 && !subject && !body) return null; + return { + to, + cc: cc.length ? cc : undefined, + bcc: bcc.length ? bcc : undefined, + subject, + body, + }; +} diff --git a/src/lib/reply-identity.ts b/src/lib/reply-identity.ts new file mode 100644 index 00000000..20c68f0b --- /dev/null +++ b/src/lib/reply-identity.ts @@ -0,0 +1,229 @@ +// Identity selection for replies, drafts and new messages. Port of the +// webmail's lib/reply-identity.ts. + +import type { Identity } from '../api/types'; + +interface ReplyRecipient { + email?: string | null; + name?: string | null; +} + +export interface ReplyRecipients { + to?: ReplyRecipient[]; + cc?: ReplyRecipient[]; + bcc?: ReplyRecipient[]; +} + +function normalizeEmailAddress(email: string): string { + return email.trim().toLowerCase(); +} + +function normalizeBaseEmailAddress(email: string): string { + const normalized = normalizeEmailAddress(email); + const atIndex = normalized.indexOf('@'); + + if (atIndex <= 0) { + return normalized; + } + + const localPart = normalized.slice(0, atIndex); + const domain = normalized.slice(atIndex + 1); + const plusIndex = localPart.indexOf('+'); + + return `${plusIndex >= 0 ? localPart.slice(0, plusIndex) : localPart}@${domain}`; +} + +function domainOf(email: string): string { + const at = email.indexOf('@'); + return at > 0 ? email.slice(at + 1).toLowerCase() : ''; +} + +/** + * The identity that received the original message: exact address match over + * to/cc/bcc first, then with `+tag` sub-addressing stripped. Null when none + * matches (caller keeps the default identity). + */ +export function findReplyIdentityId( + identities: Identity[], + recipients?: ReplyRecipients, +): string | null { + if (identities.length === 0 || !recipients) { + return null; + } + + const receivedAddresses = [ + ...(recipients.to || []), + ...(recipients.cc || []), + ...(recipients.bcc || []), + ] + .map((recipient) => recipient.email?.trim()) + .filter((email): email is string => Boolean(email)); + + if (receivedAddresses.length === 0) { + return null; + } + + const exactMatches = new Set(receivedAddresses.map(normalizeEmailAddress)); + const exactIdentity = identities.find((identity) => exactMatches.has(normalizeEmailAddress(identity.email))); + if (exactIdentity) { + return exactIdentity.id; + } + + const baseMatches = new Set(receivedAddresses.map(normalizeBaseEmailAddress)); + const baseIdentity = identities.find((identity) => baseMatches.has(normalizeBaseEmailAddress(identity.email))); + + return baseIdentity?.id ?? null; +} + +/** + * Pick the identity to use for a NEW message started while viewing a specific + * mailbox/account. Matches the active mailbox's address to a configured + * identity (exact, then `+tag`-stripped). Returns `null` when no address is + * given or none matches, so the caller keeps the primary identity. + */ +export function findComposeIdentityId( + identities: Identity[], + accountEmail?: string | null, +): string | null { + const email = accountEmail?.trim(); + if (identities.length === 0 || !email) { + return null; + } + + const exact = normalizeEmailAddress(email); + const exactIdentity = identities.find((identity) => normalizeEmailAddress(identity.email) === exact); + if (exactIdentity) { + return exactIdentity.id; + } + + const base = normalizeBaseEmailAddress(email); + const baseIdentity = identities.find((identity) => normalizeBaseEmailAddress(identity.email) === base); + + return baseIdentity?.id ?? null; +} + +/** + * Restore the identity a draft was composed with from its saved From. A draft + * stores only the From address+name, not an identityId, so when two identities + * share an address (a default + an alias with a different display name) the name + * has to disambiguate - an email-only match picks the wrong one. Falls back to + * email, then `+tag`-stripped email. Returns null when none match (caller keeps + * the default). + */ +export function findDraftIdentityId( + identities: Identity[], + from?: { email?: string | null; name?: string | null } | null, +): string | null { + const email = from?.email?.trim(); + if (identities.length === 0 || !email) { + return null; + } + + const wantEmail = normalizeEmailAddress(email); + const wantName = (from?.name ?? '').trim(); + + const nameAndEmail = identities.find( + (i) => normalizeEmailAddress(i.email) === wantEmail && (i.name ?? '').trim() === wantName, + ); + if (nameAndEmail) { + return nameAndEmail.id; + } + + const exact = identities.find((i) => normalizeEmailAddress(i.email) === wantEmail); + if (exact) { + return exact.id; + } + + const wantBase = normalizeBaseEmailAddress(email); + const base = identities.find((i) => normalizeBaseEmailAddress(i.email) === wantBase); + return base?.id ?? null; +} + +export interface ReplyFromResolution { + /** Identity to use for JMAP `identityId` and the SMTP envelope MAIL FROM. */ + identityId: string; + /** + * Override for the outgoing `From:` header. Populated when the incoming + * message was delivered to an address on a domain the user owns (by + * identity) but that isn't itself a configured identity - typical + * domain-catch-all deployments. When set, the composer should put this + * address (and `overrideName`) in the message's From header while sending + * through the chosen identity. + */ + overrideEmail?: string; + overrideName?: string; +} + +/** + * Pick the identity + optional header-From override for replying to a message. + * + * Decision order: + * 1. If a recipient address exactly matches an identity, reply as that + * identity with no override. + * 2. Else if a recipient matches an identity after stripping `+tag` + * sub-addressing, reply as that identity with no override. + * 3. Else if a recipient address is on a domain that one of the identities + * uses, treat that recipient as a catch-all alias: return the matching + * identity + the recipient as a header-From override. + * 4. Else return `null` (caller falls back to primary identity). + */ +export function resolveReplyFrom( + identities: Identity[], + recipients?: ReplyRecipients, +): ReplyFromResolution | null { + if (identities.length === 0 || !recipients) { + return null; + } + + const received: { email: string; name: string | undefined }[] = [ + ...(recipients.to || []), + ...(recipients.cc || []), + ...(recipients.bcc || []), + ].flatMap((r) => { + const email = r.email?.trim(); + if (!email) return []; + return [{ email, name: r.name?.trim() || undefined }]; + }); + + if (received.length === 0) { + return null; + } + + const identityEmails = new Set(identities.map((i) => normalizeEmailAddress(i.email))); + const identityBaseEmails = new Set(identities.map((i) => normalizeBaseEmailAddress(i.email))); + + const exactIdentity = identities.find((i) => + received.some((r) => normalizeEmailAddress(r.email) === normalizeEmailAddress(i.email)), + ); + if (exactIdentity) { + return { identityId: exactIdentity.id }; + } + + const baseIdentity = identities.find((i) => + received.some((r) => normalizeBaseEmailAddress(r.email) === normalizeBaseEmailAddress(i.email)), + ); + if (baseIdentity) { + return { identityId: baseIdentity.id }; + } + + const ownedDomains = new Set(identities.map((i) => domainOf(i.email)).filter(Boolean)); + + const catchAll = received.find((r) => { + const email = normalizeEmailAddress(r.email); + if (identityEmails.has(email) || identityBaseEmails.has(normalizeBaseEmailAddress(email))) { + return false; + } + return ownedDomains.has(domainOf(email)); + }); + + if (catchAll) { + const anchor = identities.find((i) => domainOf(i.email) === domainOf(catchAll.email)) || identities[0]; + return { + identityId: anchor.id, + overrideEmail: catchAll.email, + overrideName: catchAll.name, + }; + } + + return null; +} diff --git a/src/lib/signature-utils.ts b/src/lib/signature-utils.ts new file mode 100644 index 00000000..963585bc --- /dev/null +++ b/src/lib/signature-utils.ts @@ -0,0 +1,228 @@ +// Signature helpers for the composer. Port of the webmail's +// lib/signature-utils.ts plus the embedded-signature block builder from +// components/email/email-composer.tsx (buildEmbeddedSignatureHtml). +// +// The RN editor is a plain contenteditable (no Tiptap atom), so the signature +// is bracketed by `data-signature-block` marker paragraphs and is always +// editable. The markers let an identity switch swap just the signature +// without touching the draft text or the quoted message. + +import { stripDangerousTags } from './email-html'; +import { htmlToPlainText } from './compose-html'; + +export type SignatureSource = { + textSignature?: string; + htmlSignature?: string; +} | null | undefined; + +export const SIGNATURE_RANGE_MARKER = 'data-signature-block'; + +const SIGNATURE_ALLOWED_TAGS = new Set([ + 'p', 'br', 'b', 'strong', 'i', 'em', 'u', 'a', 'span', 'div', 'img', + 'table', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th', +]); +const SIGNATURE_ALLOWED_ATTRS = new Set([ + 'href', 'style', 'class', 'src', 'alt', 'width', 'height', 'title', + 'cellpadding', 'cellspacing', 'border', 'valign', 'align', 'bgcolor', + 'colspan', 'rowspan', +]); + +/** + * Sanitize an HTML signature for storage and for the outgoing message. A + * regex allowlist stands in for the webmail's DOMPurify config + * (SIGNATURE_SANITIZE_CONFIG): formatting, links, images and tables only; no + * script, no event handlers, no external URI schemes; images only over https: + * or as a base64 raster data: URI. + */ +export function sanitizeSignatureHtml(html: string): string { + if (!html?.trim()) return ''; + const stripped = stripDangerousTags(html); + return stripped.replace(/<\/?([a-zA-Z][a-zA-Z0-9]*)\b([^>]*)>/g, (full, rawTag: string, rawAttrs: string) => { + const tag = rawTag.toLowerCase(); + if (!SIGNATURE_ALLOWED_TAGS.has(tag)) return ''; + if (full.startsWith('')) return `${tag}>`; + const attrs: string[] = []; + const attrRe = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*(?:=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g; + let m: RegExpExecArray | null; + while ((m = attrRe.exec(rawAttrs)) !== null) { + const name = m[1].toLowerCase(); + if (!SIGNATURE_ALLOWED_ATTRS.has(name)) continue; + const value = m[3] ?? m[4] ?? m[5] ?? ''; + if (name === 'href' && !/^(https?:|mailto:|tel:)/i.test(value.trim())) continue; + if (name === 'src' && !/^(?:https:\/\/|data:image\/(?:png|jpe?g|gif|webp);base64,)/i.test(value.trim())) { + // An image with a disallowed source is dropped entirely. + return ''; + } + if (name === 'style' && /expression\s*\(|url\s*\(\s*['"]?\s*javascript:/i.test(value)) continue; + attrs.push(`${name}="${value.replace(/"/g, '"')}"`); + } + const selfClose = /\/\s*$/.test(rawAttrs) ? ' /' : ''; + return `<${tag}${attrs.length ? ' ' + attrs.join(' ') : ''}${selfClose}>`; + }); +} + +function normalizeSignatureLineBreaks(value: string): string { + return value + .replace(/\r\n?/g, '\n') + .replace(/ /g, ' ') + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +export function hasSignature(signature?: SignatureSource): boolean { + return !!(signature?.htmlSignature?.trim() || signature?.textSignature?.trim()); +} + +export function getPlainTextSignature(signature?: SignatureSource): string { + if (signature?.textSignature?.trim()) { + return normalizeSignatureLineBreaks(signature.textSignature); + } + + if (signature?.htmlSignature?.trim()) { + return htmlToPlainText(sanitizeSignatureHtml(signature.htmlSignature)); + } + + return ''; +} + +export function appendPlainTextSignature( + body: string, + signature?: SignatureSource, + options: { separator?: boolean } = {}, +): string { + const plainTextSignature = getPlainTextSignature(signature); + if (!plainTextSignature) { + return body; + } + + const sep = options.separator === false ? '\n\n' : '\n\n-- \n'; + return `${body}${sep}${plainTextSignature}`; +} + +/** + * Whether a plain-text body already ends with the identity's signature. + */ +export function plainTextBodyHasSignature( + body: string, + signature?: SignatureSource, +): boolean { + const plainTextSignature = getPlainTextSignature(signature); + if (!plainTextSignature) { + return false; + } + return normalizeSignatureLineBreaks(body).endsWith(plainTextSignature); +} + +/** + * The plain-text body with a trailing signature - and the `-- ` separator + * line in front of it - removed. Returns the body unchanged when it does not + * end with the identity's signature. + */ +export function plainTextBodyWithoutSignature( + body: string, + signature?: SignatureSource, +): string { + const plainTextSignature = getPlainTextSignature(signature); + if (!plainTextSignature) { + return body; + } + const normalized = normalizeSignatureLineBreaks(body); + if (!normalized.endsWith(plainTextSignature)) { + return body; + } + return normalized + .slice(0, normalized.length - plainTextSignature.length) + .replace(/\n*(?:-- ?)?\n*$/, ''); +} + +function escapeText(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\n/g, '
'); +} + +/** + * Append a signature to an HTML body, preserving rich formatting (quick + * reply path, no markers). + */ +export function appendHtmlSignature( + htmlBody: string, + signature?: SignatureSource, + options: { separator?: boolean } = {}, +): string { + const sep = options.separator === false ? '
' : '
--
'; + + if (signature?.htmlSignature?.trim()) { + return `${htmlBody}${sep}${sanitizeSignatureHtml(signature.htmlSignature)}`; + } + + if (signature?.textSignature?.trim()) { + return `${htmlBody}${sep}${escapeText(signature.textSignature)}`; + } + + return htmlBody; +} + +/** + * Render the embedded, editable signature range: a `-- ` separator paragraph + * (or an empty start marker), the signature, and an end marker. Returns '' + * when the identity has no signature. + */ +export function buildEmbeddedSignatureHtml( + identity: SignatureSource, + options: { separator: boolean }, +): string { + const startMarker = options.separator + ? `--
` + : ``; + const endMarker = ``; + if (identity?.htmlSignature?.trim()) { + return `${startMarker}
${sanitizeSignatureHtml(identity.htmlSignature)}${endMarker}`; + } + if (identity?.textSignature?.trim()) { + return `${startMarker}${escapeText(identity.textSignature)}
${endMarker}`; + } + return ''; +} + +const SIGNATURE_RANGE_RE = new RegExp( + `]*>[\\s\\S]*?
]*>(?:
`, + 'i', +); + +/** True when the body carries an embedded signature range. */ +export function containsEmbeddedSignature(html: string): boolean { + return SIGNATURE_RANGE_RE.test(html); +} + +/** + * Replace the embedded signature range with `nextSignatureHtml` (which may + * be '' to remove it). When the body has no range yet, the signature is + * appended at the end. Returns the body unchanged when nothing changes. + */ +export function spliceSignature(html: string, nextSignatureHtml: string): string { + if (SIGNATURE_RANGE_RE.test(html)) { + return html.replace(SIGNATURE_RANGE_RE, () => nextSignatureHtml); + } + if (!nextSignatureHtml) return html; + return `${html}${nextSignatureHtml}`; +} + +/** The body without its embedded signature range. */ +export function stripEmbeddedSignature(html: string): string { + return html.replace(SIGNATURE_RANGE_RE, ''); +} + +/** + * Insert the signature range in front of the quoted block (`On … wrote:` / + * forwarded header) or, when there is none, at the end of the body. + */ +export function insertSignatureAboveQuote(html: string, signatureHtml: string, quoteStartMarker: string): string { + if (!signatureHtml) return html; + const idx = quoteStartMarker ? html.indexOf(quoteStartMarker) : -1; + if (idx === -1) return `${html}${signatureHtml}`; + return `${html.slice(0, idx)}${signatureHtml}${html.slice(idx)}`; +} diff --git a/src/lib/sub-addressing.ts b/src/lib/sub-addressing.ts new file mode 100644 index 00000000..726efd58 --- /dev/null +++ b/src/lib/sub-addressing.ts @@ -0,0 +1,167 @@ +/** + * Sub-addressing utilities for user{delimiter}tag@domain.com format. + * Works server-side automatically - no JMAP API calls needed. + * Port of the webmail's lib/sub-addressing.ts. + * + * The delimiter character is configurable per server (RFC 5233). Common + * choices: "+" (Postfix, Stalwart default), "-" (qmail), ".", "=". + */ + +const MAX_TAG_LENGTH = 30; +const TAG_REGEX = /^[a-zA-Z0-9-]{1,30}$/; + +export const DEFAULT_SUB_ADDRESS_DELIMITER = '+'; +export const SUPPORTED_SUB_ADDRESS_DELIMITERS = ['+', '-', '.', '='] as const; +export type SubAddressDelimiterPreset = (typeof SUPPORTED_SUB_ADDRESS_DELIMITERS)[number]; + +export function isSupportedSubAddressDelimiter(value: string): value is SubAddressDelimiterPreset { + return (SUPPORTED_SUB_ADDRESS_DELIMITERS as readonly string[]).includes(value); +} + +// RFC 5321 atext "special" characters, minus alphanumerics and "@". A custom +// delimiter must be exactly one of these - they're safe to embed in a local +// part and unambiguously separate the user from the tag. +const VALID_DELIMITER_REGEX = /^[!#$%&'*+\-./=?^_`{|}~]$/; + +export function isValidSubAddressDelimiter(value: unknown): value is string { + return typeof value === 'string' && VALID_DELIMITER_REGEX.test(value); +} + +export type TagValidationErrorCode = + | 'EMPTY' + | 'TOO_LONG' + | 'INVALID_CHARS' + | null; + +export interface ParsedAddress { + localPart: string; + baseUser: string; + tag: string | null; + domain: string; + fullAddress: string; +} + +/** + * Parse an email address to extract the sub-address tag. The first occurrence + * of the delimiter in the local part separates the base user from the tag, + * matching the behavior of Postfix/qmail/Sieve. + */ +export function parseSubAddress( + email: string, + delimiter: string = DEFAULT_SUB_ADDRESS_DELIMITER, +): ParsedAddress { + const [localPart, domain] = email.split('@'); + + if (!localPart || !domain) { + return { + localPart: localPart || '', + baseUser: localPart || '', + tag: null, + domain: domain || '', + fullAddress: email, + }; + } + + const delimiterIndex = localPart.indexOf(delimiter); + + if (delimiterIndex === -1) { + return { localPart, baseUser: localPart, tag: null, domain, fullAddress: email }; + } + + const baseUser = localPart.substring(0, delimiterIndex); + const tag = localPart.substring(delimiterIndex + delimiter.length); + + return { localPart, baseUser, tag: tag || null, domain, fullAddress: email }; +} + +/** + * Generate a sub-addressed email. + * Example: generateSubAddress("user@example.com", "shopping", "+") -> "user+shopping@example.com" + */ +export function generateSubAddress( + baseEmail: string, + tag: string, + delimiter: string = DEFAULT_SUB_ADDRESS_DELIMITER, +): string { + const [localPart, domain] = baseEmail.split('@'); + + if (!localPart || !domain || !tag) { + return baseEmail; + } + + // Strip an existing tag if one is already present + const existingDelimiterIndex = localPart.indexOf(delimiter); + const cleanLocal = existingDelimiterIndex === -1 + ? localPart + : localPart.substring(0, existingDelimiterIndex); + + // Sanitize tag (alphanumeric and dash only) + const cleanTag = tag.replace(/[^a-zA-Z0-9-]/g, '').toLowerCase(); + + if (!cleanTag) { + return baseEmail; + } + + return `${cleanLocal}${delimiter}${cleanTag}@${domain}`; +} + +/** Extract domain from recipient email for tag suggestions. */ +export function extractDomain(email: string): string | null { + const match = email.match(/@([^@]+)$/); + return match ? match[1].toLowerCase() : null; +} + +/** Suggest tags based on the recipient domain. */ +export function suggestTagsForDomain(domain: string): string[] { + const domainLower = domain.toLowerCase(); + + const suggestions: Record
)?= { + 'amazon.com': ['amazon', 'shopping', 'orders'], + 'amazon.fr': ['amazon', 'shopping', 'orders'], + 'amazon.de': ['amazon', 'shopping', 'orders'], + 'amazon.co.uk': ['amazon', 'shopping', 'orders'], + 'ebay.com': ['ebay', 'shopping'], + 'ebay.fr': ['ebay', 'shopping'], + 'paypal.com': ['paypal', 'payments'], + 'facebook.com': ['facebook', 'social'], + 'twitter.com': ['twitter', 'social'], + 'x.com': ['twitter', 'social'], + 'linkedin.com': ['linkedin', 'professional'], + 'github.com': ['github', 'dev', 'notifications'], + 'gitlab.com': ['gitlab', 'dev', 'notifications'], + 'stackoverflow.com': ['stackoverflow', 'dev'], + 'reddit.com': ['reddit', 'social'], + 'netflix.com': ['netflix', 'entertainment'], + 'spotify.com': ['spotify', 'music'], + 'steam.com': ['steam', 'gaming'], + 'discord.com': ['discord', 'gaming'], + }; + + if (suggestions[domainLower]) { + return suggestions[domainLower]; + } + + // Extract main domain (e.g., "mail.google.com" -> "google") + const parts = domainLower.split('.'); + const mainDomain = parts.length >= 2 ? parts[parts.length - 2] : parts[0]; + + return [mainDomain, 'newsletter', 'registration']; +} + +/** Validate if a tag is safe to use. */ +export function isValidTag(tag: string): boolean { + return TAG_REGEX.test(tag); +} + +/** + * Get validation error code for an invalid tag. Returns an error code that + * should be translated by the calling component. + */ +export function getTagValidationError(tag: string): TagValidationErrorCode { + if (!tag) return 'EMPTY'; + if (tag.length > MAX_TAG_LENGTH) return 'TOO_LONG'; + if (!/^[a-zA-Z0-9-]+$/.test(tag)) return 'INVALID_CHARS'; + return null; +} + +export { MAX_TAG_LENGTH }; diff --git a/src/lib/template-utils.ts b/src/lib/template-utils.ts new file mode 100644 index 00000000..2fea0181 --- /dev/null +++ b/src/lib/template-utils.ts @@ -0,0 +1,217 @@ +// Template placeholders, export/import envelope and filtering. Port of the +// webmail's lib/template-utils.ts + lib/template-types.ts (DOM-free: the +// tag-stripping DOMPurify pass is replaced by a regex strip). + +import type { EmailTemplate } from '../stores/templates-store'; +import { generateUUID } from './uuid'; + +export const BUILT_IN_PLACEHOLDERS = [ + 'recipient_name', + 'company', + 'date', + 'day_of_week', + 'sender_name', +] as const; + +export type BuiltInPlaceholder = (typeof BUILT_IN_PLACEHOLDERS)[number]; + +const PLACEHOLDER_REGEX = /\{\{(\w+)\}\}/g; +const MAX_TEMPLATE_NAME_LENGTH = 200; + +function stripTags(value: string): string { + return value.replace(/<[^>]*>/g, ''); +} + +export function extractPlaceholders(text: string): string[] { + const matches = new Set (); + let match: RegExpExecArray | null; + const regex = new RegExp(PLACEHOLDER_REGEX.source, 'g'); + while ((match = regex.exec(text)) !== null) { + matches.add(match[1]); + } + return Array.from(matches); +} + +export function substitutePlaceholders( + text: string, + values: Record , +): string { + return text.replace(PLACEHOLDER_REGEX, (full, name: string) => { + if (values[name] === undefined) return full; + return stripTags(values[name]); + }); +} + +export function hasUnresolvedPlaceholders(text: string): boolean { + return new RegExp(PLACEHOLDER_REGEX.source).test(text); +} + +export function validateTemplateName(name: string): 'empty' | 'too_long' | null { + const trimmed = name.trim(); + if (!trimmed) return 'empty'; + if (trimmed.length > MAX_TEMPLATE_NAME_LENGTH) return 'too_long'; + return null; +} + +export interface AutoFillContext { + senderName?: string; + recipientName?: string; + locale?: string; +} + +export function getAutoFilledPlaceholders( + context: AutoFillContext, +): Record { + const now = new Date(); + const locale = context.locale || 'en'; + const intlLocale = locale === 'en' ? 'en-US' : locale; + + const values: Record = {}; + try { + values.date = now.toLocaleDateString(intlLocale, { year: 'numeric', month: 'long', day: 'numeric' }); + values.day_of_week = now.toLocaleDateString(intlLocale, { weekday: 'long' }); + } catch { + values.date = now.toDateString(); + values.day_of_week = now.toLocaleDateString(undefined, { weekday: 'long' }); + } + + if (context.senderName) { + values.sender_name = context.senderName; + } + if (context.recipientName) { + values.recipient_name = context.recipientName; + } + + return values; +} + +export function getPlaceholdersFromTemplate(template: Pick ): string[] { + const combined = `${template.subject} ${template.body}`; + return extractPlaceholders(combined); +} + +export function isBuiltInPlaceholder(name: string): boolean { + return (BUILT_IN_PLACEHOLDERS as readonly string[]).includes(name); +} + +export function filterTemplates(templates: EmailTemplate[], query: string): EmailTemplate[] { + const lower = query.toLowerCase(); + return templates.filter( + (t) => + t.name.toLowerCase().includes(lower) || + t.subject.toLowerCase().includes(lower) || + t.category.toLowerCase().includes(lower), + ); +} + +interface ExportData { + version: 1; + type: 'webmail-templates'; + exportedAt: string; + templates: EmailTemplate[]; +} + +/** JSON in the webmail's export shape so a file round-trips between clients. */ +export function exportTemplates(templates: EmailTemplate[]): string { + const data: ExportData = { + version: 1, + type: 'webmail-templates', + exportedAt: new Date().toISOString(), + templates, + }; + return JSON.stringify(data, null, 2); +} + +export interface ImportResult { + templates: EmailTemplate[]; + errors: string[]; +} + +function sanitizeText(value: unknown): string { + return stripTags(String(value || '')); +} + +/** + * Parse a templates export. Accepts the webmail envelope (`type: + * 'webmail-templates'`) and, for backwards compatibility, an older RN export + * that carried only `{ version, templates }`. Ids are regenerated so an + * import never overwrites an existing template. + */ +export function importTemplates(json: string): ImportResult { + const errors: string[] = []; + + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + return { templates: [], errors: ['invalid_json'] }; + } + + if (typeof parsed !== 'object' || parsed === null) { + return { templates: [], errors: ['invalid_format'] }; + } + + const data = parsed as Record ; + + if (data.type !== undefined && data.type !== 'webmail-templates') { + return { templates: [], errors: ['invalid_type'] }; + } + + if (data.version !== undefined && data.version !== 1) { + return { templates: [], errors: ['unsupported_version'] }; + } + + if (!Array.isArray(data.templates)) { + return { templates: [], errors: ['invalid_templates'] }; + } + + const templates: EmailTemplate[] = []; + for (const item of data.templates) { + if (typeof item !== 'object' || item === null) { + errors.push('invalid_template_entry'); + continue; + } + + const t = item as Record ; + if (typeof t.name !== 'string' || !t.name.trim()) { + errors.push('missing_template_name'); + continue; + } + + const recipients = t.defaultRecipients as Record | undefined; + const now = new Date().toISOString(); + + templates.push({ + id: generateUUID(), + name: sanitizeText(t.name), + subject: sanitizeText(t.subject), + body: t.isHTML ? String(t.body || '') : sanitizeText(t.body), + isHTML: Boolean(t.isHTML), + category: sanitizeText(t.category), + defaultRecipients: recipients && typeof recipients === 'object' + ? { + to: Array.isArray(recipients.to) ? (recipients.to as unknown[]).map(String) : undefined, + cc: Array.isArray(recipients.cc) ? (recipients.cc as unknown[]).map(String) : undefined, + bcc: Array.isArray(recipients.bcc) ? (recipients.bcc as unknown[]).map(String) : undefined, + } + : undefined, + identityId: typeof t.identityId === 'string' ? t.identityId : undefined, + isFavorite: Boolean(t.isFavorite), + createdAt: typeof t.createdAt === 'string' ? t.createdAt : now, + updatedAt: typeof t.updatedAt === 'string' ? t.updatedAt : now, + }); + } + + return { templates, errors }; +} + +/** Convert a plain-text template body to composer HTML (escape +
). */ +export function templateBodyToHtml(template: Pick): string { + if (template.isHTML) return template.body; + const escaped = template.body + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\r?\n/g, '
'); + return `${escaped}
`; +} diff --git a/src/stores/templates-store.ts b/src/stores/templates-store.ts index 6c3927c2..07d6c9b9 100644 --- a/src/stores/templates-store.ts +++ b/src/stores/templates-store.ts @@ -6,13 +6,22 @@ import { create } from 'zustand'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import { exportTemplates, importTemplates as parseTemplateImport } from '../lib/template-utils'; export interface EmailTemplate { id: string; name: string; subject: string; body: string; + /** Body is HTML (inserted verbatim) rather than plain text. */ + isHTML?: boolean; category: string; + defaultRecipients?: { + to?: string[]; + cc?: string[]; + bcc?: string[]; + }; + identityId?: string; isFavorite: boolean; createdAt: string; updatedAt: string; @@ -25,7 +34,10 @@ interface TemplatesState { hydrated: boolean; hydrate: () => Promise; - addTemplate: (data: Pick & Partial >) => EmailTemplate; + addTemplate: ( + data: Pick + & Partial >, + ) => EmailTemplate; updateTemplate: (id: string, updates: Partial >) => void; deleteTemplate: (id: string) => void; exportAll: () => string; @@ -71,6 +83,9 @@ export const useTemplatesStore = create ((set, get) => ({ subject: data.subject, body: data.body, category: data.category ?? '', + isHTML: data.isHTML, + defaultRecipients: data.defaultRecipients, + identityId: data.identityId, isFavorite: data.isFavorite ?? false, createdAt: now, updatedAt: now, @@ -95,44 +110,19 @@ export const useTemplatesStore = create ((set, get) => ({ persist(next); }, - exportAll: () => { - return JSON.stringify({ version: 1, templates: get().templates }, null, 2); - }, + // Webmail export envelope (type: 'webmail-templates', exportedAt) so a + // file exported here imports on the web and vice versa. + exportAll: () => exportTemplates(get().templates), importTemplates: (json) => { - try { - const parsed = JSON.parse(json) as { templates?: unknown }; - if (!Array.isArray(parsed.templates)) { - return { count: 0, error: 'Invalid template file: expected `templates` array' }; - } - const incoming: EmailTemplate[] = []; - for (const raw of parsed.templates) { - if (!raw || typeof raw !== 'object') continue; - const t = raw as Partial ; - if (typeof t.name !== 'string' || typeof t.body !== 'string') continue; - const now = new Date().toISOString(); - incoming.push({ - id: typeof t.id === 'string' ? t.id : genId(), - name: t.name, - subject: typeof t.subject === 'string' ? t.subject : '', - body: t.body, - category: typeof t.category === 'string' ? t.category : '', - isFavorite: !!t.isFavorite, - createdAt: typeof t.createdAt === 'string' ? t.createdAt : now, - updatedAt: typeof t.updatedAt === 'string' ? t.updatedAt : now, - }); - } - const existing = get().templates; - const existingIds = new Set(existing.map((t) => t.id)); - const merged = [ - ...existing, - ...incoming.map((t) => (existingIds.has(t.id) ? { ...t, id: genId() } : t)), - ]; - set({ templates: merged }); - persist(merged); - return { count: incoming.length }; - } catch (err) { - return { count: 0, error: err instanceof Error ? err.message : 'Parse failed' }; + const result = parseTemplateImport(json); + if (result.errors.length && result.templates.length === 0) { + return { count: 0, error: result.errors[0] }; } + if (result.templates.length === 0) return { count: 0 }; + const merged = [...get().templates, ...result.templates]; + set({ templates: merged }); + persist(merged); + return { count: result.templates.length }; }, }));