From 86e33b9afed8945fcd6ca02e806fdc65789ae4ba Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:09:34 +0200 Subject: [PATCH] fix: sync trusted senders to the address book by default The "Sync with address book" toggle under Content & Senders was off until the user turned it on, so senders trusted on the phone stayed in the local allow-list and never reached the "Trusted Senders" address book the other devices read. A setting the user never chose now counts as on; only an explicit opt-out, or a server without contacts, turns it off. --- src/components/EmailBodyView.tsx | 20 ++++++++++-------- .../settings/ContentSendersSettings.tsx | 8 +++---- src/lib/__tests__/trusted-senders.test.ts | 21 +++++++++++++++---- src/lib/trusted-senders.ts | 16 +++++++++++--- src/screens/ComposeScreen.tsx | 5 ++++- src/stores/settings-store.ts | 4 ++-- 6 files changed, 51 insertions(+), 23 deletions(-) diff --git a/src/components/EmailBodyView.tsx b/src/components/EmailBodyView.tsx index 37710bf4..225cdfd2 100644 --- a/src/components/EmailBodyView.tsx +++ b/src/components/EmailBodyView.tsx @@ -12,7 +12,8 @@ import { bodyDocument } from '../lib/email-body-document'; import { estimateBodyHeight, lastBodyHeight, rememberBodyHeight } from '../lib/body-heights'; import { parseMailtoUrl } from '../lib/unsubscribe'; import { fetchInlineImageDataUri } from '../lib/email-export'; -import { isSenderContentTrusted } from '../lib/trusted-senders'; +import { isSenderContentTrusted, isTrustedSendersSyncOn } from '../lib/trusted-senders'; +import { useHasContacts } from '../lib/capabilities'; import { useSettingsStore } from '../stores/settings-store'; import { useContactsStore } from '../stores/contacts-store'; import { useLocaleStore } from '../stores/locale-store'; @@ -527,6 +528,8 @@ export default function EmailBodyView({ const isSenderTrusted = useSettingsStore((s) => s.isSenderTrusted); const addTrustedSender = useSettingsStore((s) => s.addTrustedSender); const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook); + const hasContacts = useHasContacts(); + const syncTrustedSenders = isTrustedSendersSyncOn(trustedSendersAddressBook, hasContacts); const emailAlwaysLightMode = useSettingsStore((s) => s.emailAlwaysLightMode); const plainTextFont = useSettingsStore((s) => s.plainTextFont); const messageSpacing = useSettingsStore((s) => s.messageSpacing); @@ -540,8 +543,8 @@ export default function EmailBodyView({ // entries (synced across devices) feed the external-content trust check. We // do not create the book here — it is created lazily when a sender is trusted. React.useEffect(() => { - if (trustedSendersAddressBook && !trustedSendersLoaded) void loadTrustedSendersBook(false); - }, [trustedSendersAddressBook, trustedSendersLoaded, loadTrustedSendersBook]); + if (syncTrustedSenders && !trustedSendersLoaded) void loadTrustedSendersBook(false); + }, [syncTrustedSenders, trustedSendersLoaded, loadTrustedSendersBook]); // Most marketing email is authored against a white background, so dark-mode // inversion can wreck logos/banners. With this flag the user opts to render // emails on a light surface even while the rest of the app is dark; the @@ -561,7 +564,7 @@ export default function EmailBodyView({ const text = picked.text; const trusted = isSenderContentTrusted(senderEmail, { isLocallyTrusted: isSenderTrusted, - syncEnabled: trustedSendersAddressBook, + syncEnabled: syncTrustedSenders, trustedBookEmails: trustedSenderEmails, }); @@ -721,12 +724,11 @@ export default function EmailBodyView({ const onLoadImages = () => setAllowOnce(true); const onTrustSender = () => { if (senderEmail) { - // Keep the local allow-list for instant effect. The server-side "Trusted - // Senders" address book (synced across devices) is only written when - // the user opted into it - otherwise a book would silently appear in - // their contacts (matches the webmail). + // Keep the local allow-list for instant effect, and file the sender in + // the server-side "Trusted Senders" address book (synced across + // devices) unless the user turned that off. addTrustedSender(senderEmail); - if (trustedSendersAddressBook) { + if (syncTrustedSenders) { void addToTrustedSendersBook(senderEmail).catch(() => {}); } setAllowOnce(true); diff --git a/src/components/settings/ContentSendersSettings.tsx b/src/components/settings/ContentSendersSettings.tsx index 88a892c4..16f13eab 100644 --- a/src/components/settings/ContentSendersSettings.tsx +++ b/src/components/settings/ContentSendersSettings.tsx @@ -10,6 +10,7 @@ import { useColors } from '../../theme/colors'; import { useSettingsStore, type ExternalContentPolicy } from '../../stores/settings-store'; import { useContactsStore } from '../../stores/contacts-store'; import { useHasContacts } from '../../lib/capabilities'; +import { isTrustedSendersSyncOn } from '../../lib/trusted-senders'; import { useLocaleStore } from '../../stores/locale-store'; interface TrustedRow { @@ -49,9 +50,8 @@ export function ContentSendersSettings() { useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]); - // Sync stays off until the user turns it on below (null = never chosen): - // opening this pane must not change which senders the viewer trusts. - const syncEnabled = !!trustedSendersAddressBook && hasContacts; + // On unless the user turned it off below (null = never chosen). + const syncEnabled = isTrustedSendersSyncOn(trustedSendersAddressBook, hasContacts); useEffect(() => { if (modalOpen && syncEnabled) void loadTrustedSendersBook(); @@ -179,7 +179,7 @@ export function ContentSendersSettings() { description={t('settings.email_behavior.trusted_senders.use_address_book_description', 'Store trusted senders in a dedicated "Trusted Senders" address book so they sync across all your devices')} > { updateSetting('trustedSendersAddressBook', enabled); if (enabled) void loadTrustedSendersBook(); diff --git a/src/lib/__tests__/trusted-senders.test.ts b/src/lib/__tests__/trusted-senders.test.ts index 4e5d0126..88484601 100644 --- a/src/lib/__tests__/trusted-senders.test.ts +++ b/src/lib/__tests__/trusted-senders.test.ts @@ -1,11 +1,26 @@ import { describe, it, expect } from 'vitest'; -import { isSenderContentTrusted } from '../trusted-senders'; +import { isSenderContentTrusted, isTrustedSendersSyncOn } from '../trusted-senders'; const local = (list: string[]) => (email: string) => list.includes(email.toLowerCase()); +describe('isTrustedSendersSyncOn', () => { + it('is on unless the user opted out', () => { + expect(isTrustedSendersSyncOn(null, true)).toBe(true); + expect(isTrustedSendersSyncOn(undefined, true)).toBe(true); + expect(isTrustedSendersSyncOn(true, true)).toBe(true); + expect(isTrustedSendersSyncOn(false, true)).toBe(false); + }); + + it('is off on a server without contacts', () => { + for (const setting of [null, true, false]) { + expect(isTrustedSendersSyncOn(setting, false)).toBe(false); + } + }); +}); + describe('isSenderContentTrusted', () => { it('trusts senders on the local allow-list whatever the sync setting', () => { - for (const syncEnabled of [null, false, true]) { + for (const syncEnabled of [false, true]) { expect(isSenderContentTrusted('Alice@Example.com', { isLocallyTrusted: local(['alice@example.com']), syncEnabled, @@ -18,8 +33,6 @@ describe('isSenderContentTrusted', () => { const opts = { isLocallyTrusted: local([]), trustedBookEmails: ['bob@example.com'] }; expect(isSenderContentTrusted('Bob@Example.com', { ...opts, syncEnabled: true })).toBe(true); expect(isSenderContentTrusted('bob@example.com', { ...opts, syncEnabled: false })).toBe(false); - // Never chosen counts as off: the default must not widen trust. - expect(isSenderContentTrusted('bob@example.com', { ...opts, syncEnabled: null })).toBe(false); }); it('does not trust a sender just because some other list knows them', () => { diff --git a/src/lib/trusted-senders.ts b/src/lib/trusted-senders.ts index 3b5031f0..1853b63f 100644 --- a/src/lib/trusted-senders.ts +++ b/src/lib/trusted-senders.ts @@ -1,3 +1,13 @@ +/** + * Whether "Sync trusted senders to address book" is in effect. Never chosen + * (null) counts as on, so trusted senders land in the dedicated "Trusted + * Senders" address book out of the box; only the user's opt-out (false), or + * a server without contacts, turns it off. + */ +export function isTrustedSendersSyncOn(setting: boolean | null | undefined, hasContacts: boolean): boolean { + return hasContacts && setting !== false; +} + /** * Whether the viewer may load a sender's remote content (images, tracking * pixels) without asking. @@ -13,8 +23,8 @@ export function isSenderContentTrusted( opts: { /** The settings store's local allow-list check. */ isLocallyTrusted: (email: string) => boolean; - /** The "Sync trusted senders to address book" setting (null = never chosen, off). */ - syncEnabled: boolean | null; + /** Whether address book sync is in effect (`isTrustedSendersSyncOn`). */ + syncEnabled: boolean; /** Lowercased addresses filed in the "Trusted Senders" book. */ trustedBookEmails: readonly string[]; }, @@ -22,5 +32,5 @@ export function isSenderContentTrusted( const email = senderEmail?.trim(); if (!email) return false; if (opts.isLocallyTrusted(email)) return true; - return !!opts.syncEnabled && opts.trustedBookEmails.includes(email.toLowerCase()); + return opts.syncEnabled && opts.trustedBookEmails.includes(email.toLowerCase()); } diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index 652c2bce..3ec09ce2 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -33,6 +33,8 @@ import { getEmailInitials } from '../lib/avatar-utils'; import { useContactsStore, type RecipientSuggestion } from '../stores/contacts-store'; import { useLocaleStore } from '../stores/locale-store'; import { useSettingsStore } from '../stores/settings-store'; +import { useHasContacts } from '../lib/capabilities'; +import { isTrustedSendersSyncOn } from '../lib/trusted-senders'; import { useAccountStore } from '../stores/account-store'; import { useSendUndoStore } from '../stores/send-undo-store'; import { toast } from '../stores/toast-store'; @@ -477,6 +479,7 @@ export default function ComposeScreen({ route, navigation }: Props) { const subAddressDelimiter = useSettingsStore((s) => s.subAddressDelimiter); const preferredIdentityIds = useSettingsStore((s) => s.preferredIdentityIds); const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook); + const hasContacts = useHasContacts(); const updateSetting = useSettingsStore((s) => s.updateSetting); const quoteLabels = React.useMemo(() => quoteHeaderLabels(t), [t]); @@ -2130,7 +2133,7 @@ export default function ComposeScreen({ route, navigation }: Props) { const contacts = useContactsStore.getState(); for (const r of [...outgoing.to, ...(outgoing.cc ?? [])]) { settings.addTrustedSender(r.email); - if (trustedSendersAddressBook) { + if (isTrustedSendersSyncOn(trustedSendersAddressBook, hasContacts)) { contacts.addToTrustedSendersBook(r.name ? `${r.name} <${r.email}>` : r.email).catch(() => undefined); } } diff --git a/src/stores/settings-store.ts b/src/stores/settings-store.ts index d82ec07e..1a4e5793 100644 --- a/src/stores/settings-store.ts +++ b/src/stores/settings-store.ts @@ -127,8 +127,8 @@ interface PersistedSettings { // Privacy & content externalContentPolicy: ExternalContentPolicy; trustedSenders: string[]; - // null = not decided yet and treated as off; only the user's toggle in - // Content & Senders turns it on, false = user opted out. + // null = not decided yet and treated as on (isTrustedSendersSyncOn); + // false = the user turned it off in Content & Senders. trustedSendersAddressBook: boolean | null; senderFavicons: boolean; hideInlineImageAttachments: boolean;