Skip to content

CardDAV: mirror address books, suggest contacts while composing, edit with two-way sync - #109

Open
jo2904 wants to merge 15 commits into
karatektus:milestone/v0.4.0from
jo2904:feat/carddav-contacts
Open

jo2904 wants to merge 15 commits into
karatektus:milestone/v0.4.0from
jo2904:feat/carddav-contacts

Conversation

@jo2904

@jo2904 jo2904 commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Closes #157

Adds CardDAV: mirror address books from Nextcloud (or Radicale, Baïkal, Fastmail, iCloud…), suggest their contacts while composing, and edit them from a new Contacts page with two-way sync.

It is built the way CalDAV already is, and reuses its WebDAV layer rather than adding a second one.

What a user gets

  • Settings → Contacts: connect a CardDAV server (same form and flow as CalDAV: address, credential, then tick the address books to mirror). Each book shows its last sync or its last error, and has a sync-now button.

  • Contacts page (user menu → Contacts): list, search, create, edit, delete. Read-only books (shared or global address lists) are shown but not editable.

  • Composer autocomplete and JMAP Contact/autocomplete now suggest address-book contacts as well as harvested addresses, in one ranking shared by both:

    1. mail frequency;
    2. then whether the address is in an address book;
    3. then recency, then name.

    With no address book the order is exactly as before. JMAP suggestions gain an inAddressBook flag.

How it is built (14 commits, one idea each)

The first five build the feature; the nine after them are fixes and one refactor from a review pass, each with its own test.

  1. CalDavDiscovery refactor. The RFC 6764 bootstrap now takes a DavService (Calendar or AddressBook), so .well-known/carddav and addressbook-home-set use the same discovery code. This includes its SSRF re-validation on every redirect. Calendars are the default; no behaviour change for CalDAV.
  2. CardDAV model and sync.
    • Connection: Provider::CardDav is a connection under a new ServiceKind::Contacts. It is kept out of the file picker and the calendar screens.
    • Entities: AddressBook (the counterpart of Calendar) and ContactCard.
    • Driver: CardDavDriver reuses CalDavClient and MultiStatusParser. Error wording is provider-aware, so a contacts failure never says "calendar server". The read-only rule (DavResource::isReadOnly()) and the etag read-back after a PUT (CalDavClient::etagAfterWrite()) are shared with the CalDAV driver rather than copied; no CalDAV behaviour changes.
    • Sync:
      • Local edits are pushed before the pull.
      • Writes are conditional (If-None-Match: * and If-Match). On a 412 the server's version wins and the loss is logged.
      • A 403 on one card is given up on the same way (logged with the discarded vCard), so one refused card cannot stop the whole book syncing. Any other failure (unreachable, 507 out of space, 401) keeps the edit pending for the retry.
      • A dead sync token, whether reported as a moved ctag or as the valid-sync-token precondition, is dropped and the book is read from scratch.
      • Only a full listing deletes cards it did not mention; a delta or a truncated report never does, including a truncated report that gives no token to continue from.
      • Removals are applied last, and the href/UID index is kept current while a pull is applied, so a card "moved" on the server (same UID, new href) keeps its row whichever order the report lists the move in.
    • Schedule: app:contacts:sync --stale runs every 15 minutes, offset to :11 so it does not stack on the mail and calendar sweeps.
  3. UI.
    • The connect form is CalDavConnectType with a protocol option, so the reuse-a-mail-password rule and the probe-on-save rule are not duplicated.
    • Edits are saved locally, marked pending, and pushed by the worker. Nothing waits on the CardDAV server.
  4. Autocomplete merge (ContactSuggester), used by both the web route and JMAP. Only cards that carry an address are asked for, in SQL before the limit, so a book full of number-only phone contacts does not crowd out the ones you can write to.
  5. Docs: docs/providers/carddav.md (English and German), the index, and CHANGELOG.

vCards are edited, not rebuilt. ContactCard keeps the raw vCard, and an edit only touches FN, N, EMAIL, TEL, ORG and NOTE. Photos, birthdays, postal addresses and phone-specific X- properties survive a round trip. A kept address also keeps its TYPE and its Apple grouping.

Migration

