diff --git a/components/gmail/contacts/contact-detail-view.tsx b/components/gmail/contacts/contact-detail-view.tsx index 9dcc5b8..17ae822 100644 --- a/components/gmail/contacts/contact-detail-view.tsx +++ b/components/gmail/contacts/contact-detail-view.tsx @@ -22,6 +22,7 @@ import { useContactsList } from "@/lib/contacts/use-contacts-list" import { ContactAvatar } from "@/components/gmail/contacts/contact-avatar" import { fullContactDisplayName } from "@/lib/contacts/types" import { useMailSearch } from "@/lib/api/hooks/use-mail-queries" +import { repairSnippet } from "@/lib/mail-mime-body" import { useComposeActions } from "@/lib/compose-context" import { useNavStore } from "@/lib/stores/nav-store" import { @@ -90,7 +91,7 @@ export function ContactDetailView({ contactId }: ContactDetailViewProps) { return searchResult.data.slice(0, 10).map((msg) => ({ id: msg.id, subject: msg.subject, - preview: msg.snippet, + preview: repairSnippet(msg.snippet) ?? msg.snippet, date: msg.date, })) }, [searchResult]) diff --git a/components/gmail/email-view.tsx b/components/gmail/email-view.tsx index a4a1fcf..fbfabe8 100644 --- a/components/gmail/email-view.tsx +++ b/components/gmail/email-view.tsx @@ -103,7 +103,7 @@ function apiToLegacyEmail( senderEmail: m.from[0]?.address ?? "", date: m.date, body: m.body_html ?? m.body_text ?? "", - preview: m.snippet, + preview: repairSnippet(m.snippet) ?? m.snippet, })), } } diff --git a/components/gmail/email-view/email-view-messages.tsx b/components/gmail/email-view/email-view-messages.tsx index c3bf71d..4118e58 100644 --- a/components/gmail/email-view/email-view-messages.tsx +++ b/components/gmail/email-view/email-view-messages.tsx @@ -23,6 +23,7 @@ import { type MessageHeaderDetails, } from "@/lib/mail-message-header-details" import type { EmailAttachment } from "@/lib/email-data" +import { repairMimeBodies, repairSnippet } from "@/lib/mail-mime-body" import { ContactHoverCard } from "@/components/gmail/contact-hover-card" import { EmailViewMessageToolbar } from "@/components/gmail/email-view/email-view-toolbar" import { MessageBodyContent } from "@/components/gmail/email-view/message-body-content" @@ -31,7 +32,6 @@ import { MAIL_MESSAGE_HOVER_CLASS, MAIL_TOOLTIP_CONTENT_CLASS, } from "@/lib/mail-chrome-classes" -import { repairMimeBodies } from "@/lib/mail-mime-body" import { plainTextToDisplayHtml } from "@/lib/mail-plain-text-html" export function plainTextBodyFallback( @@ -47,8 +47,9 @@ export function formatApiMessageBody( snippet: string | undefined, loading: boolean ): string { - const snippetHtml = snippet?.trim() - ? `
${snippet.trim()}
` + const preview = repairSnippet(snippet) ?? snippet + const snippetHtml = preview?.trim() + ? `${preview.trim()}
` : "" if (loading) { @@ -62,13 +63,13 @@ export function formatApiMessageBody( return plainTextToDisplayHtml(text) } if (full) { - const s = snippet?.trim() + const s = preview?.trim() if (s) { return `${s}
` } return `Ce message n’a pas de contenu.
` } - const s = snippet?.trim() + const s = preview?.trim() return s ? `${s}
` : "" @@ -242,7 +243,9 @@ export function CollapsedMessage({ /> -{message.snippet}
++ {repairSnippet(message.snippet) ?? message.snippet} +
) diff --git a/lib/mail-mime-body.ts b/lib/mail-mime-body.ts index 90e25b3..8493c5b 100644 --- a/lib/mail-mime-body.ts +++ b/lib/mail-mime-body.ts @@ -55,17 +55,55 @@ function decodeBytesToUtf8(bytes: Uint8Array, charset = ""): string { } } +const UTF8_MOJIBAKE_PAIR_RE = /[\u00C2\u00C3][\u0080-\u00BF]/ + +function looksLikeUtf8Mojibake(s: string): boolean { + return UTF8_MOJIBAKE_PAIR_RE.test(s) +} + +/** UTF-8 misread as Latin-1: "réactivité" → "réactivité". */ +function repairUtf8Mojibake(s: string): string { + if (!s || !looksLikeUtf8Mojibake(s)) return s + let out = "" + for (let i = 0; i < s.length; i++) { + const code = s.charCodeAt(i) + if ((code === 0xc2 || code === 0xc3) && i + 1 < s.length) { + const next = s.charCodeAt(i + 1) + if (next >= 0x80 && next <= 0xbf) { + const bytes = new Uint8Array([code, next]) + try { + out += new TextDecoder("utf-8", { fatal: true }).decode(bytes) + i++ + continue + } catch { + /* keep raw chars */ + } + } + } + out += s[i]! + } + if (out === s) return s + return repairLoneMojibakeLeaders(out) +} + +function repairLoneMojibakeLeaders(s: string): string { + return s + .replace(/\u00C3(?=[\s,.;:!?])/g, "à") + .replace(/\u00C2(?=[\s,.;:!?])/g, "Â") +} + function repairLegacyCharsetString(s: string): string { if (!s) return s + let repaired = s try { new TextDecoder("utf-8", { fatal: true }).decode( Uint8Array.from(s, (c) => c.charCodeAt(0) & 0xff) ) - return s } catch { const bytes = Uint8Array.from(s, (c) => c.charCodeAt(0) & 0xff) - return decodeBytesToUtf8(bytes) + repaired = decodeBytesToUtf8(bytes) } + return repairUtf8Mojibake(repaired) } function decodeBase64Part(encoded: string, charset = ""): string { @@ -203,13 +241,29 @@ export function repairMimeBodies( text = stripInvisibleTextRuns(text) if (!looksLikeRawMime(text) && !looksLikeRawMime(html)) { - return { bodyText: text || bodyText, bodyHtml: html || bodyHtml } + return finalizeMimeBodies(text, bodyText, html, bodyHtml) } const parsed = parseEmbeddedMime(text || html) - if (!parsed) return { bodyText: text || bodyText, bodyHtml: html || bodyHtml } + if (!parsed) return finalizeMimeBodies(text, bodyText, html, bodyHtml) + return finalizeMimeBodies( + parsed.text || text || bodyText, + bodyText, + parsed.html || html || bodyHtml, + bodyHtml + ) +} + +function finalizeMimeBodies( + text: string | undefined, + bodyText: string | undefined, + html: string | undefined, + bodyHtml: string | undefined +): { bodyText?: string; bodyHtml?: string } { + const outText = repairUtf8Mojibake(repairLegacyCharsetString(text?.trim() ?? "")) + const outHtml = repairUtf8Mojibake(repairLegacyCharsetString(html?.trim() ?? "")) return { - bodyText: parsed.text || text || bodyText, - bodyHtml: parsed.html || html || bodyHtml, + bodyText: outText || bodyText, + bodyHtml: outHtml || bodyHtml, } } @@ -418,6 +472,6 @@ export function repairSnippet(snippet?: string): string | undefined { const decoded = decodeBareBase64IfNeeded(qp) const raw = decoded !== trimmed ? decoded : snippet const cleaned = stripInvisibleTextRuns(raw) - const polished = polishSnippetPreview(cleaned) + const polished = repairUtf8Mojibake(polishSnippetPreview(cleaned)) return polished || undefined }