Files
Lesstime/docs/mail-integration.md
T
Matthieu 0269bc6d28
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 1m20s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m44s
fix(mail) : stop le spam GlitchTip de sync (double-encodage UTF7 + dossiers fantômes)
Deux causes racines généraient ~170 erreurs/cycle (toutes les 10 min) sur
la prod : "syncFolder[...] listMessages failed: Folder ... not found".

1. Double-encodage UTF7-IMAP : listFolders() stocke le chemin brut UTF7-IMAP,
   mais ImapMailProvider rappelait getFolder($path) qui ré-encode UTF8->UTF7-IMAP
   (webklex Client::getFolderByPath, utf7=false). Le caractère de shift "&" était
   ré-encodé, rendant introuvables les dossiers à accents/specials. Fix :
   getFolder($path, null, utf7: true) partout dans ImapMailProvider.

2. Dossiers fantômes jamais purgés : syncFolderStructure() gardait en DB les
   dossiers disparus du serveur, re-tentés à chaque cycle. Fix :
   syncFolderStructure() retourne le set des chemins présents sur le serveur ;
   doSyncAll() skip silencieusement les dossiers DB absents (conservés en DB
   pour les liens messages/tâches). Fallback historique si listFolders échoue.

Test : testSyncAllSkipsFoldersNoLongerPresentOnServer.
2026-06-29 17:33:59 +02:00

10 KiB

Intégration Mail — Vue d'ensemble

🟢 Statut & reprise (handoff — MAJ 2026-05-20)

Branche : feat/mail-integration · MR Gitea : #5 (base develop) Construit en 7 phases (plans dans docs/superpowers/plans/2026-05-19-mail-phase*.md).

Ce qui marche (testé contre une vraie boîte OVH contact@malio.fr)

  • Connexion IMAP + test connexion (admin → /admin onglet Mail)
  • Synchro complète multi-dossiers : 456 messages / 57 dossiers ramenés, ne crashe plus
  • Lecture dossiers/messages dans /mail, arbre repliable (chevrons, sous-dossiers masqués par défaut)
  • Lecture d'un mail, sanitization DOMPurify
  • Création/lien tâche depuis un mail

Bugs déjà corrigés ce soir (NE PAS ré-investiguer)

Tous dans ImapMailProvider / MailSyncService — les tests mockaient le provider, donc le fetch réel n'avait jamais été exercé avant le test live :

  1. Requête sans critère → BAD parse error: zero-length contentwhereAll()
  2. getDate()/getSubject() renvoient des Attribute webklex v6 → casts explicites
  3. Séquence par défaut ST_MSGNpeek() faisait un STORE rejeté par OVH (flag could not be removed) → forcé ST_UID partout
  4. Snippet via getTextBody() = fetch du corps de chaque mail (sync 179s + peek) → setFetchBody(false), snippet désactivé au listing
  5. Test connexion exigeait enabled=true → découplé via getClient(requireEnabled:false) + testConnection()
  6. Contrainte UNIQUE globale sur message_id → fausse pour IMAP (même Message-ID dans plusieurs dossiers) → fermait l'EntityManager → cascade. Migration Version20260520061736 : index simple. Garde anti-cascade dans MailSyncService (reset ManagerRegistry).
  7. 139 connexions IMAP (une/dossier) → throttling OVH → réutilisation d'1 connexion (closeConnection() sur l'interface) + reconnexion ciblée après dossier en erreur.
  • Contrat front/back réaligné dans frontend/services/mail.ts (route /mail/folders/{path}/messages, mapping messages→items, fromAddress→fromEmail, détail plat→imbriqué).

Bugs corrigés 2026-06-29 (spam GlitchTip syncFolder[...] listMessages failed: Folder ... not found)

