From b7b3dd4c4d08777462299a13350ff7148361b496 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:22:15 +0100 Subject: [PATCH 1/9] feat: show the colour mode as set by the organisation when its policy locks it --- .../settings/AppearanceSettings.tsx | 5 ++++- .../__tests__/org-policy-settings.test.ts | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 src/components/settings/__tests__/org-policy-settings.test.ts diff --git a/src/components/settings/AppearanceSettings.tsx b/src/components/settings/AppearanceSettings.tsx index ed48a426..c08e9a82 100644 --- a/src/components/settings/AppearanceSettings.tsx +++ b/src/components/settings/AppearanceSettings.tsx @@ -7,6 +7,7 @@ import { useSettingsStore, type ThemeMode, type Density } from '../../stores/set import { useLocaleStore } from '../../stores/locale-store'; import { setPendingSettingsTab } from '../../navigation/pending-settings-tab'; import { densityPreviewConfig } from './density-preview'; +import { useSettingLocked } from '../../stores/policy-store'; type Theme = ThemeMode; @@ -81,6 +82,8 @@ export function AppearanceSettings() { const theme = useSettingsStore((s) => s.theme); const t = useLocaleStore((s) => s.t); const setTheme = useSettingsStore((s) => s.setTheme); + // The organisation's theme policy can pin the colour mode (#1280). + const themeLocked = useSettingLocked('theme'); const density = useSettingsStore((s) => s.density); const setDensity = useSettingsStore((s) => s.setDensity); const showToolbarLabels = useSettingsStore((s) => s.showToolbarLabels); @@ -99,7 +102,7 @@ export function AppearanceSettings() { title={t('settings.appearance.title', "Appearance")} description={t('settings.appearance.description_mobile', 'Customize how the app looks')} > - + setTheme(v as Theme)} diff --git a/src/components/settings/__tests__/org-policy-settings.test.ts b/src/components/settings/__tests__/org-policy-settings.test.ts new file mode 100644 index 00000000..353c9e79 --- /dev/null +++ b/src/components/settings/__tests__/org-policy-settings.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +// Where the organisation policy reaches the settings pages. No React Native +// renderer in the test env, so this checks the wiring (as +// src/components/__tests__/org-policy-wiring.test.ts does); what the policy +// does to the settings is tested in org-policy.test.ts and policy-store.test.ts. + +const read = (rel: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', '..', '..', rel), 'utf8'); +const settings = (name: string) => read(`src/components/settings/${name}.tsx`); + +describe('organisation policy on the settings pages', () => { + it('shows the colour mode as set by the organisation when its theme policy locks it', () => { + const page = settings('AppearanceSettings'); + expect(page).toContain("const themeLocked = useSettingLocked('theme');"); + expect(page).toMatch(/label=\{t\('settings\.appearance\.theme\.label', "Theme"\)\}[^>]*locked=\{themeLocked\}>/); + }); +}); From a69bc071d16f44d06389819168a9d31e4c84e495 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:24:16 +0100 Subject: [PATCH 2/9] feat: hide the themes the organisation turns off and keep a locked theme in place --- src/components/settings/ThemesSettings.tsx | 31 ++++++++++++++++--- .../__tests__/org-policy-settings.test.ts | 10 ++++++ .../settings/__tests__/theme-cards.test.ts | 25 +++++++++++++++ src/components/settings/theme-cards.ts | 16 ++++++++++ 4 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 src/components/settings/__tests__/theme-cards.test.ts create mode 100644 src/components/settings/theme-cards.ts diff --git a/src/components/settings/ThemesSettings.tsx b/src/components/settings/ThemesSettings.tsx index 37383f01..957fe01f 100644 --- a/src/components/settings/ThemesSettings.tsx +++ b/src/components/settings/ThemesSettings.tsx @@ -7,6 +7,8 @@ import { resolvePalette, useColors, useResolvedTheme } from '../../theme/colors' import { BUILTIN_THEMES } from '../../theme/builtin-themes'; import { useSettingsStore } from '../../stores/settings-store'; import { useLocaleStore } from '../../stores/locale-store'; +import { usePolicyStore, useSettingLocked } from '../../stores/policy-store'; +import { visibleThemeCards } from './theme-cards'; interface ThemeCard { id: string | null; @@ -28,19 +30,23 @@ export function ThemesSettings() { const hydrate = useSettingsStore((s) => s.hydrate); const active = useSettingsStore((s) => s.activeThemeId); const update = useSettingsStore((s) => s.updateSetting); + // The organisation can turn built-in themes off, and pin one (or the + // default, with themes turned off) for everyone (webmail themes-settings.tsx). + const policy = usePolicyStore((s) => s.effective); + const themeLocked = useSettingLocked('activeThemeId'); useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]); - const cards: ThemeCard[] = [ + const cards = React.useMemo(() => visibleThemeCards([ { id: null, name: t('settings.themes.default_name', 'Bulwark'), description: t('settings.themes.default_description', 'The default light and dark palettes.'), }, ...BUILTIN_THEMES.map((theme) => ({ id: theme.id, name: theme.name, description: theme.description })), - ]; + ], policy), [t, policy]); return ( + {themeLocked && ( + + {t('settings.themes.locked', 'Theme selection is locked by an administrator.')} + + )} {cards.map((card) => { const isActive = (active ?? null) === card.id; @@ -58,10 +69,11 @@ export function ThemesSettings() { update('activeThemeId', card.id)} + disabled={themeLocked} accessibilityRole="radio" - accessibilityState={{ selected: isActive, checked: isActive }} + accessibilityState={{ selected: isActive, checked: isActive, disabled: themeLocked }} accessibilityLabel={card.name} - style={[styles.card, isActive && styles.cardActive]} + style={[styles.card, isActive && styles.cardActive, themeLocked && !isActive && styles.cardDisabled]} > @@ -112,6 +124,17 @@ function makeStyles(c: ThemePalette) { borderColor: c.primary, backgroundColor: c.primaryBg, }, + cardDisabled: { opacity: 0.6 }, + lockedNote: { + marginBottom: spacing.md, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: radius.md, + borderWidth: 1, + borderColor: c.warning, + backgroundColor: c.warningBg, + }, + lockedNoteText: { ...typography.caption, color: c.text }, preview: { width: '100%', aspectRatio: 16 / 10, diff --git a/src/components/settings/__tests__/org-policy-settings.test.ts b/src/components/settings/__tests__/org-policy-settings.test.ts index 353c9e79..9f5eb132 100644 --- a/src/components/settings/__tests__/org-policy-settings.test.ts +++ b/src/components/settings/__tests__/org-policy-settings.test.ts @@ -16,4 +16,14 @@ describe('organisation policy on the settings pages', () => { expect(page).toContain("const themeLocked = useSettingLocked('theme');"); expect(page).toMatch(/label=\{t\('settings\.appearance\.theme\.label', "Theme"\)\}[^>]*locked=\{themeLocked\}>/); }); + + it('hides the themes the policy turns off and keeps the grid still while the theme is locked', () => { + const page = settings('ThemesSettings'); + expect(page).toContain('const policy = usePolicyStore((s) => s.effective);'); + expect(page).toMatch(/visibleThemeCards\(\[[\s\S]*\], policy\), \[t, policy\]\)/); + expect(page).toContain('{cards.map((card) => {'); + expect(page).toContain("const themeLocked = useSettingLocked('activeThemeId');"); + expect(page).toContain('disabled={themeLocked}'); + expect(page).toContain("{themeLocked && ("); + }); }); diff --git a/src/components/settings/__tests__/theme-cards.test.ts b/src/components/settings/__tests__/theme-cards.test.ts new file mode 100644 index 00000000..fe5070a1 --- /dev/null +++ b/src/components/settings/__tests__/theme-cards.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { visibleThemeCards } from '../theme-cards'; +import { sanitizePolicy } from '../../../lib/org-policy'; + +const CARDS = [ + { id: null, name: 'Bulwark' }, + { id: 'nord', name: 'Nord' }, + { id: 'solarized', name: 'Solarized' }, +]; + +describe('visibleThemeCards', () => { + it('offers every card without a policy', () => { + expect(visibleThemeCards(CARDS, null).map((c) => c.id)).toEqual([null, 'nord', 'solarized']); + }); + + it('leaves out the built-in themes the policy turns off', () => { + const policy = sanitizePolicy({ themePolicy: { disabledBuiltinThemes: ['nord'] } }); + expect(visibleThemeCards(CARDS, policy).map((c) => c.id)).toEqual([null, 'solarized']); + }); + + it('keeps the default card whatever the policy lists', () => { + const policy = sanitizePolicy({ themePolicy: { disabledBuiltinThemes: ['nord', 'solarized', 'null', ''] } }); + expect(visibleThemeCards(CARDS, policy).map((c) => c.id)).toEqual([null]); + }); +}); diff --git a/src/components/settings/theme-cards.ts b/src/components/settings/theme-cards.ts new file mode 100644 index 00000000..0fed0eac --- /dev/null +++ b/src/components/settings/theme-cards.ts @@ -0,0 +1,16 @@ +import type { OrgPolicy } from '../../lib/org-policy'; + +/** + * The theme cards the Themes page offers: every card but the built-in themes + * the organisation's policy turns off (webmail themes-settings.tsx filters + * them with isThemeDisabled). The default card (id null) always stays, so + * there is something to pick. + */ +export function visibleThemeCards( + cards: readonly T[], + policy: OrgPolicy | null | undefined, +): T[] { + const disabled = policy?.themePolicy.disabledBuiltinThemes ?? []; + if (disabled.length === 0) return [...cards]; + return cards.filter((card) => card.id === null || !disabled.includes(card.id)); +} From 8ba2f27af24908926b2f5f69b4babc2e18bb1bca Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:25:37 +0100 Subject: [PATCH 3/9] feat: hide calendar tasks in settings when the organisation turns them off --- src/components/settings/CalendarSettings.tsx | 28 ++++++++++++------- .../__tests__/org-policy-settings.test.ts | 8 ++++++ 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/components/settings/CalendarSettings.tsx b/src/components/settings/CalendarSettings.tsx index 5d575c54..592db790 100644 --- a/src/components/settings/CalendarSettings.tsx +++ b/src/components/settings/CalendarSettings.tsx @@ -7,6 +7,7 @@ import { type TimeFormat, } from '../../stores/settings-store'; import { useLocaleStore } from '../../stores/locale-store'; +import { usePolicyFeature, useSettingLocked } from '../../stores/policy-store'; import { AUTO_TIME_ZONE, getDeviceTimeZone, isValidTimeZone } from '../../lib/calendar-timezone'; import { deviceSyncAvailable } from '../../device-sync/app/available'; import { CALENDAR_AUTHORITY } from '../../device-sync/types'; @@ -49,6 +50,10 @@ export function CalendarSettings() { const birthdayCal = useSettingsStore((s) => s.showBirthdayCalendar); const tasksEnabled = useSettingsStore((s) => s.enableCalendarTasks); const showTasksOnCal = useSettingsStore((s) => s.showTasksOnCalendar); + // An organisation can turn tasks off: the option goes, as in the webmail + // (calendar-settings.tsx), and the policy pins it off. + const tasksAllowed = usePolicyFeature('calendarTasksEnabled'); + const tasksLocked = useSettingLocked('enableCalendarTasks'); // Android with the native module only (#34). const deviceSync = React.useMemo(() => deviceSyncAvailable(), []); @@ -185,17 +190,20 @@ export function CalendarSettings() { /> - - update('enableCalendarTasks', v)} - /> - + {tasksAllowed && ( + + update('enableCalendarTasks', v)} + /> + + )} - {tasksEnabled && ( + {tasksAllowed && tasksEnabled && ( { expect(page).toContain('disabled={themeLocked}'); expect(page).toContain("{themeLocked && ("); }); + + it('hides the tasks option when the organisation turns tasks off, and marks it locked', () => { + const page = settings('CalendarSettings'); + expect(page).toContain("const tasksAllowed = usePolicyFeature('calendarTasksEnabled');"); + expect(page).toContain("const tasksLocked = useSettingLocked('enableCalendarTasks');"); + expect(page).toMatch(/\{tasksAllowed && \(\s* Date: Sat, 31 Oct 2026 15:26:45 +0100 Subject: [PATCH 4/9] feat: hide or lock the external content option as the organisation sets it --- .../settings/ContentSendersSettings.tsx | 38 ++++++++++++------- .../__tests__/org-policy-settings.test.ts | 9 +++++ 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/src/components/settings/ContentSendersSettings.tsx b/src/components/settings/ContentSendersSettings.tsx index e33824c4..95d0e518 100644 --- a/src/components/settings/ContentSendersSettings.tsx +++ b/src/components/settings/ContentSendersSettings.tsx @@ -13,6 +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 { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; import { useSheetBottomInset } from '../../lib/sheet-bottom-inset'; import { ToastHost } from '../ToastHost'; @@ -31,6 +32,12 @@ export function ContentSendersSettings() { const t = useLocaleStore((s) => s.t); const externalContentPolicy = useSettingsStore((s) => s.externalContentPolicy); const setExternalContentPolicy = useSettingsStore((s) => s.setExternalContentPolicy); + // The organisation can take the option away or pin it (webmail + // reading-settings.tsx). + const externalContentAllowed = usePolicyFeature('externalContentEnabled'); + const externalContentHidden = useSettingHidden('externalContentPolicy'); + const externalContentLocked = useSettingLocked('externalContentPolicy'); + const showExternalContent = externalContentAllowed && !externalContentHidden; const emailAlwaysLightMode = useSettingsStore((s) => s.emailAlwaysLightMode); const setEmailAlwaysLightMode = useSettingsStore((s) => s.setEmailAlwaysLightMode); const trustedSenders = useSettingsStore((s) => s.trustedSenders); @@ -134,20 +141,23 @@ export function ContentSendersSettings() { title={t('settings.tabs.content_senders', 'Content & Senders')} description={t('settings.email_behavior.content_senders_description_mobile', 'Control how external resources and tracking pixels are handled.')} > - - setExternalContentPolicy(v as ExternalContentPolicy)} - options={[ - { value: 'ask', label: t('settings.email_behavior.external_content.ask_mobile', 'Ask') }, - { value: 'block', label: t('settings.email_behavior.external_content.block_mobile', 'Block') }, - { value: 'allow', label: t('settings.email_behavior.external_content.allow_mobile', 'Allow') }, - ]} - /> - + {showExternalContent && ( + + setExternalContentPolicy(v as ExternalContentPolicy)} + options={[ + { value: 'ask', label: t('settings.email_behavior.external_content.ask_mobile', 'Ask') }, + { value: 'block', label: t('settings.email_behavior.external_content.block_mobile', 'Block') }, + { value: 'allow', label: t('settings.email_behavior.external_content.allow_mobile', 'Allow') }, + ]} + /> + + )} { expect(page).toMatch(/\{tasksAllowed && \(\s* { + const page = settings('ContentSendersSettings'); + 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* Date: Sat, 31 Oct 2026 15:30:15 +0100 Subject: [PATCH 5/9] feat: leave out the settings pages the organisation turns off --- .../settings/__tests__/policy-pages.test.ts | 56 +++++++++++++++++++ src/components/settings/policy-pages.ts | 42 ++++++++++++++ src/screens/SettingsScreen.tsx | 7 ++- 3 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 src/components/settings/__tests__/policy-pages.test.ts create mode 100644 src/components/settings/policy-pages.ts diff --git a/src/components/settings/__tests__/policy-pages.test.ts b/src/components/settings/__tests__/policy-pages.test.ts new file mode 100644 index 00000000..2eb9d0b1 --- /dev/null +++ b/src/components/settings/__tests__/policy-pages.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { policyHiddenPages } from '../policy-pages'; +import { sanitizePolicy } from '../../../lib/org-policy'; + +const screen = fs.readFileSync(path.resolve(__dirname, '..', '..', '..', 'screens', 'SettingsScreen.tsx'), 'utf8'); + +describe('policyHiddenPages', () => { + it('leaves every page without a policy or with the default gates', () => { + expect(policyHiddenPages(null)).toEqual([]); + expect(policyHiddenPages(sanitizePolicy({}))).toEqual([]); + }); + + it('takes away the pages of the gates the policy turns off', () => { + const policy = sanitizePolicy({ + features: { + templatesEnabled: false, + customKeywordsEnabled: false, + sidebarAppsEnabled: false, + themesEnabled: false, + calendarEnabled: false, + contactsEnabled: false, + filesEnabled: false, + }, + }); + expect(policyHiddenPages(policy).sort()).toEqual( + ['calendar', 'contacts', 'files', 'keywords', 'sidebar_apps', 'templates', 'themes'], + ); + }); + + it('takes away Delegates and Recover deleted items when the policy hides them', () => { + const policy = sanitizePolicy({ + restrictions: { delegation: { hidden: true }, deletedItemsRecovery: { hidden: true } }, + }); + expect(policyHiddenPages(policy).sort()).toEqual(['recovery', 'sharing']); + const locked = sanitizePolicy({ restrictions: { delegation: { locked: true } } }); + expect(policyHiddenPages(locked)).toEqual([]); + }); + + it('names pages the Settings list has', () => { + const policy = sanitizePolicy({ + features: Object.fromEntries(['templatesEnabled', 'customKeywordsEnabled', 'sidebarAppsEnabled', 'themesEnabled', 'calendarEnabled', 'contactsEnabled', 'filesEnabled'].map((g) => [g, false])), + restrictions: { delegation: { hidden: true }, deletedItemsRecovery: { hidden: true } }, + }); + for (const page of policyHiddenPages(policy)) expect(screen).toContain(`{ id: '${page}',`); + }); +}); + +describe('Settings list', () => { + it('leaves out the pages the active policy takes away', () => { + expect(screen).toContain('const policy = usePolicyStore((s) => s.effective);'); + expect(screen).toContain('for (const page of policyHiddenPages(policy)) set.add(page as Tab);'); + expect(screen).toContain('}, [recoveryAvailable, delegationAvailable, policy]);'); + }); +}); diff --git a/src/components/settings/policy-pages.ts b/src/components/settings/policy-pages.ts new file mode 100644 index 00000000..29bc9997 --- /dev/null +++ b/src/components/settings/policy-pages.ts @@ -0,0 +1,42 @@ +import { isFeatureEnabled, type FeatureGate, type OrgPolicy } from '../../lib/org-policy'; + +/** + * Settings pages a feature gate takes away, as the webmail's settings-app.tsx + * leaves their tabs out: Templates (templatesEnabled, in Composing there), + * Keywords (customKeywordsEnabled: Organize turns into Folders), Sidebar apps + * (sidebarAppsEnabled: the app has no plugins, which keep Add-ons there), + * Themes (themesEnabled, appearance-settings.tsx), and Calendar, Contacts and + * Files with their apps. + */ +const GATED_PAGES: ReadonlyArray<[FeatureGate, string]> = [ + ['templatesEnabled', 'templates'], + ['customKeywordsEnabled', 'keywords'], + ['sidebarAppsEnabled', 'sidebar_apps'], + ['themesEnabled', 'themes'], + ['calendarEnabled', 'calendar'], + ['contactsEnabled', 'contacts'], + ['filesEnabled', 'files'], +]; + +/** + * Pages a hidden restriction takes away: Delegates (`delegation`, webmail + * settings-app supportsSharing) and Recover deleted items + * (`deletedItemsRecovery`, webmail hooks/use-deleted-items-recovery.ts). + */ +const HIDDEN_PAGES: ReadonlyArray<[string, string]> = [ + ['delegation', 'sharing'], + ['deletedItemsRecovery', 'recovery'], +]; + +/** The settings pages the active account's policy leaves out, by page id. */ +export function policyHiddenPages(policy: OrgPolicy | null | undefined): string[] { + if (!policy) return []; + const out: string[] = []; + for (const [gate, page] of GATED_PAGES) { + if (!isFeatureEnabled(policy, gate)) out.push(page); + } + for (const [key, page] of HIDDEN_PAGES) { + if (policy.restrictions[key]?.hidden === true) out.push(page); + } + return out; +} diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index a1463bcd..6a5a5fd9 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -54,6 +54,8 @@ import { useAuthStore } from '../stores/auth-store'; import { useManagedAccountStore } from '../stores/managed-account-store'; import { useAccountStore } from '../stores/account-store'; import { useSettingsStore } from '../stores/settings-store'; +import { usePolicyStore } from '../stores/policy-store'; +import { policyHiddenPages } from '../components/settings/policy-pages'; import { useUpdatesStore } from '../stores/updates-store'; import { SUPPORTED_LOCALES } from '../i18n'; import { settingsSummary, type SummaryState } from '../lib/settings-summary'; @@ -231,12 +233,15 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin // list, not shown as "Not on this server", where the server lacks them. const recoveryAvailable = useDeletedItemsRecovery().available; const delegationAvailable = useDelegationAvailable(); + // Pages the organisation's policy takes away (feature gates, hidden pages). + const policy = usePolicyStore((s) => s.effective); const hiddenTabs = React.useMemo(() => { const set = new Set(); if (!recoveryAvailable) set.add('recovery'); if (!delegationAvailable) set.add('sharing'); + for (const page of policyHiddenPages(policy)) set.add(page as Tab); return set; - }, [recoveryAvailable, delegationAvailable]); + }, [recoveryAvailable, delegationAvailable, policy]); // A shared/group account picked under "Shared with me" scopes Settings to // the panes that account can be managed in (webmail: scoped settings). From df1e211780ec0b7d92635a10f41d1d5f082610cb Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:31:27 +0100 Subject: [PATCH 6/9] feat: hide settings export and import when the organisation turns them off --- src/components/settings/StorageSettings.tsx | 32 +++++++++++-------- .../__tests__/org-policy-settings.test.ts | 6 ++++ 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/src/components/settings/StorageSettings.tsx b/src/components/settings/StorageSettings.tsx index beaadb80..c3314043 100644 --- a/src/components/settings/StorageSettings.tsx +++ b/src/components/settings/StorageSettings.tsx @@ -10,6 +10,7 @@ import { spacing, radius, typography, type ThemePalette } from '../../theme/toke import { useColors } from '../../theme/colors'; import { useSettingsStore } from '../../stores/settings-store'; import { useLocaleStore } from '../../stores/locale-store'; +import { usePolicyFeature } from '../../stores/policy-store'; import { useOfflineCacheStore } from '../../stores/offline-cache-store'; import { useOutboxStore } from '../../stores/outbox-store'; import { runOfflineSync, formatBytes } from '../../lib/offline-sync'; @@ -29,6 +30,9 @@ export function StorageSettings({ onOpenTroubleshooting }: StorageSettingsProps) const resetToDefaults = useSettingsStore((s) => s.resetToDefaults); const exportSettings = useSettingsStore((s) => s.exportSettings); const importSettings = useSettingsStore((s) => s.importSettings); + // The organisation can turn settings export and import off (webmail + // about-data-settings.tsx). + const exportAllowed = usePolicyFeature('settingsExportEnabled'); const [confirmReset, setConfirmReset] = useState(false); @@ -229,19 +233,21 @@ export function StorageSettings({ onOpenTroubleshooting }: StorageSettingsProps) title={t('settings.backup.title', 'Settings backup')} description={t('settings.backup.description', 'Move your settings to another phone or the webmail.')} > - - - - - - + {exportAllowed && ( + + + + + + + )} { expect(page).toMatch(/\{showExternalContent && \(\s* { + const page = settings('StorageSettings'); + expect(page).toContain("const exportAllowed = usePolicyFeature('settingsExportEnabled');"); + expect(page).toMatch(/\{exportAllowed && \(\s*\s*\)\}/); + }); }); From efb92593b24b7fc9c501af35377254f21af87d49 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:34:06 +0100 Subject: [PATCH 7/9] feat: take archive out of the swipe and quick action settings when the organisation turns it off --- src/components/settings/LayoutSettings.tsx | 20 +++--- src/components/settings/ReadingSettings.tsx | 72 ++++++++++--------- .../__tests__/org-policy-settings.test.ts | 14 ++++ src/lib/__tests__/swipe-actions.test.ts | 22 ++++++ src/lib/swipe-actions.ts | 24 +++++++ 5 files changed, 111 insertions(+), 41 deletions(-) create mode 100644 src/lib/__tests__/swipe-actions.test.ts create mode 100644 src/lib/swipe-actions.ts diff --git a/src/components/settings/LayoutSettings.tsx b/src/components/settings/LayoutSettings.tsx index 88dea75a..4fad183f 100644 --- a/src/components/settings/LayoutSettings.tsx +++ b/src/components/settings/LayoutSettings.tsx @@ -7,7 +7,6 @@ import { useColors } from '../../theme/colors'; import { useLocaleStore } from '../../stores/locale-store'; import { useSettingsStore, - normalizeBottomQuickActions, REPLY_QUICK_ACTIONS, ALL_QUICK_ACTIONS, type SwipeAction, @@ -16,7 +15,8 @@ import { } from '../../stores/settings-store'; import { useEmailStore } from '../../stores/email-store'; import { useKeywordsStore } from '../../stores/keywords-store'; -import { useSettingLocked } from '../../stores/policy-store'; +import { usePolicyFeature, useSettingLocked } from '../../stores/policy-store'; +import { bottomQuickActionsFor, swipeActionShown, withoutArchive } from '../../lib/swipe-actions'; import { ORDER_PRESETS, detectPreset, presetLevels, sanitizeSortLevels, type OrderPreset, type MessageListOrderScope, @@ -74,12 +74,16 @@ export function LayoutSettings() { const hydrated = useSettingsStore((s) => s.hydrated); const hydrate = useSettingsStore((s) => s.hydrate); const t = useLocaleStore((s) => s.t); - const SWIPE_OPTIONS = React.useMemo(() => swipeOptions(t), [t]); + // With the organisation's Archive action off, Archive is no swipe or quick + // action, and a stored one shows as what it does (webmail + // message-list-settings.tsx). + const archiveEnabled = usePolicyFeature('archiveActionEnabled'); + const SWIPE_OPTIONS = React.useMemo(() => withoutArchive(swipeOptions(t), archiveEnabled), [t, archiveEnabled]); const SWIPE_MODE_OPTIONS = React.useMemo(() => swipeModeOptions(t), [t]); const QUICK_ACTION_LABELS = React.useMemo(() => quickActionLabels(t), [t]); const QUICK_ACTION_OPTIONS = React.useMemo( - () => ALL_QUICK_ACTIONS.map((value) => ({ value, label: QUICK_ACTION_LABELS[value] })), - [QUICK_ACTION_LABELS], + () => withoutArchive(ALL_QUICK_ACTIONS.map((value) => ({ value, label: QUICK_ACTION_LABELS[value] })), archiveEnabled), + [QUICK_ACTION_LABELS, archiveEnabled], ); useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]); @@ -125,7 +129,7 @@ export function LayoutSettings() { { value: 'all', label: t('settings.message_list_order.scope_all', 'All folders') }, ]; - const bottomActions = normalizeBottomQuickActions(bottomQuickActionsRaw); + const bottomActions = bottomQuickActionsFor(bottomQuickActionsRaw, archiveEnabled); // Pick an action for a bar slot. If the chosen action already occupies // another slot, swap the two so the three slots stay unique. const setQuickActionSlot = (index: number, value: QuickAction) => { @@ -242,7 +246,7 @@ export function LayoutSettings() { {t('settings.email_behavior.swipe_right_action.description_mobile', "Action when you drag a row from its left edge towards the right.")} setSwipeRightAction(v as SwipeAction)} options={SWIPE_OPTIONS} /> @@ -257,7 +261,7 @@ export function LayoutSettings() { {t('settings.email_behavior.swipe_left_action.description_mobile', "Action when you drag a row from its right edge towards the left.")} setSwipeLeftAction(v as SwipeAction)} options={SWIPE_OPTIONS} /> diff --git a/src/components/settings/ReadingSettings.tsx b/src/components/settings/ReadingSettings.tsx index ed57262c..d9fa47a2 100644 --- a/src/components/settings/ReadingSettings.tsx +++ b/src/components/settings/ReadingSettings.tsx @@ -18,6 +18,7 @@ import { useEmailStore } from '../../stores/email-store'; import { archiveEmails, queryEmails, getEmails } from '../../api/email'; import { ownMailboxes } from '../../lib/mailbox-tree'; import { useLocaleStore } from '../../stores/locale-store'; +import { usePolicyFeature } from '../../stores/policy-store'; export function ReadingSettings() { const c = useColors(); @@ -28,6 +29,9 @@ export function ReadingSettings() { const update = useSettingsStore((s) => s.updateSetting); const archiveMode = useSettingsStore((s) => s.archiveMode); + // Where archived mail goes means nothing with the organisation's Archive + // action off (webmail message-list-settings.tsx). + const archiveEnabled = usePolicyFeature('archiveActionEnabled'); const markAsReadDelay = useSettingsStore((s) => s.markAsReadDelay); const deleteAction = useSettingsStore((s) => s.deleteAction); const permanentlyDeleteJunk = useSettingsStore((s) => s.permanentlyDeleteJunk); @@ -147,40 +151,42 @@ export function ReadingSettings() { - - - update('archiveMode', v as ArchiveMode)} + options={[ + { value: 'single', label: t('settings.email_behavior.archive_mode.single', "A single folder") }, + { value: 'year', label: t('settings.email_behavior.archive_mode.year', "A folder per year") }, + { value: 'month', label: t('settings.email_behavior.archive_mode.month', "A folder per month") }, + ]} + /> + {archiveMode !== 'single' && ( + <> + + {reorganizing ? ( + + ) : ( + + )} + + {reorganizing ? t('settings.email_behavior.archive_mode.reorganizing', "Reorganizing…") : t('settings.email_behavior.archive_mode.reorganize', "Reorganize existing archive")} + + + {reorganizeResult && ( + {reorganizeResult} )} - - {reorganizing ? t('settings.email_behavior.archive_mode.reorganizing', "Reorganizing…") : t('settings.email_behavior.archive_mode.reorganize', "Reorganize existing archive")} - - - {reorganizeResult && ( - {reorganizeResult} - )} - - )} - - + + )} + + + )} update('permanentlyDeleteJunk', v)} /> diff --git a/src/components/settings/__tests__/org-policy-settings.test.ts b/src/components/settings/__tests__/org-policy-settings.test.ts index 73fce48f..97665eb9 100644 --- a/src/components/settings/__tests__/org-policy-settings.test.ts +++ b/src/components/settings/__tests__/org-policy-settings.test.ts @@ -49,4 +49,18 @@ describe('organisation policy on the settings pages', () => { expect(page).toContain("const exportAllowed = usePolicyFeature('settingsExportEnabled');"); expect(page).toMatch(/\{exportAllowed && \(\s*\s*\)\}/); }); + + it('takes Archive out of the swipe and quick actions when the organisation turns it off', () => { + const layout = settings('LayoutSettings'); + expect(layout).toContain("const archiveEnabled = usePolicyFeature('archiveActionEnabled');"); + expect(layout).toContain('withoutArchive(swipeOptions(t), archiveEnabled)'); + expect(layout).toContain('value={swipeActionShown(swipeRightAction, archiveEnabled)}'); + expect(layout).toContain('value={swipeActionShown(swipeLeftAction, archiveEnabled)}'); + expect(layout).toMatch(/withoutArchive\(ALL_QUICK_ACTIONS\.map\([\s\S]*?\), archiveEnabled\)/); + expect(layout).toContain('const bottomActions = bottomQuickActionsFor(bottomQuickActionsRaw, archiveEnabled);'); + + const reading = settings('ReadingSettings'); + expect(reading).toContain("const archiveEnabled = usePolicyFeature('archiveActionEnabled');"); + expect(reading).toMatch(/\{archiveEnabled && \(\s*\s* { + it('shows a stored Archive swipe as none while archiving is off', () => { + expect(swipeActionShown('archive', false)).toBe('none'); + expect(swipeActionShown('archive', true)).toBe('archive'); + expect(swipeActionShown('delete', false)).toBe('delete'); + }); + + it('drops Archive from the pickers while archiving is off', () => { + const options = [{ value: 'none' }, { value: 'archive' }, { value: 'delete' }]; + expect(withoutArchive(options, false).map((o) => o.value)).toEqual(['none', 'delete']); + expect(withoutArchive(options, true).map((o) => o.value)).toEqual(['none', 'archive', 'delete']); + }); + + it('fills a stored Archive quick action with a reply action, keeping three', () => { + expect(bottomQuickActionsFor(['archive', 'delete', 'reply'], false)).toEqual(['delete', 'reply', 'replyAll']); + expect(bottomQuickActionsFor(['reply', 'archive', 'replyAll'], false)).toEqual(['reply', 'replyAll', 'forward']); + expect(bottomQuickActionsFor(['archive', 'delete', 'reply'], true)).toEqual(['archive', 'delete', 'reply']); + }); +}); diff --git a/src/lib/swipe-actions.ts b/src/lib/swipe-actions.ts new file mode 100644 index 00000000..bf688533 --- /dev/null +++ b/src/lib/swipe-actions.ts @@ -0,0 +1,24 @@ +import { normalizeBottomQuickActions, type QuickAction, type SwipeAction } from '../stores/settings-store'; + +// The organisation's archiveActionEnabled gate (webmail +// message-list-settings.tsx): with it off, Archive is no swipe action, no +// row action and no quick action, and a stored one does nothing. + +/** What a swipe does: a stored Archive swipe does nothing while archiving is off. */ +export function swipeActionShown(action: SwipeAction, archiveEnabled: boolean): SwipeAction { + return !archiveEnabled && action === 'archive' ? 'none' : action; +} + +/** The actions a picker offers: without Archive while archiving is off. */ +export function withoutArchive(options: readonly T[], archiveEnabled: boolean): T[] { + return archiveEnabled ? [...options] : options.filter((option) => option.value !== 'archive'); +} + +/** + * The reader's three quick actions: a stored Archive slot gives way to a + * reply action while archiving is off, so the bar keeps three buttons. + */ +export function bottomQuickActionsFor(value: unknown, archiveEnabled: boolean): QuickAction[] { + const actions = normalizeBottomQuickActions(value); + return archiveEnabled ? actions : normalizeBottomQuickActions(actions.filter((action) => action !== 'archive')); +} From f27e2cebb4abb22ed7fe868bd2952aa642d27416 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:40:05 +0100 Subject: [PATCH 8/9] feat: lock or hide the settings the organisation pins or hides --- .../settings/AccessibilitySettings.tsx | 38 +++-- .../settings/AppearanceSettings.tsx | 39 +++-- src/components/settings/ComposingSettings.tsx | 13 +- .../settings/NotificationSettings.tsx | 41 +++-- src/components/settings/ReadingSettings.tsx | 155 +++++++++++------- .../__tests__/org-policy-settings.test.ts | 48 ++++++ 6 files changed, 231 insertions(+), 103 deletions(-) diff --git a/src/components/settings/AccessibilitySettings.tsx b/src/components/settings/AccessibilitySettings.tsx index 1973bde8..65fc629c 100644 --- a/src/components/settings/AccessibilitySettings.tsx +++ b/src/components/settings/AccessibilitySettings.tsx @@ -4,6 +4,8 @@ import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-s import Button from '../Button'; import { spacing } from '../../theme/tokens'; import { useLocaleStore } from '../../stores/locale-store'; +import { usePolicyStore, useSettingHidden, useSettingLocked } from '../../stores/policy-store'; +import { policyAllowsValue } from '../../lib/org-policy'; import { APP_FONTS, FONT_SIZES, useSettingsStore, type AppFont, type FontSize } from '../../stores/settings-store'; import { uiFontsBundled } from '../../theme/reading-fonts'; import { toast } from '../../stores/toast-store'; @@ -81,6 +83,16 @@ export function AccessibilitySettings() { const animationsEnabled = useSettingsStore((s) => s.animationsEnabled); const toastDuration = useSettingsStore((s) => s.toastDuration); const sendConfirmation = useSettingsStore((s) => s.sendConfirmation); + // What the organisation pins or hides (webmail accessibility-settings.tsx): + // a minimum notification duration leaves out the shorter choices. + const policyRules = usePolicyStore((s) => s.rules); + const toastDurationLocked = useSettingLocked('toastDuration'); + const sendConfirmationLocked = useSettingLocked('sendConfirmation'); + const sendConfirmationHidden = useSettingHidden('sendConfirmation'); + const toastChoices = React.useMemo( + () => TOAST_DURATION_CHOICES.filter((value) => value === toastDuration || policyAllowsValue('toastDuration', value, policyRules)), + [toastDuration, policyRules], + ); const confirmDelete = useSettingsStore((s) => s.confirmDelete); const confirmArchive = useSettingsStore((s) => s.confirmArchive); @@ -214,23 +226,27 @@ export function AccessibilitySettings() { update('markAsReadDelay', Number(v))} - options={[ - { value: '0', label: t('settings.email_behavior.mark_read.instant', "Instantly") }, - { value: '3000', label: t('settings.email_behavior.mark_read.delay_3s', "After 3 seconds") }, - { value: '5000', label: t('settings.email_behavior.mark_read.delay_5s', "After 5 seconds") }, - { value: '-1', label: t('settings.email_behavior.mark_read.never', "Never") }, - ]} - /> - + {!markAsReadHidden && ( + + update('deleteAction', v as DeleteAction)} - options={[ - { value: 'trash', label: t('settings.email_behavior.delete_action.trash', "Move to Trash") }, - { value: 'trash-and-read', label: t('settings.email_behavior.delete_action.trash_and_read', "Move to Trash and mark as read") }, - { value: 'permanent', label: t('settings.email_behavior.delete_action.permanent', "Delete Permanently") }, - ]} - /> - {deleteAction === 'permanent' && ( - - - {t('settings.email_behavior.delete_action.warning', "Emails will be permanently deleted and cannot be recovered. This action is irreversible.")} + {!deleteActionHidden && ( + + + + update('messageSpacing', v as MessageSpacing)} - options={[ - { value: 'auto', label: t('settings.email_behavior.message_spacing.auto', "Auto (recommended)") }, - { value: 'always', label: t('settings.email_behavior.message_spacing.always', "Always add spacing") }, - { value: 'edge', label: t('settings.email_behavior.message_spacing.edge', "Edge to edge") }, - ]} - /> - + {!messageSpacingHidden && ( + + - - update('emailsPerPage', Number(v))} + options={[ + { value: '10', label: '10' }, + { value: '25', label: '25' }, + { value: '50', label: '50' }, + { value: '100', label: '100' }, + ]} + /> + + )} ); @@ -364,6 +394,7 @@ export function ReadingSettings() { function makeStyles(c: ThemePalette) { return StyleSheet.create({ group: {}, + lockedControl: { opacity: 0.6 }, warning: { flexDirection: 'row', gap: spacing.sm, diff --git a/src/components/settings/__tests__/org-policy-settings.test.ts b/src/components/settings/__tests__/org-policy-settings.test.ts index 97665eb9..a5bd4be8 100644 --- a/src/components/settings/__tests__/org-policy-settings.test.ts +++ b/src/components/settings/__tests__/org-policy-settings.test.ts @@ -63,4 +63,52 @@ describe('organisation policy on the settings pages', () => { expect(reading).toContain("const archiveEnabled = usePolicyFeature('archiveActionEnabled');"); expect(reading).toMatch(/\{archiveEnabled && \(\s*\s* = [ + ['AppearanceSettings', 'density', true], + ['AccessibilitySettings', 'toastDuration', false], + ['AccessibilitySettings', 'sendConfirmation', true], + ['ReadingSettings', 'markAsReadDelay', true], + ['ReadingSettings', 'deleteAction', true], + ['ReadingSettings', 'showPreview', true], + ['ReadingSettings', 'includeGroupInUnified', true], + ['ReadingSettings', 'plainTextMode', false], + ['ReadingSettings', 'autoSelectReplyIdentity', false], + ['ReadingSettings', 'messageSpacing', true], + ['ReadingSettings', 'emailsPerPage', true], + ['ComposingSettings', 'autoSelectReplyIdentity', false], + ['ComposingSettings', 'replyIdentityMatch', false], + ['ComposingSettings', 'plainTextMode', false], + ['NotificationSettings', 'emailNotificationsEnabled', true], + ['NotificationSettings', 'pushNotifyInboxOnly', true], + ['NotificationSettings', 'calendarNotificationsEnabled', true], + ]; + + it.each(LOCKABLE_ROWS)('%s marks %s as set by the organisation when locked', (page, key, hides) => { + const source = settings(page); + const locked = new RegExp(`const (\\w+) = useSettingLocked\\('${key}'\\);`).exec(source); + expect(locked, `useSettingLocked('${key}')`).not.toBeNull(); + // On the row, or on the control of a row whose control sits outside it. + expect(source).toMatch(new RegExp(`locked=\\{${locked![1]}\\}|pointerEvents=\\{${locked![1]} \\? 'none' : 'auto'\\}`)); + if (hides) { + const hidden = new RegExp(`const (\\w+) = useSettingHidden\\('${key}'\\);`).exec(source); + expect(hidden, `useSettingHidden('${key}')`).not.toBeNull(); + expect(source).toMatch(new RegExp(`\\{!${hidden![1]} && \\(|&& !${hidden![1]} && \\(`)); + } + }); + + it('keeps notification durations under the organisation minimum out of the choices', () => { + const page = settings('AccessibilitySettings'); + expect(page).toContain("policyAllowsValue('toastDuration', value, policyRules)"); + expect(page).toContain('options={toastChoices.map('); + }); + + it('keeps the controls of locked rows that sit outside their row still', () => { + expect(settings('AppearanceSettings')).toMatch(/]*>\s*]*>\s*]*>\s*\{segmented\(REPLY_IDENTITY_MATCHES/); + }); }); From 83ddd13726c526ede731b236716f1eb8027a4b77 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:44:35 +0100 Subject: [PATCH 9/9] feat: show the sender's BIMI logo from the webmail when the message passes DMARC --- src/components/SenderAvatar.tsx | 45 +++++++++- src/lib/__tests__/avatar-bimi.test.ts | 115 ++++++++++++++++++++++++++ src/lib/avatar-bimi.ts | 96 +++++++++++++++++++++ src/lib/avatar-utils.ts | 9 +- 4 files changed, 259 insertions(+), 6 deletions(-) create mode 100644 src/lib/__tests__/avatar-bimi.test.ts create mode 100644 src/lib/avatar-bimi.ts diff --git a/src/components/SenderAvatar.tsx b/src/components/SenderAvatar.tsx index 22396e38..618dada8 100644 --- a/src/components/SenderAvatar.tsx +++ b/src/components/SenderAvatar.tsx @@ -12,10 +12,12 @@ import { hasFaviconFailed, markFaviconFailed, } from '../lib/avatar-utils'; +import { bimiLogoUrl, isNoLogoImage, markBrandLogoFailed, showBrandLogo } from '../lib/avatar-bimi'; import type { ContactPhotoRef } from '../lib/contact-utils'; 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 { useColors } from '../theme/colors'; import { ShieldAlert } from './icons'; @@ -43,12 +45,19 @@ interface SenderAvatarProps { * or photo (webmail #1439). */ senderWarning?: string | null; + /** + * The From domain of a message that passed DMARC for it (see + * brandIndicatorDomain in lib/avatar-bimi): its BIMI logo, from the + * account's webmail, comes before the favicon, under the same switch. + */ + brandDomain?: string | null; } // Mirrors the webmail `Avatar` priority for the subset available on native: -// sender warning → address-book photo → company favicon (if sender domain is +// sender warning → address-book photo → BIMI logo (from the account's +// webmail, `senderFavicons` on) → company favicon (if sender domain is // non-personal and `senderFavicons` is on) → initials over HSL-hashed color. -export default function SenderAvatar({ name, email, size = 40, style, disableImages, photo, senderWarning }: SenderAvatarProps) { +export default function SenderAvatar({ name, email, size = 40, style, disableImages, photo, senderWarning, brandDomain }: SenderAvatarProps) { const c = useColors(); const warned = !!senderWarning; const senderFavicons = useSettingsStore((s) => s.senderFavicons) && !disableImages && !warned; @@ -63,9 +72,13 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma const { source: photoSource, onLoad: onPhotoLoad, onError: onPhotoError } = useContactPhotoSource(disableImages || warned ? null : contactPhoto); const [imgError, setImgError] = React.useState(false); + // BIMI logos come from the webmail the active account signed in through. + const webmailUrl = useAccountStore((s) => s.accounts.find((a) => a.id === s.activeAccountId)?.webmailUrl ?? null); + const [failedBrand, setFailedBrand] = React.useState(null); React.useEffect(() => { setImgError(false); + setFailedBrand(null); }, [email]); const primaryName = (name ?? '').split(',')[0].trim(); @@ -74,9 +87,13 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma const faviconDomain = getFaviconDomain(email ?? undefined); const domainFailed = faviconDomain ? hasFaviconFailed(faviconDomain) : false; + const brandUrl = brandDomain ? bimiLogoUrl(webmailUrl, brandDomain) : null; + const showBrand = !!brandUrl && brandUrl !== failedBrand + && showBrandLogo({ senderFavicons, email, brandDomain, webmailUrl }); const content = avatarContent({ senderWarning, hasPhoto: !disableImages && !!photoSource, + showBrand, showFavicon: senderFavicons && !!faviconDomain && !imgError && !domainFailed, }); @@ -84,6 +101,12 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma setImgError(true); if (faviconDomain) markFaviconFailed(faviconDomain); }, [faviconDomain]); + // No logo (or it didn't load): the favicon or initials take over. + const handleBrandFailed = React.useCallback(() => { + if (!brandUrl) return; + markBrandLogoFailed(brandUrl); + setFailedBrand(brandUrl); + }, [brandUrl]); const containerStyle = [ styles.container, @@ -93,7 +116,7 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma borderRadius: size / 2, backgroundColor: content === 'warning' ? c.errorBg - : content === 'photo' ? 'transparent' : content === 'favicon' ? '#ffffff' : bgColor, + : content === 'photo' ? 'transparent' : content === 'favicon' || content === 'brand' ? '#ffffff' : bgColor, }, style, ]; @@ -127,6 +150,22 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma ); } + if (content === 'brand' && brandUrl) { + return ( + + { if (isNoLogoImage(e.nativeEvent?.source)) handleBrandFailed(); }} + onError={handleBrandFailed} + testID="sender-brand-avatar" + /> + + ); + } + if (content === 'favicon' && faviconDomain) { return ( diff --git a/src/lib/__tests__/avatar-bimi.test.ts b/src/lib/__tests__/avatar-bimi.test.ts new file mode 100644 index 00000000..405bb0f9 --- /dev/null +++ b/src/lib/__tests__/avatar-bimi.test.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { + bimiLogoUrl, + brandIndicatorDomain, + isNoLogoImage, + markBrandLogoFailed, + resetBrandLogosForTests, + showBrandLogo, +} from '../avatar-bimi'; +import { avatarContent } from '../avatar-utils'; +import { LIST_AUTH_RESULTS_PROPERTY } from '../email-headers'; +import type { Email } from '../../api/types'; + +// BIMI logos as the webmail shows them (components/ui/avatar.tsx, +// lib/email-headers.ts brandIndicatorDomain, app/api/bimi). + +const PASS = 'mx.example.org; spf=pass smtp.mailfrom=bank.example; dkim=pass header.d=bank.example; dmarc=pass header.from=bank.example'; +const FAIL = 'mx.example.org; spf=fail smtp.mailfrom=evil.example; dkim=none; dmarc=fail header.from=bank.example'; +const OTHER_DOMAIN = 'mx.example.org; spf=pass smtp.mailfrom=other.example; dkim=pass header.d=other.example; dmarc=pass header.from=other.example'; +const NO_DMARC = 'mx.example.org; spf=pass smtp.mailfrom=bank.example; dkim=pass header.d=bank.example'; +const SPOOFED_PASS = 'mx.example.org; spf=fail smtp.mailfrom=bank.example; dkim=none; dmarc=pass header.from=bank.example'; + +function row(auth?: string[], from = 'billing@bank.example'): Email { + return { + id: 'm1', threadId: 't1', mailboxIds: {}, keywords: {}, size: 1, receivedAt: '2026-10-31T10:00:00Z', + hasAttachment: false, from: [{ name: 'Bank', email: from }], + ...(auth ? { [LIST_AUTH_RESULTS_PROPERTY]: auth } : {}), + } as Email; +} + +beforeEach(() => resetBrandLogosForTests()); + +describe('brandIndicatorDomain', () => { + it('names the From domain of a message that passed DMARC for it, from the list property or the headers', () => { + expect(brandIndicatorDomain(row([PASS]))).toBe('bank.example'); + const full = { ...row(), headers: [{ name: 'Authentication-Results', value: ` ${PASS}` }] }; + expect(brandIndicatorDomain(full)).toBe('bank.example'); + expect(brandIndicatorDomain(row([PASS], 'Billing@BANK.example'))).toBe('bank.example'); + }); + + it('names none for a failed, missing or foreign DMARC result, or a spoofed message', () => { + expect(brandIndicatorDomain(row([FAIL]))).toBeUndefined(); + expect(brandIndicatorDomain(row([NO_DMARC]))).toBeUndefined(); + expect(brandIndicatorDomain(row([OTHER_DOMAIN]))).toBeUndefined(); + expect(brandIndicatorDomain(row([SPOOFED_PASS]))).toBeUndefined(); + expect(brandIndicatorDomain(row())).toBeUndefined(); + expect(brandIndicatorDomain(undefined)).toBeUndefined(); + }); +}); + +describe('bimiLogoUrl', () => { + it('asks the account webmail for the domain', () => { + expect(bimiLogoUrl('https://mail.example.org/', 'bank.example')).toBe('https://mail.example.org/api/bimi?domain=bank.example'); + expect(bimiLogoUrl('https://mail.example.org/webmail', 'a&b.example')).toBe('https://mail.example.org/webmail/api/bimi?domain=a%26b.example'); + }); + + it('has none without a webmail', () => { + expect(bimiLogoUrl(null, 'bank.example')).toBeNull(); + expect(bimiLogoUrl('', 'bank.example')).toBeNull(); + expect(bimiLogoUrl('javascript:alert(1)', 'bank.example')).toBeNull(); + }); +}); + +describe('showBrandLogo', () => { + const base = { senderFavicons: true, email: 'billing@bank.example', brandDomain: 'bank.example', webmailUrl: 'https://mail.example.org' }; + + it('shows the logo for the address whose message passed', () => { + expect(showBrandLogo(base)).toBe(true); + }); + + it('follows the sender favicon switch, which is off by default', () => { + expect(showBrandLogo({ ...base, senderFavicons: false })).toBe(false); + }); + + it('shows none for another address than the one the check was for', () => { + expect(showBrandLogo({ ...base, email: 'someone@elsewhere.example' })).toBe(false); + expect(showBrandLogo({ ...base, email: 'news@mail.bank.example' })).toBe(false); + }); + + it('shows none without a webmail or a passing domain', () => { + expect(showBrandLogo({ ...base, webmailUrl: undefined })).toBe(false); + expect(showBrandLogo({ ...base, brandDomain: undefined })).toBe(false); + }); + + it('stops asking for a logo that failed this session', () => { + markBrandLogoFailed('https://mail.example.org/api/bimi?domain=bank.example'); + expect(showBrandLogo(base)).toBe(false); + }); + + it('reads the 1x1 answer as no logo', () => { + expect(isNoLogoImage({ width: 1, height: 1 })).toBe(true); + expect(isNoLogoImage({ width: 128, height: 128 })).toBe(false); + expect(isNoLogoImage(undefined)).toBe(false); + }); +}); + +describe('avatar order with a BIMI logo', () => { + it('keeps the warning shield and the contact photo ahead of the logo, and the logo ahead of the favicon', () => { + expect(avatarContent({ senderWarning: 'Sender check failed', hasPhoto: true, showBrand: true, showFavicon: true })).toBe('warning'); + expect(avatarContent({ hasPhoto: true, showBrand: true, showFavicon: true })).toBe('photo'); + expect(avatarContent({ hasPhoto: false, showBrand: true, showFavicon: true })).toBe('brand'); + expect(avatarContent({ hasPhoto: false, showBrand: false, showFavicon: true })).toBe('favicon'); + }); + + it('draws the logo in SenderAvatar under the favicon switch, never for a warned sender', () => { + const source = fs.readFileSync(path.resolve(__dirname, '..', '..', 'components', 'SenderAvatar.tsx'), 'utf8'); + // senderFavicons there is the setting, off for disabled images and a warned sender. + expect(source).toContain("const senderFavicons = useSettingsStore((s) => s.senderFavicons) && !disableImages && !warned;"); + expect(source).toContain('showBrandLogo({ senderFavicons, email, brandDomain, webmailUrl })'); + expect(source).toContain('const webmailUrl = useAccountStore((s) => s.accounts.find((a) => a.id === s.activeAccountId)?.webmailUrl ?? null);'); + expect(source).toMatch(/if \(content === 'brand' && brandUrl\) \{[\s\S]*?source=\{\{ uri: brandUrl \}\}[\s\S]*?onError=\{handleBrandFailed\}/); + }); +}); diff --git a/src/lib/avatar-bimi.ts b/src/lib/avatar-bimi.ts new file mode 100644 index 00000000..996032f8 --- /dev/null +++ b/src/lib/avatar-bimi.ts @@ -0,0 +1,96 @@ +// BIMI sender logos (webmail components/ui/avatar.tsx, lib/email-headers.ts +// brandIndicatorDomain, app/api/bimi/route.ts). The logo comes from the +// webmail the account signed in through: GET /api/bimi?domain= answers it as a PNG, or a 1x1 transparent PNG when the domain has +// none. The webmail caches its answers (6 h for a logo, 1 h for none); a +// domain without a logo is also remembered here for the session. + +import type { Email } from '../api/types'; +import { + LIST_AUTH_RESULTS_PROPERTY, + headerValues, + isAuthenticationSpoofed, + parseAuthenticationResults, + type AuthenticationResults, +} from './email-headers'; + +function domainOf(address: string): string | undefined { + const domain = address.slice(address.lastIndexOf('@') + 1).trim().toLowerCase().replace(/\.$/, ''); + return /^[^\s<>@]+\.[^\s<>@]+$/.test(domain) ? domain : undefined; +} + +/** The Authentication-Results of a message as the list or the reader holds it. */ +function authenticationResultsOf(email: Pick): AuthenticationResults | undefined { + let values: string[]; + if (email.headers) { + values = headerValues(email.headers, 'Authentication-Results'); + } else { + const raw = (email as { [LIST_AUTH_RESULTS_PROPERTY]?: unknown })[LIST_AUTH_RESULTS_PROPERTY]; + values = Array.isArray(raw) ? raw.filter((value): value is string => typeof value === 'string') : []; + } + return values.length > 0 ? parseAuthenticationResults(values) : undefined; +} + +/** + * The From domain of a message that passed DMARC for that domain, whose + * BIMI logo may stand for the sender (webmail brandIndicatorDomain). The + * logo says who the domain is, not that a message came from it, so a message + * without a DMARC pass for its own From domain gets none. Undefined for no + * message (rows that show the recipient). + */ +export function brandIndicatorDomain(email: Pick | undefined): string | undefined { + if (!email) return undefined; + const fromEmail = email.from?.[0]?.email ?? undefined; + const auth = authenticationResultsOf(email); + if (!auth?.dmarc || auth.dmarc.result !== 'pass' || !fromEmail) return undefined; + const domain = domainOf(fromEmail); + if (!domain || isAuthenticationSpoofed(auth)) return undefined; + const checked = auth.dmarc.domain ? domainOf(`@${auth.dmarc.domain}`) : domain; + return checked === domain ? domain : undefined; +} + +/** The logo URL on the account's webmail; null without a usable webmail address. */ +export function bimiLogoUrl(webmailUrl: string | null | undefined, domain: string): string | null { + const base = webmailUrl?.trim().replace(/\/+$/, ''); + if (!base || !/^https?:\/\/[^/]/i.test(base)) return null; + return `${base}/api/bimi?domain=${encodeURIComponent(domain)}`; +} + +/** + * Whether the avatar of `email` may show the BIMI logo of `brandDomain` + * (webmail Avatar showBrand): only under the sender favicon switch, only for + * the address shown (a thread row may show another participant than the + * message whose DMARC pass named the domain), and only from a webmail. + */ +export function showBrandLogo(options: { + senderFavicons: boolean; + email: string | null | undefined; + brandDomain: string | null | undefined; + webmailUrl: string | null | undefined; +}): boolean { + const { senderFavicons, email, brandDomain, webmailUrl } = options; + if (!senderFavicons || !brandDomain || !email) return false; + if (domainOf(email) !== brandDomain) return false; + const url = bimiLogoUrl(webmailUrl, brandDomain); + return url !== null && !failedBrandLogos.has(url); +} + +// Logos that didn't load or answered "no logo", for the session. +const failedBrandLogos = new Set(); + +export function markBrandLogoFailed(url: string): void { + failedBrandLogos.add(url); +} + +export function hasBrandLogoFailed(url: string): boolean { + return failedBrandLogos.has(url); +} + +/** The webmail's "no logo" answer is a 1x1 transparent PNG. */ +export function isNoLogoImage(size: { width?: number; height?: number } | null | undefined): boolean { + return !!size && typeof size.width === 'number' && size.width <= 1; +} + +export function resetBrandLogosForTests(): void { + failedBrandLogos.clear(); +} diff --git a/src/lib/avatar-utils.ts b/src/lib/avatar-utils.ts index c8670f7c..912d1040 100644 --- a/src/lib/avatar-utils.ts +++ b/src/lib/avatar-utils.ts @@ -238,21 +238,24 @@ export function getContactPhotoForEmail( // --- What the avatar shows -------------------------------------------------- -export type AvatarContent = 'warning' | 'photo' | 'favicon' | 'initials'; +export type AvatarContent = 'warning' | 'photo' | 'brand' | 'favicon' | 'initials'; /** * What a sender avatar shows, in the webmail `Avatar`'s order. A failed or * unverified sender check comes first: a warning shield instead of any photo, - * favicon or initials, so a forged From doesn't get the real sender's logo or - * contact photo (webmail 605a3983b, #1439). + * logo, favicon or initials, so a forged From doesn't get the real sender's + * logo or contact photo (webmail 605a3983b, #1439). Then the contact photo, + * the domain's BIMI logo (see avatar-bimi.ts), the favicon. */ export function avatarContent(options: { senderWarning?: string | null; hasPhoto: boolean; + showBrand?: boolean; showFavicon: boolean; }): AvatarContent { if (options.senderWarning) return 'warning'; if (options.hasPhoto) return 'photo'; + if (options.showBrand) return 'brand'; if (options.showFavicon) return 'favicon'; return 'initials'; }