fix: export contacts as valid vCard 4.0 #1407

The export declared VERSION:3.0 but wrote vCard 4.0 syntax, and wrote the JSContact phone feature \"mobile\" as TYPE=MOBILE, which is no vCard type, so importers dropped such numbers.

- Declare VERSION:4.0 and write 4.0 throughout: basic-format dates and timestamps, data: URIs for inline media, VALUE=text for a UID or RELATED that is no URI, geo: URIs, lines folded at 75 octets, CRLF after every line, and the webmail's own PRODID.
- Map "mobile" to CELL and back; MAIN-NUMBER and x- types are kept.
- Write N before FN: some importers ignore the name otherwise.
- The contact form stores mobile phones as "mobile" (RFC 9553) and still reads the "cell" it used to write.
This commit is contained in:
Julien Béti
2026-10-28 17:27:07 +01:00
parent 4ed555a828
commit a3fdd72763
4 changed files with 259 additions and 49 deletions
@@ -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 () => {
+4 -3
View File
@@ -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
>
<option value="">{t("phone_type")}</option>
<option value="voice">{t("phone_voice")}</option>
<option value="cell">{t("phone_cell")}</option>
<option value="mobile">{t("phone_cell")}</option>
<option value="fax">{t("phone_fax")}</option>
<option value="pager">{t("phone_pager")}</option>
<option value="video">{t("phone_video")}</option>
+122 -10
View File
@@ -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>): 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 });
});
+127 -30
View File
@@ -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<string, string> = {
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<string, string> = {
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<string, boolean> | undefined {
if (!typeStr) return undefined;
const types = typeStr.toUpperCase().split(",");
const features: Record<string, boolean> = {};
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<string, boolean> | undefined): string[] {
const types = new Set<string>();
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<string, boolean> | undefined {
if (!typeStr) return undefined;
const types = typeStr.toUpperCase().split(",");
@@ -1134,19 +1176,70 @@ function buildContact(raw: Record<string, string[]>): 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(