diff --git a/components/email/__tests__/remote-content-no-third-party.test.tsx b/components/email/__tests__/remote-content-no-third-party.test.tsx new file mode 100644 index 000000000..cfffb131b --- /dev/null +++ b/components/email/__tests__/remote-content-no-third-party.test.tsx @@ -0,0 +1,90 @@ +import { act, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { setNoThirdPartyTraffic } from '@/lib/third-party-mode'; +import { useSettingsStore } from '@/stores/settings-store'; + +// #903: with no third-party traffic, remote content in mail never loads, so +// nothing offers to load it or to trust the sender; the notice says the +// organisation blocks it. Without the mode the controls are as before. + +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }), + useMediaQuery: () => false, +})); +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: () => [] })); +vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => ({ enabled: false }), canWopiOpen: () => false })); +vi.mock('@/hooks/use-own-domain-address', () => ({ useOwnDomainAddress: () => () => false })); +vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); + +const { EmailViewer } = await import('../email-viewer'); +const { ThreadConversationView } = await import('../thread-conversation-view'); + +const mailboxes = [{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }] as unknown as Mailbox[]; +const email = { + id: 'e1', blobId: 'b-e1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true }, + from: [{ name: 'News', email: 'news@shop.example' }], to: [{ email: 'me@example.org' }], + subject: 'Offer', receivedAt: '2026-10-01T10:00:00Z', preview: 'Hello', hasAttachment: false, size: 100, + textBody: [], htmlBody: [{ partId: '1', type: 'text/html' }], attachments: [], + bodyValues: { '1': { value: '

Hello

' } }, +} as unknown as Email; + +afterEach(() => { + setNoThirdPartyTraffic(false); + useSettingsStore.setState({ externalContentPolicy: 'ask' }); +}); + +async function renderViewer() { + render(); + await act(async () => { await new Promise((r) => setTimeout(r, 30)); }); + return screen.findByTestId('external-content-notice'); +} + +async function renderConversation() { + const view = render( + {}} />, + ); + await act(async () => { await new Promise((r) => setTimeout(r, 30)); }); + return view; +} + +describe('reading pane', () => { + it('offers to load remote content without the mode', async () => { + const notice = await renderViewer(); + expect(notice.textContent).toContain('external_content_warning'); + expect(screen.queryByText('load_external_content')).not.toBeNull(); + expect(screen.queryByText('trust_sender')).not.toBeNull(); + }); + + it('says the organisation blocks it, with no load or trust button, even when the policy allows', async () => { + setNoThirdPartyTraffic(true); + useSettingsStore.setState({ externalContentPolicy: 'allow' }); + const notice = await renderViewer(); + expect(notice.textContent).toContain('remote_content_blocked'); + expect(screen.queryByText('load_external_content')).toBeNull(); + expect(screen.queryByText('trust_sender')).toBeNull(); + }); +}); + +describe('conversation view', () => { + it('offers to load remote content without the mode', async () => { + await renderConversation(); + expect(screen.getByTestId('external-content-notice').textContent).toContain('email_viewer.external_content_warning'); + expect(screen.queryByText('email_viewer.load_external_content')).not.toBeNull(); + }); + + it('says the organisation blocks it, keeps it blocked and offers nothing in the mode', async () => { + setNoThirdPartyTraffic(true); + useSettingsStore.setState({ externalContentPolicy: 'allow' }); + const { container } = await renderConversation(); + expect(screen.getByTestId('external-content-notice').textContent).toContain('third_party.remote_content_blocked'); + expect(screen.queryByText('email_viewer.load_external_content')).toBeNull(); + expect(screen.queryByText('email_viewer.trust_sender')).toBeNull(); + const srcdoc = container.querySelector('iframe')?.getAttribute('srcdoc') ?? ''; + expect(srcdoc).not.toMatch(/\ssrc="https:\/\/track\.shop\.example/); + }); +}); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 7ab8f5dac..cc77bb149 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -149,6 +149,7 @@ import type { AttachmentInfo, AttachmentPreview } from "@/lib/plugin-types"; import { useAttachmentDrag, isDragOutSupported, type AttachmentDragSource } from "@/hooks/use-attachment-drag"; import type { IJMAPClient } from "@/lib/jmap/client-interface"; import { useArchiveActionEnabled } from "@/lib/archive-gate"; +import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode"; /** The More menu's two drill-downs: a folder list and a tag list. */ type MoreMenuSub = 'move' | 'tag' | 'rules'; @@ -1090,6 +1091,10 @@ export function EmailViewer({ const tWelcome = useTranslations('welcome'); const quickReplyEnabled = useSettingsStore((state) => state.quickReplyEnabled); const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy); + // No third-party traffic (#903): remote content never loads, whatever the + // policy or trust says, and nothing offers to load it. + const remoteBlockedByOrg = useNoThirdPartyTraffic(); + const tThirdParty = useTranslations('third_party'); const messageSpacing = useSettingsStore((state) => state.messageSpacing); const plainTextFont = useSettingsStore((state) => state.plainTextFont); const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction); @@ -2484,6 +2489,7 @@ export function EmailViewer({ // 'ask' = block until the user allows this message or trusts the sender. const shouldBlockExternal = useMemo(() => { if (!email) return false; + if (remoteBlockedByOrg) return true; const senderEmail = email.from?.[0]?.email?.toLowerCase(); const senderIsTrusted = senderEmail ? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail)) @@ -2496,7 +2502,7 @@ export function EmailViewer({ // permission changes, so they're deliberately omitted from deps (matches // the srcDoc rebuild contract described on `emailContent`). // eslint-disable-next-line react-hooks/exhaustive-deps - }, [email, externalContentPolicy, allowExternalContent]); + }, [email, externalContentPolicy, allowExternalContent, remoteBlockedByOrg]); // Sanitize and prepare email HTML content const emailContent = useMemo(() => { @@ -5856,7 +5862,7 @@ export function EmailViewer({ {/* Unified Notification Banner - External Content + Calendar Invitation + Read Receipt */} {(senderVerification || - (hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') || + (hasBlockedContent && (remoteBlockedByOrg || (!allowExternalContent && externalContentPolicy !== 'allow'))) || hasCalendarInvitation || (readReceiptResponse === 'ask' && shouldOfferReadReceipt)) && (
@@ -5886,8 +5892,8 @@ export function EmailViewer({ )} {/* External Content Controls */} - {hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && ( -
+ {hasBlockedContent && (remoteBlockedByOrg || (!allowExternalContent && externalContentPolicy !== 'allow')) && ( +
@@ -5897,9 +5903,10 @@ export function EmailViewer({ External Content
- {t('external_content_warning')} + {remoteBlockedByOrg ? tThirdParty('remote_content_blocked') : t('external_content_warning')}
+ {!remoteBlockedByOrg && (
{externalContentPolicy === 'ask' && (
+ )}
)} diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index 4cde880b6..c8a7525d8 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -56,6 +56,7 @@ import { useDeviceDetection } from "@/hooks/use-media-query"; import { usePluginRenderedBody } from "@/hooks/use-plugin-rendered-body"; import { emailToReadView } from "@/lib/plugin-projection"; import { AttachmentPluginMenu, ReaderAboveQuoteSlot } from "./reader-plugin-surfaces"; +import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode"; export type ConversationAttachmentHandler = ( blobId: string, name: string, type: string | undefined, source: EmailBlobContext, @@ -113,6 +114,8 @@ export function useConversationExternalContent() { const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook); const { client } = useAuthStore(useShallow((state) => ({ client: state.client }))); const [allowedIds, setAllowedIds] = useState>(new Set()); + // No third-party traffic (#903): nothing remote loads and nothing offers to. + const remoteBlockedByOrg = useNoThirdPartyTraffic(); const allow = useCallback((emailId: string) => { setAllowedIds(prev => new Set(prev).add(emailId)); @@ -123,12 +126,13 @@ export function useConversationExternalContent() { const senderIsTrusted = senderEmail ? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail)) : false; + if (remoteBlockedByOrg) return false; return externalContentPolicy === 'allow' || !!senderIsTrusted || allowedIds.has(email.id); }; const trustSender = (email: Email) => { const senderEmail = email.from?.[0]?.email?.toLowerCase(); - if (!senderEmail) return undefined; + if (!senderEmail || remoteBlockedByOrg) return undefined; return () => { if (trustedSendersAddressBook && client) { addToTrustedSendersBook(client, senderEmail).catch(console.error); @@ -523,6 +527,7 @@ export function ConversationEmailBody({ bodyStyle, className, }: ConversationEmailBodyProps) { + const remoteBlockedByOrg = useNoThirdPartyTraffic(); const t = useTranslations(); const resolvedTheme = useThemeStore((state) => state.resolvedTheme); const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction); @@ -832,10 +837,11 @@ export function ConversationEmailBody({
{/* External content warning */} {hasBlockedContent && !allowExternal && ( -
+
- {t("email_viewer.external_content_warning")} + {remoteBlockedByOrg ? t("third_party.remote_content_blocked") : t("email_viewer.external_content_warning")} + {!remoteBlockedByOrg && (
+ )}
)} diff --git a/components/files/eml-preview.tsx b/components/files/eml-preview.tsx index b9a943611..27e73ddcf 100644 --- a/components/files/eml-preview.tsx +++ b/components/files/eml-preview.tsx @@ -6,6 +6,7 @@ import { emailIframeCsp, sanitizeEmailBodyForIframe } from "@/lib/email-sanitiza import { getEffectiveTimeZone } from "@/lib/timezone"; import { stripBidiControls } from "@/lib/bidi"; import { useSettingsStore } from "@/stores/settings-store"; +import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode"; // A parsed message/rfc822 (.eml), as produced by postal-mime. Only the fields // this preview renders are typed. @@ -43,7 +44,9 @@ function escapeHtml(s: string): string { // (FilePreviewModal); this is pure presentation. export function EmlPreview({ message }: { message: ParsedEml }) { const t = useTranslations("email_viewer"); - const blockRemote = useSettingsStore((state) => state.externalContentPolicy) !== "allow"; + // Always blocked with no third-party traffic (#903). + const remoteBlockedByOrg = useNoThirdPartyTraffic(); + const blockRemote = useSettingsStore((state) => state.externalContentPolicy) !== "allow" || remoteBlockedByOrg; // dir="auto" on a wrapper we control lets the first strong character pick // the direction, matching the main viewer. diff --git a/components/files/file-preview-modal.tsx b/components/files/file-preview-modal.tsx index 4e046e5ad..cf7234554 100644 --- a/components/files/file-preview-modal.tsx +++ b/components/files/file-preview-modal.tsx @@ -14,6 +14,7 @@ import { canOpenUrlInNewWindow } from "@/lib/standalone-display"; import { emailIframeCsp, sanitizeEmailBodyForIframe } from "@/lib/email-sanitization"; import { useSettingsStore } from "@/stores/settings-store"; import type { FileResourceView } from "@/lib/plugin-types"; +import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode"; /** * An HTML attachment as a document for a fully locked frame (sandbox="", @@ -161,7 +162,9 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi // open-in-new-tab fallback use. const [pdfBlob, setPdfBlob] = useState(null); const [htmlDoc, setHtmlDoc] = useState(null); - const blockRemote = useSettingsStore((state) => state.externalContentPolicy) !== "allow"; + // Always blocked with no third-party traffic (#903). + const remoteBlockedByOrg = useNoThirdPartyTraffic(); + const blockRemote = useSettingsStore((state) => state.externalContentPolicy) !== "allow" || remoteBlockedByOrg; // Decide whether to render the PDF in a plain