Adds the address_book and contact_card tables. Nothing existing changes. doctrine:schema:validate --skip-sync passes; index and FK names follow DBAL's generator.

Testing

Run here without Postgres:

  • 65 new or updated unit tests pass: vCard converter, CardDAV driver against a scripted server, sync service with an in-memory repository, card editor, suggester, provider enum, discovery.
  • Documentation, translation-parity and informal-German tests pass.
  • lint:container, lint:twig and targeted PHPStan (src and tests) are clean.
  • The new templates were rendered against in-memory entities.
  • Compared with main, the full suite shows no newly failing test. The remaining failures are the DB-dependent ones, plus Graph time-zone tests that need an ICU this host lacks; they fail the same way on main.

Not run here (needs the stack):

  • tests/Controller/AddressBook/ContactsFlowTest.php, end to end with a scripted Nextcloud on a .test host: connect, discover, subscribe, list, search, edit, create, delete, read-only, cross-user isolation.
  • The existing JMAP autocomplete tests, one of which now expects the inAddressBook key.

Please run them with npm run test:unit:docker, or let CI run them.

Not in this PR

Inherited from the CalDAV code this mirrors, and left alone here because fixing them means changing the calendar side too:

  • CalDavCalendarDriver has the same "truncated sync report with no token" exit that is fixed here for CardDAV. CalendarChangeSet has no full-listing flag, so the fix there touches the calendar engine.
  • A connect that fails its probe still saves the connection (as CalDAV does). Resubmitting the same dialog without changing the name then hits the (usr, provider, name) unique key.
  • No per-book lock: two sync runs for the same book at once (an edit's dispatch and the sweep) can both push the same pending card. The second gets a 412 and lets the server win, which is the server's copy of the same edit. The calendar sync has no lock either.

Out of scope:

🤖 Generated with Claude Code

https://claude.ai/code/session_018nYWPc4bWacb79hm4ytfni

karatektus added a commit that referenced this pull request Oct 14, 2026
- The migration is dated after the ones main has run since, under a name
  no open branch uses (#109 and #112 hold the earlier slots today).
- The inbox's query ceiling is one above the shared one again: main
  raised that to 28 for the sidebar's change counter, and the own-tabs
  read is still the one statement the inbox adds on top.
- French for the new strings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@karatektus karatektus added enhancement New feature or a change to how something works area: mail Concerns mail labels Oct 15, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation area: calendar Concerns calendar area: settings Concerns settings area: install Concerns install labels Oct 15, 2026
@karatektus

Copy link
Copy Markdown
Owner

Thanks for CardDAV, @jo2904. Contacts are wanted, and building it on the CalDAV layer rather than a second one is the right call. It's accepted for v0.4.0 (#157).

One thing has to change before review: how it connects. We decided in #158 that a Nextcloud connection is made once and gives files, calendars and contacts, with the one app password the existing Nextcloud integration (Settings → Integrations) already holds. So for Nextcloud, address books should come from that connection, not from a separate CardDAV connection with its own address and password. A standalone CardDAV connection is still right for servers that aren't Nextcloud: Radicale, Baïkal, Fastmail, iCloud.

The sync engine, AddressBook/ContactCard, the Contacts page and the composer ranking look like they fit #158 as they are; it's the connection model that has to follow it. Could you read #158 and say there how you'd hang address books off the Nextcloud connection, before reworking? Agreeing that first will keep this from being redone.

It also needs the French catalogue (messages.fr.yaml), which landed after you branched; the catalogue-parity test checks all four.

@jo2904
jo2904 force-pushed the feat/carddav-contacts branch from 6333baf to d04cb5e Compare October 15, 2026 21:59
jo2904 and others added 15 commits October 16, 2026 00:00
…u carnet d'adresses)

Préparation de CardDAV. RFC 6764 décrit le même amorçage pour les deux
protocoles : .well-known, principal, propriété *-home-set, listing du
home. Seuls les noms changent (/.well-known/carddav, addressbook-home-set,
resourcetype addressbook). Plutôt que de copier CalDavDiscovery, les noms
sortent dans un enum DavService et l'échelle reste écrite une fois, avec
ses garde-fous SSRF : chaque redirection est revalidée, et le nombre de
sauts est borné.

Calendar est la valeur par défaut d'endpointFor() : les appelants
existants ne changent pas, et les 72 tests CalDAV passent sans
modification hormis le renommage ci-dessous.

- CalDavEndpoint::$calendarHome devient $home, sinon le code carnet
  d'adresses lirait « calendarHome ».
- CalDavClient::baseUrl() cherche l'adresse épinglée par l'admin pour le
  fournisseur de la connexion et non plus pour CalDav en dur. Sans ça, un
  épinglage CalDAV s'appliquerait aussi aux contacts. Aucun effet sur une
  connexion CalDAV.
- Le message « aucun service à cette adresse » vient du service : on ne
  dit pas « CalDAV » à quelqu'un qui configure ses contacts.

Un piège testé : une URL de calendrier collée dans un formulaire CardDAV
n'est pas acceptée comme carnet d'adresses. Sans le test
isCollection() par service, la première collection rencontrée gagnerait.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018nYWPc4bWacb79hm4ytfni
…directionnelle

Les contacts d'un serveur CardDAV (Nextcloud, Radicale, Baïkal, Fastmail…)
sont maintenant stockés dans plMail, et une modification faite ici repart
vers le serveur. Ce commit couvre le modèle et la synchro ; l'interface
vient au commit suivant.

Modèle : Provider::CardDav est une connexion Integration comme CalDAV, avec
un nouveau ServiceKind::Contacts pour qu'elle n'apparaisse ni dans le
sélecteur de fichiers ni sur les écrans calendrier. C'est une connexion
distincte de CalDAV : certains serveurs ne font que l'un des deux, et un
même utilisateur peut avoir ses contacts et ses calendriers à deux
endroits différents. AddressBook est l'équivalent de Calendar (une
collection suivie, son jeton de synchro, sa dernière erreur) et
ContactCard est une vCard.

ContactCard garde la vCard brute, pas seulement les champs affichés. Une
modification faite ici est appliquée au texte stocké par VCardConverter :
seuls FN/N/EMAIL/TEL/ORG/NOTE changent, et une adresse conservée garde
son TYPE et son groupe Apple (item1.EMAIL). Reconstruire la carte à
partir des colonnes aurait effacé la photo, l'anniversaire, l'adresse
postale et les X- propriétés du téléphone dès la première correction
d'une faute de frappe. Le test testAnEditKeepsEverythingPlMailDoesNotOwn
est là pour ça. N n'est réécrit que si le nom a changé : un N structuré
(préfixe, deuxième prénom) ne se reconstruit pas à partir de FN.

Transport : CardDavDriver réutilise CalDavClient, CalDavDiscovery (via
DavService, commit précédent) et MultiStatusParser. Il hérite donc tel
quel des contrôles SSRF, des redirections suivies à la main et de la
classification des erreurs. Les exceptions restent CalendarSync* parce
que leur classe décide des relances Messenger, et cette décision est la
même pour un serveur de contacts. Seule la formulation change :
CalDavClient::say() choisit « contacts server » pour une connexion
CardDAV, sinon l'utilisateur lirait « the calendar server rejected the
app password » sous un carnet d'adresses.

Synchro (AddressBookSync), quatre règles et leurs pièges :
- On pousse avant de tirer. Dans l'autre ordre, la copie plus ancienne
  du serveur écraserait une modification locale avant qu'elle parte.
- En conflit (412 sur un If-Match), le serveur gagne et le carnet est
  relu en entier. Fusionner deux vCards champ par champ est une
  fonctionnalité en soi ; une erreur là-dedans corrompt un carnet.
  Perdre une modification locale est journalisé avec le nom du contact.
- Seule une liste complète (jeton nul) supprime ce qu'elle ne mentionne
  pas. Une liste delta ne dit rien du reste, et une liste
  sync-collection tronquée au plafond de pages n'est jamais considérée
  complète. Comme pour CalDAV, un ctag qui a bougé abandonne le jeton
  plutôt que de faire passer un listing pour un delta.
- Les suppressions sont appliquées en dernier. Doctrine insère avant de
  supprimer : un serveur qui « déplace » une carte (même UID, nouvel
  href) ferait sauter la contrainte unique (carnet, uid) et échouer
  toute la synchro.

Planification : app:contacts:sync --stale tourne toutes les 15 min,
décalé à :11 pour ne pas s'empiler sur les synchros mail (:00) et
calendrier (:07), qui partagent le même worker. SyncAddressBookMessage
va sur ingest, comme la synchro calendrier. Une erreur permanente est
enregistrée sur le carnet puis absorbée, pour ne pas classer une
enveloppe morte toutes les 15 minutes. Le journal n'écrit l'erreur que
quand elle change.

Tests (sans base) : convertisseur vCard, driver contre un serveur
scripté (découverte, sync-collection, repli ctag, écritures
conditionnelles), et service de synchro avec un dépôt en mémoire. La
migration et le mapping sont vérifiés avec doctrine:schema:validate
--skip-sync ; les noms d'index et de clés suivent l'algorithme de DBAL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018nYWPc4bWacb79hm4ytfni
L'interface du commit précédent. Deux écrans, sur le modèle des
calendriers pour qu'on n'ait rien de nouveau à apprendre.

Réglages → Contacts : connecter un serveur CardDAV, puis cocher les
carnets à refléter. C'est le même parcours que pour CalDAV : adresse et
identifiants, puis la liste de ce que le serveur propose, dans la même
modale. Le formulaire est CalDavConnectType avec une option protocol :
mêmes questions, même ordre, souvent les mêmes réponses sur le même
serveur. Seules les clés de traduction changent. Le passage par
CalDavConnector reprend tel quel la règle « réutiliser le mot de passe
mail seulement si la case est cochée » et le test de connexion à
l'enregistrement, sans en écrire une deuxième copie. Seuls les ids
cochés sont lus dans le POST : le nom et le statut lecture seule d'un
carnet viennent d'une nouvelle découverte, comme pour les calendriers.

/contacts : liste, recherche, création, modification, suppression.
Une modification est enregistrée localement, marquée en attente, puis
mise en file pour le worker (ContactCardEditor). Personne n'attend le
serveur CardDAV, et un serveur en panne retarde l'envoi sans perdre la
modification. L'horloge sur une ligne indique qu'elle n'est pas encore
partie. Le formulaire n'a pas de champ pour la photo, l'anniversaire ou
l'adresse postale. Une note sous les champs précise que ces données
restent sur le serveur, pour qu'on ne croie pas les avoir perdues.

Pièges couverts :
- Un contact créé ici puis modifié avant le passage du worker reste un
  PendingCreate. Le passer en PendingUpdate enverrait un PUT avec
  If-Match sans etag vers un href qui n'existe pas encore.
- Un carnet que le serveur a rendu lecture seule refuse toute écriture
  (403), et le formulaire ne le propose pas à la création. L'éditeur
  refuse aussi, pour un POST forgé.
- Chaque route qui prend un id vérifie le propriétaire via le carnet :
  un id de carte est un numéro de séquence, il se devine.

Navigation : une entrée « Contacts » dans le menu utilisateur, pas dans
la barre latérale dont les lignes sont des vues de mail. La section est
ajoutée dans le groupe Mail des réglages et dans la liste du test e2e
des marges de cartes.

Tests : ContactCardEditorTest (sans base) ; ContactsFlowTest de bout en
bout avec un Nextcloud scripté sur un hôte .test. Il couvre la découverte
qui ne propose que les carnets, l'abonnement qui met la première synchro
en file, la recherche, l'édition qui garde le BDAY, les droits lecture
seule et l'isolation entre utilisateurs. Ce dernier test demande
Postgres et n'a pas tourné ici ; les gabarits ont été rendus à blanc
avec des entités en mémoire, et chaque clé de traduction utilisée a été
vérifiée dans le catalogue anglais.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018nYWPc4bWacb79hm4ytfni
Les carnets synchronisés servent d'abord à ça : proposer « Ada » dans le
champ À sans qu'on lui ait jamais écrit depuis plMail. Le composer web et
JMAP Contact/autocomplete lisent maintenant la même liste
(ContactSuggester). C'était déjà le principe quand ils partageaient
ContactRepository::findForAutocomplete : deux classements feraient
dépendre l'ordre des suggestions de l'appareil.

Classement : fréquence dans le courrier, puis présence dans un carnet,
puis récence, puis nom. Conséquences voulues :
- Sans carnet, l'ordre est exactement celui d'avant. Le test
  testWithNoAddressBookTheHarvestOrderStands l'épingle.
- Être dans un carnet ne fait que départager des égalités. Ça ne fait
  jamais passer quelqu'un devant une adresse à qui on écrit plus souvent.
- Un contact de carnet jamais écrit (fréquence 0) passe devant une
  adresse tapée une fois dans un brouillon, elle aussi à fréquence 0
  (createUnsent).
Une adresse présente des deux côtés donne une seule suggestion, avec le
nom du carnet : celui que la personne a écrit elle-même, plutôt que ce
que le client mail de l'expéditeur a mis dans l'en-tête.

Une correspondance sur le nom propose toutes les adresses de la carte
(taper « ada » propose le perso et le pro). Une correspondance sur une
adresse ne propose que celle-là.

Prendre les $limit premiers de chaque côté puis fusionner donne le bon
résultat, pas une approximation : chaque côté est déjà trié dans l'ordre
final, donc rien au-delà de sa limite ne pouvait entrer dans le top
fusionné.

JMAP : un champ inAddressBook s'ajoute à chaque suggestion. Il explique
pourquoi une suggestion a fréquence 0 et lastSeenAt nul.
CLIENT_DEVELOPMENT.md (en et de) ne dit plus que le carnet est
uniquement récolté depuis les en-têtes. La route web garde ses clés
JSON et ajoute aussi inAddressBook, que le JS ignore.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018nYWPc4bWacb79hm4ytfni
docs/providers/carddav.md, traduit en allemand comme toutes les autres
pages fournisseurs. Le guide renvoie à la page CalDAV pour ce qui est
identique (les quatre champs du formulaire, la découverte RFC 6764, les
adresses refusées par le garde SSRF) plutôt que de le recopier : deux
copies divergeraient à la première correction.

Ce qui est propre aux contacts y est écrit :
- la connexion est distincte de CalDAV, donc l'app password se saisit
  deux fois pour une même Nextcloud ;
- en conflit, le serveur gagne ;
- les champs sans équivalent dans plMail (photo, anniversaire, adresse
  postale) sont conservés ;
- la page liste 300 contacts au maximum ;
- décocher un carnet perd les modifications pas encore envoyées.

Le CHANGELOG signale la migration (deux tables nouvelles, rien
d'existant modifié) et le champ inAddressBook ajouté à JMAP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018nYWPc4bWacb79hm4ytfni
… de bloquer le carnet

CardDavDriver::pull() signale un jeton refusé de deux façons : le résultat
resyncRequired (ctag qui a bougé) et une CalendarResyncRequiredException,
levée par CalDavClient::classify() pour la précondition DAV
`valid-sync-token` (403, 409 ou 400 selon le serveur) et pour un 507 sur
le REPORT. AddressBookSync::pull() ne traitait que le premier cas.
L'exception remontait donc jusqu'à sync(), qui enregistrait l'échec sur le
carnet et la relançait — avec le jeton mort toujours stocké. Comme la
classe n'est pas permanente, Messenger réessayait, renvoyait le même jeton,
recevait le même refus, et ainsi de suite : un carnet dont le serveur a
expiré le jeton (Nextcloud le fait après un certain temps) ne se
synchronisait plus jamais, avec « The contacts server no longer recognises
our sync position… » affiché en permanence dans les réglages.

Le correctif reprend ce que fait CalendarSyncService::pullOnce() : la
pull avec jeton est enveloppée, l'exception devient
AddressBookChangeSet::resyncRequired(), et le chemin existant efface le
jeton puis relit tout. Seulement quand un jeton a été envoyé : sans jeton,
le serveur n'a aucune position à refuser, et l'échec est rapporté tel quel
plutôt que de boucler.

Le test rejoue exactement ce scénario (403 avec `valid-sync-token`, puis
listing complet) et échoue sans le correctif.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018nYWPc4bWacb79hm4ytfni
… d'une pull

apply() construit une fois l'index des cartes locales par href et par UID,
puis le consulte pour chaque carte distante. Or une carte adoptée par UID
change de href en cours de route, et l'index ne le savait pas.

Le cas qui le montre est le « déplacement » CardDAV : le serveur supprime
l'ancien href et recrée la carte sous un nouveau, avec le même UID. Si le
delta liste la recréation d'abord, la ligne locale est adoptée par UID et
prend le nouveau href ; puis la suppression de l'ancien href est lue,
byHref[ancien] pointe toujours vers cette même ligne, et elle est
condamnée. Résultat : le contact disparaît localement alors qu'il existe
sur le serveur, et ne revient qu'au prochain listing complet. Dans l'ordre
inverse (suppression puis recréation) tout allait bien, ce qui explique
que la logique de suppression différée semblait couvrir ce cas.

Les deux tables sont maintenant mises à jour à chaque adoption ou
création : l'ancien href est retiré de byHref, le nouveau y est inscrit,
et byUid est indexé par $local->uid (l'UID tel que stocké, tronqué à 255
caractères comme dans la base) plutôt que par la valeur lue dans la vCard.
Inscrire le nouveau href règle aussi le cas d'un href créé puis supprimé
dans la même fenêtre de sync-collection paginée, qui restait sinon
localement jusqu'au listing complet suivant.

Le test joue le déplacement dans les deux ordres ; l'ordre « suppression
en second » échoue sans le correctif.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018nYWPc4bWacb79hm4ytfni
Trois défauts dans push(), tous du côté « qu'est-ce qui arrive à
l'édition locale quand le serveur dit non ».

1. Toute CalendarResyncRequiredException était traitée comme un conflit
   412, donc l'édition locale était jetée. Mais classify() range aussi le
   507 dans cette classe — sur un PUT, c'est le quota du serveur plein,
   pas une modification concurrente. Un utilisateur dont le Nextcloud est
   plein perdait son édition en silence. Seul le 412 laisse maintenant
   gagner le serveur ; le reste remonte, la carte reste en attente et le
   retry de Messenger la reproposera, comme CalendarPusher le fait pour
   les événements.

2. Un 403 sur le PUT ou le DELETE d'une carte faisait échouer toute la
   synchronisation en permanent, et la carte restait en attente : le run
   suivant renvoyait le même PUT, recevait le même 403, et la pull qui
   suit ne s'exécutait plus jamais. Le cas typique est un carnet dont le
   serveur ne publie pas current-user-privilege-set : CardDavDriver le
   suppose alors modifiable (le commentaire de isReadOnly() dit bien que
   cela coûte « au pire un 403 au premier push »), et une seule édition
   suffisait à geler tout le carnet. Le 403 est désormais traité par
   carte, comme CalendarPusher::abandon() : journal en error avec la vCard
   abandonnée, carte remise à Clean, relecture complète. Les autres
   échecs permanents (401, adresse refusée par la garde SSRF, identifiants
   manquants) concernent la connexion et non la carte : ils remontent et
   l'édition est gardée pour quand ce sera réparé.

3. Le test « carnet devenu lecture seule » ne venait qu'après la branche
   PendingDelete, donc une suppression dans un tel carnet partait quand
   même en DELETE, recevait un 403 et retombait dans le point 2. Le test
   est remonté en tête de boucle et couvre les deux opérations.

Chaque point a son test, qui échoue sans le correctif ; un quatrième
vérifie qu'un 401 garde bien l'édition en attente.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018nYWPc4bWacb79hm4ytfni
L'autocomplétion demandait au dépôt les $limit premières cartes
correspondant à la saisie, triées par nom, puis n'en gardait que les
adresses e-mail. Une carte sans adresse consommait donc une place du
LIMIT sans rien proposer. Or un carnet synchronisé depuis un téléphone est
rempli de cartes qui n'ont qu'un numéro : taper « ma » dans un carnet où
les huit premières « Ma… » par ordre alphabétique n'ont pas d'e-mail ne
proposait aucun contact du carnet, même si « Martin » avec une adresse
venait juste après.

Le filtre est fait en SQL, avant le LIMIT, par une nouvelle méthode
ContactCardRepository::findForAutocomplete(). Il teste searchText LIKE
'%@%' : searchText contient toutes les adresses de la carte (voir
ContactCard::fill()), donc une carte avec une adresse contient un @.
C'est le seul test possible sur une colonne existante sans opérateur jsonb
(absent du DQL) ni nouvelle colonne et migration. Une carte sans adresse
dont le nom contiendrait un @ passerait le filtre et ne proposerait rien,
comme avant : pas de régression possible. Le SQL généré a été vérifié
(getSQL() sur la QueryBuilder), pas exécuté — pas de Postgres ici.

La requête commune est extraite dans visibleTo(), et le paramètre $book
de search(), que rien n'appelait, disparaît au passage.

Le docblock affirmait aussi que fusionner le top $limit de chaque côté
était « exact ». C'est vrai pour le côté récolté, pas pour les cartes,
qui reviennent par ordre de nom : une carte au-delà des $limit premières
peut être manquée même si la récolte connaît son adresse, quand la
recherche de la récolte ne l'a pas trouvée sur ce texte. Le docblock dit
maintenant ce qui est vrai.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018nYWPc4bWacb79hm4ytfni
…g avec le pilote CalDAV

CardDavDriver recopiait mot pour mot deux morceaux de
CalDavCalendarDriver, avec un « See CalDavCalendarDriver::… » en guise de
docblock : la liste WRITE_PRIVILEGES et isReadOnly(), et
etagAfterWrite() (le GET de rattrapage quand un PUT ne renvoie pas
d'ETag). Les deux ne sont pas des détails de pilote :

- isReadOnly() est une politique (« un privilege-set absent vaut
  modifiable ») que le commentaire de AddressBookSync invoque désormais
  pour justifier l'abandon d'une carte sur 403. Si les deux copies
  divergeaient, un calendrier et un carnet du même serveur se
  contrediraient sur la même question. Elle devient
  DavResource::isReadOnly(), puisqu'elle ne lit que la ressource.
- etagAfterWrite() est la même requête avec la même tolérance aux 404/410
  et le même rattrapage d'erreur. Elle devient
  CalDavClient::etagAfterWrite(), à côté de request(). Le client reçoit
  pour cela un LoggerInterface, optionnel (NullLogger par défaut) pour que
  les deux fixtures de test qui le construisent à la main n'aient pas à
  changer ; l'autowiring fournit le vrai. La ligne de log porte le
  provider, puisqu'elle ne dit plus « CardDav: » ou « CalDav: » d'elle-même.

Aucun changement de comportement : les suites CalDAV et AddressBook
(129 tests) passent sans modification, lint:container et PHPStan aussi.
isTruncated() (huit lignes) et probeCollection() (qui diffère par le corps
PROPFIND et le message d'erreur) restent dupliqués : les factoriser
coûterait plus de paramètres que de lignes économisées.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018nYWPc4bWacb79hm4ytfni
Le guide ne décrivait qu'un seul refus, le conflit 412 (« la version du
serveur gagne »). Depuis 97848c5, AddressBookSync distingue trois cas
et l'utilisateur doit pouvoir savoir lequel s'applique quand une édition
ne passe pas :

- 403 sur une carte : abandonnée comme un conflit, journalisée, et le
  reste du carnet continue de se synchroniser ;
- 507 (serveur plein), 401, serveur injoignable : l'édition reste en
  attente et repart à une synchronisation suivante.

Paragraphe ajouté dans la section Sync, traduit dans docs/de (tutoiement
inutile ici, la phrase ne s'adresse à personne) ; le marqueur sha1 de la
traduction est mis à jour. tests/Documentation et
bin/mirror-wiki.php --check passent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018nYWPc4bWacb79hm4ytfni
…neur

setUp() récupérait UserRepository puis seulement ensuite remplaçait
app.http_client.integration par le MockHttpClient. Le conteneur de test
refuse de remplacer un service déjà instancié (« already initialized, you
cannot replace it »), et récupérer le dépôt construit l'entity manager et
ce qu'il tire avec lui. Rien aujourd'hui ne dépend du client HTTP sur ce
chemin, donc le test passe probablement tel quel — mais c'est un ordre qui
casse le jour où un listener Doctrine ou un service eager en dépendra, et
alors avec une erreur sans rapport avec les contacts.

IcsControllerTest::signIn() documente déjà la règle (« First, before the
container has had a reason to build the real one ») ; ce test la suit
maintenant aussi. Non exécuté ici (pas de Postgres).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018nYWPc4bWacb79hm4ytfni
…sting complet

pullBySyncCollection() sortait de la boucle par la même branche dans trois
cas : réponse non tronquée, réponse tronquée sans nouveau jeton, réponse
tronquée avec le même jeton. Et cette branche marquait le résultat
« listing complet » dès que la pull était partie d'un jeton nul. Dans les
deux derniers cas, ce qui est arrivé n'est qu'une page.

Les conséquences : AddressBookSync supprime localement toute carte qu'un
listing complet ne mentionne pas. Après une resynchronisation (jeton mort,
ctag qui a bougé, conflit 412 — tous repartent d'un jeton nul), un serveur
qui tronque sans donner de quoi continuer faisait disparaître du miroir
toutes les cartes au-delà de la première page. Et comme le jeton stocké
restait nul, la pull suivante redemandait tout, recevait la même première
page, et supprimait de nouveau : les cartes ne revenaient jamais. La sortie
par plafond de pages (MAX_SYNC_PAGES) gérait déjà ce cas correctement
(isFullListing à false, avec un commentaire qui le dit) ; seule cette
sortie-là l'oubliait.

La sortie « tronqué sans suite » est maintenant séparée, journalisée en
warning, et renvoie isFullListing à false. Aucune suppression n'a lieu sur
une réponse partielle ; les cartes présentes dans la page sont appliquées
normalement.

Deux tests : la pagination suivie jusqu'au bout reste un listing complet
(cas nominal, qui n'était pas couvert), et une page tronquée sans jeton
n'en est pas un — ce second test échoue sans le correctif.

CalDavCalendarDriver a la même forme de sortie, mais CalendarChangeSet
n'a pas de drapeau isFullListing (le moteur calendrier déduit le
caractère complet du jeton envoyé) : le corriger touche le moteur
calendrier et sort du périmètre de cette branche. Signalé dans la
description de la PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018nYWPc4bWacb79hm4ytfni
$addressBook était la seule relation d'identité des deux nouvelles
entités à rester `public` en écriture : AddressBook::$usr et
::$integration sont `public private(set)`, comme le reste du modèle.
Rien ne l'assigne hors du constructeur, et rien ne doit le faire :
changer le carnet d'une carte ne la déplacerait pas sur le serveur (son
href pointe toujours dans l'ancienne collection), casserait la clé unique
(address_book_id, uid) sans que rien ne la vérifie avant le flush, et le
formulaire dit explicitement que le déplacement n'est pas proposé.

private(set), avec un docblock qui renvoie à cette raison. PHPStan,
doctrine:schema:validate et la suite AddressBook passent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018nYWPc4bWacb79hm4ytfni
Le catalogue français est arrivé sur main après le départ de cette
branche ; rebasée, elle ajoutait 69 clés à messages.en.yaml et 4 à
validators.en.yaml sans équivalent français, et CatalogueParityTest
(qui vérifie de, fr et en_PI) échouait sur fr.

Vocabulaire repris des clés CalDAV déjà traduites pour que les deux
écrans se lisent pareil : « reflété » pour mirrored, « mot de passe
d'application », guillemets « » et apostrophe typographique. Les textes
du formulaire CardDAV (réutilisation d'un mot de passe de compte mail,
aide sur l'adresse) suivent mot pour mot leurs jumeaux CalDAV, à
« carnets d'adresses » près.

Vérifié : plus aucune clé de en absente de fr dans messages, validators
et security ; les deux fichiers se chargent en YAML. PHPUnit n'a pas
tourné ici (pas de Docker sur cette machine).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BP7Zev5qvDRzKbcmecvRGW
@jo2904
jo2904 force-pushed the feat/carddav-contacts branch from d04cb5e to 7d3ec7e Compare October 15, 2026 22:00
@pmdevelopment
pmdevelopment changed the base branch from main to milestone/v0.4.0 October 16, 2026 10:56
@pmdevelopment pmdevelopment mentioned this pull request Oct 16, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: calendar Concerns calendar area: install Concerns install area: mail Concerns mail area: settings Concerns settings documentation Improvements or additions to documentation enhancement New feature or a change to how something works

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Mirror CardDAV address books and suggest contacts while composing

2 participants