Deux causes racines, ~170 erreurs/cycle (toutes les 10 min) sur la prod :

  1. Double-encodage UTF7-IMAP : listFolders() stocke $folder->path = nom brut UTF7-IMAP (webklex Folder::$path). ImapMailProvider appelait ensuite $client->getFolder($path) qui ré-encode UTF8→UTF7-IMAP (Client::getFolderByPath, $utf7=false) → le & (shift UTF7) est ré-encodé → dossiers à accents/specials introuvables. Fix : getFolder($path, null, utf7: true) partout dans ImapMailProvider (les paths sont déjà UTF7-IMAP). Résout les ~7 dossiers à encodage spécial qui étaient « skippés ».
  2. Dossiers fantômes jamais purgés : syncFolderStructure() gardait en DB les dossiers disparus du serveur (Trash vidé, dossiers RH supprimés) → re-tentés à chaque cycle → listMessages → "not found" → log error en boucle. Fix : syncFolderStructure() retourne le set des chemins présents sur le serveur ; doSyncAll() skip silencieusement les dossiers DB absents de ce set (gardés en DB pour les liens messages/tâches, mais plus synchronisés). Si listFolders échoue (retour null), fallback = sync de tous les dossiers connus (comportement historique).

Points en suspens / à savoir

  • Mise à jour auto = cron OS lançant make mail-sync toutes les 10 min (cf docs/mail-cron-setup.md). Pas configuré en dev — lancer à la main.
  • Bouton "Actualiser" : dispatch async Messenger (MailSyncRequested → async). Sans worker messenger:consume async qui tourne, les demandes s'empilent sans s'exécuter. En prod : supervisor. En dev : lancer un worker.
  • Dépendance : webklex/php-imap ^6.2 tire des paquets Laravel (illuminate/* via carbon ^3) dans ce projet Symfony — fonctionnel mais à valider en review.
  • 6 PHPUnit Notices (mocks sans expectations) non bloquantes.

Commandes utiles

make mail-sync                                                   # synchro complète
docker exec -i -u www-data php-lesstime-fpm php bin/console app:mail:sync --folder=INBOX -v
docker exec -i -u www-data php-lesstime-fpm php bin/console messenger:consume async -vv   # worker (fait marcher le bouton)
docker exec -i php-lesstime-fpm php bin/console app:mail:redecode-headers [--dry-run]  # re-décode les en-têtes MIME déjà en base (backfill)
make test                                                        # 33 tests

Fixtures make fixtures plantent sur un état legacy workflow_id (hors-scope mail) — configurer la boîte via l'UI admin.

Fonctionnalités

  • Lecture de la boîte mail partagée (IMAP) depuis Lesstime
  • Navigation par dossiers (arbre récursif avec compteurs non-lus)
  • Liste paginée des messages (infinite scroll, cursor-based)
  • Lecture des corps de mail sanitisés (DOMPurify — protection XSS + pixels tracking)
  • Décodage des en-têtes MIME encodés (RFC 2047, ex =?UTF-8?Q?...) sur sujet + nom d'expéditeur (App\Mail\MimeHeaderDecoder, appliqué dans ImapMailProvider)
  • Aperçu inline des pièces jointes images + PDF (visionneuse modale plein écran), téléchargement pour les autres types
  • Création d'une tâche Lesstime depuis un mail (sujet → titre, texte → description)
  • Lien mail ↔ tâche (bidirectionnel)
  • Onglet "Mails" dans le TaskDrawer pour retrouver les mails liés à une tâche
  • Synchronisation IMAP automatique via cron OS (toutes les 10 min)
  • Déclenchement manuel de sync depuis l'UI (bouton Refresh)
  • Badge non-lus en temps réel dans la sidebar (polling 30s)

Endpoints API

Méthode URL Rôle Description
GET /api/mail/configuration ROLE_ADMIN Lire la config singleton
PATCH /api/mail/configuration ROLE_ADMIN Mettre à jour la config
POST /api/mail/configuration/test ROLE_ADMIN Tester la connexion IMAP
GET /api/mail/folders ROLE_USER Arbre des dossiers + unread
GET /api/mail/messages ROLE_USER Liste paginée (param: folder, cursor, limit)
GET /api/mail/messages/{id} ROLE_USER Détail + body (cached 5 min)
POST /api/mail/messages/{id}/read ROLE_USER Marquer lu/non-lu
POST /api/mail/messages/{id}/flag ROLE_USER Marquer étoilé/non-étoilé
POST /api/mail/messages/{id}/create-task ROLE_USER Créer tâche depuis mail
POST /api/mail/messages/{id}/link-task ROLE_USER Lier mail à tâche existante
DELETE /api/mail/messages/{id}/link-task/{taskId} ROLE_USER Supprimer le lien
GET /api/tasks/{id}/mails ROLE_USER Mails liés à une tâche
GET /api/mail/attachments/{id} ROLE_USER Télécharger une pièce jointe
POST /api/mail/sync ROLE_USER Déclencher sync async (Messenger)

Tous les endpoints /api/mail/* refusent explicitement ROLE_CLIENT.

Sécurité

  • ROLE_CLIENT exclusif : accès refusé à tous les endpoints mail et à la page /mail
  • Le sidebar "Messagerie" est masqué pour les ROLE_CLIENT
  • Password IMAP chiffré via libsodium secretbox (env ENCRYPTION_KEY)
  • Corps de mail sanitisés via DOMPurify (sanitizeMailHtml.ts) — script/iframe/object/embed/on*/javascript: bloqués
  • Pixels tracking distants (img src http) remplacés par placeholder
  • Aucun body, password ou contenu de pièce jointe dans les logs

Dépendances

Backend

  • webklex/php-imap : client IMAP PHP
  • symfony/lock : Symfony Lock pour éviter les syncs parallèles
  • symfony/messenger : dispatch asynchrone MailSyncRequested
  • libsodium (ext PHP) : chiffrement du password IMAP

Frontend

  • dompurify + @types/dompurify : sanitization HTML des corps de mail

Fichiers clés

Backend

  • src/Entity/MailConfiguration.php — entité singleton (credentials, enabled)
  • src/Entity/MailFolder.php — dossier IMAP synced
  • src/Entity/MailMessage.php — message IMAP synced (headers, flags)
  • src/Entity/TaskMailLink.php — lien tâche ↔ mail
  • src/Mail/ImapMailProvider.php — implémentation IMAP (webklex)
  • src/Service/MailSyncService.php — algorithme de sync (UID FETCH, resync flags)
  • src/Controller/Mail/ — controllers custom (test, folders, messages, sync)
  • src/State/Mail/ — providers/processors API Platform (configuration)

Frontend

  • frontend/pages/mail.vue — page principale 3 colonnes
  • frontend/components/mail/ — MailFolderTree, MailMessageList, MailMessageViewer, MailRefreshButton, MailAttachmentPreview (visionneuse modale image/PDF)
  • frontend/components/admin/AdminMailTab.vue — onglet config admin
  • frontend/stores/mail.ts — store Pinia (folders, messages, polling)
  • frontend/services/mail.ts — service API (toutes les méthodes)
  • frontend/services/dto/mail.ts — types TypeScript
  • frontend/utils/sanitizeMailHtml.ts — DOMPurify wrapper

Synchronisation cron

Voir docs/mail-cron-setup.md pour la configuration détaillée.

Résumé :

# Cron OS (toutes les 10 min)
*/10 * * * * cd /path/to/Lesstime && make mail-sync >> /var/log/lesstime-mail-sync.log 2>&1

# Commandes Makefile
make mail-sync              # Sync complète
make mail-sync FOLDER=INBOX # Sync d'un dossier
make mail-sync DRYRUN=1     # Simulation sans écriture

Configuration admin

  1. Aller sur /admin → onglet "Mail"
  2. Renseigner les credentials IMAP/SMTP (OVH : ssl0.ovh.net, port 993/465, SSL)
  3. Cliquer "Tester la connexion"
  4. Activer la synchronisation → Enregistrer
  5. Configurer le cron OS

Variables d'environnement

Variable Description Obligatoire
ENCRYPTION_KEY Clé hex 32 bytes libsodium pour chiffrer le password IMAP Oui
LOCK_DSN DSN Symfony Lock (défaut: flock) Non
MESSENGER_TRANSPORT_DSN Transport Messenger pour sync async Recommandé (prod)