Instant-messaging contacts, several per person and per contact, each checked against its service #682
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
performance
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo#682
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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/mainatf09b6010b, which holds #304 and #305, the code this mirrors.core.PhoneNumber,core.PostalAddress,core.WebLink. Each is anOwnedModelwith a generic holder (the account'sProfileor ajobs.Contact, each with aGenericRelationso the holder's deletion cascades), a kind, a label that only matters under Other,is_primarywith a partial unique index, andordering = ("-is_primary", "created_at", "pk")(core/models.py:267,:486,:638).verified_atandis_recovery, which make a number a way into an account (#142, #144).WebLinkandPostalAddressdeliberately 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).partials/phone_numbers.html,partials/postal_addresses.htmlandpartials/web_links.htmldraw a block of rows (the star for Primary, the bin that asks in a dialog, #303);core/mixins.pyhas one mixin per block (PhoneNumbersMixin,WebLinksMixin) thatProfileViewand the contact views use;accounts/views.py_section_navlists each block as an anchor with a count;jobs/contact_form.htmlandjobs/company_detail.html:218-232draw a contact's rows.Kindper row type inaccounts/removals.py(NUMBER,LINK,ADDRESS,IDENTIFIER), each with its own addressaccounts:remove_<name>(accounts/urls.py),offered,anchor,words,hand_onandleft; a new kind is a new entry in each.plugins/phone_numbers,social_profiles,repositories,websitesare declarations only (kind="feature"), asked throughplugins.policy.decide, off keeps every row and offers none (plugins/phone_numbers/__init__.py).core/link_services.py(Service,registry(),settle()), the plugin kindlink-service, the entry-point grouppostulo.link_services, the shipped tableplugins/link_services/services.py, andLinkService,LinkServicePlugin,link_service_ofonpostulo.plugins.api. It validates a web address by host and path pattern, and is built for addresses: a service needshostsorany_host.*_FIELDSand the profile and contact blocks (core/export.py:198,_web_links:451, blocks at:470,:520),FORMAT_VERSION(:80; 29 atf09b6010b) andCANDIDATE_FORMAT(:104; 4), the candidate file'sCONTACT_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.pyDETAILS),core/gdpr.py(_web_link_rows:117,erase_contact'sdeletedcounts:302,_kinds:238,retention_dry_run'swould_remove:364,DOCUMENT_VERSION2:58, andtemplates/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-153the document's rows,:155contact_document,:238_kinds,:277-310erase_contact,:337-372retention_dry_run).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
mainatf09b6010b.Proposal
Three commits, in this order; each leaves
maingreen.core/messaging_services.py: aMessagingServicehas a key, a name, a pattern for the handle (compiled over text, read once, with the module's time bound stated as inlink_services), a normaliser (strip a leading@, fold case where the service does), an example, an icon and nothing else;settle()followslink_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 kindmessaging-service, entry-point grouppostulo.messaging_services, shipped aspostulo.plugins.messaging_services, with its ownlocale/(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).MessagingHandle(service,label,handle,is_primary, a foldedcomparable), generic holder on the profile and on a contact, migration andGenericRelations; 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 besideWebLinksMixin; aMESSAGINGkind inaccounts/removals.pywithaccounts:remove_messaging; a sidebar entry and count; a feature pluginmessaging-contacts(“Messaging contacts”) besidephone-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_handlesblocks 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.messagingentry inprinting.DETAILSwhose 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; thecontactdict gainsmessaging.core/gdpr.py: the contact's document lists the handles (DOCUMENT_VERSION3),erase_contactandretention_dry_runcount them,_kindsnames them, the data-protection page's sentence includes them. The contact merge moves them (_moved(...)) and itsexpectedset knows the new type.Decisions
maingreen, 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.WebLinkis aURLField;xmpp:andmatrix: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).postulo.plugins.apiand 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.@localpart:server, 255 bytes at most, a localpart of lower-case letters, digits and-.=_/+; older IDs may hold anything but:), XMPP (a bare JIDlocal@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.:and NUL". Recommended: the current grammar for a named service, an older ID under Other; nothing already stored is refused in retrospect./resourcenaming one device. Recommended: a bare JID only; a typed resource is dropped with a note.matrix.to,t.meandsignal.melinks 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.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
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), andmatrix-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.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.applications/ical.pyanddocuments/docx.py. This is smaller than either.imppor vCard mapping, RFC 6350'sIMPP(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 andcore/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.pyandtests/test_wiki_surface.pycover the three new names, with their lines in the wiki's Writing a plugin;tests/test_plugin_manifests.pythe 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.pyexpects the anchor.tests/test_row_removal.pyandtests/e2e/test_row_removal.py: the bin, its dialog, the primary handed on, a row already gone;tests/test_page_coverage.pylistsaccounts:remove_messaging;tests/security/test_isolation_sweep.pyand a new case intests/test_jobs_isolation.pyshow another account's handles are a 404.tests/test_export.py,tests/test_candidate_file.pyfingerprint,tests/security/test_candidate_file.pyfor 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.pyandtests/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.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).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.
Done in
e3cc68c55(on main; CI green at15e6d7ee4): 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.