feat: store trusted senders in a dedicated JMAP address book #176

This commit is contained in:
Linus Rath
2026-04-12 01:10:28 +02:00
parent 4ad8396adc
commit 927a3b8b11
11 changed files with 232 additions and 28 deletions
+87
View File
@@ -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<string>;
lastSelectedContactId: string | null;
activeTab: 'all' | 'groups';
@@ -95,6 +104,12 @@ interface ContactStore {
renameKeyword: (client: IJMAPClient | null, oldKeyword: string, newKeyword: string) => Promise<void>;
importContacts: (client: IJMAPClient | null, contacts: ContactCard[]) => Promise<number>;
// Trusted senders address book
loadTrustedSendersBook: (client: IJMAPClient) => Promise<void>;
addToTrustedSendersBook: (client: IJMAPClient, email: string) => Promise<void>;
removeFromTrustedSendersBook: (client: IJMAPClient, email: string) => Promise<void>;
isTrustedAddressBookSender: (email: string) => boolean;
}
export const useContactStore = create<ContactStore>()(
@@ -139,6 +154,10 @@ export const useContactStore = create<ContactStore>()(
isLoading: false,
error: null,
supportsSync: false,
trustedSenderEmails: [],
trustedSendersBookId: null,
trustedSendersLoaded: false,
trustedSendersLoading: false,
selectedContactIds: new Set<string>(),
lastSelectedContactId: null,
activeTab: 'all' as const,
@@ -667,6 +686,74 @@ export const useContactStore = create<ContactStore>()(
}
},
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;
+4 -1
View File
@@ -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,