diff --git a/components/contacts/__tests__/contact-export.test.ts b/components/contacts/__tests__/contact-export.test.ts index db0734444..e191d840a 100644 --- a/components/contacts/__tests__/contact-export.test.ts +++ b/components/contacts/__tests__/contact-export.test.ts @@ -54,8 +54,8 @@ describe('vCard export of contact photos', () => { const vcf = await exported!.text(); expect(getBlobsAsBase64).toHaveBeenCalledWith(['dbcX', 'dbcLogo'], 'a'); expect(fetchBlob).not.toHaveBeenCalled(); - expect(vcf).toContain('PHOTO;ENCODING=b;TYPE=image/jpeg:/9j/'); - expect(vcf).toContain('LOGO;ENCODING=b;TYPE=image/png:iVBO'); + expect(vcf).toContain('PHOTO:data:image/jpeg;base64,/9j/'); + expect(vcf).toContain('LOGO:data:image/png;base64,iVBO'); }); it('downloads Stalwart 1.0 blob photos one by one when the client cannot batch them', async () => { @@ -70,8 +70,8 @@ describe('vCard export of contact photos', () => { const vcf = await exported!.text(); expect(fetchBlob).toHaveBeenCalledWith('dbcX', 'photo', 'image/jpeg', 'a'); expect(fetchBlob).toHaveBeenCalledWith('dbcLogo', 'logo', 'image/png', 'a'); - expect(vcf).toContain('PHOTO;ENCODING=b;TYPE=image/jpeg:/9j/'); - expect(vcf).toContain('LOGO;ENCODING=b;TYPE=image/png:/9j/'); + expect(vcf).toContain('PHOTO:data:image/jpeg;base64,/9j/'); + expect(vcf).toContain('LOGO:data:image/png;base64,/9j/'); }); it('writes Stalwart 0.16 photos without a media type too', async () => { @@ -82,8 +82,8 @@ describe('vCard export of contact photos', () => { const vcf = await exported!.text(); expect(fetchBlob).not.toHaveBeenCalled(); - expect(vcf).toContain('PHOTO;ENCODING=b;TYPE=image/jpeg:/9j/'); - expect(vcf).toContain('PHOTO;ENCODING=b:/9j/'); + expect(vcf).toContain('PHOTO:data:image/jpeg;base64,/9j/'); + expect(vcf).toContain('PHOTO:data:;base64,/9j/'); }); it('leaves out a photo that cannot be downloaded and exports the rest', async () => { diff --git a/components/contacts/contact-form.tsx b/components/contacts/contact-form.tsx index ebfe802a0..e31056919 100644 --- a/components/contacts/contact-form.tsx +++ b/components/contacts/contact-form.tsx @@ -21,7 +21,7 @@ interface EmailEntry { interface PhoneEntry { number: string; context: "work" | "private" | ""; - feature: "voice" | "cell" | "fax" | "pager" | "video" | "text" | ""; + feature: "voice" | "mobile" | "fax" | "pager" | "video" | "text" | ""; } interface OnlineServiceEntry { @@ -319,7 +319,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress return Object.values(contact.phones).map(p => ({ number: p.number, context: p.contexts?.work ? "work" : p.contexts?.private ? "private" : "", - feature: p.features?.cell ? "cell" : p.features?.fax ? "fax" : p.features?.pager ? "pager" : p.features?.video ? "video" : p.features?.text ? "text" : p.features?.voice ? "voice" : "", + // RFC 9553 calls it "mobile"; cards saved by older versions of this form say "cell". + feature: p.features?.mobile || p.features?.cell ? "mobile" : p.features?.fax ? "fax" : p.features?.pager ? "pager" : p.features?.video ? "video" : p.features?.text ? "text" : p.features?.voice ? "voice" : "", })); } return []; @@ -942,7 +943,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress > - + diff --git a/lib/__tests__/vcard.test.ts b/lib/__tests__/vcard.test.ts index af850edab..e9af44004 100644 --- a/lib/__tests__/vcard.test.ts +++ b/lib/__tests__/vcard.test.ts @@ -373,8 +373,8 @@ describe("generateVCard", () => { expect(vcf).toContain("BEGIN:VCARD"); expect(vcf).toContain("END:VCARD"); - expect(vcf).toContain("VERSION:3.0"); - expect(vcf).toContain("UID:uid-1"); + expect(vcf).toContain("VERSION:4.0"); + expect(vcf).toContain("UID;VALUE=text:uid-1"); expect(vcf).toContain("KIND:individual"); expect(vcf).toContain("FN:Dr. Jane Marie Smith PhD"); expect(vcf).toContain("N:Smith;Jane;Marie;Dr.;PhD"); @@ -401,10 +401,11 @@ describe("generateVCard", () => { const lines = vcf.split("\r\n"); expect(lines[0]).toBe("BEGIN:VCARD"); - expect(lines[1]).toBe("VERSION:3.0"); + expect(lines[1]).toBe("VERSION:4.0"); expect(lines).toContain("FN:Solo"); expect(lines).toContain("N:;Solo;;;"); - expect(lines[lines.length - 1]).toBe("END:VCARD"); + // Every line ends with CRLF, END:VCARD included. + expect(vcf.endsWith("END:VCARD\r\n")).toBe(true); }); it("exports GENDER, LOGO, SOUND, GEO, TZ, CALURI, CALADRURI, FBURL, SOURCE", () => { @@ -460,14 +461,14 @@ describe("generateVCard", () => { }; const lines = generateVCard([contact]).split("\r\n"); - expect(lines).toContain("PHOTO;ENCODING=b;TYPE=image/jpeg:/9j/"); - expect(lines).toContain("PHOTO;ENCODING=b:iVBO"); - expect(lines).toContain("LOGO;ENCODING=b;TYPE=image/png:iVBO"); + expect(lines).toContain("PHOTO:data:image/jpeg;base64,/9j/"); + expect(lines).toContain("PHOTO:data:;base64,iVBO"); + expect(lines).toContain("LOGO:data:image/png;base64,iVBO"); const [reparsed] = parseVCard(generateVCard([contact])); expect(Object.values(reparsed.media ?? {}).map(m => m.uri)).toEqual([ "data:image/jpeg;base64,/9j/", - "data:image/jpeg;base64,iVBO", + "data:;base64,iVBO", "data:image/png;base64,iVBO", ]); }); @@ -524,6 +525,117 @@ describe("generateVCard", () => { }); }); +describe("vCard 4.0 export (issue #1407)", () => { + const card = (fields: Partial): ContactCard => ({ + id: "c1", + addressBookIds: {}, + name: { full: "Jane Doe", components: [{ kind: "given", value: "Jane" }, { kind: "surname", value: "Doe" }] }, + ...fields, + }); + const linesOf = (contact: ContactCard) => generateVCard([contact]).split("\r\n"); + + it("writes the card from the issue as valid vCard 4.0", () => { + const vcf = generateVCard([card({ + uid: "sabre-vobject-5e64d0ac-69da-4393-bfe4-87844b9a7db6", + prodId: "-//MStilkerich//RCMCardDAV v4.4.6//EN", + updated: "2025-07-28T12:17:00Z", + emails: { e0: { address: "jane.doe@example.com", pref: 1 } }, + phones: { p0: { number: "+33 6 00 00 00 00", features: { mobile: true, voice: true } } }, + organizations: { o0: { name: "Example" } }, + })]); + + expect(vcf).toBe([ + "BEGIN:VCARD", + "VERSION:4.0", + "PRODID:-//JMAP-Webmail//EN", + "UID;VALUE=text:sabre-vobject-5e64d0ac-69da-4393-bfe4-87844b9a7db6", + "REV:20250728T121700Z", + "N:Doe;Jane;;;", + "FN:Jane Doe", + "EMAIL;PREF=1:jane.doe@example.com", + "TEL;TYPE=CELL,VOICE:+33 6 00 00 00 00", + "ORG:Example", + "END:VCARD", + "", + ].join("\r\n")); + }); + + it("writes N before FN, which some importers need to read the name", () => { + const lines = linesOf(card({})); + expect(lines.indexOf("N:Doe;Jane;;;")).toBeLessThan(lines.indexOf("FN:Jane Doe")); + }); + + it("keeps a URI UID without VALUE=text", () => { + expect(linesOf(card({ uid: "urn:uuid:0f3c9d2e" }))).toContain("UID:urn:uuid:0f3c9d2e"); + }); + + it.each([ + [{ mobile: true }, "TEL;TYPE=CELL:+1"], + [{ cell: true }, "TEL;TYPE=CELL:+1"], + [{ mobile: true, cell: true }, "TEL;TYPE=CELL:+1"], + [{ "main-number": true }, "TEL;TYPE=MAIN-NUMBER:+1"], + [{ "x-sat": true, fax: true }, "TEL;TYPE=X-SAT,FAX:+1"], + [{ satellite: true }, "TEL:+1"], + ])("writes phone features %o as %s", (features, expected) => { + expect(linesOf(card({ phones: { p0: { number: "+1", features } } }))).toContain(expected); + }); + + it.each(["CELL", "cell", "MOBILE"])("imports TEL;TYPE=%s as the RFC 9553 mobile feature", (type) => { + const [parsed] = parseVCard(`BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Jane\r\nTEL;TYPE=${type}:+1\r\nEND:VCARD\r\n`); + expect(parsed.phones?.p0?.features).toEqual({ mobile: true }); + }); + + it("round-trips a mobile phone", () => { + const [reparsed] = parseVCard(generateVCard([card({ phones: { p0: { number: "+1", features: { mobile: true } } } })])); + expect(reparsed.phones?.p0?.features).toEqual({ mobile: true }); + }); + + it("writes CREATED in the basic timestamp format", () => { + expect(linesOf(card({ created: "2024-01-01T08:30:15.250+02:00" }))).toContain("CREATED:20240101T083015+0200"); + }); + + it("writes a bare RELATED name as text", () => { + const lines = linesOf(card({ relatedTo: { + "Sam Smith, Jr.": { relation: { sibling: true } }, + "urn:uuid:42": { relation: { friend: true } }, + } })); + expect(lines).toContain("RELATED;VALUE=text;TYPE=sibling:Sam Smith\\, Jr."); + expect(lines).toContain("RELATED;TYPE=friend:urn:uuid:42"); + const [reparsed] = parseVCard(lines.join("\r\n")); + expect(Object.keys(reparsed.relatedTo ?? {})).toEqual(["Sam Smith, Jr.", "urn:uuid:42"]); + }); + + it("writes vCard 3.0 GEO coordinates as a geo: URI", () => { + expect(linesOf(card({ addresses: { a0: { coordinates: "37.386013;-122.082932" } } }))) + .toContain("GEO:geo:37.386013,-122.082932"); + }); + + it("folds long lines at 75 octets without splitting a UTF-8 character", () => { + const note = "Rendez-vous à l'hôtel de ville, salle des fêtes, près de l'église ".repeat(4).trim(); + const vcf = generateVCard([card({ notes: { n0: { note } } })]); + const encoder = new TextEncoder(); + for (const line of vcf.split("\r\n")) { + expect(encoder.encode(line).length).toBeLessThanOrEqual(75); + } + expect(vcf).toContain("\r\n "); + expect(parseVCard(vcf)[0].notes?.n0?.note).toBe(note); + }); + + it("folds an inline photo and reads it back", () => { + const data = "iVBORw0KGgo".repeat(40); + const vcf = generateVCard([card({ media: { m0: { kind: "photo", uri: `data:image/png;base64,${data}` } } })]); + expect(vcf.split("\r\n").every(line => line.length <= 75)).toBe(true); + expect(parseVCard(vcf)[0].media?.m0?.uri).toBe(`data:image/png;base64,${data}`); + }); + + it("ends every card with CRLF so cards concatenate cleanly", () => { + const vcf = generateVCard([card({ uid: "urn:uuid:a" }), card({ uid: "urn:uuid:b" })]); + expect(vcf).toContain("END:VCARD\r\nBEGIN:VCARD\r\n"); + expect(vcf.endsWith("END:VCARD\r\n")).toBe(true); + expect(parseVCard(vcf).map(c => c.uid)).toEqual(["urn:uuid:a", "urn:uuid:b"]); + }); +}); + describe("organization-only cards (issue #701)", () => { it("keeps a vCard that has only an organization name", () => { const parsed = parseVCard([ @@ -911,7 +1023,7 @@ describe("anniversary dates (issue #224)", () => { it("round-trips a birthday through generateVCard", () => { const exported = generateVCard([parseOne("BDAY:19850412")]); - expect(exported).toContain("BDAY:1985-04-12"); + expect(exported).toContain("BDAY:19850412"); const reparsed = parseVCard(exported)[0]; expect(Object.values(reparsed.anniversaries || {})[0]?.date).toEqual({ year: 1985, month: 4, day: 12, @@ -920,7 +1032,7 @@ describe("anniversary dates (issue #224)", () => { it("round-trips a day/month-only birthday", () => { const exported = generateVCard([parseOne("BDAY:--0412")]); - expect(exported).toContain("BDAY:--04-12"); + expect(exported).toContain("BDAY:--0412"); expect(Object.values(parseVCard(exported)[0].anniversaries || {})[0]?.date) .toEqual({ month: 4, day: 12 }); }); diff --git a/lib/vcard.ts b/lib/vcard.ts index 52c60ff81..869038040 100644 --- a/lib/vcard.ts +++ b/lib/vcard.ts @@ -2,19 +2,21 @@ import type { ContactCard, NameComponent, ContactOnlineService, AnniversaryDate, import { generateUUID } from "@/lib/utils"; import { parseBase64DataUri } from "@/lib/data-uri"; -// Convert RFC 9553 AnniversaryDate (PartialDate|Timestamp|string) to vCard date string +// Convert RFC 9553 AnniversaryDate (PartialDate|Timestamp|string) to a vCard +// 4.0 date, which uses the ISO 8601 basic format (RFC 6350 §4.3.1): 19850412, +// --0412, 1985-04, 1985. 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]; + return (date as { utc: string }).utc.split('T')[0].replace(/-/g, ''); } 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')}`; + 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')}`; + 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')}`; @@ -323,20 +325,60 @@ function anniversaryKindFromLabel(label: string | undefined): "birth" | "wedding return "other"; } -const PHONE_FEATURE_TYPES = new Set(["CELL", "FAX", "VOICE", "PAGER", "VIDEO", "TEXT", "TEXTPHONE"]); +// vCard TEL types (RFC 6350 §6.4.1, RFC 7852) and the RFC 9553 phone features +// they stand for: vCard calls a mobile phone "cell", JSContact "mobile". +// MOBILE is no vCard type, but Bulwark's own exports used to write it. +const TEL_TYPE_TO_PHONE_FEATURE: Record = { + CELL: "mobile", + MOBILE: "mobile", + VOICE: "voice", + FAX: "fax", + PAGER: "pager", + VIDEO: "video", + TEXT: "text", + TEXTPHONE: "textphone", + "MAIN-NUMBER": "main-number", +}; + +// The contact form stored mobile phones as "cell" before it used "mobile". +const PHONE_FEATURE_TO_TEL_TYPE: Record = { + mobile: "CELL", + cell: "CELL", + voice: "VOICE", + fax: "FAX", + pager: "PAGER", + video: "VIDEO", + text: "TEXT", + textphone: "TEXTPHONE", + "main-number": "MAIN-NUMBER", +}; function typeToPhoneFeatures(typeStr: string | undefined): Record | undefined { if (!typeStr) return undefined; const types = typeStr.toUpperCase().split(","); const features: Record = {}; for (const t of types) { - if (PHONE_FEATURE_TYPES.has(t)) { - features[t.toLowerCase()] = true; - } + const feature = TEL_TYPE_TO_PHONE_FEATURE[t]; + if (feature) features[feature] = true; } return Object.keys(features).length > 0 ? features : undefined; } +function phoneFeaturesToTypes(features: Record | undefined): string[] { + const types = new Set(); + for (const [feature, on] of Object.entries(features || {})) { + if (!on) continue; + const type = PHONE_FEATURE_TO_TEL_TYPE[feature.toLowerCase()]; + if (type) { + types.add(type); + } else if (/^x-[a-z0-9-]+$/i.test(feature)) { + // RFC 6350 allows x-name TYPE values; anything else is not a TEL type. + types.add(feature.toUpperCase()); + } + } + return [...types]; +} + function typeToContext(typeStr: string | undefined): Record | undefined { if (!typeStr) return undefined; const types = typeStr.toUpperCase().split(","); @@ -1134,19 +1176,70 @@ function buildContact(raw: Record): ContactCard | null { return card; } +// PRODID names the product that wrote the file, not the one that first made +// the card, so the card's own prodId is not written back. +const VCARD_PRODID = "-//JMAP-Webmail//EN"; + +function hasUriScheme(value: string): boolean { + return /^[a-z][a-z0-9+.-]*:/i.test(value); +} + +// JSContact timestamps are RFC 3339 (2025-07-28T12:17:00Z); vCard 4.0 wants +// the ISO 8601 basic format (RFC 6350 §4.3.5): 20250728T121700Z. +function toVcardTimestamp(value: string): string { + const m = value.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-]\d{2}:?\d{2})?$/i); + if (!m) return value; + const zone = (m[7] || "").replace(":", "").toUpperCase(); + return `${m[1]}${m[2]}${m[3]}T${m[4]}${m[5]}${m[6]}${zone}`; +} + +// vCard 4.0 GEO is a geo: URI; a vCard 3.0 import may have left "lat;lon". +function toGeoUri(coordinates: string): string { + if (/^geo:/i.test(coordinates)) return coordinates; + const m = coordinates.match(/^\s*(-?[\d.]+)\s*[;,]\s*(-?[\d.]+)\s*$/); + return m ? `geo:${m[1]},${m[2]}` : coordinates; +} + +function utf8Length(char: string): number { + const code = char.codePointAt(0) ?? 0; + if (code < 0x80) return 1; + if (code < 0x800) return 2; + if (code < 0x10000) return 3; + return 4; +} + +// RFC 6350 §3.2: lines SHOULD be folded at 75 octets, and a fold must not +// split a multi-octet UTF-8 character. A continuation starts with one space. +function foldLine(line: string): string { + const out: string[] = []; + let current = ""; + let octets = 0; + for (const char of line) { + const size = utf8Length(char); + const limit = out.length === 0 ? 75 : 74; + if (octets + size > limit) { + out.push(current); + current = ""; + octets = 0; + } + current += char; + octets += size; + } + out.push(current); + return out.join("\r\n "); +} + +/** Contacts as vCard 4.0 (RFC 6350), each line folded and ended by CRLF. */ export function generateVCard(contacts: ContactCard[]): string { - return contacts.map(generateSingleVCard).join("\r\n"); + return contacts.map(generateSingleVCard).join(""); } function generateSingleVCard(contact: ContactCard): string { - const lines: string[] = ["BEGIN:VCARD", "VERSION:3.0"]; + const lines: string[] = ["BEGIN:VCARD", "VERSION:4.0", `PRODID:${VCARD_PRODID}`]; if (contact.uid) { - lines.push(`UID:${contact.uid}`); - } - - if (contact.prodId) { - lines.push(`PRODID:${contact.prodId}`); + // UID is a URI by default (RFC 6350 §6.7.6); other values must say so. + lines.push(hasUriScheme(contact.uid) ? `UID:${contact.uid}` : `UID;VALUE=text:${contact.uid}`); } if (contact.kind) { @@ -1154,7 +1247,7 @@ function generateSingleVCard(contact: ContactCard): string { } if (contact.updated) { - lines.push(`REV:${contact.updated}`); + lines.push(`REV:${toVcardTimestamp(contact.updated)}`); } const components = contact.name?.components || []; @@ -1173,8 +1266,10 @@ function generateSingleVCard(contact: ContactCard): string { || Object.values(contact.organizations || {})[0]?.name || ""; if (fn) { - lines.push(`FN:${encodeValue(fn)}`); + // vCard does not order properties, but some importers drop the first + // and last name unless N comes before FN (#1407). lines.push(`N:${encodeValue(surname)};${encodeValue(given)};${encodeValue(additional)};${encodeValue(prefix)};${encodeValue(suffix)}`); + lines.push(`FN:${encodeValue(fn)}`); } // vCard has no second-surname slot; X-MAIDENNAME is the de-facto extension @@ -1206,11 +1301,7 @@ function generateSingleVCard(contact: ContactCard): string { const typeParts: string[] = []; const ctxType = contextToType(phone.contexts); if (ctxType) typeParts.push(ctxType); - if (phone.features) { - for (const feat of Object.keys(phone.features)) { - if (phone.features[feat]) typeParts.push(feat.toUpperCase()); - } - } + typeParts.push(...phoneFeaturesToTypes(phone.features)); const params: string[] = []; if (typeParts.length > 0) params.push(`TYPE=${typeParts.join(",")}`); if (phone.pref) params.push(`PREF=${phone.pref}`); @@ -1341,8 +1432,13 @@ function generateSingleVCard(contact: ContactCard): string { if (contact.relatedTo) { for (const [uri, rel] of Object.entries(contact.relatedTo)) { const relType = rel.relation ? Object.keys(rel.relation).find(k => rel.relation![k]) : undefined; - const typeParam = relType ? `;TYPE=${relType}` : ""; - lines.push(`RELATED${typeParam}:${uri}`); + const params: string[] = []; + // RELATED is a URI by default (RFC 6350 §6.6.6); a bare name, as + // imported from Apple or Android, is text. + if (!hasUriScheme(uri)) params.push("VALUE=text"); + if (relType) params.push(`TYPE=${relType}`); + const paramStr = params.length > 0 ? `;${params.join(";")}` : ""; + lines.push(`RELATED${paramStr}:${hasUriScheme(uri) ? uri : encodeValue(uri)}`); } } @@ -1421,12 +1517,13 @@ function generateSingleVCard(contact: ContactCard): string { if (media?.uri) { const prop = media.kind === "logo" ? "LOGO" : media.kind === "sound" ? "SOUND" : "PHOTO"; if (media.uri.startsWith("data:")) { + // vCard 4.0 carries inline media as a data: URI (no ENCODING=b). // Stalwart writes photos without a media type as `data:;base64,` // (0.16.x) and `data:base64,` (0.16.0); the card's mediaType fills in. const parsed = parseBase64DataUri(media.uri); if (parsed?.data) { - const type = parsed.type || media.mediaType; - lines.push(`${prop};ENCODING=b${type ? `;TYPE=${type}` : ""}:${parsed.data}`); + const type = parsed.type || media.mediaType || ""; + lines.push(`${prop}:data:${type};base64,${parsed.data}`); } } else { const mt = media.mediaType ? `;MEDIATYPE=${media.mediaType}` : ""; @@ -1440,7 +1537,7 @@ function generateSingleVCard(contact: ContactCard): string { if (contact.addresses) { for (const addr of Object.values(contact.addresses)) { if (addr.coordinates) { - lines.push(`GEO:${addr.coordinates}`); + lines.push(`GEO:${toGeoUri(addr.coordinates)}`); } if (addr.timeZone) { lines.push(`TZ:${addr.timeZone}`); @@ -1476,12 +1573,12 @@ function generateSingleVCard(contact: ContactCard): string { } if (contact.created) { - // RFC 9554 §3.1 - CREATED is a timestamp; emit as-is for round-trip. - lines.push(`CREATED:${contact.created}`); + // RFC 9554 §3.1 - CREATED is a timestamp. + lines.push(`CREATED:${toVcardTimestamp(contact.created)}`); } lines.push("END:VCARD"); - return lines.join("\r\n"); + return lines.map(line => `${foldLine(line)}\r\n`).join(""); } export function detectDuplicates(