Instant-messaging contacts, several per person and per contact, each checked against its service #682

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

Postulo can hold several telephone numbers, several addresses and several web links for the person and for each contact, but nowhere to put how somebody is reached on Signal, Matrix, Telegram or XMPP. The maintainer's list asks for it: "Add instant messaging contacts (multiple, like phone numbers), with validation."

What exists today

Read from origin/main at f09b6010b, which holds #304 and #305, the code this mirrors.

  • Three row types share one shape: core.PhoneNumber, core.PostalAddress, core.WebLink. Each is an OwnedModel with a generic holder (the account's Profile or a jobs.Contact, each with a GenericRelation so the holder's deletion cascades), a kind, a label that only matters under Other, is_primary with a partial unique index, and ordering = ("-is_primary", "created_at", "pk") (core/models.py:267, :486, :638).
  • What a number adds that a handle must not copy: uniqueness across the whole instance, verified_at and is_recovery, which make a number a way into an account (#142, #144). WebLink and PostalAddress deliberately have none: unique per holder, never verified, "nothing about it is a way back in", because refusing a second holder discloses that someone else here has the same value (docs/THREAT-MODEL.md).
  • The page. partials/phone_numbers.html, partials/postal_addresses.html and partials/web_links.html draw a block of rows (the star for Primary, the bin that asks in a dialog, #303); core/mixins.py has one mixin per block (PhoneNumbersMixin, WebLinksMixin) that ProfileView and the contact views use; accounts/views.py _section_nav lists each block as an anchor with a count; jobs/contact_form.html and jobs/company_detail.html:218-232 draw a contact's rows.
  • The removal dialogs are one Kind per row type in accounts/removals.py (NUMBER, LINK, ADDRESS, IDENTIFIER), each with its own address accounts:remove_<name> (accounts/urls.py), offered, anchor, words, hand_on and left; a new kind is a new entry in each.
  • A feature plugin governs whether several are offered: plugins/phone_numbers, social_profiles, repositories, websites are declarations only (kind="feature"), asked through plugins.policy.decide, off keeps every row and offers none (plugins/phone_numbers/__init__.py).
  • A registry that plugins supply, with always an Other, exists since #305: core/link_services.py (Service, registry(), settle()), the plugin kind link-service, the entry-point group postulo.link_services, the shipped table plugins/link_services/services.py, and LinkService, LinkServicePlugin, link_service_of on postulo.plugins.api. It validates a web address by host and path pattern, and is built for addresses: a service needs hosts or any_host.
  • Everything a row type touches: the archive's *_FIELDS and the profile and contact blocks (core/export.py:198, _web_links :451, blocks at :470, :520), FORMAT_VERSION (:80; 29 at f09b6010b) and CANDIDATE_FORMAT (:104; 4), the candidate file's CONTACT_BLOCKS (resume/candidate.py:296), the importer's _restore_* (core/importer.py:292), the contact merge (jobs/merging.py:518-548, :633-684, whose _refuse_what_is_left(expected=…) names the row types it knows), the API's contacts (api/routers/companies.py:114-153), the CV's printed details (documents/printing.py DETAILS), core/gdpr.py (_web_link_rows :117, erase_contact's deleted counts :302, _kinds :238, retention_dry_run's would_remove :364, DOCUMENT_VERSION 2 :58, and templates/server/data_protection.html:62-65), seed_demo, and the isolation sweep. Search indexes none of the three row types (core/search.py).
  • record_of_processing() lists plugins and connections, not data, so it needs no line for a new row type; the contact's own document and erasure do (core/gdpr.py:89-153 the document's rows, :155 contact_document, :238 _kinds, :277-310 erase_contact, :337-372 retention_dry_run).
  • Nothing for messaging exists: no model, no field, no registry.
  • The default of a CV is "the document it was, byte for byte" (#308, tests/test_cv_prints.py): a new kind of printed detail whose default followed a primary would change every CV of anyone who added one.

Checked against main at f09b6010b.

Proposal

Three commits, in this order; each leaves main green.

  1. A registry of messaging services, modelled on #305 and kept apart from it. core/messaging_services.py: a MessagingService has a key, a name, a pattern for the handle (compiled over text, read once, with the module's time bound stated as in link_services), a normaliser (strip a leading @, fold case where the service does), an example, an icon and nothing else; settle() follows link_services.settle (a named service's handle has to be one of its handles; Other takes a name and any text up to 255 characters; nothing chosen is Other). The plugin kind messaging-service, entry-point group postulo.messaging_services, shipped as postulo.plugins.messaging_services, with its own locale/ (a plugin carries its own catalogues; core does not translate a plugin's strings). First list, each with a format checked below: Matrix, XMPP, Signal, Telegram, Threema. The icon is a generic Lucide one for every service; no brand marks (TRADEMARKS.md, #654).
  2. The rows. A new core model MessagingHandle (service, label, handle, is_primary, a folded comparable), generic holder on the profile and on a contact, migration and GenericRelations; unique per holder, service and comparable form, not across the instance, never verified, never a way back in; one primary per holder. The block Messaging on Your details after Telephone numbers (service first, name under Other, handle, star, bin, as #213 and #305 lay rows out), on the contact form, and on the company page's contact rows; a mixin beside WebLinksMixin; a MESSAGING kind in accounts/removals.py with accounts:remove_messaging; a sidebar entry and count; a feature plugin messaging-contacts (“Messaging contacts”) beside phone-numbers, on by default, off offers none and keeps every row. Archive, candidate file, importer, API, merge and seed data carry the rows as the three others are carried (messaging_handles blocks on the profile and on each contact; MESSAGING_FIELDS ("service", "label", "handle", "is_primary")); archive and candidate formats go up by one from whatever they are when the commit lands, each with its history line.
  3. Printing, the contact's document, the way out. A messaging entry in printing.DETAILS whose default prints nothing (a handle is not printed because it was added), so the choice is none or chosen and a CV nobody touched is unchanged; the contact dict gains messaging. core/gdpr.py: the contact's document lists the handles (DOCUMENT_VERSION 3), erase_contact and retention_dry_run count them, _kinds names them, the data-protection page's sentence includes them. The contact merge moves them (_moved(...)) and its expected set knows the new type.

Decisions

  • One issue or three. The three commits above are each a unit that leaves main green, and the project's rule is one issue per commit. Recommended: file this as the plan and open the registry, the rows and the printing and erasure as three issues when the work starts (or split at filing, if the maintainer prefers); the order and the dependencies between them are the Proposal's.
  • A model of its own, not a fourth kind of web link. A WebLink is a URLField; xmpp: and matrix: are not web addresses, Signal and Threema have no stable address a person writes, and what people give is a handle. Recommended: MessagingHandle, named for what it holds, because contact already means a person in Postulo (jobs.Contact).
  • A plugin kind or a table in core. A registry as a plugin kind adds three names to postulo.plugins.api and a wiki section. A table in core is one module. Recommended: the plugin kind, since an instance's own service (an IRC network, a company's chat) is exactly the case #305 designed the registry for, and the shape is already written once; if the maintainer prefers the smaller change, commit 1 shrinks to a module and the kind comes later.
  • Which services first. Only those whose identifier format is published: Matrix (@localpart:server, 255 bytes at most, a localpart of lower-case letters, digits and -.=_/+; older IDs may hold anything but :), XMPP (a bare JID local@domain, each part 1 to 1023 octets, RFC 7622; the characters its PRECIS rules exclude from a localpart were not read in detail), Telegram (5 to 32 letters, digits and underscores, Telegram's own page), Threema (an ID of 8 characters, shown as upper-case letters and digits in Threema's examples, with a Gateway ID beginning with *; Threema's own pages give the length and examples but do not spell out the character set, so confirm it before coding), Signal (a nickname of 3 to 32 letters, digits and underscores that does not start with a digit, a dot and a number of at least two digits; as relayed by secondary sources, Signal's support page refusing the automated fetch, so confirm before coding). Not first: Discord (usernames of 2 to 32 lower-case letters, digits, underscores and periods, per secondary sources only), Session (a 66-character hexadecimal ID, secondary sources only), WhatsApp (a phone number, which the telephone rows already hold; usernames were reported rolling out in 2026, not verified from WhatsApp's own help), Skype (announced as retired by Microsoft in 2025, not verified here) and iMessage (an Apple account is an email address or a number, both already held). Everything else is Other.
  • A number on Signal or WhatsApp. The phone row cannot say “also on Signal”. Recommended: leave it; the block says in one sentence that a number on a messaging app belongs under Telephone numbers.
  • Matrix IDs from before the grammar. The spec lets servers and clients accept localparts of "any legal non-surrogate Unicode code points except : and NUL". Recommended: the current grammar for a named service, an older ID under Other; nothing already stored is refused in retrospect.
  • XMPP resources. A full JID ends in /resource naming one device. Recommended: a bare JID only; a typed resource is dropped with a note.
  • A handle as text, or as a link. matrix.to, t.me and signal.me links are third parties' pages, and Postulo never opens or builds a link nobody asked for. Recommended: the handle as text with the service's icon, selectable and copyable; no link in this issue.
  • On a CV. Default prints none (#308's promise); one at most can be chosen per CV. Recommended: as in commit 3.
  • Searching them. Nothing indexes numbers, addresses or links today. Recommended: not searched.
  • Who sees a contact's handles. A contact's handles are data about another person, so they are in the contact's document and counted by the erasure and the retention dry run (core/gdpr.py), as its numbers and links are. Recommended: exactly that; the person's own handles are the account holder's, covered by the archive and account deletion.

Prior art

  • No library validates messaging identifiers across services; each service's rule is one expression. Rejected as dependencies, each for being an XMPP or Matrix client stack for the sake of one pattern: slixmpp (MIT, 1.17.0, released 2026-07-08, maintained; OSV lists six advisories for the project, which of them touch 1.17.0 not checked), aioxmpp (LGPL-3.0-or-later, 0.13.3, last released 2023-02-05, so effectively unmaintained; two advisories on OSV), and matrix-nio (ISC, 0.26.0, released 2026-07-23; two advisories on OSV, not checked). None is a dependency today (uv.lock). The licences are compatible with AGPL-3.0 as dependencies; the reason is size, not licence.
  • The telephone rows took the other path (#304, phonenumberslite, Apache-2.0, 9.0.40 released 2026-09-24, no advisories on OSV, now a dependency) because a numbering plan is large and keeps changing. A handle format is a line.
  • Hand-written standards on purpose: applications/ical.py and documents/docx.py. This is smaller than either.
  • Standards: RFC 7622 (XMPP addresses), the Matrix specification's appendix on identifiers, and, for a later impp or vCard mapping, RFC 6350's IMPP (a URI, which is why a stored handle would be turned into one at export and not stored as one).

Builds on

#90 (several numbers), #189 (several web links), #303 (the bin and its dialog), #305 (the service registry and settle), #213 (what the row is first), #308 (what a CV prints), #297 and core/gdpr.py (a contact's document and erasure), #92 (postal addresses and the "nothing here is a way in" rule).

Needed by

#660 (contacts as vCard 4) maps the handles to IMPP; postulo/postulo-dav#8 to #10 pair and export contacts.

Done when

  • tests/test_messaging_services.py (new): each shipped service accepts its example and refuses a near miss (Matrix without a server, a Telegram name of four characters, a Threema ID of seven, a Signal username without a number, a JID with a space), normalises as stated, reads in bounded time, and a plugin's own service is added and cannot redefine a shipped key; tests/test_plugin_surface.py and tests/test_wiki_surface.py cover the three new names, with their lines in the wiki's Writing a plugin; tests/test_plugin_manifests.py the new plugin.
  • tests/test_messaging_handles.py (new): the rows save, one primary per holder, unique per holder and service and not across holders, never verified; the block is on Your details and on the contact form; the feature plugin off hides the block and keeps the rows; tests/test_profile_sections.py expects the anchor.
  • tests/test_row_removal.py and tests/e2e/test_row_removal.py: the bin, its dialog, the primary handed on, a row already gone; tests/test_page_coverage.py lists accounts:remove_messaging; tests/security/test_isolation_sweep.py and a new case in tests/test_jobs_isolation.py show another account's handles are a 404.
  • The archive and candidate round trips carry the block at the new formats (tests/test_export.py, tests/test_candidate_file.py fingerprint, tests/security/test_candidate_file.py for a file with too many or malformed rows); the API takes and returns handles on a contact (tests/test_api.py); the merge moves them (tests/test_merging.py).
  • tests/test_cv_prints.py and tests/security/test_cv_prints.py: the default prints none and an unopened CV is byte for byte the same; a chosen handle prints; another account's handle cannot be pinned.
  • tests/test_gdpr.py: a contact's document lists the handles, erasure and the dry run count them.
  • Axe in both themes over the new block (tests/e2e/test_accessibility.py), the row's reading order and width at 320 pixels (tests/e2e/test_reflow.py), and the handle in a right-to-left page (tests/e2e/test_direction.py).
  • Strings are in the working catalogues (en-GB, fr-FR, pt-PT as drafts) and Weblate (#349); the plugin's own strings are in its locale/; the wiki's Writing a plugin, Your career record and The capture API change in the same commits.

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

Postulo can hold several telephone numbers, several addresses and several web links for the person and for each contact, but nowhere to put how somebody is reached on Signal, Matrix, Telegram or XMPP. The maintainer's list asks for it: "Add instant messaging contacts (multiple, like phone numbers), with validation." ## What exists today Read from `origin/main` at `f09b6010b`, which holds #304 and #305, the code this mirrors. - **Three row types share one shape**: `core.PhoneNumber`, `core.PostalAddress`, `core.WebLink`. Each is an `OwnedModel` with a generic holder (the account's `Profile` or a `jobs.Contact`, each with a `GenericRelation` so the holder's deletion cascades), a kind, a label that only matters under *Other*, `is_primary` with a partial unique index, and `ordering = ("-is_primary", "created_at", "pk")` (`core/models.py:267`, `:486`, `:638`). - **What a number adds that a handle must not copy**: uniqueness across the whole instance, `verified_at` and `is_recovery`, which make a number a way into an account (#142, #144). `WebLink` and `PostalAddress` deliberately have none: unique per holder, never verified, "nothing about it is a way back in", because refusing a second holder discloses that someone else here has the same value (`docs/THREAT-MODEL.md`). - **The page.** `partials/phone_numbers.html`, `partials/postal_addresses.html` and `partials/web_links.html` draw a block of rows (the star for *Primary*, the bin that asks in a dialog, #303); `core/mixins.py` has one mixin per block (`PhoneNumbersMixin`, `WebLinksMixin`) that `ProfileView` and the contact views use; `accounts/views.py` `_section_nav` lists each block as an anchor with a count; `jobs/contact_form.html` and `jobs/company_detail.html:218-232` draw a contact's rows. - **The removal dialogs** are one `Kind` per row type in `accounts/removals.py` (`NUMBER`, `LINK`, `ADDRESS`, `IDENTIFIER`), each with its own address `accounts:remove_<name>` (`accounts/urls.py`), `offered`, `anchor`, `words`, `hand_on` and `left`; a new kind is a new entry in each. - **A feature plugin governs whether several are offered**: `plugins/phone_numbers`, `social_profiles`, `repositories`, `websites` are declarations only (`kind="feature"`), asked through `plugins.policy.decide`, off keeps every row and offers none (`plugins/phone_numbers/__init__.py`). - **A registry that plugins supply**, with always an *Other*, exists since #305: `core/link_services.py` (`Service`, `registry()`, `settle()`), the plugin kind `link-service`, the entry-point group `postulo.link_services`, the shipped table `plugins/link_services/services.py`, and `LinkService`, `LinkServicePlugin`, `link_service_of` on `postulo.plugins.api`. It validates a web address by host and path pattern, and is built for addresses: a service needs `hosts` or `any_host`. - **Everything a row type touches**: the archive's `*_FIELDS` and the profile and contact blocks (`core/export.py:198`, `_web_links` `:451`, blocks at `:470`, `:520`), `FORMAT_VERSION` (`:80`; 29 at `f09b6010b`) and `CANDIDATE_FORMAT` (`:104`; 4), the candidate file's `CONTACT_BLOCKS` (`resume/candidate.py:296`), the importer's `_restore_*` (`core/importer.py:292`), the contact merge (`jobs/merging.py:518-548`, `:633-684`, whose `_refuse_what_is_left(expected=…)` names the row types it knows), the API's contacts (`api/routers/companies.py:114-153`), the CV's printed details (`documents/printing.py` `DETAILS`), `core/gdpr.py` (`_web_link_rows` `:117`, `erase_contact`'s `deleted` counts `:302`, `_kinds` `:238`, `retention_dry_run`'s `would_remove` `:364`, `DOCUMENT_VERSION` 2 `:58`, and `templates/server/data_protection.html:62-65`), `seed_demo`, and the isolation sweep. Search indexes none of the three row types (`core/search.py`). - **`record_of_processing()` lists plugins and connections, not data**, so it needs no line for a new row type; the contact's own document and erasure do (`core/gdpr.py:89-153` the document's rows, `:155` `contact_document`, `:238` `_kinds`, `:277-310` `erase_contact`, `:337-372` `retention_dry_run`). - **Nothing for messaging exists**: no model, no field, no registry. - The default of a CV is "the document it was, byte for byte" (#308, `tests/test_cv_prints.py`): a new kind of printed detail whose default followed a primary would change every CV of anyone who added one. Checked against `main` at `f09b6010b`. ## Proposal Three commits, in this order; each leaves `main` green. 1. **A registry of messaging services**, modelled on #305 and kept apart from it. `core/messaging_services.py`: a `MessagingService` has a key, a name, a pattern for the handle (compiled over text, read once, with the module's time bound stated as in `link_services`), a normaliser (strip a leading `@`, fold case where the service does), an example, an icon and nothing else; `settle()` follows `link_services.settle` (a named service's handle has to be one of its handles; *Other* takes a name and any text up to 255 characters; nothing chosen is *Other*). The plugin kind `messaging-service`, entry-point group `postulo.messaging_services`, shipped as `postulo.plugins.messaging_services`, with its own `locale/` (a plugin carries its own catalogues; core does not translate a plugin's strings). First list, each with a format checked below: Matrix, XMPP, Signal, Telegram, Threema. The icon is a generic Lucide one for every service; no brand marks (`TRADEMARKS.md`, #654). 2. **The rows.** A new core model `MessagingHandle` (`service`, `label`, `handle`, `is_primary`, a folded `comparable`), generic holder on the profile and on a contact, migration and `GenericRelation`s; unique per holder, service and comparable form, **not across the instance**, never verified, never a way back in; one primary per holder. The block *Messaging* on Your details after *Telephone numbers* (service first, name under *Other*, handle, star, bin, as #213 and #305 lay rows out), on the contact form, and on the company page's contact rows; a mixin beside `WebLinksMixin`; a `MESSAGING` kind in `accounts/removals.py` with `accounts:remove_messaging`; a sidebar entry and count; a feature plugin `messaging-contacts` (“Messaging contacts”) beside `phone-numbers`, on by default, off offers none and keeps every row. Archive, candidate file, importer, API, merge and seed data carry the rows as the three others are carried (`messaging_handles` blocks on the profile and on each contact; `MESSAGING_FIELDS` `("service", "label", "handle", "is_primary")`); archive and candidate formats go up by one from whatever they are when the commit lands, each with its history line. 3. **Printing, the contact's document, the way out.** A `messaging` entry in `printing.DETAILS` whose default prints **nothing** (a handle is not printed because it was added), so the choice is *none* or *chosen* and a CV nobody touched is unchanged; the `contact` dict gains `messaging`. `core/gdpr.py`: the contact's document lists the handles (`DOCUMENT_VERSION` 3), `erase_contact` and `retention_dry_run` count them, `_kinds` names them, the data-protection page's sentence includes them. The contact merge moves them (`_moved(...)`) and its `expected` set knows the new type. ## Decisions - **One issue or three.** The three commits above are each a unit that leaves `main` green, and the project's rule is one issue per commit. **Recommended:** file this as the plan and open the registry, the rows and the printing and erasure as three issues when the work starts (or split at filing, if the maintainer prefers); the order and the dependencies between them are the Proposal's. - **A model of its own, not a fourth kind of web link.** A `WebLink` is a `URLField`; `xmpp:` and `matrix:` are not web addresses, Signal and Threema have no stable address a person writes, and what people give is a handle. **Recommended:** `MessagingHandle`, named for what it holds, because *contact* already means a person in Postulo (`jobs.Contact`). - **A plugin kind or a table in core.** A registry as a plugin kind adds three names to `postulo.plugins.api` and a wiki section. A table in core is one module. **Recommended:** the plugin kind, since an instance's own service (an IRC network, a company's chat) is exactly the case #305 designed the registry for, and the shape is already written once; if the maintainer prefers the smaller change, commit 1 shrinks to a module and the kind comes later. - **Which services first.** Only those whose identifier format is published: Matrix (`@localpart:server`, 255 bytes at most, a localpart of lower-case letters, digits and `-.=_/+`; older IDs may hold anything but `:`), XMPP (a bare JID `local@domain`, each part 1 to 1023 octets, RFC 7622; the characters its PRECIS rules exclude from a localpart were not read in detail), Telegram (5 to 32 letters, digits and underscores, Telegram's own page), Threema (an ID of 8 characters, shown as upper-case letters and digits in Threema's examples, with a Gateway ID beginning with `*`; Threema's own pages give the length and examples but do not spell out the character set, so confirm it before coding), Signal (a nickname of 3 to 32 letters, digits and underscores that does not start with a digit, a dot and a number of at least two digits; as relayed by secondary sources, Signal's support page refusing the automated fetch, so confirm before coding). **Not first:** Discord (usernames of 2 to 32 lower-case letters, digits, underscores and periods, per secondary sources only), Session (a 66-character hexadecimal ID, secondary sources only), WhatsApp (a phone number, which the telephone rows already hold; usernames were reported rolling out in 2026, not verified from WhatsApp's own help), Skype (announced as retired by Microsoft in 2025, not verified here) and iMessage (an Apple account is an email address or a number, both already held). Everything else is *Other*. - **A number on Signal or WhatsApp.** The phone row cannot say “also on Signal”. **Recommended:** leave it; the block says in one sentence that a number on a messaging app belongs under *Telephone numbers*. - **Matrix IDs from before the grammar.** The spec lets servers and clients accept localparts of "any legal non-surrogate Unicode code points except `:` and NUL". **Recommended:** the current grammar for a named service, an older ID under *Other*; nothing already stored is refused in retrospect. - **XMPP resources.** A full JID ends in `/resource` naming one device. **Recommended:** a bare JID only; a typed resource is dropped with a note. - **A handle as text, or as a link.** `matrix.to`, `t.me` and `signal.me` links are third parties' pages, and Postulo never opens or builds a link nobody asked for. **Recommended:** the handle as text with the service's icon, selectable and copyable; no link in this issue. - **On a CV.** Default prints none (#308's promise); one at most can be chosen per CV. **Recommended:** as in commit 3. - **Searching them.** Nothing indexes numbers, addresses or links today. **Recommended:** not searched. - **Who sees a contact's handles.** A contact's handles are data about another person, so they are in the contact's document and counted by the erasure and the retention dry run (`core/gdpr.py`), as its numbers and links are. **Recommended:** exactly that; the person's own handles are the account holder's, covered by the archive and account deletion. ## Prior art - **No library validates messaging identifiers across services**; each service's rule is one expression. **Rejected as dependencies**, each for being an XMPP or Matrix client stack for the sake of one pattern: `slixmpp` (MIT, 1.17.0, released 2026-07-08, maintained; OSV lists six advisories for the project, which of them touch 1.17.0 not checked), `aioxmpp` (LGPL-3.0-or-later, 0.13.3, last released 2023-02-05, so effectively unmaintained; two advisories on OSV), and `matrix-nio` (ISC, 0.26.0, released 2026-07-23; two advisories on OSV, not checked). None is a dependency today (`uv.lock`). The licences are compatible with AGPL-3.0 as dependencies; the reason is size, not licence. - **The telephone rows took the other path** (#304, `phonenumberslite`, Apache-2.0, 9.0.40 released 2026-09-24, no advisories on OSV, now a dependency) because a numbering plan is large and keeps changing. A handle format is a line. - **Hand-written standards on purpose:** `applications/ical.py` and `documents/docx.py`. This is smaller than either. - **Standards:** RFC 7622 (XMPP addresses), the Matrix specification's appendix on identifiers, and, for a later `impp` or vCard mapping, RFC 6350's `IMPP` (a URI, which is why a stored handle would be turned into one at export and not stored as one). ## Builds on #90 (several numbers), #189 (several web links), #303 (the bin and its dialog), #305 (the service registry and `settle`), #213 (what the row is first), #308 (what a CV prints), #297 and `core/gdpr.py` (a contact's document and erasure), #92 (postal addresses and the "nothing here is a way in" rule). ## Needed by #660 (contacts as vCard 4) maps the handles to `IMPP`; postulo/postulo-dav#8 to #10 pair and export contacts. ## Done when - `tests/test_messaging_services.py` (new): each shipped service accepts its example and refuses a near miss (Matrix without a server, a Telegram name of four characters, a Threema ID of seven, a Signal username without a number, a JID with a space), normalises as stated, reads in bounded time, and a plugin's own service is added and cannot redefine a shipped key; `tests/test_plugin_surface.py` and `tests/test_wiki_surface.py` cover the three new names, with their lines in the wiki's *Writing a plugin*; `tests/test_plugin_manifests.py` the new plugin. - `tests/test_messaging_handles.py` (new): the rows save, one primary per holder, unique per holder and service and not across holders, never verified; the block is on Your details and on the contact form; the feature plugin off hides the block and keeps the rows; `tests/test_profile_sections.py` expects the anchor. - `tests/test_row_removal.py` and `tests/e2e/test_row_removal.py`: the bin, its dialog, the primary handed on, a row already gone; `tests/test_page_coverage.py` lists `accounts:remove_messaging`; `tests/security/test_isolation_sweep.py` and a new case in `tests/test_jobs_isolation.py` show another account's handles are a 404. - The archive and candidate round trips carry the block at the new formats (`tests/test_export.py`, `tests/test_candidate_file.py` fingerprint, `tests/security/test_candidate_file.py` for a file with too many or malformed rows); the API takes and returns handles on a contact (`tests/test_api.py`); the merge moves them (`tests/test_merging.py`). - `tests/test_cv_prints.py` and `tests/security/test_cv_prints.py`: the default prints none and an unopened CV is byte for byte the same; a chosen handle prints; another account's handle cannot be pinned. - `tests/test_gdpr.py`: a contact's document lists the handles, erasure and the dry run count them. - Axe in both themes over the new block (`tests/e2e/test_accessibility.py`), the row's reading order and width at 320 pixels (`tests/e2e/test_reflow.py`), and the handle in a right-to-left page (`tests/e2e/test_direction.py`). - Strings are in the working catalogues (en-GB, fr-FR, pt-PT as drafts) and Weblate (#349); the plugin's own strings are in its `locale/`; the wiki's *Writing a plugin*, *Your career record* and *The capture API* change in the same commits. From the list of improvements of 2026-10-02.
tiagoagueda added this to the 0.6.0 milestone 2026-10-03 01:58:58 +00:00
Author
Owner

Done in e3cc68c55 (on main; CI green at 15e6d7ee4): Matrix, XMPP, Signal, Telegram and Threema, several per person and contact, each checked against its service. The Signal and Threema formats are not yet confirmed against those services' own pages. Wiki: Writing a plugin, Your career record, The capture API.

Done in e3cc68c55 (on main; CI green at 15e6d7ee4): Matrix, XMPP, Signal, Telegram and Threema, several per person and contact, each checked against its service. The Signal and Threema formats are not yet confirmed against those services' own pages. Wiki: Writing a plugin, Your career record, The capture API.
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#682
No description provided.