05ce6549a4
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.
161 lines
12 KiB
Markdown
161 lines
12 KiB
Markdown
# Intégration Mail — Vue d'ensemble
|
|
|
|
> ## 🟢 Statut & reprise (handoff — MAJ 2026-05-20)
|
|
>
|
|
> **Branche** : `feat/mail-integration` · **MR Gitea** : https://gitea.malio.fr/MALIO-DEV/Lesstime/pulls/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 content` → `whereAll()`
|
|
> 2. `getDate()`/`getSubject()` renvoient des `Attribute` webklex v6 → casts explicites
|
|
> 3. Séquence par défaut `ST_MSGN` → `peek()` 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
|
|
> ```bash
|
|
> 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é :
|
|
|
|
```bash
|
|
# 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) |
|