feat(mail) : intégration mail OVH IMAP — boîte partagée, lecture, création/lien tâche #5

Merged
matthieu merged 70 commits from feat/mail-integration into develop 2026-05-20 07:45:33 +00:00
Owner

Summary

Intégration complète d'un client mail OVH IMAP dans Lesstime : boîte mail partagée unique avec lecture inbox/dossiers, création de tâche depuis un mail, et lien mail ↔ tâche existante. Livrée en 7 phases TDD distinctes.

Spec source : docs/superpowers/specs/2026-05-19-mail-integration-design.md
Master plan : docs/superpowers/plans/2026-05-19-mail-integration-master-plan.md

Découpage en phases

Phase Scope Commits
1 Foundations BDD — 4 entités (MailConfiguration singleton, MailFolder, MailMessage, TaskMailLink), 4 repos, migration Doctrine, DTOs, interface MailProviderInterface, exception, fixture OVH 8
2 IMAP provider + sync — webklex/php-imap 6.2, symfony/lock, ImapMailProvider, MailSyncService (syncAll/syncFolder/syncFolderStructure avec garde 50% suppressions), commande `app:mail:sync`, cible Makefile `make mail-sync`, doc cron 8
3 API backend — 14 endpoints `/api/mail/*` (config singleton admin, folders, messages cursor-paginated, fetch live avec cache Symfony 5min, read/flag, create-task/link-task, attachments stream, sync async via Messenger), `MailAccessChecker` (refus explicite ROLE_CLIENT), `symfony/messenger` + `doctrine-messenger` installés 14
4 Frontend services — `dompurify`, `services/dto/mail.ts`, `services/mail.ts` (14 méthodes), `stores/mail.ts` (Pinia + polling 30s + auto-mark-as-read), `utils/sanitizeMailHtml.ts` (DOMPurify + placeholder images distantes) 5
5 UI principale `/mail` — layout 3 colonnes (dossiers/liste/lecteur), `MailFolderTree` (arbre récursif + badges), `MailMessageList` (infinite scroll), `MailMessageViewer` (body sanitizé + toggle images + PJ + 4 actions), `MailRefreshButton`, `useSystemFolderLabel`, i18n `mail.*`, refus ROLE_CLIENT 7
6 Intégration tâches — `MailCreateTaskModal` (pré-rempli subject+body), `MailLinkTaskModal` (autocomplete), `MailPickerModal`, onglet "Mails" dans `TaskModal` (caché ROLE_CLIENT), handlers branchés dans `/mail` 6
7 Admin + sidebar + polish — `AdminMailTab` (form IMAP/SMTP/credentials + test connexion), onglet dans `pages/admin.vue`, lien sidebar avec badge unread temps réel, polling lifecycle (start au login / stop au logout), i18n complémentaire, `docs/mail-integration.md`, enrichissement `docs/mail-cron-setup.md` + README 5

Total : ~61 commits dont 7 docs (master plan + 7 plans détaillés Phase 1→7) et 7 documents de plan distincts.

Sécurité

  • ROLE_CLIENT bloqué sur toute la stack (security.yaml + `MailAccessChecker` côté backend, middleware + check explicite côté frontend)
  • Password jamais leak : GET config renvoie `hasPassword: bool` uniquement, PATCH chiffre via `TokenEncryptor` existant
  • DOMPurify côté frontend : FORBID scripts/iframes/objects/embeds/on*/javascript:, images distantes remplacées par placeholder cliquable (anti-tracking)
  • Pièces jointes : `Content-Disposition: attachment` forcé, jamais d'inline preview
  • Logs : aucun body / password / contenu PJ loggé (vérifié par grep en Phase 7)

Stack ajoutée

Backend (composer) :

  • `webklex/php-imap ^6.2` (5.x bloquait sur conflit `carbon ^2` vs `symfony/translation ^8`)
  • `symfony/lock ^8.0`
  • `symfony/messenger ^8.0`
  • `symfony/doctrine-messenger ^8.0`
  • `symfony/browser-kit ^8.0` (dev — pour WebTestCase)
  • `symfony/css-selector ^8.0` (dev)

