diff --git a/README.md b/README.md index 04b8c743..29c5bd98 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,16 @@ This webmail client is designed to work seamlessly with [**Stalwart Mail Server* - Live email arrival notifications - 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 - External content blocked by default - 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 - No password storage (session-based auth) - Shared folder support with proper permissions +- Newsletter unsubscribe support (RFC 2369) ### Internationalization - 8 language support: English, French, Japanese, Spanish, Italian, German, Dutch, Portuguese diff --git a/ROADMAP.md b/ROADMAP.md index 98d2c9a4..76fab466 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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] 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 - [x] Unit tests for validation utilities (57 tests) -- [x] Unit tests for email sanitization -- [x] Unit tests for color transformation +- [x] Unit tests for email sanitization (27 tests) +- [x] Unit tests for color transformation (40 tests) - [x] XSS attack vector testing ### Deployment @@ -107,15 +121,10 @@ This document tracks the development status and planned features for JMAP Webmai ## Planned Features -### Address Book & Contacts -- [ ] Contact store with CRUD operations -- [ ] Contacts list view with search/filter -- [ ] Contact details view/edit form -- [ ] Contact groups management +### Address Book (Phase 2) +- [ ] Contact groups/lists management - [ ] vCard import/export -- [ ] JMAP contacts sync (if server supports) -- [ ] Email autocomplete from contacts -- [ ] Contacts integration in composer +- [ ] Bulk contact operations ### Advanced Features - [ ] Email filters and rules diff --git a/app/[locale]/contacts/page.tsx b/app/[locale]/contacts/page.tsx new file mode 100644 index 00000000..5e02c91c --- /dev/null +++ b/app/[locale]/contacts/page.tsx @@ -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("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) => { + 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) => { + 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 ( +
+
+
+ +
+ + +
+ +
+ {view === "create" && ( + + )} + {view === "edit" && selectedContact && ( + + )} + {(view === "list" || view === "detail") && ( + + )} +
+
+ ); +} diff --git a/app/globals.css b/app/globals.css index 6013a229..c8752ea3 100644 --- a/app/globals.css +++ b/app/globals.css @@ -88,9 +88,7 @@ body { /* Enhanced Email Content Styling */ -/* Wrapper uses inline-block to size to content, enabling horizontal scroll */ .email-content-wrapper { - display: inline-block; min-width: 100%; } @@ -100,16 +98,8 @@ body { line-height: 1.6; color: var(--color-foreground); max-width: none; - /* Size to content's natural width, don't shrink below it */ - width: max-content; - 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; + overflow-wrap: break-word; + word-wrap: break-word; } .email-content p { diff --git a/components/contacts/contact-detail.tsx b/components/contacts/contact-detail.tsx new file mode 100644 index 00000000..bec81786 --- /dev/null +++ b/components/contacts/contact-detail.tsx @@ -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 ( +
+ +

{t("detail.no_contact_selected")}

+
+ ); + } + + 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 ( +
+
+
+
+ +
+

{name || "—"}

+ {orgs.length > 0 && orgs[0].name && ( +

{orgs[0].name}

+ )} +
+
+
+ + +
+
+
+ +
+ {emails.length > 0 && ( +
+ {emails.map((e, i) => ( +
+ + {e.address} + + {e.contexts && ( + + )} +
+ ))} +
+ )} + + {phones.length > 0 && ( +
+ {phones.map((p, i) => ( +
+ + {p.number} + + {p.contexts && ( + + )} +
+ ))} +
+ )} + + {orgs.length > 0 && ( +
+ {orgs.map((o, i) => ( +
+ {o.name} + {o.units && o.units.length > 0 && ( + — {o.units.map(u => u.name).join(", ")} + )} +
+ ))} +
+ )} + + {addresses.length > 0 && ( +
+ {addresses.map((a, i) => ( +
+ {[a.street, a.locality, a.region, a.postcode, a.country].filter(Boolean).join(", ")} + {a.contexts && ( + + )} +
+ ))} +
+ )} + + {notes.length > 0 && ( +
+ {notes.map((n, i) => ( +

{n.note}

+ ))} +
+ )} +
+
+ ); +} + +function Section({ icon: Icon, title, children }: { icon: React.ComponentType<{ className?: string }>; title: string; children: React.ReactNode }) { + return ( +
+
+ +

{title}

+
+
{children}
+
+ ); +} + +function ContextBadge({ contexts }: { contexts: Record }) { + const labels = Object.keys(contexts).filter(k => contexts[k]); + if (labels.length === 0) return null; + + return ( + + {labels.join(", ")} + + ); +} diff --git a/components/contacts/contact-form.tsx b/components/contacts/contact-form.tsx new file mode 100644 index 00000000..8d005e45 --- /dev/null +++ b/components/contacts/contact-form.tsx @@ -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) => Promise; + 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(() => { + 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(() => { + 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(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 }> = {}; + validEmails.forEach((entry, i) => { + const obj: { address: string; contexts?: Record } = { 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 }> = {}; + validPhones.forEach((entry, i) => { + const obj: { number: string; contexts?: Record } = { 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 = { + 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 ( +
+
+

+ {isEditing ? t("edit_title") : t("create_title")} +

+
+ +
+ {error && ( +
+ {error} +
+ )} + +
+
+ + setGivenName(e.target.value)} + placeholder={t("given_name")} + autoFocus + /> +
+
+ + setSurname(e.target.value)} + placeholder={t("surname")} + /> +
+
+ +
+ +
+ {emails.map((entry, i) => ( +
+ { + const next = [...emails]; + next[i] = { ...next[i], address: e.target.value }; + setEmails(next); + }} + placeholder={t("email_placeholder")} + className="flex-1" + /> + + {emails.length > 1 && ( + + )} +
+ ))} + +
+
+ +
+ +
+ {phones.map((entry, i) => ( +
+ { + const next = [...phones]; + next[i] = { ...next[i], number: e.target.value }; + setPhones(next); + }} + placeholder={t("phone_placeholder")} + className="flex-1" + /> + + +
+ ))} + +
+
+ +
+ + setOrganization(e.target.value)} + placeholder={t("organization_placeholder")} + /> +
+ +
+ +