mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user