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.
This commit is contained in:
Linus Rath
2026-10-02 14:09:34 +02:00
parent 2a1265a9ac
commit 86e33b9afe
6 changed files with 51 additions and 23 deletions
+11 -9
View File
@@ -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);
@@ -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')}
>
<ToggleSwitch
checked={!!trustedSendersAddressBook}
checked={syncEnabled}
onChange={(enabled) => {
updateSetting('trustedSendersAddressBook', enabled);
if (enabled) void loadTrustedSendersBook();
+17 -4
View File
@@ -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', () => {
+13 -3
View File
@@ -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());
}
+4 -1
View File
@@ -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>(() => 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);
}
}
+2 -2
View File
@@ -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;