From 927a3b8b113e49bfc2187bdb884b9e179faa85d4 Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Sun, 12 Apr 2026 01:10:28 +0200 Subject: [PATCH] feat: store trusted senders in a dedicated JMAP address book #176 --- app/[locale]/page.tsx | 10 +++ components/email/email-viewer.tsx | 17 +++- components/email/thread-conversation-view.tsx | 14 ++- components/settings/email-settings.tsx | 13 ++- components/trusted-senders-modal.tsx | 81 +++++++++++++---- lib/debug.ts | 2 +- lib/jmap/client-interface.ts | 1 + lib/jmap/client.ts | 21 +++++ locales/en/common.json | 9 +- stores/contact-store.ts | 87 +++++++++++++++++++ stores/settings-store.ts | 5 +- 11 files changed, 232 insertions(+), 28 deletions(-) diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index aa7ebf5d..2263d185 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -14,6 +14,7 @@ import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; import { useEmailStore } from "@/stores/email-store"; import { useAuthStore, redirectToLogin } from "@/stores/auth-store"; import { useSettingsStore } from "@/stores/settings-store"; +import { useContactStore } from "@/stores/contact-store"; import { useIdentityStore } from "@/stores/identity-store"; import { useUIStore } from "@/stores/ui-store"; import { useDeviceDetection } from "@/hooks/use-media-query"; @@ -80,6 +81,15 @@ export default function Home() { const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore(); const { identities } = useIdentityStore(); useIdentitySync(); + const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook); + const { loadTrustedSendersBook, trustedSendersLoaded } = useContactStore(); + + // Load trusted senders address book when feature is enabled + useEffect(() => { + if (trustedSendersAddressBook && client && !trustedSendersLoaded) { + loadTrustedSendersBook(client); + } + }, [trustedSendersAddressBook, client, trustedSendersLoaded, loadTrustedSendersBook]); useEffect(() => { if (!isRateLimited || !rateLimitUntil) { diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 8003dc95..e5000d14 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -888,6 +888,9 @@ export function EmailViewer({ const attachmentPosition = useSettingsStore((state) => state.attachmentPosition); const addTrustedSender = useSettingsStore((state) => state.addTrustedSender); const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted); + const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook); + const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender); + const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook); const emailKeywords = useSettingsStore((state) => state.emailKeywords); const toolbarPosition = useSettingsStore((state) => state.toolbarPosition); const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels); @@ -2311,9 +2314,11 @@ export function EmailViewer({ // Use shared sanitization config as base (more secure) const sanitizeConfig = { ...EMAIL_SANITIZE_CONFIG }; - // Check if sender is trusted + // Check if sender is trusted (localStorage list or address book) const senderEmail = email.from?.[0]?.email?.toLowerCase(); - const senderIsTrusted = senderEmail ? isSenderTrusted(senderEmail) : false; + const senderIsTrusted = senderEmail + ? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail)) + : false; // Block external content based on policy: // 'allow' = never block, 'block' = always block (unless trusted), 'ask' = block until user allows or trusted @@ -2420,7 +2425,7 @@ export function EmailViewer({ html: '

No content available

