mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
166 lines
6.5 KiB
TypeScript
166 lines
6.5 KiB
TypeScript
// 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("") === ""
|
||
*/
|
||
const FORWARD_RE = buildPrefixRegex(FORWARD_TOKENS);
|
||
const REPLY_RE = buildPrefixRegex(REPLY_TOKENS);
|
||
|
||
/**
|
||
* Whether the subject's leading prefix chain marks a forward anywhere in it
|
||
* ("Fwd:", "Re: Fwd:", "AW: WG:"), case-insensitively. Booking cards use it:
|
||
* a forwarded booking mail was authenticated for the forwarder, not for the
|
||
* airline or shop it quotes.
|
||
*/
|
||
export function hasForwardPrefix(subject: string | undefined | null): boolean {
|
||
let rest = (subject ?? "").trimStart();
|
||
for (let i = 0; i < 20; i++) {
|
||
const forward = FORWARD_RE.exec(rest);
|
||
if (forward && forward[0].length > 0) return true;
|
||
const reply = REPLY_RE.exec(rest);
|
||
if (!reply || reply[0].length === 0) return false;
|
||
rest = rest.slice(reply[0].length).trimStart();
|
||
}
|
||
return false;
|
||
}
|
||
|
||
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;
|
||
}
|