From b0eb9fcfe1f7059e8a14081993a4a021b9c66650 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 05:38:39 +0100
Subject: [PATCH] fix: say the organisation blocks remote content instead of
offering to load it with no third-party traffic #903
---
.../remote-content-no-third-party.test.tsx | 90 +++++++++++++++++++
components/email/email-viewer.tsx | 18 ++--
components/email/thread-conversation-view.tsx | 13 ++-
components/files/eml-preview.tsx | 5 +-
components/files/file-preview-modal.tsx | 5 +-
.../no-third-party-settings.test.tsx | 24 +++++
components/settings/reading-settings.tsx | 8 +-
locales/de/common.json | 3 +-
locales/en/common.json | 3 +-
9 files changed, 155 insertions(+), 14 deletions(-)
create mode 100644 components/email/__tests__/remote-content-no-third-party.test.tsx
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