diff --git a/hermes-tt/mail-o365-bug-D-deplier-nest-pas-selectionner.md b/hermes-tt/mail-o365-bug-D-deplier-nest-pas-selectionner.md new file mode 100644 index 0000000..5330d0a --- /dev/null +++ b/hermes-tt/mail-o365-bug-D-deplier-nest-pas-selectionner.md @@ -0,0 +1,176 @@ +# Bug D — « déplier » n'est pas « sélectionner » : `/search?folder_path=` renvoyait la Boîte de réception en HTTP 200 + +**Date** : 10/08/2026 (soir) +**Instance** : hermes-tt +**Statut** : **CAUSE TROUVÉE, CORRIGÉE ET VALIDÉE** · bug C corrigé dans le même passage · 46 notes +polluées purgées · **bug E découvert par les tests et bloqué** (recherche élargie par OWA) +**Fichiers** : `/volume1/docker/hermes-mail-browser/app/main.py` — `go_to_folder_path` (D, C) · +`/volume1/docker/nyora-notes-tt/hermes_mail_client.py` — `_verifier_portee` (E) +**Sauvegardes** : `main.py.bak-20260810-212013-pre-fix-DC` · +`hermes_mail_client.py.bak-20260810-*-pre-fix-E` + +Suite directe de [`nyora-notes-tt-parsing-owa-pua-10-08-2026.md`](nyora-notes-tt-parsing-owa-pua-10-08-2026.md) +(bugs A/B corrigés, D mitigé par `HermesFolderScopeError`, C diagnostiqué). Ce runbook ne traite +que la **cause de fond** de D et le correctif de C. La désambiguïsation des chemins reste décrite +dans [`mail-o365-folder-path-disambiguation.md`](mail-o365-folder-path-disambiguation.md), et +l'anti-toggle du 02/08 dans +[`mail-o365-toggle-expand-non-idempotent-fix.md`](mail-o365-toggle-expand-non-idempotent-fix.md). + +--- + +## Symptôme + +`/search?folder_path=X` renvoyait le contenu de la **Boîte de réception** pour certains chemins, +en **HTTP 200, sans erreur**. Déterministe, lié au chemin, indépendant des emojis : + +| Chemin | Résultat | +|---|---| +| `00 2026\|⏱️ suivi` | 81 items correctement scopés ✅ | +| `00 2026\|Relance marchés RLA` | 76 items tous libellés « Boîte de réception » ❌ | +| re-test du premier | à nouveau correct | + +## Cause + +**Sur cette UI, déplier un dossier et le sélectionner sont deux gestes distincts — et le code les +confondait.** La portée d'une recherche OWA est le **dossier courant** ; le dernier segment du +chemin doit donc être *sélectionné*, pas seulement *déplié*. + +L'ancienne boucle de `go_to_folder_path` ne cliquait la **ligne** que par défaut, quand aucun +bouton d'expansion n'était trouvé : + +```python +if match["expanded"] != "true": # (1) si déjà déplié : aucun clic du tout + expand_btn = row.locator("button").first + if await expand_btn.count() > 0: + await expand_btn.evaluate("el => el.click()") # (2) chevron : déplie, ne sélectionne pas + else: + await row.evaluate("el => el.click()") # seul chemin qui sélectionne +``` + +D'où le comportement, **entièrement déterminé par la présence d'enfants** : + +- **dossier feuille** → pas de chevron → repli sur le clic de la ligne → **sélection réelle → OK** ; +- **dossier avec enfants** → soit seul le chevron est cliqué (2), soit — s'il était déjà + `aria-expanded="true"`, l'état d'expansion persistant d'un appel à l'autre — **aucun clic** (1). + Le dossier n'est jamais sélectionné. `goto_owa_root()` ayant rechargé la racine, le dossier + courant reste la Boîte de réception, et la recherche s'y exécute. + +Le branchement (1) est le fix anti-toggle du 02/08 : correct pour l'expansion, mais il a laissé la +**sélection sans aucun chemin de code**. Les deux cas discriminants s'expliquent exactement : +`⏱️ suivi` est une feuille, `🔁 relance marchés rla` un conteneur déplié (2 enfants). + +Portée mesurée sur l'arbre réel : **63 dossiers sur 336** ont des enfants — tous inatteignables. + +## Correctif + +Séparer les deux responsabilités dans la boucle : + +- **segments intermédiaires** → seule l'expansion compte (comportement du 02/08 inchangé, pour que + les enfants soient montés dans le DOM) ; +- **dernier segment** → clic **toujours sur la ligne**, jamais sur le chevron, puis **confirmation + de la sélection via `aria-selected`**, avec 3 tentatives et attente croissante. Sans + confirmation → `HTTPException(502)` nommant le dossier demandé *et* le dossier réellement + sélectionné. + +`snapshot_folders()` lisait déjà `aria-selected` et l'exposait en `selected` : la machinerie de +vérification existait, elle n'était simplement pas utilisée. + +## Bug C — dossiers hors Réception (même passage) + +`cur = inbox_candidates[0]` ancrait **tout** chemin sous la Boîte de réception, rendant ses frères +de niveau 2 (`éléments envoyés`, `archive`, `brouillons`…) inatteignables. Le **premier segment** +est désormais cherché d'abord sous la Boîte de réception, puis — à défaut seulement — parmi les +dossiers de niveau 2 du compte. Cet ordre garantit que les chemins existants (`00 2026`, +`01 2025`, `02 2024`…) gardent exactement leur résolution : aucune régression possible. + +## Pollution induite, et sa purge + +La dédup `message_id` est **globale** (`SELECT id FROM notes WHERE message_id = ?`, sans filtre de +dossier). Les messages de la Réception ingérés sous un `folder_path` arbitraire y occupaient donc +l'identifiant du vrai message, ce qui **aurait empêché leur ré-ingestion correcte** au backfill. +La purge était un prérequis, pas un nettoyage cosmétique. + +Détection : dernière ligne de `raw_excerpt` (= libellé du dossier réel, signal déjà utilisé par +`HermesFolderScopeError`) contredisant le `folder_path` assigné. + +**46 notes sur 399 (11,5 %)**, réparties sur **10 dossiers, tous des conteneurs** — signature +exacte de la cause : + +| Notes | Dossier | +|---|---| +| 18 | `00 2026\|Consultations` | +| 6 | `02 2024\|RLA` | +| 4 | `02 2024\|RLA\|E. Partielles` | +| 3 | `00 2026\|DR Zone Sud`, `01 2025\|Consommables 2025`, `02 2024\|Inventaire 2023`, `02 2024\|RLA\|AO 76/2024 E FO` | +| 2 | `00 2026\|Automated`, `02 2024\|DR Zone Sud`, `02 2024\|RLA\|AO 66/2024 E Curatif` | + +Contenus manifestement étrangers à leur dossier : notifications de release logicielle sous +`Inventaire 2023`, veille infra sous `RLA`, newsletter mode sous `Consultations`. + +Procédure (script `backups/purge_bugD.py`, dry-run par défaut, `--apply` pour exécuter) : +sauvegarde `VACUUM INTO` → re-mesure de la liste (jamais figée) → suppression `notes_vec` +(lié par `rowid`), `note_links`, `notes` → **rebuild de `notes_fts`** → checkpoints des 10 dossiers +remis à `pending` avec `last_processed_*` à NULL. + +Résultat : 399 → **353 notes, 353 vecteurs, 0 incohérence résiduelle**. + +### Deux pièges rencontrés pendant la purge + +- `no such module: vec0` — `notes_vec` est une table virtuelle sqlite-vec : passer par + `setup_db.get_db_connection()`, pas par `sqlite3.connect()` brut. +- `database is locked` — le container garde une connexion ouverte. Faire `docker stop nyora-notes-tt`, + exécuter dans un container éphémère (`docker run --rm` sur la même image, mêmes montages), puis + `docker start`. Le code étant cuit dans l'image, le script se dépose dans `backups/` (monté rw). + +## Validation + +Batterie de 9 appels après rebuild (`--no-cache` + `--force-recreate`), garde-fou actif : + +| Cas | Avant | Après | +|---|---|---| +| `00 2026\|suivi` (feuille, référence) | 81 items scopés | **81/81** — aucune régression | +| `00 2026\|Relance marchés RLA` (conteneur déplié) | 76 items « Boîte de réception » | **19/19** correctement scopés | +| `02 2024\|RLA` (conteneur profond) | — | **27/27** | +| `…\|AO 66/2024 E Curatif\|Notification` (homonyme) | — | **8/8** | +| `Éléments envoyés` (bug C, frère L2) | inatteignable | **40/40** | +| 3 appels consécutifs sur le même conteneur | alternance succès/échec (02/08) | stable, aucune alternance | + +**Piège de méthode** : une première série de tests a tourné en double (un job `nohup` chaîné +derrière le build avait survécu à sa session SSH). Les deux séries tapant le même navigateur et +`@serialize_owa` sérialisant les appels côté serveur, leurs résultats s'entrelaçaient — un test +renvoyait la portée du test précédent. Toujours lancer ces batteries sous `flock`, et se méfier +d'un résultat qui « pointe vers le dossier d'à côté ». + +## Bug E — OWA élargit la recherche sur un dossier sans message direct + +Découvert par ces mêmes tests. `00 2026|Consultations` renvoyait 40 items dont le libellé +majoritaire était `Archive` à **10/40** — un mélange, **stable sur 3 appels**. Même profil sur +`01 2025|Cons° animation commerciale`. + +Ce n'est **pas** un reliquat du bug D, et les deux effets ont été isolés : `/inbox` sur ces mêmes +dossiers répond « liste des messages introuvable » — c'est-à-dire **dossier vide** — au lieu du +contenu de la Boîte de réception. La navigation est donc correcte ; c'est la **recherche** qui +s'élargit. Quand un dossier ne contient aucun message *direct* (ses messages sont dans ses +sous-dossiers), OWA étend de lui-même la requête à toute la boîte aux lettres, en HTTP 200. + +Correctif côté client (`_verifier_portee`) : **politique inversée**. L'hétérogénéité était traitée +comme une indétermination et laissait passer l'ingestion ; elle est désormais un motif de blocage +(`HermesSearchScopeWidenedError`). Justification empirique : une recherche réellement scopée est +uniforme **même sur un dossier à enfants** (`02 2024|RLA` → 27/27, `Relance marchés RLA` → 19/19). +L'uniformité est la norme d'une portée correcte ; son absence est une preuve suffisante. + +Sans ce durcissement, le backfill aurait classé des messages de toute la boîte sous ces +`folder_path` — le mode de corruption exact que la purge ci-dessus venait de réparer. + +## Leçon généralisable + +> Sur une UI React/Fluent, **le succès d'un clic ne prouve pas son effet**. Un chevron d'expansion +> et la ligne qui le porte sont deux cibles distinctes aux effets distincts. Après toute +> navigation dont dépend la suite, confirmer l'état atteint par un **attribut structuré** +> (`aria-selected`) et échouer explicitement sinon — un 200 qui répond sur le mauvais périmètre +> coûte infiniment plus cher qu'une erreur franche. + +Corollaire tiré du bug E : quand un garde-fou doit trancher sur des données bruitées, **le cas +« je ne peux pas conclure » mérite d'être mesuré avant d'être traité comme inoffensif**. Ici il +recouvrait un second mode de corruption, et la politique permissive choisie par prudence était +précisément ce qui l'aurait laissé passer.