mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
fix: block remote content when the webmail allows no third-party traffic and hide address matching without custom From
This commit is contained in:
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user