mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 08:15:38 +00:00
fix: say the organisation blocks remote content instead of offering to load it with no third-party traffic #903
This commit is contained in:
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user