From b58c9dd719ff2788252698ed6f3d9800ff12fe30 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:38:43 +0100 Subject: [PATCH 1/9] fix: hide the custom From in the composer when the organisation turns it off --- .../custom-from-third-party-wiring.test.ts | 8 +++ src/lib/__tests__/compose-sender.test.ts | 38 ++++++++++++ src/lib/compose-sender.ts | 52 ++++++++++++++++ src/screens/ComposeScreen.tsx | 59 +++++++++---------- 4 files changed, 125 insertions(+), 32 deletions(-) create mode 100644 src/lib/__tests__/compose-sender.test.ts create mode 100644 src/lib/compose-sender.ts diff --git a/src/components/__tests__/custom-from-third-party-wiring.test.ts b/src/components/__tests__/custom-from-third-party-wiring.test.ts index 7d648ed3..4df4945a 100644 --- a/src/components/__tests__/custom-from-third-party-wiring.test.ts +++ b/src/components/__tests__/custom-from-third-party-wiring.test.ts @@ -20,6 +20,14 @@ describe('customFromEnabled', () => { it('matches only exact identities in the composer when custom From is off', () => { expect(read('screens/ComposeScreen.tsx')).toMatch(/matchMode: customFromEnabled \? replyIdentityMatch : 'exact',/); }); + + it('offers the custom From in the sender sheet only when allowed, and sends without it otherwise', () => { + const src = read('screens/ComposeScreen.tsx'); + expect(src).toMatch(/\{customFromEnabled && \(\s+\s+\{t\('email_composer\.from_override\.toggle_off'/); + expect(src).toMatch(/\{overrideEnabled && customFromEnabled && \(/); + expect(src).toMatch(/if \(overrideEnabled && customFromEnabled\) \{/); + expect(src).toMatch(/composeSenderAddress\(identity, \{ fromOverride, customFromEnabled, subAddressTag, subAddressDelimiter \}\)/); + }); }); describe('no third-party traffic', () => { diff --git a/src/lib/__tests__/compose-sender.test.ts b/src/lib/__tests__/compose-sender.test.ts new file mode 100644 index 00000000..bfe47496 --- /dev/null +++ b/src/lib/__tests__/compose-sender.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { activeFromOverride, composeSenderAddress } from '../compose-sender'; +import type { Identity } from '../../api/types'; + +const identity: Identity = { id: 'i1', name: 'Ada Lovelace', email: 'ada@example.org', mayDelete: true }; +const override = { name: 'Support', email: 'support@example.org' }; + +describe('composeSenderAddress', () => { + it('writes the custom From and keeps the identity as envelope sender', () => { + expect(composeSenderAddress(identity, { fromOverride: override, customFromEnabled: true, subAddressTag: '' })).toEqual({ + from: { name: 'Support', email: 'support@example.org' }, + envelopeMailFrom: 'ada@example.org', + }); + }); + + it('sends from the identity when the organisation turned custom From off', () => { + expect(composeSenderAddress(identity, { fromOverride: override, customFromEnabled: false, subAddressTag: '' })).toEqual({ + from: { name: 'Ada Lovelace', email: 'ada@example.org' }, + envelopeMailFrom: undefined, + }); + }); + + it('keeps the sub-address tag when custom From is off', () => { + const { from, envelopeMailFrom } = composeSenderAddress(identity, { + fromOverride: override, customFromEnabled: false, subAddressTag: 'shop', subAddressDelimiter: '+', + }); + expect(from).toEqual({ name: 'Ada Lovelace', email: 'ada+shop@example.org' }); + expect(envelopeMailFrom).toBe('ada@example.org'); + }); +}); + +describe('activeFromOverride', () => { + it('drops an override when custom From is off', () => { + expect(activeFromOverride(override, true)).toBe(override); + expect(activeFromOverride(override, false)).toBeNull(); + expect(activeFromOverride(null, true)).toBeNull(); + }); +}); diff --git a/src/lib/compose-sender.ts b/src/lib/compose-sender.ts new file mode 100644 index 00000000..c55bfb86 --- /dev/null +++ b/src/lib/compose-sender.ts @@ -0,0 +1,52 @@ +import type { EmailAddress, Identity } from '../api/types'; +import { generateSubAddress } from './sub-addressing'; +import { sanitizeDisplayName } from './rfc5322-mailbox'; + +export interface FromOverride { + name: string; + email: string; +} + +/** + * The composer's custom From, or null when the organisation turned custom + * From off (`customFromEnabled`, webmail email-composer.tsx + * fromOverrideEnabled). An override set earlier, by hand or by the reply + * identity match, then no longer applies. + */ +export function activeFromOverride( + override: FromOverride | null, + customFromEnabled: boolean, +): FromOverride | null { + return customFromEnabled && override ? override : null; +} + +/** + * The From header and envelope sender of an outgoing message: the custom From + * when one applies, else the identity's address with its sub-address tag. A + * From that isn't the identity's own address still goes out through the + * identity's envelope sender. + */ +export function composeSenderAddress( + identity: Identity, + opts: { + fromOverride: FromOverride | null; + customFromEnabled: boolean; + subAddressTag: string; + subAddressDelimiter?: string; + }, +): { from: EmailAddress; envelopeMailFrom?: string } { + const override = activeFromOverride(opts.fromOverride, opts.customFromEnabled); + let from: EmailAddress; + if (override?.email.trim()) { + const overrideName = sanitizeDisplayName(override.name); + from = overrideName ? { name: overrideName, email: override.email.trim() } : { email: override.email.trim() }; + } else { + const name = sanitizeDisplayName(identity.name); + const email = opts.subAddressTag + ? generateSubAddress(identity.email, opts.subAddressTag, opts.subAddressDelimiter) + : identity.email; + from = name ? { name, email } : { email }; + } + const envelopeMailFrom = from.email.toLowerCase() !== identity.email.toLowerCase() ? identity.email : undefined; + return { from, envelopeMailFrom }; +} diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index 737e5c1f..e2a11735 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -117,7 +117,7 @@ import { import { generateSubAddress, extractDomain, suggestTagsForDomain, getTagValidationError, MAX_TAG_LENGTH, } from '../lib/sub-addressing'; -import { sanitizeDisplayName } from '../lib/rfc5322-mailbox'; +import { activeFromOverride, composeSenderAddress } from '../lib/compose-sender'; import type { EmailAddress, FileNode, Identity } from '../api/types'; import type { RootStackParamList } from '../navigation/types'; import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; @@ -1425,21 +1425,13 @@ export default function ComposeScreen({ route, navigation }: Props) { // ── Outgoing message assembly ───────────────────────────────────────── - const senderAddress = React.useCallback((identity: Identity): { from: EmailAddress; envelopeMailFrom?: string } => { - const name = sanitizeDisplayName(identity.name); - let from: EmailAddress; - if (fromOverride?.email.trim()) { - const overrideName = sanitizeDisplayName(fromOverride.name); - from = overrideName ? { name: overrideName, email: fromOverride.email.trim() } : { email: fromOverride.email.trim() }; - } else { - const email = subAddressTag ? generateSubAddress(identity.email, subAddressTag, subAddressDelimiter) : identity.email; - from = name ? { name, email } : { email }; - } - // A From that isn't the identity's own address still goes out through - // the identity's envelope sender. - const envelopeMailFrom = from.email.toLowerCase() !== identity.email.toLowerCase() ? identity.email : undefined; - return { from, envelopeMailFrom }; - }, [fromOverride, subAddressTag, subAddressDelimiter]); + // Without the organisation's custom From, an override set earlier no longer applies. + const senderAddress = React.useCallback( + (identity: Identity): { from: EmailAddress; envelopeMailFrom?: string } => + composeSenderAddress(identity, { fromOverride, customFromEnabled, subAddressTag, subAddressDelimiter }), + [fromOverride, customFromEnabled, subAddressTag, subAddressDelimiter], + ); + const shownFromOverride = activeFromOverride(fromOverride, customFromEnabled); const buildOutgoing = React.useCallback((identity: Identity, liveHtml: string, opts: { forDraft: boolean }): OutgoingEmail => { const { from, envelopeMailFrom } = senderAddress(identity); @@ -2361,9 +2353,9 @@ export default function ComposeScreen({ route, navigation }: Props) { const [overrideEmail, setOverrideEmail] = React.useState(''); const openFromOptions = () => { setTagDraft(subAddressTag); - setOverrideEnabled(!!fromOverride); - setOverrideName(fromOverride?.name ?? primaryIdentity?.name ?? ''); - setOverrideEmail(fromOverride?.email ?? primaryIdentity?.email ?? ''); + setOverrideEnabled(!!shownFromOverride); + setOverrideName(shownFromOverride?.name ?? primaryIdentity?.name ?? ''); + setOverrideEmail(shownFromOverride?.email ?? primaryIdentity?.email ?? ''); setFromOptionsOpen(true); }; const tagError = tagDraft ? getTagValidationError(tagDraft) : null; @@ -2376,7 +2368,7 @@ export default function ComposeScreen({ route, navigation }: Props) { return out.slice(0, 5); }, [finalTo]); const applyFromOptions = () => { - if (overrideEnabled) { + if (overrideEnabled && customFromEnabled) { const email = overrideEmail.trim(); if (!isValidEmail(email)) { Alert.alert(t('email_composer.from_override.email_label', 'From email address'), t('identities.form.email_invalid', 'Please enter a valid email address')); @@ -3147,7 +3139,7 @@ export default function ComposeScreen({ route, navigation }: Props) { disabled={identities.length <= 1 && !primaryIdentity} style={styles.fieldContent} > - + {fromDisplay} {identities.length > 1 && } @@ -3160,7 +3152,7 @@ export default function ComposeScreen({ route, navigation }: Props) { accessibilityRole="button" accessibilityLabel={t('email_composer.from_options', 'Sender options')} > - + )} @@ -3683,7 +3675,7 @@ export default function ComposeScreen({ route, navigation }: Props) { {t('email_composer.from', 'From')} - {!overrideEnabled && ( + {!(overrideEnabled && customFromEnabled) && ( <> {t('identities.sub_address.popover_title', 'Add Sub-Address Tag')} )} - - {t('email_composer.from_override.toggle_off', 'Override')} - - - {overrideEnabled && ( + {/* The organisation can turn custom From off (webmail email-composer.tsx customFromEnabled). */} + {customFromEnabled && ( + + {t('email_composer.from_override.toggle_off', 'Override')} + + + )} + {overrideEnabled && customFromEnabled && ( <> {t('email_composer.from_override.toggle_tooltip', 'Edit the From name and address freely. Mail is still sent through your identity - only the visible From header changes.')} Date: Mon, 2 Nov 2026 22:45:04 +0100 Subject: [PATCH 2/9] fix: keep remote content blocked until the webmail's config says third-party traffic is allowed --- src/stores/__tests__/policy-store.test.ts | 66 +++++++++++++++++- src/stores/policy-store.ts | 83 +++++++++++++++++------ 2 files changed, 128 insertions(+), 21 deletions(-) diff --git a/src/stores/__tests__/policy-store.test.ts b/src/stores/__tests__/policy-store.test.ts index 85bb7970..e5a5bcd8 100644 --- a/src/stores/__tests__/policy-store.test.ts +++ b/src/stores/__tests__/policy-store.test.ts @@ -27,7 +27,7 @@ import { useAuthStore } from '../auth-store'; import { useAccountStore, type AccountEntry } from '../account-store'; import { useSettingsStore, resetPolicyRulesForTests } from '../settings-store'; import { useToastStore } from '../toast-store'; -import { startPolicySync, usePolicyStore, settingKeyFor } from '../policy-store'; +import { noThirdPartyTraffic, startPolicySync, usePolicyStore, settingKeyFor } from '../policy-store'; const WEBMAIL = 'https://webmail.example.org'; const ADA = 'ada@example.com@mail.example.com'; @@ -81,7 +81,7 @@ beforeEach(() => { defaultAccountId: ADA, }); useAuthStore.setState({ activeAccountId: ADA, session: null }); - usePolicyStore.setState({ byAccount: {}, accountId: null, effective: null, links: {} }); + usePolicyStore.setState({ byAccount: {}, configByAccount: {}, accountId: null, effective: null, links: {}, configPending: true }); useSettingsStore.setState({ density: 'regular', markAsReadDelay: 0, explicitSettings: [], hydrated: true }); useToastStore.setState({ toasts: [] }); }); @@ -196,6 +196,68 @@ describe('startPolicySync', () => { }); }); +describe('no third-party traffic', () => { + const mode = () => noThirdPartyTraffic(usePolicyStore.getState()); + + it('counts as on until the config of the account\'s webmail is known', async () => { + usePolicyStore.getState().activate(ADA); + expect(mode()).toBe(true); + await usePolicyStore.getState().refresh(ADA); + expect(mode()).toBe(false); + }); + + it('keeps it on when the config says so', async () => { + configs[WEBMAIL] = { noThirdPartyTraffic: true }; + await usePolicyStore.getState().refresh(ADA); + expect(mode()).toBe(true); + }); + + it('is off for an account without a webmail', () => { + useAccountStore.getState().updateAccount(ADA, { webmailUrl: undefined }); + usePolicyStore.getState().activate(ADA); + expect(mode()).toBe(false); + }); + + it('uses the cached config on an offline start', async () => { + await usePolicyStore.getState().refresh(ADA); + usePolicyStore.setState({ links: {}, configPending: true }); + mockFetch.mockRejectedValue(new Error('Network request failed')); + usePolicyStore.getState().activate(ADA); + await usePolicyStore.getState().refresh(ADA); + expect(mode()).toBe(false); + expect(usePolicyStore.getState().links).toEqual({ helpUrl: 'https://help.example.org' }); + }); + + it('uses the links a policy cached by an earlier version holds', async () => { + await usePolicyStore.getState().refresh(ADA); + const cached = usePolicyStore.getState().byAccount[ADA]; + usePolicyStore.setState({ byAccount: { [ADA]: { ...cached, links: { noThirdPartyTraffic: true } } }, configByAccount: {} }); + usePolicyStore.getState().activate(ADA); + expect(mode()).toBe(true); + usePolicyStore.setState({ byAccount: { [ADA]: { ...cached, links: {} } } }); + usePolicyStore.getState().activate(ADA); + expect(mode()).toBe(false); + }); + + it('reads the config of a webmail without the policy route', async () => { + mockFetch.mockImplementation(async (url: string) => (url.endsWith('/api/config') + ? response(url, {}) + : ({ ok: false, status: 404, headers: { get: () => null } } as unknown as Response))); + usePolicyStore.getState().activate(ADA); + await usePolicyStore.getState().refresh(ADA); + expect(mode()).toBe(false); + }); + + it('stays on when only the policy answered', async () => { + mockFetch.mockImplementation(async (url: string) => (url.endsWith('/api/config') + ? Promise.reject(new Error('timeout')) + : response(url, policies[WEBMAIL]))); + await usePolicyStore.getState().refresh(ADA); + expect(usePolicyStore.getState().effective).not.toBeNull(); + expect(mode()).toBe(true); + }); +}); + describe('settingKeyFor', () => { it('maps the webmail names of settings to the app keys', () => { expect(settingKeyFor('firstDayOfWeek')).toBe('calendarFirstDayOfWeek'); diff --git a/src/stores/policy-store.ts b/src/stores/policy-store.ts index 50115421..78807fe4 100644 --- a/src/stores/policy-store.ts +++ b/src/stores/policy-store.ts @@ -47,12 +47,20 @@ export interface CachedPolicy { partial: boolean; fetchedAt: number; /** The webmail it came from; an account that moved to another drops it. */ + webmailUrl: string; + /** Only in policies cached before configByAccount; the config now has its own entry. */ + links?: WebmailLinks; +} + +/** The last answer of the webmail's /api/config, kept apart from the policy (an older webmail has no policy route). */ +export interface CachedConfig { webmailUrl: string; links: WebmailLinks; } interface PolicyState { byAccount: Record; + configByAccount: Record; /** The account `effective` was resolved for. */ accountId: string | null; /** The active account's policy with its scopes applied; null without a webmail. */ @@ -60,6 +68,12 @@ interface PolicyState { /** What `effective` does to the settings. */ rules: SettingsRules; links: WebmailLinks; + /** + * The active account has a webmail whose /api/config hasn't been read + * yet (nor cached). Its "no third-party traffic" mode counts as on + * meanwhile, as in the webmail (hooks/use-third-party-mode.ts). + */ + configPending: boolean; /** Resolve and apply the cached policy of this account (the active one). */ activate: (accountId: string | null) => void; @@ -195,10 +209,13 @@ export const usePolicyStore = create()( persist( (set, get) => ({ byAccount: {}, + configByAccount: {}, accountId: null, effective: null, rules: EMPTY_SETTINGS_RULES, links: {}, + // Until the cache is read and the active account known. + configPending: true, activate: (accountId) => { const account = accountId ? useAccountStore.getState().getAccountById(accountId) : undefined; @@ -207,7 +224,16 @@ export const usePolicyStore = create()( const usable = account?.webmailUrl && cached && cached.webmailUrl === account.webmailUrl ? cached : null; const effective = usable && account ? resolvePolicy(usable.policy, policySubjectFor(account)) : null; const rules = rulesFor(effective); - set({ accountId: accountId ?? null, effective, rules, links: usable?.links ?? {} }); + const config = accountId && account?.webmailUrl ? get().configByAccount[accountId] : undefined; + // A policy cached before the config had its own entry came with the config's links. + const links = config?.webmailUrl === account?.webmailUrl && config ? config.links : usable?.links; + set({ + accountId: accountId ?? null, + effective, + rules, + links: links ?? {}, + configPending: !!account?.webmailUrl && !links, + }); useSettingsStore.getState().applyPolicyRules(rules); }, @@ -220,21 +246,27 @@ export const usePolicyStore = create()( if (!account || !webmailUrl) return; const auth = await bearerFor(account); const [fetched, links] = await Promise.all([fetchOrgPolicy(webmailUrl, auth), fetchWebmailLinks(webmailUrl)]); - if (!fetched) return; // offline or no policy route: keep what is cached + // Offline: keep what is cached. A webmail without the policy route + // still has its config. + if (!fetched && !links) return; // Signed out of the account meanwhile. if (!useAccountStore.getState().getAccountById(accountId)) return; const previous = get().byAccount[accountId]; + const legacyLinks = previous?.webmailUrl === webmailUrl ? previous.links : undefined; set({ - byAccount: { - ...get().byAccount, - [accountId]: { - policy: fetched.policy, - partial: fetched.partial, - fetchedAt: Date.now(), - webmailUrl, - links: links ?? (previous?.webmailUrl === webmailUrl ? previous.links : {}), + ...(fetched ? { + byAccount: { + ...get().byAccount, + [accountId]: { policy: fetched.policy, partial: fetched.partial, fetchedAt: Date.now(), webmailUrl }, }, - }, + } : {}), + // Config links cached inside an older policy entry move to their own. + ...(links || (fetched && legacyLinks) ? { + configByAccount: { + ...get().configByAccount, + [accountId]: { webmailUrl, links: (links ?? legacyLinks)! }, + }, + } : {}), }); if (useAuthStore.getState().activeAccountId === accountId) get().activate(accountId); })().finally(() => refreshes.delete(accountId)); @@ -244,15 +276,18 @@ export const usePolicyStore = create()( prune: (accountIds) => { const keep = new Set(accountIds); - const current = get().byAccount; - if (Object.keys(current).every((id) => keep.has(id))) return; - set({ byAccount: Object.fromEntries(Object.entries(current).filter(([id]) => keep.has(id))) }); + const { byAccount, configByAccount } = get(); + if ([...Object.keys(byAccount), ...Object.keys(configByAccount)].every((id) => keep.has(id))) return; + set({ + byAccount: Object.fromEntries(Object.entries(byAccount).filter(([id]) => keep.has(id))), + configByAccount: Object.fromEntries(Object.entries(configByAccount).filter(([id]) => keep.has(id))), + }); }, }), { name: 'org-policy-cache', storage: createPersistStorage(), - partialize: (state) => ({ byAccount: state.byAccount }) as unknown as PolicyState, + partialize: (state) => ({ byAccount: state.byAccount, configByAccount: state.configByAccount }) as unknown as PolicyState, }, ), ); @@ -270,10 +305,15 @@ export function useSettingLocked(key: string): boolean { /** * 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. + * loads, whatever the setting or a trusted sender says. On while the + * config is still unknown, so nothing reaches out before it says it may. */ +export function noThirdPartyTraffic(state: Pick): boolean { + return state.configPending || state.links.noThirdPartyTraffic === true; +} + export function useNoThirdPartyTraffic(): boolean { - return usePolicyStore((s) => s.links.noThirdPartyTraffic === true); + return usePolicyStore(noThirdPartyTraffic); } /** Whether the active account's policy hides a setting (by the app's key). */ @@ -301,7 +341,8 @@ export function startPolicySync(): () => void { }); }); - let activeId: string | null = null; + // Undefined until the first activate, which also settles configPending without an account. + let activeId: string | null | undefined; let fetchedFor: string | null = null; const follow = () => { const auth = useAuthStore.getState(); @@ -337,7 +378,11 @@ export function startPolicySync(): () => void { const before = id ? prev.accounts.find((a) => a.id === id) : undefined; if (id && now?.webmailUrl && now.webmailUrl !== before?.webmailUrl) { fetchedFor = null; - whenHydrated(follow); + // Its config is unknown until the fetch answers. + whenHydrated(() => { + usePolicyStore.getState().activate(id); + follow(); + }); } }); From f173358e86818155e1723a050aec26fede38543e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:45:04 +0100 Subject: [PATCH 3/9] fix: show no sender favicons when the webmail allows no third-party traffic --- src/components/SenderAvatar.tsx | 7 ++++++- .../__tests__/custom-from-third-party-wiring.test.ts | 6 ++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/components/SenderAvatar.tsx b/src/components/SenderAvatar.tsx index 618dada8..0bf36f7b 100644 --- a/src/components/SenderAvatar.tsx +++ b/src/components/SenderAvatar.tsx @@ -18,6 +18,7 @@ import { useContactPhotoSource } from '../lib/use-contact-photo'; import { useSettingsStore } from '../stores/settings-store'; import { useContactsStore } from '../stores/contacts-store'; import { useAccountStore } from '../stores/account-store'; +import { useNoThirdPartyTraffic } from '../stores/policy-store'; import { useColors } from '../theme/colors'; import { ShieldAlert } from './icons'; @@ -61,6 +62,10 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma const c = useColors(); const warned = !!senderWarning; const senderFavicons = useSettingsStore((s) => s.senderFavicons) && !disableImages && !warned; + // Favicons come from an outside host (DuckDuckGo), which the webmail's + // "no third-party traffic" mode rules out (webmail lib/outgoing-hosts.ts). + // BIMI logos come from the webmail itself. + const noThirdParty = useNoThirdPartyTraffic(); // Compared shallowly, so only avatars whose photo changed re-render when the // contacts change; the index behind it is built once per contacts array. const indexedPhoto = useContactsStore( @@ -94,7 +99,7 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma senderWarning, hasPhoto: !disableImages && !!photoSource, showBrand, - showFavicon: senderFavicons && !!faviconDomain && !imgError && !domainFailed, + showFavicon: senderFavicons && !noThirdParty && !!faviconDomain && !imgError && !domainFailed, }); const handleError = React.useCallback(() => { diff --git a/src/components/__tests__/custom-from-third-party-wiring.test.ts b/src/components/__tests__/custom-from-third-party-wiring.test.ts index 4df4945a..7235a859 100644 --- a/src/components/__tests__/custom-from-third-party-wiring.test.ts +++ b/src/components/__tests__/custom-from-third-party-wiring.test.ts @@ -54,6 +54,12 @@ describe('no third-party traffic', () => { expect(src).toMatch(/t\('third_party\.remote_content_blocked'/); }); + it('shows no sender favicons from the outside host', () => { + const src = read('components/SenderAvatar.tsx'); + expect(src).toMatch(/const noThirdParty = useNoThirdPartyTraffic\(\);/); + expect(src).toMatch(/showFavicon: senderFavicons && !noThirdParty && /); + }); + 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'); From 762ec6bab063f218c86926bc2d409086d9b5d4a1 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:50:45 +0100 Subject: [PATCH 4/9] fix: leave archive off the android widget when the organisation turns archiving off --- src/widgets/__tests__/actions.test.ts | 7 +++ src/widgets/__tests__/archive-policy.test.ts | 50 ++++++++++++++++++++ src/widgets/__tests__/layouts.test.ts | 14 ++++++ src/widgets/actions.ts | 8 +++- src/widgets/build.ts | 26 ++++++++++ src/widgets/layouts/mail.tsx | 14 ++++-- src/widgets/snapshot.ts | 5 ++ 7 files changed, 118 insertions(+), 6 deletions(-) create mode 100644 src/widgets/__tests__/archive-policy.test.ts diff --git a/src/widgets/__tests__/actions.test.ts b/src/widgets/__tests__/actions.test.ts index a6a7ff7d..8a436e21 100644 --- a/src/widgets/__tests__/actions.test.ts +++ b/src/widgets/__tests__/actions.test.ts @@ -178,4 +178,11 @@ describe('widget archive', () => { calls.moves[0].resolve(true); await tap; }); + + it('does nothing for a tap on an older drawing once the organisation turned archiving off', async () => { + await saveSnapshot({ ...snapshotOf(server.inbox), archiveEnabled: false }); + await handleWidgetAction('archive', { id: 'm1', accountId: 'acc' }, 7); + expect(calls.moves).toEqual([]); + expect(await shown()).toEqual(['m1', 'm2', 'm3']); + }); }); diff --git a/src/widgets/__tests__/archive-policy.test.ts b/src/widgets/__tests__/archive-policy.test.ts new file mode 100644 index 00000000..04411c14 --- /dev/null +++ b/src/widgets/__tests__/archive-policy.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { readArchiveEnabled } from '../build'; + +// The organisation's archiveActionEnabled gate reaches the widgets through +// the policy the app cached (stores/policy-store.ts, key org-policy-cache). + +const WEBMAIL = 'https://webmail.example.org'; +const account = { id: 'acc', email: 'ada@example.org', webmailUrl: WEBMAIL }; + +function cache(features: Record, webmailUrl = WEBMAIL, scopes: unknown[] = []) { + return AsyncStorage.setItem('org-policy-cache', JSON.stringify({ + state: { + byAccount: { + acc: { + policy: { restrictions: {}, features, defaults: {}, themePolicy: {}, scopes }, + partial: false, + fetchedAt: 1, + webmailUrl, + }, + }, + }, + version: 0, + })); +} + +beforeEach(async () => { + await AsyncStorage.clear(); +}); + +describe('readArchiveEnabled', () => { + it('is off when the cached policy turns archiving off', async () => { + await cache({ archiveActionEnabled: false }); + expect(await readArchiveEnabled(account)).toBe(false); + }); + + it('is on when the policy allows it, without a policy and without a webmail', async () => { + expect(await readArchiveEnabled(account)).toBe(true); + await cache({ archiveActionEnabled: true }); + expect(await readArchiveEnabled(account)).toBe(true); + await cache({ archiveActionEnabled: false }); + expect(await readArchiveEnabled({ ...account, webmailUrl: undefined })).toBe(true); + expect(await readArchiveEnabled(undefined)).toBe(true); + }); + + it('ignores a policy cached from another webmail', async () => { + await cache({ archiveActionEnabled: false }, 'https://elsewhere.example.org'); + expect(await readArchiveEnabled(account)).toBe(true); + }); +}); diff --git a/src/widgets/__tests__/layouts.test.ts b/src/widgets/__tests__/layouts.test.ts index 00033fdd..74f2d19a 100644 --- a/src/widgets/__tests__/layouts.test.ts +++ b/src/widgets/__tests__/layouts.test.ts @@ -159,6 +159,20 @@ describe('widget layouts', () => { for (const w of catalog.widgets) build(w.name, { ...sample, images }, w.cols, w.rows); }); + it('leaves Archive off the triage card when the organisation turned archiving off', () => { + const actions = (s: WidgetSnapshot, cols: number) => { + const trees = renderFor('TriageWidget', s, { width: dp(cols), height: dp(2), widgetId: 1 }, {}, PREVIEW_NOW); + const tree = 'light' in trees ? trees.light : trees; + return [...JSON.stringify(buildWidgetTree(tree)).matchAll(/"clickAction":"(\w+)"/g)].map((m) => m[1]); + }; + expect(actions(sample, 4)).toContain('archive'); + for (const cols of [3, 4, 5]) { + const shown = actions({ ...sample, archiveEnabled: false }, cols); + expect(shown).not.toContain('archive'); + expect(shown).toContain('trash'); + } + }); + it('builds the triage card on a message that is gone', () => { build('TriageWidget', sample, 4, 2, { triageId: 'no-such-message' }); build('TriageWidget', { ...sample, mail: { ...sample.mail, inbox: [] } }, 4, 2, { triageId: 'm1' }); diff --git a/src/widgets/actions.ts b/src/widgets/actions.ts index b9ffea6a..d514122e 100644 --- a/src/widgets/actions.ts +++ b/src/widgets/actions.ts @@ -94,7 +94,13 @@ export async function handleWidgetAction(name: string, data: Data, widgetId: num case 'markRead': { const id = str(data.id); if (!id) return; - const subject = (await currentView()).mail.inbox.find((m) => m.id === id)?.subject ?? ''; + const view = await currentView(); + // A drawing from before the organisation turned archiving off. + if (name === 'archive' && view.archiveEnabled === false) { + await redrawAll(); + return; + } + const subject = view.mail.inbox.find((m) => m.id === id)?.subject ?? ''; // The triage card moves on to the next unread message, not back to // the first one. if (name !== 'markRead') await moveTriagePointer(widgetId, id); diff --git a/src/widgets/build.ts b/src/widgets/build.ts index ed5478f5..7c0ae1e1 100644 --- a/src/widgets/build.ts +++ b/src/widgets/build.ts @@ -16,6 +16,7 @@ import { getEventEndDate, getEventStartDate } from '../lib/calendar-utils'; import { getUserParticipantId, getUserStatus } from '../lib/calendar-participants'; import { getBirthday, getContactDisplayName, getDateParts } from '../lib/contact-utils'; import { notificationLocale } from '../lib/push-background-task'; +import { isFeatureEnabled, resolvePolicy, sanitizePolicy } from '../lib/org-policy'; import { useSettingsStore } from '../stores/settings-store'; import { useKeywordsStore, keywordToken } from '../stores/keywords-store'; import { LIGHT_COLORS } from '../theme/tokens'; @@ -46,6 +47,30 @@ interface RegistryAccount { displayName?: string; username?: string; avatarColor?: string; + webmailUrl?: string; +} + +/** + * Whether the account's organisation lets it archive (policy + * archiveActionEnabled), from the policy the app cached for it + * (stores/policy-store.ts, which this runtime may not have loaded). On + * without a webmail or a cached policy from it. + */ +export async function readArchiveEnabled(account: RegistryAccount | undefined): Promise { + if (!account?.webmailUrl) return true; + try { + const raw = await AsyncStorage.getItem('org-policy-cache'); + const byAccount = raw + ? (JSON.parse(raw) as { state?: { byAccount?: Record } }).state?.byAccount + : undefined; + const cached = byAccount?.[account.id]; + const policy = cached?.webmailUrl === account.webmailUrl ? sanitizePolicy(cached.policy) : null; + if (!policy) return true; + const email = (account.email || account.username || '').trim().toLowerCase(); + return isFeatureEnabled(resolvePolicy(policy, { email, groups: [] }), 'archiveActionEnabled'); + } catch { + return true; + } } export async function readRegistry(): Promise<{ accounts: RegistryAccount[]; activeAccountId: string | null }> { @@ -390,6 +415,7 @@ async function buildSnapshot(previous: WidgetSnapshot): Promise hour12: timeFormat === '12h', weekStart: calendarFirstDayOfWeek === 0 ? 0 : 1, activeAccountId, + archiveEnabled: await readArchiveEnabled(accounts.find((a) => a.id === activeAccountId)), }; if (previous.activeAccountId !== activeAccountId) { // Nothing of another account's data may linger in a widget. diff --git a/src/widgets/layouts/mail.tsx b/src/widgets/layouts/mail.tsx index ff925653..4af81a33 100644 --- a/src/widgets/layouts/mail.tsx +++ b/src/widgets/layouts/mail.tsx @@ -112,9 +112,11 @@ export const TriageLayout: Layout = ({ s, p, f, now, width, height, widgetId, lo trash: f.t('widgets.mail.delete', 'Delete'), reply: f.t('widgets.mail.reply', 'Reply'), }; - const room = width - 2 - 24 - 32 - 18; - const allFit = buttonWidth(labels.archive) + buttonWidth(labels.trash) + buttonWidth(labels.reply) <= room; - const replyFits = 2 * 32 + buttonWidth(labels.reply) <= room; + // No Archive button when the organisation turned archiving off. + const archive = s.archiveEnabled !== false; + const room = width - 2 - 24 - 32 - (archive ? 18 : 12); + const allFit = (archive ? buttonWidth(labels.archive) : 0) + buttonWidth(labels.trash) + buttonWidth(labels.reply) <= room; + const replyFits = (archive ? 2 : 1) * 32 + buttonWidth(labels.reply) <= room; return ( // The buttons draw 32dp tall in a 48dp tap area (slop 8), which takes the // gap above them and 8dp of the bottom padding. @@ -146,8 +148,10 @@ export const TriageLayout: Layout = ({ s, p, f, now, width, height, widgetId, lo {notice ? : null} -