Files
Lesstime/docs/mail-integration.md
T
Matthieu 05ce6549a4
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 43s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m3s
fix(mail) : stop le spam GlitchTip de sync (reconnexion AUTHENTICATIONFAILED + double-log)
Un seul echec de dossier (empty response) generait 4 events GlitchTip :
- le bloc de detection de suppression rappelait listMessages quand le
  fetch initial avait echoue, forcant une reconnexion IMAP refusee par OVH
  (AUTHENTICATIONFAILED, throttling) ;
- chaque echec etait logge 2x en error (provider + service).

Fix :
- garde `if (null !== $remoteHeaders)` autour de la detection de
  suppression : si le fetch a echoue, on saute le diff (reprise au
  cycle suivant), plus de reconnexion parasite ;
- le log service des MailProviderException passe en warning (le provider
  reste la source unique au niveau error pour GlitchTip, couvre aussi les
  chemins HTTP).

Net : 1 event GlitchTip par echec de dossier.
Test de regression : testSyncFolderDoesNotRefetchMessagesWhenInitialFetchFails.
2026-06-30 17:25:38 +02:00

12 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).

Bugs corrigés 2026-06-30 (spam GlitchTip AUTHENTICATIONFAILED + double-log)

4 events GlitchTip pour un seul échec de dossier (INBOX/RH/LUCILE NEAU, release 0.4.54). Root cause différente du fix du 29/06 (le « Folder not found » est bien éteint). Deux amplificateurs dans MailSyncService::syncFolder :

  1. Re-fetch après échec : quand le listMessages initial échoue (empty response), le bloc de détection de suppression rappelait listMessages ($remoteHeaders === null). Ce 2ᵉ appel forçait une reconnexion IMAP que OVH refusait (AUTHENTICATIONFAILED, throttling) → 2 events parasites. Fix : le bloc deletion est gardé par if (null !== $remoteHeaders) — si le fetch a échoué, on saute la détection de suppression (impossible de differ sans liste distante fiable ; reprise au cycle suivant). Les credentials sont valides — l'auth-fail venait de la reconnexion, pas du mot de passe.
  2. Double-log error : provider (ImapMailProvider::listMessages, error) et service (syncFolder[...] listMessages failed, error) logguaient la même MailProviderException → 2 issues GlitchTip. Fix : le log service MailProviderException passe en warning (le provider reste la source unique au niveau error pour GlitchTip, ce qui couvre aussi les chemins HTTP où les controllers catchent sans logger). Net : 1 event GlitchTip par échec de dossier. Test de régression : MailSyncServiceTest::testSyncFolderDoesNotRefetchMessagesWhenInitialFetchFails (assert listMessages appelé une seule fois).

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)