feat: port the reply-recipient, subject-prefix, threading and mailbox-parsing helpers from the webmail

This commit is contained in:
Linus Rath
2026-08-29 17:49:23 +02:00
parent b538a0e53d
commit 506a4b1a84
7 changed files with 692 additions and 0 deletions
+86
View File
@@ -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('<abc@example.com>')).toBe('abc@example.com');
});
it('handles whitespace and missing brackets', () => {
expect(stripMessageIdBrackets(' abc@example.com ')).toBe('abc@example.com');
expect(stripMessageIdBrackets('<abc@example.com')).toBe('abc@example.com');
expect(stripMessageIdBrackets('abc@example.com>')).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: '<root@example.com>',
});
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: '<msg-2@example.com>',
references: ['<msg-0@example.com>', '<msg-1@example.com>'],
});
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: '<msg-1@example.com>',
references: ['<msg-0@example.com>', '<msg-1@example.com>'],
});
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: ['<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'],
});
});
it('returns null for an empty messageId array', () => {
expect(computeReplyThreadingHeaders({ messageId: [] })).toBeNull();
});
});
+137
View File
@@ -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);
});
});
+87
View File
@@ -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');
});
});
+66
View File
@@ -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<string>();
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}`;
}
+111
View File
@@ -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),
};
}
+61
View File
@@ -0,0 +1,61 @@
/**
* Parsing helpers for RFC 5322 mailboxes (`Display Name <addr@example.com>`).
* 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 <j@x.com>" <j@x.com>`), 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 <j@x.com>`, `Jane<j@x.com` → `Jane`). Line breaks are collapsed so a
* pasted name can never inject a header.
*/
export function sanitizeDisplayName(name: string | undefined | null): string {
if (!name) return '';
const flat = name.replace(/[\r\n\t]+/g, ' ');
return unquoteDisplayName(unquoteDisplayName(flat).replace(/\s*<[^<>]*>?\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<j@x.com` still resolves.
const angle = value.match(/<([^<>]*)>?\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 };
}
+144
View File
@@ -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;
}