fix: block remote content when the webmail allows no third-party traffic and hide address matching without custom From

This commit is contained in:
Linus Rath
2026-10-31 16:22:11 +01:00
parent c66e6209ed
commit d254d7b5ab
9 changed files with 118 additions and 22 deletions
+13 -7
View File
@@ -20,6 +20,7 @@ import { buildMailTextSizeScript, mailTextFactor } from '../lib/mail-reading';
import { buildPauseAnimatedImagesScript, withReducedMotion } from '../lib/mail-motion';
import { useHasContacts } from '../lib/capabilities';
import { useSettingsStore } from '../stores/settings-store';
import { useNoThirdPartyTraffic } from '../stores/policy-store';
import { useContactsStore } from '../stores/contacts-store';
import { useLocaleStore } from '../stores/locale-store';
import { spacing, typography, type ThemePalette } from '../theme/tokens';
@@ -437,6 +438,9 @@ export default function EmailBodyView({
const t = useLocaleStore((s) => s.t);
const navigation = useNavigation<Nav>();
const externalContentPolicy = useSettingsStore((s) => s.externalContentPolicy);
// No third-party traffic (webmail #903): remote content never loads,
// whatever the policy or trust says, and nothing offers to load it.
const remoteBlockedByOrg = useNoThirdPartyTraffic();
const isSenderTrusted = useSettingsStore((s) => s.isSenderTrusted);
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
@@ -523,10 +527,10 @@ export default function EmailBodyView({
// trusted, regardless of what the detection pass finds - the pass only
// drives the banner and the placeholder swap.
const shouldBlock =
!!rawHtml &&
!trusted &&
!allowOnce &&
externalContentPolicy !== 'allow';
!!rawHtml && (
remoteBlockedByOrg ||
(!trusted && !allowOnce && externalContentPolicy !== 'allow')
);
// The WebView's height: what the page reported, else the height this body
// had the last time it was shown, else (see `fill`) an estimate. Never a
@@ -736,9 +740,11 @@ export default function EmailBodyView({
{showBanner && (
<View style={styles.banner}>
<Text style={styles.bannerText}>
{t('email_viewer.external_content_warning', 'Images and external content have been blocked')}
{remoteBlockedByOrg
? t('third_party.remote_content_blocked', "Your organisation blocks content from other servers, so remote pictures in this message aren't shown.")
: t('email_viewer.external_content_warning', 'Images and external content have been blocked')}
</Text>
<View style={styles.bannerActions}>
{!remoteBlockedByOrg && <View style={styles.bannerActions}>
{externalContentPolicy === 'ask' && (
<Pressable style={styles.bannerButton} onPress={onLoadImages} hitSlop={8}>
<ImageIcon size={14} color={c.textSecondary} />
@@ -751,7 +757,7 @@ export default function EmailBodyView({
<Text style={styles.bannerButtonText}>{t('email_viewer.trust_sender', 'Always trust this sender')}</Text>
</Pressable>
) : null}
</View>
</View>}
</View>
)}
{showTruncated && (
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { webmailLinksFromConfig, helpLinks } from '../../lib/org-policy';
// The organisation's customFromEnabled gate (webmail composing-settings.tsx,
// email-composer.tsx) and the webmail's "no third-party traffic" mode (#903,
// hooks/use-third-party-mode.ts, reading-settings.tsx, email-viewer.tsx). No
// React Native renderer in the test env, so this checks the wiring.
const read = (rel: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', rel), 'utf8');
describe('customFromEnabled', () => {
it('shows Received Address Matching only when custom From is allowed', () => {
const src = read('components/settings/ComposingSettings.tsx');
expect(src).toMatch(/const customFromEnabled = usePolicyFeature\('customFromEnabled'\);/);
expect(src).toMatch(/\{autoSelectReplyIdentity && customFromEnabled && \(/);
});
it('matches only exact identities in the composer when custom From is off', () => {
expect(read('screens/ComposeScreen.tsx')).toMatch(/matchMode: customFromEnabled \? replyIdentityMatch : 'exact',/);
});
});
describe('no third-party traffic', () => {
it('reads the mode from the webmail config, as a boolean or an env-style string', () => {
expect(webmailLinksFromConfig({ noThirdPartyTraffic: true }).noThirdPartyTraffic).toBe(true);
expect(webmailLinksFromConfig({ noThirdPartyTraffic: 'Yes' }).noThirdPartyTraffic).toBe(true);
expect(webmailLinksFromConfig({ noThirdPartyTraffic: false }).noThirdPartyTraffic).toBeUndefined();
expect(webmailLinksFromConfig({ noThirdPartyTraffic: 'no' }).noThirdPartyTraffic).toBeUndefined();
// Not a help link.
expect(helpLinks(webmailLinksFromConfig({ noThirdPartyTraffic: true }))).toEqual([]);
});
it('shows External Content as the organisation\'s, set to block', () => {
const src = read('components/settings/ContentSendersSettings.tsx');
expect(src).toMatch(/const remoteBlockedByOrg = useNoThirdPartyTraffic\(\);/);
expect(src).toMatch(/locked=\{externalContentLocked \|\| remoteBlockedByOrg\}/);
expect(src).toMatch(/value=\{remoteBlockedByOrg \? 'block' : externalContentPolicy\}/);
});
it('never loads remote content in the reader and offers nothing to load it', () => {
const src = read('components/EmailBodyView.tsx');
expect(src).toMatch(/!!rawHtml && \(\s+remoteBlockedByOrg \|\|\s+\(!trusted && !allowOnce && externalContentPolicy !== 'allow'\)\s+\)/);
expect(src).toMatch(/\{!remoteBlockedByOrg && <View style=\{styles\.bannerActions\}>/);
expect(src).toMatch(/t\('third_party\.remote_content_blocked'/);
});
it('keeps remote images blocked in booking cards and quoted originals', () => {
expect(read('components/email/structured-mail/StructuredMailBanner.tsx')).toMatch(/\(\) => !remoteBlockedByOrg && \(policy === 'allow' \|\|/);
const compose = read('screens/ComposeScreen.tsx');
expect(compose).toMatch(/externalContentPolicy: remoteBlockedByOrg \? 'block' : settings\.externalContentPolicy,/);
expect(compose).toMatch(/senderTrusted: !remoteBlockedByOrg && !!sender/);
});
});
@@ -8,6 +8,7 @@ import { isSenderContentTrusted, isTrustedSendersSyncOn } from '../../../lib/tru
import { useHasContacts } from '../../../lib/capabilities';
import { useContactsStore } from '../../../stores/contacts-store';
import { useSettingsStore } from '../../../stores/settings-store';
import { useNoThirdPartyTraffic } from '../../../stores/policy-store';
import { BookingCalendarActions } from './booking-calendar-actions';
import { StructuredMailCards } from './structured-mail-cards';
import { useStructuredMail } from './use-structured-mail';
@@ -28,6 +29,8 @@ interface Props {
export function StructuredMailBanner({ email, jmapAccountId }: Props) {
const cards = useStructuredMail(email, jmapAccountId);
const policy = useSettingsStore((s) => s.externalContentPolicy);
// No third-party traffic (webmail #903): remote images never load.
const remoteBlockedByOrg = useNoThirdPartyTraffic();
const trustedSenders = useSettingsStore((s) => s.trustedSenders);
const isSenderTrusted = useSettingsStore((s) => s.isSenderTrusted);
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
@@ -35,13 +38,13 @@ export function StructuredMailBanner({ email, jmapAccountId }: Props) {
const hasContacts = useHasContacts();
const sender = email.from?.[0]?.email;
const allowRemoteImages = React.useMemo(
() => policy === 'allow' || isSenderContentTrusted(sender, {
() => !remoteBlockedByOrg && (policy === 'allow' || isSenderContentTrusted(sender, {
isLocallyTrusted: isSenderTrusted,
syncEnabled: isTrustedSendersSyncOn(trustedSendersAddressBook, hasContacts),
trustedBookEmails,
}),
})),
// trustedSenders changes what isSenderTrusted answers.
[policy, sender, isSenderTrusted, trustedSenders, trustedSendersAddressBook, hasContacts, trustedBookEmails], // eslint-disable-line react-hooks/exhaustive-deps
[remoteBlockedByOrg, policy, sender, isSenderTrusted, trustedSenders, trustedSendersAddressBook, hasContacts, trustedBookEmails], // eslint-disable-line react-hooks/exhaustive-deps
);
const calendarActions = React.useCallback(
(booking: Booking) => <BookingCalendarActions booking={booking} email={email} />,
@@ -7,7 +7,7 @@ import { spacing, radius, typography, type ThemePalette } from '../../theme/toke
import { useColors } from '../../theme/colors';
import { useSettingsStore, type MentionAddsRecipientTo, type ReplyIdentityMatch, type SignaturePosition } from '../../stores/settings-store';
import { useLocaleStore } from '../../stores/locale-store';
import { useSettingLocked } from '../../stores/policy-store';
import { usePolicyFeature, useSettingLocked } from '../../stores/policy-store';
import { SUPPORTED_SUB_ADDRESS_DELIMITERS } from '../../lib/sub-addressing';
import { RADIO_GROUP_A11Y, radioOptionA11y } from '../radio-a11y';
@@ -29,6 +29,9 @@ export function ComposingSettings() {
const autoSelectReplyIdentityLocked = useSettingLocked('autoSelectReplyIdentity');
const replyIdentityMatchLocked = useSettingLocked('replyIdentityMatch');
const plainTextModeLocked = useSettingLocked('plainTextMode');
// Catch-all matching only rewrites From, which the organisation can turn
// off (webmail composing-settings.tsx customFromEnabled).
const customFromEnabled = usePolicyFeature('customFromEnabled');
const signaturePosition = useSettingsStore((s) => s.signaturePosition);
const signatureSeparatorEnabled = useSettingsStore((s) => s.signatureSeparatorEnabled);
const requestReadReceiptDefault = useSettingsStore((s) => s.requestReadReceiptDefault);
@@ -135,7 +138,7 @@ export function ComposingSettings() {
<ToggleSwitch checked={autoSelectReplyIdentity} onChange={setAutoSelectReplyIdentity} />
</SettingItem>
{autoSelectReplyIdentity && (
{autoSelectReplyIdentity && customFromEnabled && (
<View style={styles.subBlock}>
<Text style={styles.subLabel}>{t('settings.email_behavior.reply_identity_match.label', 'Received Address Matching')}</Text>
<Text style={styles.subDescription}>
@@ -13,7 +13,7 @@ import { useHasContacts } from '../../lib/capabilities';
import { isTrustedSendersSyncOn } from '../../lib/trusted-senders';
import { removeTrustedSenderWithUndo } from '../../lib/settings-undo';
import { useLocaleStore } from '../../stores/locale-store';
import { usePolicyFeature, useSettingHidden, useSettingLocked } from '../../stores/policy-store';
import { useNoThirdPartyTraffic, usePolicyFeature, useSettingHidden, useSettingLocked } from '../../stores/policy-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetBottomInset } from '../../lib/sheet-bottom-inset';
import { ToastHost } from '../ToastHost';
@@ -38,6 +38,9 @@ export function ContentSendersSettings() {
const externalContentHidden = useSettingHidden('externalContentPolicy');
const externalContentLocked = useSettingLocked('externalContentPolicy');
const showExternalContent = externalContentAllowed && !externalContentHidden;
// No third-party traffic (webmail #903) blocks remote content outright:
// the setting shows as the organisation's, set to block.
const remoteBlockedByOrg = useNoThirdPartyTraffic();
const emailAlwaysLightMode = useSettingsStore((s) => s.emailAlwaysLightMode);
const setEmailAlwaysLightMode = useSettingsStore((s) => s.setEmailAlwaysLightMode);
const trustedSenders = useSettingsStore((s) => s.trustedSenders);
@@ -145,10 +148,10 @@ export function ContentSendersSettings() {
<SettingItem
label={t('settings.email_behavior.external_content.label', 'External Content')}
description={t('settings.email_behavior.external_content.description', 'How to handle images and external content')}
locked={externalContentLocked}
locked={externalContentLocked || remoteBlockedByOrg}
>
<RadioGroup
value={externalContentPolicy}
value={remoteBlockedByOrg ? 'block' : externalContentPolicy}
onChange={(v) => setExternalContentPolicy(v as ExternalContentPolicy)}
options={[
{ value: 'ask', label: t('settings.email_behavior.external_content.ask_mobile', 'Ask') },
@@ -40,7 +40,7 @@ describe('organisation policy on the settings pages', () => {
expect(page).toContain("const externalContentAllowed = usePolicyFeature('externalContentEnabled');");
expect(page).toContain("const externalContentHidden = useSettingHidden('externalContentPolicy');");
expect(page).toContain('const showExternalContent = externalContentAllowed && !externalContentHidden;');
expect(page).toMatch(/\{showExternalContent && \(\s*<SettingItem\s+label=\{t\('settings\.email_behavior\.external_content\.label'[\s\S]*?locked=\{externalContentLocked\}/);
expect(page).toMatch(/\{showExternalContent && \(\s*<SettingItem\s+label=\{t\('settings\.email_behavior\.external_content\.label'[\s\S]*?locked=\{externalContentLocked(?: \|\| remoteBlockedByOrg)?\}/);
expect(page).toContain("const externalContentLocked = useSettingLocked('externalContentPolicy');");
});
+14 -1
View File
@@ -480,10 +480,18 @@ export interface WebmailLinks {
signLanguageUrl?: string;
/** The config's company name (`loginCompanyName`), for the organisation signature's {company}. */
companyName?: string;
/**
* The webmail's "no third-party traffic" mode (#903, config
* `noThirdPartyTraffic`): remote content in mail never loads.
*/
noThirdPartyTraffic?: boolean;
}
/** The accessibility and help links, in the webmail's fixed order (lib/help-links.ts). */
export const HELP_LINK_ORDER: ReadonlyArray<[HelpLinkId, keyof WebmailLinks]> = [
/** The keys of WebmailLinks that hold a link. */
export type WebmailLinkKey = Exclude<keyof WebmailLinks, 'companyName' | 'noThirdPartyTraffic'>;
export const HELP_LINK_ORDER: ReadonlyArray<[HelpLinkId, WebmailLinkKey]> = [
['statement', 'accessibilityStatementUrl'],
['feedback', 'accessibilityFeedbackUrl'],
['help', 'helpUrl'],
@@ -506,6 +514,11 @@ export function webmailLinksFromConfig(raw: unknown): WebmailLinks {
if (typeof raw.loginCompanyName === 'string' && raw.loginCompanyName.trim()) {
out.companyName = raw.loginCompanyName.trim().slice(0, 200);
}
// A boolean, or a string as in env (webmail lib/third-party-mode.ts parseNoThirdPartyValue).
const noThirdParty = raw.noThirdPartyTraffic;
if (noThirdParty === true || (typeof noThirdParty === 'string' && /^(true|1|yes|on)$/i.test(noThirdParty.trim()))) {
out.noThirdPartyTraffic = true;
}
return out;
}
+9 -5
View File
@@ -35,7 +35,7 @@ import { useSheetBottomInset } from '../lib/sheet-bottom-inset';
import { createAfterDismissQueue } from '../lib/after-modal-dismiss';
import { useLocaleStore } from '../stores/locale-store';
import { useSettingsStore } from '../stores/settings-store';
import { usePolicyFeature, usePolicyStore } from '../stores/policy-store';
import { useNoThirdPartyTraffic, usePolicyFeature, usePolicyStore } from '../stores/policy-store';
import { effectiveSignatureIdentity, finalizeOrgOutgoing, orgSignatureSetup, htmlCarriesDisclaimer, textCarriesDisclaimer } from '../lib/org-signature';
import { useHasContacts } from '../lib/capabilities';
import { isTrustedSendersSyncOn } from '../lib/trusted-senders';
@@ -416,6 +416,9 @@ export default function ComposeScreen({ route, navigation }: Props) {
const autoSelectReplyIdentity = useSettingsStore((s) => s.autoSelectReplyIdentity);
const replyIdentityMatch = useSettingsStore((s) => s.replyIdentityMatch);
const customFromEnabled = usePolicyFeature('customFromEnabled');
// No third-party traffic (webmail #903): a quoted original's remote images stay blocked.
const remoteBlockedByOrg = useNoThirdPartyTraffic();
// Per-message format (#1022): seeded from the "plain text only" setting,
// or the format a reopened draft was written in, and switchable from the
// toolbar for just this message.
@@ -518,8 +521,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
return shouldBlockEditorRemoteImages({
seedHtml: draft ? draft.htmlBody : replyTo?.htmlBody,
isDraft: !!draft,
externalContentPolicy: settings.externalContentPolicy,
senderTrusted: !!sender && (
externalContentPolicy: remoteBlockedByOrg ? 'block' : settings.externalContentPolicy,
senderTrusted: !remoteBlockedByOrg && !!sender && (
settings.isSenderTrusted(sender)
|| useContactsStore.getState().trustedSenderEmails.includes(sender)
),
@@ -972,7 +975,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
const resolved = resolveReplyIdentity(identities, replyTo, {
ownEmails,
catchAll: autoSelectReplyIdentity && mode !== 'forward',
matchMode: replyIdentityMatch,
// Without the organisation's custom From, only an exact identity (webmail email-composer.tsx).
matchMode: customFromEnabled ? replyIdentityMatch : 'exact',
});
if (resolved) {
setSelectedIdentityId(resolved.identityId);
@@ -984,7 +988,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
}
setSelectedIdentityId(defaultIdentity.id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [identities, autoSelectReplyIdentity, replyIdentityMatch, replyTo, draft, selectedIdentityId, preferredIdentityIds]);
}, [identities, autoSelectReplyIdentity, replyIdentityMatch, customFromEnabled, replyTo, draft, selectedIdentityId, preferredIdentityIds]);
const primaryIdentity = React.useMemo(() => {
if (identities.length === 0) return null;
+9
View File
@@ -267,6 +267,15 @@ export function useSettingLocked(key: string): boolean {
return usePolicyStore((s) => isLockedSetting(s.rules, key));
}
/**
* Whether the active account's webmail runs without third-party traffic
* (webmail hooks/use-third-party-mode.ts): remote content in mail never
* loads, whatever the setting or a trusted sender says.
*/
export function useNoThirdPartyTraffic(): boolean {
return usePolicyStore((s) => s.links.noThirdPartyTraffic === true);
}
/** Whether the active account's policy hides a setting (by the app's key). */
export function useSettingHidden(key: string): boolean {
return usePolicyStore((s) => s.rules.hidden.includes(key));