', isHtml: false }; - }, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, cidBlobUrls]); + }, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, isTrustedAddressBookSender, trustedSendersAddressBook, cidBlobUrls]); // Override email content with S/MIME decrypted content when available const effectiveEmailContent = useMemo(() => { @@ -4565,7 +4570,11 @@ export function EmailViewer({ onClick={() => { const senderEmail = email.from?.[0]?.email; if (senderEmail) { - addTrustedSender(senderEmail); + if (trustedSendersAddressBook && client) { + addToTrustedSendersBook(client, senderEmail).catch(console.error); + } else { + addTrustedSender(senderEmail); + } setAllowExternalContent(true); } }} diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index 3c746df4..27908391 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -31,6 +31,7 @@ import { } from "lucide-react"; import { useTranslations } from "next-intl"; import { useSettingsStore } from "@/stores/settings-store"; +import { useContactStore } from "@/stores/contact-store"; import { useAuthStore } from "@/stores/auth-store"; import { isFilePreviewable } from "@/lib/file-preview"; @@ -84,6 +85,9 @@ export function ThreadConversationView({ const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy); const addTrustedSender = useSettingsStore((state) => state.addTrustedSender); const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted); + const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook); + const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender); + const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook); // Track which emails are expanded (most recent by default) const [expandedIds, setExpandedIds] = useState>(new Set()); @@ -164,7 +168,9 @@ export function ThreadConversationView({
{emails.map((email, index) => { const senderEmail = email.from?.[0]?.email?.toLowerCase(); - const senderIsTrusted = senderEmail ? isSenderTrusted(senderEmail) : false; + const senderIsTrusted = senderEmail + ? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail)) + : false; return ( toggleExpanded(email.id)} onAllowExternal={() => toggleAllowExternal(email.id)} onTrustSender={senderEmail ? () => { - addTrustedSender(senderEmail); + if (trustedSendersAddressBook && client) { + addToTrustedSendersBook(client, senderEmail).catch(console.error); + } else { + addTrustedSender(senderEmail); + } toggleAllowExternal(email.id); } : undefined} onReply={onReply ? () => onReply(email) : undefined} diff --git a/components/settings/email-settings.tsx b/components/settings/email-settings.tsx index d77126b6..d8d3fdda 100644 --- a/components/settings/email-settings.tsx +++ b/components/settings/email-settings.tsx @@ -13,6 +13,7 @@ import { RadioGroup, SettingsSection, SettingItem, Select, ToggleSwitch } from ' import { TrustedSendersModal } from '@/components/trusted-senders-modal'; import { ChevronRight, AlertTriangle, FolderSync, Loader2, Mail, X } from 'lucide-react'; import { usePolicyStore } from '@/stores/policy-store'; +import { useContactStore } from '@/stores/contact-store'; const MAIL_LAYOUT_PREVIEW_ROWS = [ { sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false }, @@ -131,14 +132,16 @@ export function EmailSettings() { hoverActionsMode, hoverActionsCorner, trustedSenders, + trustedSendersAddressBook, attachmentReminderEnabled, attachmentReminderKeywords, updateSetting, } = useSettingsStore(); + const { trustedSenderEmails } = useContactStore(); // Get count label for trusted senders button const getTrustedSendersCount = () => { - const count = trustedSenders.length; + const count = trustedSendersAddressBook ? trustedSenderEmails.length : trustedSenders.length; if (count === 0) return t('trusted_senders.count_zero'); if (count === 1) return t('trusted_senders.count_one'); return t('trusted_senders.count_other', { count }); @@ -572,6 +575,14 @@ export function EmailSettings() { + {/* Trusted Senders — address book storage */} + + updateSetting('trustedSendersAddressBook', checked)} + /> + + {/* Trusted Senders Modal */} (null); const inputRef = useRef(null); - const { trustedSenders, addTrustedSender, removeTrustedSender } = useSettingsStore(); + const { trustedSenders, addTrustedSender, removeTrustedSender, trustedSendersAddressBook } = useSettingsStore(); + const { + trustedSenderEmails, + trustedSendersLoaded, + trustedSendersLoading, + loadTrustedSendersBook, + addToTrustedSendersBook, + removeFromTrustedSendersBook, + } = useContactStore(); + const { client } = useAuthStore(); const [searchQuery, setSearchQuery] = useState(""); const [isAdding, setIsAdding] = useState(false); const [newEmail, setNewEmail] = useState(""); const [emailError, setEmailError] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + // When address book mode is on, load the book on first open + useEffect(() => { + if (isOpen && trustedSendersAddressBook && client && !trustedSendersLoaded) { + loadTrustedSendersBook(client); + } + }, [isOpen, trustedSendersAddressBook, client, trustedSendersLoaded, loadTrustedSendersBook]); + + // The active list depends on mode + const activeSenders = trustedSendersAddressBook ? trustedSenderEmails : trustedSenders; + const isLoading = trustedSendersAddressBook && (!trustedSendersLoaded || trustedSendersLoading); // Filter senders based on search query const filteredSenders = useMemo(() => { - if (!searchQuery.trim()) return trustedSenders; + if (!searchQuery.trim()) return activeSenders; const query = searchQuery.toLowerCase(); - return trustedSenders.filter((email) => email.toLowerCase().includes(query)); - }, [trustedSenders, searchQuery]); + return activeSenders.filter((email) => email.toLowerCase().includes(query)); + }, [activeSenders, searchQuery]); // Show search only when 5+ senders - const showSearch = trustedSenders.length >= 5; + const showSearch = activeSenders.length >= 5; // Close on Escape key useEffect(() => { @@ -90,7 +113,7 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp return emailRegex.test(email); }; - const handleAddSender = () => { + const handleAddSender = async () => { const trimmedEmail = newEmail.trim().toLowerCase(); if (!trimmedEmail) { @@ -103,15 +126,34 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp return; } - if (trustedSenders.includes(trimmedEmail)) { + if (activeSenders.includes(trimmedEmail)) { setEmailError(t("already_added")); return; } - addTrustedSender(trimmedEmail); - setNewEmail(""); - setIsAdding(false); - setEmailError(""); + setIsSubmitting(true); + try { + if (trustedSendersAddressBook && client) { + await addToTrustedSendersBook(client, trimmedEmail); + } else { + addTrustedSender(trimmedEmail); + } + setNewEmail(""); + setIsAdding(false); + setEmailError(""); + } catch { + setEmailError(t("save_error")); + } finally { + setIsSubmitting(false); + } + }; + + const handleRemoveSender = async (email: string) => { + if (trustedSendersAddressBook && client) { + await removeFromTrustedSendersBook(client, email); + } else { + removeTrustedSender(email); + } }; const handleKeyDown = (e: React.KeyboardEvent) => { @@ -170,7 +212,11 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp {/* Content */}
- {trustedSenders.length === 0 ? ( + {isLoading ? ( +
+ +
+ ) : activeSenders.length === 0 ? ( /* Empty State */
@@ -209,7 +255,7 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp {email}
{/* Footer - Add sender */} - {trustedSenders.length > 0 && ( + {!isLoading && activeSenders.length > 0 && (
{isAdding ? (
@@ -244,9 +290,10 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp />
{emailError && ( diff --git a/lib/debug.ts b/lib/debug.ts index 116235b2..d8049dac 100644 --- a/lib/debug.ts +++ b/lib/debug.ts @@ -104,7 +104,7 @@ export const debug = { } }; -const CATEGORY_KEYS = new Set(['jmap', 'calendar', 'tasks', 'auth', 'filters', 'email', 'push']); +const CATEGORY_KEYS = new Set(['jmap', 'calendar', 'tasks', 'auth', 'filters', 'email', 'push', 'contacts']); function isCategoryKey(value: string): value is DebugCategory { return CATEGORY_KEYS.has(value); } diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 2ee86860..8b1a225b 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -178,6 +178,7 @@ export interface IJMAPClient { getContactsAccountId(): string; getAddressBooks(): Promise; getAllAddressBooks(): Promise; + createAddressBook(name: string): Promise; updateAddressBook(addressBookId: string, updates: Partial, targetAccountId?: string): Promise; getContacts(addressBookId?: string): Promise; getAllContacts(): Promise; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index cddd4bea..5f5c9771 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -2818,6 +2818,27 @@ export class JMAPClient implements IJMAPClient { } } + async createAddressBook(name: string): Promise { + const accountId = this.getContactsAccountId(); + const response = await this.request([ + ["AddressBook/set", { + accountId, + create: { "new-book": { name } }, + }, "0"] + ], this.contactUsing()); + + if (response.methodResponses?.[0]?.[0] === "AddressBook/set") { + const result = response.methodResponses[0][1]; + const created = result.created?.["new-book"]; + if (created) { + return { id: created.id, name, ...created } as AddressBook; + } + const err = result.notCreated?.["new-book"]; + throw new Error(err?.description || "Failed to create address book"); + } + throw new Error("Failed to create address book"); + } + async updateAddressBook(addressBookId: string, updates: Partial, targetAccountId?: string): Promise { const accountId = targetAccountId || this.getContactsAccountId(); // Only forward server-settable properties diff --git a/locales/en/common.json b/locales/en/common.json index 8062a10e..8a8a6549 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -893,7 +893,10 @@ "remove": "Remove", "close": "Close", "invalid_email": "Please enter a valid email address", - "already_added": "This sender is already trusted" + "already_added": "This sender is already trusted", + "save_error": "Failed to save — check the Contacts debug log for details", + "use_address_book_label": "Sync with address book", + "use_address_book_description": "Store trusted senders in a dedicated \"Trusted Senders\" address book so they sync across all your devices" }, "hover_actions": { "label": "Quick Hover Actions", @@ -1197,7 +1200,9 @@ "email": "Email Viewing", "email_description": "Email rendering, TNEF processing, and mark-as-read", "push": "Push Notifications", - "push_description": "Push notification setup and delivery" + "push_description": "Push notification setup and delivery", + "contacts": "Contacts & Address Books", + "contacts_description": "Contact sync, address book operations, and trusted senders" }, "settings_sync": { "label": "Settings Sync", diff --git a/stores/contact-store.ts b/stores/contact-store.ts index dd844513..ee80767d 100644 --- a/stores/contact-store.ts +++ b/stores/contact-store.ts @@ -3,6 +3,7 @@ import { persist } from 'zustand/middleware'; import type { ContactCard, AddressBook, ContactName } from '@/lib/jmap/types'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; import { generateUUID } from '@/lib/utils'; +import { debug } from '@/lib/debug'; export function getContactDisplayName(contact: ContactCard): string { if (contact.name) { @@ -44,6 +45,8 @@ export function getContactPhotoUri(contact: ContactCard): string | undefined { return undefined; } +export const TRUSTED_SENDERS_BOOK_NAME = 'Trusted Senders'; + interface ContactStore { contacts: ContactCard[]; addressBooks: AddressBook[]; @@ -53,6 +56,12 @@ interface ContactStore { error: string | null; supportsSync: boolean; + // Trusted senders address book cache (runtime only, not persisted) + trustedSenderEmails: string[]; + trustedSendersBookId: string | null; + trustedSendersLoaded: boolean; + trustedSendersLoading: boolean; + selectedContactIds: Set; lastSelectedContactId: string | null; activeTab: 'all' | 'groups'; @@ -95,6 +104,12 @@ interface ContactStore { renameKeyword: (client: IJMAPClient | null, oldKeyword: string, newKeyword: string) => Promise; importContacts: (client: IJMAPClient | null, contacts: ContactCard[]) => Promise; + + // Trusted senders address book + loadTrustedSendersBook: (client: IJMAPClient) => Promise; + addToTrustedSendersBook: (client: IJMAPClient, email: string) => Promise; + removeFromTrustedSendersBook: (client: IJMAPClient, email: string) => Promise; + isTrustedAddressBookSender: (email: string) => boolean; } export const useContactStore = create()( @@ -139,6 +154,10 @@ export const useContactStore = create()( isLoading: false, error: null, supportsSync: false, + trustedSenderEmails: [], + trustedSendersBookId: null, + trustedSendersLoaded: false, + trustedSendersLoading: false, selectedContactIds: new Set(), lastSelectedContactId: null, activeTab: 'all' as const, @@ -667,6 +686,74 @@ export const useContactStore = create()( } }, + loadTrustedSendersBook: async (client) => { + if (get().trustedSendersLoading) return; + set({ trustedSendersLoading: true }); + try { + debug.log('contacts', 'Loading trusted senders address book'); + const books = await client.getAddressBooks(); + let book = books.find(b => b.name === TRUSTED_SENDERS_BOOK_NAME); + if (!book) { + debug.log('contacts', 'Creating new trusted senders address book'); + book = await client.createAddressBook(TRUSTED_SENDERS_BOOK_NAME); + } + const bookId = book.id; + debug.log('contacts', 'Trusted senders book id:', bookId); + const contacts = await client.getContacts(bookId); + debug.log('contacts', 'Loaded', contacts.length, 'trusted sender contacts'); + const emails = contacts.flatMap(c => + c.emails ? Object.values(c.emails).map(e => e.address.toLowerCase().trim()) : [] + ).filter(Boolean); + set({ trustedSendersBookId: bookId, trustedSenderEmails: emails, trustedSendersLoaded: true, trustedSendersLoading: false }); + } catch (error) { + debug.error('Failed to load trusted senders address book:', error); + set({ trustedSendersLoaded: true, trustedSendersLoading: false }); + } + }, + + addToTrustedSendersBook: async (client, email) => { + const normalizedEmail = email.toLowerCase().trim(); + const { trustedSenderEmails } = get(); + if (trustedSenderEmails.includes(normalizedEmail)) return; + + let bookId = get().trustedSendersBookId; + if (!bookId) { + await get().loadTrustedSendersBook(client); + bookId = get().trustedSendersBookId; + } + if (!bookId) throw new Error('Could not find or create trusted senders address book'); + + debug.log('contacts', 'Adding trusted sender:', normalizedEmail, 'to book:', bookId); + await client.createContact({ + addressBookIds: { [bookId]: true }, + emails: { email: { address: normalizedEmail } }, + }); + set((state) => ({ trustedSenderEmails: [...state.trustedSenderEmails, normalizedEmail] })); + debug.log('contacts', 'Trusted sender added successfully'); + }, + + removeFromTrustedSendersBook: async (client, email) => { + const normalizedEmail = email.toLowerCase().trim(); + const { trustedSendersBookId } = get(); + if (!trustedSendersBookId) return; + + debug.log('contacts', 'Removing trusted sender:', normalizedEmail); + const contacts = await client.getContacts(trustedSendersBookId); + const match = contacts.find(c => + c.emails && Object.values(c.emails).some(e => e.address.toLowerCase().trim() === normalizedEmail) + ); + if (match) { + await client.deleteContact(match.id); + debug.log('contacts', 'Trusted sender removed'); + } + set((state) => ({ trustedSenderEmails: state.trustedSenderEmails.filter(e => e !== normalizedEmail) })); + }, + + isTrustedAddressBookSender: (email) => { + const normalizedEmail = email.toLowerCase().trim(); + return get().trustedSenderEmails.includes(normalizedEmail); + }, + importContacts: async (client, contacts) => { const { supportsSync } = get(); let imported = 0; diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 870d9fab..8a5f5fb9 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -49,7 +49,7 @@ export const ALL_HOVER_ACTIONS: { id: HoverAction; labelKey: string }[] = [ { id: 'spam', labelKey: 'spam' }, ]; -export type DebugCategory = 'jmap' | 'calendar' | 'tasks' | 'auth' | 'filters' | 'email' | 'push'; +export type DebugCategory = 'jmap' | 'calendar' | 'tasks' | 'auth' | 'filters' | 'email' | 'push' | 'contacts'; export const ALL_DEBUG_CATEGORIES: { id: DebugCategory; labelKey: string }[] = [ { id: 'jmap', labelKey: 'jmap' }, @@ -59,6 +59,7 @@ export const ALL_DEBUG_CATEGORIES: { id: DebugCategory; labelKey: string }[] = [ { id: 'filters', labelKey: 'filters' }, { id: 'email', labelKey: 'email' }, { id: 'push', labelKey: 'push' }, + { id: 'contacts', labelKey: 'contacts' }, ]; export interface KeywordDefinition { @@ -140,6 +141,7 @@ interface SettingsState { // Privacy & Security sessionTimeout: number; // minutes (0 = never) trustedSenders: string[]; // Email addresses that can load external content + trustedSendersAddressBook: boolean; // Store trusted senders in a dedicated JMAP address book // Filters expandedFilterView: boolean; @@ -273,6 +275,7 @@ const DEFAULT_SETTINGS = { // Privacy & Security sessionTimeout: 0, // Never trustedSenders: [] as string[], + trustedSendersAddressBook: false, // Filters expandedFilterView: false,