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 { useEmailStore } from "@/stores/email-store";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store"; import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { useContactStore } from "@/stores/contact-store";
import { useIdentityStore } from "@/stores/identity-store"; import { useIdentityStore } from "@/stores/identity-store";
import { useUIStore } from "@/stores/ui-store"; import { useUIStore } from "@/stores/ui-store";
import { useDeviceDetection } from "@/hooks/use-media-query"; 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 { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore();
const { identities } = useIdentityStore(); const { identities } = useIdentityStore();
useIdentitySync(); 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(() => { useEffect(() => {
if (!isRateLimited || !rateLimitUntil) { if (!isRateLimited || !rateLimitUntil) {
+13 -4
View File
@@ -888,6 +888,9 @@ export function EmailViewer({
const attachmentPosition = useSettingsStore((state) => state.attachmentPosition); const attachmentPosition = useSettingsStore((state) => state.attachmentPosition);
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender); const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted); 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 emailKeywords = useSettingsStore((state) => state.emailKeywords);
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition); const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels); const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
@@ -2311,9 +2314,11 @@ export function EmailViewer({
// Use shared sanitization config as base (more secure) // Use shared sanitization config as base (more secure)
const sanitizeConfig = { ...EMAIL_SANITIZE_CONFIG }; 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 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: // Block external content based on policy:
// 'allow' = never block, 'block' = always block (unless trusted), 'ask' = block until user allows or trusted // '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>', html: '<p style="color: var(--color-muted-foreground);">No content available</p>',
isHtml: false 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 // Override email content with S/MIME decrypted content when available
const effectiveEmailContent = useMemo(() => { const effectiveEmailContent = useMemo(() => {
@@ -4565,7 +4570,11 @@ export function EmailViewer({
onClick={() => { onClick={() => {
const senderEmail = email.from?.[0]?.email; const senderEmail = email.from?.[0]?.email;
if (senderEmail) { if (senderEmail) {
addTrustedSender(senderEmail); if (trustedSendersAddressBook && client) {
addToTrustedSendersBook(client, senderEmail).catch(console.error);
} else {
addTrustedSender(senderEmail);
}
setAllowExternalContent(true); setAllowExternalContent(true);
} }
}} }}
+12 -2
View File
@@ -31,6 +31,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { useContactStore } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { isFilePreviewable } from "@/lib/file-preview"; import { isFilePreviewable } from "@/lib/file-preview";
@@ -84,6 +85,9 @@ export function ThreadConversationView({
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy); const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender); const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted); 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) // Track which emails are expanded (most recent by default)
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set()); 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)' }}> <div className="space-y-3" style={{ padding: 'var(--density-card-p)' }}>
{emails.map((email, index) => { {emails.map((email, index) => {
const senderEmail = email.from?.[0]?.email?.toLowerCase(); const senderEmail = email.from?.[0]?.email?.toLowerCase();
const senderIsTrusted = senderEmail ? isSenderTrusted(senderEmail) : false; const senderIsTrusted = senderEmail
? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail))
: false;
return ( return (
<EmailCard <EmailCard
key={email.id} key={email.id}
@@ -175,7 +181,11 @@ export function ThreadConversationView({
onToggleExpanded={() => toggleExpanded(email.id)} onToggleExpanded={() => toggleExpanded(email.id)}
onAllowExternal={() => toggleAllowExternal(email.id)} onAllowExternal={() => toggleAllowExternal(email.id)}
onTrustSender={senderEmail ? () => { onTrustSender={senderEmail ? () => {
addTrustedSender(senderEmail); if (trustedSendersAddressBook && client) {
addToTrustedSendersBook(client, senderEmail).catch(console.error);
} else {
addTrustedSender(senderEmail);
}
toggleAllowExternal(email.id); toggleAllowExternal(email.id);
} : undefined} } : undefined}
onReply={onReply ? () => onReply(email) : 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 { TrustedSendersModal } from '@/components/trusted-senders-modal';
import { ChevronRight, AlertTriangle, FolderSync, Loader2, Mail, X } from 'lucide-react'; import { ChevronRight, AlertTriangle, FolderSync, Loader2, Mail, X } from 'lucide-react';
import { usePolicyStore } from '@/stores/policy-store'; import { usePolicyStore } from '@/stores/policy-store';
import { useContactStore } from '@/stores/contact-store';
const MAIL_LAYOUT_PREVIEW_ROWS = [ const MAIL_LAYOUT_PREVIEW_ROWS = [
{ sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false }, { sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false },
@@ -131,14 +132,16 @@ export function EmailSettings() {
hoverActionsMode, hoverActionsMode,
hoverActionsCorner, hoverActionsCorner,
trustedSenders, trustedSenders,
trustedSendersAddressBook,
attachmentReminderEnabled, attachmentReminderEnabled,
attachmentReminderKeywords, attachmentReminderKeywords,
updateSetting, updateSetting,
} = useSettingsStore(); } = useSettingsStore();
const { trustedSenderEmails } = useContactStore();
// Get count label for trusted senders button // Get count label for trusted senders button
const getTrustedSendersCount = () => { const getTrustedSendersCount = () => {
const count = trustedSenders.length; const count = trustedSendersAddressBook ? trustedSenderEmails.length : trustedSenders.length;
if (count === 0) return t('trusted_senders.count_zero'); if (count === 0) return t('trusted_senders.count_zero');
if (count === 1) return t('trusted_senders.count_one'); if (count === 1) return t('trusted_senders.count_one');
return t('trusted_senders.count_other', { count }); return t('trusted_senders.count_other', { count });
@@ -572,6 +575,14 @@ export function EmailSettings() {
</button> </button>
</SettingItem> </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 */} {/* Trusted Senders Modal */}
<TrustedSendersModal <TrustedSendersModal
isOpen={showTrustedModal} isOpen={showTrustedModal}
+64 -17
View File
@@ -2,9 +2,11 @@
import { useState, useEffect, useRef, useMemo } from "react"; import { useState, useEffect, useRef, useMemo } from "react";
import { useTranslations } from "next-intl"; 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 { Avatar } from "@/components/ui/avatar";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { useContactStore } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface TrustedSendersModalProps { interface TrustedSendersModalProps {
@@ -17,22 +19,43 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
const modalRef = useRef<HTMLDivElement>(null); const modalRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(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 [searchQuery, setSearchQuery] = useState("");
const [isAdding, setIsAdding] = useState(false); const [isAdding, setIsAdding] = useState(false);
const [newEmail, setNewEmail] = useState(""); const [newEmail, setNewEmail] = useState("");
const [emailError, setEmailError] = 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 // Filter senders based on search query
const filteredSenders = useMemo(() => { const filteredSenders = useMemo(() => {
if (!searchQuery.trim()) return trustedSenders; if (!searchQuery.trim()) return activeSenders;
const query = searchQuery.toLowerCase(); const query = searchQuery.toLowerCase();
return trustedSenders.filter((email) => email.toLowerCase().includes(query)); return activeSenders.filter((email) => email.toLowerCase().includes(query));
}, [trustedSenders, searchQuery]); }, [activeSenders, searchQuery]);
// Show search only when 5+ senders // Show search only when 5+ senders
const showSearch = trustedSenders.length >= 5; const showSearch = activeSenders.length >= 5;
// Close on Escape key // Close on Escape key
useEffect(() => { useEffect(() => {
@@ -90,7 +113,7 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
return emailRegex.test(email); return emailRegex.test(email);
}; };
const handleAddSender = () => { const handleAddSender = async () => {
const trimmedEmail = newEmail.trim().toLowerCase(); const trimmedEmail = newEmail.trim().toLowerCase();
if (!trimmedEmail) { if (!trimmedEmail) {
@@ -103,15 +126,34 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
return; return;
} }
if (trustedSenders.includes(trimmedEmail)) { if (activeSenders.includes(trimmedEmail)) {
setEmailError(t("already_added")); setEmailError(t("already_added"));
return; return;
} }
addTrustedSender(trimmedEmail); setIsSubmitting(true);
setNewEmail(""); try {
setIsAdding(false); if (trustedSendersAddressBook && client) {
setEmailError(""); 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>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
@@ -170,7 +212,11 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto"> <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 */ /* Empty State */
<div className="flex flex-col items-center justify-center py-12 px-6 text-center"> <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" /> <ShieldCheck className="w-12 h-12 text-muted-foreground/50 mb-4" />
@@ -209,7 +255,7 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
{email} {email}
</span> </span>
<button <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" 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}`} aria-label={`${t("remove")} ${email}`}
> >
@@ -222,7 +268,7 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
</div> </div>
{/* Footer - Add sender */} {/* Footer - Add sender */}
{trustedSenders.length > 0 && ( {!isLoading && activeSenders.length > 0 && (
<div className="px-6 py-4 border-t border-border flex-shrink-0"> <div className="px-6 py-4 border-t border-border flex-shrink-0">
{isAdding ? ( {isAdding ? (
<div className="space-y-2"> <div className="space-y-2">
@@ -244,9 +290,10 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
/> />
<button <button
onClick={handleAddSender} 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> </button>
</div> </div>
{emailError && ( {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 { function isCategoryKey(value: string): value is DebugCategory {
return CATEGORY_KEYS.has(value); return CATEGORY_KEYS.has(value);
} }
+1
View File
@@ -178,6 +178,7 @@ export interface IJMAPClient {
getContactsAccountId(): string; getContactsAccountId(): string;
getAddressBooks(): Promise<AddressBook[]>; getAddressBooks(): Promise<AddressBook[]>;
getAllAddressBooks(): Promise<AddressBook[]>; getAllAddressBooks(): Promise<AddressBook[]>;
createAddressBook(name: string): Promise<AddressBook>;
updateAddressBook(addressBookId: string, updates: Partial<AddressBook>, targetAccountId?: string): Promise<void>; updateAddressBook(addressBookId: string, updates: Partial<AddressBook>, targetAccountId?: string): Promise<void>;
getContacts(addressBookId?: string): Promise<ContactCard[]>; getContacts(addressBookId?: string): Promise<ContactCard[]>;
getAllContacts(): 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> { async updateAddressBook(addressBookId: string, updates: Partial<AddressBook>, targetAccountId?: string): Promise<void> {
const accountId = targetAccountId || this.getContactsAccountId(); const accountId = targetAccountId || this.getContactsAccountId();
// Only forward server-settable properties // Only forward server-settable properties
+7 -2
View File
@@ -893,7 +893,10 @@
"remove": "Remove", "remove": "Remove",
"close": "Close", "close": "Close",
"invalid_email": "Please enter a valid email address", "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": { "hover_actions": {
"label": "Quick Hover Actions", "label": "Quick Hover Actions",
@@ -1197,7 +1200,9 @@
"email": "Email Viewing", "email": "Email Viewing",
"email_description": "Email rendering, TNEF processing, and mark-as-read", "email_description": "Email rendering, TNEF processing, and mark-as-read",
"push": "Push Notifications", "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": { "settings_sync": {
"label": "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 { ContactCard, AddressBook, ContactName } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface'; import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { generateUUID } from '@/lib/utils'; import { generateUUID } from '@/lib/utils';
import { debug } from '@/lib/debug';
export function getContactDisplayName(contact: ContactCard): string { export function getContactDisplayName(contact: ContactCard): string {
if (contact.name) { if (contact.name) {
@@ -44,6 +45,8 @@ export function getContactPhotoUri(contact: ContactCard): string | undefined {
return undefined; return undefined;
} }
export const TRUSTED_SENDERS_BOOK_NAME = 'Trusted Senders';
interface ContactStore { interface ContactStore {
contacts: ContactCard[]; contacts: ContactCard[];
addressBooks: AddressBook[]; addressBooks: AddressBook[];
@@ -53,6 +56,12 @@ interface ContactStore {
error: string | null; error: string | null;
supportsSync: boolean; supportsSync: boolean;
// Trusted senders address book cache (runtime only, not persisted)
trustedSenderEmails: string[];
trustedSendersBookId: string | null;
trustedSendersLoaded: boolean;
trustedSendersLoading: boolean;
selectedContactIds: Set<string>; selectedContactIds: Set<string>;
lastSelectedContactId: string | null; lastSelectedContactId: string | null;
activeTab: 'all' | 'groups'; activeTab: 'all' | 'groups';
@@ -95,6 +104,12 @@ interface ContactStore {
renameKeyword: (client: IJMAPClient | null, oldKeyword: string, newKeyword: string) => Promise<void>; renameKeyword: (client: IJMAPClient | null, oldKeyword: string, newKeyword: string) => Promise<void>;
importContacts: (client: IJMAPClient | null, contacts: ContactCard[]) => Promise<number>; 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>()( export const useContactStore = create<ContactStore>()(
@@ -139,6 +154,10 @@ export const useContactStore = create<ContactStore>()(
isLoading: false, isLoading: false,
error: null, error: null,
supportsSync: false, supportsSync: false,
trustedSenderEmails: [],
trustedSendersBookId: null,
trustedSendersLoaded: false,
trustedSendersLoading: false,
selectedContactIds: new Set<string>(), selectedContactIds: new Set<string>(),
lastSelectedContactId: null, lastSelectedContactId: null,
activeTab: 'all' as const, 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) => { importContacts: async (client, contacts) => {
const { supportsSync } = get(); const { supportsSync } = get();
let imported = 0; let imported = 0;
+4 -1
View File
@@ -49,7 +49,7 @@ export const ALL_HOVER_ACTIONS: { id: HoverAction; labelKey: string }[] = [
{ id: 'spam', labelKey: 'spam' }, { 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 }[] = [ export const ALL_DEBUG_CATEGORIES: { id: DebugCategory; labelKey: string }[] = [
{ id: 'jmap', labelKey: 'jmap' }, { id: 'jmap', labelKey: 'jmap' },
@@ -59,6 +59,7 @@ export const ALL_DEBUG_CATEGORIES: { id: DebugCategory; labelKey: string }[] = [
{ id: 'filters', labelKey: 'filters' }, { id: 'filters', labelKey: 'filters' },
{ id: 'email', labelKey: 'email' }, { id: 'email', labelKey: 'email' },
{ id: 'push', labelKey: 'push' }, { id: 'push', labelKey: 'push' },
{ id: 'contacts', labelKey: 'contacts' },
]; ];
export interface KeywordDefinition { export interface KeywordDefinition {
@@ -140,6 +141,7 @@ interface SettingsState {
// Privacy & Security // Privacy & Security
sessionTimeout: number; // minutes (0 = never) sessionTimeout: number; // minutes (0 = never)
trustedSenders: string[]; // Email addresses that can load external content trustedSenders: string[]; // Email addresses that can load external content
trustedSendersAddressBook: boolean; // Store trusted senders in a dedicated JMAP address book
// Filters // Filters
expandedFilterView: boolean; expandedFilterView: boolean;
@@ -273,6 +275,7 @@ const DEFAULT_SETTINGS = {
// Privacy & Security // Privacy & Security
sessionTimeout: 0, // Never sessionTimeout: 0, // Never
trustedSenders: [] as string[], trustedSenders: [] as string[],
trustedSendersAddressBook: false,
// Filters // Filters
expandedFilterView: false, expandedFilterView: false,