From 07fd03e284ed52645e66895d73df8ffa1cb382bb Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:28:10 +0100 Subject: [PATCH] fix: export contacts as vCard 4.0 that other apps read in full #1407 --- src/lib/__tests__/contact-form.test.ts | 8 ++ src/lib/__tests__/contact-media.test.ts | 5 +- src/lib/__tests__/vcard.test.ts | 69 ++++++++-- src/lib/contact-form.ts | 4 +- src/lib/vcard.ts | 165 ++++++++++++++++++------ src/screens/ContactDetailScreen.tsx | 1 + src/screens/ContactFormScreen.tsx | 2 +- 7 files changed, 206 insertions(+), 48 deletions(-) diff --git a/src/lib/__tests__/contact-form.test.ts b/src/lib/__tests__/contact-form.test.ts index f79834d9..a69a3978 100644 --- a/src/lib/__tests__/contact-form.test.ts +++ b/src/lib/__tests__/contact-form.test.ts @@ -33,6 +33,14 @@ const CARDS: Array<[string, ContactCard]> = [ ]; describe('contact form save', () => { + // RFC 9553 has "mobile"; "cell" is what older versions of the form wrote. + it('reads a "cell" phone as mobile and saves it as the RFC 9553 feature', () => { + const card: ContactCard = { ...base, phones: { p1: { number: '+1000', features: { cell: true } } } }; + const form = contactToForm(card, []); + expect(form.phones[0].feature).toBe('mobile'); + expect(formToPatch(form, card, false, [card]).phones).toEqual({ p1: { number: '+1000', features: { mobile: true } } }); + }); + it.each(CARDS)('a phone-only edit leaves the photo alone on Stalwart %s', (_version, card) => { const form = contactToForm(card, []); form.phones = [{ number: '+2000', context: '', feature: '' }]; diff --git a/src/lib/__tests__/contact-media.test.ts b/src/lib/__tests__/contact-media.test.ts index 54eda320..582e29d5 100644 --- a/src/lib/__tests__/contact-media.test.ts +++ b/src/lib/__tests__/contact-media.test.ts @@ -372,8 +372,9 @@ describe('inlineContactMediaForExport', () => { expect(out[1].media).toEqual({ photo: { kind: 'photo', mediaType: 'image/png', uri: 'data:image/png;base64,iVBORw0KGgo=' } }); const vcf = contactsToVCard(out); - expect(vcf).toContain(`PHOTO;ENCODING=b;TYPE=image/jpeg:${Buffer.from(JPEG_BYTES).toString('base64')}`); - expect(vcf).toContain('PHOTO;ENCODING=b;TYPE=image/png:iVBORw0KGgo='); + const unfolded = vcf.replace(/\r\n /g, ''); + expect(unfolded).toContain(`PHOTO:data:image/jpeg;base64,${Buffer.from(JPEG_BYTES).toString('base64')}`); + expect(unfolded).toContain('PHOTO:data:image/png;base64,iVBORw0KGgo='); expect(mockFetchBlob).not.toHaveBeenCalled(); }); diff --git a/src/lib/__tests__/vcard.test.ts b/src/lib/__tests__/vcard.test.ts index f812dde6..ae2253d8 100644 --- a/src/lib/__tests__/vcard.test.ts +++ b/src/lib/__tests__/vcard.test.ts @@ -45,7 +45,8 @@ describe('parseVCard', () => { expect(emails[0].contexts).toEqual({ work: true }); const phones = Object.values(card.phones ?? {}); expect(phones[0].number).toBe('+1-555-0100'); - expect(phones[0].features).toEqual({ cell: true }); + // CELL is "mobile" in RFC 9553; "cell" is no JSContact feature. + expect(phones[0].features).toEqual({ mobile: true }); const given = card.name?.components?.find((c) => c.kind === 'given')?.value; const surname = card.name?.components?.find((c) => c.kind === 'surname')?.value; expect(given).toBe('Jane'); @@ -115,6 +116,58 @@ describe('generateVCard', () => { expect(out.match(/BEGIN:VCARD/g)).toHaveLength(1); expect(out.match(/END:VCARD/g)).toHaveLength(1); }); + + // #1407: what Apple Contacts, Google and Outlook expect of a vCard 4.0. + describe('vCard 4.0 (#1407)', () => { + const card: ContactCard = { + id: 'c1', + addressBookIds: {}, + uid: 'abc-123', + updated: '2026-10-29T08:15:00Z', + name: { components: [{ kind: 'given', value: 'Jane' }, { kind: 'surname', value: 'Doe' }] }, + phones: { + p1: { number: '+1 555 0100', features: { mobile: true } }, + p2: { number: '+1 555 0101', features: { cell: true } }, + }, + notes: { n1: { note: 'x'.repeat(200) } }, + }; + const out = generateVCard([card]); + const lines = out.split('\r\n'); + + it('says it is version 4.0 and ends every line with CRLF', () => { + expect(lines.slice(0, 2)).toEqual(['BEGIN:VCARD', 'VERSION:4.0']); + expect(out.endsWith('END:VCARD\r\n')).toBe(true); + expect(out.replace(/\r\n/g, '')).not.toContain('\n'); + }); + + it('writes N before FN', () => { + expect(lines.indexOf('N:Doe;Jane;;;')).toBeGreaterThan(-1); + expect(lines.indexOf('N:Doe;Jane;;;')).toBeLessThan(lines.indexOf('FN:Jane Doe')); + }); + + it('writes mobiles as CELL, never MOBILE', () => { + expect(lines).toContain('TEL;TYPE=CELL:+1 555 0100'); + expect(lines).toContain('TEL;TYPE=CELL:+1 555 0101'); + expect(out).not.toContain('MOBILE'); + }); + + it('marks a UID without a scheme as text and writes REV in the basic format', () => { + expect(lines).toContain('UID;VALUE=text:abc-123'); + expect(lines).toContain('REV:20261029T081500Z'); + }); + + it('folds lines longer than 75 octets', () => { + expect(lines.every((line) => line.length <= 75)).toBe(true); + expect(lines.some((line) => line.startsWith(' '))).toBe(true); + }); + + it('reads its own export back', () => { + const [back] = parseVCard(out); + expect(back.uid).toBe('abc-123'); + expect(Object.values(back.phones ?? {}).map((p) => p.features)).toEqual([{ mobile: true }, { mobile: true }]); + expect(Object.values(back.notes ?? {})[0]?.note).toBe('x'.repeat(200)); + }); + }); }); describe('detectDuplicates', () => { @@ -185,7 +238,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, @@ -194,7 +247,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 }); }); @@ -207,7 +260,7 @@ describe('anniversary dates (issue #224)', () => { anniversaries: { a0: { kind: 'other', date: { year: 2015, month: 9, day: 20 } } }, }; const exported = generateVCard([card]); - expect(exported).toContain('X-ABDATE:2015-09-20'); + expect(exported).toContain('X-ABDATE:20150920'); const reparsed = parseVCard(exported)[0]; expect(Object.values(reparsed.anniversaries || {})).toEqual([ { kind: 'other', date: { year: 2015, month: 9, day: 20 } }, @@ -399,10 +452,10 @@ describe('photo export', () => { d: { kind: 'photo', uri: 'data:;base64,DDDD' }, })]); const lines = vcf.split('\r\n'); - expect(lines).toContain('PHOTO;ENCODING=b;TYPE=image/png:AAAA'); - expect(lines).toContain('LOGO;ENCODING=b;TYPE=image/jpeg:BBBB'); - expect(lines).toContain('PHOTO;ENCODING=b;TYPE=image/jpeg:CCCC'); - expect(lines).toContain('PHOTO;ENCODING=b:DDDD'); + expect(lines).toContain('PHOTO:data:image/png;base64,AAAA'); + expect(lines).toContain('LOGO:data:image/jpeg;base64,BBBB'); + expect(lines).toContain('PHOTO:data:image/jpeg;base64,CCCC'); + expect(lines).toContain('PHOTO:data:;base64,DDDD'); }); it('skips a Stalwart 1.0 blob photo that was not downloaded into the card', () => { diff --git a/src/lib/contact-form.ts b/src/lib/contact-form.ts index 2c72f371..74bf1577 100644 --- a/src/lib/contact-form.ts +++ b/src/lib/contact-form.ts @@ -138,8 +138,10 @@ export function contactToForm(contact: ContactCard, memberIds: string[]): FormSt const phones = contact.phones ? Object.values(contact.phones).map((p) => ({ number: p.number, context: p.contexts?.work ? 'work' : p.contexts?.private ? 'private' : '', + // RFC 9553 calls it "mobile"; cards saved by older versions of the app + // (and of the webmail) say "cell", which a save turns into "mobile". feature: - p.features?.cell ? 'cell' + p.features?.mobile || p.features?.cell ? 'mobile' : p.features?.fax ? 'fax' : p.features?.pager ? 'pager' : p.features?.video ? 'video' diff --git a/src/lib/vcard.ts b/src/lib/vcard.ts index 6b95b847..0f58e4e4 100644 --- a/src/lib/vcard.ts +++ b/src/lib/vcard.ts @@ -9,19 +9,21 @@ import { generateUUID } from './uuid'; import { deriveFullName } from './contact-utils'; import { parseBase64DataUri } from './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')}`; @@ -367,20 +369,58 @@ 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 and the RFC 9553 phone features they stand for. CELL is +// "mobile" in JSContact; a "cell" feature is no RFC 9553 one. 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', +}; + +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)) { + types.add(feature.toUpperCase()); + } + } + return [...types]; +} + function typeToContext(typeStr: string | undefined): Record | undefined { if (!typeStr) return undefined; const types = typeStr.toUpperCase().split(','); @@ -1185,19 +1225,68 @@ function buildContact(raw: Record): ContactCard | null { return card; } +const VCARD_PRODID = '-//JMAP-Webmail//EN'; + +function hasUriScheme(value: string): boolean { + return /^[a-z][a-z0-9+.-]*:/i.test(value); +} + +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}`; +} + +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 longer than 75 octets are folded, the continuation +// starting with a 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 + * (webmail #1407): dates in the basic format, CELL for mobiles, N before FN. + */ 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 in vCard 4.0; anything else is marked as text. + lines.push(hasUriScheme(contact.uid) ? `UID:${contact.uid}` : `UID;VALUE=text:${contact.uid}`); } if (contact.kind) { @@ -1205,7 +1294,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 || []; @@ -1224,8 +1313,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 @@ -1259,11 +1350,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}`); @@ -1326,16 +1413,16 @@ function generateSingleVCard(contact: ContactCard): string { const dateStr = anniversaryDateToVcardString(ann.date); if (ann.kind === 'birth') { if (dateStr) lines.push(`BDAY:${dateStr}`); - if (ann.place?.fullAddress) { - lines.push(`BIRTHPLACE:${encodeValue(ann.place.fullAddress)}`); - } + // `full` as the server stores it (contactToWire), fullAddress as an + // import holds it before the save. + const place = ann.place?.full || ann.place?.fullAddress; + if (place) lines.push(`BIRTHPLACE:${encodeValue(place)}`); } else if (ann.kind === 'wedding') { if (dateStr) lines.push(`ANNIVERSARY:${dateStr}`); } else if (ann.kind === 'death') { if (dateStr) lines.push(`DEATHDATE:${dateStr}`); - if (ann.place?.fullAddress) { - lines.push(`DEATHPLACE:${encodeValue(ann.place.fullAddress)}`); - } + const place = ann.place?.full || ann.place?.fullAddress; + if (place) lines.push(`DEATHPLACE:${encodeValue(place)}`); } else if (dateStr) { // No standard property for an unclassified date; X-ABDATE is the one // the common exporters (and our own importer) understand. @@ -1389,8 +1476,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)}`); } } @@ -1475,8 +1567,9 @@ function generateSingleVCard(contact: ContactCard): string { // (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}`); + // vCard 4.0 carries inline media as a data: URI (no ENCODING=b). + const type = parsed.type || media.mediaType || ''; + lines.push(`${prop}:data:${type};base64,${parsed.data}`); } } else { const mt = media.mediaType ? `;MEDIATYPE=${media.mediaType}` : ''; @@ -1490,7 +1583,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}`); @@ -1526,12 +1619,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( diff --git a/src/screens/ContactDetailScreen.tsx b/src/screens/ContactDetailScreen.tsx index 1cdd38f9..5394249f 100644 --- a/src/screens/ContactDetailScreen.tsx +++ b/src/screens/ContactDetailScreen.tsx @@ -62,6 +62,7 @@ function contextLabel(key: string, t: TranslateFn): string { function phoneFeatureLabel(key: string, t: TranslateFn): string { switch (key) { case 'voice': return t('contacts.form.phone_voice', 'Voice'); + case 'mobile': case 'cell': return t('contacts.form.phone_cell', 'Mobile'); case 'fax': return t('contacts.form.phone_fax', 'Fax'); case 'pager': return t('contacts.form.phone_pager', 'Pager'); diff --git a/src/screens/ContactFormScreen.tsx b/src/screens/ContactFormScreen.tsx index d5ce8511..c669c162 100644 --- a/src/screens/ContactFormScreen.tsx +++ b/src/screens/ContactFormScreen.tsx @@ -45,7 +45,7 @@ function formOptions(t: TranslateFn) { const phoneFeatures: Option[] = [ { value: '', label: t('contacts.form.phone', 'Phone') }, { value: 'voice', label: t('contacts.form.phone_voice', 'Voice') }, - { value: 'cell', label: t('contacts.form.phone_cell', 'Mobile') }, + { value: 'mobile', label: t('contacts.form.phone_cell', 'Mobile') }, { value: 'fax', label: t('contacts.form.phone_fax', 'Fax') }, { value: 'pager', label: t('contacts.form.phone_pager', 'Pager') }, { value: 'video', label: t('contacts.form.phone_video', 'Video') },