Files
SRCmail/lib/subject-prefix.ts
Stefan HildebrandtandLinus Rath 8af6694152 fix: strip reply/forward prefixes followed by a full-width colon
The prefix-stripping regex only matched an ASCII ":", so a localized
prefix from a CJK mail client (e.g. "回复:foo", using the full-width
colon U+FF1A) was left in place. On reply this caused the user's own
prefix to be stacked on top, growing the subject chain.

Accept both ":" and ":" after the prefix token. Adds tests.
2026-06-24 15:56:38 +02:00

122 lines
4.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}