From 77514bd0541d357a95c4f7c2e9d6d157b51951d4 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 19 Mar 2026 13:33:53 +0100 Subject: [PATCH] fix: RFC 9553 compliance for contacts (birthday, addresses) --- components/contacts/contact-detail.tsx | 29 ++++++--- components/contacts/contact-form.tsx | 89 +++++++++++++++++++++----- components/email/email-viewer.tsx | 6 +- lib/jmap/types.ts | 33 +++++++++- lib/vcard.ts | 57 ++++++++++++++--- 5 files changed, 178 insertions(+), 36 deletions(-) diff --git a/components/contacts/contact-detail.tsx b/components/contacts/contact-detail.tsx index ae05be43..55aad09b 100644 --- a/components/contacts/contact-detail.tsx +++ b/components/contacts/contact-detail.tsx @@ -6,7 +6,7 @@ import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Co 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 type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types"; import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store"; import { useSmimeStore } from "@/stores/smime-store"; import { parseCertificatePemOrDer, extractCertificateInfo } from "@/lib/smime/certificate-utils"; @@ -26,12 +26,23 @@ function formatPhoneFeatures(features?: Record): string { return Object.keys(features).filter(k => features[k]).join(", "); } -function formatDate(dateInput: string | Record): string { +function formatDate(dateInput: AnniversaryDate): string { // Handle RFC 9553 PartialDate objects: { year?, month?, day?, calendarScale? } + // Handle RFC 9553 Timestamp objects: { "@type": "Timestamp", utc: "..." } if (typeof dateInput === 'object' && dateInput !== null) { - const year = dateInput.year as number | undefined; - const month = dateInput.month as number | undefined; - const day = dateInput.day as number | undefined; + if (dateInput['@type'] === 'Timestamp' && typeof dateInput.utc === 'string') { + try { + const d = new Date(dateInput.utc as string); + if (!isNaN(d.getTime())) { + return d.toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" }); + } + } catch { /* fallback */ } + return String(dateInput.utc); + } + const pd = dateInput as PartialDate; + const year = pd.year; + const month = pd.month; + const day = pd.day; const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; const parts: string[] = []; if (month && monthNames[month - 1]) parts.push(monthNames[month - 1]); @@ -282,9 +293,11 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className } {addresses.map((a, i) => (
- {a.fullAddress - ? a.fullAddress - : [a.street, a.locality, a.region, a.postcode, a.country].filter(Boolean).join(", ")} + {a.full || a.fullAddress + ? (a.full || a.fullAddress) + : a.components && a.components.length > 0 + ? a.components.filter(c => c.kind !== 'separator').map(c => c.value).filter(Boolean).join(", ") + : [a.street, a.locality, a.region, a.postcode, a.country].filter(Boolean).join(", ")} {a.contexts && }
{a.timeZone && ( diff --git a/components/contacts/contact-form.tsx b/components/contacts/contact-form.tsx index ffeb6603..fbe1f59f 100644 --- a/components/contacts/contact-form.tsx +++ b/components/contacts/contact-form.tsx @@ -6,7 +6,7 @@ import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; -import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook } from "@/lib/jmap/types"; +import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress } from "@/lib/jmap/types"; interface EmailEntry { address: string; @@ -129,6 +129,67 @@ export function ContactForm({ contact, addressBooks, onSave, onCancel }: Contact const findComponent = (kind: string) => contact?.name?.components?.find(c => c.kind === kind)?.value || ""; + // Convert RFC 9553 AnniversaryDate to ISO date string for HTML date input + function anniversaryDateToString(date: AnniversaryDate): string { + if (typeof date === 'string') return date; + if (date && typeof date === 'object') { + if ('@type' in date && date['@type'] === 'Timestamp' && 'utc' in date) { + return (date as { utc: string }).utc.split('T')[0]; + } + const pd = date as PartialDate; + if (pd.year && pd.month && pd.day) { + return `${String(pd.year).padStart(4, '0')}-${String(pd.month).padStart(2, '0')}-${String(pd.day).padStart(2, '0')}`; + } + if (pd.month && pd.day) { + return `--${String(pd.month).padStart(2, '0')}-${String(pd.day).padStart(2, '0')}`; + } + if (pd.year && pd.month) { + return `${String(pd.year).padStart(4, '0')}-${String(pd.month).padStart(2, '0')}`; + } + if (pd.year) return String(pd.year); + } + return String(date); + } + + // Convert ISO date string back to RFC 9553 PartialDate for the server + function stringToPartialDate(str: string): PartialDate { + if (str.startsWith('--')) { + const parts = str.substring(2).split('-'); + const pd: PartialDate = { month: parseInt(parts[0], 10) }; + if (parts[1]) pd.day = parseInt(parts[1], 10); + return pd; + } + const parts = str.split('-'); + const pd: PartialDate = {}; + if (parts[0]) pd.year = parseInt(parts[0], 10); + if (parts[1]) pd.month = parseInt(parts[1], 10); + if (parts[2]) pd.day = parseInt(parts[2], 10); + return pd; + } + + // Extract flat address fields from RFC 9553 components format + function addressToFlat(a: ContactAddress): AddressEntry { + if (a.components && a.components.length > 0) { + const findComp = (kind: string) => a.components!.filter(c => c.kind === kind).map(c => c.value).join(' '); + return { + street: findComp('name') || findComp('number') ? [findComp('number'), findComp('name')].filter(Boolean).join(' ') : '', + locality: findComp('locality'), + region: findComp('region'), + postcode: findComp('postcode'), + country: findComp('country'), + context: a.contexts?.work ? 'work' : a.contexts?.private ? 'private' : '', + }; + } + return { + street: a.street || '', + locality: a.locality || '', + region: a.region || '', + postcode: a.postcode || '', + country: a.country || '', + context: a.contexts?.work ? 'work' : a.contexts?.private ? 'private' : '', + }; + } + const [prefix, setPrefix] = useState(findComponent("prefix")); const [givenName, setGivenName] = useState(findComponent("given")); const [additionalName, setAdditionalName] = useState(findComponent("additional")); @@ -184,14 +245,7 @@ export function ContactForm({ contact, addressBooks, onSave, onCancel }: Contact const [addresses, setAddresses] = useState(() => { if (contact?.addresses) { - return Object.values(contact.addresses).map(a => ({ - street: a.street || "", - locality: a.locality || "", - region: a.region || "", - postcode: a.postcode || "", - country: a.country || "", - context: a.contexts?.work ? "work" : a.contexts?.private ? "private" : "", - })); + return Object.values(contact.addresses).map(a => addressToFlat(a)); } return []; }); @@ -210,7 +264,7 @@ export function ContactForm({ contact, addressBooks, onSave, onCancel }: Contact const [anniversaries, setAnniversaries] = useState(() => { if (contact?.anniversaries) { return Object.values(contact.anniversaries).map(a => ({ - date: a.date, + date: anniversaryDateToString(a.date), kind: a.kind, })); } @@ -336,12 +390,13 @@ export function ContactForm({ contact, addressBooks, onSave, onCancel }: Contact const addressesMap: Record ? V : never> = {}; addresses.filter(a => a.street.trim() || a.locality.trim() || a.country.trim()).forEach((a, i) => { - const obj: Record = {}; - if (a.street.trim()) obj.street = a.street.trim(); - if (a.locality.trim()) obj.locality = a.locality.trim(); - if (a.region.trim()) obj.region = a.region.trim(); - if (a.postcode.trim()) obj.postcode = a.postcode.trim(); - if (a.country.trim()) obj.country = a.country.trim(); + const components: Array<{ kind: string; value: string }> = []; + if (a.street.trim()) components.push({ kind: "name", value: a.street.trim() }); + if (a.locality.trim()) components.push({ kind: "locality", value: a.locality.trim() }); + if (a.region.trim()) components.push({ kind: "region", value: a.region.trim() }); + if (a.postcode.trim()) components.push({ kind: "postcode", value: a.postcode.trim() }); + if (a.country.trim()) components.push({ kind: "country", value: a.country.trim() }); + const obj: Record = { components, isOrdered: true, defaultSeparator: ", " }; if (a.context) obj.contexts = { [a.context]: true }; // @ts-expect-error - dynamic build addressesMap[`a${i}`] = obj; @@ -357,7 +412,7 @@ export function ContactForm({ contact, addressBooks, onSave, onCancel }: Contact const anniversariesMap: Record = {}; anniversaries.filter(a => a.date.trim()).forEach((a, i) => { - anniversariesMap[`an${i}`] = { date: a.date.trim(), kind: a.kind }; + anniversariesMap[`an${i}`] = { date: stringToPartialDate(a.date.trim()), kind: a.kind }; }); const personalInfoMap: Record = {}; diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index c3770397..b1ce029e 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -706,7 +706,11 @@ function ContactSidebarPanel({ {addresses.map((a, i) => (
- {[a.street, a.locality, a.region, a.postcode, a.country].filter(Boolean).join(", ")} + {a.full || a.fullAddress + ? (a.full || a.fullAddress) + : a.components && a.components.length > 0 + ? a.components.filter(c => c.kind !== 'separator').map(c => c.value).filter(Boolean).join(", ") + : [a.street, a.locality, a.region, a.postcode, a.country].filter(Boolean).join(", ")}
))}
diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index 1136c57a..c4854ee5 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -252,7 +252,20 @@ export interface ContactTitle { organizationId?: string; } +// RFC 9553 AddressComponent +export interface AddressComponent { + kind: 'room' | 'apartment' | 'floor' | 'building' | 'number' | 'name' | 'block' | 'subDistrict' | 'district' | 'locality' | 'region' | 'postcode' | 'country' | 'direction' | 'landmark' | 'postOfficeBox' | 'separator' | string; + value: string; + phonetic?: string; +} + export interface ContactAddress { + // RFC 9553 format + components?: AddressComponent[]; + full?: string; + isOrdered?: boolean; + defaultSeparator?: string; + // Legacy flat fields (from vCard import) street?: string; locality?: string; region?: string; @@ -284,9 +297,27 @@ export interface ContactMedia { mediaType?: string; } +// RFC 9553 PartialDate +export interface PartialDate { + '@type'?: 'PartialDate'; + year?: number; + month?: number; + day?: number; + calendarScale?: string; +} + +// RFC 9553 Timestamp +export interface Timestamp { + '@type': 'Timestamp'; + utc: string; +} + +export type AnniversaryDate = string | PartialDate | Timestamp; + export interface ContactAnniversary { + '@type'?: 'Anniversary'; kind: 'birth' | 'death' | 'wedding' | 'other'; - date: string; + date: AnniversaryDate; place?: ContactAddress; } diff --git a/lib/vcard.ts b/lib/vcard.ts index 8fbc819f..6ce18bf3 100644 --- a/lib/vcard.ts +++ b/lib/vcard.ts @@ -1,4 +1,26 @@ -import type { ContactCard, NameComponent, ContactMedia, ContactOnlineService } from "@/lib/jmap/types"; +import type { ContactCard, NameComponent, ContactMedia, ContactOnlineService, AnniversaryDate, PartialDate } from "@/lib/jmap/types"; + +// Convert RFC 9553 AnniversaryDate (PartialDate|Timestamp|string) to vCard date string +function anniversaryDateToVcardString(date: AnniversaryDate): string { + if (typeof date === 'string') return date; + if (date && typeof date === 'object') { + if ('@type' in date && date['@type'] === 'Timestamp' && 'utc' in date) { + return (date as { utc: string }).utc.split('T')[0]; + } + const pd = date as PartialDate; + if (pd.year && pd.month && pd.day) { + return `${String(pd.year).padStart(4, '0')}-${String(pd.month).padStart(2, '0')}-${String(pd.day).padStart(2, '0')}`; + } + if (pd.month && pd.day) { + return `--${String(pd.month).padStart(2, '0')}-${String(pd.day).padStart(2, '0')}`; + } + if (pd.year && pd.month) { + return `${String(pd.year).padStart(4, '0')}-${String(pd.month).padStart(2, '0')}`; + } + if (pd.year) return String(pd.year); + } + return String(date); +} const VCARD_SEX_TO_GENDER: Record = { M: "masculine", @@ -598,14 +620,30 @@ function generateSingleVCard(contact: ContactCard): string { for (const addr of Object.values(contact.addresses)) { const type = contextToType(addr.contexts); const typeParam = type ? `;TYPE=${type}` : ""; + let street = addr.street || ""; + let locality = addr.locality || ""; + let region = addr.region || ""; + let postcode = addr.postcode || ""; + let country = addr.country || ""; + // RFC 9553 components-based address: extract flat fields for vCard ADR + if (addr.components && addr.components.length > 0) { + const findComp = (kind: string) => addr.components!.filter(c => c.kind === kind).map(c => c.value).join(' '); + const number = findComp('number'); + const name = findComp('name'); + street = street || [number, name].filter(Boolean).join(' '); + locality = locality || findComp('locality'); + region = region || findComp('region'); + postcode = postcode || findComp('postcode'); + country = country || findComp('country'); + } const parts = [ "", "", - addr.street || "", - addr.locality || "", - addr.region || "", - addr.postcode || "", - addr.country || "", + street, + locality, + region, + postcode, + country, ]; lines.push(`ADR${typeParam}:${parts.map(encodeValue).join(";")}`); } @@ -613,12 +651,13 @@ function generateSingleVCard(contact: ContactCard): string { if (contact.anniversaries) { for (const ann of Object.values(contact.anniversaries)) { + const dateStr = anniversaryDateToVcardString(ann.date); if (ann.kind === "birth") { - lines.push(`BDAY:${ann.date}`); + lines.push(`BDAY:${dateStr}`); } else if (ann.kind === "wedding") { - lines.push(`ANNIVERSARY:${ann.date}`); + lines.push(`ANNIVERSARY:${dateStr}`); } else if (ann.kind === "death") { - lines.push(`DEATHDATE:${ann.date}`); + lines.push(`DEATHDATE:${dateStr}`); } } }