Contacts sync: a card's number that is taken elsewhere is dropped without a note, and a removed TEL deletes the number here #26
Labels
No labels
bug
documentation
enhancement
performance
security
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo-dav#26
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?
Found on 2026-09-29 in a read-only review of postulo-dav at
97abc9779, readingapply_cardagainst the rules #1 set for telephone numbers and running both cases against the suite's in-memory server.Related to postulo/postulo#333.
When a pulled card carries a number that some other row on the instance already holds, the number is skipped and nothing says so; the link is then stored as if the card had been applied, so the two sides stay different and a later edit here writes the old number over the phone's. When a pulled card has no
TELat all, the contact's primary number here is deleted. Related to #1 (closed), which asked for the opposite of both: "A number refused as taken elsewhere is a note in the report, never a failed sync", and "A number removed from a card must not remove the number in Postulo".src/postulo_dav/sync.py:159-162(apply_card),src/postulo_dav/sync.py:491-496(the pull in_sync_contacts),src/postulo_dav/sync.py:540-549(adoption, the same call). In the core,core/phone_numbers.py:269-274(save_only_numberdeletes the primary row when given an empty string).sync.py:161isif not number or not phone_number_is_taken(number, exclude_pk=primary.pk if primary else None):. A taken number skipssave_phone_numberand adds nothing toreport.notes. The caller then setslink.etag = resource.etagandlink.local_hash = digest(card_for(contact).digest_source()), computed from the contact as it now is, with the old number. Run against the fake server: the report was1 pulledwith no note, the primary stayed+1 555 0100, the next run saidnothing to dowhile the card still held the other number, and after a change of role here the push put+1 555 0100back on the card. The push is conditional on the ETag just stored, so nothing notices.taken_elsewherecounts the same account's rows too, so two contacts at one company who share a switchboard number are enough.TEL. Withnumber == ""the first half of the condition is true andsave_phone_number(contact, contact.owner, "")runs, which deletes the primary row. Run: a card with itsTELline removed gave1 pulledandprimary_for(recruiter)wasNoneafterwards.apply_card, callsave_phone_numberonly when the card has a number and it is not taken. When it is taken, append a note that names the contact and says no more than the core's own sentence does (ALREADY_IN_USE,core/phone_numbers.py:33), and have the caller leavelink.etagas it was, so the card is still seen as changed: the note repeats until the two sides agree, and a later edit here meets the conflict rule atsync.py:472(the losing version kept as a note) instead of a silent overwrite.apply_cardneeds the report, or a return value, for that. When the card has noTEL, leave the number here alone.save_web_link(..., card.url[:500])onsync.py:163has the same shape and removes the primary social link when a card has noURL; decide it the same way.Done when
tests/test_dav.pyhas a test in which a pulled card carries a number another contact holds: the report carries a note, the contact keeps its number, the next run does not saynothing to do, and a later change here does not put the old number on the card without a note.tests/test_dav.pyhas a test in which a pulled card has noTEL: the contact's primary number is still there afterwards.uv run postulo-messages extract, with fr-fr and pt-pt filled.Note from postulo/postulo#333 (the core side is done; this is the plugin's half).
apply_cardcallsphone_number_is_taken(number, exclude_pk=...)without saying who is asking. In core that bare form is now deprecated: it still answers, but uncharged, with aDeprecationWarning, and it will be removed in a later release. Whoever fixes this issue should also:phone_number_is_taken(number, exclude_pk=..., asked_by=<the account being synced>). A yes then spends one of that account's answers, and oncePOSTULO_NUMBER_RATEis spent every number answers yes, held or not, so a sync can no longer map which numbers the instance holds.asked_by(the surface test lists it asphone_number_is_taken(number, asked_by=account)).Once this lands, postulo/postulo#333 can be closed.
Fixed by pull request #35, merged as
fb8bbd7ae.