fix: load remote content only for senders in the trusted senders book

With "Sync trusted senders to address book" on, the viewer trusted every
address in every address book, so remote images and tracking pixels
loaded for any contact, including mail with a spoofed From that uses a
contact's address. The toggle also switched itself on the first time
Content & Senders was opened on a contacts-capable account, so merely
visiting settings widened trust to the whole address book.

Now only the local allow-list and, while sync is on, the dedicated
"Trusted Senders" book count, as in the webmail viewer. The setting
stays off until the user turns it on, and the viewer only fetches the
book while sync is on.
This commit is contained in:
Linus Rath
2026-09-24 19:03:33 +02:00
parent ac58b36a77
commit d89e33110a
5 changed files with 79 additions and 32 deletions
+8 -21
View File
@@ -12,6 +12,7 @@ import { hasTruncatedDisplayedBody, pickEmailBody, selectRenderableHtml } from '
import { buildQuoteCollapseScript, collapsePlainTextQuotes } from '../lib/quote-collapse'; import { buildQuoteCollapseScript, collapsePlainTextQuotes } from '../lib/quote-collapse';
import { parseMailtoUrl } from '../lib/unsubscribe'; import { parseMailtoUrl } from '../lib/unsubscribe';
import { fetchInlineImageDataUri } from '../lib/email-export'; import { fetchInlineImageDataUri } from '../lib/email-export';
import { isSenderContentTrusted } from '../lib/trusted-senders';
import { useSettingsStore } from '../stores/settings-store'; import { useSettingsStore } from '../stores/settings-store';
import { useContactsStore } from '../stores/contacts-store'; import { useContactsStore } from '../stores/contacts-store';
import { useLocaleStore } from '../stores/locale-store'; import { useLocaleStore } from '../stores/locale-store';
@@ -522,7 +523,6 @@ export default function EmailBodyView({
const emailAlwaysLightMode = useSettingsStore((s) => s.emailAlwaysLightMode); const emailAlwaysLightMode = useSettingsStore((s) => s.emailAlwaysLightMode);
const plainTextFont = useSettingsStore((s) => s.plainTextFont); const plainTextFont = useSettingsStore((s) => s.plainTextFont);
const messageSpacing = useSettingsStore((s) => s.messageSpacing); const messageSpacing = useSettingsStore((s) => s.messageSpacing);
const contacts = useContactsStore((s) => s.contacts);
const trustedSenderEmails = useContactsStore((s) => s.trustedSenderEmails); const trustedSenderEmails = useContactsStore((s) => s.trustedSenderEmails);
const trustedSendersLoaded = useContactsStore((s) => s.trustedSendersLoaded); const trustedSendersLoaded = useContactsStore((s) => s.trustedSendersLoaded);
const loadTrustedSendersBook = useContactsStore((s) => s.loadTrustedSendersBook); const loadTrustedSendersBook = useContactsStore((s) => s.loadTrustedSendersBook);
@@ -533,21 +533,8 @@ export default function EmailBodyView({
// entries (synced across devices) feed the external-content trust check. We // 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. // do not create the book here — it is created lazily when a sender is trusted.
React.useEffect(() => { React.useEffect(() => {
if (!trustedSendersLoaded) void loadTrustedSendersBook(false); if (trustedSendersAddressBook && !trustedSendersLoaded) void loadTrustedSendersBook(false);
}, [trustedSendersLoaded, loadTrustedSendersBook]); }, [trustedSendersAddressBook, trustedSendersLoaded, loadTrustedSendersBook]);
// Index the address book once per render so the trusted-sender check below
// is O(1) per email. Only used when the matching setting is on.
const addressBookEmails = React.useMemo(() => {
if (!trustedSendersAddressBook) return null;
const out = new Set<string>();
for (const card of contacts) {
if (!card.emails) continue;
for (const e of Object.values(card.emails)) {
if (e?.address) out.add(e.address.toLowerCase());
}
}
return out;
}, [contacts, trustedSendersAddressBook]);
// Most marketing email is authored against a white background, so dark-mode // 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 // 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 // emails on a light surface even while the rest of the app is dark; the
@@ -565,11 +552,11 @@ export default function EmailBodyView({
}, [email, bodyOverride]); }, [email, bodyOverride]);
const rawHtml = React.useMemo(() => selectRenderableHtml(picked), [picked]); const rawHtml = React.useMemo(() => selectRenderableHtml(picked), [picked]);
const text = picked.text; const text = picked.text;
const trusted = senderEmail const trusted = isSenderContentTrusted(senderEmail, {
? isSenderTrusted(senderEmail) isLocallyTrusted: isSenderTrusted,
|| trustedSenderEmails.includes(senderEmail.toLowerCase()) syncEnabled: trustedSendersAddressBook,
|| (addressBookEmails?.has(senderEmail.toLowerCase()) ?? false) trustedBookEmails: trustedSenderEmails,
: false; });
// One-time override: user tapped "Load images" for this email only. // One-time override: user tapped "Load images" for this email only.
const [allowOnce, setAllowOnce] = React.useState(false); const [allowOnce, setAllowOnce] = React.useState(false);
@@ -47,15 +47,8 @@ export function ContentSendersSettings() {
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]); useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
// Sync to the address book once the account proves it supports contacts; // Sync stays off until the user turns it on below (null = never chosen):
// the toggle below lets the user opt out again. Mirrors the webmail, where // opening this pane must not change which senders the viewer trusts.
// the setting starts unset (null) and flips on with the first
// contacts-capable session.
useEffect(() => {
if (!hydrated || !hasContacts) return;
if (trustedSendersAddressBook === null) updateSetting('trustedSendersAddressBook', true);
}, [hydrated, hasContacts, trustedSendersAddressBook, updateSetting]);
const syncEnabled = !!trustedSendersAddressBook && hasContacts; const syncEnabled = !!trustedSendersAddressBook && hasContacts;
useEffect(() => { useEffect(() => {
+41
View File
@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest';
import { isSenderContentTrusted } from '../trusted-senders';
const local = (list: string[]) => (email: string) => list.includes(email.toLowerCase());
describe('isSenderContentTrusted', () => {
it('trusts senders on the local allow-list whatever the sync setting', () => {
for (const syncEnabled of [null, false, true]) {
expect(isSenderContentTrusted('Alice@Example.com', {
isLocallyTrusted: local(['alice@example.com']),
syncEnabled,
trustedBookEmails: [],
})).toBe(true);
}
});
it('trusts the Trusted Senders book only while sync is on', () => {
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', () => {
// An ordinary contact (e.g. in the personal book) is not in either list,
// so a spoofed From with their address still gets remote content blocked.
expect(isSenderContentTrusted('carol@example.com', {
isLocallyTrusted: local(['alice@example.com']),
syncEnabled: true,
trustedBookEmails: ['bob@example.com'],
})).toBe(false);
});
it('never trusts a missing sender', () => {
const opts = { isLocallyTrusted: () => true, syncEnabled: true, trustedBookEmails: [''] };
expect(isSenderContentTrusted(undefined, opts)).toBe(false);
expect(isSenderContentTrusted(null, opts)).toBe(false);
expect(isSenderContentTrusted(' ', opts)).toBe(false);
});
});
+26
View File
@@ -0,0 +1,26 @@
/**
* Whether the viewer may load a sender's remote content (images, tracking
* pixels) without asking.
*
* Only two lists count: the local allow-list and, while "Sync trusted senders
* to address book" is on, the dedicated "Trusted Senders" address book. An
* ordinary contact is never trusted just for being in the address book: the
* From header is trivially spoofed, so that would let anyone who knows a
* contact's address load pixels (webmail `components/email/email-viewer.tsx`).
*/
export function isSenderContentTrusted(
senderEmail: string | null | undefined,
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;
/** Lowercased addresses filed in the "Trusted Senders" book. */
trustedBookEmails: readonly string[];
},
): boolean {
const email = senderEmail?.trim();
if (!email) return false;
if (opts.isLocallyTrusted(email)) return true;
return !!opts.syncEnabled && opts.trustedBookEmails.includes(email.toLowerCase());
}
+2 -2
View File
@@ -123,8 +123,8 @@ interface PersistedSettings {
// Privacy & content // Privacy & content
externalContentPolicy: ExternalContentPolicy; externalContentPolicy: ExternalContentPolicy;
trustedSenders: string[]; trustedSenders: string[];
// null = not decided yet; flips to true automatically once the account // null = not decided yet and treated as off; only the user's toggle in
// proves it supports JMAP contacts (webmail parity), false = user opted out. // Content & Senders turns it on, false = user opted out.
trustedSendersAddressBook: boolean | null; trustedSendersAddressBook: boolean | null;
senderFavicons: boolean; senderFavicons: boolean;
hideInlineImageAttachments: boolean; hideInlineImageAttachments: boolean;