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,