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 { 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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[]>;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user