diff --git a/components/email/attachment-chips.tsx b/components/email/attachment-chips.tsx
index 9aa2a6f8b..4ced28825 100644
--- a/components/email/attachment-chips.tsx
+++ b/components/email/attachment-chips.tsx
@@ -3,6 +3,7 @@
import { useEffect, useState, type ReactNode } from "react";
import { FileText, FileSpreadsheet, FileImage, FileArchive, File as FileIcon } from "@/components/icons";
import { cn } from "@/lib/utils";
+import { stripBidiControls } from "@/lib/bidi";
import type { Attachment, Email } from "@/lib/jmap/types";
import type { LoadListAttachments } from "@/lib/list-attachments";
@@ -78,11 +79,11 @@ export function AttachmentChips({ attachments, onOpen, max = 2, leading, classNa
// The row itself opens the message; a chip must not do both.
onClick={(e) => { e.stopPropagation(); onOpen(a); }}
onDoubleClick={(e) => e.stopPropagation()}
- title={a.name}
+ title={stripBidiControls(a.name ?? "")}
className={cn(CHIP_BOX_CLASS, "max-w-[12rem] bg-background/60 text-foreground/80 hover:bg-muted hover:text-foreground")}
>
- {shortName(a.name ?? "")}
+ {shortName(stripBidiControls(a.name ?? ""))}
);
})}
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index 7ed5fd726..d179d93eb 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -11,6 +11,7 @@ import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collaps
import { DARK_PENDING_CLASS, applyEmailDarkMode, emailColorScheme, emailDarkModeCss, emailHasNativeDarkMode as htmlHasNativeDarkMode, resolveColorSchemeMedia } from "@/lib/email-dark-mode";
import { fitEmailBodyWidth } from "@/lib/email-fit-width";
import { hasFullBleedCanvas } from "@/lib/email-full-bleed";
+import { stripBidiControls } from "@/lib/bidi";
import { withBasePath } from "@/lib/browser-navigation";
import { buildContactsPath, buildMailPath } from "@/lib/deep-links";
import { useCopyLink } from "@/hooks/use-copy-link";
@@ -269,7 +270,9 @@ const MIME_TYPE_LABELS: Record = {
};
const getAttachmentDisplayName = (name: string | null | undefined, mimeType?: string): string => {
- if (name) return name;
+ // Without directional overrides, which would show `reportfdp.exe`
+ // as `reportexe.pdf` (the download name is cleaned separately).
+ if (name) return stripBidiControls(name);
if (mimeType) {
const label = MIME_TYPE_LABELS[mimeType.toLowerCase()];
if (label) return label;
diff --git a/lib/__tests__/bidi.test.ts b/lib/__tests__/bidi.test.ts
new file mode 100644
index 000000000..25837fe89
--- /dev/null
+++ b/lib/__tests__/bidi.test.ts
@@ -0,0 +1,15 @@
+import { describe, expect, it } from 'vitest';
+import { stripBidiControls } from '@/lib/bidi';
+
+describe('stripBidiControls', () => {
+ it('removes the characters that reorder what the reader sees', () => {
+ // An RTL override makes `reportfdp.exe` read as `reportexe.pdf`.
+ expect(stripBidiControls('reportfdp.exe')).toBe('reportfdp.exe');
+ expect(stripBidiControls('ab')).toBe('ab');
+ });
+
+ it('leaves ordinary text, including right-to-left scripts, alone', () => {
+ expect(stripBidiControls('Rechnung März.pdf')).toBe('Rechnung März.pdf');
+ expect(stripBidiControls('تقرير.pdf')).toBe('تقرير.pdf');
+ });
+});
diff --git a/lib/bidi.ts b/lib/bidi.ts
new file mode 100644
index 000000000..1aa5ead27
--- /dev/null
+++ b/lib/bidi.ts
@@ -0,0 +1,12 @@
+/**
+ * Unicode directional formatting characters: the explicit embeddings,
+ * overrides and isolates (U+202A-202E, U+2066-2069) and the LRM/RLM marks.
+ * In a sender-controlled name they reorder what the reader sees, so
+ * `reportfdp.exe` shows as `reportexe.pdf`.
+ */
+const BIDI_CONTROLS = /[--]/g;
+
+/** `text` without directional formatting characters, for display. */
+export function stripBidiControls(text: string): string {
+ return text.replace(BIDI_CONTROLS, '');
+}