fix: RFC 9553 compliance for contacts (birthday, addresses)

This commit is contained in:
Linus Rath
2026-03-19 13:33:53 +01:00
parent 2edf2fab89
commit 77514bd054
5 changed files with 178 additions and 36 deletions
+21 -8
View File
@@ -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, boolean>): string {
return Object.keys(features).filter(k => features[k]).join(", ");
}
function formatDate(dateInput: string | Record<string, unknown>): 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) => (
<div key={i} className="text-sm space-y-0.5 rounded-md border border-border/60 bg-muted/30 p-3">
<div>
{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 && <ContextBadge contexts={a.contexts} />}
</div>
{a.timeZone && (
+72 -17
View File
@@ -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<AddressEntry[]>(() => {
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<AnniversaryEntry[]>(() => {
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<string, ContactCard["addresses"] extends Record<string, infer V> ? V : never> = {};
addresses.filter(a => a.street.trim() || a.locality.trim() || a.country.trim()).forEach((a, i) => {
const obj: Record<string, unknown> = {};
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<string, unknown> = { 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<string, ContactAnniversary> = {};
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<string, ContactPersonalInfo> = {};
+5 -1
View File
@@ -706,7 +706,11 @@ function ContactSidebarPanel({
<SidebarSection icon={MapPin} title="Addresses">
{addresses.map((a, i) => (
<div key={i} className="text-sm text-muted-foreground">
{[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(", ")}
</div>
))}
</SidebarSection>
+32 -1
View File
@@ -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;
}
+48 -9
View File
@@ -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<string, string> = {
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}`);
}
}
}