Fix: deduplicate localized reply/forward subject prefixes
Replying to a reply produced "Re: Re: foo" (and German used the English "Re:"/"Fwd:" instead of "AW:"/"WG:"). Four code paths built reply/forward subjects and only one deduplicated - and only for the English prefix, so cross-locale threads accumulated chains. New lib/subject-prefix.ts strips any leading run of reply/forward markers across ~35 tokens from all supported languages (plus Outlook Re[2]: and Eudora Re*2: counters), then prepends the locale-appropriate prefix. All four call sites (composer getInitialSubject, the two page.tsx sites, and the three pro-tab handlers) now use buildReplySubject/buildForwardSubject. German prefix corrected to AW:/WG:.
This commit is contained in:
@@ -72,6 +72,7 @@ import { plainTextToComposerBody } from "@/lib/email-composer-utils";
|
||||
import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks";
|
||||
import { emailToReadView } from "@/lib/plugin-projection";
|
||||
import { buildQuoteHeader } from "@/lib/quote-header";
|
||||
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
||||
import { useLocaleStore } from "@/stores/locale-store";
|
||||
import type { QuoteHeader } from "@/lib/plugin-types";
|
||||
|
||||
@@ -751,9 +752,9 @@ export default function Home() {
|
||||
let title = t('email_composer.new_message');
|
||||
if (baseSubject) {
|
||||
if (effectiveMode === 'reply' || effectiveMode === 'replyAll') {
|
||||
title = baseSubject.startsWith('Re:') ? baseSubject : `Re: ${baseSubject}`;
|
||||
title = buildReplySubject(baseSubject, t('email_composer.prefix.reply'));
|
||||
} else if (effectiveMode === 'forward') {
|
||||
title = baseSubject.startsWith('Fwd:') ? baseSubject : `Fwd: ${baseSubject}`;
|
||||
title = buildForwardSubject(baseSubject, t('email_composer.prefix.forward'));
|
||||
} else {
|
||||
title = baseSubject;
|
||||
}
|
||||
@@ -2128,7 +2129,7 @@ export default function Home() {
|
||||
const result = await sendEmail(
|
||||
client,
|
||||
[sender.email],
|
||||
`Re: ${selectedEmail.subject || "(no subject)"}`,
|
||||
buildReplySubject(selectedEmail.subject || "(no subject)", t('email_composer.prefix.reply')),
|
||||
finalBody,
|
||||
undefined,
|
||||
undefined,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { sanitizeSignatureHtml } from "@/lib/email-sanitization";
|
||||
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
||||
import { emailHooks, contactHooks } from "@/lib/plugin-hooks";
|
||||
import type { OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
@@ -265,11 +266,9 @@ export function EmailComposer({
|
||||
const getInitialSubject = () => {
|
||||
if (!replyTo?.subject) return "";
|
||||
if (mode === 'forward') {
|
||||
const fwdPrefix = t('prefix.forward');
|
||||
return `${fwdPrefix} ${replyTo.subject.replace(/^(Fwd:\s*|Tr:\s*)+/i, '')}`;
|
||||
return buildForwardSubject(replyTo.subject, t('prefix.forward'));
|
||||
} else if (mode === 'reply' || mode === 'replyAll') {
|
||||
const rePrefix = t('prefix.reply');
|
||||
return `${rePrefix} ${replyTo.subject.replace(/^(Re:\s*)+/i, '')}`;
|
||||
return buildReplySubject(replyTo.subject, t('prefix.reply'));
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useProTabStore, type ProEmailTabData, type ProReplyContext } from "@/stores/pro-tab-store";
|
||||
import type { Email } from "@/lib/jmap/types";
|
||||
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
||||
|
||||
interface ProEmailTabBodyProps {
|
||||
tabId: string;
|
||||
@@ -104,7 +105,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
||||
replyTo: buildReplyContext(email),
|
||||
sourceEmailId: email.id,
|
||||
initialDraftText: draftText,
|
||||
title: `Re: ${email.subject || t('email_composer.new_message')}`,
|
||||
title: buildReplySubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.reply')),
|
||||
});
|
||||
}, [email, openComposeTab, t]);
|
||||
|
||||
@@ -116,7 +117,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
||||
mode: 'replyAll',
|
||||
replyTo: buildReplyContext(email),
|
||||
sourceEmailId: email.id,
|
||||
title: `Re: ${email.subject || t('email_composer.new_message')}`,
|
||||
title: buildReplySubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.reply')),
|
||||
});
|
||||
}, [email, openComposeTab, t]);
|
||||
|
||||
@@ -128,7 +129,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
||||
mode: 'forward',
|
||||
replyTo: buildReplyContext(email),
|
||||
sourceEmailId: email.id,
|
||||
title: `Fwd: ${email.subject || t('email_composer.new_message')}`,
|
||||
title: buildForwardSubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.forward')),
|
||||
});
|
||||
}, [email, openComposeTab, t]);
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
// 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
|
||||
"R", // Italian short form
|
||||
];
|
||||
|
||||
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)
|
||||
"I", // Italian short
|
||||
"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);
|
||||
return new RegExp(
|
||||
`^\\s*(?:${escaped.join("|")})(?:\\[\\d+\\]|\\*\\d*)?\\s*:\\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;
|
||||
}
|
||||
@@ -578,8 +578,8 @@
|
||||
"subject_label": "Betreff:",
|
||||
"file_size_kb": "KB",
|
||||
"prefix": {
|
||||
"forward": "Fwd:",
|
||||
"reply": "Re:"
|
||||
"forward": "WG:",
|
||||
"reply": "AW:"
|
||||
},
|
||||
"no_subject": "(Kein Betreff)",
|
||||
"unknown_sender": "Unbekannt",
|
||||
|
||||
Reference in New Issue
Block a user