From 506a4b1a84d05c4bf0194dfaa74bd8fe414bb034 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 29 Aug 2026 17:49:23 +0200 Subject: [PATCH] feat: port the reply-recipient, subject-prefix, threading and mailbox-parsing helpers from the webmail --- src/lib/__tests__/email-threading.test.ts | 86 ++++++++++++ src/lib/__tests__/reply-recipients.test.ts | 137 ++++++++++++++++++++ src/lib/__tests__/subject-prefix.test.ts | 87 +++++++++++++ src/lib/email-threading.ts | 66 ++++++++++ src/lib/reply-recipients.ts | 111 ++++++++++++++++ src/lib/rfc5322-mailbox.ts | 61 +++++++++ src/lib/subject-prefix.ts | 144 +++++++++++++++++++++ 7 files changed, 692 insertions(+) create mode 100644 src/lib/__tests__/email-threading.test.ts create mode 100644 src/lib/__tests__/reply-recipients.test.ts create mode 100644 src/lib/__tests__/subject-prefix.test.ts create mode 100644 src/lib/email-threading.ts create mode 100644 src/lib/reply-recipients.ts create mode 100644 src/lib/rfc5322-mailbox.ts create mode 100644 src/lib/subject-prefix.ts diff --git a/src/lib/__tests__/email-threading.test.ts b/src/lib/__tests__/email-threading.test.ts new file mode 100644 index 00000000..307815b1 --- /dev/null +++ b/src/lib/__tests__/email-threading.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from 'vitest'; +import { + computeReplyThreadingHeaders, + stripMessageIdBrackets, +} from '../email-threading'; + +describe('stripMessageIdBrackets', () => { + it('strips surrounding angle brackets', () => { + expect(stripMessageIdBrackets('')).toBe('abc@example.com'); + }); + + it('handles whitespace and missing brackets', () => { + expect(stripMessageIdBrackets(' abc@example.com ')).toBe('abc@example.com'); + expect(stripMessageIdBrackets('')).toBe('abc@example.com'); + }); +}); + +describe('computeReplyThreadingHeaders', () => { + it('returns null when the parent has no Message-ID', () => { + expect(computeReplyThreadingHeaders(undefined)).toBeNull(); + expect(computeReplyThreadingHeaders({})).toBeNull(); + expect(computeReplyThreadingHeaders({ messageId: '' })).toBeNull(); + expect(computeReplyThreadingHeaders({ messageId: ' ' })).toBeNull(); + }); + + it('sets In-Reply-To to the parent Message-ID and seeds References with it', () => { + const result = computeReplyThreadingHeaders({ + messageId: '', + }); + expect(result).toEqual({ + inReplyTo: ['root@example.com'], + references: ['root@example.com'], + }); + }); + + it('appends the parent to existing References per RFC 5322', () => { + const result = computeReplyThreadingHeaders({ + messageId: '', + references: ['', ''], + }); + expect(result).toEqual({ + inReplyTo: ['msg-2@example.com'], + references: ['msg-0@example.com', 'msg-1@example.com', 'msg-2@example.com'], + }); + }); + + it('de-duplicates if the parent already appears in References', () => { + const result = computeReplyThreadingHeaders({ + messageId: '', + references: ['', ''], + }); + expect(result?.references).toEqual([ + 'msg-0@example.com', + 'msg-1@example.com', + ]); + }); + + it('accepts bare Message-IDs without angle brackets', () => { + const result = computeReplyThreadingHeaders({ + messageId: 'msg-2@example.com', + references: ['msg-1@example.com'], + }); + expect(result).toEqual({ + inReplyTo: ['msg-2@example.com'], + references: ['msg-1@example.com', 'msg-2@example.com'], + }); + }); + + // JMAP RFC 8621 §4.1.2.3 returns messageId as String[]|null. Verify we + // don't crash on that shape even though most call sites pass a string. + it('accepts an array-shaped messageId per JMAP spec', () => { + const result = computeReplyThreadingHeaders({ + messageId: [''], + references: [''], + }); + expect(result).toEqual({ + inReplyTo: ['msg-2@example.com'], + references: ['msg-1@example.com', 'msg-2@example.com'], + }); + }); + + it('returns null for an empty messageId array', () => { + expect(computeReplyThreadingHeaders({ messageId: [] })).toBeNull(); + }); +}); diff --git a/src/lib/__tests__/reply-recipients.test.ts b/src/lib/__tests__/reply-recipients.test.ts new file mode 100644 index 00000000..1d9b794e --- /dev/null +++ b/src/lib/__tests__/reply-recipients.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect } from 'vitest'; +import { buildReplyRecipients, isSelfSent } from '../reply-recipients'; + +const OWN = ['me@example.com', 'info@example.com']; + +const emails = (list: { email?: string }[]) => list.map((r) => r.email); + +describe('buildReplyRecipients', () => { + describe('received message', () => { + const received = { + from: [{ email: 'bob@other.com', name: 'Bob' }], + to: [{ email: 'me@example.com' }, { email: 'carol@other.com' }], + cc: [{ email: 'dave@other.com' }], + }; + + it('replies to the sender', () => { + const { to, cc } = buildReplyRecipients(received, 'reply', OWN); + expect(emails(to)).toEqual(['bob@other.com']); + expect(cc).toEqual([]); + }); + + it('prefers the Reply-To header over From', () => { + const { to } = buildReplyRecipients( + { ...received, replyToAddresses: [{ email: 'list@other.com' }] }, + 'reply', + OWN, + ); + expect(emails(to)).toEqual(['list@other.com']); + }); + + it('reply-all keeps the other recipients and drops our own address', () => { + const { to, cc } = buildReplyRecipients(received, 'replyAll', OWN); + expect(emails(to)).toEqual(['bob@other.com', 'carol@other.com']); + expect(emails(cc)).toEqual(['dave@other.com']); + }); + + it('reply-all drops our own address even with +tag sub-addressing', () => { + const { to } = buildReplyRecipients( + { ...received, to: [{ email: 'me+newsletter@example.com' }, { email: 'carol@other.com' }] }, + 'replyAll', + OWN, + ); + expect(emails(to)).toEqual(['bob@other.com', 'carol@other.com']); + }); + }); + + describe('self-sent message (#703)', () => { + const sent = { + from: [{ email: 'me@example.com', name: 'Me' }], + to: [{ email: 'bob@other.com', name: 'Bob' }], + cc: [{ email: 'carol@other.com' }], + }; + + it('replies to the original recipient, not to ourselves', () => { + const { to, cc } = buildReplyRecipients(sent, 'reply', OWN); + expect(emails(to)).toEqual(['bob@other.com']); + expect(cc).toEqual([]); + }); + + it('reply-all restores the original To and Cc', () => { + const { to, cc } = buildReplyRecipients(sent, 'replyAll', OWN); + expect(emails(to)).toEqual(['bob@other.com']); + expect(emails(cc)).toEqual(['carol@other.com']); + }); + + it('recognises the sending identity through +tag sub-addressing', () => { + const { to } = buildReplyRecipients( + { ...sent, from: [{ email: 'me+project@example.com' }] }, + 'reply', + OWN, + ); + expect(emails(to)).toEqual(['bob@other.com']); + }); + + it('ignores our own Reply-To header so the reply leaves our mailbox', () => { + const { to } = buildReplyRecipients( + { ...sent, replyToAddresses: [{ email: 'info@example.com' }] }, + 'reply', + OWN, + ); + expect(emails(to)).toEqual(['bob@other.com']); + }); + + it('honours an EXTERNAL Reply-To even on a self-sent message (#776)', () => { + // Contact-form / helpdesk pattern: the mail arrives From our own address + // (so it looks self-sent) but Reply-To carries the real correspondent. + // The reply must reach them, not loop back to our own To. + const { to } = buildReplyRecipients( + { ...sent, to: [{ email: 'me@example.com' }], replyToAddresses: [{ email: 'customer@external.com' }] }, + 'reply', + OWN, + ); + expect(emails(to)).toEqual(['customer@external.com']); + }); + + it('reply-all with an external Reply-To routes To to the Reply-To (#776)', () => { + const { to } = buildReplyRecipients( + { ...sent, replyToAddresses: [{ email: 'customer@external.com' }] }, + 'replyAll', + OWN, + ); + // Reply-To leads; the other original recipients (bob) are still included, + // our own address dropped. + expect(emails(to)).toEqual(['customer@external.com', 'bob@other.com']); + }); + + it('keeps a self-addressed recipient we chose ourselves', () => { + const { to } = buildReplyRecipients( + { ...sent, to: [{ email: 'info@example.com' }] }, + 'reply', + OWN, + ); + expect(emails(to)).toEqual(['info@example.com']); + }); + + it('falls back to the sender when there is no visible recipient (Bcc-only)', () => { + const { to } = buildReplyRecipients({ ...sent, to: [], cc: [] }, 'reply', OWN); + expect(emails(to)).toEqual(['me@example.com']); + }); + + it('keeps the display names of the original recipients', () => { + const { to } = buildReplyRecipients(sent, 'reply', OWN); + expect(to[0]).toEqual({ email: 'bob@other.com', name: 'Bob' }); + }); + }); + + it('returns nothing without a source message', () => { + expect(buildReplyRecipients(undefined, 'replyAll', OWN)).toEqual({ to: [], cc: [] }); + }); + + it('treats a message as foreign when no identity matches', () => { + expect(isSelfSent({ from: [{ email: 'bob@other.com' }] }, OWN)).toBe(false); + expect(isSelfSent({ from: [{ email: 'ME@Example.com ' }] }, OWN)).toBe(true); + expect(isSelfSent({ from: [] }, OWN)).toBe(false); + expect(isSelfSent(undefined, OWN)).toBe(false); + }); +}); diff --git a/src/lib/__tests__/subject-prefix.test.ts b/src/lib/__tests__/subject-prefix.test.ts new file mode 100644 index 00000000..2bb519b7 --- /dev/null +++ b/src/lib/__tests__/subject-prefix.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest'; +import { + stripSubjectPrefixes, + buildReplySubject, + buildForwardSubject, + buildComposeTabTitle, +} from '../subject-prefix'; + +describe('stripSubjectPrefixes', () => { + it('strips a chain of mixed-language prefixes', () => { + expect(stripSubjectPrefixes('Re: AW: WG: foo')).toBe('foo'); + }); + + it('strips the Outlook [N] and Eudora *N counters', () => { + expect(stripSubjectPrefixes('Re[2]: foo')).toBe('foo'); + expect(stripSubjectPrefixes('Re*3: foo')).toBe('foo'); + }); + + it('is case-insensitive and idempotent', () => { + expect(stripSubjectPrefixes('RE: Re: foo')).toBe('foo'); + expect(stripSubjectPrefixes(stripSubjectPrefixes('RE: Re: foo'))).toBe('foo'); + }); + + it('strips a Cyrillic token and an ASCII-colon Chinese token', () => { + expect(stripSubjectPrefixes('Ответ: foo')).toBe('foo'); + expect(stripSubjectPrefixes('回复: foo')).toBe('foo'); + }); + + it('strips a token followed by a full-width colon (CJK clients)', () => { + expect(stripSubjectPrefixes('回复:foo')).toBe('foo'); + expect(stripSubjectPrefixes('回覆:foo')).toBe('foo'); + expect(stripSubjectPrefixes('Re:foo')).toBe('foo'); + }); + + it('still does not strip a bare single-letter "R:"', () => { + expect(stripSubjectPrefixes('R: budget 2024')).toBe('R: budget 2024'); + }); + + it('returns "" for empty / null / undefined and leaves clean subjects alone', () => { + expect(stripSubjectPrefixes('')).toBe(''); + expect(stripSubjectPrefixes(null)).toBe(''); + expect(stripSubjectPrefixes(undefined)).toBe(''); + expect(stripSubjectPrefixes('foo')).toBe('foo'); + }); +}); + +describe('buildReplySubject / buildForwardSubject', () => { + it('replaces a prefix chain (incl. a full-width colon) with the given prefix', () => { + expect(buildReplySubject('回复:foo', 'Re:')).toBe('Re: foo'); + expect(buildForwardSubject('Re: foo', 'Fwd:')).toBe('Fwd: foo'); + }); + + it('prepends to a clean subject and returns the bare prefix for empty input', () => { + expect(buildReplySubject('foo', 'AW:')).toBe('AW: foo'); + expect(buildReplySubject('', 'AW:')).toBe('AW:'); + }); +}); + +describe('buildComposeTabTitle', () => { + const prefixes = { replyPrefix: 'Re:', forwardPrefix: 'Fwd:', fallback: 'New message' }; + + it('never inherits the selected email subject for a fresh compose (#856)', () => { + expect(buildComposeTabTitle({ mode: 'compose', sourceSubject: 'Invoice August 2026', ...prefixes })) + .toBe('New message'); + }); + + it('uses the draft subject for a fresh compose when one exists (mailto, draft edit)', () => { + expect(buildComposeTabTitle({ mode: 'compose', draftSubject: 'Lunch?', sourceSubject: 'Invoice August 2026', ...prefixes })) + .toBe('Lunch?'); + }); + + it('derives reply and forward titles from the source subject', () => { + expect(buildComposeTabTitle({ mode: 'reply', sourceSubject: 'Invoice August 2026', ...prefixes })) + .toBe('Re: Invoice August 2026'); + expect(buildComposeTabTitle({ mode: 'replyAll', sourceSubject: 'AW: Invoice', ...prefixes })) + .toBe('Re: Invoice'); + expect(buildComposeTabTitle({ mode: 'forward', sourceSubject: 'Invoice', ...prefixes })) + .toBe('Fwd: Invoice'); + }); + + it('prefers a resumed draft subject over the source and falls back when both are blank', () => { + expect(buildComposeTabTitle({ mode: 'reply', draftSubject: 'Re: edited', sourceSubject: 'Original', ...prefixes })) + .toBe('Re: edited'); + expect(buildComposeTabTitle({ mode: 'reply', sourceSubject: ' ', ...prefixes })) + .toBe('New message'); + }); +}); diff --git a/src/lib/email-threading.ts b/src/lib/email-threading.ts new file mode 100644 index 00000000..f88bc7c6 --- /dev/null +++ b/src/lib/email-threading.ts @@ -0,0 +1,66 @@ +/** + * RFC 5322 §3.6.4 reply threading. Port of the webmail's lib/email-threading.ts. + * + * Computes the In-Reply-To and References headers an outgoing reply must + * carry so MUAs can stitch the conversation back together. + * + * In-Reply-To = parent.Message-ID + * References = parent.References (if any) + parent.Message-ID + * + * Bare msg-ids only - angle brackets are stripped because JMAP RFC 8621 + * §4.1.2.3 stores Message-IDs without them. The JMAP *object id* of the parent + * is never a valid msg-id and must not be used here. + */ + +import { generateUUID } from './uuid'; + +export interface ParentThreadingInfo { + // JMAP RFC 8621 §4.1.2.3 specifies messageId as String[]|null; accept a + // bare string too for callers that flattened it. + messageId?: string | string[] | null; + references?: string[] | null; +} + +export interface ReplyThreadingHeaders { + inReplyTo: string[]; + references: string[]; +} + +export function stripMessageIdBrackets(id: string): string { + return id.trim().replace(/^<+/, '').replace(/>+$/, '').trim(); +} + +export function computeReplyThreadingHeaders( + parent: ParentThreadingInfo | undefined | null, +): ReplyThreadingHeaders | null { + const rawId = Array.isArray(parent?.messageId) ? parent.messageId[0] : parent?.messageId; + const parentId = rawId ? stripMessageIdBrackets(rawId) : ''; + if (!parentId) return null; + + const ancestors = (parent?.references ?? []) + .map(stripMessageIdBrackets) + .filter(Boolean); + + // De-dupe while preserving order; the parent's id closes the chain. + const seen = new Set(); + const references: string[] = []; + for (const id of [...ancestors, parentId]) { + if (seen.has(id)) continue; + seen.add(id); + references.push(id); + } + + return { inReplyTo: [parentId], references }; +} + +/** + * Message-ID for outgoing mail (bare msg-id, no angle brackets, per RFC 8621 + * §4.1.2.3). Without one the server synthesizes it from its OS hostname, + * which leaks internal names into headers - an anti-spam signal and an + * information disclosure. Use the sender's domain instead. + */ +export function generateMessageId(fromEmail: string): string { + const at = fromEmail.lastIndexOf('@'); + const domain = at > 0 ? fromEmail.slice(at + 1) : 'localhost'; + return `${Date.now().toString(36)}.${generateUUID()}@${domain}`; +} diff --git a/src/lib/reply-recipients.ts b/src/lib/reply-recipients.ts new file mode 100644 index 00000000..f98a50c2 --- /dev/null +++ b/src/lib/reply-recipients.ts @@ -0,0 +1,111 @@ +export interface ReplyAddress { + email?: string; + name?: string; +} + +export interface ReplySource { + from?: ReplyAddress[]; + /** Addresses from the original message's Reply-To header. */ + replyToAddresses?: ReplyAddress[]; + to?: ReplyAddress[]; + cc?: ReplyAddress[]; +} + +export interface ReplyRecipientsResult { + to: ReplyAddress[]; + cc: ReplyAddress[]; +} + +function normalize(email: string): string { + return email.trim().toLowerCase(); +} + +function normalizeBase(email: string): string { + const normalized = normalize(email); + const at = normalized.indexOf('@'); + if (at <= 0) return normalized; + + const local = normalized.slice(0, at); + const domain = normalized.slice(at + 1); + const plus = local.indexOf('+'); + + return `${plus >= 0 ? local.slice(0, plus) : local}@${domain}`; +} + +/** + * Does `email` belong to the user? Matches exactly first, then with `+tag` + * sub-addressing stripped (info+news@ is still info@). + */ +function isOwnAddress(email: string | undefined, ownEmails: string[]): boolean { + if (!email?.trim()) return false; + const exact = normalize(email); + if (ownEmails.some((own) => normalize(own) === exact)) return true; + const base = normalizeBase(email); + return ownEmails.some((own) => normalizeBase(own) === base); +} + +/** + * Is this a message the user themself sent? True when the From address is one + * of their own identities - the case that shows up when browsing a thread and + * replying to your own last message. + */ +export function isSelfSent(source: ReplySource | undefined, ownEmails: string[]): boolean { + return isOwnAddress(source?.from?.[0]?.email, ownEmails); +} + +/** + * Work out the To/Cc a reply should open with. + * + * Normal case: reply goes to the Reply-To header if the original carried one, + * else to From (RFC 5322). Reply-all adds the other original recipients, + * minus the user's own addresses. + * + * Self-sent case (#703): replying to your own message inside a thread must + * continue the conversation, not mail yourself. Gmail and Thunderbird address + * the reply to the message's original recipients instead, so that's what we do + * - the original To for reply, plus the original Cc for reply-all. Those + * addresses were the user's own choice, so they're kept verbatim (no self- + * filtering) and the Reply-To header is ignored, since answering your own + * Reply-To would land the mail back in your inbox again. + * + * A self-sent message with no visible recipients (Bcc-only) has nothing to + * continue to, so it falls back to the normal behaviour. + */ +export function buildReplyRecipients( + source: ReplySource | undefined, + mode: 'reply' | 'replyAll', + ownEmails: string[], +): ReplyRecipientsResult { + if (!source) return { to: [], cc: [] }; + + const withEmail = (list: ReplyAddress[] | undefined) => (list ?? []).filter((r) => Boolean(r.email)); + + const replyToList = withEmail(source.replyToAddresses); + const externalReplyTo = replyToList.filter((r) => !isOwnAddress(r.email, ownEmails)); + + if (!externalReplyTo.length && isSelfSent(source, ownEmails)) { + const originalTo = withEmail(source.to); + if (originalTo.length > 0) { + return { + to: originalTo, + cc: mode === 'replyAll' ? withEmail(source.cc) : [], + }; + } + } + + const replyTarget = replyToList.length + ? replyToList + : (source.from?.[0]?.email ? [source.from[0]] : []); + + if (mode === 'reply') { + return { to: replyTarget, cc: [] }; + } + + const others = (list: ReplyAddress[] | undefined) => + withEmail(list).filter((r) => !isOwnAddress(r.email, ownEmails)); + + return { + to: [...replyTarget, ...others(source.to)], + cc: others(source.cc), + }; +} diff --git a/src/lib/rfc5322-mailbox.ts b/src/lib/rfc5322-mailbox.ts new file mode 100644 index 00000000..ebda903d --- /dev/null +++ b/src/lib/rfc5322-mailbox.ts @@ -0,0 +1,61 @@ +/** + * Parsing helpers for RFC 5322 mailboxes (`Display Name `). + * Port of the webmail's lib/rfc5322-mailbox.ts. + * + * Address data reaches us from places that do not keep the display name and + * the address apart: vCard `FN` fields that hold the whole mailbox, Sent-folder + * headers, `Identity.name` on some servers. Handing such a string back to a + * server as a structured display name produces a doubled, invalid mailbox + * (`"Jane " `), which mail servers then re-parse into a + * broken envelope recipient (#672). These helpers reduce any of those forms to + * a clean name/address pair. + */ + +/** Strips a surrounding quoted-string (and its escapes) from a display name. */ +export function unquoteDisplayName(name: string): string { + const trimmed = name.trim(); + if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) { + return trimmed.slice(1, -1).replace(/\\(["\\])/g, '$1').trim(); + } + return trimmed; +} + +/** + * Reduces a display name to just the name: unquotes it and drops an address it + * carries inline, whether or not the angle brackets are balanced + * (`Jane `, `Jane]*>?\s*$/, '')); +} + +/** The addr-spec a mailbox string carries, or '' when it has none. */ +function extractAddrSpec(value: string): string { + // Prefer the last angle-bracketed run - a display name that embeds an + // address puts an earlier, redundant copy in front of the real one. The + // closing bracket is optional so a truncated `Jane]*)>?\s*$/); + if (angle) return angle[1].trim(); + // No brackets: the whole string is the address, if it looks like one at all. + return value.includes('@') ? value : ''; +} + +/** + * Splits an RFC 5322 mailbox into its display name and addr-spec. Accepts the + * malformed shapes above; the returned name never contains an address and the + * returned email is always a bare addr-spec. `name` is omitted when there is + * no display name distinct from the address. + */ +export function splitMailbox(value: string): { name?: string; email: string } { + const trimmed = value.trim(); + const email = extractAddrSpec(trimmed); + if (!email) return { email: trimmed }; + + // Everything ahead of the address is the display name. + const nameSource = trimmed.slice(0, trimmed.lastIndexOf(email)).replace(/<\s*$/, ''); + const name = sanitizeDisplayName(nameSource); + return name && name !== email ? { name, email } : { email }; +} diff --git a/src/lib/subject-prefix.ts b/src/lib/subject-prefix.ts new file mode 100644 index 00000000..e61e9f25 --- /dev/null +++ b/src/lib/subject-prefix.ts @@ -0,0 +1,144 @@ +// Reply / forward subject prefix handling. +// +// Real-world email subjects accumulate prefixes across clients and languages: +// "Re: AW: WG: Fwd: Re: foo". The deduplication regex needs to know ALL +// commonly-used reply/forward markers - not just the current locale's, since +// inbound messages may come from any locale. Failing to strip a foreign-locale +// prefix means the user's locale prefix gets *added on top* and the subject +// chain keeps growing. +// +// Sources: de-facto conventions in Outlook / Thunderbird / Apple Mail per +// language. Includes a handful of legacy short-forms (R:, Fw:) that some +// mobile clients still emit. + +const REPLY_TOKENS = [ + "Re", // English, Italian, French (also generic ISO) + "RE", // Outlook variant + "AW", // German (Antwort) + "Antw", // German verbose + "Sv", // Danish / Swedish / Norwegian (Svar) + "Yn", // Turkish (Yanit) + "Yanit", // Turkish verbose + "Odp", // Polish (Odpowiedz) + "Ответ", // Russian + "Resp", // Spanish/Portuguese variant + "Vá", // Hungarian + "回复", // Chinese + "回覆", // Chinese traditional + "답장", // Korean + // NB: deliberately no bare "R" token — a single letter would strip the first + // word of legitimate subjects like "R: budget 2024". The full "Re" covers + // the common Italian/English case anyway. +]; + +const FORWARD_TOKENS = [ + "Fwd", // English standard + "Fw", // English short / Polish / German short + "WG", // German (Weitergeleitet) + "Tr", // French (Transfert) + "Vs", // Danish (Videresend) + "Enc", // Portuguese (Encaminhar) + "ENC", // Portuguese caps + "Rv", // Spanish (Reenviar) + "RV", // Spanish caps + "Rvf", // Spanish variant + "Inol", // Italian (Inoltro) + // NB: deliberately no bare "I" token — see the REPLY_TOKENS note above. + "PD", // Polish (Przekazane Dalej) + "PR", // Czech (Preposlat) + "İlt", // Turkish (Ilet) + "Ilt", // Turkish ASCII + "Пересл", // Russian (Peresylka) + "Пер", // Russian short + "转发", // Chinese + "轉寄", // Chinese traditional + "전달", // Korean +]; + +// Match a single prefix token + optional [N] counter (Outlook) or *N (Eudora) +// + colon + whitespace. Case-insensitive. The non-capturing groups keep the +// regex composable for stripping multiple prefixes in a row. +function buildPrefixRegex(tokens: string[]): RegExp { + // Escape regex specials in tokens (none currently, but be defensive) + const escaped = tokens.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); + // Sort by length DESC so longer tokens (e.g. "Пересл") win over their + // shorter prefixes (e.g. "Пер") during alternation matching. + escaped.sort((a, b) => b.length - a.length); + // Accept both the ASCII colon and the full-width colon ":" (U+FF1A) that CJK + // mail clients emit after a localized prefix (e.g. "回复:foo"). + return new RegExp( + `^\\s*(?:${escaped.join("|")})(?:\\[\\d+\\]|\\*\\d*)?\\s*[:\\uFF1A]\\s*`, + "i", + ); +} + +const ANY_PREFIX_RE = buildPrefixRegex([...REPLY_TOKENS, ...FORWARD_TOKENS]); + +/** + * Strip any leading sequence of reply/forward prefixes (across languages) from + * a subject line. Idempotent and safe for empty input. + * + * Examples: + * stripSubjectPrefixes("Re: AW: WG: foo") === "foo" + * stripSubjectPrefixes("Re[2]: foo") === "foo" + * stripSubjectPrefixes("RE: Re: foo") === "foo" + * stripSubjectPrefixes("foo") === "foo" + * stripSubjectPrefixes("") === "" + */ +export function stripSubjectPrefixes(subject: string | undefined | null): string { + if (!subject) return ""; + let s = subject; + // Bounded loop: in practice you never see more than ~10 prefixes; the bound + // protects against pathological input. Each iteration must consume input. + for (let i = 0; i < 20; i++) { + const next = s.replace(ANY_PREFIX_RE, ""); + if (next === s) break; + s = next; + } + return s; +} + +/** + * Build a reply subject with the given locale-aware prefix. Strips any + * pre-existing prefixes (in any language) first so chains don't accumulate. + * + * buildReplySubject("AW: WG: foo", "Re:") === "Re: foo" + * buildReplySubject("foo", "AW:") === "AW: foo" + * buildReplySubject("", "AW:") === "AW:" + */ +export function buildReplySubject(subject: string | undefined | null, prefix: string): string { + const stripped = stripSubjectPrefixes(subject); + return stripped ? `${prefix} ${stripped}` : prefix; +} + +/** + * Build a forward subject. Same logic as buildReplySubject but conceptually + * separate for clarity at the call site. + */ +export function buildForwardSubject(subject: string | undefined | null, prefix: string): string { + const stripped = stripSubjectPrefixes(subject); + return stripped ? `${prefix} ${stripped}` : prefix; +} + +/** + * Working title for a compose tab. Reply and forward derive it from the + * message they act on; a fresh compose must NOT inherit the subject of + * whatever email happens to be selected in the list (#856) - it starts on + * the fallback ("New message") until a draft subject exists. + */ +export function buildComposeTabTitle(opts: { + mode: 'compose' | 'reply' | 'replyAll' | 'forward'; + draftSubject?: string | null; + sourceSubject?: string | null; + replyPrefix: string; + forwardPrefix: string; + fallback: string; +}): string { + const draft = opts.draftSubject?.trim() ?? ''; + const source = opts.mode === 'compose' ? '' : (opts.sourceSubject?.trim() ?? ''); + const base = draft || source; + if (!base) return opts.fallback; + if (opts.mode === 'reply' || opts.mode === 'replyAll') return buildReplySubject(base, opts.replyPrefix); + if (opts.mode === 'forward') return buildForwardSubject(base, opts.forwardPrefix); + return base; +}