diff --git a/components/email/__tests__/conversation-reading.test.tsx b/components/email/__tests__/conversation-reading.test.tsx new file mode 100644 index 000000000..0c8a2592a --- /dev/null +++ b/components/email/__tests__/conversation-reading.test.tsx @@ -0,0 +1,56 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +// Conversation view: each message frame has its own name and language, and +// the reading settings reach every message body. + +const t = Object.assign( + (key: string, params?: Record) => (params ? `${key} ${JSON.stringify(params)}` : key), + { raw: (key: string) => key, rich: (key: string) => key, has: () => true }, +); +vi.mock('next-intl', () => ({ + useTranslations: () => t, + useLocale: () => 'en', +})); +vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => ({ enabled: false }), canWopiOpen: () => false })); +vi.mock('@/hooks/use-own-domain-address', () => ({ useOwnDomainAddress: () => () => false })); + +const { ThreadConversationView } = await import('@/components/email/thread-conversation-view'); +const { useSettingsStore } = await import('@/stores/settings-store'); +const { ACCESSIBILITY_DEFAULTS } = await import('@/lib/accessibility-settings'); + +function mail(id: string, extra: Record = {}) { + return { + id, threadId: 't1', mailboxIds: { a: true }, keywords: { $seen: true }, + from: [{ name: 'Sender', email: 'sender@t.example' }], to: [{ email: 'me@example.org' }], + subject: `Subject ${id}`, receivedAt: '2026-09-26T10:00:00Z', size: 1, preview: 'hello', hasAttachment: false, + htmlBody: [{ partId: '1', type: 'text/html' }], textBody: [], attachments: [], + bodyValues: { '1': { value: '

hello there