⚠️ La 6.2 de `webklex/php-imap` co-install `illuminate/*` (Laravel) via `carbon ^3`. Pas bloquant mais ajoute des deps Laravel au projet Symfony — à valider.

Frontend (npm) :

  • `dompurify ^3.4`
  • `@types/dompurify ^3.0` (dev)

Validation

  • `make test` : 33 tests, 61 assertions, 0 failure (6 PHPUnit Notices pré-existantes Phase 2, non-bloquantes)
  • `make php-cs-fixer-allow-risky` : idempotent
  • `cd frontend && npx tsc --noEmit` : 0 erreur
  • `make dev-nuxt` : démarre clean, page `/mail` compile sans erreur Vite
  • `bin/console doctrine:schema:validate` : OK sur les tables mail (les divergences pré-existantes hors-scope subsistent)
  • `bin/console app:mail:sync --dry-run` : sort proprement (config désactivée → exit 0)

Migrations / cron à appliquer post-merge

  1. `make migration-migrate` (ajoute 4 tables mail + table `messenger_messages`)
  2. `make fixtures` charge une `MailConfiguration` OVH désactivée (faciliter setup dev)
  3. Admin configure via UI `/admin` onglet "Mail" → test connexion → `enabled = true`
  4. Crontab OS : `*/10 * * * * cd /path && make mail-sync` (cf `docs/mail-cron-setup.md`)

