feat: add address book, fix email layout, update dependencies

- Address book with JMAP sync and local fallback (contacts CRUD,
  search/filter, composer autocomplete, i18n for 8 languages)
- Fix email layout: remove horizontal scroll, left-side clipping,
  and empty spaces from blocked external images in newsletters
- Update all dependencies to latest compatible versions
- Expand i18n from 3 to 8 languages (added ES, IT, DE, NL, PT)
- Upgrade Next.js to 16.1.6 for security patches
This commit is contained in:
Matthieu MALVACHE
2026-02-16 17:25:20 +01:00
committed by Matthieu MALVACHE
parent 5d60fe5186
commit 8a5bc9b88d
27 changed files with 2927 additions and 968 deletions
+11
View File
@@ -43,6 +43,16 @@ This webmail client is designed to work seamlessly with [**Stalwart Mail Server*
- Live email arrival notifications - Live email arrival notifications
- Connection status indicator - Connection status indicator
### Identity Management
- Multiple sender identities with per-identity signatures
- Sub-addressing support (user+tag@domain.com) with tag suggestions
- Identity badges in email viewer and list
### Address Book
- Contact management with search and filtering
- JMAP server sync (RFC 9553/9610) with local fallback
- Email autocomplete from contacts in composer
### Security & Privacy ### Security & Privacy
- External content blocked by default - External content blocked by default
- Trusted senders list for automatic image loading - Trusted senders list for automatic image loading
@@ -50,6 +60,7 @@ This webmail client is designed to work seamlessly with [**Stalwart Mail Server*
- SPF/DKIM/DMARC status indicators - SPF/DKIM/DMARC status indicators
- No password storage (session-based auth) - No password storage (session-based auth)
- Shared folder support with proper permissions - Shared folder support with proper permissions
- Newsletter unsubscribe support (RFC 2369)
### Internationalization ### Internationalization
- 8 language support: English, French, Japanese, Spanish, Italian, German, Dutch, Portuguese - 8 language support: English, French, Japanese, Spanish, Italian, German, Dutch, Portuguese
+19 -10
View File
@@ -96,10 +96,24 @@ This document tracks the development status and planned features for JMAP Webmai
- [x] Identity badges in email viewer and list - [x] Identity badges in email viewer and list
- [x] Tag suggestions based on context - [x] Tag suggestions based on context
### Address Book & Contacts
- [x] Contact store with JMAP sync and local fallback
- [x] Contact CRUD operations (create, read, update, delete)
- [x] Contacts list view with search/filter
- [x] Contact details view/edit form
- [x] JMAP contacts sync (RFC 9553/9610 ContactCard/AddressBook)
- [x] Email autocomplete from contacts
- [x] Contacts integration in email composer (To/Cc/Bcc)
- [x] i18n support for contacts (all 8 languages)
### Email Display
- [x] Proper email layout without horizontal scroll or clipping
- [x] Blocked image container collapsing (no empty spaces in newsletters)
### Testing ### Testing
- [x] Unit tests for validation utilities (57 tests) - [x] Unit tests for validation utilities (57 tests)
- [x] Unit tests for email sanitization - [x] Unit tests for email sanitization (27 tests)
- [x] Unit tests for color transformation - [x] Unit tests for color transformation (40 tests)
- [x] XSS attack vector testing - [x] XSS attack vector testing
### Deployment ### Deployment
@@ -107,15 +121,10 @@ This document tracks the development status and planned features for JMAP Webmai
## Planned Features ## Planned Features
### Address Book & Contacts ### Address Book (Phase 2)
- [ ] Contact store with CRUD operations - [ ] Contact groups/lists management
- [ ] Contacts list view with search/filter
- [ ] Contact details view/edit form
- [ ] Contact groups management
- [ ] vCard import/export - [ ] vCard import/export
- [ ] JMAP contacts sync (if server supports) - [ ] Bulk contact operations
- [ ] Email autocomplete from contacts
- [ ] Contacts integration in composer
### Advanced Features ### Advanced Features
- [ ] Email filters and rules - [ ] Email filters and rules
+170
View File
@@ -0,0 +1,170 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useRouter } from "@/i18n/navigation";
import { useTranslations } from "next-intl";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ContactList } from "@/components/contacts/contact-list";
import { ContactDetail } from "@/components/contacts/contact-detail";
import { ContactForm } from "@/components/contacts/contact-form";
import { useContactStore } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store";
import { toast } from "@/stores/toast-store";
import type { ContactCard } from "@/lib/jmap/types";
type View = "list" | "detail" | "create" | "edit";
export default function ContactsPage() {
const router = useRouter();
const t = useTranslations("contacts");
const { client, isAuthenticated } = useAuthStore();
const {
contacts,
selectedContactId,
searchQuery,
supportsSync,
setSelectedContact,
setSearchQuery,
fetchContacts,
createContact,
updateContact,
deleteContact,
addLocalContact,
updateLocalContact,
deleteLocalContact,
} = useContactStore();
const [view, setView] = useState<View>("list");
const hasFetched = useRef(false);
useEffect(() => {
if (!isAuthenticated) {
router.push("/login");
}
}, [isAuthenticated, router]);
useEffect(() => {
if (client && supportsSync && !hasFetched.current) {
hasFetched.current = true;
fetchContacts(client);
}
}, [client, supportsSync, fetchContacts]);
const selectedContact = contacts.find((c) => c.id === selectedContactId) || null;
const handleSelectContact = (id: string) => {
setSelectedContact(id);
setView("detail");
};
const handleCreateNew = () => {
setSelectedContact(null);
setView("create");
};
const handleEdit = () => {
setView("edit");
};
const handleDelete = async () => {
if (!selectedContact) return;
if (!window.confirm(t("delete_confirm"))) return;
try {
if (supportsSync && client) {
await deleteContact(client, selectedContact.id);
} else {
deleteLocalContact(selectedContact.id);
}
toast.success(t("toast.deleted"));
setView("list");
} catch {
toast.error(t("toast.error_delete"));
}
};
const handleSaveNew = useCallback(async (data: Partial<ContactCard>) => {
if (supportsSync && client) {
await createContact(client, data);
toast.success(t("toast.created"));
} else {
const localContact: ContactCard = {
id: `local-${crypto.randomUUID()}`,
addressBookIds: {},
...data,
};
addLocalContact(localContact);
toast.success(t("toast.created"));
}
setView("list");
}, [supportsSync, client, createContact, addLocalContact, t]);
const handleSaveEdit = useCallback(async (data: Partial<ContactCard>) => {
if (!selectedContact) return;
if (supportsSync && client) {
await updateContact(client, selectedContact.id, data);
toast.success(t("toast.updated"));
} else {
updateLocalContact(selectedContact.id, data);
toast.success(t("toast.updated"));
}
setView("detail");
}, [supportsSync, client, selectedContact, updateContact, updateLocalContact, t]);
const handleCancel = () => {
setView(selectedContact ? "detail" : "list");
};
if (!isAuthenticated) return null;
return (
<div className="flex h-screen bg-background">
<div className="w-80 border-r border-border flex flex-col">
<div className="p-4 border-b border-border">
<Button
variant="ghost"
size="sm"
onClick={() => router.push("/")}
className="w-full justify-start"
>
<ArrowLeft className="w-4 h-4 mr-2" />
{t("back_to_mail")}
</Button>
</div>
<ContactList
contacts={contacts}
selectedContactId={selectedContactId}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
onSelectContact={handleSelectContact}
onCreateNew={handleCreateNew}
supportsSync={supportsSync}
className="flex-1"
/>
</div>
<div className="flex-1">
{view === "create" && (
<ContactForm onSave={handleSaveNew} onCancel={handleCancel} />
)}
{view === "edit" && selectedContact && (
<ContactForm
contact={selectedContact}
onSave={handleSaveEdit}
onCancel={handleCancel}
/>
)}
{(view === "list" || view === "detail") && (
<ContactDetail
contact={selectedContact}
onEdit={handleEdit}
onDelete={handleDelete}
/>
)}
</div>
</div>
);
}
+2 -12
View File
@@ -88,9 +88,7 @@ body {
/* Enhanced Email Content Styling */ /* Enhanced Email Content Styling */
/* Wrapper uses inline-block to size to content, enabling horizontal scroll */
.email-content-wrapper { .email-content-wrapper {
display: inline-block;
min-width: 100%; min-width: 100%;
} }
@@ -100,16 +98,8 @@ body {
line-height: 1.6; line-height: 1.6;
color: var(--color-foreground); color: var(--color-foreground);
max-width: none; max-width: none;
/* Size to content's natural width, don't shrink below it */ overflow-wrap: break-word;
width: max-content; word-wrap: break-word;
min-width: 100%;
}
/* Prevent tables with width="100%" from shrinking below their content */
.email-content table[width="100%"],
.email-content table[style*="width:100%"],
.email-content table[style*="width: 100%"] {
min-width: max-content;
} }
.email-content p { .email-content p {
+153
View File
@@ -0,0 +1,153 @@
"use client";
import { useTranslations } from "next-intl";
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser } from "lucide-react";
import { Avatar } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { ContactCard } from "@/lib/jmap/types";
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
interface ContactDetailProps {
contact: ContactCard | null;
onEdit: () => void;
onDelete: () => void;
className?: string;
}
export function ContactDetail({ contact, onEdit, onDelete, className }: ContactDetailProps) {
const t = useTranslations("contacts");
if (!contact) {
return (
<div className={cn("flex flex-col items-center justify-center h-full text-muted-foreground", className)}>
<BookUser className="w-16 h-16 mb-4 opacity-20" />
<p className="text-sm">{t("detail.no_contact_selected")}</p>
</div>
);
}
const name = getContactDisplayName(contact);
const email = getContactPrimaryEmail(contact);
const emails = contact.emails ? Object.values(contact.emails) : [];
const phones = contact.phones ? Object.values(contact.phones) : [];
const orgs = contact.organizations ? Object.values(contact.organizations) : [];
const addresses = contact.addresses ? Object.values(contact.addresses) : [];
const notes = contact.notes ? Object.values(contact.notes) : [];
return (
<div className={cn("flex flex-col h-full overflow-y-auto", className)}>
<div className="px-6 py-6 border-b border-border">
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<Avatar name={name} email={email} size="lg" />
<div>
<h2 className="text-xl font-semibold">{name || "—"}</h2>
{orgs.length > 0 && orgs[0].name && (
<p className="text-sm text-muted-foreground">{orgs[0].name}</p>
)}
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={onEdit}>
<Pencil className="w-4 h-4 mr-1" />
{t("form.edit_title")}
</Button>
<Button variant="outline" size="sm" onClick={onDelete} className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-950">
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</div>
<div className="px-6 py-4 space-y-6">
{emails.length > 0 && (
<Section icon={Mail} title={t("detail.emails")}>
{emails.map((e, i) => (
<div key={i} className="flex items-center gap-2">
<a href={`mailto:${e.address}`} className="text-sm text-primary hover:underline">
{e.address}
</a>
{e.contexts && (
<ContextBadge contexts={e.contexts} />
)}
</div>
))}
</Section>
)}
{phones.length > 0 && (
<Section icon={Phone} title={t("detail.phones")}>
{phones.map((p, i) => (
<div key={i} className="flex items-center gap-2">
<a href={`tel:${p.number}`} className="text-sm text-primary hover:underline">
{p.number}
</a>
{p.contexts && (
<ContextBadge contexts={p.contexts} />
)}
</div>
))}
</Section>
)}
{orgs.length > 0 && (
<Section icon={Building} title={t("detail.organizations")}>
{orgs.map((o, i) => (
<div key={i} className="text-sm">
{o.name}
{o.units && o.units.length > 0 && (
<span className="text-muted-foreground"> {o.units.map(u => u.name).join(", ")}</span>
)}
</div>
))}
</Section>
)}
{addresses.length > 0 && (
<Section icon={MapPin} title={t("detail.addresses")}>
{addresses.map((a, i) => (
<div key={i} className="text-sm">
{[a.street, a.locality, a.region, a.postcode, a.country].filter(Boolean).join(", ")}
{a.contexts && (
<ContextBadge contexts={a.contexts} />
)}
</div>
))}
</Section>
)}
{notes.length > 0 && (
<Section icon={StickyNote} title={t("detail.notes")}>
{notes.map((n, i) => (
<p key={i} className="text-sm whitespace-pre-wrap">{n.note}</p>
))}
</Section>
)}
</div>
</div>
);
}
function Section({ icon: Icon, title, children }: { icon: React.ComponentType<{ className?: string }>; title: string; children: React.ReactNode }) {
return (
<div>
<div className="flex items-center gap-2 mb-2">
<Icon className="w-4 h-4 text-muted-foreground" />
<h3 className="text-sm font-medium text-muted-foreground">{title}</h3>
</div>
<div className="space-y-1 pl-6">{children}</div>
</div>
);
}
function ContextBadge({ contexts }: { contexts: Record<string, boolean> }) {
const labels = Object.keys(contexts).filter(k => contexts[k]);
if (labels.length === 0) return null;
return (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
{labels.join(", ")}
</span>
);
}
+304
View File
@@ -0,0 +1,304 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { X, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { ContactCard } from "@/lib/jmap/types";
interface EmailEntry {
address: string;
context: "work" | "private" | "";
}
interface PhoneEntry {
number: string;
context: "work" | "private" | "";
}
interface ContactFormProps {
contact?: ContactCard | null;
onSave: (data: Partial<ContactCard>) => Promise<void>;
onCancel: () => void;
}
export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) {
const t = useTranslations("contacts.form");
const isEditing = !!contact;
const givenInit = contact?.name?.components?.find(c => c.kind === "given")?.value || "";
const surnameInit = contact?.name?.components?.find(c => c.kind === "surname")?.value || "";
const [givenName, setGivenName] = useState(givenInit);
const [surname, setSurname] = useState(surnameInit);
const [emails, setEmails] = useState<EmailEntry[]>(() => {
if (contact?.emails) {
return Object.values(contact.emails).map(e => ({
address: e.address,
context: e.contexts?.work ? "work" : e.contexts?.private ? "private" : "",
}));
}
return [{ address: "", context: "" }];
});
const [phones, setPhones] = useState<PhoneEntry[]>(() => {
if (contact?.phones) {
return Object.values(contact.phones).map(p => ({
number: p.number,
context: p.contexts?.work ? "work" : p.contexts?.private ? "private" : "",
}));
}
return [];
});
const [organization, setOrganization] = useState(
contact?.organizations ? Object.values(contact.organizations)[0]?.name || "" : ""
);
const [note, setNote] = useState(
contact?.notes ? Object.values(contact.notes)[0]?.note || "" : ""
);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!givenName.trim() && !surname.trim()) {
setError(t("name_required"));
return;
}
const validEmails = emails.filter(e => e.address.trim());
for (const entry of validEmails) {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(entry.address.trim())) {
setError(t("email_invalid"));
return;
}
}
const emailsMap: Record<string, { address: string; contexts?: Record<string, boolean> }> = {};
validEmails.forEach((entry, i) => {
const obj: { address: string; contexts?: Record<string, boolean> } = { address: entry.address.trim() };
if (entry.context) {
obj.contexts = { [entry.context]: true };
}
emailsMap[`e${i}`] = obj;
});
const validPhones = phones.filter(p => p.number.trim());
const phonesMap: Record<string, { number: string; contexts?: Record<string, boolean> }> = {};
validPhones.forEach((entry, i) => {
const obj: { number: string; contexts?: Record<string, boolean> } = { number: entry.number.trim() };
if (entry.context) {
obj.contexts = { [entry.context]: true };
}
phonesMap[`p${i}`] = obj;
});
const nameComponents = [];
if (givenName.trim()) {
nameComponents.push({ kind: "given" as const, value: givenName.trim() });
}
if (surname.trim()) {
nameComponents.push({ kind: "surname" as const, value: surname.trim() });
}
const data: Partial<ContactCard> = {
name: { components: nameComponents, isOrdered: true },
emails: Object.keys(emailsMap).length > 0 ? emailsMap : undefined,
phones: Object.keys(phonesMap).length > 0 ? phonesMap : undefined,
organizations: organization.trim()
? { o0: { name: organization.trim() } }
: undefined,
notes: note.trim()
? { n0: { note: note.trim() } }
: undefined,
};
setIsSaving(true);
try {
await onSave(data);
} catch (err) {
setError(err instanceof Error ? err.message : t("save_failed"));
} finally {
setIsSaving(false);
}
};
return (
<form onSubmit={handleSubmit} className="flex flex-col h-full">
<div className="px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold">
{isEditing ? t("edit_title") : t("create_title")}
</h2>
</div>
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{error && (
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950 px-3 py-2 rounded">
{error}
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-sm text-muted-foreground mb-1 block">{t("given_name")}</label>
<Input
value={givenName}
onChange={(e) => setGivenName(e.target.value)}
placeholder={t("given_name")}
autoFocus
/>
</div>
<div>
<label className="text-sm text-muted-foreground mb-1 block">{t("surname")}</label>
<Input
value={surname}
onChange={(e) => setSurname(e.target.value)}
placeholder={t("surname")}
/>
</div>
</div>
<div>
<label className="text-sm text-muted-foreground mb-1 block">{t("email")}</label>
<div className="space-y-2">
{emails.map((entry, i) => (
<div key={i} className="flex items-center gap-2">
<Input
type="email"
value={entry.address}
onChange={(e) => {
const next = [...emails];
next[i] = { ...next[i], address: e.target.value };
setEmails(next);
}}
placeholder={t("email_placeholder")}
className="flex-1"
/>
<select
value={entry.context}
onChange={(e) => {
const next = [...emails];
next[i] = { ...next[i], context: e.target.value as EmailEntry["context"] };
setEmails(next);
}}
className="text-sm bg-transparent border rounded px-2 py-2 text-foreground"
>
<option value=""></option>
<option value="work">{t("context_work")}</option>
<option value="private">{t("context_private")}</option>
</select>
{emails.length > 1 && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setEmails(emails.filter((_, j) => j !== i))}
className="h-8 w-8"
>
<X className="w-3 h-3" />
</Button>
)}
</div>
))}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setEmails([...emails, { address: "", context: "" }])}
>
<Plus className="w-3 h-3 mr-1" />
{t("add_email")}
</Button>
</div>
</div>
<div>
<label className="text-sm text-muted-foreground mb-1 block">{t("phone")}</label>
<div className="space-y-2">
{phones.map((entry, i) => (
<div key={i} className="flex items-center gap-2">
<Input
type="tel"
value={entry.number}
onChange={(e) => {
const next = [...phones];
next[i] = { ...next[i], number: e.target.value };
setPhones(next);
}}
placeholder={t("phone_placeholder")}
className="flex-1"
/>
<select
value={entry.context}
onChange={(e) => {
const next = [...phones];
next[i] = { ...next[i], context: e.target.value as PhoneEntry["context"] };
setPhones(next);
}}
className="text-sm bg-transparent border rounded px-2 py-2 text-foreground"
>
<option value=""></option>
<option value="work">{t("context_work")}</option>
<option value="private">{t("context_private")}</option>
</select>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setPhones(phones.filter((_, j) => j !== i))}
className="h-8 w-8"
>
<X className="w-3 h-3" />
</Button>
</div>
))}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setPhones([...phones, { number: "", context: "" }])}
>
<Plus className="w-3 h-3 mr-1" />
{t("add_phone")}
</Button>
</div>
</div>
<div>
<label className="text-sm text-muted-foreground mb-1 block">{t("organization")}</label>
<Input
value={organization}
onChange={(e) => setOrganization(e.target.value)}
placeholder={t("organization_placeholder")}
/>
</div>
<div>
<label className="text-sm text-muted-foreground mb-1 block">{t("note")}</label>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder={t("note_placeholder")}
className="w-full min-h-[80px] rounded border bg-transparent px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground resize-y outline-none focus:ring-2 focus:ring-ring"
/>
</div>
</div>
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
<Button type="button" variant="outline" onClick={onCancel} disabled={isSaving}>
{t("cancel")}
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? (isEditing ? t("updating") : t("creating")) : t("save")}
</Button>
</div>
</form>
);
}
+44
View File
@@ -0,0 +1,44 @@
"use client";
import { Avatar } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
import type { ContactCard } from "@/lib/jmap/types";
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
interface ContactListItemProps {
contact: ContactCard;
isSelected: boolean;
onClick: () => void;
}
export function ContactListItem({ contact, isSelected, onClick }: ContactListItemProps) {
const name = getContactDisplayName(contact);
const email = getContactPrimaryEmail(contact);
const org = contact.organizations
? Object.values(contact.organizations)[0]?.name
: undefined;
return (
<button
onClick={onClick}
className={cn(
"w-full flex items-center gap-3 px-4 py-3 text-left transition-colors",
"hover:bg-muted",
isSelected && "bg-accent text-accent-foreground"
)}
>
<Avatar name={name} email={email} size="sm" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">
{name || email || "—"}
</div>
{email && name && (
<div className="text-xs text-muted-foreground truncate">{email}</div>
)}
{org && (
<div className="text-xs text-muted-foreground truncate">{org}</div>
)}
</div>
</button>
);
}
+110
View File
@@ -0,0 +1,110 @@
"use client";
import { useMemo } from "react";
import { useTranslations } from "next-intl";
import { Search, Plus, BookUser, Info } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ContactListItem } from "./contact-list-item";
import { cn } from "@/lib/utils";
import type { ContactCard } from "@/lib/jmap/types";
import { getContactDisplayName } from "@/stores/contact-store";
interface ContactListProps {
contacts: ContactCard[];
selectedContactId: string | null;
searchQuery: string;
onSearchChange: (query: string) => void;
onSelectContact: (id: string) => void;
onCreateNew: () => void;
supportsSync: boolean;
className?: string;
}
export function ContactList({
contacts,
selectedContactId,
searchQuery,
onSearchChange,
onSelectContact,
onCreateNew,
supportsSync,
className,
}: ContactListProps) {
const t = useTranslations("contacts");
const filtered = useMemo(() => {
if (!searchQuery) return contacts;
const lower = searchQuery.toLowerCase();
return contacts.filter((c) => {
const name = getContactDisplayName(c).toLowerCase();
const emails = c.emails
? Object.values(c.emails).map((e) => e.address.toLowerCase())
: [];
return (
name.includes(lower) || emails.some((e) => e.includes(lower))
);
});
}, [contacts, searchQuery]);
const sorted = useMemo(() => {
return [...filtered].sort((a, b) => {
const nameA = getContactDisplayName(a).toLowerCase();
const nameB = getContactDisplayName(b).toLowerCase();
return nameA.localeCompare(nameB);
});
}, [filtered]);
return (
<div className={cn("flex flex-col h-full", className)}>
<div className="px-4 py-3 border-b border-border space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">{t("title")}</h2>
<Button size="sm" onClick={onCreateNew}>
<Plus className="w-4 h-4 mr-1" />
{t("create_new")}
</Button>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder={t("search_placeholder")}
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9"
/>
</div>
{!supportsSync && (
<div className="flex items-start gap-2 text-xs text-muted-foreground bg-muted rounded px-3 py-2">
<Info className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
<span>{t("local_mode")}</span>
</div>
)}
</div>
<div className="flex-1 overflow-y-auto">
{sorted.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground px-4">
<BookUser className="w-12 h-12 mb-3 opacity-30" />
<p className="text-sm">
{searchQuery ? t("empty_search") : t("empty_state")}
</p>
</div>
) : (
<div className="divide-y divide-border">
{sorted.map((contact) => (
<ContactListItem
key={contact.id}
contact={contact}
isSelected={contact.id === selectedContactId}
onClick={() => onSelectContact(contact.id)}
/>
))}
</div>
)}
</div>
</div>
);
}
+170 -10
View File
@@ -1,12 +1,13 @@
"use client"; "use client";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle } from "lucide-react"; import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useContactStore } from "@/stores/contact-store";
import { SubAddressHelper } from "@/components/identity/sub-address-helper"; import { SubAddressHelper } from "@/components/identity/sub-address-helper";
import { generateSubAddress } from "@/lib/sub-addressing"; import { generateSubAddress } from "@/lib/sub-addressing";
@@ -108,6 +109,70 @@ export function EmailComposer({
const [subAddressTag, setSubAddressTag] = useState<string>(''); const [subAddressTag, setSubAddressTag] = useState<string>('');
const { client, identities, primaryIdentity } = useAuthStore(); const { client, identities, primaryIdentity } = useAuthStore();
const getAutocomplete = useContactStore((s) => s.getAutocomplete);
const [autocompleteResults, setAutocompleteResults] = useState<Array<{ name: string; email: string }>>([]);
const [activeAutoField, setActiveAutoField] = useState<'to' | 'cc' | 'bcc' | null>(null);
const [autoSelectedIndex, setAutoSelectedIndex] = useState(-1);
const autocompleteTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const toInputRef = useRef<HTMLInputElement>(null);
const ccInputRef = useRef<HTMLInputElement>(null);
const bccInputRef = useRef<HTMLInputElement>(null);
const handleAutocomplete = useCallback((value: string, field: 'to' | 'cc' | 'bcc') => {
if (autocompleteTimeoutRef.current) {
clearTimeout(autocompleteTimeoutRef.current);
}
const lastPart = value.split(',').pop()?.trim() || '';
if (lastPart.length < 1) {
setAutocompleteResults([]);
setActiveAutoField(null);
setAutoSelectedIndex(-1);
return;
}
autocompleteTimeoutRef.current = setTimeout(() => {
const results = getAutocomplete(lastPart);
setAutocompleteResults(results);
setActiveAutoField(results.length > 0 ? field : null);
setAutoSelectedIndex(-1);
}, 200);
}, [getAutocomplete]);
const insertAutocomplete = (email: string, field: 'to' | 'cc' | 'bcc') => {
const setter = field === 'to' ? setTo : field === 'cc' ? setCc : setBcc;
const getter = field === 'to' ? to : field === 'cc' ? cc : bcc;
const parts = getter.split(',');
parts.pop();
parts.push(` ${email}`);
setter(parts.join(',').replace(/^,\s*/, ''));
setAutocompleteResults([]);
setActiveAutoField(null);
setAutoSelectedIndex(-1);
const ref = field === 'to' ? toInputRef : field === 'cc' ? ccInputRef : bccInputRef;
ref.current?.focus();
};
const handleAutoKeyDown = (e: React.KeyboardEvent, field: 'to' | 'cc' | 'bcc') => {
if (!activeAutoField || autocompleteResults.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setAutoSelectedIndex((prev) => Math.min(prev + 1, autocompleteResults.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setAutoSelectedIndex((prev) => Math.max(prev - 1, -1));
} else if (e.key === 'Enter' && autoSelectedIndex >= 0) {
e.preventDefault();
insertAutocomplete(autocompleteResults[autoSelectedIndex].email, field);
} else if (e.key === 'Escape') {
setAutocompleteResults([]);
setActiveAutoField(null);
setAutoSelectedIndex(-1);
}
};
// Handle file selection // Handle file selection
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -258,6 +323,14 @@ export function EmailComposer({
// eslint-disable-next-line react-hooks/exhaustive-deps -- saveDraft reads current state when called, not when effect is set up // eslint-disable-next-line react-hooks/exhaustive-deps -- saveDraft reads current state when called, not when effect is set up
}, [to, cc, bcc, subject, body, attachments]); }, [to, cc, bcc, subject, body, attachments]);
useEffect(() => {
return () => {
if (autocompleteTimeoutRef.current) {
clearTimeout(autocompleteTimeoutRef.current);
}
};
}, []);
const handleSend = async () => { const handleSend = async () => {
const toAddresses = to.split(",").map(e => e.trim()).filter(Boolean); const toAddresses = to.split(",").map(e => e.trim()).filter(Boolean);
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean); const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
@@ -421,15 +494,31 @@ export function EmailComposer({
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 relative">
<span className="text-sm text-muted-foreground w-16">{t('to')}:</span> <span className="text-sm text-muted-foreground w-16">{t('to')}:</span>
<div className="flex-1 relative">
<Input <Input
ref={toInputRef}
type="email" type="email"
placeholder={t('to_placeholder')} placeholder={t('to_placeholder')}
value={to} value={to}
onChange={(e) => setTo(e.target.value)} onChange={(e) => {
className="flex-1 border-0 focus-visible:ring-0" setTo(e.target.value);
handleAutocomplete(e.target.value, 'to');
}}
onKeyDown={(e) => handleAutoKeyDown(e, 'to')}
onBlur={() => setTimeout(() => { if (activeAutoField === 'to') { setActiveAutoField(null); setAutoSelectedIndex(-1); } }, 200)}
className="border-0 focus-visible:ring-0"
role="combobox"
aria-expanded={activeAutoField === 'to' && autocompleteResults.length > 0}
aria-autocomplete="list"
aria-controls={activeAutoField === 'to' ? 'autocomplete-to' : undefined}
aria-activedescendant={activeAutoField === 'to' && autoSelectedIndex >= 0 ? `autocomplete-option-${autoSelectedIndex}` : undefined}
/> />
{activeAutoField === 'to' && autocompleteResults.length > 0 && (
<AutocompleteDropdown id="autocomplete-to" results={autocompleteResults} selectedIndex={autoSelectedIndex} onSelect={(email) => insertAutocomplete(email, 'to')} />
)}
</div>
<div className="flex gap-1"> <div className="flex gap-1">
<Button <Button
variant="ghost" variant="ghost"
@@ -451,28 +540,60 @@ export function EmailComposer({
</div> </div>
{showCc && ( {showCc && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 relative">
<span className="text-sm text-muted-foreground w-16">{t('cc_label')}</span> <span className="text-sm text-muted-foreground w-16">{t('cc_label')}</span>
<div className="flex-1 relative">
<Input <Input
ref={ccInputRef}
type="email" type="email"
placeholder={t('cc_placeholder')} placeholder={t('cc_placeholder')}
value={cc} value={cc}
onChange={(e) => setCc(e.target.value)} onChange={(e) => {
className="flex-1 border-0 focus-visible:ring-0" setCc(e.target.value);
handleAutocomplete(e.target.value, 'cc');
}}
onKeyDown={(e) => handleAutoKeyDown(e, 'cc')}
onBlur={() => setTimeout(() => { if (activeAutoField === 'cc') { setActiveAutoField(null); setAutoSelectedIndex(-1); } }, 200)}
className="border-0 focus-visible:ring-0"
role="combobox"
aria-expanded={activeAutoField === 'cc' && autocompleteResults.length > 0}
aria-autocomplete="list"
aria-controls={activeAutoField === 'cc' ? 'autocomplete-cc' : undefined}
aria-activedescendant={activeAutoField === 'cc' && autoSelectedIndex >= 0 ? `autocomplete-option-${autoSelectedIndex}` : undefined}
/> />
{activeAutoField === 'cc' && autocompleteResults.length > 0 && (
<AutocompleteDropdown id="autocomplete-cc" results={autocompleteResults} selectedIndex={autoSelectedIndex} onSelect={(email) => insertAutocomplete(email, 'cc')} />
)}
</div>
</div> </div>
)} )}
{showBcc && ( {showBcc && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 relative">
<span className="text-sm text-muted-foreground w-16">{t('bcc_label')}</span> <span className="text-sm text-muted-foreground w-16">{t('bcc_label')}</span>
<div className="flex-1 relative">
<Input <Input
ref={bccInputRef}
type="email" type="email"
placeholder={t('bcc_placeholder')} placeholder={t('bcc_placeholder')}
value={bcc} value={bcc}
onChange={(e) => setBcc(e.target.value)} onChange={(e) => {
className="flex-1 border-0 focus-visible:ring-0" setBcc(e.target.value);
handleAutocomplete(e.target.value, 'bcc');
}}
onKeyDown={(e) => handleAutoKeyDown(e, 'bcc')}
onBlur={() => setTimeout(() => { if (activeAutoField === 'bcc') { setActiveAutoField(null); setAutoSelectedIndex(-1); } }, 200)}
className="border-0 focus-visible:ring-0"
role="combobox"
aria-expanded={activeAutoField === 'bcc' && autocompleteResults.length > 0}
aria-autocomplete="list"
aria-controls={activeAutoField === 'bcc' ? 'autocomplete-bcc' : undefined}
aria-activedescendant={activeAutoField === 'bcc' && autoSelectedIndex >= 0 ? `autocomplete-option-${autoSelectedIndex}` : undefined}
/> />
{activeAutoField === 'bcc' && autocompleteResults.length > 0 && (
<AutocompleteDropdown id="autocomplete-bcc" results={autocompleteResults} selectedIndex={autoSelectedIndex} onSelect={(email) => insertAutocomplete(email, 'bcc')} />
)}
</div>
</div> </div>
)} )}
@@ -570,3 +691,42 @@ export function EmailComposer({
</div> </div>
); );
} }
function AutocompleteDropdown({
id,
results,
selectedIndex,
onSelect,
}: {
id: string;
results: Array<{ name: string; email: string }>;
selectedIndex: number;
onSelect: (email: string) => void;
}) {
return (
<div id={id} role="listbox" className="absolute top-full left-0 right-0 z-50 mt-1 bg-popover border border-border rounded-md shadow-lg max-h-48 overflow-y-auto">
{results.map((r, i) => (
<button
key={i}
id={`autocomplete-option-${i}`}
type="button"
role="option"
aria-selected={i === selectedIndex}
className={cn(
"w-full px-3 py-2 text-left text-sm flex items-center gap-2",
i === selectedIndex ? "bg-accent text-accent-foreground" : "hover:bg-muted"
)}
onMouseDown={(e) => {
e.preventDefault();
onSelect(r.email);
}}
>
<span className="font-medium truncate">{r.name || r.email}</span>
{r.name && (
<span className="text-muted-foreground truncate">&lt;{r.email}&gt;</span>
)}
</button>
))}
</div>
);
}
+7 -2
View File
@@ -3,7 +3,7 @@
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo } from "react";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { Email } from "@/lib/jmap/types"; import { Email } from "@/lib/jmap/types";
import { hasRichFormatting, EMAIL_SANITIZE_CONFIG } from "@/lib/email-sanitization"; import { hasRichFormatting, EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar"; import { Avatar } from "@/components/ui/avatar";
import { formatFileSize, cn } from "@/lib/utils"; import { formatFileSize, cn } from "@/lib/utils";
@@ -466,11 +466,16 @@ export function EmailViewer({
} }
// Sanitize HTML to prevent XSS // Sanitize HTML to prevent XSS
const cleanHtml = DOMPurify.sanitize(htmlContent, sanitizeConfig); let cleanHtml = DOMPurify.sanitize(htmlContent, sanitizeConfig);
// Remove the hook after sanitization // Remove the hook after sanitization
DOMPurify.removeAllHooks(); DOMPurify.removeAllHooks();
// Collapse empty containers left behind by blocked images
if (shouldBlockExternal && blockedExternalContent) {
cleanHtml = collapseBlockedImageContainers(cleanHtml);
}
// Update blocked content state // Update blocked content state
if (blockedExternalContent && !hasBlockedContent) { if (blockedExternalContent && !hasBlockedContent) {
setHasBlockedContent(true); setHasBlockedContent(true);
@@ -3,7 +3,7 @@
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo } from "react";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { Email, ThreadGroup } from "@/lib/jmap/types"; import { Email, ThreadGroup } from "@/lib/jmap/types";
import { hasRichFormatting, EMAIL_SANITIZE_CONFIG } from "@/lib/email-sanitization"; import { hasRichFormatting, EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization";
import { Avatar } from "@/components/ui/avatar"; import { Avatar } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { formatDate, formatFileSize, cn } from "@/lib/utils"; import { formatDate, formatFileSize, cn } from "@/lib/utils";
@@ -296,11 +296,13 @@ function EmailCard({
const sanitized = DOMPurify.sanitize(htmlContent, sanitizeConfig); const sanitized = DOMPurify.sanitize(htmlContent, sanitizeConfig);
DOMPurify.removeHook('afterSanitizeAttributes'); DOMPurify.removeHook('afterSanitizeAttributes');
let finalHtml = sanitized;
if (blockedExternalContent) { if (blockedExternalContent) {
setHasBlockedContent(true); setHasBlockedContent(true);
finalHtml = collapseBlockedImageContainers(sanitized);
} }
return { html: sanitized, isHtml: true }; return { html: finalHtml, isHtml: true };
} }
// Plain text fallback // Plain text fallback
+13
View File
@@ -24,6 +24,7 @@ import {
ChevronUp, ChevronUp,
Users, Users,
User, User,
BookUser,
X, X,
} from "lucide-react"; } from "lucide-react";
import { cn, buildMailboxTree, MailboxNode, formatFileSize } from "@/lib/utils"; import { cn, buildMailboxTree, MailboxNode, formatFileSize } from "@/lib/utils";
@@ -453,6 +454,18 @@ export function Sidebar({
)} )}
<div className="border-t border-border mt-2 pt-2"> <div className="border-t border-border mt-2 pt-2">
{/* Contacts */}
<button
onClick={() => router.push('/contacts')}
className="w-full px-4 py-2 flex items-center justify-between hover:bg-muted transition-colors text-sm"
>
<span className="flex items-center gap-2">
<BookUser className="w-4 h-4" />
{t("contacts")}
</span>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
{/* Settings */} {/* Settings */}
<button <button
onClick={() => router.push('/settings')} onClick={() => router.push('/settings')}
+32
View File
@@ -75,3 +75,35 @@ export function hasRichFormatting(html: string): boolean {
'h1, h2, h3, h4, h5, h6, ul, ol, blockquote' 'h1, h2, h3, h4, h5, h6, ul, ol, blockquote'
); );
} }
/**
* Collapse empty containers left behind when external images are blocked.
* Walks up from each blocked img to find the nearest table cell or wrapper div
* and hides it if it contains no meaningful visible content.
*/
export function collapseBlockedImageContainers(html: string): string {
const doc = parseHtmlSafely(html);
const blockedImages = doc.querySelectorAll('img[data-blocked-src]');
blockedImages.forEach((img) => {
let el: HTMLElement | null = img.parentElement;
while (el && el !== doc.body) {
if (el.tagName === 'TD' || el.tagName === 'TH' || (el.tagName === 'DIV' && el.parentElement?.tagName === 'TD')) {
const hasVisibleText = el.textContent?.replace(/[\s\u00A0]+/g, '').trim();
const hasVisibleMedia = el.querySelector('img:not([data-blocked-src]), video, canvas');
const hasLinks = el.querySelector('a[href]');
if (!hasVisibleText && !hasVisibleMedia && !hasLinks) {
el.style.display = 'none';
el.style.height = '0';
el.style.padding = '0';
el.style.overflow = 'hidden';
}
break;
}
if (el.tagName === 'TABLE' || el.tagName === 'TR') break;
el = el.parentElement;
}
});
return doc.body.innerHTML;
}
+211 -3
View File
@@ -1,4 +1,4 @@
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress } from "./types"; import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook } from "./types";
// JMAP protocol types - these are intentionally flexible due to server variations // JMAP protocol types - these are intentionally flexible due to server variations
interface JMAPSession { interface JMAPSession {
@@ -198,13 +198,13 @@ export class JMAPClient {
this.capabilities = {}; this.capabilities = {};
} }
private async request(methodCalls: JMAPMethodCall[]): Promise<JMAPResponse> { private async request(methodCalls: JMAPMethodCall[], using?: string[]): Promise<JMAPResponse> {
if (!this.apiUrl) { if (!this.apiUrl) {
throw new Error('Not connected. Call connect() first.'); throw new Error('Not connected. Call connect() first.');
} }
const requestBody = { const requestBody = {
using: ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], using: using || ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
methodCalls: methodCalls, methodCalls: methodCalls,
}; };
@@ -1481,6 +1481,214 @@ export class JMAPClient {
return this.hasCapability("urn:ietf:params:jmap:vacationresponse"); return this.hasCapability("urn:ietf:params:jmap:vacationresponse");
} }
supportsContacts(): boolean {
return this.hasCapability("urn:ietf:params:jmap:contacts");
}
getContactsAccountId(): string {
const contactsAccount = this.session?.primaryAccounts?.["urn:ietf:params:jmap:contacts"];
return contactsAccount || this.accountId;
}
private contactUsing(): string[] {
return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:contacts"];
}
async getAddressBooks(): Promise<AddressBook[]> {
try {
const accountId = this.getContactsAccountId();
const response = await this.request([
["AddressBook/get", { accountId }, "0"]
], this.contactUsing());
if (response.methodResponses?.[0]?.[0] === "AddressBook/get") {
return (response.methodResponses[0][1].list || []) as AddressBook[];
}
return [];
} catch (error) {
console.error('Failed to get address books:', error);
return [];
}
}
async getContacts(addressBookId?: string): Promise<ContactCard[]> {
try {
const accountId = this.getContactsAccountId();
const methodCalls: JMAPMethodCall[] = [];
if (addressBookId) {
methodCalls.push(
["ContactCard/query", {
accountId,
filter: { inAddressBook: addressBookId },
limit: 1000,
}, "0"],
["ContactCard/get", {
accountId,
"#ids": { resultOf: "0", name: "ContactCard/query", path: "/ids" },
}, "1"]
);
} else {
methodCalls.push(
["ContactCard/query", { accountId, limit: 1000 }, "0"],
["ContactCard/get", {
accountId,
"#ids": { resultOf: "0", name: "ContactCard/query", path: "/ids" },
}, "1"]
);
}
const response = await this.request(methodCalls, this.contactUsing());
if (response.methodResponses?.[1]?.[0] === "ContactCard/get") {
return (response.methodResponses[1][1].list || []) as ContactCard[];
}
return [];
} catch (error) {
console.error('Failed to get contacts:', error);
return [];
}
}
async getContact(contactId: string): Promise<ContactCard | null> {
try {
const accountId = this.getContactsAccountId();
const response = await this.request([
["ContactCard/get", {
accountId,
ids: [contactId],
}, "0"]
], this.contactUsing());
if (response.methodResponses?.[0]?.[0] === "ContactCard/get") {
const list = response.methodResponses[0][1].list || [];
return list[0] || null;
}
return null;
} catch (error) {
console.error('Failed to get contact:', error);
return null;
}
}
async createContact(contact: Partial<ContactCard>): Promise<ContactCard> {
const accountId = this.getContactsAccountId();
// If no addressBookIds provided, get default address book
let addressBookIds = contact.addressBookIds;
if (!addressBookIds || Object.keys(addressBookIds).length === 0) {
const books = await this.getAddressBooks();
const defaultBook = books.find(b => b.isDefault) || books[0];
if (defaultBook) {
addressBookIds = { [defaultBook.id]: true };
}
}
const response = await this.request([
["ContactCard/set", {
accountId,
create: {
"new-contact": {
...contact,
addressBookIds,
}
}
}, "0"]
], this.contactUsing());
if (response.methodResponses?.[0]?.[0] === "ContactCard/set") {
const result = response.methodResponses[0][1];
if (result.notCreated?.["new-contact"]) {
const error = result.notCreated["new-contact"];
throw new Error(error.description || "Failed to create contact");
}
const createdId = result.created?.["new-contact"]?.id;
if (createdId) {
const created = await this.getContact(createdId);
if (created) return created;
}
}
throw new Error("Failed to create contact");
}
async updateContact(contactId: string, updates: Partial<ContactCard>): Promise<void> {
const accountId = this.getContactsAccountId();
const response = await this.request([
["ContactCard/set", {
accountId,
update: {
[contactId]: updates
}
}, "0"]
], this.contactUsing());
if (response.methodResponses?.[0]?.[0] === "ContactCard/set") {
const result = response.methodResponses[0][1];
if (result.notUpdated?.[contactId]) {
const error = result.notUpdated[contactId];
throw new Error(error.description || "Failed to update contact");
}
return;
}
throw new Error("Failed to update contact");
}
async deleteContact(contactId: string): Promise<void> {
const accountId = this.getContactsAccountId();
const response = await this.request([
["ContactCard/set", {
accountId,
destroy: [contactId]
}, "0"]
], this.contactUsing());
if (response.methodResponses?.[0]?.[0] === "ContactCard/set") {
const result = response.methodResponses[0][1];
if (result.notDestroyed?.[contactId]) {
const error = result.notDestroyed[contactId];
throw new Error(error.description || "Failed to delete contact");
}
return;
}
throw new Error("Failed to delete contact");
}
async searchContacts(query: string): Promise<ContactCard[]> {
try {
const accountId = this.getContactsAccountId();
const response = await this.request([
["ContactCard/query", {
accountId,
filter: { text: query },
limit: 50,
}, "0"],
["ContactCard/get", {
accountId,
"#ids": { resultOf: "0", name: "ContactCard/query", path: "/ids" },
}, "1"]
], this.contactUsing());
if (response.methodResponses?.[1]?.[0] === "ContactCard/get") {
return (response.methodResponses[1][1].list || []) as ContactCard[];
}
return [];
} catch (error) {
console.error('Failed to search contacts:', error);
return [];
}
}
async downloadBlob(blobId: string, name?: string, type?: string): Promise<void> { async downloadBlob(blobId: string, name?: string, type?: string): Promise<void> {
const url = this.getBlobDownloadUrl(blobId, name, type); const url = this.getBlobDownloadUrl(blobId, name, type);
+82
View File
@@ -153,6 +153,86 @@ export interface Identity {
mayDelete: boolean; mayDelete: boolean;
} }
// RFC 9553 JSContact / RFC 9610 JMAP for Contacts
export interface ContactCard {
id: string;
uid?: string;
addressBookIds: Record<string, boolean>;
kind?: 'individual' | 'group' | 'org';
name?: ContactName;
emails?: Record<string, ContactEmail>;
phones?: Record<string, ContactPhone>;
organizations?: Record<string, ContactOrganization>;
addresses?: Record<string, ContactAddress>;
nicknames?: Record<string, ContactNickname>;
notes?: Record<string, ContactNote>;
created?: string;
updated?: string;
}
export interface ContactName {
components: NameComponent[];
isOrdered?: boolean;
}
export interface NameComponent {
kind: 'given' | 'surname' | 'prefix' | 'suffix' | 'additional';
value: string;
}
export interface ContactEmail {
address: string;
contexts?: Record<string, boolean>;
label?: string;
}
export interface ContactPhone {
number: string;
contexts?: Record<string, boolean>;
label?: string;
}
export interface ContactOrganization {
name?: string;
units?: Array<{ name: string }>;
}
export interface ContactAddress {
street?: string;
locality?: string;
region?: string;
postcode?: string;
country?: string;
contexts?: Record<string, boolean>;
label?: string;
}
export interface ContactNickname {
name: string;
}
export interface ContactNote {
note: string;
}
export interface AddressBook {
id: string;
name: string;
description?: string | null;
sortOrder?: number;
isDefault?: boolean;
isSubscribed?: boolean;
myRights?: AddressBookRights;
}
export interface AddressBookRights {
mayRead: boolean;
mayWrite: boolean;
mayShare: boolean;
mayDelete: boolean;
}
export interface EmailSubmission { export interface EmailSubmission {
id: string; id: string;
identityId: string; identityId: string;
@@ -187,6 +267,8 @@ export interface StateChange {
EmailDelivery?: string; EmailDelivery?: string;
EmailSubmission?: string; EmailSubmission?: string;
Identity?: string; Identity?: string;
ContactCard?: string;
AddressBook?: string;
}; };
}; };
} }
+54
View File
@@ -26,6 +26,7 @@
"search_placeholder": "E-Mails durchsuchen...", "search_placeholder": "E-Mails durchsuchen...",
"storage": "Speicher", "storage": "Speicher",
"sign_out": "Abmelden", "sign_out": "Abmelden",
"contacts": "Kontakte",
"settings": "Einstellungen", "settings": "Einstellungen",
"loading_mailboxes": "Postfächer werden geladen...", "loading_mailboxes": "Postfächer werden geladen...",
"push_connected": "Echtzeit-Updates aktiv", "push_connected": "Echtzeit-Updates aktiv",
@@ -742,5 +743,58 @@
"identity_short": "über {name}", "identity_short": "über {name}",
"subaddress_tag": "+{tag}" "subaddress_tag": "+{tag}"
} }
},
"contacts": {
"title": "Kontakte",
"search_placeholder": "Kontakte suchen...",
"create_new": "Neuer Kontakt",
"empty_state": "Keine Kontakte",
"empty_search": "Keine Kontakte gefunden",
"delete_confirm": "Möchten Sie diesen Kontakt wirklich löschen?",
"local_mode": "Kontakte werden lokal gespeichert (Server unterstützt kein JMAP Contacts)",
"back_to_mail": "Zurück zur E-Mail",
"detail": {
"emails": "E-Mail-Adressen",
"phones": "Telefonnummern",
"organizations": "Organisationen",
"addresses": "Adressen",
"notes": "Notizen",
"no_contact_selected": "Kontakt auswählen, um Details anzuzeigen",
"created": "Erstellt",
"updated": "Zuletzt aktualisiert"
},
"form": {
"create_title": "Neuer Kontakt",
"edit_title": "Kontakt bearbeiten",
"given_name": "Vorname",
"surname": "Nachname",
"email": "E-Mail",
"email_placeholder": "email@example.com",
"phone": "Telefon",
"phone_placeholder": "+49 30 1234 5678",
"organization": "Organisation",
"organization_placeholder": "Firmenname",
"note": "Notizen",
"note_placeholder": "Notiz hinzufügen...",
"context_work": "Arbeit",
"context_private": "Privat",
"add_email": "E-Mail hinzufügen",
"add_phone": "Telefon hinzufügen",
"save": "Speichern",
"cancel": "Abbrechen",
"creating": "Wird erstellt...",
"updating": "Wird aktualisiert...",
"name_required": "Mindestens ein Vor- oder Nachname ist erforderlich",
"email_invalid": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"save_failed": "Kontakt konnte nicht gespeichert werden"
},
"toast": {
"created": "Kontakt erstellt",
"updated": "Kontakt aktualisiert",
"deleted": "Kontakt gelöscht",
"error_create": "Kontakt konnte nicht erstellt werden",
"error_update": "Kontakt konnte nicht aktualisiert werden",
"error_delete": "Kontakt konnte nicht gelöscht werden"
}
} }
} }
+54
View File
@@ -26,6 +26,7 @@
"search_placeholder": "Search mail...", "search_placeholder": "Search mail...",
"storage": "Storage", "storage": "Storage",
"sign_out": "Sign out", "sign_out": "Sign out",
"contacts": "Contacts",
"settings": "Settings", "settings": "Settings",
"loading_mailboxes": "Loading mailboxes...", "loading_mailboxes": "Loading mailboxes...",
"push_connected": "Real-time updates active", "push_connected": "Real-time updates active",
@@ -742,5 +743,58 @@
"identity_short": "via {name}", "identity_short": "via {name}",
"subaddress_tag": "+{tag}" "subaddress_tag": "+{tag}"
} }
},
"contacts": {
"title": "Contacts",
"search_placeholder": "Search contacts...",
"create_new": "New Contact",
"empty_state": "No contacts yet",
"empty_search": "No contacts match your search",
"delete_confirm": "Are you sure you want to delete this contact?",
"local_mode": "Contacts are stored locally (server does not support JMAP Contacts)",
"back_to_mail": "Back to mail",
"detail": {
"emails": "Email Addresses",
"phones": "Phone Numbers",
"organizations": "Organizations",
"addresses": "Addresses",
"notes": "Notes",
"no_contact_selected": "Select a contact to view details",
"created": "Created",
"updated": "Last updated"
},
"form": {
"create_title": "New Contact",
"edit_title": "Edit Contact",
"given_name": "First name",
"surname": "Last name",
"email": "Email",
"email_placeholder": "email@example.com",
"phone": "Phone",
"phone_placeholder": "+1 234 567 890",
"organization": "Organization",
"organization_placeholder": "Company name",
"note": "Notes",
"note_placeholder": "Add a note...",
"context_work": "Work",
"context_private": "Private",
"add_email": "Add email",
"add_phone": "Add phone",
"save": "Save",
"cancel": "Cancel",
"creating": "Creating...",
"updating": "Updating...",
"name_required": "At least a first name or last name is required",
"email_invalid": "Please enter a valid email address",
"save_failed": "Failed to save contact"
},
"toast": {
"created": "Contact created",
"updated": "Contact updated",
"deleted": "Contact deleted",
"error_create": "Failed to create contact",
"error_update": "Failed to update contact",
"error_delete": "Failed to delete contact"
}
} }
} }
+54
View File
@@ -26,6 +26,7 @@
"search_placeholder": "Buscar correo...", "search_placeholder": "Buscar correo...",
"storage": "Almacenamiento", "storage": "Almacenamiento",
"sign_out": "Cerrar sesión", "sign_out": "Cerrar sesión",
"contacts": "Contactos",
"settings": "Configuración", "settings": "Configuración",
"loading_mailboxes": "Cargando buzones...", "loading_mailboxes": "Cargando buzones...",
"push_connected": "Actualizaciones en tiempo real activas", "push_connected": "Actualizaciones en tiempo real activas",
@@ -742,5 +743,58 @@
"identity_short": "vía {name}", "identity_short": "vía {name}",
"subaddress_tag": "+{tag}" "subaddress_tag": "+{tag}"
} }
},
"contacts": {
"title": "Contactos",
"search_placeholder": "Buscar contactos...",
"create_new": "Nuevo contacto",
"empty_state": "No hay contactos",
"empty_search": "Ningún contacto coincide con tu búsqueda",
"delete_confirm": "¿Estás seguro de que quieres eliminar este contacto?",
"local_mode": "Los contactos se almacenan localmente (el servidor no soporta JMAP Contacts)",
"back_to_mail": "Volver al correo",
"detail": {
"emails": "Direcciones de correo",
"phones": "Números de teléfono",
"organizations": "Organizaciones",
"addresses": "Direcciones",
"notes": "Notas",
"no_contact_selected": "Selecciona un contacto para ver los detalles",
"created": "Creado",
"updated": "Última actualización"
},
"form": {
"create_title": "Nuevo contacto",
"edit_title": "Editar contacto",
"given_name": "Nombre",
"surname": "Apellido",
"email": "Correo",
"email_placeholder": "email@example.com",
"phone": "Teléfono",
"phone_placeholder": "+34 612 345 678",
"organization": "Organización",
"organization_placeholder": "Nombre de la empresa",
"note": "Notas",
"note_placeholder": "Agregar una nota...",
"context_work": "Trabajo",
"context_private": "Personal",
"add_email": "Agregar correo",
"add_phone": "Agregar teléfono",
"save": "Guardar",
"cancel": "Cancelar",
"creating": "Creando...",
"updating": "Actualizando...",
"name_required": "Se requiere al menos un nombre o apellido",
"email_invalid": "Introduce una dirección de correo válida",
"save_failed": "Error al guardar el contacto"
},
"toast": {
"created": "Contacto creado",
"updated": "Contacto actualizado",
"deleted": "Contacto eliminado",
"error_create": "Error al crear el contacto",
"error_update": "Error al actualizar el contacto",
"error_delete": "Error al eliminar el contacto"
}
} }
} }
+54
View File
@@ -26,6 +26,7 @@
"search_placeholder": "Rechercher un email...", "search_placeholder": "Rechercher un email...",
"storage": "Stockage", "storage": "Stockage",
"sign_out": "Se déconnecter", "sign_out": "Se déconnecter",
"contacts": "Contacts",
"settings": "Paramètres", "settings": "Paramètres",
"loading_mailboxes": "Chargement des boîtes mail...", "loading_mailboxes": "Chargement des boîtes mail...",
"push_connected": "Mises à jour en temps réel actives", "push_connected": "Mises à jour en temps réel actives",
@@ -742,5 +743,58 @@
"identity_short": "via {name}", "identity_short": "via {name}",
"subaddress_tag": "+{tag}" "subaddress_tag": "+{tag}"
} }
},
"contacts": {
"title": "Contacts",
"search_placeholder": "Rechercher des contacts...",
"create_new": "Nouveau contact",
"empty_state": "Aucun contact",
"empty_search": "Aucun contact ne correspond à votre recherche",
"delete_confirm": "Êtes-vous sûr de vouloir supprimer ce contact ?",
"local_mode": "Les contacts sont stockés localement (le serveur ne prend pas en charge JMAP Contacts)",
"back_to_mail": "Retour aux e-mails",
"detail": {
"emails": "Adresses e-mail",
"phones": "Numéros de téléphone",
"organizations": "Organisations",
"addresses": "Adresses",
"notes": "Notes",
"no_contact_selected": "Sélectionnez un contact pour voir les détails",
"created": "Créé",
"updated": "Dernière mise à jour"
},
"form": {
"create_title": "Nouveau contact",
"edit_title": "Modifier le contact",
"given_name": "Prénom",
"surname": "Nom",
"email": "E-mail",
"email_placeholder": "email@example.com",
"phone": "Téléphone",
"phone_placeholder": "+33 1 23 45 67 89",
"organization": "Organisation",
"organization_placeholder": "Nom de l'entreprise",
"note": "Notes",
"note_placeholder": "Ajouter une note...",
"context_work": "Professionnel",
"context_private": "Personnel",
"add_email": "Ajouter un e-mail",
"add_phone": "Ajouter un téléphone",
"save": "Enregistrer",
"cancel": "Annuler",
"creating": "Création...",
"updating": "Mise à jour...",
"name_required": "Un prénom ou un nom est requis",
"email_invalid": "Veuillez saisir une adresse e-mail valide",
"save_failed": "Échec de l'enregistrement du contact"
},
"toast": {
"created": "Contact créé",
"updated": "Contact mis à jour",
"deleted": "Contact supprimé",
"error_create": "Échec de la création du contact",
"error_update": "Échec de la mise à jour du contact",
"error_delete": "Échec de la suppression du contact"
}
} }
} }
+54
View File
@@ -26,6 +26,7 @@
"search_placeholder": "Cerca nella posta...", "search_placeholder": "Cerca nella posta...",
"storage": "Spazio di archiviazione", "storage": "Spazio di archiviazione",
"sign_out": "Esci", "sign_out": "Esci",
"contacts": "Contatti",
"settings": "Impostazioni", "settings": "Impostazioni",
"loading_mailboxes": "Caricamento caselle di posta...", "loading_mailboxes": "Caricamento caselle di posta...",
"push_connected": "Aggiornamenti in tempo reale attivi", "push_connected": "Aggiornamenti in tempo reale attivi",
@@ -742,5 +743,58 @@
"identity_short": "tramite {name}", "identity_short": "tramite {name}",
"subaddress_tag": "+{tag}" "subaddress_tag": "+{tag}"
} }
},
"contacts": {
"title": "Contatti",
"search_placeholder": "Cerca contatti...",
"create_new": "Nuovo contatto",
"empty_state": "Nessun contatto",
"empty_search": "Nessun contatto corrisponde alla ricerca",
"delete_confirm": "Sei sicuro di voler eliminare questo contatto?",
"local_mode": "I contatti sono salvati localmente (il server non supporta JMAP Contacts)",
"back_to_mail": "Torna alla posta",
"detail": {
"emails": "Indirizzi email",
"phones": "Numeri di telefono",
"organizations": "Organizzazioni",
"addresses": "Indirizzi",
"notes": "Note",
"no_contact_selected": "Seleziona un contatto per vedere i dettagli",
"created": "Creato",
"updated": "Ultimo aggiornamento"
},
"form": {
"create_title": "Nuovo contatto",
"edit_title": "Modifica contatto",
"given_name": "Nome",
"surname": "Cognome",
"email": "Email",
"email_placeholder": "email@example.com",
"phone": "Telefono",
"phone_placeholder": "+39 02 1234 5678",
"organization": "Organizzazione",
"organization_placeholder": "Nome azienda",
"note": "Note",
"note_placeholder": "Aggiungi una nota...",
"context_work": "Lavoro",
"context_private": "Personale",
"add_email": "Aggiungi email",
"add_phone": "Aggiungi telefono",
"save": "Salva",
"cancel": "Annulla",
"creating": "Creazione...",
"updating": "Aggiornamento...",
"name_required": "È richiesto almeno un nome o cognome",
"email_invalid": "Inserisci un indirizzo email valido",
"save_failed": "Impossibile salvare il contatto"
},
"toast": {
"created": "Contatto creato",
"updated": "Contatto aggiornato",
"deleted": "Contatto eliminato",
"error_create": "Impossibile creare il contatto",
"error_update": "Impossibile aggiornare il contatto",
"error_delete": "Impossibile eliminare il contatto"
}
} }
} }
+54
View File
@@ -26,6 +26,7 @@
"search_placeholder": "メールを検索...", "search_placeholder": "メールを検索...",
"storage": "ストレージ", "storage": "ストレージ",
"sign_out": "サインアウト", "sign_out": "サインアウト",
"contacts": "連絡先",
"settings": "設定", "settings": "設定",
"loading_mailboxes": "メールボックスを読み込み中...", "loading_mailboxes": "メールボックスを読み込み中...",
"push_connected": "リアルタイム更新が有効", "push_connected": "リアルタイム更新が有効",
@@ -742,5 +743,58 @@
"identity_short": "{name}経由", "identity_short": "{name}経由",
"subaddress_tag": "+{tag}" "subaddress_tag": "+{tag}"
} }
},
"contacts": {
"title": "連絡先",
"search_placeholder": "連絡先を検索...",
"create_new": "新しい連絡先",
"empty_state": "連絡先がありません",
"empty_search": "検索に一致する連絡先がありません",
"delete_confirm": "この連絡先を削除してもよろしいですか?",
"local_mode": "連絡先はローカルに保存されています(サーバーがJMAPコンタクトをサポートしていません)",
"back_to_mail": "メールに戻る",
"detail": {
"emails": "メールアドレス",
"phones": "電話番号",
"organizations": "組織",
"addresses": "住所",
"notes": "メモ",
"no_contact_selected": "連絡先を選択して詳細を表示",
"created": "作成日",
"updated": "最終更新"
},
"form": {
"create_title": "新しい連絡先",
"edit_title": "連絡先を編集",
"given_name": "名",
"surname": "姓",
"email": "メール",
"email_placeholder": "email@example.com",
"phone": "電話",
"phone_placeholder": "+81 3 1234 5678",
"organization": "組織",
"organization_placeholder": "会社名",
"note": "メモ",
"note_placeholder": "メモを追加...",
"context_work": "仕事",
"context_private": "プライベート",
"add_email": "メール追加",
"add_phone": "電話追加",
"save": "保存",
"cancel": "キャンセル",
"creating": "作成中...",
"updating": "更新中...",
"name_required": "名前は必須です",
"email_invalid": "有効なメールアドレスを入力してください",
"save_failed": "連絡先の保存に失敗しました"
},
"toast": {
"created": "連絡先を作成しました",
"updated": "連絡先を更新しました",
"deleted": "連絡先を削除しました",
"error_create": "連絡先の作成に失敗しました",
"error_update": "連絡先の更新に失敗しました",
"error_delete": "連絡先の削除に失敗しました"
}
} }
} }
+54
View File
@@ -26,6 +26,7 @@
"search_placeholder": "Zoeken in e-mail...", "search_placeholder": "Zoeken in e-mail...",
"storage": "Opslag", "storage": "Opslag",
"sign_out": "Afmelden", "sign_out": "Afmelden",
"contacts": "Contacten",
"settings": "Instellingen", "settings": "Instellingen",
"loading_mailboxes": "Mappen laden...", "loading_mailboxes": "Mappen laden...",
"push_connected": "Real-time updates actief", "push_connected": "Real-time updates actief",
@@ -742,5 +743,58 @@
"identity_short": "via {name}", "identity_short": "via {name}",
"subaddress_tag": "+{tag}" "subaddress_tag": "+{tag}"
} }
},
"contacts": {
"title": "Contacten",
"search_placeholder": "Contacten zoeken...",
"create_new": "Nieuw contact",
"empty_state": "Geen contacten",
"empty_search": "Geen contacten gevonden",
"delete_confirm": "Weet u zeker dat u dit contact wilt verwijderen?",
"local_mode": "Contacten worden lokaal opgeslagen (server ondersteunt geen JMAP Contacts)",
"back_to_mail": "Terug naar e-mail",
"detail": {
"emails": "E-mailadressen",
"phones": "Telefoonnummers",
"organizations": "Organisaties",
"addresses": "Adressen",
"notes": "Notities",
"no_contact_selected": "Selecteer een contact om details te bekijken",
"created": "Aangemaakt",
"updated": "Laatst bijgewerkt"
},
"form": {
"create_title": "Nieuw contact",
"edit_title": "Contact bewerken",
"given_name": "Voornaam",
"surname": "Achternaam",
"email": "E-mail",
"email_placeholder": "email@example.com",
"phone": "Telefoon",
"phone_placeholder": "+31 20 123 4567",
"organization": "Organisatie",
"organization_placeholder": "Bedrijfsnaam",
"note": "Notities",
"note_placeholder": "Notitie toevoegen...",
"context_work": "Werk",
"context_private": "Privé",
"add_email": "E-mail toevoegen",
"add_phone": "Telefoon toevoegen",
"save": "Opslaan",
"cancel": "Annuleren",
"creating": "Aanmaken...",
"updating": "Bijwerken...",
"name_required": "Ten minste een voor- of achternaam is vereist",
"email_invalid": "Voer een geldig e-mailadres in",
"save_failed": "Kon contact niet opslaan"
},
"toast": {
"created": "Contact aangemaakt",
"updated": "Contact bijgewerkt",
"deleted": "Contact verwijderd",
"error_create": "Kon contact niet aanmaken",
"error_update": "Kon contact niet bijwerken",
"error_delete": "Kon contact niet verwijderen"
}
} }
} }
+54
View File
@@ -26,6 +26,7 @@
"search_placeholder": "Buscar e-mails...", "search_placeholder": "Buscar e-mails...",
"storage": "Armazenamento", "storage": "Armazenamento",
"sign_out": "Sair", "sign_out": "Sair",
"contacts": "Contatos",
"settings": "Configurações", "settings": "Configurações",
"loading_mailboxes": "Carregando caixas de entrada...", "loading_mailboxes": "Carregando caixas de entrada...",
"push_connected": "Atualizações em tempo real ativas", "push_connected": "Atualizações em tempo real ativas",
@@ -742,5 +743,58 @@
"identity_short": "via {name}", "identity_short": "via {name}",
"subaddress_tag": "+{tag}" "subaddress_tag": "+{tag}"
} }
},
"contacts": {
"title": "Contatos",
"search_placeholder": "Pesquisar contatos...",
"create_new": "Novo contato",
"empty_state": "Nenhum contato",
"empty_search": "Nenhum contato encontrado",
"delete_confirm": "Tem certeza de que deseja excluir este contato?",
"local_mode": "Os contatos são armazenados localmente (o servidor não suporta JMAP Contacts)",
"back_to_mail": "Voltar ao e-mail",
"detail": {
"emails": "Endereços de e-mail",
"phones": "Números de telefone",
"organizations": "Organizações",
"addresses": "Endereços",
"notes": "Notas",
"no_contact_selected": "Selecione um contato para ver os detalhes",
"created": "Criado",
"updated": "Última atualização"
},
"form": {
"create_title": "Novo contato",
"edit_title": "Editar contato",
"given_name": "Nome",
"surname": "Sobrenome",
"email": "E-mail",
"email_placeholder": "email@example.com",
"phone": "Telefone",
"phone_placeholder": "+55 11 1234 5678",
"organization": "Organização",
"organization_placeholder": "Nome da empresa",
"note": "Notas",
"note_placeholder": "Adicionar uma nota...",
"context_work": "Trabalho",
"context_private": "Pessoal",
"add_email": "Adicionar e-mail",
"add_phone": "Adicionar telefone",
"save": "Salvar",
"cancel": "Cancelar",
"creating": "Criando...",
"updating": "Atualizando...",
"name_required": "É necessário pelo menos um nome ou sobrenome",
"email_invalid": "Por favor, insira um endereço de e-mail válido",
"save_failed": "Falha ao salvar contato"
},
"toast": {
"created": "Contato criado",
"updated": "Contato atualizado",
"deleted": "Contato excluído",
"error_create": "Falha ao criar contato",
"error_update": "Falha ao atualizar contato",
"error_delete": "Falha ao excluir contato"
}
} }
} }
+932 -910
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -36,7 +36,7 @@
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dompurify": "^3.3.1", "dompurify": "^3.3.1",
"jmap-jam": "^0.13.1", "jmap-jam": "^0.13.1",
"lucide-react": "^0.562.0", "lucide-react": "^0.564.0",
"next": "^16.1.5", "next": "^16.1.5",
"next-intl": "^4.5.8", "next-intl": "^4.5.8",
"react": "^19.2.1", "react": "^19.2.1",
@@ -50,19 +50,19 @@
"@testing-library/dom": "^10.4.1", "@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1", "@testing-library/react": "^16.3.1",
"@types/node": "^22", "@types/node": "^25.2.3",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.49.0", "@typescript-eslint/eslint-plugin": "^8.49.0",
"@typescript-eslint/parser": "^8.49.0", "@typescript-eslint/parser": "^8.49.0",
"@vitejs/plugin-react": "^5.1.2", "@vitejs/plugin-react": "^5.1.2",
"@vitest/ui": "^4.0.16", "@vitest/ui": "^4.0.16",
"eslint": "^9.39.1", "eslint": "^9.39.2",
"eslint-config-next": "^16.1.5", "eslint-config-next": "^16.1.5",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"globals": "^17.0.0", "globals": "^17.0.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"jsdom": "^27.4.0", "jsdom": "^28.1.0",
"lint-staged": "^16.2.7", "lint-staged": "^16.2.7",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"typescript": "^5.9.3", "typescript": "^5.9.3",
+14
View File
@@ -3,6 +3,7 @@ import { persist } from 'zustand/middleware';
import { JMAPClient } from '@/lib/jmap/client'; import { JMAPClient } from '@/lib/jmap/client';
import { useEmailStore } from './email-store'; import { useEmailStore } from './email-store';
import { useIdentityStore } from './identity-store'; import { useIdentityStore } from './identity-store';
import { useContactStore } from './contact-store';
import type { Identity } from '@/lib/jmap/types'; import type { Identity } from '@/lib/jmap/types';
interface AuthState { interface AuthState {
@@ -50,6 +51,16 @@ export const useAuthStore = create<AuthState>()(
// Sync identities to identity store // Sync identities to identity store
useIdentityStore.getState().setIdentities(identities); useIdentityStore.getState().setIdentities(identities);
// Fetch contacts if server supports JMAP Contacts
if (client.supportsContacts()) {
const contactStore = useContactStore.getState();
contactStore.setSupportsSync(true);
contactStore.fetchAddressBooks(client).catch(() => {});
contactStore.fetchContacts(client).catch(() => {});
} else {
useContactStore.getState().setSupportsSync(false);
}
// Success - save state (but NOT the password) // Success - save state (but NOT the password)
set({ set({
isAuthenticated: true, isAuthenticated: true,
@@ -124,6 +135,9 @@ export const useAuthStore = create<AuthState>()(
// Clear identity store state // Clear identity store state
useIdentityStore.getState().clearIdentities(); useIdentityStore.getState().clearIdentities();
// Clear contact store state
useContactStore.getState().clearContacts();
}, },
checkAuth: async () => { checkAuth: async () => {
+198
View File
@@ -0,0 +1,198 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { ContactCard, AddressBook, ContactName } from '@/lib/jmap/types';
import type { JMAPClient } from '@/lib/jmap/client';
function getContactDisplayName(contact: ContactCard): string {
if (contact.name?.components) {
const given = contact.name.components.find(c => c.kind === 'given')?.value || '';
const surname = contact.name.components.find(c => c.kind === 'surname')?.value || '';
const full = [given, surname].filter(Boolean).join(' ');
if (full) return full;
}
if (contact.nicknames) {
const nick = Object.values(contact.nicknames)[0];
if (nick?.name) return nick.name;
}
if (contact.emails) {
const email = Object.values(contact.emails)[0];
if (email?.address) return email.address;
}
return '';
}
function getContactPrimaryEmail(contact: ContactCard): string {
if (!contact.emails) return '';
return Object.values(contact.emails)[0]?.address || '';
}
interface ContactStore {
contacts: ContactCard[];
addressBooks: AddressBook[];
selectedContactId: string | null;
searchQuery: string;
isLoading: boolean;
error: string | null;
supportsSync: boolean;
fetchContacts: (client: JMAPClient) => Promise<void>;
fetchAddressBooks: (client: JMAPClient) => Promise<void>;
createContact: (client: JMAPClient, contact: Partial<ContactCard>) => Promise<void>;
updateContact: (client: JMAPClient, id: string, updates: Partial<ContactCard>) => Promise<void>;
deleteContact: (client: JMAPClient, id: string) => Promise<void>;
addLocalContact: (contact: ContactCard) => void;
updateLocalContact: (id: string, updates: Partial<ContactCard>) => void;
deleteLocalContact: (id: string) => void;
setSelectedContact: (id: string | null) => void;
setSearchQuery: (query: string) => void;
setSupportsSync: (supports: boolean) => void;
clearContacts: () => void;
getAutocomplete: (query: string) => Array<{ name: string; email: string }>;
}
export const useContactStore = create<ContactStore>()(
persist(
(set, get) => ({
contacts: [],
addressBooks: [],
selectedContactId: null,
searchQuery: '',
isLoading: false,
error: null,
supportsSync: false,
fetchContacts: async (client) => {
set({ isLoading: true, error: null });
try {
const contacts = await client.getContacts();
set({ contacts, isLoading: false });
} catch (error) {
console.error('Failed to fetch contacts:', error);
set({ error: 'Failed to fetch contacts', isLoading: false });
}
},
fetchAddressBooks: async (client) => {
try {
const addressBooks = await client.getAddressBooks();
set({ addressBooks });
} catch (error) {
console.error('Failed to fetch address books:', error);
}
},
createContact: async (client, contact) => {
set({ isLoading: true, error: null });
try {
const created = await client.createContact(contact);
set((state) => ({
contacts: [...state.contacts, created],
isLoading: false,
}));
} catch (error) {
const msg = error instanceof Error ? error.message : 'Failed to create contact';
set({ error: msg, isLoading: false });
throw error;
}
},
updateContact: async (client, id, updates) => {
set({ error: null });
try {
await client.updateContact(id, updates);
set((state) => ({
contacts: state.contacts.map(c =>
c.id === id ? { ...c, ...updates } : c
),
}));
} catch (error) {
const msg = error instanceof Error ? error.message : 'Failed to update contact';
set({ error: msg });
throw error;
}
},
deleteContact: async (client, id) => {
set({ error: null });
try {
await client.deleteContact(id);
set((state) => ({
contacts: state.contacts.filter(c => c.id !== id),
selectedContactId: state.selectedContactId === id ? null : state.selectedContactId,
}));
} catch (error) {
const msg = error instanceof Error ? error.message : 'Failed to delete contact';
set({ error: msg });
throw error;
}
},
addLocalContact: (contact) => set((state) => ({
contacts: [...state.contacts, contact],
})),
updateLocalContact: (id, updates) => set((state) => ({
contacts: state.contacts.map(c =>
c.id === id ? { ...c, ...updates } : c
),
})),
deleteLocalContact: (id) => set((state) => ({
contacts: state.contacts.filter(c => c.id !== id),
selectedContactId: state.selectedContactId === id ? null : state.selectedContactId,
})),
setSelectedContact: (id) => set({ selectedContactId: id }),
setSearchQuery: (query) => set({ searchQuery: query }),
setSupportsSync: (supports) => set({ supportsSync: supports }),
clearContacts: () => set({
contacts: [],
addressBooks: [],
selectedContactId: null,
searchQuery: '',
error: null,
}),
getAutocomplete: (query) => {
const { contacts } = get();
if (!query || query.length < 1) return [];
const lower = query.toLowerCase();
const results: Array<{ name: string; email: string }> = [];
for (const contact of contacts) {
const name = getContactDisplayName(contact);
const emails = contact.emails ? Object.values(contact.emails) : [];
for (const emailEntry of emails) {
if (!emailEntry.address) continue;
if (
name.toLowerCase().includes(lower) ||
emailEntry.address.toLowerCase().includes(lower)
) {
results.push({ name, email: emailEntry.address });
}
}
if (results.length >= 10) break;
}
return results;
},
}),
{
name: 'contact-storage',
partialize: (state) => ({
contacts: state.supportsSync ? [] : state.contacts,
supportsSync: state.supportsSync,
}),
}
)
);
export { getContactDisplayName, getContactPrimaryEmail };
export type { ContactName };