Contacts as vCard 4: export and import .vcf, through one mapping the DAV plugin can reuse #660

Closed
opened 2026-10-03 01:58:40 +00:00 by tiagoagueda · 1 comment
Owner

From the maintainer's list: “Preferable export/import format of contact details is vCard 4. A step closer to official DAV plugin.” The core cannot read or write a vCard. The DAV plugin can, for eight properties, in a copy of its own. This issue puts one mapping in the core, used by a file export and import here and offered on the plugin surface so the plugin deletes its copy and the two cannot disagree.

What exists today

  • No vCard in the core. A search of src/ for vcard, .vcf and text/vcard finds nothing.
  • What a contact is. Contact (jobs/models.py:604-640): company (optional), department, name (one field, 200), role, one email, notes; telephone numbers (core/models.py:267, kinds mobile, work, home, switchboard, fax, other; the number as typed, a normalised international form, one primary), postal addresses (:486; kinds home, work, postal, other; street, postcode, municipality, region, a two-letter country, checked against the country's rules since #306) and web links (:638: social, repository, website, and a service key into the registry #305 added). A company has a name, website, careers page, location and notes, and no phone, address or email (#329, open). The person's own details are assembled for a CV by contact_details (documents/rendering.py:151) under the CV's print choices (#308).
  • Telephone numbers. Since #304 core/phones.py reads a number against its country's numbering plan (check, :881, over phonenumberslite), keeps the international form (normalise, :1044), refuses what cannot exist and stores what it cannot read as typed, with a warning (:13-45).
  • The DAV plugin's copy. postulo-dav/src/postulo_dav/text.py:1-8 writes vCard 4.0 by hand: card_to_text (:155) and text_to_card (:177) handle FN, N, ORG, TITLE, EMAIL, one TEL, URL, NOTE, UID, REV. It guesses N by splitting the name at its last space, drops the department, always writes VERSION:4.0, and keeps its own fold, unfold and escape (:22-60). Its open issues design the rest: KIND:org company cards, ORG:Company;Unit for a department, ADR as postal addresses, patching a card and never rebuilding it, and a vCard 3 fallback where the book does not list 4.0 (postulo/postulo-dav#8, #9, #10); the pairing key is collection plus UID, never rewritten (#7); a lone carriage return in a name adds properties to the card (#27); an extended REV is not read (#25).
  • The plugin surface offers Contact, get_or_create_company and the primary number and link only; #328 (open) adds companies, departments, contacts and their rows; #329 adds a company's phones, addresses and email; #327 the pairing page.
  • The pattern for a file import is the candidate file's review (resume/candidate.py) and the spreadsheet import's preview; #659 factors the shared part.

Checked against main at f09b6010b.

Proposal

  1. A mapping module in the core, with no I/O and no model in its signatures: plain values in, text out, and text in, plain values out. The core hands it rows; the plugin hands it what it read through #328.
  2. The mapping, Postulo to vCard 4.0 (RFC 6350):
Postulo vCard Note
name FN (§6.2.1) N (§6.2.2) is optional in 4.0 and is not invented from FN
company, department ORG:Company;Unit (§6.6.4) as postulo-dav#8 and #10
role TITLE (§6.6.1)
email EMAIL;TYPE=work (§6.4.2)
telephone number TEL;VALUE=uri;TYPE=…:tel:+… (§6.4.1) kind to type: mobile cell, work work, home home, switchboard work,voice, fax fax; the primary PREF=1; the international form, else as typed
postal address ADR;TYPE=…;CC=PT (§6.3.1) components in RFC order; CC from RFC 8605 §3.1 beside the country name, in English as phones.country_name has it
web link URL (§6.7.8) all three kinds, the service not written; on import it is recognised from the host through the #305 registry, and SOCIALPROFILE (RFC 9554) is read
notes NOTE (§6.7.2) only when the person ticks Include my notes: they are private
a company KIND:org card (§6.1.4): FN, ORG, URL for the website, NOTE location waits for #329
the person FN, N from the account's first and last name, EMAIL, primary TEL and ADR, URLs, TITLE from the headline, PRONOUNS (RFC 9554) only what the default CV prints (#308)
every card VERSION:4.0, PRODID, UID, REV

As #679, #681 and #682 land, their values join the mapping: BDAY and BIRTHPLACE, and GENDER, on the person's own card, and IMPP on every contact's and the person's card, each under the print rule of its own issue. Nothing is written for a field before its issue exists.

  1. Export as text/vcard; charset=utf-8, .vcf: one contact, a company's contacts, all contacts, a company card, the person's own card, from the pages where each lives.
  2. Import a .vcf (3.0 and 4.0 read; 2.1 refused with a sentence) through the review page: each card shown with what it would become and what is not kept (PHOTO, LOGO, KEY, BDAY, X- properties), none ticked by default, since an address book is not a job search and nothing from it should arrive unchosen. Chosen cards become a contact under the company ORG names, matched by name and never merged silently, or a company for KIND:org; an existing contact is never overwritten.
  3. Every value through the form a person would type it in: numbers through the phone field's rules, an address through the postal rules of its country, an email and a web address through their validators; a value refused is reported and the rest of the card still imports. Lengths are the model's, control characters are removed, a lone carriage return cannot end a line, and a PHOTO or SOURCE address is never fetched.
  4. On the plugin surface, the mapping under new names (proposals: a card from plain values, cards from text), with their line in Writing a plugin and a repin of postulo-dav, which then deletes the vCard half of text.py.

Decisions

  • N. Options: write it split from the name (as the plugin does); omit it; ask the person. Recommended: omit. A name field has no given and family part, and Portuguese and many other names do not split at the last space. The person's own card, which has first and last names, writes it.
  • UID. A contact has no stored UID, and a pairing needs one that is never rewritten (postulo-dav#7). Recommended: one function derives a stable urn:uuid: from the instance and the contact for file export; the plugin keeps the UID already stored on its link; an imported UID is used only to spot a repeat inside the file.
  • vCard 3.0 on output. Recommended: the core writes 4.0 only. The plugin's step down to 3.0 for a book that does not list 4.0 (RFC 6352 §6.2.2) stays a small transform in the plugin.
  • Notes. Recommended: left out unless ticked at export, for the reason postulo-dav#8 gives: they are Postulo's private notes and, in the plugin, hold conflict records.
  • The person's own card. Recommended: export only, never read back into the profile (the sign-in address and verified numbers are not a card's to change, #328).
  • Limits. Recommended: 2 MiB, and the first 500 cards shown.

Prior art

Looked for under the rule of #653, 2026-10-02:

  • vobject 0.9.9 (2024-12-16), Apache-2.0, repository py-vobject/vobject (pushed 2026-08-18); needs pytz, six and python-dateutil, none of them in uv.lock; no OSV advisory. Its documentation says it supports vCard 2.1 and 3.0 and notes 4.0 “has not been universally adopted”; its README says vCard 3.0 files are supported; three requests for vCard 4.0 are open (#29, #113, #124) and its ROADMAP lists “Robust vCard 4.0 support” unticked; there is no 1.0.0 on PyPI. Reject: it does not do the format asked for.
  • vcard 1.0.0 (2024-10-06), AGPLv3, a validator for vCard 3.0 only. Reject.
  • vcfpy is the genomics VCF; not a contacts library.
  • Hand-written, once. The project writes vCard and iCalendar by hand today (text.py, applications/ical.py). The weakness is the copies: ical.py fixed content-line injection in #218 and the plugin's own escape never received it (#27). Recommended: hand-written, in one module the plugin imports; the library check is recorded here, not skipped. Revisit when a maintained library ships vCard 4.0 (watch vobject's #124).

Builds on / Needed by

Builds on postulo/postulo-dav#7, #8, #9, #10, #25, #27; #328, #329, #327, #297, #304, #305, #308 and #653, #659. Needed by postulo-dav#8, #9 and #10.

Done when

  • tests/test_vcard.py (new): each row of the table; N never invented; ORG:Acme;Legal; KIND:org; folding at 75 octets never inside a character; 3.0 and 4.0 read; a lone \r, \n or other control character in a name, note, email or URL adds no property (the test postulo-dav#27 asks for, at the source); a round trip loses nothing the table says is kept.
  • tests/security/test_vcard.py (new): a hostile file is bounded in size, cards and line length; a PHOTO;VALUE=uri makes no request (postulo.plugins.http is patched to fail); nothing names another account's record.
  • tests/e2e/test_vcard.py (new): export a contact, import it into a second account through the review page with nothing ticked, then ticked. New names on postulo.plugins.api get their line in Writing a plugin (tests/test_wiki_surface.py) and a test in tests/test_plugin_surface.py; new pages join tests/test_page_coverage.py and tests/security/test_isolation_sweep.py.
  • No record type: the archive format (29 today), the candidate format (4), API and search are untouched. Strings in en-GB, fr-FR and pt-PT (drafts) and Weblate (#349); the wiki's contacts page.

From the list of improvements of 2026-10-02.

From the maintainer's list: “Preferable export/import format of contact details is vCard 4. A step closer to official DAV plugin.” The core cannot read or write a vCard. The DAV plugin can, for eight properties, in a copy of its own. This issue puts one mapping in the core, used by a file export and import here and offered on the plugin surface so the plugin deletes its copy and the two cannot disagree. ## What exists today - **No vCard in the core.** A search of `src/` for `vcard`, `.vcf` and `text/vcard` finds nothing. - **What a contact is.** `Contact` (`jobs/models.py:604-640`): company (optional), department, `name` (one field, 200), `role`, one `email`, `notes`; telephone numbers (`core/models.py:267`, kinds mobile, work, home, switchboard, fax, other; the number as typed, a `normalised` international form, one primary), postal addresses (`:486`; kinds home, work, postal, other; street, postcode, municipality, region, a two-letter country, checked against the country's rules since #306) and web links (`:638`: social, repository, website, and a `service` key into the registry #305 added). A company has a name, website, careers page, location and notes, and no phone, address or email (#329, open). The person's own details are assembled for a CV by `contact_details` (`documents/rendering.py:151`) under the CV's print choices (#308). - **Telephone numbers.** Since #304 `core/phones.py` reads a number against its country's numbering plan (`check`, `:881`, over `phonenumberslite`), keeps the international form (`normalise`, `:1044`), refuses what cannot exist and stores what it cannot read as typed, with a warning (`:13-45`). - **The DAV plugin's copy.** `postulo-dav/src/postulo_dav/text.py:1-8` writes vCard 4.0 by hand: `card_to_text` (`:155`) and `text_to_card` (`:177`) handle `FN`, `N`, `ORG`, `TITLE`, `EMAIL`, one `TEL`, `URL`, `NOTE`, `UID`, `REV`. It guesses `N` by splitting the name at its last space, drops the department, always writes `VERSION:4.0`, and keeps its own `fold`, `unfold` and `escape` (`:22-60`). Its open issues design the rest: `KIND:org` company cards, `ORG:Company;Unit` for a department, `ADR` as postal addresses, patching a card and never rebuilding it, and a vCard 3 fallback where the book does not list 4.0 (postulo/postulo-dav#8, #9, #10); the pairing key is collection plus `UID`, never rewritten (#7); a lone carriage return in a name adds properties to the card (#27); an extended `REV` is not read (#25). - **The plugin surface** offers `Contact`, `get_or_create_company` and the primary number and link only; #328 (open) adds companies, departments, contacts and their rows; #329 adds a company's phones, addresses and email; #327 the pairing page. - **The pattern for a file import** is the candidate file's review (`resume/candidate.py`) and the spreadsheet import's preview; #659 factors the shared part. Checked against `main` at `f09b6010b`. ## Proposal 1. **A mapping module in the core, with no I/O and no model in its signatures:** plain values in, text out, and text in, plain values out. The core hands it rows; the plugin hands it what it read through #328. 2. **The mapping, Postulo to vCard 4.0 (RFC 6350):** | Postulo | vCard | Note | | --- | --- | --- | | name | `FN` (§6.2.1) | `N` (§6.2.2) is optional in 4.0 and is **not** invented from `FN` | | company, department | `ORG:Company;Unit` (§6.6.4) | as postulo-dav#8 and #10 | | role | `TITLE` (§6.6.1) | | | email | `EMAIL;TYPE=work` (§6.4.2) | | | telephone number | `TEL;VALUE=uri;TYPE=…:tel:+…` (§6.4.1) | kind to type: mobile `cell`, work `work`, home `home`, switchboard `work,voice`, fax `fax`; the primary `PREF=1`; the international form, else as typed | | postal address | `ADR;TYPE=…;CC=PT` (§6.3.1) | components in RFC order; `CC` from RFC 8605 §3.1 beside the country name, in English as `phones.country_name` has it | | web link | `URL` (§6.7.8) | all three kinds, the service not written; on import it is recognised from the host through the #305 registry, and `SOCIALPROFILE` (RFC 9554) is read | | notes | `NOTE` (§6.7.2) | only when the person ticks *Include my notes*: they are private | | a company | `KIND:org` card (§6.1.4): `FN`, `ORG`, `URL` for the website, `NOTE` | location waits for #329 | | the person | `FN`, `N` from the account's first and last name, `EMAIL`, primary `TEL` and `ADR`, `URL`s, `TITLE` from the headline, `PRONOUNS` (RFC 9554) | only what the default CV prints (#308) | | every card | `VERSION:4.0`, `PRODID`, `UID`, `REV` | | As #679, #681 and #682 land, their values join the mapping: `BDAY` and `BIRTHPLACE`, and `GENDER`, on the person's own card, and `IMPP` on every contact's and the person's card, each under the print rule of its own issue. Nothing is written for a field before its issue exists. 3. **Export** as `text/vcard; charset=utf-8`, `.vcf`: one contact, a company's contacts, all contacts, a company card, the person's own card, from the pages where each lives. 4. **Import** a `.vcf` (3.0 and 4.0 read; 2.1 refused with a sentence) through the review page: each card shown with what it would become and what is not kept (`PHOTO`, `LOGO`, `KEY`, `BDAY`, `X-` properties), **none ticked** by default, since an address book is not a job search and nothing from it should arrive unchosen. Chosen cards become a contact under the company `ORG` names, matched by name and never merged silently, or a company for `KIND:org`; an existing contact is never overwritten. 5. **Every value through the form a person would type it in:** numbers through the phone field's rules, an address through the postal rules of its country, an email and a web address through their validators; a value refused is reported and the rest of the card still imports. Lengths are the model's, control characters are removed, a lone carriage return cannot end a line, and a `PHOTO` or `SOURCE` address is never fetched. 6. **On the plugin surface,** the mapping under new names (proposals: a card from plain values, cards from text), with their line in *Writing a plugin* and a repin of postulo-dav, which then deletes the vCard half of `text.py`. ## Decisions - **`N`.** Options: write it split from the name (as the plugin does); omit it; ask the person. **Recommended:** omit. A name field has no given and family part, and Portuguese and many other names do not split at the last space. The person's own card, which has first and last names, writes it. - **`UID`.** A contact has no stored UID, and a pairing needs one that is never rewritten (postulo-dav#7). **Recommended:** one function derives a stable `urn:uuid:` from the instance and the contact for file export; the plugin keeps the UID already stored on its link; an imported `UID` is used only to spot a repeat inside the file. - **vCard 3.0 on output.** **Recommended:** the core writes 4.0 only. The plugin's step down to 3.0 for a book that does not list 4.0 (RFC 6352 §6.2.2) stays a small transform in the plugin. - **Notes.** **Recommended:** left out unless ticked at export, for the reason postulo-dav#8 gives: they are Postulo's private notes and, in the plugin, hold conflict records. - **The person's own card.** **Recommended:** export only, never read back into the profile (the sign-in address and verified numbers are not a card's to change, #328). - **Limits.** **Recommended:** 2 MiB, and the first 500 cards shown. ## Prior art Looked for under the rule of #653, 2026-10-02: - **vobject** 0.9.9 (2024-12-16), Apache-2.0, repository `py-vobject/vobject` (pushed 2026-08-18); needs pytz, six and python-dateutil, none of them in `uv.lock`; no OSV advisory. Its documentation says it supports vCard 2.1 and 3.0 and notes 4.0 “has not been universally adopted”; its README says vCard 3.0 files are supported; three requests for vCard 4.0 are open (#29, #113, #124) and its ROADMAP lists “Robust vCard 4.0 support” unticked; there is no 1.0.0 on PyPI. **Reject: it does not do the format asked for.** - **vcard** 1.0.0 (2024-10-06), AGPLv3, a validator for vCard 3.0 only. **Reject.** - **vcfpy** is the genomics VCF; not a contacts library. - **Hand-written, once.** The project writes vCard and iCalendar by hand today (`text.py`, `applications/ical.py`). The weakness is the copies: `ical.py` fixed content-line injection in #218 and the plugin's own `escape` never received it (#27). **Recommended:** hand-written, in one module the plugin imports; the library check is recorded here, not skipped. Revisit when a maintained library ships vCard 4.0 (watch vobject's #124). ## Builds on / Needed by Builds on postulo/postulo-dav#7, #8, #9, #10, #25, #27; #328, #329, #327, #297, #304, #305, #308 and #653, #659. Needed by postulo-dav#8, #9 and #10. ## Done when - `tests/test_vcard.py` (new): each row of the table; `N` never invented; `ORG:Acme;Legal`; `KIND:org`; folding at 75 octets never inside a character; 3.0 and 4.0 read; a lone `\r`, `\n` or other control character in a name, note, email or URL adds no property (the test postulo-dav#27 asks for, at the source); a round trip loses nothing the table says is kept. - `tests/security/test_vcard.py` (new): a hostile file is bounded in size, cards and line length; a `PHOTO;VALUE=uri` makes no request (`postulo.plugins.http` is patched to fail); nothing names another account's record. - `tests/e2e/test_vcard.py` (new): export a contact, import it into a second account through the review page with nothing ticked, then ticked. New names on `postulo.plugins.api` get their line in *Writing a plugin* (`tests/test_wiki_surface.py`) and a test in `tests/test_plugin_surface.py`; new pages join `tests/test_page_coverage.py` and `tests/security/test_isolation_sweep.py`. - No record type: the archive format (29 today), the candidate format (4), API and search are untouched. Strings in en-GB, fr-FR and pt-PT (drafts) and Weblate (#349); the wiki's contacts page. From the list of improvements of 2026-10-02.
tiagoagueda added this to the 0.6.0 milestone 2026-10-03 01:58:40 +00:00
Author
Owner

Landed on main with a green Forgejo CI run. Its entry in the CHANGELOG's Unreleased section ends in (#660).

Landed on main with a green Forgejo CI run. Its entry in the CHANGELOG's Unreleased section ends in (#660).
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo#660
No description provided.