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
+10
View File
@@ -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) {
+12 -3
View File
@@ -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: '<p style="color: var(--color-muted-foreground);">No content available</p>',
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) {
if (trustedSendersAddressBook && client) {
addToTrustedSendersBook(client, senderEmail).catch(console.error);
} else {
addTrustedSender(senderEmail);
}
setAllowExternalContent(true);
}
}}
+11 -1
View File
@@ -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<Set<string>>(new Set());
@@ -164,7 +168,9 @@ export function ThreadConversationView({
<div className="space-y-3" style={{ padding: 'var(--density-card-p)' }}>
{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 (
<EmailCard
key={email.id}
@@ -175,7 +181,11 @@ export function ThreadConversationView({
onToggleExpanded={() => toggleExpanded(email.id)}
onAllowExternal={() => toggleAllowExternal(email.id)}
onTrustSender={senderEmail ? () => {
if (trustedSendersAddressBook && client) {
addToTrustedSendersBook(client, senderEmail).catch(console.error);
} else {
addTrustedSender(senderEmail);
}
toggleAllowExternal(email.id);
} : undefined}
onReply={onReply ? () => onReply(email) : undefined}
+12 -1
View File
@@ -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() {
</button>
</SettingItem>
{/* Trusted Senders — address book storage */}
<SettingItem label={t('trusted_senders.use_address_book_label')} description={t('trusted_senders.use_address_book_description')}>
<ToggleSwitch
checked={trustedSendersAddressBook}
onChange={(checked) => updateSetting('trustedSendersAddressBook', checked)}
/>
</SettingItem>
{/* Trusted Senders Modal */}
<TrustedSendersModal
isOpen={showTrustedModal}
+60 -13
View File
@@ -2,9 +2,11 @@
import { useState, useEffect, useRef, useMemo } from "react";
import { useTranslations } from "next-intl";
import { X, ShieldCheck, Search, Trash2, Plus } from "lucide-react";
import { X, ShieldCheck, Search, Trash2, Plus, Loader2 } from "lucide-react";
import { Avatar } from "@/components/ui/avatar";
import { useSettingsStore } from "@/stores/settings-store";
import { useContactStore } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store";
import { cn } from "@/lib/utils";
interface TrustedSendersModalProps {
@@ -17,22 +19,43 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
const modalRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(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;
}
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<HTMLInputElement>) => {
@@ -170,7 +212,11 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
{/* Content */}
<div className="flex-1 overflow-y-auto">
{trustedSenders.length === 0 ? (
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : activeSenders.length === 0 ? (
/* Empty State */
<div className="flex flex-col items-center justify-center py-12 px-6 text-center">
<ShieldCheck className="w-12 h-12 text-muted-foreground/50 mb-4" />
@@ -209,7 +255,7 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
{email}
</span>
<button
onClick={() => removeTrustedSender(email)}
onClick={() => handleRemoveSender(email)}
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100"
aria-label={`${t("remove")} ${email}`}
>
@@ -222,7 +268,7 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
</div>
{/* Footer - Add sender */}
{trustedSenders.length > 0 && (
{!isLoading && activeSenders.length > 0 && (
<div className="px-6 py-4 border-t border-border flex-shrink-0">
{isAdding ? (
<div className="space-y-2">
@@ -244,9 +290,10 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
/>
<button
onClick={handleAddSender}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors text-sm font-medium"
disabled={isSubmitting}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors text-sm font-medium disabled:opacity-50"
>
{t("add_button")}
{isSubmitting ? <Loader2 className="w-4 h-4 animate-spin" /> : t("add_button")}
</button>
</div>
{emailError && (
+1 -1
View File
@@ -104,7 +104,7 @@ export const debug = {
}
};
const CATEGORY_KEYS = new Set<string>(['jmap', 'calendar', 'tasks', 'auth', 'filters', 'email', 'push']);
const CATEGORY_KEYS = new Set<string>(['jmap', 'calendar', 'tasks', 'auth', 'filters', 'email', 'push', 'contacts']);
function isCategoryKey(value: string): value is DebugCategory {
return CATEGORY_KEYS.has(value);
}
+1
View File
@@ -178,6 +178,7 @@ export interface IJMAPClient {
getContactsAccountId(): string;
getAddressBooks(): Promise<AddressBook[]>;
getAllAddressBooks(): Promise<AddressBook[]>;
createAddressBook(name: string): Promise<AddressBook>;
updateAddressBook(addressBookId: string, updates: Partial<AddressBook>, targetAccountId?: string): Promise<void>;
getContacts(addressBookId?: string): Promise<ContactCard[]>;
getAllContacts(): Promise<ContactCard[]>;
+21
View File
@@ -2818,6 +2818,27 @@ export class JMAPClient implements IJMAPClient {
}
}
async createAddressBook(name: string): Promise<AddressBook> {
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<AddressBook>, targetAccountId?: string): Promise<void> {
const accountId = targetAccountId || this.getContactsAccountId();
// Only forward server-settable properties
+7 -2
View File
@@ -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",
+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,