Test plan

  • Migration applique sans erreur sur une copie de la prod
  • Admin ROLE_ADMIN configure la boîte OVH réelle + test connexion OK
  • User ROLE_USER accède à `/mail`, voit l'arbre, ouvre un mail, body sanitizé sans XSS
  • Images distantes masquées par défaut, toggle "afficher" fonctionne
  • Pièces jointes téléchargent en attachment (pas d'inline)
  • Workflow "Créer tâche depuis mail" → tâche créée + apparaît dans onglet "Mails" du TaskModal
  • Workflow "Lier mail à tâche existante" → apparaît dans onglet
  • Badge sidebar reflète le total unread, refresh 30s
  • ROLE_CLIENT : redirect /portal sur `/mail`, 403 sur endpoints, onglet "Mails" caché dans TaskModal
  • `make mail-sync` (cron) tourne sans erreur, lock empêche overlap
  • Migration roll-back propre si besoin (`migration:migrate prev`)

🤖 Generated with Claude Code

## Summary Intégration complète d'un client mail OVH IMAP dans Lesstime : boîte mail partagée unique avec lecture inbox/dossiers, création de tâche depuis un mail, et lien mail ↔ tâche existante. Livrée en 7 phases TDD distinctes. **Spec source :** `docs/superpowers/specs/2026-05-19-mail-integration-design.md` **Master plan :** `docs/superpowers/plans/2026-05-19-mail-integration-master-plan.md` ## Découpage en phases | Phase | Scope | Commits | |---|---|---| | **1** | Foundations BDD — 4 entités (MailConfiguration singleton, MailFolder, MailMessage, TaskMailLink), 4 repos, migration Doctrine, DTOs, interface `MailProviderInterface`, exception, fixture OVH | 8 | | **2** | IMAP provider + sync — `webklex/php-imap` 6.2, `symfony/lock`, `ImapMailProvider`, `MailSyncService` (syncAll/syncFolder/syncFolderStructure avec garde 50% suppressions), commande \`app:mail:sync\`, cible Makefile \`make mail-sync\`, doc cron | 8 | | **3** | API backend — 14 endpoints \`/api/mail/*\` (config singleton admin, folders, messages cursor-paginated, fetch live avec cache Symfony 5min, read/flag, create-task/link-task, attachments stream, sync async via Messenger), \`MailAccessChecker\` (refus explicite ROLE_CLIENT), \`symfony/messenger\` + \`doctrine-messenger\` installés | 14 | | **4** | Frontend services — \`dompurify\`, \`services/dto/mail.ts\`, \`services/mail.ts\` (14 méthodes), \`stores/mail.ts\` (Pinia + polling 30s + auto-mark-as-read), \`utils/sanitizeMailHtml.ts\` (DOMPurify + placeholder images distantes) | 5 | | **5** | UI principale \`/mail\` — layout 3 colonnes (dossiers/liste/lecteur), \`MailFolderTree\` (arbre récursif + badges), \`MailMessageList\` (infinite scroll), \`MailMessageViewer\` (body sanitizé + toggle images + PJ + 4 actions), \`MailRefreshButton\`, \`useSystemFolderLabel\`, i18n \`mail.*\`, refus ROLE_CLIENT | 7 | | **6** | Intégration tâches — \`MailCreateTaskModal\` (pré-rempli subject+body), \`MailLinkTaskModal\` (autocomplete), \`MailPickerModal\`, onglet "Mails" dans \`TaskModal\` (caché ROLE_CLIENT), handlers branchés dans \`/mail\` | 6 | | **7** | Admin + sidebar + polish — \`AdminMailTab\` (form IMAP/SMTP/credentials + test connexion), onglet dans \`pages/admin.vue\`, lien sidebar avec badge unread temps réel, polling lifecycle (start au login / stop au logout), i18n complémentaire, \`docs/mail-integration.md\`, enrichissement \`docs/mail-cron-setup.md\` + README | 5 | **Total : ~61 commits** dont 7 docs (master plan + 7 plans détaillés Phase 1→7) et 7 documents de plan distincts. ## Sécurité - **ROLE_CLIENT bloqué** sur toute la stack (security.yaml + \`MailAccessChecker\` côté backend, middleware + check explicite côté frontend) - **Password jamais leak** : GET config renvoie \`hasPassword: bool\` uniquement, PATCH chiffre via \`TokenEncryptor\` existant - **DOMPurify** côté frontend : FORBID scripts/iframes/objects/embeds/on*/javascript:, images distantes remplacées par placeholder cliquable (anti-tracking) - **Pièces jointes** : \`Content-Disposition: attachment\` forcé, jamais d'inline preview - **Logs** : aucun body / password / contenu PJ loggé (vérifié par grep en Phase 7) ## Stack ajoutée **Backend (composer) :** - \`webklex/php-imap ^6.2\` (5.x bloquait sur conflit \`carbon ^2\` vs \`symfony/translation ^8\`) - \`symfony/lock ^8.0\` - \`symfony/messenger ^8.0\` - \`symfony/doctrine-messenger ^8.0\` - \`symfony/browser-kit ^8.0\` (dev — pour WebTestCase) - \`symfony/css-selector ^8.0\` (dev) ⚠️ La 6.2 de \`webklex/php-imap\` co-install \`illuminate/*\` (Laravel) via \`carbon ^3\`. Pas bloquant mais ajoute des deps Laravel au projet Symfony — à valider. **Frontend (npm) :** - \`dompurify ^3.4\` - \`@types/dompurify ^3.0\` (dev) ## Validation - \`make test\` : **33 tests, 61 assertions, 0 failure** (6 PHPUnit Notices pré-existantes Phase 2, non-bloquantes) - \`make php-cs-fixer-allow-risky\` : idempotent - \`cd frontend && npx tsc --noEmit\` : 0 erreur - \`make dev-nuxt\` : démarre clean, page \`/mail\` compile sans erreur Vite - \`bin/console doctrine:schema:validate\` : OK sur les tables mail (les divergences pré-existantes hors-scope subsistent) - \`bin/console app:mail:sync --dry-run\` : sort proprement (config désactivée → exit 0) ## Migrations / cron à appliquer post-merge 1. \`make migration-migrate\` (ajoute 4 tables mail + table \`messenger_messages\`) 2. \`make fixtures\` charge une \`MailConfiguration\` OVH désactivée (faciliter setup dev) 3. Admin configure via UI \`/admin\` onglet "Mail" → test connexion → \`enabled = true\` 4. Crontab OS : \`*/10 * * * * cd /path && make mail-sync\` (cf \`docs/mail-cron-setup.md\`) ## Test plan - [ ] Migration applique sans erreur sur une copie de la prod - [ ] Admin ROLE_ADMIN configure la boîte OVH réelle + test connexion OK - [ ] User ROLE_USER accède à \`/mail\`, voit l'arbre, ouvre un mail, body sanitizé sans XSS - [ ] Images distantes masquées par défaut, toggle "afficher" fonctionne - [ ] Pièces jointes téléchargent en attachment (pas d'inline) - [ ] Workflow "Créer tâche depuis mail" → tâche créée + apparaît dans onglet "Mails" du TaskModal - [ ] Workflow "Lier mail à tâche existante" → apparaît dans onglet - [ ] Badge sidebar reflète le total unread, refresh 30s - [ ] ROLE_CLIENT : redirect /portal sur \`/mail\`, 403 sur endpoints, onglet "Mails" caché dans TaskModal - [ ] \`make mail-sync\` (cron) tourne sans erreur, lock empêche overlap - [ ] Migration roll-back propre si besoin (\`migration:migrate prev\`) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
matthieu added 61 commits 2026-05-19 23:04:14 +00:00
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- ajoute symfony/messenger ^8.0 et symfony/doctrine-messenger ^8.0 pour la sync mail async
- ajoute symfony/browser-kit + css-selector en dev pour tests fonctionnels WebTestCase
- ENCRYPTION_KEY ajoutee dans phpunit.dist.xml pour permettre le chiffrement en test
- MESSENGER_TRANSPORT_DSN configure (Doctrine), messenger.yaml minimal (sera enrichi en Task 12)
- fix(orm) : ClientTicket - migre uniqueConstraints en attribut separe (Doctrine ORM 4 deprecation)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- ApiResource MailSettings expose les operations Get + Patch sur /api/mail/configuration
- Provider + Processor relient le DTO a l'entite MailConfiguration (singleton)
- password en write-only (jamais retourne) + hasPassword en lecture
- chiffrement password via TokenEncryptor (sodium)
- securite ROLE_ADMIN sur les deux operations

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- endpoint ROLE_ADMIN qui teste la connexion IMAP via listFolders
- retourne ok:bool + foldersCount ou error sanitise (pas de leak interne)
- priority:1 obligatoire pour eviter conflit avec route API Platform {id}

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- ensureCanAccessMail : refuse ROLE_CLIENT pur (sans ROLE_ADMIN)
- ensureIsAdmin : helper pour endpoints config
- service utilise par tous les controllers metier mail

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- lit la BDD (pas l'IMAP live), retourne l'arbre des dossiers avec metadata
- securite via MailAccessChecker : ROLE_USER/ADMIN, refus ROLE_CLIENT pur
- tests fonctionnels 401/403/200

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- MailMessageRepository::findByFolderCursor : pagination cursor sentAt DESC, id DESC
- cursor base64url(sentAt_iso:id), limit max 100
- folderPath URL-encode (requirements: .+ pour supporter les slashes nested)
- securite via MailAccessChecker

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- recupere headers + body + attachments via ImapMailProvider::fetchMessage
- cache Symfony pool cache.app, cle mail_body_{md5(messageId)}, TTL 300s
- attachments serialises sans contenu binaire, avec downloadId base64url(messageDbId:partNumber)
- 503 si IMAP indisponible, 404 si message inconnu
- les tests read/flag ROLE_CLIENT/auth seront ajoutes en Task 10 (route deja existante)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- POST /api/mail/messages/{id}/read body {read: bool} - synchro IMAP + BDD
- POST /api/mail/messages/{id}/flag body {flagged: bool} - synchro IMAP + BDD
- IMAP-side non bloquant : BDD est mise a jour meme si IMAP fail (resync au prochain cycle)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- cree une Task avec titre = subject du mail (max 255 chars)
- utilise findMaxNumberByProjectForUpdate pour numero (advisory lock PG)
- transaction wrapInTransaction pour eviter race conditions
- taskGroupId et priorityId optionnels via body JSON
- cree automatiquement le TaskMailLink (mail <-> tache)
- retourne 201 + taskId/taskNumber/taskTitle/messageId

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- POST /api/mail/messages/{id}/link-task body {taskId} : cree TaskMailLink (idempotent)
- DELETE /api/mail/messages/{id}/link-task/{taskId} : supprime le lien (204)
- GET /api/tasks/{id}/mails : liste les mails lies a une tache
- securite via MailAccessChecker, tests fonctionnels 401/403

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- downloadId = base64url(messageDbId:partNumber)
- Content-Disposition: attachment systematique (jamais inline pour eviter XSS via HTML attachments)
- X-Content-Type-Options: nosniff
- filename sanitise via basename pour eviter path traversal

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- App\Message\MailSyncRequested (optionnel folderPath)
- App\MessageHandler\MailSyncRequestedHandler delegue a MailSyncService::syncFolder ou syncAll
- messenger.yaml : transport async via Doctrine DSN, retry 3x exponentiel, failure transport
- en test : transport in-memory (sync immediat)
- migration Version20260519220000 : cree messenger_messages table (idempotente, IF NOT EXISTS)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- dispatch MailSyncRequested au bus Messenger, retourne 202 immediat
- folderPath optionnel via body JSON pour sync ciblee
- en test : transport in-memory route le message en sync
- securite via MailAccessChecker

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- ajoute la regle ^/api/mail avant ^/api pour expliciter l'authentification requise
- les checks fins ROLE_USER vs ROLE_CLIENT restent dans MailAccessChecker (chaque controller)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
matthieu added 3 commits 2026-05-20 05:57:28 +00:00
Le guard enabled dans getClient() bloquait le test de connexion alors que le
workflow naturel est configurer → tester → activer. getClient(requireEnabled)
permet au nouveau testConnection() de se connecter sans exiger enabled=true.
Le controller (ROLE_ADMIN) renvoie désormais le détail de l'erreur pour faciliter
le diagnostic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Le service appelait GET /mail/messages?folder=X (404) au lieu de la vraie route
/mail/folders/{path}/messages. Ajoute aussi une couche de mapping backend→DTO
(messages→items, fromAddress→fromEmail, toAddresses→toRecipients, détail plat→imbriqué)
pour réconcilier la dérive de contrat entre Phase 3 (API) et Phase 4 (DTOs front).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Quatre bugs révélés en testant contre une vraie boîte OVH (les tests mockaient
le provider, donc jamais exercés) :
- requête sans critère → "BAD parse error: zero-length content" : ajout de whereAll()
- getDate()/getSubject() renvoient des Attribute webklex v6, pas des scalaires : casts explicites
- séquence par défaut ST_MSGN → le peek() de webklex faisait un STORE par numéro de
  séquence rejeté par OVH ("flag could not be removed") : force ST_UID sur toutes les requêtes
- snippet via getTextBody() forçait un fetch de corps par mail (sync 179s + peek) :
  setFetchBody(false) au listing, snippet désormais optionnel

Sync INBOX : 9 messages en 1,6s (avant : échec en 179s).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
matthieu added 1 commit 2026-05-20 06:05:24 +00:00
Seuls les dossiers racine sont affichés au départ ; chevron pour déplier/replier
chaque dossier ayant des sous-dossiers. Le clic sur le nom sélectionne toujours le dossier.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
matthieu added 2 commits 2026-05-20 06:22:16 +00:00
Trois causes racines révélées par une vraie synchro complète (139 dossiers) :
- contrainte UNIQUE globale sur message_id : fausse pour IMAP (un même Message-ID
  existe dans plusieurs dossiers) → violation → fermeture de l'EntityManager →
  cascade qui tuait tous les dossiers suivants. Migration : index simple à la place.
- 139 connexions IMAP (une par dossier) → throttling OVH (failed to authenticate) :
  réutilisation d'une seule connexion (closeConnection() ajouté à l'interface).
- état de connexion corrompu après un dossier en erreur (must be in SELECTED state) :
  reconnexion ciblée après chaque dossier en échec.
- garde anti-cascade : reset du ManagerRegistry + arrêt propre si l'EM se ferme.

Résultat : 456 messages sur 57 dossiers (avant : 188/30 puis crash). Les rares
dossiers à encodage spécial sont skippés proprement et réessayés au cycle suivant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
matthieu added 1 commit 2026-05-20 06:22:41 +00:00
matthieu added 1 commit 2026-05-20 06:24:04 +00:00
Boutons "Créer une tâche" / "Lier à une tâche" réduits (text-xs, padding compact),
suppression des actions "Marquer comme non lu" et "Marquer important".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
matthieu added 1 commit 2026-05-20 07:45:16 +00:00
matthieu merged commit 90bf46f598 into develop 2026-05-20 07:45:33 +00:00
matthieu deleted branch feat/mail-integration 2026-05-20 07:45:34 +00:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: MALIO-DEV/Lesstime#5