' } }, + ...extra, + }; +} + +function show(emails: ReturnType[]) { + const latest = emails[emails.length - 1]; + return render( + {}} + />, + ); +} + +beforeEach(() => { + useSettingsStore.setState({ ...ACCESSIBILITY_DEFAULTS, fontSize: 'medium' } as never); +}); + +describe('conversation message frames', () => { + it('are named after their own subject and carry the body language', () => { + const { container } = show([mail('a', { htmlBody: [{ partId: '1', type: 'text/html', language: ['it'] }] })]); + const iframe = container.querySelector('iframe')!; + expect(iframe.getAttribute('title')).toBe('email_viewer.frame_title {"subject":"Subject a"}'); + expect(iframe.getAttribute('srcdoc')).toMatch(/) => (params ? `${key} ${JSON.stringify(params)}` : key), + { raw: (key: string) => key, rich: (key: string) => key, has: () => true }, +); +vi.mock('next-intl', () => ({ + useTranslations: () => t, + useLocale: () => 'en', + useFormatter: () => ({ dateTime: (d: Date | string) => String(d), relativeTime: (d: Date | string) => String(d), number: (n: number) => String(n) }), +})); +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }), + useMediaQuery: () => false, + useIsMobile: () => false, + useIsDesktop: () => true, +})); +vi.mock('@/components/tour/tour-provider', () => ({ + useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }), +})); +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] })); + +class NoopResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +vi.stubGlobal('ResizeObserver', NoopResizeObserver); + +const { EmailViewer } = await import('../email-viewer'); +const { useSettingsStore } = await import('@/stores/settings-store'); +const { ACCESSIBILITY_DEFAULTS } = await import('@/lib/accessibility-settings'); + +const mailboxes = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }, +] as unknown as Mailbox[]; + +function message(overrides: Partial & { html?: string; text?: string } = {}): Email { + const { html, text, ...rest } = overrides; + const bodyValues: Record = {}; + if (html !== undefined) bodyValues.h = { value: html }; + if (text !== undefined) bodyValues.t = { value: text }; + return { + id: 'm1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true }, + from: [{ name: 'Alice', email: 'alice@example.com' }], to: [{ name: 'Bob', email: 'bob@example.com' }], + subject: 'Bonjour', receivedAt: '2026-01-01T00:00:00Z', preview: 'Hello', hasAttachment: false, size: 100, + htmlBody: html !== undefined ? [{ partId: 'h', type: 'text/html' }] : [], + textBody: text !== undefined ? [{ partId: 't', type: 'text/plain' }] : html !== undefined ? [{ partId: 'h', type: 'text/html' }] : [], + attachments: [], + bodyValues, + ...rest, + } as unknown as Email; +} + +async function show(email: Email) { + const view = render( + , + ); + await act(async () => { await new Promise((r) => setTimeout(r, 20)); }); + return view; +} + +beforeEach(() => { + clearAllHooks(); + useSettingsStore.setState({ ...ACCESSIBILITY_DEFAULTS, fontSize: 'medium' } as never); +}); + +describe('message body frame', () => { + it('is named after the subject and carries the mail language', async () => { + const { container } = await show(message({ + html: '

Bonjour tout le monde

', + })); + const iframe = container.querySelector('iframe')!; + expect(iframe.getAttribute('title')).toBe('frame_title {"subject":"Bonjour"}'); + expect(iframe.getAttribute('srcdoc')).toMatch(/^\s* { + const { container } = await show(message({ subject: '', html: '

Hi there, friend

' })); + const iframe = container.querySelector('iframe')!; + expect(iframe.getAttribute('title')).toBe('frame_title_from {"sender":"Alice"}'); + expect(iframe.getAttribute('srcdoc')).not.toMatch(/]*\slang=/); + }); + + it('marks a plain-text body with its Content-Language', async () => { + await show(message({ text: 'Hallo zusammen', textBody: [{ partId: 't', type: 'text/plain', language: ['de'] }] as never })); + expect(screen.getByText('Hallo zusammen').closest('[lang]')?.getAttribute('lang')).toBe('de'); + }); +}); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 45302a079..7da947bbe 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -6,6 +6,7 @@ import { attachmentDownloadFilename, attachmentsBundleFilename, DEFAULT_ATTACHME import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import"; import { TRANSPARENT_BLOCKED_PIXEL, applyNewTabToAnchor, emailIframeCsp, escapeHtml, isExternalResourceUrl, isOpenableLinkHref, plainTextToSafeHtml, sanitizeEmailBodyForIframe, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization"; import { getRenderableHtmlBody } from "@/lib/email-body-selection"; +import { mailLanguage } from "@/lib/mail-language"; import { collectReferencedCids, isEmbeddedInBody, normalizeCid, rewriteCidReferences } from "@/lib/attachment-visibility"; import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse"; import { DARK_PENDING_CLASS, applyEmailDarkMode, emailColorScheme, emailDarkModeCss, emailHasNativeDarkMode as htmlHasNativeDarkMode, resolveColorSchemeMedia } from "@/lib/email-dark-mode"; @@ -3153,6 +3154,14 @@ export function EmailViewer({ ? emailViewDarkOverride : (emailAlwaysLightMode ? false : resolvedTheme === 'dark'); + // The body's language (Content-Language or the HTML's own lang), so a + // screen reader switches voice for it; none when the mail doesn't say. + const bodyLanguage = useMemo(() => mailLanguage(email, email ? getRenderableHtmlBody(email) : null), [email]); + const frameSender = email?.from?.[0]; + const frameTitle = email?.subject + ? t('frame_title', { subject: email.subject }) + : t('frame_title_from', { sender: frameSender?.name || frameSender?.email || t('unknown_sender') }); + const emailIframeSrcDoc = useMemo(() => { if (!effectiveEmailContent.isHtml) return ''; @@ -3195,7 +3204,7 @@ export function EmailViewer({ const iframeCsp = emailIframeCsp(effectiveEmailContent.externalBlocked); return ` - + @@ -3247,7 +3256,7 @@ export function EmailViewer({ ${wordHtmlCSS} ${darkModeCSS} ${resolveColorSchemeMedia(effectiveEmailContent.html, isDark)}`; - }, [effectiveEmailContent.html, effectiveEmailContent.isHtml, effectiveEmailContent.hasStyleTag, effectiveEmailContent.externalBlocked, isDark, emailHasNativeDarkMode, messageSpacing]); + }, [effectiveEmailContent.html, effectiveEmailContent.isHtml, effectiveEmailContent.hasStyleTag, effectiveEmailContent.externalBlocked, isDark, emailHasNativeDarkMode, messageSpacing, bodyLanguage]); // Unblocking external content is handled by rebuilding the iframe srcDoc: // toggling allowExternalContent (both "Load images" and "Trust sender" set @@ -6156,7 +6165,7 @@ export function EmailViewer({ ref={iframeRef} srcDoc={emailIframeSrcDoc} sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox" - title="Email content" + title={frameTitle} className="w-full border-0 block" scrolling="no" style={{ minHeight: '100px', colorScheme: emailColorScheme(isDark, emailHasNativeDarkMode) }} @@ -6167,6 +6176,7 @@ export function EmailViewer({ ref={setPlainBodyEl} className="email-content-text text-foreground" dir="auto" + lang={bodyLanguage ?? undefined} data-plugin-source="email-body" data-email-id={email?.id} onContextMenu={(e) => selectionMenu.onElementContextMenu(e, email?.id)} diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index e694ef692..d668633e4 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -15,6 +15,7 @@ import { ExternalSenderChip } from "./external-sender-chip"; import { collectReferencedCids, isEmbeddedInBody, normalizeCid, rewriteCidReferences } from "@/lib/attachment-visibility"; import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse"; import { fitEmailBodyWidth } from "@/lib/email-fit-width"; +import { mailLanguage } from "@/lib/mail-language"; import { DARK_PENDING_CLASS, applyEmailDarkMode, emailDarkModeCss } from "@/lib/email-dark-mode"; import { useThemeStore } from "@/stores/theme-store"; import { useShallow } from "zustand/react/shallow"; @@ -756,13 +757,20 @@ export function ConversationEmailBody({ // the host page. CSP is defense-in-depth in case the sanitizer ever // emits a