feat: store trusted senders in a dedicated JMAP address book #176
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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[]>;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user