From 4c1d0931a1c33a20736578e48ac51cb0d9d8f551 Mon Sep 17 00:00:00 2001 From: dealerweb Date: Fri, 29 May 2026 12:56:53 +0200 Subject: [PATCH] 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:. --- app/(main)/[locale]/page.tsx | 7 +- components/email/email-composer.tsx | 7 +- components/pro/pro-email-tab-body.tsx | 7 +- lib/subject-prefix.ts | 117 ++++++++++++++++++++++++++ locales/de/common.json | 4 +- 5 files changed, 130 insertions(+), 12 deletions(-) create mode 100644 lib/subject-prefix.ts diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index dfaa9a20..a7c57c61 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -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, diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 64f9c9d8..bf80385c 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -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 ""; }; diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx index 0f5fc0d7..e80e0865 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -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]); diff --git a/lib/subject-prefix.ts b/lib/subject-prefix.ts new file mode 100644 index 00000000..d9a6732b --- /dev/null +++ b/lib/subject-prefix.ts @@ -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; +} diff --git a/locales/de/common.json b/locales/de/common.json index 1bf01467..520464e4 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -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",