fix: say the organisation blocks remote content instead of offering to load it with no third-party traffic #903

This commit is contained in:
Linus Rath
2026-10-28 05:38:39 +01:00
parent 9e809a31db
commit b0eb9fcfe1
9 changed files with 155 additions and 14 deletions
@@ -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: '<p>Hello</p><img src="https://track.shop.example/pixel.gif">' } },
} as unknown as Email;
afterEach(() => {
setNoThirdPartyTraffic(false);
useSettingsStore.setState({ externalContentPolicy: 'ask' });
});
async function renderViewer() {
render(<EmailViewer email={email} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
await act(async () => { await new Promise((r) => setTimeout(r, 30)); });
return screen.findByTestId('external-content-notice');
}
async function renderConversation() {
const view = render(
<ThreadConversationView thread={{ threadId: 't1', emails: [email], latestEmail: email } as never} emails={[email] as never} onBack={() => {}} />,
);
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/);
});
});
+13 -5
View File
@@ -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)) && (
<div className="border-b border-border bg-muted/30 isolate">
@@ -5886,8 +5892,8 @@ export function EmailViewer({
)}
{/* External Content Controls */}
{hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
<div className="flex items-start gap-3 py-1">
{hasBlockedContent && (remoteBlockedByOrg || (!allowExternalContent && externalContentPolicy !== 'allow')) && (
<div className="flex items-start gap-3 py-1" data-testid="external-content-notice">
<div className="w-10 h-10 rounded-full bg-info/15 text-info flex items-center justify-center flex-shrink-0 shadow-sm">
<Image className="w-5 h-5" />
</div>
@@ -5897,9 +5903,10 @@ export function EmailViewer({
External Content
</div>
<div className="text-sm font-medium text-foreground break-words">
{t('external_content_warning')}
{remoteBlockedByOrg ? tThirdParty('remote_content_blocked') : t('external_content_warning')}
</div>
</div>
{!remoteBlockedByOrg && (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
{externalContentPolicy === 'ask' && (
<button
@@ -5932,6 +5939,7 @@ export function EmailViewer({
</button>
)}
</div>
)}
</div>
</div>
)}
+10 -3
View File
@@ -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<Set<string>>(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({
<div className={className}>
{/* External content warning */}
{hasBlockedContent && !allowExternal && (
<div className="px-4 py-2 bg-muted/50 flex items-center justify-between text-sm">
<div className="px-4 py-2 bg-muted/50 flex items-center justify-between text-sm" data-testid="external-content-notice">
<span className="text-muted-foreground">
{t("email_viewer.external_content_warning")}
{remoteBlockedByOrg ? t("third_party.remote_content_blocked") : t("email_viewer.external_content_warning")}
</span>
{!remoteBlockedByOrg && (
<div className="flex items-center gap-2">
<Button
variant="ghost"
@@ -860,6 +866,7 @@ export function ConversationEmailBody({
</Button>
)}
</div>
)}
</div>
)}
+4 -1
View File
@@ -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.
+4 -1
View File
@@ -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<Blob | null>(null);
const [htmlDoc, setHtmlDoc] = useState<string | null>(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 <iframe> (desktop) or with the
// pdf.js canvas viewer (mobile). navigator.pdfViewerEnabled is the standard
@@ -3,6 +3,7 @@ import { afterEach, describe, it, expect, vi, beforeEach } from 'vitest';
import { MessageListSettings } from '../message-list-settings';
import { NotificationSettings } from '../notification-settings';
import { PluginsSettings } from '../plugins-settings';
import { ReadingSettings } from '../reading-settings';
import { useAuthStore } from '@/stores/auth-store';
import { usePolicyStore } from '@/stores/policy-store';
import { usePluginStore } from '@/stores/plugin-store';
@@ -89,3 +90,26 @@ describe('Settings > Plugins', () => {
expect(screen.getByText('plugin_refused')).toBeInTheDocument();
});
});
describe('Settings > Reading', () => {
function externalContentRow() {
return screen.getByText('external_content.label').closest('[data-search-label]') as HTMLElement;
}
it('lets the user pick the external content policy without the mode', () => {
useSettingsStore.setState({ externalContentPolicy: 'ask' });
render(<ReadingSettings />);
expect(externalContentRow().textContent).not.toContain('org_locked');
expect(externalContentRow().querySelector('select')?.value).toBe('ask');
});
it('shows the policy as blocked and set by the organisation in the mode', () => {
setNoThirdPartyTraffic(true);
useSettingsStore.setState({ externalContentPolicy: 'allow' });
render(<ReadingSettings />);
const row = externalContentRow();
expect(row.textContent).toContain('org_locked');
expect(row.querySelector('select')?.value).toBe('block');
expect(row.querySelector('[inert]')).not.toBeNull();
});
});
+6 -2
View File
@@ -10,6 +10,7 @@ import { ChevronRight } from '@/components/icons';
import { usePolicyStore } from '@/stores/policy-store';
import { useContactStore } from '@/stores/contact-store';
import { useAuthStore } from '@/stores/auth-store';
import { useNoThirdPartyTraffic } from '@/hooks/use-third-party-mode';
/**
* The Reading page: the open message. Opening it, how its body is drawn,
@@ -18,6 +19,9 @@ import { useAuthStore } from '@/stores/auth-store';
* tab. List, delete and swipe rows live on the Message list page.
*/
export function ReadingSettings() {
// No third-party traffic (#903) blocks remote content outright: the
// setting shows as the organisation's, set to block.
const remoteBlockedByOrg = useNoThirdPartyTraffic();
const t = useTranslations('settings.email_behavior');
const tPage = useTranslations('settings.reading_page');
const [showTrustedModal, setShowTrustedModal] = useState(false);
@@ -119,9 +123,9 @@ export function ReadingSettings() {
<SettingsSection id="images" title={tPage('sections.images')} description={tPage('sections.images_description')}>
{isFeatureEnabled('externalContentEnabled') && !isSettingHidden('externalContentPolicy') && (
<SettingItem label={t('external_content.label')} description={t('external_content.description')} locked={isSettingLocked('externalContentPolicy')}>
<SettingItem label={t('external_content.label')} description={t('external_content.description')} locked={isSettingLocked('externalContentPolicy') || remoteBlockedByOrg}>
<Select
value={externalContentPolicy}
value={remoteBlockedByOrg ? 'block' : externalContentPolicy}
onChange={(value) =>
updateSetting('externalContentPolicy', value as 'ask' | 'block' | 'allow')
}
+2 -1
View File
@@ -999,7 +999,8 @@
"push_needs_relay": "Push-Benachrichtigungen brauchen ein Push-Relay Ihrer Organisation, und es ist keines eingerichtet. Neue E-Mails werden weiterhin angezeigt, solange die App geöffnet ist.",
"relay_desc": "Es werden nur die eigenen Relays Ihrer Organisation angeboten, weil dieser Server keine externen Dienste kontaktiert.",
"relay_none": "Keines eingerichtet",
"plugin_refused": "Nicht gestartet: Dieses Plugin verbindet sich mit externen Diensten, die Ihre Organisation abgeschaltet hat."
"plugin_refused": "Nicht gestartet: Dieses Plugin verbindet sich mit externen Diensten, die Ihre Organisation abgeschaltet hat.",
"remote_content_blocked": "Ihre Organisation blockiert Inhalte von anderen Servern, daher werden externe Bilder in dieser Nachricht nicht angezeigt."
},
"date": {
"today": "Heute",
+2 -1
View File
@@ -999,7 +999,8 @@
"push_needs_relay": "Push notifications need a push relay run by your organisation, and none is set up. New mail still shows while the app is open.",
"relay_desc": "Only your organisation's own relays are offered, because this server doesn't contact outside services.",
"relay_none": "None set up",
"plugin_refused": "Not started: this plugin connects to outside services, which your organisation has turned off."
"plugin_refused": "Not started: this plugin connects to outside services, which your organisation has turned off.",
"remote_content_blocked": "Your organisation blocks content from other servers, so remote pictures in this message aren't shown."
},
"date": {
"today": "Today",