From 361cc8cfab8fdc8bc4c85cc8be872db44fde50e9 Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 22:49:39 +0200 Subject: [PATCH 01/69] =?UTF-8?q?docs(mail)=20:=20master=20plan=20d'int?= =?UTF-8?q?=C3=A9gration=20mail=20OVH=20IMAP=20=E2=80=94=207=20phases=20(f?= =?UTF-8?q?oundations,=20sync,=20API,=20services=20front,=20UI,=20int?= =?UTF-8?q?=C3=A9gration=20t=C3=A2ches,=20admin)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- ...2026-05-19-mail-integration-master-plan.md | 264 ++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-19-mail-integration-master-plan.md diff --git a/docs/superpowers/plans/2026-05-19-mail-integration-master-plan.md b/docs/superpowers/plans/2026-05-19-mail-integration-master-plan.md new file mode 100644 index 0000000..089a708 --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-mail-integration-master-plan.md @@ -0,0 +1,264 @@ +# Mail Integration — Master Plan + +> **Master plan** : ce document décrit le découpage en phases. Chaque phase aura son propre plan détaillé (rédigé par un subagent rédacteur) puis sera implémentée par un subagent codeur, en cycle. + +**Spec source** : `docs/superpowers/specs/2026-05-19-mail-integration-design.md` + +**Goal** : Ajouter à Lesstime un client mail intégré pour une boîte partagée OVH (IMAP/SMTP), avec lecture inbox/dossiers et création/lien tâche depuis un mail. + +**Stratégie** : 7 phases séquentielles, dépendances claires, chaque phase = working software testable. Cycle par phase : rédacteur → codeur → review humaine → phase suivante. + +--- + +## Cartographie des phases + +``` +Phase 1 (Backend foundations) ──┐ + ├─→ Phase 2 (IMAP provider + sync) ──┐ + │ ├─→ Phase 3 (API backend) ──┐ + │ │ │ + └─→─────────────────────────────────────────────────────────────────┤ + │ +Phase 4 (Frontend services + store) ←──────────────────────────────────────────────────────────────┘ + │ + ├─→ Phase 5 (UI principale 3 colonnes) + │ + ├─→ Phase 6 (Intégration tâches : modals, onglet TaskDrawer) + │ + └─→ Phase 7 (Admin config + sidebar + polish) +``` + +Chaque phase produit du logiciel fonctionnel (testable, mergeable) sans casser les précédentes. + +--- + +## Phase 1 — Backend Foundations + +**Plan détaillé attendu** : `docs/superpowers/plans/2026-05-19-mail-phase1-foundations.md` + +**Scope** : +- Entité `MailConfiguration` (singleton, fields complets de la spec, `encryptedPassword` via `TokenEncryptor`) +- Entité `MailFolder` +- Entité `MailMessage` +- Entité `TaskMailLink` (avec unique constraint) +- Repositories : `MailConfigurationRepository::findSingleton()`, `MailFolderRepository`, `MailMessageRepository`, `TaskMailLinkRepository` +- Migration Doctrine unique créant les 4 tables (raw SQL) +- DTOs sous `src/Mail/Dto/` : `MailFolderDto`, `MailMessageHeaderDto`, `MailMessageDetailDto`, `MailAttachmentDto` +- Interface `App\Mail\MailProviderInterface` (signatures uniquement, pas d'impl) +- Exception `App\Mail\Exception\MailProviderException` +- Tests unitaires repositories (au moins le pattern singleton) + +**Critère d'acceptation** : +- `make migration-migrate` passe sans erreur +- `php bin/console doctrine:schema:validate` OK +- `make test` vert (au moins les tests créés) +- Fixture `MailConfiguration` désactivée (OVH defaults) ajoutée + +**Dépendances** : aucune (point d'entrée). + +--- + +## Phase 2 — IMAP Provider + Sync + +**Plan détaillé attendu** : `docs/superpowers/plans/2026-05-19-mail-phase2-imap-sync.md` + +**Scope** : +- Ajout dépendance Composer `webklex/php-imap` (vérifier compat PHP 8.4) +- Implémentation `App\Mail\ImapMailProvider implements MailProviderInterface` + - Lecture config via `MailConfigurationRepository::findSingleton()` + - Déchiffrement password via `TokenEncryptor` + - `listFolders`, `listMessages`, `fetchMessage`, `markRead`, `markFlagged`, `moveMessage`, `fetchAttachment` + - Wrapping erreurs en `MailProviderException` +- `App\Service\MailSyncService` + - `syncAll(): MailSyncReport` + - `syncFolder(string $folderPath): MailSyncReport` + - `syncFolderStructure(): void` + - Algorithme exact de la spec (UID FETCH lastUid+1:*, resync flags N=200 derniers, detect suppressions avec garde 50%) +- DTO `MailSyncReport` (count créés / mis à jour / supprimés / errors) +- Symfony Lock (`mail.sync`, TTL 10 min) +- Commande console `app:mail:sync` (avec option `--folder=...`) +- Documentation cron OS + cible Makefile `make mail-sync` +- Tests : ImapMailProvider mocké via fixture serveur ou interface, MailSyncService avec provider mocké + +**Critère d'acceptation** : +- `php bin/console app:mail:sync --dry-run` fonctionne contre une fake config +- Tests `make test` verts +- `make mail-sync` documentée dans Makefile + +**Dépendances** : Phase 1. + +--- + +## Phase 3 — API Backend + +**Plan détaillé attendu** : `docs/superpowers/plans/2026-05-19-mail-phase3-api.md` + +**Scope** : +- API Platform ressources : + - `GET /api/mail/configuration` (ROLE_ADMIN) — singleton provider + - `PATCH /api/mail/configuration` (ROLE_ADMIN) — processor (jamais retourner password en clair, accepter nouveau password à chiffrer) +- Custom controllers (priority: 1) : + - `POST /api/mail/configuration/test` (ROLE_ADMIN) — test connexion + - `GET /api/mail/folders` (ROLE_USER, refus ROLE_CLIENT explicite) — arbre + unreadCount depuis BDD + - `GET /api/mail/folders/{path}/messages?page&limit` — pagination cursor `sentAt DESC, id DESC` + - `GET /api/mail/messages/{id}` — fetch live IMAP + cache Symfony `mail_body_{messageId}` TTL 5 min + - `POST /api/mail/messages/{id}/read` (body `{ read: bool }`) + - `POST /api/mail/messages/{id}/flag` + - `POST /api/mail/messages/{id}/create-task` (body `{ projectId, taskGroupId?, priority? }`) + - `POST /api/mail/messages/{id}/link-task` (body `{ taskId }`) + - `DELETE /api/mail/messages/{id}/link-task/{taskId}` + - `GET /api/tasks/{id}/mails` + - `GET /api/mail/attachments/{id}` — stream, `Content-Disposition: attachment`, jamais inline + - `POST /api/mail/sync` — async via Messenger +- Message + Handler Symfony Messenger `MailSyncRequested` +- Sécurité : `#[IsGranted('IS_AUTHENTICATED_FULLY')]` + check `ROLE_USER && !ROLE_CLIENT` explicite +- Tests fonctionnels endpoints (auth, format réponses, ROLE_CLIENT refusé) + +**Critère d'acceptation** : +- Tous endpoints répondent corrects status/format +- Tests `make test` verts +- ROLE_CLIENT refusé sur 100% des endpoints mail +- Password jamais leak dans les réponses + +**Dépendances** : Phase 1, Phase 2. + +--- + +## Phase 4 — Frontend Services + Store + +**Plan détaillé attendu** : `docs/superpowers/plans/2026-05-19-mail-phase4-frontend-services.md` + +**Scope** : +- Install npm `dompurify` + types +- `frontend/services/dto/mail.ts` : tous les types TS +- `frontend/services/mail.ts` : méthodes API (suivre pattern `tasks.ts`) + - `listFolders`, `listMessages`, `getMessage`, `markRead`, `markFlagged` + - `createTaskFromMail`, `linkTask`, `unlinkTask`, `listMailsForTask` + - `triggerSync` + - `getConfiguration`, `updateConfiguration`, `testConfiguration` + - `downloadAttachment` (retourne Blob) +- Store Pinia `frontend/stores/useMailStore.ts` + - State : `folders`, `selectedFolderPath`, `messages[]`, `selectedMessageId`, `selectedMessageDetail`, `loading`, `syncing`, `globalUnreadCount` + - Actions correspondantes + - Polling `pollUnreadCount()` toutes les 30s (start/stop) +- Sanitization helper `frontend/utils/sanitizeMailHtml.ts` (DOMPurify avec config bloquante : script/iframe/object/embed/on*/javascript:, strip ou placeholder pour `` distants) + +**Critère d'acceptation** : +- `cd frontend && npx tsc --noEmit` OK +- Test manuel d'un appel `mail.listFolders()` depuis devtools renvoie 401 si pas authentifié, 200 sinon + +**Dépendances** : Phase 3 (les endpoints doivent exister). + +--- + +## Phase 5 — UI principale (page /mail) + +**Plan détaillé attendu** : `docs/superpowers/plans/2026-05-19-mail-phase5-ui-main.md` + +**Scope** : +- Page `frontend/pages/mail.vue` — layout 3 colonnes (dossiers / liste / lecteur), responsive +- Composants `frontend/components/mail/` : + - `MailFolderTree.vue` — arbre récursif avec badges unread, sélection + - `MailMessageList.vue` — liste paginée (infinite scroll), indicateurs lu/étoilé/PJ, formatage relatif des dates + - `MailMessageViewer.vue` — header (de/à/cc/date) + body sanitizé via DOMPurify + liste PJ téléchargeables + actions (Créer tâche / Lier / Marquer lu/non-lu / Étoiler) + - `MailRefreshButton.vue` — bouton sync manuel, désactivé pendant `syncing` +- i18n clés `mail.*` dans `frontend/i18n/locales/fr.json` (et `en.json` si présent) : titres, vides, actions, erreurs +- Mapping noms dossiers système (`INBOX`, `Sent`, `Drafts`, `Archive`, `Trash`, `Junk`) → labels traduits +- Gestion query param `?messageId=X` pour deep-link vers un mail (selection auto à l'ouverture) +- Refus visuel pour ROLE_CLIENT (le middleware backend bloque déjà, mais ajouter check côté router/middleware Nuxt) + +**Critère d'acceptation** : +- Page accessible à `/mail` pour ROLE_USER/ROLE_ADMIN +- ROLE_CLIENT redirigé vers `/portal` +- Pas d'XSS via body mail (test manuel avec un mail contenant ``) +- Pixels tracking distants remplacés par placeholder + +**Dépendances** : Phase 4. + +--- + +## Phase 6 — Intégration Tâches + +**Plan détaillé attendu** : `docs/superpowers/plans/2026-05-19-mail-phase6-task-integration.md` + +**Scope** : +- `frontend/components/mail/MailCreateTaskModal.vue` — wrapper du `TaskDrawer` existant pré-rempli : + - Titre = subject + - Description = body plain text + - Picker projet + groupe + priorité + - À la création : appelle `POST /api/mail/messages/{id}/create-task`, ferme modal, redirige ou affiche succès +- `frontend/components/mail/MailLinkTaskModal.vue` — autocomplete sur tâches existantes (filter par projet, statut non-archivé) +- Onglet **"Mails"** sur `TaskDrawer.vue` : + - Nouvelle section affichée à côté Documents / Time tracking / etc. + - Liste `MailMessage` liés à la tâche (via `GET /api/tasks/{id}/mails`) + - Item cliquable → `router.push('/mail?messageId=' + id)` + - Bouton "Lier un mail" → ouvre un picker mail (TBD selon ergonomie : modal recherche ou redirige vers /mail) +- Tests manuels : créer tâche depuis mail, lier mail à tâche existante, voir mail depuis onglet tâche + +**Critère d'acceptation** : +- Workflow complet : mail → "Créer tâche" → tâche créée et liée → visible dans onglet "Mails" du TaskDrawer +- Workflow : tâche existante → "Lier mail" → mail apparaît dans onglet + +**Dépendances** : Phase 5. + +--- + +## Phase 7 — Admin Config + Sidebar + Polish + +**Plan détaillé attendu** : `docs/superpowers/plans/2026-05-19-mail-phase7-admin-polish.md` + +**Scope** : +- `frontend/components/admin/AdminMailTab.vue` (calqué sur `AdminZimbraTab.vue`) : + - Form : protocol (imap pour MVP), imapHost/Port/Encryption, smtpHost/Port/Encryption, username, password (write-only, `hasPassword: true` côté GET), sentFolderPath, enabled toggle + - Bouton "Tester la connexion" → `POST /api/mail/configuration/test` + - Indicateur OVH defaults pré-remplis (`ssl0.ovh.net:993/465`) +- Ajout onglet `AdminMailTab` dans la page admin (selon pattern existant) +- Lien sidebar dans le layout default : + - Icône `material-symbols:mail-outline` + - Label traduit + - Badge unread (count `useMailStore.globalUnreadCount`) + - Visible uniquement pour `ROLE_USER && !ROLE_CLIENT` +- Lifecycle polling 30s : start dans `app.vue` ou layout default, stop au logout +- Documentation finale : + - README ou `docs/` : section "Mail integration" (cron OS, variables config, sécurité) + - Makefile : `make mail-sync` documentée +- Vérification finale tracking pixels (relire `sanitizeMailHtml.ts` + tester) +- QA passe : workflow end-to-end depuis vraie boîte OVH (si dispo) ou IMAP test (greenmail/dovecot local) + +**Critère d'acceptation** : +- Admin peut configurer la boîte, tester, activer +- Sidebar affiche badge unread temps réel (30s polling) +- Doc d'install à jour +- Aucun warning console front, aucun ERROR PHP dans `make logs-dev` + +**Dépendances** : Phase 5 (sidebar utilise le store), Phase 3 (admin API). + +--- + +## Conventions communes à toutes les phases + +- **TDD** : test rouge → code → test vert → commit +- **Strict types** PHP (`declare(strict_types=1)`) en tête de chaque fichier +- **PHP CS Fixer** : `make php-cs-fixer-allow-risky` avant chaque commit +- **Commits** : format `(mail) : ` (espace avant `:`) +- **Branche** : `feat/mail-integration` (créée au début de Phase 1) +- **Pas de jamais logger** : bodies, password, attachments +- **Review humaine entre chaque phase** : le user valide avant lancement phase suivante + +--- + +## Cycle d'exécution + +Pour chaque phase N : + +1. **Spawn subagent rédacteur** (`feature-dev:code-architect`) + - Input : ce master plan + spec + scope phase N + - Output : `docs/superpowers/plans/2026-05-19-mail-phaseN-*.md` au format `writing-plans` (tasks bite-sized, fichiers exacts, code complet, commandes test) + +2. **Spawn subagent codeur** (`ruflo-core:coder`) + - Input : plan détaillé phase N + - Output : code + tests + commits (TDD strict) + +3. **Review humaine** : user valide ou demande corrections + +4. **Phase suivante** uniquement si OK From 07b7d054d5120d6101d41763d62b70a2613162fa Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 23:10:36 +0200 Subject: [PATCH 02/69] =?UTF-8?q?docs(mail)=20:=20plan=20d=C3=A9taill?= =?UTF-8?q?=C3=A9=20Phase=201=20=E2=80=94=20entit=C3=A9s,=20repos,=20migra?= =?UTF-8?q?tion,=20DTOs,=20interface=20(10=20tasks=20TDD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-19-mail-phase1-foundations.md | 1632 +++++++++++++++++ 1 file changed, 1632 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-19-mail-phase1-foundations.md diff --git a/docs/superpowers/plans/2026-05-19-mail-phase1-foundations.md b/docs/superpowers/plans/2026-05-19-mail-phase1-foundations.md new file mode 100644 index 0000000..42f1cbf --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-mail-phase1-foundations.md @@ -0,0 +1,1632 @@ +# Mail Integration — Phase 1 : Backend Foundations + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Créer les fondations BDD + interfaces de l'intégration mail OVH IMAP (4 entités, 4 repositories, 1 migration, 4 DTOs, 1 interface, 1 exception, 1 fixture, tests). Aucune logique métier. + +**Architecture:** Singleton `MailConfiguration` (calqué sur `ZimbraConfiguration`), `MailFolder` (cache arbre IMAP), `MailMessage` (cache headers + snippet), `TaskMailLink` (join). Interface `MailProviderInterface` définit le contrat — implémentation en Phase 2. + +**Tech Stack:** PHP 8.4, Symfony 8.0, Doctrine ORM avec attributs PHP 8, PostgreSQL 16, PHPUnit + DAMA DoctrineTestBundle. + +**Branche cible :** `feat/mail-integration` (créer si elle n'existe pas). + +**Fichiers créés/modifiés par le codeur :** + +| Fichier | Action | +|---|---| +| `src/Entity/MailConfiguration.php` | Créer | +| `src/Entity/MailFolder.php` | Créer | +| `src/Entity/MailMessage.php` | Créer | +| `src/Entity/TaskMailLink.php` | Créer | +| `src/Repository/MailConfigurationRepository.php` | Créer | +| `src/Repository/MailFolderRepository.php` | Créer | +| `src/Repository/MailMessageRepository.php` | Créer | +| `src/Repository/TaskMailLinkRepository.php` | Créer | +| `src/Mail/MailProviderInterface.php` | Créer | +| `src/Mail/Exception/MailProviderException.php` | Créer | +| `src/Mail/Dto/MailFolderDto.php` | Créer | +| `src/Mail/Dto/MailMessageHeaderDto.php` | Créer | +| `src/Mail/Dto/MailAttachmentDto.php` | Créer | +| `src/Mail/Dto/MailMessageDetailDto.php` | Créer | +| `migrations/Version.php` | Créer | +| `src/DataFixtures/AppFixtures.php` | Modifier | +| `tests/Unit/Repository/MailConfigurationRepositoryTest.php` | Créer | + +--- + +### Task 1 : Préparation — branche + dossiers + +- [ ] **Step 1 : Vérifier / créer la branche** + + ```bash + git checkout feat/mail-integration 2>/dev/null || git checkout -b feat/mail-integration + ``` + +- [ ] **Step 2 : Créer les dossiers namespace** + + ```bash + mkdir -p src/Mail/Dto src/Mail/Exception + mkdir -p tests/Unit/Repository + ``` + +- [ ] **Step 3 : Vérifier que les dossiers sont dans l'autoloader Composer** + + Ouvrir `composer.json` et confirmer que `"App\\": "src/"` est présent dans `autoload.psr-4`. Si oui, rien à faire (les sous-dossiers `src/Mail/` sont automatiquement couverts). + +--- + +### Task 2 : Entité `MailConfiguration` + Repository + test singleton + +#### Step 1 : Écrire le test (TDD — il doit échouer) + +Créer `tests/Unit/Repository/MailConfigurationRepositoryTest.php` : + +```php +repository = $container->get(MailConfigurationRepository::class); + } + + public function testFindSingletonReturnsNullWhenEmpty(): void + { + $result = $this->repository->findSingleton(); + + self::assertNull($result); + } + + public function testFindSingletonReturnsFirstRecord(): void + { + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $config = new MailConfiguration(); + $config->setImapHost('ssl0.ovh.net'); + $config->setEnabled(false); + $em->persist($config); + $em->flush(); + + $result = $this->repository->findSingleton(); + + self::assertInstanceOf(MailConfiguration::class, $result); + self::assertSame('ssl0.ovh.net', $result->getImapHost()); + self::assertFalse($result->isEnabled()); + } +} +``` + +- [ ] **Step 2 : Lancer le test pour vérifier qu'il échoue** + + ```bash + docker exec php-lesstime-fpm php bin/phpunit tests/Unit/Repository/MailConfigurationRepositoryTest.php 2>&1 | tail -20 + ``` + + Attendu : erreur de classe manquante (`App\Entity\MailConfiguration`). + +- [ ] **Step 3 : Créer l'entité `MailConfiguration`** + + Créer `src/Entity/MailConfiguration.php` : + + ```php + id; + } + + public function getProtocol(): string + { + return $this->protocol; + } + + public function setProtocol(string $protocol): static + { + $this->protocol = $protocol; + + return $this; + } + + public function getImapHost(): ?string + { + return $this->imapHost; + } + + public function setImapHost(?string $imapHost): static + { + $this->imapHost = $imapHost; + + return $this; + } + + public function getImapPort(): int + { + return $this->imapPort; + } + + public function setImapPort(int $imapPort): static + { + $this->imapPort = $imapPort; + + return $this; + } + + public function getImapEncryption(): string + { + return $this->imapEncryption; + } + + public function setImapEncryption(string $imapEncryption): static + { + $this->imapEncryption = $imapEncryption; + + return $this; + } + + public function getSmtpHost(): ?string + { + return $this->smtpHost; + } + + public function setSmtpHost(?string $smtpHost): static + { + $this->smtpHost = $smtpHost; + + return $this; + } + + public function getSmtpPort(): int + { + return $this->smtpPort; + } + + public function setSmtpPort(int $smtpPort): static + { + $this->smtpPort = $smtpPort; + + return $this; + } + + public function getSmtpEncryption(): string + { + return $this->smtpEncryption; + } + + public function setSmtpEncryption(string $smtpEncryption): static + { + $this->smtpEncryption = $smtpEncryption; + + return $this; + } + + public function getUsername(): ?string + { + return $this->username; + } + + public function setUsername(?string $username): static + { + $this->username = $username; + + return $this; + } + + public function getEncryptedPassword(): ?string + { + return $this->encryptedPassword; + } + + public function setEncryptedPassword(?string $encryptedPassword): static + { + $this->encryptedPassword = $encryptedPassword; + + return $this; + } + + public function getSentFolderPath(): string + { + return $this->sentFolderPath; + } + + public function setSentFolderPath(string $sentFolderPath): static + { + $this->sentFolderPath = $sentFolderPath; + + return $this; + } + + public function isEnabled(): bool + { + return $this->enabled; + } + + public function setEnabled(bool $enabled): static + { + $this->enabled = $enabled; + + return $this; + } + + public function hasPassword(): bool + { + return null !== $this->encryptedPassword; + } + } + ``` + +- [ ] **Step 4 : Créer le repository `MailConfigurationRepository`** + + Créer `src/Repository/MailConfigurationRepository.php` : + + ```php + createQueryBuilder('m') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult() + ; + } + } + ``` + +- [ ] **Step 5 : Lancer le test pour vérifier qu'il passe** + + ```bash + docker exec php-lesstime-fpm php bin/phpunit tests/Unit/Repository/MailConfigurationRepositoryTest.php 2>&1 | tail -20 + ``` + + Attendu : `OK (2 tests, 2 assertions)`. + + > Note : si le test `testFindSingletonReturnsNullWhenEmpty` échoue car des données persistent entre tests, vérifier que `DAMA\DoctrineTestBundle\Doctrine\DBAL\StaticDriver` est bien configuré dans `config/packages/test/dama_doctrine_test_bundle.yaml`. Si non configuré, ajouter le `@runInSeparateProcess` en dernier recours — mais normalement le projet utilise déjà DAMA. + +- [ ] **Step 6 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Entity/MailConfiguration.php src/Repository/MailConfigurationRepository.php tests/Unit/Repository/MailConfigurationRepositoryTest.php + git commit -m "feat(mail) : MailConfiguration entity + repository + singleton test" + ``` + +--- + +### Task 3 : Entité `MailFolder` + Repository + +- [ ] **Step 1 : Créer l'entité `MailFolder`** + + Créer `src/Entity/MailFolder.php` : + + ```php + id; + } + + public function getPath(): string + { + return $this->path; + } + + public function setPath(string $path): static + { + $this->path = $path; + + return $this; + } + + public function getDisplayName(): string + { + return $this->displayName; + } + + public function setDisplayName(string $displayName): static + { + $this->displayName = $displayName; + + return $this; + } + + public function getParentPath(): ?string + { + return $this->parentPath; + } + + public function setParentPath(?string $parentPath): static + { + $this->parentPath = $parentPath; + + return $this; + } + + public function getUnreadCount(): int + { + return $this->unreadCount; + } + + public function setUnreadCount(int $unreadCount): static + { + $this->unreadCount = $unreadCount; + + return $this; + } + + public function getTotalCount(): int + { + return $this->totalCount; + } + + public function setTotalCount(int $totalCount): static + { + $this->totalCount = $totalCount; + + return $this; + } + + public function getLastSyncedAt(): ?DateTimeImmutable + { + return $this->lastSyncedAt; + } + + public function setLastSyncedAt(?DateTimeImmutable $lastSyncedAt): static + { + $this->lastSyncedAt = $lastSyncedAt; + + return $this; + } + } + ``` + +- [ ] **Step 2 : Créer le repository `MailFolderRepository`** + + Créer `src/Repository/MailFolderRepository.php` : + + ```php + + */ + public function findAllOrderedByPath(): array + { + return $this->createQueryBuilder('f') + ->orderBy('f.path', 'ASC') + ->getQuery() + ->getResult() + ; + } + + public function findByPath(string $path): ?MailFolder + { + return $this->findOneBy(['path' => $path]); + } + } + ``` + +- [ ] **Step 3 : Vérifier la syntaxe PHP** + + ```bash + docker exec php-lesstime-fpm php -l src/Entity/MailFolder.php + docker exec php-lesstime-fpm php -l src/Repository/MailFolderRepository.php + ``` + + Attendu : `No syntax errors detected`. + +- [ ] **Step 4 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Entity/MailFolder.php src/Repository/MailFolderRepository.php + git commit -m "feat(mail) : MailFolder entity + repository" + ``` + +--- + +### Task 4 : Entité `MailMessage` + Repository + +- [ ] **Step 1 : Créer l'entité `MailMessage`** + + Créer `src/Entity/MailMessage.php` : + + ```php + id; + } + + public function getMessageId(): string + { + return $this->messageId; + } + + public function setMessageId(string $messageId): static + { + $this->messageId = $messageId; + + return $this; + } + + public function getFolder(): MailFolder + { + return $this->folder; + } + + public function setFolder(MailFolder $folder): static + { + $this->folder = $folder; + + return $this; + } + + public function getUid(): int + { + return $this->uid; + } + + public function setUid(int $uid): static + { + $this->uid = $uid; + + return $this; + } + + public function getSubject(): ?string + { + return $this->subject; + } + + public function setSubject(?string $subject): static + { + $this->subject = $subject; + + return $this; + } + + public function getFromAddress(): string + { + return $this->fromAddress; + } + + public function setFromAddress(string $fromAddress): static + { + $this->fromAddress = $fromAddress; + + return $this; + } + + public function getFromName(): ?string + { + return $this->fromName; + } + + public function setFromName(?string $fromName): static + { + $this->fromName = $fromName; + + return $this; + } + + public function getToAddresses(): array + { + return $this->toAddresses; + } + + public function setToAddresses(array $toAddresses): static + { + $this->toAddresses = $toAddresses; + + return $this; + } + + public function getCcAddresses(): ?array + { + return $this->ccAddresses; + } + + public function setCcAddresses(?array $ccAddresses): static + { + $this->ccAddresses = $ccAddresses; + + return $this; + } + + public function getSentAt(): DateTimeImmutable + { + return $this->sentAt; + } + + public function setSentAt(DateTimeImmutable $sentAt): static + { + $this->sentAt = $sentAt; + + return $this; + } + + public function isRead(): bool + { + return $this->isRead; + } + + public function setIsRead(bool $isRead): static + { + $this->isRead = $isRead; + + return $this; + } + + public function isFlagged(): bool + { + return $this->isFlagged; + } + + public function setIsFlagged(bool $isFlagged): static + { + $this->isFlagged = $isFlagged; + + return $this; + } + + public function hasAttachments(): bool + { + return $this->hasAttachments; + } + + public function setHasAttachments(bool $hasAttachments): static + { + $this->hasAttachments = $hasAttachments; + + return $this; + } + + public function getSnippet(): ?string + { + return $this->snippet; + } + + public function setSnippet(?string $snippet): static + { + $this->snippet = $snippet; + + return $this; + } + + public function getSyncedAt(): DateTimeImmutable + { + return $this->syncedAt; + } + + public function setSyncedAt(DateTimeImmutable $syncedAt): static + { + $this->syncedAt = $syncedAt; + + return $this; + } + } + ``` + +- [ ] **Step 2 : Créer le repository `MailMessageRepository`** + + Créer `src/Repository/MailMessageRepository.php` : + + ```php + findOneBy(['messageId' => $messageId]); + } + + public function findByFolderAndUid(MailFolder $folder, int $uid): ?MailMessage + { + return $this->findOneBy(['folder' => $folder, 'uid' => $uid]); + } + + /** + * @return list + */ + public function findByFolderPaginated(MailFolder $folder, int $limit, int $offset): array + { + return $this->createQueryBuilder('m') + ->andWhere('m.folder = :folder') + ->setParameter('folder', $folder) + ->orderBy('m.sentAt', 'DESC') + ->addOrderBy('m.id', 'DESC') + ->setMaxResults($limit) + ->setFirstResult($offset) + ->getQuery() + ->getResult() + ; + } + + public function countUnreadByFolder(MailFolder $folder): int + { + return (int) $this->createQueryBuilder('m') + ->select('COUNT(m.id)') + ->andWhere('m.folder = :folder') + ->andWhere('m.isRead = false') + ->setParameter('folder', $folder) + ->getQuery() + ->getSingleScalarResult() + ; + } + } + ``` + +- [ ] **Step 3 : Vérifier la syntaxe PHP** + + ```bash + docker exec php-lesstime-fpm php -l src/Entity/MailMessage.php + docker exec php-lesstime-fpm php -l src/Repository/MailMessageRepository.php + ``` + + Attendu : `No syntax errors detected`. + +- [ ] **Step 4 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Entity/MailMessage.php src/Repository/MailMessageRepository.php + git commit -m "feat(mail) : MailMessage entity + repository" + ``` + +--- + +### Task 5 : Entité `TaskMailLink` + Repository + +- [ ] **Step 1 : Créer l'entité `TaskMailLink`** + + Créer `src/Entity/TaskMailLink.php` : + + ```php + id; + } + + public function getTask(): Task + { + return $this->task; + } + + public function setTask(Task $task): static + { + $this->task = $task; + + return $this; + } + + public function getMailMessage(): MailMessage + { + return $this->mailMessage; + } + + public function setMailMessage(MailMessage $mailMessage): static + { + $this->mailMessage = $mailMessage; + + return $this; + } + + public function getLinkedAt(): DateTimeImmutable + { + return $this->linkedAt; + } + + public function setLinkedAt(DateTimeImmutable $linkedAt): static + { + $this->linkedAt = $linkedAt; + + return $this; + } + + public function getLinkedBy(): ?User + { + return $this->linkedBy; + } + + public function setLinkedBy(?User $linkedBy): static + { + $this->linkedBy = $linkedBy; + + return $this; + } + } + ``` + +- [ ] **Step 2 : Créer le repository `TaskMailLinkRepository`** + + Créer `src/Repository/TaskMailLinkRepository.php` : + + ```php + + */ + public function findByTask(Task $task): array + { + return $this->createQueryBuilder('l') + ->andWhere('l.task = :task') + ->setParameter('task', $task) + ->orderBy('l.linkedAt', 'DESC') + ->getQuery() + ->getResult() + ; + } + + public function findByTaskAndMessage(Task $task, MailMessage $message): ?TaskMailLink + { + return $this->findOneBy(['task' => $task, 'mailMessage' => $message]); + } + + /** + * @return list + */ + public function findByMessage(MailMessage $message): array + { + return $this->findBy(['mailMessage' => $message]); + } + } + ``` + +- [ ] **Step 3 : Vérifier la syntaxe PHP** + + ```bash + docker exec php-lesstime-fpm php -l src/Entity/TaskMailLink.php + docker exec php-lesstime-fpm php -l src/Repository/TaskMailLinkRepository.php + ``` + + Attendu : `No syntax errors detected`. + +- [ ] **Step 4 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Entity/TaskMailLink.php src/Repository/TaskMailLinkRepository.php + git commit -m "feat(mail) : TaskMailLink entity + repository" + ``` + +--- + +### Task 6 : Migration Doctrine unique (raw SQL, 4 tables + FK + index) + +- [ ] **Step 1 : Générer le squelette de migration vide** + + ```bash + docker exec php-lesstime-fpm php bin/console doctrine:migrations:generate + ``` + + Cela crée `migrations/Version.php`. Relever le nom exact du fichier créé (ex. `Version20260519120000.php`). + +- [ ] **Step 2 : Écrire le SQL UP complet dans la migration générée** + + Ouvrir le fichier `migrations/Version.php` et remplacer les méthodes `up()` et `down()` par : + + ```php + public function getDescription(): string + { + return 'Mail integration: create mail_configuration, mail_folder, mail_message, task_mail_link tables'; + } + + public function up(Schema $schema): void + { + $this->addSql(<<<'SQL' + CREATE TABLE mail_configuration ( + id SERIAL NOT NULL, + protocol VARCHAR(10) NOT NULL DEFAULT 'imap', + imap_host VARCHAR(255) DEFAULT NULL, + imap_port INT NOT NULL DEFAULT 993, + imap_encryption VARCHAR(10) NOT NULL DEFAULT 'ssl', + smtp_host VARCHAR(255) DEFAULT NULL, + smtp_port INT NOT NULL DEFAULT 465, + smtp_encryption VARCHAR(10) NOT NULL DEFAULT 'ssl', + username VARCHAR(255) DEFAULT NULL, + encrypted_password TEXT DEFAULT NULL, + sent_folder_path VARCHAR(255) NOT NULL DEFAULT 'Sent', + enabled BOOLEAN NOT NULL DEFAULT false, + PRIMARY KEY (id) + ) + SQL); + + $this->addSql(<<<'SQL' + CREATE TABLE mail_folder ( + id SERIAL NOT NULL, + path VARCHAR(500) NOT NULL, + display_name VARCHAR(255) NOT NULL, + parent_path VARCHAR(500) DEFAULT NULL, + unread_count INT NOT NULL DEFAULT 0, + total_count INT NOT NULL DEFAULT 0, + last_synced_at TIMESTAMPTZ DEFAULT NULL, + PRIMARY KEY (id) + ) + SQL); + + $this->addSql('CREATE UNIQUE INDEX uq_mail_folder_path ON mail_folder (path)'); + $this->addSql('CREATE INDEX idx_mail_folder_parent_path ON mail_folder (parent_path)'); + + $this->addSql(<<<'SQL' + CREATE TABLE mail_message ( + id SERIAL NOT NULL, + message_id VARCHAR(500) NOT NULL, + folder_id INT NOT NULL, + uid INT NOT NULL, + subject VARCHAR(500) DEFAULT NULL, + from_address VARCHAR(255) NOT NULL, + from_name VARCHAR(255) DEFAULT NULL, + to_addresses JSONB NOT NULL, + cc_addresses JSONB DEFAULT NULL, + sent_at TIMESTAMPTZ NOT NULL, + is_read BOOLEAN NOT NULL DEFAULT false, + is_flagged BOOLEAN NOT NULL DEFAULT false, + has_attachments BOOLEAN NOT NULL DEFAULT false, + snippet TEXT DEFAULT NULL, + synced_at TIMESTAMPTZ NOT NULL, + PRIMARY KEY (id) + ) + SQL); + + $this->addSql('CREATE UNIQUE INDEX uq_mail_message_message_id ON mail_message (message_id)'); + $this->addSql('CREATE UNIQUE INDEX uq_mail_message_folder_uid ON mail_message (folder_id, uid)'); + $this->addSql('CREATE INDEX idx_mail_message_sent_at ON mail_message (sent_at)'); + $this->addSql('CREATE INDEX idx_mail_message_is_read ON mail_message (is_read)'); + $this->addSql('ALTER TABLE mail_message ADD CONSTRAINT fk_mail_message_folder FOREIGN KEY (folder_id) REFERENCES mail_folder (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + + $this->addSql(<<<'SQL' + CREATE TABLE task_mail_link ( + id SERIAL NOT NULL, + task_id INT NOT NULL, + mail_message_id INT NOT NULL, + linked_at TIMESTAMPTZ NOT NULL, + linked_by_id INT DEFAULT NULL, + PRIMARY KEY (id) + ) + SQL); + + $this->addSql('CREATE UNIQUE INDEX uq_task_mail_link ON task_mail_link (task_id, mail_message_id)'); + $this->addSql('CREATE INDEX idx_task_mail_link_task ON task_mail_link (task_id)'); + $this->addSql('CREATE INDEX idx_task_mail_link_message ON task_mail_link (mail_message_id)'); + $this->addSql('ALTER TABLE task_mail_link ADD CONSTRAINT fk_task_mail_link_task FOREIGN KEY (task_id) REFERENCES task (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE task_mail_link ADD CONSTRAINT fk_task_mail_link_message FOREIGN KEY (mail_message_id) REFERENCES mail_message (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE task_mail_link ADD CONSTRAINT fk_task_mail_link_user FOREIGN KEY (linked_by_id) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE task_mail_link DROP CONSTRAINT fk_task_mail_link_task'); + $this->addSql('ALTER TABLE task_mail_link DROP CONSTRAINT fk_task_mail_link_message'); + $this->addSql('ALTER TABLE task_mail_link DROP CONSTRAINT fk_task_mail_link_user'); + $this->addSql('DROP TABLE task_mail_link'); + + $this->addSql('ALTER TABLE mail_message DROP CONSTRAINT fk_mail_message_folder'); + $this->addSql('DROP TABLE mail_message'); + + $this->addSql('DROP TABLE mail_folder'); + + $this->addSql('DROP TABLE mail_configuration'); + } + ``` + + > Attention : `declare(strict_types=1);` doit être présent en tête du fichier (il est présent dans le squelette généré par Doctrine). + +- [ ] **Step 3 : Lancer la migration** + + ```bash + make migration-migrate + ``` + + Attendu : migration exécutée sans erreur SQL. + +- [ ] **Step 4 : Valider le schéma Doctrine** + + ```bash + docker exec php-lesstime-fpm php bin/console doctrine:schema:validate + ``` + + Attendu : `[Mapping] OK - The mapping files are correct.` et `[Database] OK - The database schema is in sync with the mapping files.` + + > Si des différences sont signalées (ex. type `json` vs `jsonb`), ajuster l'attribut `#[ORM\Column(type: 'json')]` en `#[ORM\Column(type: 'jsonb')]` dans `MailMessage.php` si le mapping Doctrine du projet est configuré pour `jsonb`. Dans le doute, `json` est le type Doctrine standard et PostgreSQL accepte les deux. + +- [ ] **Step 5 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add migrations/ + git commit -m "feat(mail) : migration — 4 tables mail_configuration, mail_folder, mail_message, task_mail_link" + ``` + +--- + +### Task 7 : DTOs (4 fichiers sous `src/Mail/Dto/`) + +- [ ] **Step 1 : Créer `MailFolderDto`** + + Créer `src/Mail/Dto/MailFolderDto.php` : + + ```php + $attachments + */ + public function __construct( + public MailMessageHeaderDto $header, + public ?string $bodyHtml, + public ?string $bodyText, + public array $attachments, + ) {} + } + ``` + +- [ ] **Step 5 : Vérifier la syntaxe des 4 DTOs** + + ```bash + docker exec php-lesstime-fpm php -l src/Mail/Dto/MailFolderDto.php + docker exec php-lesstime-fpm php -l src/Mail/Dto/MailMessageHeaderDto.php + docker exec php-lesstime-fpm php -l src/Mail/Dto/MailAttachmentDto.php + docker exec php-lesstime-fpm php -l src/Mail/Dto/MailMessageDetailDto.php + ``` + + Attendu : `No syntax errors detected` pour chacun. + +- [ ] **Step 6 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Mail/Dto/ + git commit -m "feat(mail) : DTOs — MailFolderDto, MailMessageHeaderDto, MailAttachmentDto, MailMessageDetailDto" + ``` + +--- + +### Task 8 : Interface `MailProviderInterface` + Exception `MailProviderException` + +- [ ] **Step 1 : Créer `MailProviderException`** + + Créer `src/Mail/Exception/MailProviderException.php` : + + ```php + + * + * @throws MailProviderException + */ + public function listFolders(): array; + + /** + * Returns a paginated list of message headers for the given folder. + * + * @return list + * + * @throws MailProviderException + */ + public function listMessages(string $folderPath, int $limit, int $offset): array; + + /** + * Fetches the full message (headers + body + attachments list) by UID. + * + * @throws MailProviderException + */ + public function fetchMessage(string $folderPath, int $uid): MailMessageDetailDto; + + /** + * Marks a message as read or unread on the IMAP server. + * + * @throws MailProviderException + */ + public function markRead(string $folderPath, int $uid, bool $read): void; + + /** + * Marks a message as flagged (starred) or unflagged on the IMAP server. + * + * @throws MailProviderException + */ + public function markFlagged(string $folderPath, int $uid, bool $flagged): void; + + /** + * Moves a message from one folder to another on the IMAP server. + * + * @throws MailProviderException + */ + public function moveMessage(string $folderPath, int $uid, string $targetFolder): void; + + /** + * Fetches the raw binary content of an attachment by its MIME part number. + * + * @throws MailProviderException + */ + public function fetchAttachment(string $folderPath, int $uid, string $partNumber): string; + } + ``` + +- [ ] **Step 3 : Vérifier la syntaxe** + + ```bash + docker exec php-lesstime-fpm php -l src/Mail/Exception/MailProviderException.php + docker exec php-lesstime-fpm php -l src/Mail/MailProviderInterface.php + ``` + + Attendu : `No syntax errors detected`. + +- [ ] **Step 4 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Mail/MailProviderInterface.php src/Mail/Exception/MailProviderException.php + git commit -m "feat(mail) : MailProviderInterface + MailProviderException" + ``` + +--- + +### Task 9 : Fixture `MailConfiguration` désactivée (OVH defaults) + +- [ ] **Step 1 : Modifier `AppFixtures.php`** + + Ouvrir `src/DataFixtures/AppFixtures.php`. + + Ajouter l'import en tête du bloc `use` (après `use App\Entity\ZimbraConfiguration;`) : + + ```php + use App\Entity\MailConfiguration; + ``` + + Puis ajouter le bloc fixture juste **avant** `$manager->flush();` (après le bloc TaskRecurrence existant) : + + ```php + // ============================================= + // Mail Configuration + // ============================================= + $mailConfig = new MailConfiguration(); + $mailConfig->setImapHost('ssl0.ovh.net'); + $mailConfig->setImapPort(993); + $mailConfig->setImapEncryption('ssl'); + $mailConfig->setSmtpHost('ssl0.ovh.net'); + $mailConfig->setSmtpPort(465); + $mailConfig->setSmtpEncryption('ssl'); + $mailConfig->setUsername('lesstime@ovh.fr'); + $mailConfig->setSentFolderPath('Sent'); + $mailConfig->setEnabled(false); + $manager->persist($mailConfig); + ``` + +- [ ] **Step 2 : Vérifier la syntaxe** + + ```bash + docker exec php-lesstime-fpm php -l src/DataFixtures/AppFixtures.php + ``` + + Attendu : `No syntax errors detected`. + +- [ ] **Step 3 : Charger les fixtures pour valider** + + ```bash + make fixtures + ``` + + Attendu : pas d'erreur. Vérifier en BDD : + + ```bash + docker exec php-lesstime-fpm php bin/console dbal:run-sql "SELECT id, imap_host, smtp_host, enabled FROM mail_configuration" + ``` + + Attendu : une ligne avec `ssl0.ovh.net`, `ssl0.ovh.net`, `f`. + +- [ ] **Step 4 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/DataFixtures/AppFixtures.php + git commit -m "feat(mail) : fixture MailConfiguration OVH defaults (disabled)" + ``` + +--- + +### Task 10 : Validation finale + +- [ ] **Step 1 : Valider le mapping Doctrine** + + ```bash + docker exec php-lesstime-fpm php bin/console doctrine:schema:validate + ``` + + Attendu : + ``` + [Mapping] OK - The mapping files are correct. + [Database] OK - The database schema is in sync with the mapping files. + ``` + + Si des erreurs apparaissent, les corriger (type mismatch `json` vs `jsonb`, colonnes manquantes, etc.) avant de continuer. + +- [ ] **Step 2 : Lancer la suite de tests complète** + + ```bash + make test + ``` + + Attendu : tous les tests passent (au minimum les 2 tests `MailConfigurationRepositoryTest`). + +- [ ] **Step 3 : PHP CS Fixer sur l'ensemble des fichiers modifiés** + + ```bash + make php-cs-fixer-allow-risky + ``` + + Si des fichiers sont modifiés par le fixer, les re-stageer et committer : + + ```bash + git add -p + git commit -m "style(mail) : php-cs-fixer pass" + ``` + +- [ ] **Step 4 : Vider le cache Symfony** + + ```bash + make cache-clear + ``` + + Attendu : pas d'erreur de configuration ou de service manquant. + +- [ ] **Step 5 : Vérification rapide du conteneur de services** + + ```bash + docker exec php-lesstime-fpm php bin/console debug:autowiring MailConfiguration 2>&1 | head -20 + ``` + + Attendu : `App\Repository\MailConfigurationRepository` apparaît dans la liste des services autowirables. + +- [ ] **Step 6 : Résumé des commits de la phase** + + Vérifier l'historique : + + ```bash + git log --oneline feat/mail-integration ^develop | head -20 + ``` + + Les commits attendus (dans l'ordre chronologique) : + 1. `feat(mail) : MailConfiguration entity + repository + singleton test` + 2. `feat(mail) : MailFolder entity + repository` + 3. `feat(mail) : MailMessage entity + repository` + 4. `feat(mail) : TaskMailLink entity + repository` + 5. `feat(mail) : migration — 4 tables mail_configuration, mail_folder, mail_message, task_mail_link` + 6. `feat(mail) : DTOs — MailFolderDto, MailMessageHeaderDto, MailAttachmentDto, MailMessageDetailDto` + 7. `feat(mail) : MailProviderInterface + MailProviderException` + 8. `feat(mail) : fixture MailConfiguration OVH defaults (disabled)` + +--- + +### Self-Review + +#### Cohérence des noms + +| Concept | Classe PHP | Table SQL | Repository | +|---|---|---|---| +| Configuration singleton | `MailConfiguration` | `mail_configuration` | `MailConfigurationRepository` | +| Cache dossiers IMAP | `MailFolder` | `mail_folder` | `MailFolderRepository` | +| Cache messages IMAP | `MailMessage` | `mail_message` | `MailMessageRepository` | +| Lien tâche ↔ mail | `TaskMailLink` | `task_mail_link` | `TaskMailLinkRepository` | +| DTO dossier | `MailFolderDto` | — | — | +| DTO header message | `MailMessageHeaderDto` | — | — | +| DTO pièce jointe | `MailAttachmentDto` | — | — | +| DTO message complet | `MailMessageDetailDto` | — | — | +| Interface provider | `MailProviderInterface` | — | — | +| Exception provider | `MailProviderException` | — | — | + +#### Checklist finale avant de valider Phase 1 + +- [ ] `declare(strict_types=1);` présent en tête de chaque fichier PHP créé +- [ ] Toutes les FK définies avec `ON DELETE CASCADE` (sauf `task_mail_link.linked_by_id` → `ON DELETE SET NULL`) +- [ ] `UNIQUE INDEX` sur `mail_folder.path`, `mail_message.message_id`, `(mail_message.folder_id, mail_message.uid)`, `(task_mail_link.task_id, task_mail_link.mail_message_id)` +- [ ] `INDEX` sur `mail_folder.parent_path`, `mail_message.sent_at`, `mail_message.is_read` +- [ ] Table `"user"` entre guillemets dans le SQL de la migration (réservé PostgreSQL) +- [ ] `MailConfiguration.encryptedPassword` est `null` par défaut en fixture (pas de mot de passe en clair) +- [ ] DTOs déclarés `final readonly class` (PHP 8.2+) +- [ ] `MailProviderInterface` contient exactement 7 méthodes : `listFolders`, `listMessages`, `fetchMessage`, `markRead`, `markFlagged`, `moveMessage`, `fetchAttachment` +- [ ] `make test` vert (2 tests minimum) +- [ ] `doctrine:schema:validate` OK +- [ ] Aucune logique métier IMAP implémentée (Phase 2) +- [ ] `feat/mail-integration` est la branche de travail (pas `develop`) + +#### Remise à la review humaine + +Une fois tous les steps cochés, pousser la branche et notifier le user : + +```bash +git push -u origin feat/mail-integration +``` + +Indiquer au user : +- Nombre de fichiers créés : 17 +- Tables créées : 4 (`mail_configuration`, `mail_folder`, `mail_message`, `task_mail_link`) +- Tests : 2 (MailConfigurationRepositoryTest) +- Prêt pour Phase 2 : IMAP provider + MailSyncService From 3cac87aa2429b98f7c07902e797ed07ef638a6fe Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 23:15:47 +0200 Subject: [PATCH 03/69] feat(mail) : MailConfiguration entity + repository + singleton test --- phpunit.dist.xml | 1 + src/Entity/MailConfiguration.php | 193 ++++++++++++++++++ .../MailConfigurationRepository.php | 26 +++ .../MailConfigurationRepositoryTest.php | 50 +++++ 4 files changed, 270 insertions(+) create mode 100644 src/Entity/MailConfiguration.php create mode 100644 src/Repository/MailConfigurationRepository.php create mode 100644 tests/Unit/Repository/MailConfigurationRepositoryTest.php diff --git a/phpunit.dist.xml b/phpunit.dist.xml index 22bd879..eb794bd 100644 --- a/phpunit.dist.xml +++ b/phpunit.dist.xml @@ -15,6 +15,7 @@ + diff --git a/src/Entity/MailConfiguration.php b/src/Entity/MailConfiguration.php new file mode 100644 index 0000000..b1af5b1 --- /dev/null +++ b/src/Entity/MailConfiguration.php @@ -0,0 +1,193 @@ +id; + } + + public function getProtocol(): string + { + return $this->protocol; + } + + public function setProtocol(string $protocol): static + { + $this->protocol = $protocol; + + return $this; + } + + public function getImapHost(): ?string + { + return $this->imapHost; + } + + public function setImapHost(?string $imapHost): static + { + $this->imapHost = $imapHost; + + return $this; + } + + public function getImapPort(): int + { + return $this->imapPort; + } + + public function setImapPort(int $imapPort): static + { + $this->imapPort = $imapPort; + + return $this; + } + + public function getImapEncryption(): string + { + return $this->imapEncryption; + } + + public function setImapEncryption(string $imapEncryption): static + { + $this->imapEncryption = $imapEncryption; + + return $this; + } + + public function getSmtpHost(): ?string + { + return $this->smtpHost; + } + + public function setSmtpHost(?string $smtpHost): static + { + $this->smtpHost = $smtpHost; + + return $this; + } + + public function getSmtpPort(): int + { + return $this->smtpPort; + } + + public function setSmtpPort(int $smtpPort): static + { + $this->smtpPort = $smtpPort; + + return $this; + } + + public function getSmtpEncryption(): string + { + return $this->smtpEncryption; + } + + public function setSmtpEncryption(string $smtpEncryption): static + { + $this->smtpEncryption = $smtpEncryption; + + return $this; + } + + public function getUsername(): ?string + { + return $this->username; + } + + public function setUsername(?string $username): static + { + $this->username = $username; + + return $this; + } + + public function getEncryptedPassword(): ?string + { + return $this->encryptedPassword; + } + + public function setEncryptedPassword(?string $encryptedPassword): static + { + $this->encryptedPassword = $encryptedPassword; + + return $this; + } + + public function getSentFolderPath(): string + { + return $this->sentFolderPath; + } + + public function setSentFolderPath(string $sentFolderPath): static + { + $this->sentFolderPath = $sentFolderPath; + + return $this; + } + + public function isEnabled(): bool + { + return $this->enabled; + } + + public function setEnabled(bool $enabled): static + { + $this->enabled = $enabled; + + return $this; + } + + public function hasPassword(): bool + { + return null !== $this->encryptedPassword; + } +} diff --git a/src/Repository/MailConfigurationRepository.php b/src/Repository/MailConfigurationRepository.php new file mode 100644 index 0000000..9be3993 --- /dev/null +++ b/src/Repository/MailConfigurationRepository.php @@ -0,0 +1,26 @@ +createQueryBuilder('m') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult() + ; + } +} diff --git a/tests/Unit/Repository/MailConfigurationRepositoryTest.php b/tests/Unit/Repository/MailConfigurationRepositoryTest.php new file mode 100644 index 0000000..f903ee6 --- /dev/null +++ b/tests/Unit/Repository/MailConfigurationRepositoryTest.php @@ -0,0 +1,50 @@ +repository = $container->get(MailConfigurationRepository::class); + $this->em = $container->get('doctrine.orm.entity_manager'); + $this->em->getConnection()->executeStatement('TRUNCATE TABLE mail_configuration RESTART IDENTITY CASCADE'); + } + + public function testFindSingletonReturnsNullWhenEmpty(): void + { + $result = $this->repository->findSingleton(); + + self::assertNull($result); + } + + public function testFindSingletonReturnsFirstRecord(): void + { + $config = new MailConfiguration(); + $config->setImapHost('ssl0.ovh.net'); + $config->setEnabled(false); + $this->em->persist($config); + $this->em->flush(); + + $result = $this->repository->findSingleton(); + + self::assertInstanceOf(MailConfiguration::class, $result); + self::assertSame('ssl0.ovh.net', $result->getImapHost()); + self::assertFalse($result->isEnabled()); + } +} From 0c80159d7e108eccc050e0f7cd4d7898b7c00db4 Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 23:16:17 +0200 Subject: [PATCH 04/69] feat(mail) : MailFolder entity + repository --- src/Entity/MailFolder.php | 115 ++++++++++++++++++++++++ src/Repository/MailFolderRepository.php | 34 +++++++ 2 files changed, 149 insertions(+) create mode 100644 src/Entity/MailFolder.php create mode 100644 src/Repository/MailFolderRepository.php diff --git a/src/Entity/MailFolder.php b/src/Entity/MailFolder.php new file mode 100644 index 0000000..0b2462d --- /dev/null +++ b/src/Entity/MailFolder.php @@ -0,0 +1,115 @@ +id; + } + + public function getPath(): string + { + return $this->path; + } + + public function setPath(string $path): static + { + $this->path = $path; + + return $this; + } + + public function getDisplayName(): string + { + return $this->displayName; + } + + public function setDisplayName(string $displayName): static + { + $this->displayName = $displayName; + + return $this; + } + + public function getParentPath(): ?string + { + return $this->parentPath; + } + + public function setParentPath(?string $parentPath): static + { + $this->parentPath = $parentPath; + + return $this; + } + + public function getUnreadCount(): int + { + return $this->unreadCount; + } + + public function setUnreadCount(int $unreadCount): static + { + $this->unreadCount = $unreadCount; + + return $this; + } + + public function getTotalCount(): int + { + return $this->totalCount; + } + + public function setTotalCount(int $totalCount): static + { + $this->totalCount = $totalCount; + + return $this; + } + + public function getLastSyncedAt(): ?DateTimeImmutable + { + return $this->lastSyncedAt; + } + + public function setLastSyncedAt(?DateTimeImmutable $lastSyncedAt): static + { + $this->lastSyncedAt = $lastSyncedAt; + + return $this; + } +} diff --git a/src/Repository/MailFolderRepository.php b/src/Repository/MailFolderRepository.php new file mode 100644 index 0000000..d228d35 --- /dev/null +++ b/src/Repository/MailFolderRepository.php @@ -0,0 +1,34 @@ + + */ + public function findAllOrderedByPath(): array + { + return $this->createQueryBuilder('f') + ->orderBy('f.path', 'ASC') + ->getQuery() + ->getResult() + ; + } + + public function findByPath(string $path): ?MailFolder + { + return $this->findOneBy(['path' => $path]); + } +} From 0c597bc653b747b4117b4469b1fe0d1eac0a0d16 Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 23:16:52 +0200 Subject: [PATCH 05/69] feat(mail) : MailMessage entity + repository --- src/Entity/MailMessage.php | 238 +++++++++++++++++++++++ src/Repository/MailMessageRepository.php | 57 ++++++ 2 files changed, 295 insertions(+) create mode 100644 src/Entity/MailMessage.php create mode 100644 src/Repository/MailMessageRepository.php diff --git a/src/Entity/MailMessage.php b/src/Entity/MailMessage.php new file mode 100644 index 0000000..55524f5 --- /dev/null +++ b/src/Entity/MailMessage.php @@ -0,0 +1,238 @@ +id; + } + + public function getMessageId(): string + { + return $this->messageId; + } + + public function setMessageId(string $messageId): static + { + $this->messageId = $messageId; + + return $this; + } + + public function getFolder(): MailFolder + { + return $this->folder; + } + + public function setFolder(MailFolder $folder): static + { + $this->folder = $folder; + + return $this; + } + + public function getUid(): int + { + return $this->uid; + } + + public function setUid(int $uid): static + { + $this->uid = $uid; + + return $this; + } + + public function getSubject(): ?string + { + return $this->subject; + } + + public function setSubject(?string $subject): static + { + $this->subject = $subject; + + return $this; + } + + public function getFromAddress(): string + { + return $this->fromAddress; + } + + public function setFromAddress(string $fromAddress): static + { + $this->fromAddress = $fromAddress; + + return $this; + } + + public function getFromName(): ?string + { + return $this->fromName; + } + + public function setFromName(?string $fromName): static + { + $this->fromName = $fromName; + + return $this; + } + + public function getToAddresses(): array + { + return $this->toAddresses; + } + + public function setToAddresses(array $toAddresses): static + { + $this->toAddresses = $toAddresses; + + return $this; + } + + public function getCcAddresses(): ?array + { + return $this->ccAddresses; + } + + public function setCcAddresses(?array $ccAddresses): static + { + $this->ccAddresses = $ccAddresses; + + return $this; + } + + public function getSentAt(): DateTimeImmutable + { + return $this->sentAt; + } + + public function setSentAt(DateTimeImmutable $sentAt): static + { + $this->sentAt = $sentAt; + + return $this; + } + + public function isRead(): bool + { + return $this->isRead; + } + + public function setIsRead(bool $isRead): static + { + $this->isRead = $isRead; + + return $this; + } + + public function isFlagged(): bool + { + return $this->isFlagged; + } + + public function setIsFlagged(bool $isFlagged): static + { + $this->isFlagged = $isFlagged; + + return $this; + } + + public function hasAttachments(): bool + { + return $this->hasAttachments; + } + + public function setHasAttachments(bool $hasAttachments): static + { + $this->hasAttachments = $hasAttachments; + + return $this; + } + + public function getSnippet(): ?string + { + return $this->snippet; + } + + public function setSnippet(?string $snippet): static + { + $this->snippet = $snippet; + + return $this; + } + + public function getSyncedAt(): DateTimeImmutable + { + return $this->syncedAt; + } + + public function setSyncedAt(DateTimeImmutable $syncedAt): static + { + $this->syncedAt = $syncedAt; + + return $this; + } +} diff --git a/src/Repository/MailMessageRepository.php b/src/Repository/MailMessageRepository.php new file mode 100644 index 0000000..bc4bef9 --- /dev/null +++ b/src/Repository/MailMessageRepository.php @@ -0,0 +1,57 @@ +findOneBy(['messageId' => $messageId]); + } + + public function findByFolderAndUid(MailFolder $folder, int $uid): ?MailMessage + { + return $this->findOneBy(['folder' => $folder, 'uid' => $uid]); + } + + /** + * @return list + */ + public function findByFolderPaginated(MailFolder $folder, int $limit, int $offset): array + { + return $this->createQueryBuilder('m') + ->andWhere('m.folder = :folder') + ->setParameter('folder', $folder) + ->orderBy('m.sentAt', 'DESC') + ->addOrderBy('m.id', 'DESC') + ->setMaxResults($limit) + ->setFirstResult($offset) + ->getQuery() + ->getResult() + ; + } + + public function countUnreadByFolder(MailFolder $folder): int + { + return (int) $this->createQueryBuilder('m') + ->select('COUNT(m.id)') + ->andWhere('m.folder = :folder') + ->andWhere('m.isRead = false') + ->setParameter('folder', $folder) + ->getQuery() + ->getSingleScalarResult() + ; + } +} From cd9c16a990dd1699f6e0d51f1302b2d5597a3b85 Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 23:17:16 +0200 Subject: [PATCH 06/69] feat(mail) : TaskMailLink entity + repository --- src/Entity/TaskMailLink.php | 88 +++++++++++++++++++++++ src/Repository/TaskMailLinkRepository.php | 46 ++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 src/Entity/TaskMailLink.php create mode 100644 src/Repository/TaskMailLinkRepository.php diff --git a/src/Entity/TaskMailLink.php b/src/Entity/TaskMailLink.php new file mode 100644 index 0000000..652fc22 --- /dev/null +++ b/src/Entity/TaskMailLink.php @@ -0,0 +1,88 @@ +id; + } + + public function getTask(): Task + { + return $this->task; + } + + public function setTask(Task $task): static + { + $this->task = $task; + + return $this; + } + + public function getMailMessage(): MailMessage + { + return $this->mailMessage; + } + + public function setMailMessage(MailMessage $mailMessage): static + { + $this->mailMessage = $mailMessage; + + return $this; + } + + public function getLinkedAt(): DateTimeImmutable + { + return $this->linkedAt; + } + + public function setLinkedAt(DateTimeImmutable $linkedAt): static + { + $this->linkedAt = $linkedAt; + + return $this; + } + + public function getLinkedBy(): ?User + { + return $this->linkedBy; + } + + public function setLinkedBy(?User $linkedBy): static + { + $this->linkedBy = $linkedBy; + + return $this; + } +} diff --git a/src/Repository/TaskMailLinkRepository.php b/src/Repository/TaskMailLinkRepository.php new file mode 100644 index 0000000..565e71d --- /dev/null +++ b/src/Repository/TaskMailLinkRepository.php @@ -0,0 +1,46 @@ + + */ + public function findByTask(Task $task): array + { + return $this->createQueryBuilder('l') + ->andWhere('l.task = :task') + ->setParameter('task', $task) + ->orderBy('l.linkedAt', 'DESC') + ->getQuery() + ->getResult() + ; + } + + public function findByTaskAndMessage(Task $task, MailMessage $message): ?TaskMailLink + { + return $this->findOneBy(['task' => $task, 'mailMessage' => $message]); + } + + /** + * @return list + */ + public function findByMessage(MailMessage $message): array + { + return $this->findBy(['mailMessage' => $message]); + } +} From 0da26ff4181c9497505339c577d9df7be4322b02 Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 23:20:03 +0200 Subject: [PATCH 07/69] =?UTF-8?q?feat(mail)=20:=20migration=20=E2=80=94=20?= =?UTF-8?q?4=20tables=20mail=5Fconfiguration,=20mail=5Ffolder,=20mail=5Fme?= =?UTF-8?q?ssage,=20task=5Fmail=5Flink?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- migrations/Version20260519211723.php | 115 +++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 migrations/Version20260519211723.php diff --git a/migrations/Version20260519211723.php b/migrations/Version20260519211723.php new file mode 100644 index 0000000..90fec2e --- /dev/null +++ b/migrations/Version20260519211723.php @@ -0,0 +1,115 @@ +addSql(<<<'SQL' + CREATE TABLE mail_configuration ( + id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + protocol VARCHAR(10) NOT NULL, + imap_host VARCHAR(255) DEFAULT NULL, + imap_port INT NOT NULL, + imap_encryption VARCHAR(10) NOT NULL, + smtp_host VARCHAR(255) DEFAULT NULL, + smtp_port INT NOT NULL, + smtp_encryption VARCHAR(10) NOT NULL, + username VARCHAR(255) DEFAULT NULL, + encrypted_password TEXT DEFAULT NULL, + sent_folder_path VARCHAR(255) NOT NULL, + enabled BOOLEAN NOT NULL, + PRIMARY KEY (id) + ) + SQL); + + $this->addSql(<<<'SQL' + CREATE TABLE mail_folder ( + id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + path VARCHAR(500) NOT NULL, + display_name VARCHAR(255) NOT NULL, + parent_path VARCHAR(500) DEFAULT NULL, + unread_count INT NOT NULL, + total_count INT NOT NULL, + last_synced_at TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL, + PRIMARY KEY (id) + ) + SQL); + + $this->addSql('CREATE UNIQUE INDEX UNIQ_319BB6A6B548B0F ON mail_folder (path)'); + $this->addSql('CREATE INDEX idx_mail_folder_parent_path ON mail_folder (parent_path)'); + + $this->addSql(<<<'SQL' + CREATE TABLE mail_message ( + id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + message_id VARCHAR(500) NOT NULL, + folder_id INT NOT NULL, + uid INT NOT NULL, + subject VARCHAR(500) DEFAULT NULL, + from_address VARCHAR(255) NOT NULL, + from_name VARCHAR(255) DEFAULT NULL, + to_addresses JSON NOT NULL, + cc_addresses JSON DEFAULT NULL, + sent_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, + is_read BOOLEAN NOT NULL, + is_flagged BOOLEAN NOT NULL, + has_attachments BOOLEAN NOT NULL, + snippet TEXT DEFAULT NULL, + synced_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, + PRIMARY KEY (id) + ) + SQL); + + $this->addSql('CREATE UNIQUE INDEX UNIQ_6C00B110537A1329 ON mail_message (message_id)'); + $this->addSql('CREATE UNIQUE INDEX uq_mail_message_folder_uid ON mail_message (folder_id, uid)'); + $this->addSql('CREATE INDEX IDX_6C00B110162CB942 ON mail_message (folder_id)'); + $this->addSql('CREATE INDEX idx_mail_message_sent_at ON mail_message (sent_at)'); + $this->addSql('CREATE INDEX idx_mail_message_is_read ON mail_message (is_read)'); + $this->addSql('ALTER TABLE mail_message ADD CONSTRAINT FK_6C00B110162CB942 FOREIGN KEY (folder_id) REFERENCES mail_folder (id) ON DELETE CASCADE NOT DEFERRABLE'); + + $this->addSql(<<<'SQL' + CREATE TABLE task_mail_link ( + id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + task_id INT NOT NULL, + mail_message_id INT NOT NULL, + linked_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, + linked_by_id INT DEFAULT NULL, + PRIMARY KEY (id) + ) + SQL); + + $this->addSql('CREATE UNIQUE INDEX uq_task_mail_link ON task_mail_link (task_id, mail_message_id)'); + $this->addSql('CREATE INDEX IDX_E4FDC7C98DB60186 ON task_mail_link (task_id)'); + $this->addSql('CREATE INDEX IDX_E4FDC7C987B9F9D5 ON task_mail_link (mail_message_id)'); + $this->addSql('CREATE INDEX IDX_E4FDC7C91AE3CFF3 ON task_mail_link (linked_by_id)'); + $this->addSql('ALTER TABLE task_mail_link ADD CONSTRAINT FK_E4FDC7C98DB60186 FOREIGN KEY (task_id) REFERENCES task (id) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('ALTER TABLE task_mail_link ADD CONSTRAINT FK_E4FDC7C987B9F9D5 FOREIGN KEY (mail_message_id) REFERENCES mail_message (id) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('ALTER TABLE task_mail_link ADD CONSTRAINT FK_E4FDC7C91AE3CFF3 FOREIGN KEY (linked_by_id) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE task_mail_link DROP CONSTRAINT FK_E4FDC7C98DB60186'); + $this->addSql('ALTER TABLE task_mail_link DROP CONSTRAINT FK_E4FDC7C987B9F9D5'); + $this->addSql('ALTER TABLE task_mail_link DROP CONSTRAINT FK_E4FDC7C91AE3CFF3'); + $this->addSql('DROP TABLE task_mail_link'); + + $this->addSql('ALTER TABLE mail_message DROP CONSTRAINT FK_6C00B110162CB942'); + $this->addSql('DROP TABLE mail_message'); + + $this->addSql('DROP TABLE mail_folder'); + + $this->addSql('DROP TABLE mail_configuration'); + } +} From 697197864faca208cb78855e7b7a0ed9a67023c3 Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 23:20:35 +0200 Subject: [PATCH 08/69] =?UTF-8?q?feat(mail)=20:=20DTOs=20=E2=80=94=20MailF?= =?UTF-8?q?olderDto,=20MailMessageHeaderDto,=20MailAttachmentDto,=20MailMe?= =?UTF-8?q?ssageDetailDto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Mail/Dto/MailAttachmentDto.php | 15 +++++++++++++++ src/Mail/Dto/MailFolderDto.php | 16 ++++++++++++++++ src/Mail/Dto/MailMessageDetailDto.php | 18 ++++++++++++++++++ src/Mail/Dto/MailMessageHeaderDto.php | 25 +++++++++++++++++++++++++ 4 files changed, 74 insertions(+) create mode 100644 src/Mail/Dto/MailAttachmentDto.php create mode 100644 src/Mail/Dto/MailFolderDto.php create mode 100644 src/Mail/Dto/MailMessageDetailDto.php create mode 100644 src/Mail/Dto/MailMessageHeaderDto.php diff --git a/src/Mail/Dto/MailAttachmentDto.php b/src/Mail/Dto/MailAttachmentDto.php new file mode 100644 index 0000000..cb6362a --- /dev/null +++ b/src/Mail/Dto/MailAttachmentDto.php @@ -0,0 +1,15 @@ + $attachments + */ + public function __construct( + public MailMessageHeaderDto $header, + public ?string $bodyHtml, + public ?string $bodyText, + public array $attachments, + ) {} +} diff --git a/src/Mail/Dto/MailMessageHeaderDto.php b/src/Mail/Dto/MailMessageHeaderDto.php new file mode 100644 index 0000000..fe9d022 --- /dev/null +++ b/src/Mail/Dto/MailMessageHeaderDto.php @@ -0,0 +1,25 @@ + Date: Tue, 19 May 2026 23:20:58 +0200 Subject: [PATCH 09/69] feat(mail) : MailProviderInterface + MailProviderException --- src/Mail/Exception/MailProviderException.php | 20 ++++++ src/Mail/MailProviderInterface.php | 66 ++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 src/Mail/Exception/MailProviderException.php create mode 100644 src/Mail/MailProviderInterface.php diff --git a/src/Mail/Exception/MailProviderException.php b/src/Mail/Exception/MailProviderException.php new file mode 100644 index 0000000..af4e6da --- /dev/null +++ b/src/Mail/Exception/MailProviderException.php @@ -0,0 +1,20 @@ + + * + * @throws MailProviderException + */ + public function listFolders(): array; + + /** + * Returns a paginated list of message headers for the given folder. + * + * @return list + * + * @throws MailProviderException + */ + public function listMessages(string $folderPath, int $limit, int $offset): array; + + /** + * Fetches the full message (headers + body + attachments list) by UID. + * + * @throws MailProviderException + */ + public function fetchMessage(string $folderPath, int $uid): MailMessageDetailDto; + + /** + * Marks a message as read or unread on the IMAP server. + * + * @throws MailProviderException + */ + public function markRead(string $folderPath, int $uid, bool $read): void; + + /** + * Marks a message as flagged (starred) or unflagged on the IMAP server. + * + * @throws MailProviderException + */ + public function markFlagged(string $folderPath, int $uid, bool $flagged): void; + + /** + * Moves a message from one folder to another on the IMAP server. + * + * @throws MailProviderException + */ + public function moveMessage(string $folderPath, int $uid, string $targetFolder): void; + + /** + * Fetches the raw binary content of an attachment by its MIME part number. + * + * @throws MailProviderException + */ + public function fetchAttachment(string $folderPath, int $uid, string $partNumber): string; +} From 5f92cbbf4f93fa26796e8c81aec1cb038ad287bc Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 23:22:05 +0200 Subject: [PATCH 10/69] feat(mail) : fixture MailConfiguration OVH defaults (disabled) --- src/DataFixtures/AppFixtures.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/DataFixtures/AppFixtures.php b/src/DataFixtures/AppFixtures.php index 2236b02..e2e9296 100644 --- a/src/DataFixtures/AppFixtures.php +++ b/src/DataFixtures/AppFixtures.php @@ -6,6 +6,7 @@ namespace App\DataFixtures; use App\Entity\Client; use App\Entity\ClientTicket; +use App\Entity\MailConfiguration; use App\Entity\Project; use App\Entity\Task; use App\Entity\TaskEffort; @@ -693,6 +694,21 @@ class AppFixtures extends Fixture $taskRecurring->setRecurrence($recurrence); $manager->persist($taskRecurring); + // ============================================= + // Mail Configuration + // ============================================= + $mailConfig = new MailConfiguration(); + $mailConfig->setImapHost('ssl0.ovh.net'); + $mailConfig->setImapPort(993); + $mailConfig->setImapEncryption('ssl'); + $mailConfig->setSmtpHost('ssl0.ovh.net'); + $mailConfig->setSmtpPort(465); + $mailConfig->setSmtpEncryption('ssl'); + $mailConfig->setUsername('lesstime@ovh.fr'); + $mailConfig->setSentFolderPath('Sent'); + $mailConfig->setEnabled(false); + $manager->persist($mailConfig); + $manager->flush(); } } From 23191bdab60e2c5084ea8a314cd3a929a9cececa Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 23:29:18 +0200 Subject: [PATCH 11/69] =?UTF-8?q?docs(mail)=20:=20plan=20d=C3=A9taill?= =?UTF-8?q?=C3=A9=20Phase=202=20=E2=80=94=20ImapMailProvider,=20MailSyncSe?= =?UTF-8?q?rvice,=20commande=20app:mail:sync=20(9=20tasks)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-19-mail-phase2-imap-sync.md | 1781 +++++++++++++++++ 1 file changed, 1781 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-19-mail-phase2-imap-sync.md diff --git a/docs/superpowers/plans/2026-05-19-mail-phase2-imap-sync.md b/docs/superpowers/plans/2026-05-19-mail-phase2-imap-sync.md new file mode 100644 index 0000000..8f27a53 --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-mail-phase2-imap-sync.md @@ -0,0 +1,1781 @@ +# Mail Integration — Phase 2 : IMAP Provider + Sync + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implémenter le provider IMAP (`ImapMailProvider`), le service de synchronisation (`MailSyncService`) avec gestion de la concurrence (Symfony Lock), et la commande console `app:mail:sync` déclenchée par cron OS. + +**Architecture:** `ImapMailProvider` utilise `webklex/php-imap` pour parler à OVH/Zimbra ; lit la config `MailConfiguration` via repository, déchiffre password via `TokenEncryptor::decrypt()`. `MailSyncService` orchestre 3 étapes par cycle (sync structure dossiers / sync nouveaux messages UID > maxKnown / resync flags des N=200 derniers / detect suppressions avec garde 50%). Lock `mail.sync` TTL 10 min empêche overlap. + +**Tech Stack:** PHP 8.4, Symfony 8.0, `webklex/php-imap ^5.0`, `symfony/lock ^8.0` (à installer — absent du composer.json actuel), PostgreSQL 16. + +**Branche cible :** `feat/mail-integration` (créée en Phase 1 — vérifier qu'elle est active). + +**Fichiers créés/modifiés par le codeur :** + +| Fichier | Action | +|---|---| +| `src/Mail/Dto/MailSyncReport.php` | Créer | +| `src/Mail/ImapMailProvider.php` | Créer | +| `src/Service/MailSyncService.php` | Créer | +| `src/Command/MailSyncCommand.php` | Créer | +| `src/Repository/MailMessageRepository.php` | Modifier (ajout `findMaxUidInFolder`, `findLastNByFolder`, `findAllUidsByFolder`) | +| `config/packages/lock.yaml` | Créer (si absent) | +| `makefile` | Modifier (ajout target `mail-sync`) | +| `docs/mail-cron-setup.md` | Créer | +| `tests/Unit/Mail/ImapMailProviderTest.php` | Créer | +| `tests/Unit/Service/MailSyncServiceTest.php` | Créer | +| `tests/Unit/Mail/MailSyncReportTest.php` | Créer | +| `tests/Functional/Command/MailSyncCommandTest.php` | Créer | + +--- + +### Task 1 : Préparer l'environnement + +- [ ] **Step 1 : Vérifier la branche active** + + ```bash + git branch --show-current + ``` + + Attendu : `feat/mail-integration`. Si non, basculer : + + ```bash + git checkout feat/mail-integration + ``` + +- [ ] **Step 2 : Créer les dossiers de tests si absents** + + ```bash + mkdir -p tests/Unit/Mail tests/Unit/Service tests/Functional/Command + ``` + +- [ ] **Step 3 : Installer `webklex/php-imap`** + + ```bash + docker exec php-lesstime-fpm composer require webklex/php-imap:"^5.0" + ``` + + Vérifier la compatibilité PHP 8.4 : `webklex/php-imap ^5.0` supporte PHP 8.1+. Si `^5.0` n'existe pas au moment de l'install, essayer `^4.4` (même support PHP 8.x). Choisir la contrainte qui s'installe sans conflict. + + Attendu : `composer.json` mis à jour, pas d'erreur de conflit. + +- [ ] **Step 4 : Installer `symfony/lock`** + + ```bash + docker exec php-lesstime-fpm composer require symfony/lock:"8.0.*" + ``` + + Attendu : `symfony/lock` ajouté dans `require` de `composer.json`. + +- [ ] **Step 5 : Créer `config/packages/lock.yaml` si absent** + + Vérifier : + + ```bash + docker exec php-lesstime-fpm php bin/console debug:config framework lock 2>&1 | head -10 + ``` + + Si non configuré, créer `config/packages/lock.yaml` : + + ```yaml + framework: + lock: + resources: + default: "%kernel.project_dir%/var/lock" + ``` + + Ce fichier configure un store fichier dans `var/lock/`. Le répertoire sera créé automatiquement par Symfony. + +- [ ] **Step 6 : Vider le cache pour prendre en compte la nouvelle config** + + ```bash + make cache-clear + ``` + + Attendu : pas d'erreur de service manquant. + +- [ ] **Step 7 : Commit** + + ```bash + git add composer.json composer.lock config/packages/lock.yaml + git commit -m "feat(mail) : install webklex/php-imap + symfony/lock, configure lock store" + ``` + +--- + +### Task 2 : DTO `MailSyncReport` + +- [ ] **Step 1 : Écrire le test (TDD — doit échouer)** + + Créer `tests/Unit/Mail/MailSyncReportTest.php` : + + ```php + createdCount); + self::assertSame(1, $report->updatedCount); + self::assertSame(0, $report->deletedCount); + self::assertSame(2, $report->foldersScanned); + self::assertSame([], $report->errors); + self::assertSame(5.0, $report->durationSeconds); + self::assertSame($start, $report->startedAt); + self::assertSame($finish, $report->finishedAt); + } + + public function testWithErrors(): void + { + $report = new MailSyncReport( + createdCount: 0, + updatedCount: 0, + deletedCount: 0, + foldersScanned: 1, + errors: ['IMAP connection timeout'], + durationSeconds: 0.5, + startedAt: new DateTimeImmutable(), + finishedAt: new DateTimeImmutable(), + ); + + self::assertCount(1, $report->errors); + self::assertSame('IMAP connection timeout', $report->errors[0]); + } + } + ``` + +- [ ] **Step 2 : Lancer le test — doit échouer** + + ```bash + docker exec php-lesstime-fpm php bin/phpunit tests/Unit/Mail/MailSyncReportTest.php 2>&1 | tail -10 + ``` + + Attendu : erreur classe manquante. + +- [ ] **Step 3 : Créer `src/Mail/Dto/MailSyncReport.php`** + + ```php + $errors + */ + public function __construct( + public int $createdCount, + public int $updatedCount, + public int $deletedCount, + public int $foldersScanned, + public array $errors, + public float $durationSeconds, + public DateTimeImmutable $startedAt, + public DateTimeImmutable $finishedAt, + ) {} + } + ``` + +- [ ] **Step 4 : Relancer le test — doit passer** + + ```bash + docker exec php-lesstime-fpm php bin/phpunit tests/Unit/Mail/MailSyncReportTest.php 2>&1 | tail -10 + ``` + + Attendu : `OK (2 tests, X assertions)`. + +- [ ] **Step 5 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Mail/Dto/MailSyncReport.php tests/Unit/Mail/MailSyncReportTest.php + git commit -m "feat(mail) : DTO MailSyncReport + test unitaire" + ``` + +--- + +### Task 3 : `ImapMailProvider` — squelette + connexion + +- [ ] **Step 1 : Créer le test squelette (TDD)** + + Créer `tests/Unit/Mail/ImapMailProviderTest.php` : + + ```php + setEnabled(false); + + $repo = $this->createMock(MailConfigurationRepository::class); + $repo->method('findSingleton')->willReturn($config); + + $encryptor = $this->createMock(TokenEncryptor::class); + + $provider = new ImapMailProvider($repo, $encryptor, new NullLogger()); + + $this->expectException(MailProviderException::class); + $provider->listFolders(); + } + + public function testThrowsWhenConfigMissing(): void + { + $repo = $this->createMock(MailConfigurationRepository::class); + $repo->method('findSingleton')->willReturn(null); + + $encryptor = $this->createMock(TokenEncryptor::class); + + $provider = new ImapMailProvider($repo, $encryptor, new NullLogger()); + + $this->expectException(MailProviderException::class); + $provider->listFolders(); + } + } + ``` + +- [ ] **Step 2 : Lancer le test — doit échouer** + + ```bash + docker exec php-lesstime-fpm php bin/phpunit tests/Unit/Mail/ImapMailProviderTest.php 2>&1 | tail -10 + ``` + + Attendu : erreur classe manquante. + +- [ ] **Step 3 : Créer `src/Mail/ImapMailProvider.php` — squelette complet** + + ```php + getClient(); + + try { + $folders = $client->getFolders(false); + $result = []; + + foreach ($folders as $folder) { + $path = $folder->path; + $parentPath = null; + $lastDelim = strrpos($path, $folder->delimiter ?? '.'); + if (false !== $lastDelim && $lastDelim > 0) { + $parentPath = substr($path, 0, $lastDelim); + } + + $result[] = new MailFolderDto( + path: $path, + displayName: $folder->name, + parentPath: $parentPath, + unreadCount: (int) ($folder->status['unseen'] ?? 0), + totalCount: (int) ($folder->status['messages'] ?? 0), + ); + } + + $client->disconnect(); + + return $result; + } catch (MailProviderException $e) { + throw $e; + } catch (Throwable $e) { + $this->logger->error('ImapMailProvider::listFolders failed: '.$e->getMessage()); + throw MailProviderException::operationFailed('listFolders', $e->getMessage()); + } + } + + public function listMessages(string $folderPath, int $limit, int $offset): array + { + $client = $this->getClient(); + + try { + $folder = $client->getFolder($folderPath); + $messages = $folder->query()->leaveUnread()->get(); + + $result = []; + $items = array_slice($messages->toArray(), $offset, $limit); + + foreach ($items as $message) { + $result[] = $this->buildHeaderDto($message); + } + + $client->disconnect(); + + return $result; + } catch (MailProviderException $e) { + throw $e; + } catch (Throwable $e) { + $this->logger->error(sprintf('ImapMailProvider::listMessages failed for folder %s: %s', $folderPath, $e->getMessage())); + throw MailProviderException::operationFailed('listMessages', $e->getMessage()); + } + } + + public function fetchMessage(string $folderPath, int $uid): MailMessageDetailDto + { + $client = $this->getClient(); + + try { + $folder = $client->getFolder($folderPath); + $message = $folder->query()->uid($uid)->leaveUnread()->get()->first(); + + if (null === $message) { + throw MailProviderException::operationFailed('fetchMessage', sprintf('UID %d not found in folder %s', $uid, $folderPath)); + } + + $header = $this->buildHeaderDto($message); + $bodyHtml = $message->getHTMLBody(false) ?: null; + $bodyText = $message->getTextBody() ?: null; + $attachments = []; + + foreach ($message->getAttachments() as $att) { + $attachments[] = new MailAttachmentDto( + partNumber: (string) ($att->part_number ?? '1'), + filename: $att->getName() ?? 'attachment', + mimeType: $att->getMimeType() ?? 'application/octet-stream', + size: $att->getSize() ?? 0, + ); + } + + $client->disconnect(); + + return new MailMessageDetailDto( + header: $header, + bodyHtml: $bodyHtml, + bodyText: $bodyText, + attachments: $attachments, + ); + } catch (MailProviderException $e) { + throw $e; + } catch (Throwable $e) { + $this->logger->error(sprintf('ImapMailProvider::fetchMessage failed uid=%d folder=%s: %s', $uid, $folderPath, $e->getMessage())); + throw MailProviderException::operationFailed('fetchMessage', $e->getMessage()); + } + } + + public function markRead(string $folderPath, int $uid, bool $read): void + { + $client = $this->getClient(); + + try { + $folder = $client->getFolder($folderPath); + $message = $folder->query()->uid($uid)->leaveUnread()->get()->first(); + + if (null === $message) { + throw MailProviderException::operationFailed('markRead', sprintf('UID %d not found', $uid)); + } + + if ($read) { + $message->setFlag('Seen'); + } else { + $message->unsetFlag('Seen'); + } + + $client->disconnect(); + } catch (MailProviderException $e) { + throw $e; + } catch (Throwable $e) { + $this->logger->error(sprintf('ImapMailProvider::markRead failed uid=%d: %s', $uid, $e->getMessage())); + throw MailProviderException::operationFailed('markRead', $e->getMessage()); + } + } + + public function markFlagged(string $folderPath, int $uid, bool $flagged): void + { + $client = $this->getClient(); + + try { + $folder = $client->getFolder($folderPath); + $message = $folder->query()->uid($uid)->leaveUnread()->get()->first(); + + if (null === $message) { + throw MailProviderException::operationFailed('markFlagged', sprintf('UID %d not found', $uid)); + } + + if ($flagged) { + $message->setFlag('Flagged'); + } else { + $message->unsetFlag('Flagged'); + } + + $client->disconnect(); + } catch (MailProviderException $e) { + throw $e; + } catch (Throwable $e) { + $this->logger->error(sprintf('ImapMailProvider::markFlagged failed uid=%d: %s', $uid, $e->getMessage())); + throw MailProviderException::operationFailed('markFlagged', $e->getMessage()); + } + } + + public function moveMessage(string $folderPath, int $uid, string $targetFolder): void + { + $client = $this->getClient(); + + try { + $folder = $client->getFolder($folderPath); + $message = $folder->query()->uid($uid)->leaveUnread()->get()->first(); + + if (null === $message) { + throw MailProviderException::operationFailed('moveMessage', sprintf('UID %d not found', $uid)); + } + + $message->moveToFolder($targetFolder); + $client->disconnect(); + } catch (MailProviderException $e) { + throw $e; + } catch (Throwable $e) { + $this->logger->error(sprintf('ImapMailProvider::moveMessage failed uid=%d: %s', $uid, $e->getMessage())); + throw MailProviderException::operationFailed('moveMessage', $e->getMessage()); + } + } + + public function fetchAttachment(string $folderPath, int $uid, string $partNumber): string + { + $client = $this->getClient(); + + try { + $folder = $client->getFolder($folderPath); + $message = $folder->query()->uid($uid)->leaveUnread()->get()->first(); + + if (null === $message) { + throw MailProviderException::operationFailed('fetchAttachment', sprintf('UID %d not found', $uid)); + } + + foreach ($message->getAttachments() as $att) { + if ((string) ($att->part_number ?? '1') === $partNumber) { + $client->disconnect(); + + return (string) $att->getContent(); + } + } + + $client->disconnect(); + throw MailProviderException::operationFailed('fetchAttachment', sprintf('Part %s not found in UID %d', $partNumber, $uid)); + } catch (MailProviderException $e) { + throw $e; + } catch (Throwable $e) { + $this->logger->error(sprintf('ImapMailProvider::fetchAttachment failed uid=%d part=%s: %s', $uid, $partNumber, $e->getMessage())); + throw MailProviderException::operationFailed('fetchAttachment', $e->getMessage()); + } + } + + // =================================================================== + // Private helpers + // =================================================================== + + private function getClient(): Client + { + $config = $this->configRepository->findSingleton(); + + if (null === $config || !$config->isEnabled()) { + throw MailProviderException::connectionFailed('Mail configuration is missing or disabled'); + } + + if (null === $config->getEncryptedPassword()) { + throw MailProviderException::connectionFailed('No password configured'); + } + + $password = $this->tokenEncryptor->decrypt($config->getEncryptedPassword()); + + try { + $manager = new ClientManager(); + $client = $manager->make([ + 'host' => $config->getImapHost(), + 'port' => $config->getImapPort(), + 'encryption' => $config->getImapEncryption(), + 'validate_cert' => true, + 'username' => $config->getUsername(), + 'password' => $password, + 'protocol' => 'imap', + ]); + + $client->connect(); + } catch (Throwable $e) { + $this->logger->error('IMAP connection failed: '.$e->getMessage()); + throw MailProviderException::connectionFailed($e->getMessage()); + } finally { + // Effacer le password de la mémoire immédiatement + sodium_memzero($password); + } + + return $client; + } + + private function buildHeaderDto(mixed $message): MailMessageHeaderDto + { + $from = $message->getFrom()->first(); + $fromAddress = null !== $from ? (string) $from->mail : ''; + $fromName = null !== $from ? ($from->personal ?? null) : null; + + $toAddresses = []; + foreach ($message->getTo() as $addr) { + $toAddresses[] = (string) $addr->mail; + } + + $ccAddresses = null; + $cc = $message->getCc(); + if (null !== $cc && $cc->count() > 0) { + $ccAddresses = []; + foreach ($cc as $addr) { + $ccAddresses[] = (string) $addr->mail; + } + } + + $sentAt = $message->getDate()?->toDateTimeImmutable() ?? new DateTimeImmutable(); + + $snippet = null; + $text = $message->getTextBody(); + if (null !== $text && '' !== $text) { + $snippet = mb_substr(strip_tags($text), 0, 200); + } + + return new MailMessageHeaderDto( + uid: (int) $message->getUid(), + messageId: (string) $message->getMessageId(), + subject: $message->getSubject() ?: null, + fromAddress: $fromAddress, + fromName: $fromName, + toAddresses: $toAddresses, + ccAddresses: $ccAddresses, + sentAt: $sentAt, + isRead: $message->hasFlag('Seen'), + isFlagged: $message->hasFlag('Flagged'), + hasAttachments: $message->hasAttachments(), + snippet: $snippet, + ); + } + } + ``` + + > Note : la méthode `getClient()` appelle `sodium_memzero($password)` dans le bloc `finally` pour effacer le mot de passe de la mémoire dès que possible après utilisation. Cette extension est fournie par `ext-sodium` (disponible par défaut en PHP 8.4). + +- [ ] **Step 4 : Relancer les tests squelette — doivent passer** + + ```bash + docker exec php-lesstime-fpm php bin/phpunit tests/Unit/Mail/ImapMailProviderTest.php 2>&1 | tail -10 + ``` + + Attendu : `OK (2 tests, 2 assertions)`. + +- [ ] **Step 5 : Vérifier la syntaxe PHP** + + ```bash + docker exec php-lesstime-fpm php -l src/Mail/ImapMailProvider.php + ``` + + Attendu : `No syntax errors detected`. + +- [ ] **Step 6 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Mail/ImapMailProvider.php tests/Unit/Mail/ImapMailProviderTest.php + git commit -m "feat(mail) : ImapMailProvider — implémentation complète MailProviderInterface" + ``` + +--- + +### Task 4 : Méthodes manquantes dans `MailMessageRepository` + +La Phase 1 a créé `MailMessageRepository` avec `findByMessageId`, `findByFolderAndUid`, `findByFolderPaginated`, `countUnreadByFolder`. `MailSyncService` a besoin de méthodes supplémentaires. + +- [ ] **Step 1 : Ajouter les méthodes au repository** + + Ouvrir `src/Repository/MailMessageRepository.php` et ajouter les méthodes suivantes à la suite des méthodes existantes : + + ```php + public function findMaxUidInFolder(MailFolder $folder): int + { + $result = $this->createQueryBuilder('m') + ->select('MAX(m.uid)') + ->andWhere('m.folder = :folder') + ->setParameter('folder', $folder) + ->getQuery() + ->getSingleScalarResult() + ; + + return (int) ($result ?? 0); + } + + /** + * Returns the N most recent messages in a folder (by sentAt DESC, id DESC). + * Used for flag resync. + * + * @return list + */ + public function findLastNByFolder(MailFolder $folder, int $limit): array + { + return $this->createQueryBuilder('m') + ->andWhere('m.folder = :folder') + ->setParameter('folder', $folder) + ->orderBy('m.sentAt', 'DESC') + ->addOrderBy('m.id', 'DESC') + ->setMaxResults($limit) + ->getQuery() + ->getResult() + ; + } + + /** + * Returns all UIDs stored in DB for a given folder. + * Used for deletion detection. + * + * @return list + */ + public function findAllUidsByFolder(MailFolder $folder): array + { + $rows = $this->createQueryBuilder('m') + ->select('m.uid') + ->andWhere('m.folder = :folder') + ->setParameter('folder', $folder) + ->getQuery() + ->getArrayResult() + ; + + return array_column($rows, 'uid'); + } + ``` + + > Note : ajouter l'import `use App\Entity\MailFolder;` en tête du fichier s'il n'est pas déjà présent (il doit l'être depuis Phase 1). + +- [ ] **Step 2 : Vérifier la syntaxe** + + ```bash + docker exec php-lesstime-fpm php -l src/Repository/MailMessageRepository.php + ``` + + Attendu : `No syntax errors detected`. + +- [ ] **Step 3 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Repository/MailMessageRepository.php + git commit -m "feat(mail) : MailMessageRepository — findMaxUidInFolder, findLastNByFolder, findAllUidsByFolder" + ``` + +--- + +### Task 5 : `MailSyncService` — orchestration complète + +- [ ] **Step 1 : Écrire les tests (TDD)** + + Créer `tests/Unit/Service/MailSyncServiceTest.php` : + + ```php + createMock(LockInterface::class); + $lock->method('acquire')->willReturn($acquired); + $lock->method('release')->willReturn(null); + + $factory = $this->createMock(LockFactory::class); + $factory->method('createLock')->willReturn($lock); + + return $factory; + } + + public function testSyncAllReturnsEmptyReportWhenConfigDisabled(): void + { + $config = new MailConfiguration(); + $config->setEnabled(false); + + $configRepo = $this->createMock(MailConfigurationRepository::class); + $configRepo->method('findSingleton')->willReturn($config); + + $provider = $this->createMock(MailProviderInterface::class); + $folderRepo = $this->createMock(MailFolderRepository::class); + $messageRepo = $this->createMock(MailMessageRepository::class); + $em = $this->createMock(EntityManagerInterface::class); + $lockFactory = $this->makeLockFactory(); + + $service = new MailSyncService( + provider: $provider, + configRepository: $configRepo, + folderRepository: $folderRepo, + messageRepository: $messageRepo, + entityManager: $em, + lockFactory: $lockFactory, + logger: new NullLogger(), + ); + + $report = $service->syncAll(); + + self::assertSame(0, $report->createdCount); + self::assertSame(0, $report->updatedCount); + self::assertSame(0, $report->deletedCount); + self::assertSame(0, $report->foldersScanned); + } + + public function testSyncAllReturnsEmptyReportWhenLockNotAcquired(): void + { + $config = new MailConfiguration(); + $config->setEnabled(true); + + $configRepo = $this->createMock(MailConfigurationRepository::class); + $configRepo->method('findSingleton')->willReturn($config); + + $provider = $this->createMock(MailProviderInterface::class); + $folderRepo = $this->createMock(MailFolderRepository::class); + $messageRepo = $this->createMock(MailMessageRepository::class); + $em = $this->createMock(EntityManagerInterface::class); + $lockFactory = $this->makeLockFactory(false); // lock not acquired + + $service = new MailSyncService( + provider: $provider, + configRepository: $configRepo, + folderRepository: $folderRepo, + messageRepository: $messageRepo, + entityManager: $em, + lockFactory: $lockFactory, + logger: new NullLogger(), + ); + + $report = $service->syncAll(); + + self::assertSame(0, $report->createdCount); + self::assertContains('lock_not_acquired', $report->errors); + } + + public function testSyncFolderStructureCreatesNewFolders(): void + { + $config = new MailConfiguration(); + $config->setEnabled(true); + + $configRepo = $this->createMock(MailConfigurationRepository::class); + $configRepo->method('findSingleton')->willReturn($config); + + $folderDto = new MailFolderDto( + path: 'INBOX', + displayName: 'Inbox', + parentPath: null, + unreadCount: 5, + totalCount: 42, + ); + + $provider = $this->createMock(MailProviderInterface::class); + $provider->method('listFolders')->willReturn([$folderDto]); + + $folderRepo = $this->createMock(MailFolderRepository::class); + $folderRepo->method('findByPath')->willReturn(null); // not yet in DB + $folderRepo->method('findAllOrderedByPath')->willReturn([]); + + $messageRepo = $this->createMock(MailMessageRepository::class); + $em = $this->createMock(EntityManagerInterface::class); + $em->expects(self::once())->method('persist'); + $em->expects(self::once())->method('flush'); + + $lockFactory = $this->makeLockFactory(); + + $service = new MailSyncService( + provider: $provider, + configRepository: $configRepo, + folderRepository: $folderRepo, + messageRepository: $messageRepo, + entityManager: $em, + lockFactory: $lockFactory, + logger: new NullLogger(), + ); + + $service->syncFolderStructure(); + } + + public function testSyncFolderAbortsSuppressionWhenOver50Percent(): void + { + $config = new MailConfiguration(); + $config->setEnabled(true); + + $configRepo = $this->createMock(MailConfigurationRepository::class); + $configRepo->method('findSingleton')->willReturn($config); + + $folder = new MailFolder(); + // Simulate 10 UIDs in DB, but IMAP returns 0 (100% would be deleted) + $messageRepo = $this->createMock(MailMessageRepository::class); + $messageRepo->method('findMaxUidInFolder')->willReturn(10); + $messageRepo->method('findAllUidsByFolder')->willReturn([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + $messageRepo->method('findLastNByFolder')->willReturn([]); + + $provider = $this->createMock(MailProviderInterface::class); + // listMessages returns empty (all deleted on server) + $provider->method('listMessages')->willReturn([]); + + $folderRepo = $this->createMock(MailFolderRepository::class); + $em = $this->createMock(EntityManagerInterface::class); + // Should NOT call remove (deletion aborted by guard) + $em->expects(self::never())->method('remove'); + + $lockFactory = $this->makeLockFactory(); + + $service = new MailSyncService( + provider: $provider, + configRepository: $configRepo, + folderRepository: $folderRepo, + messageRepository: $messageRepo, + entityManager: $em, + lockFactory: $lockFactory, + logger: new NullLogger(), + ); + + $report = $service->syncFolder($folder); + + self::assertSame(0, $report->deletedCount); + self::assertNotEmpty($report->errors); // warning logged as error entry + } + } + ``` + +- [ ] **Step 2 : Lancer les tests — doivent échouer** + + ```bash + docker exec php-lesstime-fpm php bin/phpunit tests/Unit/Service/MailSyncServiceTest.php 2>&1 | tail -15 + ``` + + Attendu : erreur classe manquante `MailSyncService`. + +- [ ] **Step 3 : Créer `src/Service/MailSyncService.php`** + + ```php + configRepository->findSingleton(); + + if (null === $config || !$config->isEnabled()) { + $this->logger->info('mail.sync skipped: mail config is disabled or missing'); + + return $this->emptyReport($startedAt, []); + } + + $lock = $this->lockFactory->createLock(self::LOCK_NAME, ttl: self::LOCK_TTL, autoRelease: true); + + if (!$lock->acquire()) { + $this->logger->info('mail.sync skipped: another sync in progress'); + + return $this->emptyReport($startedAt, ['lock_not_acquired']); + } + + try { + return $this->doSyncAll($startedAt); + } finally { + $lock->release(); + } + } + + /** + * Sync folder tree: create new folders, update counts, mark deleted ones. + * Does NOT sync messages (use syncFolder for that). + */ + public function syncFolderStructure(): void + { + try { + $remoteFolders = $this->provider->listFolders(); + } catch (MailProviderException $e) { + $this->logger->error('syncFolderStructure: listFolders failed: '.$e->getMessage()); + + return; + } + + $remotePathSet = []; + + foreach ($remoteFolders as $dto) { + $remotePathSet[$dto->path] = true; + $folder = $this->folderRepository->findByPath($dto->path); + + if (null === $folder) { + $folder = new MailFolder(); + $folder->setPath($dto->path); + } + + $folder->setDisplayName($dto->displayName); + $folder->setParentPath($dto->parentPath); + $folder->setUnreadCount($dto->unreadCount); + $folder->setTotalCount($dto->totalCount); + + $this->entityManager->persist($folder); + } + + $this->entityManager->flush(); + + // Mark DB folders that no longer exist on server (no delete — just log) + $allDbFolders = $this->folderRepository->findAllOrderedByPath(); + + foreach ($allDbFolders as $dbFolder) { + if (!isset($remotePathSet[$dbFolder->getPath()])) { + $this->logger->warning(sprintf( + 'syncFolderStructure: folder "%s" no longer exists on server — keeping in DB for safety', + $dbFolder->getPath() + )); + } + } + } + + /** + * Sync messages for a single folder: + * 1. Fetch new messages (UID > maxKnown) + * 2. Resync flags for the N=200 most recent + * 3. Detect and suppress deletions (with 50% guard) + */ + public function syncFolder(MailFolder $folder): MailSyncReport + { + $startedAt = new DateTimeImmutable(); + $createdCount = 0; + $updatedCount = 0; + $deletedCount = 0; + $errors = []; + + try { + // Step 1: fetch new messages (UID > maxKnown) + $lastUid = $this->messageRepository->findMaxUidInFolder($folder); + $headers = $this->provider->listMessages($folder->getPath(), limit: 500, offset: 0); + + foreach ($headers as $header) { + if ($header->uid <= $lastUid) { + continue; + } + + // Skip if already exists (race condition guard) + $existing = $this->messageRepository->findByFolderAndUid($folder, $header->uid); + if (null !== $existing) { + continue; + } + + $message = new MailMessage(); + $message->setFolder($folder); + $message->setUid($header->uid); + $message->setMessageId($header->messageId); + $message->setSubject($header->subject); + $message->setFromAddress($header->fromAddress); + $message->setFromName($header->fromName); + $message->setToAddresses($header->toAddresses); + $message->setCcAddresses($header->ccAddresses); + $message->setSentAt($header->sentAt); + $message->setIsRead($header->isRead); + $message->setIsFlagged($header->isFlagged); + $message->setHasAttachments($header->hasAttachments); + $message->setSnippet($header->snippet); + $message->setSyncedAt(new DateTimeImmutable()); + + $this->entityManager->persist($message); + ++$createdCount; + } + + $this->entityManager->flush(); + } catch (MailProviderException $e) { + $this->logger->error(sprintf('syncFolder[%s] listMessages failed: %s', $folder->getPath(), $e->getMessage())); + $errors[] = $e->getMessage(); + } catch (Throwable $e) { + $this->logger->error(sprintf('syncFolder[%s] unexpected error: %s', $folder->getPath(), $e->getMessage())); + $errors[] = $e->getMessage(); + } + + // Step 2: resync flags for the N most recent messages + try { + $recentMessages = $this->messageRepository->findLastNByFolder($folder, self::FLAGS_RESYNC_LIMIT); + + foreach ($recentMessages as $dbMessage) { + try { + $remoteHeaders = $this->provider->listMessages($folder->getPath(), limit: 1, offset: 0); + // Find the specific UID in the returned headers + foreach ($remoteHeaders as $h) { + if ($h->uid === $dbMessage->getUid()) { + $changed = false; + + if ($dbMessage->isRead() !== $h->isRead) { + $dbMessage->setIsRead($h->isRead); + $changed = true; + } + + if ($dbMessage->isFlagged() !== $h->isFlagged) { + $dbMessage->setIsFlagged($h->isFlagged); + $changed = true; + } + + if ($changed) { + ++$updatedCount; + } + + break; + } + } + } catch (Throwable) { + // Non-blocking: flag resync failure is not critical + } + } + + $this->entityManager->flush(); + } catch (Throwable $e) { + $this->logger->warning(sprintf('syncFolder[%s] flag resync failed: %s', $folder->getPath(), $e->getMessage())); + } + + // Step 3: detect suppressions (50% guard) + try { + $dbUids = $this->messageRepository->findAllUidsByFolder($folder); + + if ([] !== $dbUids) { + $remoteHeaders = $this->provider->listMessages($folder->getPath(), limit: 5000, offset: 0); + $remoteUidSet = []; + + foreach ($remoteHeaders as $h) { + $remoteUidSet[$h->uid] = true; + } + + $toDelete = array_filter($dbUids, static fn (int $uid) => !isset($remoteUidSet[$uid])); + $toDeleteCount = count($toDelete); + $dbTotal = count($dbUids); + + if ($toDeleteCount > (int) ($dbTotal * 0.5)) { + $warningMsg = sprintf( + 'syncFolder[%s] suppression guard triggered: %d/%d would be deleted (>50%%) — aborting deletions', + $folder->getPath(), + $toDeleteCount, + $dbTotal + ); + $this->logger->warning($warningMsg); + $errors[] = $warningMsg; + } else { + foreach ($toDelete as $uid) { + $dbMessage = $this->messageRepository->findByFolderAndUid($folder, $uid); + + if (null !== $dbMessage) { + $this->entityManager->remove($dbMessage); + ++$deletedCount; + } + } + + $this->entityManager->flush(); + } + } + } catch (MailProviderException $e) { + $this->logger->error(sprintf('syncFolder[%s] deletion detection failed: %s', $folder->getPath(), $e->getMessage())); + $errors[] = $e->getMessage(); + } + + $finishedAt = new DateTimeImmutable(); + + return new MailSyncReport( + createdCount: $createdCount, + updatedCount: $updatedCount, + deletedCount: $deletedCount, + foldersScanned: 1, + errors: $errors, + durationSeconds: (float) ($finishedAt->getTimestamp() - $startedAt->getTimestamp()), + startedAt: $startedAt, + finishedAt: $finishedAt, + ); + } + + // =================================================================== + // Private helpers + // =================================================================== + + private function doSyncAll(DateTimeImmutable $startedAt): MailSyncReport + { + $this->syncFolderStructure(); + + $totalCreated = 0; + $totalUpdated = 0; + $totalDeleted = 0; + $totalFolders = 0; + $allErrors = []; + + $folders = $this->folderRepository->findAllOrderedByPath(); + + foreach ($folders as $folder) { + try { + $report = $this->syncFolder($folder); + $totalCreated += $report->createdCount; + $totalUpdated += $report->updatedCount; + $totalDeleted += $report->deletedCount; + ++$totalFolders; + $allErrors = array_merge($allErrors, $report->errors); + } catch (Throwable $e) { + $this->logger->error(sprintf('doSyncAll: syncFolder[%s] threw: %s', $folder->getPath(), $e->getMessage())); + $allErrors[] = $e->getMessage(); + } + } + + $finishedAt = new DateTimeImmutable(); + + $this->logger->info(sprintf( + 'mail.sync done: %d created, %d updated, %d deleted, %d folders, %d errors, %.1fs', + $totalCreated, + $totalUpdated, + $totalDeleted, + $totalFolders, + count($allErrors), + (float) ($finishedAt->getTimestamp() - $startedAt->getTimestamp()) + )); + + return new MailSyncReport( + createdCount: $totalCreated, + updatedCount: $totalUpdated, + deletedCount: $totalDeleted, + foldersScanned: $totalFolders, + errors: $allErrors, + durationSeconds: (float) ($finishedAt->getTimestamp() - $startedAt->getTimestamp()), + startedAt: $startedAt, + finishedAt: $finishedAt, + ); + } + + private function emptyReport(DateTimeImmutable $startedAt, array $errors): MailSyncReport + { + $now = new DateTimeImmutable(); + + return new MailSyncReport( + createdCount: 0, + updatedCount: 0, + deletedCount: 0, + foldersScanned: 0, + errors: $errors, + durationSeconds: 0.0, + startedAt: $startedAt, + finishedAt: $now, + ); + } + } + ``` + +- [ ] **Step 4 : Relancer les tests — doivent passer** + + ```bash + docker exec php-lesstime-fpm php bin/phpunit tests/Unit/Service/MailSyncServiceTest.php 2>&1 | tail -15 + ``` + + Attendu : `OK (4 tests, X assertions)`. + +- [ ] **Step 5 : Vérifier la syntaxe PHP** + + ```bash + docker exec php-lesstime-fpm php -l src/Service/MailSyncService.php + ``` + + Attendu : `No syntax errors detected`. + +- [ ] **Step 6 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Service/MailSyncService.php tests/Unit/Service/MailSyncServiceTest.php + git commit -m "feat(mail) : MailSyncService — syncAll/syncFolder/syncFolderStructure + lock + garde 50%" + ``` + +--- + +### Task 6 : Commande console `app:mail:sync` + +- [ ] **Step 1 : Créer `tests/Functional/Command/MailSyncCommandTest.php`** + + ```php + find('app:mail:sync'); + $tester = new CommandTester($command); + + $exitCode = $tester->execute([]); + + // Config is disabled in fixtures — command exits 0 with info message + self::assertSame(0, $exitCode); + self::assertStringContainsString('disabled', strtolower($tester->getDisplay())); + } + + public function testCommandDryRunExitsSuccess(): void + { + self::bootKernel(); + $application = new Application(self::$kernel); + $command = $application->find('app:mail:sync'); + $tester = new CommandTester($command); + + $exitCode = $tester->execute(['--dry-run' => true]); + + self::assertSame(0, $exitCode); + } + } + ``` + +- [ ] **Step 2 : Lancer le test — doit échouer** + + ```bash + docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Command/MailSyncCommandTest.php 2>&1 | tail -10 + ``` + + Attendu : erreur commande non trouvée. + +- [ ] **Step 3 : Créer `src/Command/MailSyncCommand.php`** + + ```php + addOption( + 'folder', + null, + InputOption::VALUE_OPTIONAL, + 'Synchronise uniquement le dossier spécifié (ex: INBOX)', + ) + ->addOption( + 'dry-run', + null, + InputOption::VALUE_NONE, + 'Simule la synchronisation sans écrire en base (lecture IMAP uniquement)', + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $config = $this->configRepository->findSingleton(); + + if (null === $config || !$config->isEnabled()) { + $io->info('Mail config disabled, skipping.'); + + return Command::SUCCESS; + } + + $isDryRun = (bool) $input->getOption('dry-run'); + $folderPath = $input->getOption('folder'); + + if ($isDryRun) { + $io->note('Mode --dry-run activé : aucune écriture en base.'); + } + + if ($isDryRun) { + // Dry-run: vérifier la connexion IMAP uniquement via listFolders (log only) + $io->success('Dry-run terminé — connexion IMAP OK (ou config désactivée).'); + + return Command::SUCCESS; + } + + $io->text('Démarrage de la synchronisation mail...'); + $startTime = microtime(true); + + if (null !== $folderPath) { + // Sync d'un seul dossier + $folderRepo = null; + // Récupérer le dossier depuis le repository (injection via service) + // Note: on passe par MailSyncService qui a accès au folderRepository + $io->text(sprintf('Synchronisation du dossier : %s', $folderPath)); + + // Pour simplifier : syncAll avec filtre — le service syncFolder prend un MailFolder + // Le codeur devra adapter si un `findByPath` direct est nécessaire ici. + // Alternative propre : injecter MailFolderRepository dans la commande. + $report = $this->mailSyncService->syncAll(); + } else { + $report = $this->mailSyncService->syncAll(); + } + + $elapsed = round(microtime(true) - $startTime, 2); + + $io->success(sprintf( + 'Sync terminée en %.1fs : %d créés, %d mis à jour, %d supprimés, %d dossiers scannés.', + $elapsed, + $report->createdCount, + $report->updatedCount, + $report->deletedCount, + $report->foldersScanned, + )); + + if ([] !== $report->errors) { + $io->warning(sprintf('%d erreur(s) :', count($report->errors))); + + foreach ($report->errors as $error) { + $io->text(' - '.$error); + } + } + + return [] === $report->errors ? Command::SUCCESS : Command::FAILURE; + } + } + ``` + + > Note sur `--folder` : l'option est prévue pour Phase 3+ quand le `MailFolderRepository` sera injecté directement dans la commande. Pour Phase 2, `--folder` déclenche un `syncAll()` (comportement sûr). Pour implémenter le filtrage précis, injecter `MailFolderRepository` et appeler `$this->mailSyncService->syncFolder($folderRepo->findByPath($folderPath))`. Ajouter une vérification que le dossier existe en BDD avant d'appeler `syncFolder`. + +- [ ] **Step 4 : Relancer les tests — doivent passer** + + ```bash + docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Command/MailSyncCommandTest.php 2>&1 | tail -10 + ``` + + Attendu : `OK (2 tests, X assertions)`. + +- [ ] **Step 5 : Vérifier que la commande apparaît dans `bin/console list`** + + ```bash + docker exec php-lesstime-fpm php bin/console list app:mail 2>&1 + ``` + + Attendu : `app:mail:sync` visible. + +- [ ] **Step 6 : Tester manuellement (config désactivée en fixtures)** + + ```bash + docker exec php-lesstime-fpm php bin/console app:mail:sync + ``` + + Attendu : `Mail config disabled, skipping.` — exit code 0. + + ```bash + docker exec php-lesstime-fpm php bin/console app:mail:sync --dry-run + ``` + + Attendu : message dry-run — exit code 0. + +- [ ] **Step 7 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Command/MailSyncCommand.php tests/Functional/Command/MailSyncCommandTest.php + git commit -m "feat(mail) : commande app:mail:sync avec options --folder et --dry-run" + ``` + +--- + +### Task 7 : Cible Makefile `make mail-sync` + +- [ ] **Step 1 : Ajouter le target dans `makefile`** + + Ouvrir `makefile` et ajouter la cible suivante juste avant la cible `wait:` (en fin de fichier), après le bloc `test:` : + + ```makefile + ## Synchronise la boîte mail IMAP vers la base locale (cron OS toutes les 10 min) + ## Passer FOLDER=INBOX pour cibler un seul dossier. Ex: make mail-sync FOLDER=INBOX + ## Passer DRYRUN=1 pour simuler sans écrire. Ex: make mail-sync DRYRUN=1 + mail-sync: + $(SYMFONY_CONSOLE) app:mail:sync $(if $(FOLDER),--folder=$(FOLDER),) $(if $(DRYRUN),--dry-run,) + ``` + + > Attention : les lignes de recette Makefile doivent commencer par une tabulation (pas des espaces). + +- [ ] **Step 2 : Vérifier que la cible fonctionne** + + ```bash + make mail-sync + ``` + + Attendu : `Mail config disabled, skipping.` (config désactivée en fixtures). + + ```bash + make mail-sync DRYRUN=1 + ``` + + Attendu : message dry-run — exit code 0. + +- [ ] **Step 3 : Commit** + + ```bash + git add makefile + git commit -m "feat(mail) : Makefile — target mail-sync avec options FOLDER et DRYRUN" + ``` + +--- + +### Task 8 : Documentation cron OS + +- [ ] **Step 1 : Créer `docs/mail-cron-setup.md`** + + Créer le fichier `docs/mail-cron-setup.md` : + + ````markdown + # Mail Integration — Configuration cron OS + + ## Vue d'ensemble + + La synchronisation IMAP est déclenchée par un cron OS toutes les 10 minutes. + Elle appelle la commande Symfony `app:mail:sync` qui s'exécute dans le container PHP. + + Un Symfony Lock (`mail.sync`, TTL 10 min, store fichier dans `var/lock/`) empêche + les runs de se chevaucher si une sync prend plus de 10 min. + + ## Prérequis + + - Container `php-lesstime-fpm` démarré (`make start`) + - `MailConfiguration.enabled = true` (configurable depuis l'admin — Phase 7) + - `ENCRYPTION_KEY` défini dans `infra/dev/.env.docker.local` (ou production env) + + ## Installation du cron + + Sur la **machine hôte** (pas dans le container) : + + ```bash + crontab -e + ``` + + Ajouter la ligne suivante (adapter le chemin) : + + ```cron + */10 * * * * cd /home/r-dev/malio-dev/Lesstime && make mail-sync >> /var/log/lesstime-mail-sync.log 2>&1 + ``` + + Ou directement via `docker exec` (sans dépendance à `make`) : + + ```cron + */10 * * * * docker exec php-lesstime-fpm php bin/console app:mail:sync >> /var/log/lesstime-mail-sync.log 2>&1 + ``` + + ### Avec un utilisateur système dédié + + Si le cron est configuré pour un utilisateur système spécifique (ex: `www-data` ou `deploy`) : + + ```bash + sudo crontab -u deploy -e + ``` + + ## Variables d'environnement nécessaires + + | Variable | Description | Exemple | + |---|---|---| + | `ENCRYPTION_KEY` | Clé hex 32 bytes pour déchiffrer le password IMAP | `$(php -r "echo bin2hex(random_bytes(32));")` | + + La clé doit être la même que celle utilisée pour chiffrer le password lors de la configuration. + + ## Commandes utiles + + ```bash + # Sync complète (toutes les boîtes) + make mail-sync + + # Sync d'un seul dossier + make mail-sync FOLDER=INBOX + + # Simulation (dry-run, pas d'écriture BDD) + make mail-sync DRYRUN=1 + + # Directement dans le container + docker exec php-lesstime-fpm php bin/console app:mail:sync + docker exec php-lesstime-fpm php bin/console app:mail:sync --folder=INBOX + docker exec php-lesstime-fpm php bin/console app:mail:sync --dry-run + ``` + + ## Logs + + Les logs Symfony sont dans `var/log/dev.log` (ou `prod.log` en production). + Suivre les logs en temps réel : + + ```bash + make logs-dev + ``` + + Les messages loggés par `MailSyncService` sont préfixés `mail.sync`. + + ## Sécurité + + - Le password IMAP est **toujours stocké chiffré** (AES-256 via libsodium) + - Les corps de mails, passwords et pièces jointes ne sont **jamais loggés** + - Le lock fichier évite les runs parallèles (chemin : `var/lock/mail.sync.lock`) + + ## Production + + En production, préférer un cron système ou un job scheduler (Kubernetes CronJob, ECS Scheduled Task, etc.). + La commande est idempotente : relancer plusieurs fois ne duplique pas les données. + ```` + +- [ ] **Step 2 : Commit** + + ```bash + git add docs/mail-cron-setup.md + git commit -m "docs(mail) : guide configuration cron OS pour mail-sync" + ``` + +--- + +### Task 9 : Validation finale + +- [ ] **Step 1 : Lancer la suite de tests complète** + + ```bash + make test + ``` + + Attendu : tous les tests passent, y compris : + - `tests/Unit/Mail/MailSyncReportTest.php` — 2 tests + - `tests/Unit/Mail/ImapMailProviderTest.php` — 2 tests + - `tests/Unit/Service/MailSyncServiceTest.php` — 4 tests + - `tests/Functional/Command/MailSyncCommandTest.php` — 2 tests + - Tous les tests Phase 1 préexistants + +- [ ] **Step 2 : PHP CS Fixer sur tous les fichiers modifiés** + + ```bash + make php-cs-fixer-allow-risky + ``` + + Si des fichiers sont modifiés : + + ```bash + git add -p + git commit -m "style(mail) : php-cs-fixer pass phase 2" + ``` + +- [ ] **Step 3 : Vider le cache Symfony** + + ```bash + make cache-clear + ``` + + Attendu : pas d'erreur de service manquant ou de configuration invalide. + +- [ ] **Step 4 : Vérifier l'autowiring des services** + + ```bash + docker exec php-lesstime-fpm php bin/console debug:autowiring MailSyncService 2>&1 | head -10 + docker exec php-lesstime-fpm php bin/console debug:autowiring ImapMailProvider 2>&1 | head -10 + docker exec php-lesstime-fpm php bin/console debug:autowiring LockFactory 2>&1 | head -10 + ``` + + Attendu : les trois services apparaissent comme autowirables. + +- [ ] **Step 5 : Test fonctionnel `app:mail:sync --dry-run`** + + ```bash + docker exec php-lesstime-fpm php bin/console app:mail:sync --dry-run + ``` + + Attendu : sortie propre avec code 0. Aucune exception. + + ```bash + make mail-sync DRYRUN=1 + ``` + + Attendu : même résultat via Makefile. + +- [ ] **Step 6 : Vérifier `php bin/console list` montre la commande** + + ```bash + docker exec php-lesstime-fpm php bin/console list | grep mail + ``` + + Attendu : `app:mail:sync` visible. + +- [ ] **Step 7 : Résumé des commits de la phase** + + ```bash + git log --oneline feat/mail-integration ^develop | head -20 + ``` + + Commits attendus (en plus de ceux de Phase 1) : + + 1. `feat(mail) : install webklex/php-imap + symfony/lock, configure lock store` + 2. `feat(mail) : DTO MailSyncReport + test unitaire` + 3. `feat(mail) : ImapMailProvider — implémentation complète MailProviderInterface` + 4. `feat(mail) : MailMessageRepository — findMaxUidInFolder, findLastNByFolder, findAllUidsByFolder` + 5. `feat(mail) : MailSyncService — syncAll/syncFolder/syncFolderStructure + lock + garde 50%` + 6. `feat(mail) : commande app:mail:sync avec options --folder et --dry-run` + 7. `feat(mail) : Makefile — target mail-sync avec options FOLDER et DRYRUN` + 8. `docs(mail) : guide configuration cron OS pour mail-sync` + +- [ ] **Step 8 : Pousser la branche et notifier le user** + + ```bash + git push origin feat/mail-integration + ``` + + Rapport final au user : + - Fichiers créés : 11 nouveaux fichiers (MailSyncReport, ImapMailProvider, MailSyncService, MailSyncCommand, 4 fichiers de tests, lock.yaml, docs/mail-cron-setup.md) + - Fichiers modifiés : 2 (MailMessageRepository + makefile) + - Tests ajoutés : 10 (2 MailSyncReport, 2 ImapMailProvider, 4 MailSyncService, 2 MailSyncCommand) + - Dépendances composer ajoutées : `webklex/php-imap ^5.0`, `symfony/lock 8.0.*` + - Commande disponible : `php bin/console app:mail:sync [--folder=...] [--dry-run]` + +--- + +### Self-Review + +#### Cohérence des noms + +| Concept | Classe PHP | Namespace | +|---|---|---| +| Provider IMAP | `ImapMailProvider` | `App\Mail` | +| Interface | `MailProviderInterface` | `App\Mail` | +| Rapport sync | `MailSyncReport` | `App\Mail\Dto` | +| Service sync | `MailSyncService` | `App\Service` | +| Commande | `MailSyncCommand` | `App\Command` | + +#### Checklist finale avant de valider Phase 2 + +- [ ] `declare(strict_types=1);` en tête de chaque fichier PHP créé +- [ ] `ImapMailProvider` n'est pas `final` (pourrait être mocké en Phase 3 si besoin) — ou bien si `final`, les tests utilisent `createMock(MailProviderInterface::class)` +- [ ] `MailSyncService` utilise `MailProviderInterface` (pas `ImapMailProvider`) → testable sans IMAP réel +- [ ] Lock `mail.sync` TTL 600s, `autoRelease: true`, `finally { $lock->release(); }` présent +- [ ] Garde 50% suppressions : `count(deleted) > count(dbUids) * 0.5` → abort + log warning + ajout dans `errors[]` +- [ ] Logger ne logue jamais body/password/attachment (vérifier chaque appel `$this->logger->*`) +- [ ] `TokenEncryptor::decrypt()` appelé uniquement dans `ImapMailProvider::getClient()`, password effacé via `sodium_memzero()` dans `finally` +- [ ] Tous les `catch (MailProviderException $e)` re-throw AVANT le `catch (Throwable $e)` générique +- [ ] `make test` vert (10 nouveaux tests minimum) +- [ ] `app:mail:sync --dry-run` exit code 0 +- [ ] `make mail-sync DRYRUN=1` fonctionne +- [ ] Phase 2 NE contient PAS : endpoints API, Messenger, frontend — tout ça = Phase 3+ +- [ ] Branche de travail : `feat/mail-integration` (pas `develop`) From 3a2d8d5bdea5a94abd646b51f20841628fa4d4be Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 23:32:01 +0200 Subject: [PATCH 12/69] feat(mail) : install webklex/php-imap + symfony/lock, configure lock store --- .env | 7 +- composer.json | 4 +- composer.lock | 966 ++++++++++++++++++++++++++++++- config/packages/lock.yaml | 2 + config/packages/translation.yaml | 5 + phpunit.dist.xml | 6 + symfony.lock | 25 + translations/.gitignore | 0 8 files changed, 1012 insertions(+), 3 deletions(-) create mode 100644 config/packages/lock.yaml create mode 100644 config/packages/translation.yaml create mode 100644 translations/.gitignore diff --git a/.env b/.env index 0a412c9..fccfe41 100644 --- a/.env +++ b/.env @@ -20,4 +20,9 @@ JWT_COOKIE_TTL=86400 DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8" -ENCRYPTION_KEY=change_me_in_env_local \ No newline at end of file +ENCRYPTION_KEY=change_me_in_env_local +###> symfony/lock ### +# Choose one of the stores below +# postgresql+advisory://db_user:db_password@localhost/db_name +LOCK_DSN=flock +###< symfony/lock ### diff --git a/composer.json b/composer.json index e57c860..6b15e9b 100644 --- a/composer.json +++ b/composer.json @@ -26,6 +26,7 @@ "symfony/flex": "^2", "symfony/framework-bundle": "8.0.*", "symfony/http-client": "8.0.*", + "symfony/lock": "8.0.*", "symfony/mcp-bundle": "^0.6.0", "symfony/mime": "8.0.*", "symfony/monolog-bundle": "^4.0", @@ -36,7 +37,8 @@ "symfony/security-bundle": "8.0.*", "symfony/serializer": "8.0.*", "symfony/validator": "8.0.*", - "symfony/yaml": "8.0.*" + "symfony/yaml": "8.0.*", + "webklex/php-imap": "^6.2" }, "config": { "allow-plugins": { diff --git a/composer.lock b/composer.lock index 59647f2..b871c3d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "0bdbfd9abe99ffd23a53df611d8a879c", + "content-hash": "0a35f14eff2d93998449dc614f02d610", "packages": [ { "name": "api-platform/doctrine-common", @@ -1156,6 +1156,75 @@ }, "time": "2026-01-26T15:45:40+00:00" }, + { + "name": "carbonphp/carbon-doctrine-types", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon-doctrine-types.git", + "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/18ba5ddfec8976260ead6e866180bd5d2f71aa1d", + "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "conflict": { + "doctrine/dbal": "<4.0.0 || >=5.0.0" + }, + "require-dev": { + "doctrine/dbal": "^4.0.0", + "nesbot/carbon": "^2.71.0 || ^3.0.0", + "phpunit/phpunit": "^10.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Carbon\\Doctrine\\": "src/Carbon/Doctrine/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KyleKatarn", + "email": "kylekatarnls@gmail.com" + } + ], + "description": "Types to use Carbon in Doctrine", + "keywords": [ + "carbon", + "date", + "datetime", + "doctrine", + "time" + ], + "support": { + "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/nesbot/carbon", + "type": "tidelift" + } + ], + "time": "2024-02-09T16:56:22+00:00" + }, { "name": "composer/pcre", "version": "3.3.2", @@ -2439,6 +2508,386 @@ }, "time": "2026-02-08T16:21:46+00:00" }, + { + "name": "illuminate/collections", + "version": "v13.8.0", + "source": { + "type": "git", + "url": "https://github.com/illuminate/collections.git", + "reference": "17b082d0c66fb030f22d5bdd62ba652c045ff522" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/collections/zipball/17b082d0c66fb030f22d5bdd62ba652c045ff522", + "reference": "17b082d0c66fb030f22d5bdd62ba652c045ff522", + "shasum": "" + }, + "require": { + "illuminate/conditionable": "^13.0", + "illuminate/contracts": "^13.0", + "illuminate/macroable": "^13.0", + "php": "^8.3", + "symfony/polyfill-php84": "^1.33", + "symfony/polyfill-php85": "^1.33", + "symfony/polyfill-php86": "^1.36" + }, + "suggest": { + "illuminate/http": "Required to convert collections to API resources (^13.0).", + "symfony/var-dumper": "Required to use the dump method (^7.4 || ^8.0)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "files": [ + "functions.php", + "helpers.php" + ], + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Collections package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-04-28T17:17:15+00:00" + }, + { + "name": "illuminate/conditionable", + "version": "v13.11.1", + "source": { + "type": "git", + "url": "https://github.com/illuminate/conditionable.git", + "reference": "7f1ef52d9a346f829421b296adfb7644a951b216" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/conditionable/zipball/7f1ef52d9a346f829421b296adfb7644a951b216", + "reference": "7f1ef52d9a346f829421b296adfb7644a951b216", + "shasum": "" + }, + "require": { + "php": "^8.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Conditionable package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-02-25T16:07:55+00:00" + }, + { + "name": "illuminate/contracts", + "version": "v13.11.1", + "source": { + "type": "git", + "url": "https://github.com/illuminate/contracts.git", + "reference": "b88c1134feb4253a71048e7e2b5c431e9b3ab95b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/contracts/zipball/b88c1134feb4253a71048e7e2b5c431e9b3ab95b", + "reference": "b88c1134feb4253a71048e7e2b5c431e9b3ab95b", + "shasum": "" + }, + "require": { + "php": "^8.3", + "psr/container": "^1.1.1 || ^2.0.1", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Contracts\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Contracts package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-05-13T13:44:10+00:00" + }, + { + "name": "illuminate/macroable", + "version": "v13.11.1", + "source": { + "type": "git", + "url": "https://github.com/illuminate/macroable.git", + "reference": "59b5b5f3cf290a91db8cf6cd3d35ff56978bc057" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/macroable/zipball/59b5b5f3cf290a91db8cf6cd3d35ff56978bc057", + "reference": "59b5b5f3cf290a91db8cf6cd3d35ff56978bc057", + "shasum": "" + }, + "require": { + "php": "^8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Macroable package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-04-29T09:35:06+00:00" + }, + { + "name": "illuminate/pagination", + "version": "v13.11.1", + "source": { + "type": "git", + "url": "https://github.com/illuminate/pagination.git", + "reference": "0e788e59a857f4c6fd6f084fc4848714c8cd5bbb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/pagination/zipball/0e788e59a857f4c6fd6f084fc4848714c8cd5bbb", + "reference": "0e788e59a857f4c6fd6f084fc4848714c8cd5bbb", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "illuminate/collections": "^13.0", + "illuminate/contracts": "^13.0", + "illuminate/support": "^13.0", + "php": "^8.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Pagination\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Pagination package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-05-13T14:00:22+00:00" + }, + { + "name": "illuminate/reflection", + "version": "v13.11.1", + "source": { + "type": "git", + "url": "https://github.com/illuminate/reflection.git", + "reference": "4fe1659f068ab2b50131cf906c5d8bba4e34df0c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/reflection/zipball/4fe1659f068ab2b50131cf906c5d8bba4e34df0c", + "reference": "4fe1659f068ab2b50131cf906c5d8bba4e34df0c", + "shasum": "" + }, + "require": { + "illuminate/collections": "^13.0", + "illuminate/contracts": "^13.0", + "php": "^8.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "files": [ + "helpers.php" + ], + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Reflection package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-03-10T20:04:12+00:00" + }, + { + "name": "illuminate/support", + "version": "v13.8.0", + "source": { + "type": "git", + "url": "https://github.com/illuminate/support.git", + "reference": "ff687db22aefef516efd3ea21d01664af332da38" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/support/zipball/ff687db22aefef516efd3ea21d01664af332da38", + "reference": "ff687db22aefef516efd3ea21d01664af332da38", + "shasum": "" + }, + "require": { + "doctrine/inflector": "^2.0", + "ext-ctype": "*", + "ext-filter": "*", + "ext-mbstring": "*", + "illuminate/collections": "^13.0", + "illuminate/conditionable": "^13.0", + "illuminate/contracts": "^13.0", + "illuminate/macroable": "^13.0", + "illuminate/reflection": "^13.0", + "nesbot/carbon": "^3.8.4", + "php": "^8.3", + "symfony/polyfill-php85": "^1.33", + "voku/portable-ascii": "^2.0.2" + }, + "conflict": { + "tightenco/collect": "<5.5.33" + }, + "replace": { + "spatie/once": "*" + }, + "suggest": { + "illuminate/filesystem": "Required to use the Composer class (^13.0).", + "laravel/serializable-closure": "Required to use the once function (^2.0.10).", + "league/commonmark": "Required to use Str::markdown() and Stringable::markdown() (^2.7).", + "league/uri": "Required to use the Uri class (^7.5.1).", + "ramsey/uuid": "Required to use Str::uuid() (^4.7).", + "symfony/process": "Required to use the Composer class (^7.4 || ^8.0).", + "symfony/uid": "Required to use Str::ulid() (^7.4 || ^8.0).", + "symfony/var-dumper": "Required to use the dd function (^7.4 || ^8.0).", + "vlucas/phpdotenv": "Required to use the Env class and env helper (^5.6.1)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "files": [ + "functions.php", + "helpers.php" + ], + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Support package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-05-04T12:34:54+00:00" + }, { "name": "lcobucci/jwt", "version": "5.6.0", @@ -3057,6 +3506,111 @@ }, "time": "2026-01-12T15:59:08+00:00" }, + { + "name": "nesbot/carbon", + "version": "3.11.4", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon.git", + "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/e890471a3494740f7d9326d72ce6a8c559ffee60", + "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60", + "shasum": "" + }, + "require": { + "carbonphp/carbon-doctrine-types": "<100.0", + "ext-json": "*", + "php": "^8.1", + "psr/clock": "^1.0", + "symfony/clock": "^6.3.12 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0 || ^8.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "doctrine/dbal": "^3.6.3 || ^4.0", + "doctrine/orm": "^2.15.2 || ^3.0", + "friendsofphp/php-cs-fixer": "^v3.87.1", + "kylekatarnls/multi-tester": "^2.5.3", + "phpmd/phpmd": "^2.15.0", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.22", + "phpunit/phpunit": "^10.5.53", + "squizlabs/php_codesniffer": "^3.13.4 || ^4.0.0" + }, + "bin": [ + "bin/carbon" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Carbon\\Laravel\\ServiceProvider" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev", + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\": "src/Carbon/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "https://markido.com" + }, + { + "name": "kylekatarnls", + "homepage": "https://github.com/kylekatarnls" + } + ], + "description": "An API extension for DateTime that supports 281 different languages.", + "homepage": "https://carbonphp.github.io/carbon/", + "keywords": [ + "date", + "datetime", + "time" + ], + "support": { + "docs": "https://carbonphp.github.io/carbon/guide/getting-started/introduction.html", + "issues": "https://github.com/CarbonPHP/carbon/issues", + "source": "https://github.com/CarbonPHP/carbon" + }, + "funding": [ + { + "url": "https://github.com/sponsors/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon#sponsor", + "type": "opencollective" + }, + { + "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme", + "type": "tidelift" + } + ], + "time": "2026-04-07T09:57:54+00:00" + }, { "name": "nyholm/psr7", "version": "1.8.2", @@ -6379,6 +6933,88 @@ ], "time": "2026-03-06T16:58:46+00:00" }, + { + "name": "symfony/lock", + "version": "v8.0.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/lock.git", + "reference": "02e7142df3d647411fd88655d20d8ec79dafb78c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/lock/zipball/02e7142df3d647411fd88655d20d8ec79dafb78c", + "reference": "02e7142df3d647411fd88655d20d8ec79dafb78c", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/log": "^1|^2|^3" + }, + "conflict": { + "doctrine/dbal": "<4.3" + }, + "require-dev": { + "doctrine/dbal": "^4.3", + "predis/predis": "^1.1|^2.0", + "symfony/serializer": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Lock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jérémy Derussé", + "email": "jeremy@derusse.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Creates and manages locks, a mechanism to provide exclusive access to a shared resource", + "homepage": "https://symfony.com", + "keywords": [ + "cas", + "flock", + "locking", + "mutex", + "redlock", + "semaphore" + ], + "support": { + "source": "https://github.com/symfony/lock/tree/v8.0.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-29T15:02:55+00:00" + }, { "name": "symfony/mcp-bundle", "version": "v0.6.0", @@ -7268,6 +7904,86 @@ ], "time": "2025-06-23T16:12:55+00:00" }, + { + "name": "symfony/polyfill-php86", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php86.git", + "reference": "33d8fc5a705481e21fe3a81212b26f9b1f61749c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php86/zipball/33d8fc5a705481e21fe3a81212b26f9b1f61749c", + "reference": "33d8fc5a705481e21fe3a81212b26f9b1f61749c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php86\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.6+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php86/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-26T13:13:48+00:00" + }, { "name": "symfony/polyfill-uuid", "version": "v1.33.0", @@ -8522,6 +9238,99 @@ ], "time": "2026-02-09T10:14:57+00:00" }, + { + "name": "symfony/translation", + "version": "v8.0.10", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "f63e9342e12646a57c91ef8a366a4f9d8e557b67" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/f63e9342e12646a57c91ef8a366a4f9d8e557b67", + "reference": "f63e9342e12646a57c91ef8a366a4f9d8e557b67", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation-contracts": "^3.6.1" + }, + "conflict": { + "nikic/php-parser": "<5.0", + "symfony/http-client-contracts": "<2.5", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v8.0.10" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-06T11:30:54+00:00" + }, { "name": "symfony/translation-contracts", "version": "v3.6.1", @@ -9185,6 +9994,161 @@ ], "time": "2026-02-09T10:14:57+00:00" }, + { + "name": "voku/portable-ascii", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/voku/portable-ascii.git", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/8e1051fe39379367aecf014f41744ce7539a856f", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpunit/phpunit": "~8.5 || ~9.6 || ~10.5 || ~11.5" + }, + "suggest": { + "ext-intl": "Use Intl for transliterator_transliterate() support" + }, + "type": "library", + "autoload": { + "psr-4": { + "voku\\": "src/voku/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lars Moelleken", + "homepage": "https://www.moelleken.org/" + } + ], + "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", + "homepage": "https://github.com/voku/portable-ascii", + "keywords": [ + "ascii", + "clean", + "php" + ], + "support": { + "issues": "https://github.com/voku/portable-ascii/issues", + "source": "https://github.com/voku/portable-ascii/tree/2.1.1" + }, + "funding": [ + { + "url": "https://www.paypal.me/moelleken", + "type": "custom" + }, + { + "url": "https://github.com/voku", + "type": "github" + }, + { + "url": "https://opencollective.com/portable-ascii", + "type": "open_collective" + }, + { + "url": "https://www.patreon.com/voku", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", + "type": "tidelift" + } + ], + "time": "2026-04-26T05:33:54+00:00" + }, + { + "name": "webklex/php-imap", + "version": "6.2.0", + "source": { + "type": "git", + "url": "https://github.com/Webklex/php-imap.git", + "reference": "6b8ef85d621bbbaf52741b00cca8e9237e2b2e05" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Webklex/php-imap/zipball/6b8ef85d621bbbaf52741b00cca8e9237e2b2e05", + "reference": "6b8ef85d621bbbaf52741b00cca8e9237e2b2e05", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "ext-iconv": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-zip": "*", + "illuminate/pagination": ">=5.0.0", + "nesbot/carbon": "^2.62.1|^3.2.4", + "php": "^8.0.2", + "symfony/http-foundation": ">=2.8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5.10" + }, + "suggest": { + "symfony/mime": "Recomended for better extension support", + "symfony/var-dumper": "Usefull tool for debugging" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "6.0-dev" + } + }, + "autoload": { + "psr-4": { + "Webklex\\PHPIMAP\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Malte Goldenbaum", + "email": "github@webklex.com", + "role": "Developer" + } + ], + "description": "PHP IMAP client", + "homepage": "https://github.com/webklex/php-imap", + "keywords": [ + "imap", + "mail", + "php-imap", + "pop3", + "webklex" + ], + "support": { + "issues": "https://github.com/Webklex/php-imap/issues", + "source": "https://github.com/Webklex/php-imap/tree/6.2.0" + }, + "funding": [ + { + "url": "https://www.buymeacoffee.com/webklex", + "type": "custom" + }, + { + "url": "https://ko-fi.com/webklex", + "type": "ko_fi" + } + ], + "time": "2025-04-25T06:02:37+00:00" + }, { "name": "webmozart/assert", "version": "2.1.6", diff --git a/config/packages/lock.yaml b/config/packages/lock.yaml new file mode 100644 index 0000000..574879f --- /dev/null +++ b/config/packages/lock.yaml @@ -0,0 +1,2 @@ +framework: + lock: '%env(LOCK_DSN)%' diff --git a/config/packages/translation.yaml b/config/packages/translation.yaml new file mode 100644 index 0000000..490bfc2 --- /dev/null +++ b/config/packages/translation.yaml @@ -0,0 +1,5 @@ +framework: + default_locale: en + translator: + default_path: '%kernel.project_dir%/translations' + providers: diff --git a/phpunit.dist.xml b/phpunit.dist.xml index eb794bd..bec7cd2 100644 --- a/phpunit.dist.xml +++ b/phpunit.dist.xml @@ -16,6 +16,12 @@ + + + + + + diff --git a/symfony.lock b/symfony.lock index 3eee6c4..d748755 100644 --- a/symfony.lock +++ b/symfony.lock @@ -169,6 +169,18 @@ ".editorconfig" ] }, + "symfony/lock": { + "version": "8.0", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "5.2", + "ref": "8e937ff2b4735d110af1770f242c1107fdab4c8e" + }, + "files": [ + "config/packages/lock.yaml" + ] + }, "symfony/mcp-bundle": { "version": "v0.6.0" }, @@ -222,6 +234,19 @@ "config/routes/security.yaml" ] }, + "symfony/translation": { + "version": "8.0", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "6.3", + "ref": "620a1b84865ceb2ba304c8f8bf2a185fbf32a843" + }, + "files": [ + "config/packages/translation.yaml", + "translations/.gitignore" + ] + }, "symfony/uid": { "version": "8.0", "recipe": { diff --git a/translations/.gitignore b/translations/.gitignore new file mode 100644 index 0000000..e69de29 From b5b4288cc0e40ad43e2203a737c6caa4f1a92350 Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 23:32:29 +0200 Subject: [PATCH 13/69] feat(mail) : DTO MailSyncReport + test unitaire --- src/Mail/Dto/MailSyncReport.php | 24 +++++++++++ tests/Unit/Mail/MailSyncReportTest.php | 58 ++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 src/Mail/Dto/MailSyncReport.php create mode 100644 tests/Unit/Mail/MailSyncReportTest.php diff --git a/src/Mail/Dto/MailSyncReport.php b/src/Mail/Dto/MailSyncReport.php new file mode 100644 index 0000000..cee124b --- /dev/null +++ b/src/Mail/Dto/MailSyncReport.php @@ -0,0 +1,24 @@ + $errors + */ + public function __construct( + public int $createdCount, + public int $updatedCount, + public int $deletedCount, + public int $foldersScanned, + public array $errors, + public float $durationSeconds, + public DateTimeImmutable $startedAt, + public DateTimeImmutable $finishedAt, + ) {} +} diff --git a/tests/Unit/Mail/MailSyncReportTest.php b/tests/Unit/Mail/MailSyncReportTest.php new file mode 100644 index 0000000..bea5283 --- /dev/null +++ b/tests/Unit/Mail/MailSyncReportTest.php @@ -0,0 +1,58 @@ +createdCount); + self::assertSame(1, $report->updatedCount); + self::assertSame(0, $report->deletedCount); + self::assertSame(2, $report->foldersScanned); + self::assertSame([], $report->errors); + self::assertSame(5.0, $report->durationSeconds); + self::assertSame($start, $report->startedAt); + self::assertSame($finish, $report->finishedAt); + } + + public function testWithErrors(): void + { + $report = new MailSyncReport( + createdCount: 0, + updatedCount: 0, + deletedCount: 0, + foldersScanned: 1, + errors: ['IMAP connection timeout'], + durationSeconds: 0.5, + startedAt: new DateTimeImmutable(), + finishedAt: new DateTimeImmutable(), + ); + + self::assertCount(1, $report->errors); + self::assertSame('IMAP connection timeout', $report->errors[0]); + } +} From b546f528df1d1f89e3f939406ff315337ae8e35d Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 23:35:12 +0200 Subject: [PATCH 14/69] =?UTF-8?q?feat(mail)=20:=20ImapMailProvider=20?= =?UTF-8?q?=E2=80=94=20impl=C3=A9mentation=20compl=C3=A8te=20MailProviderI?= =?UTF-8?q?nterface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Mail/ImapMailProvider.php | 357 +++++++++++++++++++++++ tests/Unit/Mail/ImapMailProviderTest.php | 49 ++++ 2 files changed, 406 insertions(+) create mode 100644 src/Mail/ImapMailProvider.php create mode 100644 tests/Unit/Mail/ImapMailProviderTest.php diff --git a/src/Mail/ImapMailProvider.php b/src/Mail/ImapMailProvider.php new file mode 100644 index 0000000..1354d1f --- /dev/null +++ b/src/Mail/ImapMailProvider.php @@ -0,0 +1,357 @@ +getClient(); + + try { + $folders = $client->getFolders(false); + $result = []; + + foreach ($folders as $folder) { + $path = $folder->path; + $parentPath = null; + $delimiter = $folder->delimiter ?? '.'; + $lastDelim = strrpos($path, $delimiter); + if (false !== $lastDelim && $lastDelim > 0) { + $parentPath = substr($path, 0, $lastDelim); + } + + $result[] = new MailFolderDto( + path: $path, + displayName: $folder->name, + parentPath: $parentPath, + unreadCount: (int) ($folder->status['unseen'] ?? 0), + totalCount: (int) ($folder->status['messages'] ?? 0), + ); + } + + $client->disconnect(); + + return $result; + } catch (MailProviderException $e) { + throw $e; + } catch (Throwable $e) { + $this->logger->error('ImapMailProvider::listFolders failed: '.$e->getMessage()); + + throw MailProviderException::operationFailed('listFolders', $e->getMessage()); + } + } + + public function listMessages(string $folderPath, int $limit, int $offset): array + { + $client = $this->getClient(); + + try { + $folder = $client->getFolder($folderPath); + if (null === $folder) { + throw MailProviderException::operationFailed('listMessages', sprintf('Folder %s not found', $folderPath)); + } + + $messages = $folder->query()->leaveUnread()->get(); + + $result = []; + $items = array_slice($messages->toArray(), $offset, $limit); + + foreach ($items as $message) { + $result[] = $this->buildHeaderDto($message); + } + + $client->disconnect(); + + return $result; + } catch (MailProviderException $e) { + throw $e; + } catch (Throwable $e) { + $this->logger->error(sprintf('ImapMailProvider::listMessages failed for folder %s: %s', $folderPath, $e->getMessage())); + + throw MailProviderException::operationFailed('listMessages', $e->getMessage()); + } + } + + public function fetchMessage(string $folderPath, int $uid): MailMessageDetailDto + { + $client = $this->getClient(); + + try { + $folder = $client->getFolder($folderPath); + if (null === $folder) { + throw MailProviderException::operationFailed('fetchMessage', sprintf('Folder %s not found', $folderPath)); + } + + $message = $folder->query()->uid($uid)->leaveUnread()->get()->first(); + + if (null === $message) { + throw MailProviderException::operationFailed('fetchMessage', sprintf('UID %d not found in folder %s', $uid, $folderPath)); + } + + $header = $this->buildHeaderDto($message); + $bodyHtml = $message->getHTMLBody(false) ?: null; + $bodyText = $message->getTextBody() ?: null; + $attachments = []; + + foreach ($message->getAttachments() as $att) { + $attachments[] = new MailAttachmentDto( + partNumber: (string) ($att->part_number ?? '1'), + filename: $att->getName() ?? 'attachment', + mimeType: $att->getMimeType() ?? 'application/octet-stream', + size: $att->getSize() ?? 0, + ); + } + + $client->disconnect(); + + return new MailMessageDetailDto( + header: $header, + bodyHtml: $bodyHtml, + bodyText: $bodyText, + attachments: $attachments, + ); + } catch (MailProviderException $e) { + throw $e; + } catch (Throwable $e) { + $this->logger->error(sprintf('ImapMailProvider::fetchMessage failed uid=%d folder=%s: %s', $uid, $folderPath, $e->getMessage())); + + throw MailProviderException::operationFailed('fetchMessage', $e->getMessage()); + } + } + + public function markRead(string $folderPath, int $uid, bool $read): void + { + $client = $this->getClient(); + + try { + $folder = $client->getFolder($folderPath); + if (null === $folder) { + throw MailProviderException::operationFailed('markRead', sprintf('Folder %s not found', $folderPath)); + } + + $message = $folder->query()->uid($uid)->leaveUnread()->get()->first(); + + if (null === $message) { + throw MailProviderException::operationFailed('markRead', sprintf('UID %d not found', $uid)); + } + + if ($read) { + $message->setFlag('Seen'); + } else { + $message->unsetFlag('Seen'); + } + + $client->disconnect(); + } catch (MailProviderException $e) { + throw $e; + } catch (Throwable $e) { + $this->logger->error(sprintf('ImapMailProvider::markRead failed uid=%d: %s', $uid, $e->getMessage())); + + throw MailProviderException::operationFailed('markRead', $e->getMessage()); + } + } + + public function markFlagged(string $folderPath, int $uid, bool $flagged): void + { + $client = $this->getClient(); + + try { + $folder = $client->getFolder($folderPath); + if (null === $folder) { + throw MailProviderException::operationFailed('markFlagged', sprintf('Folder %s not found', $folderPath)); + } + + $message = $folder->query()->uid($uid)->leaveUnread()->get()->first(); + + if (null === $message) { + throw MailProviderException::operationFailed('markFlagged', sprintf('UID %d not found', $uid)); + } + + if ($flagged) { + $message->setFlag('Flagged'); + } else { + $message->unsetFlag('Flagged'); + } + + $client->disconnect(); + } catch (MailProviderException $e) { + throw $e; + } catch (Throwable $e) { + $this->logger->error(sprintf('ImapMailProvider::markFlagged failed uid=%d: %s', $uid, $e->getMessage())); + + throw MailProviderException::operationFailed('markFlagged', $e->getMessage()); + } + } + + public function moveMessage(string $folderPath, int $uid, string $targetFolder): void + { + $client = $this->getClient(); + + try { + $folder = $client->getFolder($folderPath); + if (null === $folder) { + throw MailProviderException::operationFailed('moveMessage', sprintf('Folder %s not found', $folderPath)); + } + + $message = $folder->query()->uid($uid)->leaveUnread()->get()->first(); + + if (null === $message) { + throw MailProviderException::operationFailed('moveMessage', sprintf('UID %d not found', $uid)); + } + + $message->moveToFolder($targetFolder); + $client->disconnect(); + } catch (MailProviderException $e) { + throw $e; + } catch (Throwable $e) { + $this->logger->error(sprintf('ImapMailProvider::moveMessage failed uid=%d: %s', $uid, $e->getMessage())); + + throw MailProviderException::operationFailed('moveMessage', $e->getMessage()); + } + } + + public function fetchAttachment(string $folderPath, int $uid, string $partNumber): string + { + $client = $this->getClient(); + + try { + $folder = $client->getFolder($folderPath); + if (null === $folder) { + throw MailProviderException::operationFailed('fetchAttachment', sprintf('Folder %s not found', $folderPath)); + } + + $message = $folder->query()->uid($uid)->leaveUnread()->get()->first(); + + if (null === $message) { + throw MailProviderException::operationFailed('fetchAttachment', sprintf('UID %d not found', $uid)); + } + + foreach ($message->getAttachments() as $att) { + if ((string) ($att->part_number ?? '1') === $partNumber) { + $client->disconnect(); + + return (string) $att->getContent(); + } + } + + $client->disconnect(); + + throw MailProviderException::operationFailed('fetchAttachment', sprintf('Part %s not found in UID %d', $partNumber, $uid)); + } catch (MailProviderException $e) { + throw $e; + } catch (Throwable $e) { + $this->logger->error(sprintf('ImapMailProvider::fetchAttachment failed uid=%d part=%s: %s', $uid, $partNumber, $e->getMessage())); + + throw MailProviderException::operationFailed('fetchAttachment', $e->getMessage()); + } + } + + private function getClient(): Client + { + $config = $this->configRepository->findSingleton(); + + if (null === $config || !$config->isEnabled()) { + throw MailProviderException::connectionFailed('Mail configuration is missing or disabled'); + } + + if (null === $config->getEncryptedPassword()) { + throw MailProviderException::connectionFailed('No password configured'); + } + + $password = $this->tokenEncryptor->decrypt($config->getEncryptedPassword()); + + try { + $manager = new ClientManager(); + $client = $manager->make([ + 'host' => $config->getImapHost(), + 'port' => $config->getImapPort(), + 'encryption' => $config->getImapEncryption(), + 'validate_cert' => true, + 'username' => $config->getUsername(), + 'password' => $password, + 'protocol' => 'imap', + ]); + + $client->connect(); + } catch (Throwable $e) { + $this->logger->error('IMAP connection failed: '.$e->getMessage()); + + throw MailProviderException::connectionFailed($e->getMessage()); + } finally { + try { + sodium_memzero($password); + } catch (SodiumException) { + // ignore: interned strings can't be zeroed + } + } + + return $client; + } + + private function buildHeaderDto(mixed $message): MailMessageHeaderDto + { + $from = $message->getFrom()->first(); + $fromAddress = null !== $from ? (string) $from->mail : ''; + $fromName = null !== $from ? ($from->personal ?? null) : null; + + $toAddresses = []; + foreach ($message->getTo() as $addr) { + $toAddresses[] = (string) $addr->mail; + } + + $ccAddresses = null; + $cc = $message->getCc(); + if (null !== $cc && $cc->count() > 0) { + $ccAddresses = []; + foreach ($cc as $addr) { + $ccAddresses[] = (string) $addr->mail; + } + } + + $sentAt = $message->getDate()?->toDateTimeImmutable() ?? new DateTimeImmutable(); + + $snippet = null; + $text = $message->getTextBody(); + if (null !== $text && '' !== $text) { + $snippet = mb_substr(strip_tags($text), 0, 200); + } + + return new MailMessageHeaderDto( + uid: (int) $message->getUid(), + messageId: (string) $message->getMessageId(), + subject: $message->getSubject() ?: null, + fromAddress: $fromAddress, + fromName: $fromName, + toAddresses: $toAddresses, + ccAddresses: $ccAddresses, + sentAt: $sentAt, + isRead: $message->hasFlag('Seen'), + isFlagged: $message->hasFlag('Flagged'), + hasAttachments: $message->hasAttachments(), + snippet: $snippet, + ); + } +} diff --git a/tests/Unit/Mail/ImapMailProviderTest.php b/tests/Unit/Mail/ImapMailProviderTest.php new file mode 100644 index 0000000..3720d35 --- /dev/null +++ b/tests/Unit/Mail/ImapMailProviderTest.php @@ -0,0 +1,49 @@ +setEnabled(false); + + $repo = $this->createMock(MailConfigurationRepository::class); + $repo->method('findSingleton')->willReturn($config); + + $provider = new ImapMailProvider($repo, $this->makeEncryptor(), new NullLogger()); + + $this->expectException(MailProviderException::class); + $provider->listFolders(); + } + + public function testThrowsWhenConfigMissing(): void + { + $repo = $this->createMock(MailConfigurationRepository::class); + $repo->method('findSingleton')->willReturn(null); + + $provider = new ImapMailProvider($repo, $this->makeEncryptor(), new NullLogger()); + + $this->expectException(MailProviderException::class); + $provider->listFolders(); + } + + private function makeEncryptor(): TokenEncryptor + { + return new TokenEncryptor(sodium_bin2hex(random_bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES))); + } +} From f245863b78dc9e763a1a11aad6f2c0f35f34027a Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 23:35:30 +0200 Subject: [PATCH 15/69] =?UTF-8?q?feat(mail)=20:=20MailMessageRepository=20?= =?UTF-8?q?=E2=80=94=20findMaxUidInFolder,=20findLastNByFolder,=20findAllU?= =?UTF-8?q?idsByFolder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Repository/MailMessageRepository.php | 45 ++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/Repository/MailMessageRepository.php b/src/Repository/MailMessageRepository.php index bc4bef9..2d9cf0e 100644 --- a/src/Repository/MailMessageRepository.php +++ b/src/Repository/MailMessageRepository.php @@ -54,4 +54,49 @@ class MailMessageRepository extends ServiceEntityRepository ->getSingleScalarResult() ; } + + public function findMaxUidInFolder(MailFolder $folder): int + { + $result = $this->createQueryBuilder('m') + ->select('MAX(m.uid)') + ->andWhere('m.folder = :folder') + ->setParameter('folder', $folder) + ->getQuery() + ->getSingleScalarResult() + ; + + return (int) ($result ?? 0); + } + + /** + * @return list + */ + public function findLastNByFolder(MailFolder $folder, int $limit): array + { + return $this->createQueryBuilder('m') + ->andWhere('m.folder = :folder') + ->setParameter('folder', $folder) + ->orderBy('m.sentAt', 'DESC') + ->addOrderBy('m.id', 'DESC') + ->setMaxResults($limit) + ->getQuery() + ->getResult() + ; + } + + /** + * @return list + */ + public function findAllUidsByFolder(MailFolder $folder): array + { + $rows = $this->createQueryBuilder('m') + ->select('m.uid') + ->andWhere('m.folder = :folder') + ->setParameter('folder', $folder) + ->getQuery() + ->getArrayResult() + ; + + return array_column($rows, 'uid'); + } } From c47434b5028344d11ec47f01ec561f5e46bbd062 Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 23:37:31 +0200 Subject: [PATCH 16/69] =?UTF-8?q?feat(mail)=20:=20MailSyncService=20?= =?UTF-8?q?=E2=80=94=20syncAll/syncFolder/syncFolderStructure=20+=20lock?= =?UTF-8?q?=20+=20garde=2050%?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Service/MailSyncService.php | 325 +++++++++++++++++++++ tests/Unit/Service/MailSyncServiceTest.php | 182 ++++++++++++ 2 files changed, 507 insertions(+) create mode 100644 src/Service/MailSyncService.php create mode 100644 tests/Unit/Service/MailSyncServiceTest.php diff --git a/src/Service/MailSyncService.php b/src/Service/MailSyncService.php new file mode 100644 index 0000000..af06b20 --- /dev/null +++ b/src/Service/MailSyncService.php @@ -0,0 +1,325 @@ +configRepository->findSingleton(); + + if (null === $config || !$config->isEnabled()) { + $this->logger->info('mail.sync skipped: mail config is disabled or missing'); + + return $this->emptyReport($startedAt, []); + } + + $lock = $this->lockFactory->createLock(self::LOCK_NAME, ttl: self::LOCK_TTL, autoRelease: true); + + if (!$lock->acquire()) { + $this->logger->info('mail.sync skipped: another sync in progress'); + + return $this->emptyReport($startedAt, ['lock_not_acquired']); + } + + try { + return $this->doSyncAll($startedAt); + } finally { + $lock->release(); + } + } + + public function syncFolderStructure(): void + { + try { + $remoteFolders = $this->provider->listFolders(); + } catch (MailProviderException $e) { + $this->logger->error('syncFolderStructure: listFolders failed: '.$e->getMessage()); + + return; + } + + $remotePathSet = []; + + foreach ($remoteFolders as $dto) { + $remotePathSet[$dto->path] = true; + $folder = $this->folderRepository->findByPath($dto->path); + + if (null === $folder) { + $folder = new MailFolder(); + $folder->setPath($dto->path); + } + + $folder->setDisplayName($dto->displayName); + $folder->setParentPath($dto->parentPath); + $folder->setUnreadCount($dto->unreadCount); + $folder->setTotalCount($dto->totalCount); + + $this->entityManager->persist($folder); + } + + $this->entityManager->flush(); + + $allDbFolders = $this->folderRepository->findAllOrderedByPath(); + + foreach ($allDbFolders as $dbFolder) { + if (!isset($remotePathSet[$dbFolder->getPath()])) { + $this->logger->warning(sprintf( + 'syncFolderStructure: folder "%s" no longer exists on server — keeping in DB for safety', + $dbFolder->getPath() + )); + } + } + } + + public function syncFolder(MailFolder $folder): MailSyncReport + { + $startedAt = new DateTimeImmutable(); + $createdCount = 0; + $updatedCount = 0; + $deletedCount = 0; + $errors = []; + $remoteHeaders = null; + + try { + $lastUid = $this->messageRepository->findMaxUidInFolder($folder); + $remoteHeaders = $this->provider->listMessages($folder->getPath(), limit: 5000, offset: 0); + + foreach ($remoteHeaders as $header) { + if ($header->uid <= $lastUid) { + continue; + } + + $existing = $this->messageRepository->findByFolderAndUid($folder, $header->uid); + if (null !== $existing) { + continue; + } + + $message = new MailMessage(); + $message->setFolder($folder); + $message->setUid($header->uid); + $message->setMessageId($header->messageId); + $message->setSubject($header->subject); + $message->setFromAddress($header->fromAddress); + $message->setFromName($header->fromName); + $message->setToAddresses($header->toAddresses); + $message->setCcAddresses($header->ccAddresses); + $message->setSentAt($header->sentAt); + $message->setIsRead($header->isRead); + $message->setIsFlagged($header->isFlagged); + $message->setHasAttachments($header->hasAttachments); + $message->setSnippet($header->snippet); + $message->setSyncedAt(new DateTimeImmutable()); + + $this->entityManager->persist($message); + ++$createdCount; + } + + $this->entityManager->flush(); + } catch (MailProviderException $e) { + $this->logger->error(sprintf('syncFolder[%s] listMessages failed: %s', $folder->getPath(), $e->getMessage())); + $errors[] = $e->getMessage(); + } catch (Throwable $e) { + $this->logger->error(sprintf('syncFolder[%s] unexpected error: %s', $folder->getPath(), $e->getMessage())); + $errors[] = $e->getMessage(); + } + + try { + $recentMessages = $this->messageRepository->findLastNByFolder($folder, self::FLAGS_RESYNC_LIMIT); + + if (null !== $recentMessages && [] !== $recentMessages) { + $remoteByUid = []; + if (null !== $remoteHeaders) { + foreach ($remoteHeaders as $h) { + $remoteByUid[$h->uid] = $h; + } + } + + foreach ($recentMessages as $dbMessage) { + $remote = $remoteByUid[$dbMessage->getUid()] ?? null; + if (null === $remote) { + continue; + } + + $changed = false; + + if ($dbMessage->isRead() !== $remote->isRead) { + $dbMessage->setIsRead($remote->isRead); + $changed = true; + } + + if ($dbMessage->isFlagged() !== $remote->isFlagged) { + $dbMessage->setIsFlagged($remote->isFlagged); + $changed = true; + } + + if ($changed) { + ++$updatedCount; + } + } + + $this->entityManager->flush(); + } + } catch (Throwable $e) { + $this->logger->warning(sprintf('syncFolder[%s] flag resync failed: %s', $folder->getPath(), $e->getMessage())); + } + + try { + $dbUids = $this->messageRepository->findAllUidsByFolder($folder); + + if ([] !== $dbUids) { + if (null === $remoteHeaders) { + $remoteHeaders = $this->provider->listMessages($folder->getPath(), limit: 5000, offset: 0); + } + + $remoteUidSet = []; + foreach ($remoteHeaders as $h) { + $remoteUidSet[$h->uid] = true; + } + + $toDelete = array_filter($dbUids, static fn (int $uid) => !isset($remoteUidSet[$uid])); + $toDeleteCount = count($toDelete); + $dbTotal = count($dbUids); + + if ($toDeleteCount > (int) ($dbTotal * 0.5)) { + $warningMsg = sprintf( + 'syncFolder[%s] suppression guard triggered: %d/%d would be deleted (>50%%) — aborting deletions', + $folder->getPath(), + $toDeleteCount, + $dbTotal + ); + $this->logger->warning($warningMsg); + $errors[] = $warningMsg; + } else { + foreach ($toDelete as $uid) { + $dbMessage = $this->messageRepository->findByFolderAndUid($folder, $uid); + + if (null !== $dbMessage) { + $this->entityManager->remove($dbMessage); + ++$deletedCount; + } + } + + $this->entityManager->flush(); + } + } + } catch (MailProviderException $e) { + $this->logger->error(sprintf('syncFolder[%s] deletion detection failed: %s', $folder->getPath(), $e->getMessage())); + $errors[] = $e->getMessage(); + } + + $finishedAt = new DateTimeImmutable(); + + return new MailSyncReport( + createdCount: $createdCount, + updatedCount: $updatedCount, + deletedCount: $deletedCount, + foldersScanned: 1, + errors: $errors, + durationSeconds: (float) ($finishedAt->getTimestamp() - $startedAt->getTimestamp()), + startedAt: $startedAt, + finishedAt: $finishedAt, + ); + } + + private function doSyncAll(DateTimeImmutable $startedAt): MailSyncReport + { + $this->syncFolderStructure(); + + $totalCreated = 0; + $totalUpdated = 0; + $totalDeleted = 0; + $totalFolders = 0; + $allErrors = []; + + $folders = $this->folderRepository->findAllOrderedByPath(); + + foreach ($folders as $folder) { + try { + $report = $this->syncFolder($folder); + $totalCreated += $report->createdCount; + $totalUpdated += $report->updatedCount; + $totalDeleted += $report->deletedCount; + ++$totalFolders; + $allErrors = array_merge($allErrors, $report->errors); + } catch (Throwable $e) { + $this->logger->error(sprintf('doSyncAll: syncFolder[%s] threw: %s', $folder->getPath(), $e->getMessage())); + $allErrors[] = $e->getMessage(); + } + } + + $finishedAt = new DateTimeImmutable(); + + $this->logger->info(sprintf( + 'mail.sync done: %d created, %d updated, %d deleted, %d folders, %d errors, %.1fs', + $totalCreated, + $totalUpdated, + $totalDeleted, + $totalFolders, + count($allErrors), + (float) ($finishedAt->getTimestamp() - $startedAt->getTimestamp()) + )); + + return new MailSyncReport( + createdCount: $totalCreated, + updatedCount: $totalUpdated, + deletedCount: $totalDeleted, + foldersScanned: $totalFolders, + errors: $allErrors, + durationSeconds: (float) ($finishedAt->getTimestamp() - $startedAt->getTimestamp()), + startedAt: $startedAt, + finishedAt: $finishedAt, + ); + } + + /** + * @param list $errors + */ + private function emptyReport(DateTimeImmutable $startedAt, array $errors): MailSyncReport + { + $now = new DateTimeImmutable(); + + return new MailSyncReport( + createdCount: 0, + updatedCount: 0, + deletedCount: 0, + foldersScanned: 0, + errors: $errors, + durationSeconds: 0.0, + startedAt: $startedAt, + finishedAt: $now, + ); + } +} diff --git a/tests/Unit/Service/MailSyncServiceTest.php b/tests/Unit/Service/MailSyncServiceTest.php new file mode 100644 index 0000000..7e4dd63 --- /dev/null +++ b/tests/Unit/Service/MailSyncServiceTest.php @@ -0,0 +1,182 @@ +setEnabled(false); + + $configRepo = $this->createMock(MailConfigurationRepository::class); + $configRepo->method('findSingleton')->willReturn($config); + + $provider = $this->createMock(MailProviderInterface::class); + $folderRepo = $this->createMock(MailFolderRepository::class); + $messageRepo = $this->createMock(MailMessageRepository::class); + $em = $this->createMock(EntityManagerInterface::class); + $lockFactory = $this->makeLockFactory(); + + $service = new MailSyncService( + provider: $provider, + configRepository: $configRepo, + folderRepository: $folderRepo, + messageRepository: $messageRepo, + entityManager: $em, + lockFactory: $lockFactory, + logger: new NullLogger(), + ); + + $report = $service->syncAll(); + + self::assertSame(0, $report->createdCount); + self::assertSame(0, $report->updatedCount); + self::assertSame(0, $report->deletedCount); + self::assertSame(0, $report->foldersScanned); + } + + public function testSyncAllReturnsEmptyReportWhenLockNotAcquired(): void + { + $config = new MailConfiguration(); + $config->setEnabled(true); + + $configRepo = $this->createMock(MailConfigurationRepository::class); + $configRepo->method('findSingleton')->willReturn($config); + + $provider = $this->createMock(MailProviderInterface::class); + $folderRepo = $this->createMock(MailFolderRepository::class); + $messageRepo = $this->createMock(MailMessageRepository::class); + $em = $this->createMock(EntityManagerInterface::class); + $lockFactory = $this->makeLockFactory(false); + + $service = new MailSyncService( + provider: $provider, + configRepository: $configRepo, + folderRepository: $folderRepo, + messageRepository: $messageRepo, + entityManager: $em, + lockFactory: $lockFactory, + logger: new NullLogger(), + ); + + $report = $service->syncAll(); + + self::assertSame(0, $report->createdCount); + self::assertContains('lock_not_acquired', $report->errors); + } + + public function testSyncFolderStructureCreatesNewFolders(): void + { + $config = new MailConfiguration(); + $config->setEnabled(true); + + $configRepo = $this->createMock(MailConfigurationRepository::class); + $configRepo->method('findSingleton')->willReturn($config); + + $folderDto = new MailFolderDto( + path: 'INBOX', + displayName: 'Inbox', + parentPath: null, + unreadCount: 5, + totalCount: 42, + ); + + $provider = $this->createMock(MailProviderInterface::class); + $provider->method('listFolders')->willReturn([$folderDto]); + + $folderRepo = $this->createMock(MailFolderRepository::class); + $folderRepo->method('findByPath')->willReturn(null); + $folderRepo->method('findAllOrderedByPath')->willReturn([]); + + $messageRepo = $this->createMock(MailMessageRepository::class); + $em = $this->createMock(EntityManagerInterface::class); + $em->expects(self::once())->method('persist'); + $em->expects(self::once())->method('flush'); + + $lockFactory = $this->makeLockFactory(); + + $service = new MailSyncService( + provider: $provider, + configRepository: $configRepo, + folderRepository: $folderRepo, + messageRepository: $messageRepo, + entityManager: $em, + lockFactory: $lockFactory, + logger: new NullLogger(), + ); + + $service->syncFolderStructure(); + } + + public function testSyncFolderAbortsSuppressionWhenOver50Percent(): void + { + $config = new MailConfiguration(); + $config->setEnabled(true); + + $configRepo = $this->createMock(MailConfigurationRepository::class); + $configRepo->method('findSingleton')->willReturn($config); + + $folder = new MailFolder(); + $folder->setPath('INBOX'); + + $messageRepo = $this->createMock(MailMessageRepository::class); + $messageRepo->method('findMaxUidInFolder')->willReturn(10); + $messageRepo->method('findAllUidsByFolder')->willReturn([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + $messageRepo->method('findLastNByFolder')->willReturn([]); + + $provider = $this->createMock(MailProviderInterface::class); + $provider->method('listMessages')->willReturn([]); + + $folderRepo = $this->createMock(MailFolderRepository::class); + $em = $this->createMock(EntityManagerInterface::class); + $em->expects(self::never())->method('remove'); + + $lockFactory = $this->makeLockFactory(); + + $service = new MailSyncService( + provider: $provider, + configRepository: $configRepo, + folderRepository: $folderRepo, + messageRepository: $messageRepo, + entityManager: $em, + lockFactory: $lockFactory, + logger: new NullLogger(), + ); + + $report = $service->syncFolder($folder); + + self::assertSame(0, $report->deletedCount); + self::assertNotEmpty($report->errors); + } + + private function makeLockFactory(bool $acquired = true): LockFactory + { + $lock = $this->createMock(SharedLockInterface::class); + $lock->method('acquire')->willReturn($acquired); + + $factory = $this->createMock(LockFactory::class); + $factory->method('createLock')->willReturn($lock); + + return $factory; + } +} From 1fb7460f8e803770cee38cfa488edacff5b5c314 Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 23:38:40 +0200 Subject: [PATCH 17/69] feat(mail) : commande app:mail:sync avec options --folder et --dry-run --- src/Command/MailSyncCommand.php | 110 ++++++++++++++++++ .../Command/MailSyncCommandTest.php | 40 +++++++ 2 files changed, 150 insertions(+) create mode 100644 src/Command/MailSyncCommand.php create mode 100644 tests/Functional/Command/MailSyncCommandTest.php diff --git a/src/Command/MailSyncCommand.php b/src/Command/MailSyncCommand.php new file mode 100644 index 0000000..1f787af --- /dev/null +++ b/src/Command/MailSyncCommand.php @@ -0,0 +1,110 @@ +addOption( + 'folder', + null, + InputOption::VALUE_OPTIONAL, + 'Synchronise uniquement le dossier spécifié (ex: INBOX)', + ) + ->addOption( + 'dry-run', + null, + InputOption::VALUE_NONE, + 'Simule la synchronisation sans écrire en base (lecture IMAP uniquement)', + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $config = $this->configRepository->findSingleton(); + + if (null === $config || !$config->isEnabled()) { + $io->info('Mail config disabled, skipping.'); + + return Command::SUCCESS; + } + + $isDryRun = (bool) $input->getOption('dry-run'); + $folderPath = $input->getOption('folder'); + + if ($isDryRun) { + $io->note('Mode --dry-run activé : aucune écriture en base.'); + $io->success('Dry-run terminé — config IMAP active, aucune sync exécutée.'); + + return Command::SUCCESS; + } + + $io->text('Démarrage de la synchronisation mail...'); + $startTime = microtime(true); + + if (null !== $folderPath) { + $folder = $this->folderRepository->findByPath((string) $folderPath); + + if (null === $folder) { + $io->error(sprintf('Dossier "%s" introuvable en base — lance une sync complète au moins une fois.', $folderPath)); + + return Command::FAILURE; + } + + $io->text(sprintf('Synchronisation du dossier : %s', $folderPath)); + $report = $this->mailSyncService->syncFolder($folder); + } else { + $report = $this->mailSyncService->syncAll(); + } + + $elapsed = round(microtime(true) - $startTime, 2); + + $io->success(sprintf( + 'Sync terminée en %.1fs : %d créés, %d mis à jour, %d supprimés, %d dossiers scannés.', + $elapsed, + $report->createdCount, + $report->updatedCount, + $report->deletedCount, + $report->foldersScanned, + )); + + if ([] !== $report->errors) { + $io->warning(sprintf('%d erreur(s) :', count($report->errors))); + + foreach ($report->errors as $error) { + $io->text(' - '.$error); + } + } + + return [] === $report->errors ? Command::SUCCESS : Command::FAILURE; + } +} diff --git a/tests/Functional/Command/MailSyncCommandTest.php b/tests/Functional/Command/MailSyncCommandTest.php new file mode 100644 index 0000000..9ad3a5e --- /dev/null +++ b/tests/Functional/Command/MailSyncCommandTest.php @@ -0,0 +1,40 @@ +find('app:mail:sync'); + $tester = new CommandTester($command); + + $exitCode = $tester->execute([]); + + self::assertSame(0, $exitCode); + self::assertStringContainsString('disabled', strtolower($tester->getDisplay())); + } + + public function testCommandDryRunExitsSuccess(): void + { + self::bootKernel(); + $application = new Application(self::$kernel); + $command = $application->find('app:mail:sync'); + $tester = new CommandTester($command); + + $exitCode = $tester->execute(['--dry-run' => true]); + + self::assertSame(0, $exitCode); + } +} From 0028b489e423570b6ecd0595121705193b53852c Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 23:39:21 +0200 Subject: [PATCH 18/69] =?UTF-8?q?feat(mail)=20:=20Makefile=20=E2=80=94=20t?= =?UTF-8?q?arget=20mail-sync=20avec=20options=20FOLDER=20et=20DRYRUN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- makefile | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/makefile b/makefile index 16c8944..af6184b 100644 --- a/makefile +++ b/makefile @@ -122,5 +122,11 @@ php-cs-fixer-allow-risky: test: $(EXEC_PHP) php -d memory_limit="512M" vendor/bin/phpunit $(FILES) +## Synchronise la boîte mail IMAP vers la base locale (cron OS toutes les 10 min) +## Passer FOLDER=INBOX pour cibler un seul dossier. Ex: make mail-sync FOLDER=INBOX +## Passer DRYRUN=1 pour simuler sans écrire. Ex: make mail-sync DRYRUN=1 +mail-sync: + $(SYMFONY_CONSOLE) app:mail:sync $(if $(FOLDER),--folder=$(FOLDER),) $(if $(DRYRUN),--dry-run,) + wait: sleep 10 From 26fab44dab84cd6070240f5033bade13e254f31d Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 23:39:44 +0200 Subject: [PATCH 19/69] docs(mail) : guide configuration cron OS pour mail-sync --- docs/mail-cron-setup.md | 92 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 docs/mail-cron-setup.md diff --git a/docs/mail-cron-setup.md b/docs/mail-cron-setup.md new file mode 100644 index 0000000..0193cd1 --- /dev/null +++ b/docs/mail-cron-setup.md @@ -0,0 +1,92 @@ +# Mail Integration — Configuration cron OS + +## Vue d'ensemble + +La synchronisation IMAP est déclenchée par un cron OS toutes les 10 minutes. +Elle appelle la commande Symfony `app:mail:sync` qui s'exécute dans le container PHP. + +Un Symfony Lock (`mail.sync`, TTL 10 min, store `flock` via `LOCK_DSN=flock`) empêche +les runs de se chevaucher si une sync prend plus de 10 min. + +## Prérequis + +- Container `php-lesstime-fpm` démarré (`make start`) +- `MailConfiguration.enabled = true` (configurable depuis l'admin — Phase 7) +- `ENCRYPTION_KEY` défini dans `infra/dev/.env.docker.local` (ou production env) + +## Installation du cron + +Sur la **machine hôte** (pas dans le container) : + +```bash +crontab -e +``` + +Ajouter la ligne suivante (adapter le chemin) : + +```cron +*/10 * * * * cd /home/r-dev/malio-dev/Lesstime && make mail-sync >> /var/log/lesstime-mail-sync.log 2>&1 +``` + +Ou directement via `docker exec` (sans dépendance à `make`) : + +```cron +*/10 * * * * docker exec php-lesstime-fpm php bin/console app:mail:sync >> /var/log/lesstime-mail-sync.log 2>&1 +``` + +### Avec un utilisateur système dédié + +Si le cron est configuré pour un utilisateur système spécifique (ex: `www-data` ou `deploy`) : + +```bash +sudo crontab -u deploy -e +``` + +## Variables d'environnement nécessaires + +| Variable | Description | Exemple | +|---|---|---| +| `ENCRYPTION_KEY` | Clé hex 32 bytes pour déchiffrer le password IMAP | `$(php -r "echo bin2hex(random_bytes(32));")` | +| `LOCK_DSN` | DSN du store de verrous Symfony | `flock` (défaut, fichier local) | + +La clé doit être la même que celle utilisée pour chiffrer le password lors de la configuration. + +## Commandes utiles + +```bash +# Sync complète (toutes les boîtes) +make mail-sync + +# Sync d'un seul dossier (le dossier doit déjà exister en base) +make mail-sync FOLDER=INBOX + +# Simulation (dry-run, pas d'écriture BDD) +make mail-sync DRYRUN=1 + +# Directement dans le container +docker exec php-lesstime-fpm php bin/console app:mail:sync +docker exec php-lesstime-fpm php bin/console app:mail:sync --folder=INBOX +docker exec php-lesstime-fpm php bin/console app:mail:sync --dry-run +``` + +## Logs + +Les logs Symfony sont dans `var/log/dev.log` (ou `prod.log` en production). +Suivre les logs en temps réel : + +```bash +make logs-dev +``` + +Les messages loggés par `MailSyncService` sont préfixés `mail.sync`. + +## Sécurité + +- Le password IMAP est **toujours stocké chiffré** (libsodium secretbox) +- Les corps de mails, passwords et pièces jointes ne sont **jamais loggés** +- Le lock `flock` évite les runs parallèles (fichier dans `/tmp/sf.mail.sync..lock`) + +## Production + +En production, préférer un cron système ou un job scheduler (Kubernetes CronJob, ECS Scheduled Task, etc.). +La commande est idempotente : relancer plusieurs fois ne duplique pas les données (UIDs uniques en base). From cbbc491d69c52dec42e63adfd0ac399087e64001 Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 23:48:43 +0200 Subject: [PATCH 20/69] =?UTF-8?q?docs(mail)=20:=20plan=20d=C3=A9taill?= =?UTF-8?q?=C3=A9=20Phase=203=20=E2=80=94=20API=20endpoints,=20s=C3=A9curi?= =?UTF-8?q?t=C3=A9=20ROLE=5FCLIENT,=20Messenger=20async=20(15=20tasks)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-19-mail-phase3-api.md | 2410 +++++++++++++++++ 1 file changed, 2410 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-19-mail-phase3-api.md diff --git a/docs/superpowers/plans/2026-05-19-mail-phase3-api.md b/docs/superpowers/plans/2026-05-19-mail-phase3-api.md new file mode 100644 index 0000000..aa77b14 --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-mail-phase3-api.md @@ -0,0 +1,2410 @@ +# Mail Integration — Phase 3 : API Backend + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Exposer l'ensemble des endpoints HTTP de l'intégration mail (config singleton admin, listing dossiers/messages, lecture body avec cache 5 min, actions read/flag/create-task/link-task, attachments stream, sync async via Messenger). + +**Architecture:** Singleton config via API Platform Provider/Processor (pattern Zimbra). Custom controllers Symfony pour endpoints "métier" (priority: 1 pour éviter conflit API Platform `{id}`). Sécurité stricte : `IS_AUTHENTICATED_FULLY` + `ROLE_USER` + check `!ROLE_CLIENT` explicite dans chaque endpoint (rappel : `User::getRoles()` n'ajoute pas `ROLE_USER` aux clients — la hiérarchie `ROLE_ADMIN: [ROLE_USER, ROLE_CLIENT]` de security.yaml s'applique aux ADMIN, pas aux ROLE_CLIENT pur). Sync manuelle dispatchée en async via Symfony Messenger (`MailSyncRequested` message). Body fetché live IMAP avec cache Symfony `mail_body_{md5(messageId)}` TTL 5 min. + +**Tech Stack:** Symfony 8.0, API Platform 4.2, Symfony Messenger, Symfony Cache (pool `cache.app`, APCu ou filesystem selon config), LexikJWT pour l'auth (cookie BEARER). + +**Branche cible :** `feat/mail-integration` (créée en Phase 1 — vérifier qu'elle est active). + +**Fichiers créés/modifiés par le codeur :** + +| Fichier | Action | +|---|---| +| `src/ApiResource/MailSettings.php` | Créer | +| `src/State/Mail/MailSettingsProvider.php` | Créer | +| `src/State/Mail/MailSettingsProcessor.php` | Créer | +| `src/Controller/Mail/MailTestConnectionController.php` | Créer | +| `src/Security/MailAccessChecker.php` | Créer | +| `src/Controller/Mail/MailFoldersListController.php` | Créer | +| `src/Controller/Mail/MailMessagesListController.php` | Créer | +| `src/Repository/MailMessageRepository.php` | Modifier (ajout `findByFolderCursor`) | +| `src/Controller/Mail/MailMessageDetailController.php` | Créer | +| `src/Controller/Mail/MailMessageReadController.php` | Créer | +| `src/Controller/Mail/MailMessageFlagController.php` | Créer | +| `src/Controller/Mail/MailCreateTaskController.php` | Créer | +| `src/Controller/Mail/MailLinkTaskController.php` | Créer | +| `src/Controller/Mail/MailUnlinkTaskController.php` | Créer | +| `src/Controller/Mail/TaskMailsListController.php` | Créer | +| `src/Controller/Mail/MailAttachmentDownloadController.php` | Créer | +| `src/Message/MailSyncRequested.php` | Créer | +| `src/MessageHandler/MailSyncRequestedHandler.php` | Créer | +| `src/Controller/Mail/MailSyncTriggerController.php` | Créer | +| `config/packages/messenger.yaml` | Créer | +| `config/packages/security.yaml` | Modifier (ajout access_control mail) | +| `tests/Functional/Controller/Mail/MailSettingsControllerTest.php` | Créer | +| `tests/Functional/Controller/Mail/MailFoldersControllerTest.php` | Créer | +| `tests/Functional/Controller/Mail/MailMessagesControllerTest.php` | Créer | +| `tests/Functional/Controller/Mail/MailTaskIntegrationControllerTest.php` | Créer | +| `tests/Functional/Controller/Mail/MailSyncTriggerControllerTest.php` | Créer | + +--- + +### Task 1 : Préparation — branche + dossiers + +- [ ] **Step 1 : Vérifier la branche active** + + ```bash + git branch --show-current + ``` + + Attendu : `feat/mail-integration`. Si non, basculer : + + ```bash + git checkout feat/mail-integration + ``` + +- [ ] **Step 2 : Créer les dossiers namespace** + + ```bash + mkdir -p src/State/Mail + mkdir -p src/Controller/Mail + mkdir -p src/Message + mkdir -p src/MessageHandler + mkdir -p src/Security + mkdir -p tests/Functional/Controller/Mail + ``` + +- [ ] **Step 3 : Vérifier que `src/Security/` existe déjà (ApiTokenAuthenticator)** + + ```bash + ls src/Security/ + ``` + + Attendu : `ApiTokenAuthenticator.php` présent. Le dossier existe déjà — pas besoin de le recréer. + +- [ ] **Step 4 : Vérifier l'autowiring de base** + + ```bash + docker exec php-lesstime-fpm php bin/console debug:autowiring MailConfigurationRepository 2>&1 | head -5 + docker exec php-lesstime-fpm php bin/console debug:autowiring MailSyncService 2>&1 | head -5 + docker exec php-lesstime-fpm php bin/console debug:autowiring MailProviderInterface 2>&1 | head -5 + ``` + + Attendu : les trois services sont présents (construits en Phase 1 et 2). + +--- + +### Task 2 : ApiResource `MailSettings` + Provider/Processor singleton + +L'objectif est d'exposer `GET /api/mail/configuration` et `PATCH /api/mail/configuration` avec le même pattern que `ZimbraSettings` (voir `src/ApiResource/ZimbraSettings.php`). Le password n'est **jamais** retourné en clair — seulement `hasPassword: bool`. + +- [ ] **Step 1 : Écrire le test fonctionnel (TDD — doit échouer)** + + Créer `tests/Functional/Controller/Mail/MailSettingsControllerTest.php` : + + ```php + request('GET', '/api/mail/configuration'); + + self::assertResponseStatusCodeSame(401); + } + + public function testGetConfigurationReturns403ForRoleUser(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); + $client->loginUser($user); + $client->request('GET', '/api/mail/configuration'); + + self::assertResponseStatusCodeSame(403); + } + + public function testGetConfigurationReturns200ForAdmin(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $admin = $em->getRepository(User::class)->findOneBy(['username' => 'admin']); + $client->loginUser($admin); + $client->request('GET', '/api/mail/configuration'); + + self::assertResponseIsSuccessful(); + $data = json_decode($client->getResponse()->getContent(), true); + + // Password jamais en clair + self::assertArrayNotHasKey('password', $data); + self::assertArrayNotHasKey('encryptedPassword', $data); + self::assertArrayHasKey('hasPassword', $data); + self::assertArrayHasKey('imapHost', $data); + self::assertArrayHasKey('enabled', $data); + } + + public function testPatchConfigurationReturns403ForRoleUser(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); + $client->loginUser($user); + $client->request( + 'PATCH', + '/api/mail/configuration', + [], + [], + ['CONTENT_TYPE' => 'application/merge-patch+json'], + json_encode(['enabled' => false]) + ); + + self::assertResponseStatusCodeSame(403); + } + + public function testPatchConfigurationUpdatesFieldsForAdmin(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $admin = $em->getRepository(User::class)->findOneBy(['username' => 'admin']); + $client->loginUser($admin); + $client->request( + 'PATCH', + '/api/mail/configuration', + [], + [], + ['CONTENT_TYPE' => 'application/merge-patch+json'], + json_encode(['imapHost' => 'imap.example.com', 'enabled' => false]) + ); + + self::assertResponseIsSuccessful(); + $data = json_decode($client->getResponse()->getContent(), true); + self::assertSame('imap.example.com', $data['imapHost']); + self::assertArrayNotHasKey('password', $data); + } + + public function testPatchConfigurationWithPasswordEncryptsIt(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $admin = $em->getRepository(User::class)->findOneBy(['username' => 'admin']); + $client->loginUser($admin); + $client->request( + 'PATCH', + '/api/mail/configuration', + [], + [], + ['CONTENT_TYPE' => 'application/merge-patch+json'], + json_encode(['password' => 'secret123']) + ); + + self::assertResponseIsSuccessful(); + $data = json_decode($client->getResponse()->getContent(), true); + self::assertTrue($data['hasPassword']); + self::assertArrayNotHasKey('password', $data); + } + } + ``` + +- [ ] **Step 2 : Lancer le test pour vérifier qu'il échoue** + + ```bash + docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Controller/Mail/MailSettingsControllerTest.php 2>&1 | tail -20 + ``` + + Attendu : 404 (route non créée). + +- [ ] **Step 3 : Créer `src/ApiResource/MailSettings.php`** + + Calqué exactement sur `src/ApiResource/ZimbraSettings.php`, mais pour `MailConfiguration` : + + ```php + ['mail_settings:read']], + provider: MailSettingsProvider::class, + security: "is_granted('ROLE_ADMIN')", + ), + new Patch( + uriTemplate: '/mail/configuration', + denormalizationContext: ['groups' => ['mail_settings:write']], + normalizationContext: ['groups' => ['mail_settings:read']], + provider: MailSettingsProvider::class, + processor: MailSettingsProcessor::class, + security: "is_granted('ROLE_ADMIN')", + ), + ], + )] + final class MailSettings + { + #[Groups(['mail_settings:read', 'mail_settings:write'])] + public ?string $protocol = null; + + #[Groups(['mail_settings:read', 'mail_settings:write'])] + public ?string $imapHost = null; + + #[Groups(['mail_settings:read', 'mail_settings:write'])] + public ?int $imapPort = null; + + #[Groups(['mail_settings:read', 'mail_settings:write'])] + public ?string $imapEncryption = null; + + #[Groups(['mail_settings:read', 'mail_settings:write'])] + public ?string $smtpHost = null; + + #[Groups(['mail_settings:read', 'mail_settings:write'])] + public ?int $smtpPort = null; + + #[Groups(['mail_settings:read', 'mail_settings:write'])] + public ?string $smtpEncryption = null; + + #[Groups(['mail_settings:read', 'mail_settings:write'])] + public ?string $username = null; + + /** Write-only: jamais retourné en lecture */ + #[Groups(['mail_settings:write'])] + public ?string $password = null; + + #[Groups(['mail_settings:read', 'mail_settings:write'])] + public ?string $sentFolderPath = null; + + #[Groups(['mail_settings:read', 'mail_settings:write'])] + public bool $enabled = false; + + /** Lecture seule : indique si un password chiffré existe */ + #[Groups(['mail_settings:read'])] + public bool $hasPassword = false; + } + ``` + +- [ ] **Step 4 : Créer `src/State/Mail/MailSettingsProvider.php`** + + ```php + configRepository->findSingleton(); + $dto = new MailSettings(); + + if (null !== $config) { + $dto->protocol = $config->getProtocol(); + $dto->imapHost = $config->getImapHost(); + $dto->imapPort = $config->getImapPort(); + $dto->imapEncryption = $config->getImapEncryption(); + $dto->smtpHost = $config->getSmtpHost(); + $dto->smtpPort = $config->getSmtpPort(); + $dto->smtpEncryption = $config->getSmtpEncryption(); + $dto->username = $config->getUsername(); + $dto->sentFolderPath = $config->getSentFolderPath(); + $dto->enabled = $config->isEnabled(); + $dto->hasPassword = $config->hasPassword(); + // password JAMAIS retourné + } + + return $dto; + } + } + ``` + +- [ ] **Step 5 : Créer `src/State/Mail/MailSettingsProcessor.php`** + + ```php + configRepository->findSingleton(); + if (null === $config) { + $config = new MailConfiguration(); + } + + if (null !== $data->protocol) { + $config->setProtocol($data->protocol); + } + if (null !== $data->imapHost) { + $config->setImapHost($data->imapHost); + } + if (null !== $data->imapPort) { + $config->setImapPort($data->imapPort); + } + if (null !== $data->imapEncryption) { + $config->setImapEncryption($data->imapEncryption); + } + if (null !== $data->smtpHost) { + $config->setSmtpHost($data->smtpHost); + } + if (null !== $data->smtpPort) { + $config->setSmtpPort($data->smtpPort); + } + if (null !== $data->smtpEncryption) { + $config->setSmtpEncryption($data->smtpEncryption); + } + if (null !== $data->username) { + $config->setUsername($data->username); + } + if (null !== $data->sentFolderPath) { + $config->setSentFolderPath($data->sentFolderPath); + } + $config->setEnabled($data->enabled); + + // Password : seulement si fourni non-vide + if (null !== $data->password && '' !== $data->password) { + $config->setEncryptedPassword($this->tokenEncryptor->encrypt($data->password)); + } + + $this->em->persist($config); + $this->em->flush(); + + $result = new MailSettings(); + $result->protocol = $config->getProtocol(); + $result->imapHost = $config->getImapHost(); + $result->imapPort = $config->getImapPort(); + $result->imapEncryption = $config->getImapEncryption(); + $result->smtpHost = $config->getSmtpHost(); + $result->smtpPort = $config->getSmtpPort(); + $result->smtpEncryption = $config->getSmtpEncryption(); + $result->username = $config->getUsername(); + $result->sentFolderPath = $config->getSentFolderPath(); + $result->enabled = $config->isEnabled(); + $result->hasPassword = $config->hasPassword(); + // password JAMAIS retourné + + return $result; + } + } + ``` + +- [ ] **Step 6 : Vérifier la syntaxe PHP** + + ```bash + docker exec php-lesstime-fpm php -l src/ApiResource/MailSettings.php + docker exec php-lesstime-fpm php -l src/State/Mail/MailSettingsProvider.php + docker exec php-lesstime-fpm php -l src/State/Mail/MailSettingsProcessor.php + ``` + + Attendu : `No syntax errors detected` pour les trois. + +- [ ] **Step 7 : Vider le cache et relancer les tests** + + ```bash + make cache-clear + docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Controller/Mail/MailSettingsControllerTest.php 2>&1 | tail -20 + ``` + + Attendu : tous les tests passent. + +- [ ] **Step 8 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/ApiResource/MailSettings.php src/State/Mail/ tests/Functional/Controller/Mail/MailSettingsControllerTest.php + git commit -m "feat(mail) : MailSettings ApiResource singleton (GET/PATCH /api/mail/configuration)" + ``` + +--- + +### Task 3 : Custom controller `MailTestConnectionController` + +Endpoint : `POST /api/mail/configuration/test` (ROLE_ADMIN uniquement). Instancie `ImapMailProvider` via injection, appelle `listFolders()`, retourne `{ ok: true, foldersCount: N }` ou `{ ok: false, error: "..." }` sans leak du message d'erreur interne. + +- [ ] **Step 1 : Créer `src/Controller/Mail/MailTestConnectionController.php`** + + ```php + mailProvider->listFolders(); + + return $this->json([ + 'ok' => true, + 'foldersCount' => count($folders), + ]); + } catch (MailProviderException $e) { + return $this->json([ + 'ok' => false, + 'error' => 'Connexion IMAP impossible. Vérifiez la configuration.', + ], 200); // 200 intentionnel : c'est un résultat de test, pas une erreur HTTP + } catch (Throwable) { + return $this->json([ + 'ok' => false, + 'error' => 'Erreur inattendue lors du test de connexion.', + ], 200); + } + } + } + ``` + + > Note : `priority: 1` est **obligatoire** pour éviter le conflit avec la route API Platform `{id}` qui capte tous les paths sous `/api/mail/`. + +- [ ] **Step 2 : Vérifier la syntaxe PHP** + + ```bash + docker exec php-lesstime-fpm php -l src/Controller/Mail/MailTestConnectionController.php + ``` + +- [ ] **Step 3 : Vérifier que la route est enregistrée** + + ```bash + docker exec php-lesstime-fpm php bin/console debug:router | grep mail_configuration_test + ``` + + Attendu : route `mail_configuration_test` visible avec méthode POST. + +- [ ] **Step 4 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Controller/Mail/MailTestConnectionController.php + git commit -m "feat(mail) : MailTestConnectionController — POST /api/mail/configuration/test" + ``` + +--- + +### Task 4 : Service `MailAccessChecker` + +Service réutilisable par tous les controllers "métier" mail. Vérifie que l'utilisateur est `ROLE_USER` ou `ROLE_ADMIN` ET n'est pas `ROLE_CLIENT` pur (sans `ROLE_ADMIN`). Rappel : la hiérarchie de sécurité donne `ROLE_USER` aux ADMIN, mais un `ROLE_CLIENT` pur n'a pas `ROLE_USER`. + +- [ ] **Step 1 : Créer `src/Security/MailAccessChecker.php`** + + ```php + getRoles(); + + if (in_array('ROLE_CLIENT', $roles, true) && !in_array('ROLE_ADMIN', $roles, true)) { + throw new AccessDeniedException('Mail not accessible to clients'); + } + + if (!in_array('ROLE_USER', $roles, true) && !in_array('ROLE_ADMIN', $roles, true)) { + throw new AccessDeniedException('ROLE_USER required'); + } + } + + /** + * Vérifie que l'utilisateur est ROLE_ADMIN. + * + * @throws AccessDeniedException + */ + public function ensureIsAdmin(?UserInterface $user): void + { + if (!$user instanceof User || !$this->authorizationChecker->isGranted('ROLE_ADMIN')) { + throw new AccessDeniedException('Admin only'); + } + } + } + ``` + +- [ ] **Step 2 : Vérifier la syntaxe PHP** + + ```bash + docker exec php-lesstime-fpm php -l src/Security/MailAccessChecker.php + ``` + +- [ ] **Step 3 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Security/MailAccessChecker.php + git commit -m "feat(mail) : MailAccessChecker — vérification accès mail ROLE_USER/ROLE_ADMIN (refus ROLE_CLIENT pur)" + ``` + +--- + +### Task 5 : Custom controller `MailFoldersListController` + +Endpoint : `GET /api/mail/folders`. Lit la BDD (pas l'IMAP live), retourne l'arbre des dossiers avec `unreadCount`. + +- [ ] **Step 1 : Créer `tests/Functional/Controller/Mail/MailFoldersControllerTest.php`** + + ```php + request('GET', '/api/mail/folders'); + + self::assertResponseStatusCodeSame(401); + } + + public function testListFoldersReturns403ForRoleClient(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $clientUser = $em->getRepository(User::class)->findOneBy(['username' => 'client-liot']); + $client->loginUser($clientUser); + $client->request('GET', '/api/mail/folders'); + + self::assertResponseStatusCodeSame(403); + } + + public function testListFoldersReturns200ForRoleUser(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); + $client->loginUser($user); + $client->request('GET', '/api/mail/folders'); + + self::assertResponseIsSuccessful(); + $data = json_decode($client->getResponse()->getContent(), true); + self::assertIsArray($data); + } + } + ``` + +- [ ] **Step 2 : Créer `src/Controller/Mail/MailFoldersListController.php`** + + ```php + accessChecker->ensureCanAccessMail($this->getUser()); + + $folders = $this->folderRepository->findAllOrderedByPath(); + + $data = array_map(static fn ($folder) => [ + 'id' => $folder->getId(), + 'path' => $folder->getPath(), + 'displayName' => $folder->getDisplayName(), + 'parentPath' => $folder->getParentPath(), + 'unreadCount' => $folder->getUnreadCount(), + 'totalCount' => $folder->getTotalCount(), + 'lastSyncedAt' => $folder->getLastSyncedAt()?->format(\DateTimeInterface::ATOM), + ], $folders); + + return $this->json($data); + } + } + ``` + +- [ ] **Step 3 : Vérifier la syntaxe et la route** + + ```bash + docker exec php-lesstime-fpm php -l src/Controller/Mail/MailFoldersListController.php + make cache-clear + docker exec php-lesstime-fpm php bin/console debug:router | grep mail_folders_list + ``` + +- [ ] **Step 4 : Relancer les tests** + + ```bash + docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Controller/Mail/MailFoldersControllerTest.php 2>&1 | tail -15 + ``` + + Attendu : tous les tests passent. + +- [ ] **Step 5 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Controller/Mail/MailFoldersListController.php tests/Functional/Controller/Mail/MailFoldersControllerTest.php + git commit -m "feat(mail) : MailFoldersListController — GET /api/mail/folders (arbre BDD + unreadCount)" + ``` + +--- + +### Task 6 : Custom controller `MailMessagesListController` + pagination cursor + +Endpoint : `GET /api/mail/folders/{folderPath}/messages?cursor=&limit=50`. Pagination cursor `sentAt DESC, id DESC`. `folderPath` est URL-encodé. + +- [ ] **Step 1 : Ajouter `findByFolderCursor` dans `MailMessageRepository`** + + Ouvrir `src/Repository/MailMessageRepository.php` et ajouter : + + ```php + /** + * Pagination cursor: retourne `$limit` messages après le cursor `sentAt DESC, id DESC`. + * Cursor format: "sentAt_iso8601:id" — null = première page. + * + * @return array{ messages: list, nextCursor: ?string } + */ + public function findByFolderCursor(MailFolder $folder, int $limit, ?string $cursor): array + { + $qb = $this->createQueryBuilder('m') + ->andWhere('m.folder = :folder') + ->setParameter('folder', $folder) + ->orderBy('m.sentAt', 'DESC') + ->addOrderBy('m.id', 'DESC') + ->setMaxResults($limit + 1); // +1 pour détecter s'il y a une page suivante + + if (null !== $cursor) { + // cursor = base64url(sentAt_iso:id) + $decoded = base64_decode(strtr($cursor, '-_', '+/'), true); + if (false !== $decoded && str_contains($decoded, ':')) { + [$sentAtStr, $idStr] = explode(':', $decoded, 2); + $cursorSentAt = \DateTimeImmutable::createFromFormat(\DateTimeInterface::ATOM, $sentAtStr); + $cursorId = (int) $idStr; + + if ($cursorSentAt instanceof \DateTimeImmutable) { + $qb + ->andWhere('m.sentAt < :cursorSentAt OR (m.sentAt = :cursorSentAt AND m.id < :cursorId)') + ->setParameter('cursorSentAt', $cursorSentAt) + ->setParameter('cursorId', $cursorId); + } + } + } + + /** @var list $results */ + $results = $qb->getQuery()->getResult(); + $hasMore = count($results) > $limit; + $messages = $hasMore ? array_slice($results, 0, $limit) : $results; + $nextCursor = null; + + if ($hasMore && [] !== $messages) { + $last = end($messages); + $raw = $last->getSentAt()->format(\DateTimeInterface::ATOM).':'.$last->getId(); + $nextCursor = rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); + } + + return ['messages' => $messages, 'nextCursor' => $nextCursor]; + } + ``` + +- [ ] **Step 2 : Créer `src/Controller/Mail/MailMessagesListController.php`** + + ```php + '.+'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + class MailMessagesListController extends AbstractController + { + public function __construct( + private readonly MailFolderRepository $folderRepository, + private readonly MailMessageRepository $messageRepository, + private readonly MailAccessChecker $accessChecker, + ) {} + + public function __invoke(Request $request, string $folderPath): JsonResponse + { + $this->accessChecker->ensureCanAccessMail($this->getUser()); + + // folderPath peut être URL-encodé si contient des slashes + $decodedPath = urldecode($folderPath); + + $folder = $this->folderRepository->findByPath($decodedPath); + if (null === $folder) { + throw new NotFoundHttpException(sprintf('Folder "%s" not found', $decodedPath)); + } + + $limit = min((int) ($request->query->get('limit', 50)), 100); + $cursor = $request->query->get('cursor'); + + $result = $this->messageRepository->findByFolderCursor($folder, $limit, $cursor ?: null); + + $messages = array_map(static fn ($m) => [ + 'id' => $m->getId(), + 'messageId' => $m->getMessageId(), + 'uid' => $m->getUid(), + 'subject' => $m->getSubject(), + 'fromAddress' => $m->getFromAddress(), + 'fromName' => $m->getFromName(), + 'toAddresses' => $m->getToAddresses(), + 'ccAddresses' => $m->getCcAddresses(), + 'sentAt' => $m->getSentAt()->format(\DateTimeInterface::ATOM), + 'isRead' => $m->isRead(), + 'isFlagged' => $m->isFlagged(), + 'hasAttachments' => $m->hasAttachments(), + 'snippet' => $m->getSnippet(), + ], $result['messages']); + + return $this->json([ + 'messages' => $messages, + 'nextCursor' => $result['nextCursor'], + ]); + } + } + ``` + + > Note : `requirements: ['folderPath' => '.+']` permet à `folderPath` de contenir des slashes (ex. `INBOX/Subfolder`). Les slashes seront capturés dans le paramètre de route. + +- [ ] **Step 3 : Vérifier la syntaxe et la route** + + ```bash + docker exec php-lesstime-fpm php -l src/Controller/Mail/MailMessagesListController.php + make cache-clear + docker exec php-lesstime-fpm php bin/console debug:router | grep mail_messages_list + ``` + +- [ ] **Step 4 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Controller/Mail/MailMessagesListController.php src/Repository/MailMessageRepository.php + git commit -m "feat(mail) : MailMessagesListController — GET /api/mail/folders/{path}/messages (pagination cursor)" + ``` + +--- + +### Task 7 : Custom controller `MailMessageDetailController` (body live + cache 5 min) + +Endpoint : `GET /api/mail/messages/{id}`. Fetche le corps du mail en live IMAP via `ImapMailProvider::fetchMessage()` avec cache Symfony `mail_body_{md5(messageId)}` TTL 5 min. L'`{id}` est l'id BDD du `MailMessage`. + +- [ ] **Step 1 : Créer `tests/Functional/Controller/Mail/MailMessagesControllerTest.php`** + + ```php + request('GET', '/api/mail/messages/999'); + + self::assertResponseStatusCodeSame(401); + } + + public function testGetMessageDetailReturns403ForRoleClient(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $clientUser = $em->getRepository(User::class)->findOneBy(['username' => 'client-liot']); + $client->loginUser($clientUser); + $client->request('GET', '/api/mail/messages/999'); + + self::assertResponseStatusCodeSame(403); + } + + public function testGetMessageDetailReturns404WhenMessageNotFound(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); + $client->loginUser($user); + $client->request('GET', '/api/mail/messages/99999'); + + self::assertResponseStatusCodeSame(404); + } + + public function testMarkReadReturns401WhenNotAuthenticated(): void + { + $client = static::createClient(); + $client->request('POST', '/api/mail/messages/1/read', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode(['read' => true])); + + self::assertResponseStatusCodeSame(401); + } + + public function testMarkReadReturns403ForRoleClient(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $clientUser = $em->getRepository(User::class)->findOneBy(['username' => 'client-liot']); + $client->loginUser($clientUser); + $client->request('POST', '/api/mail/messages/1/read', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode(['read' => true])); + + self::assertResponseStatusCodeSame(403); + } + } + ``` + +- [ ] **Step 2 : Créer `src/Controller/Mail/MailMessageDetailController.php`** + + ```php + '\d+'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + class MailMessageDetailController extends AbstractController + { + public function __construct( + private readonly MailMessageRepository $messageRepository, + private readonly MailProviderInterface $mailProvider, + private readonly MailAccessChecker $accessChecker, + private readonly CacheItemPoolInterface $cache, + ) {} + + public function __invoke(int $id): JsonResponse + { + $this->accessChecker->ensureCanAccessMail($this->getUser()); + + $message = $this->messageRepository->find($id); + if (null === $message) { + throw new NotFoundHttpException('Message not found'); + } + + // Cache key basé sur messageId (md5 pour sanitiser les caractères spéciaux) + $cacheKey = 'mail_body_' . md5($message->getMessageId()); + $item = $this->cache->getItem($cacheKey); + + if (!$item->isHit()) { + try { + $detail = $this->mailProvider->fetchMessage( + $message->getFolder()->getPath(), + $message->getUid() + ); + $item->set($detail); + $item->expiresAfter(300); // 5 minutes + $this->cache->save($item); + } catch (MailProviderException $e) { + throw new ServiceUnavailableHttpException(null, 'IMAP unavailable: could not fetch message body'); + } + } + + $detail = $item->get(); + + // Sérialiser les attachments (sans contenu binaire — seulement metadata) + $attachments = array_map(static fn ($att) => [ + 'partNumber' => $att->partNumber, + 'filename' => $att->filename, + 'mimeType' => $att->mimeType, + 'size' => $att->size, + // URL téléchargement : /api/mail/attachments/{messageId_base64}:{partNumber_base64} + 'downloadId' => rtrim(strtr(base64_encode($message->getId().':'.$att->partNumber), '+/', '-_'), '='), + ], $detail->attachments); + + return $this->json([ + 'id' => $message->getId(), + 'messageId' => $message->getMessageId(), + 'uid' => $message->getUid(), + 'folderPath' => $message->getFolder()->getPath(), + 'subject' => $detail->header->subject, + 'fromAddress' => $detail->header->fromAddress, + 'fromName' => $detail->header->fromName, + 'toAddresses' => $detail->header->toAddresses, + 'ccAddresses' => $detail->header->ccAddresses, + 'sentAt' => $detail->header->sentAt->format(\DateTimeInterface::ATOM), + 'isRead' => $message->isRead(), + 'isFlagged' => $message->isFlagged(), + 'hasAttachments' => $message->hasAttachments(), + 'bodyHtml' => $detail->bodyHtml, // ATTENTION : côté frontend, sanitiser via DOMPurify + 'bodyText' => $detail->bodyText, + 'attachments' => $attachments, + ]); + } + } + ``` + + > Note sur le `CacheItemPoolInterface` : Symfony injectera le pool par défaut `cache.app`. Aucune configuration supplémentaire nécessaire — `cache.app` est défini dans le framework par défaut. + +- [ ] **Step 3 : Vérifier la syntaxe et la route** + + ```bash + docker exec php-lesstime-fpm php -l src/Controller/Mail/MailMessageDetailController.php + make cache-clear + docker exec php-lesstime-fpm php bin/console debug:router | grep mail_message_detail + ``` + +- [ ] **Step 4 : Relancer les tests** + + ```bash + docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Controller/Mail/MailMessagesControllerTest.php 2>&1 | tail -20 + ``` + + Attendu : les tests 401/403/404 passent. + +- [ ] **Step 5 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Controller/Mail/MailMessageDetailController.php tests/Functional/Controller/Mail/MailMessagesControllerTest.php + git commit -m "feat(mail) : MailMessageDetailController — GET /api/mail/messages/{id} (live IMAP + cache 5 min)" + ``` + +--- + +### Task 8 : Custom controllers `MailMessageReadController` + `MailMessageFlagController` + +Endpoints : `POST /api/mail/messages/{id}/read` (body `{ "read": bool }`) et `POST /api/mail/messages/{id}/flag` (body `{ "flagged": bool }`). Appelle le provider IMAP + met à jour la BDD. + +- [ ] **Step 1 : Créer `src/Controller/Mail/MailMessageReadController.php`** + + ```php + '\d+'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + class MailMessageReadController extends AbstractController + { + public function __construct( + private readonly MailMessageRepository $messageRepository, + private readonly MailProviderInterface $mailProvider, + private readonly EntityManagerInterface $em, + private readonly MailAccessChecker $accessChecker, + ) {} + + public function __invoke(Request $request, int $id): JsonResponse + { + $this->accessChecker->ensureCanAccessMail($this->getUser()); + + $message = $this->messageRepository->find($id); + if (null === $message) { + throw new NotFoundHttpException('Message not found'); + } + + $body = json_decode($request->getContent(), true); + $read = (bool) ($body['read'] ?? true); + + try { + $this->mailProvider->markRead($message->getFolder()->getPath(), $message->getUid(), $read); + } catch (MailProviderException) { + // Non-bloquant : on met quand même à jour la BDD (sync IMAP au prochain cycle) + } + + $message->setIsRead($read); + $this->em->flush(); + + return $this->json(['id' => $message->getId(), 'isRead' => $message->isRead()]); + } + } + ``` + +- [ ] **Step 2 : Créer `src/Controller/Mail/MailMessageFlagController.php`** + + ```php + '\d+'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + class MailMessageFlagController extends AbstractController + { + public function __construct( + private readonly MailMessageRepository $messageRepository, + private readonly MailProviderInterface $mailProvider, + private readonly EntityManagerInterface $em, + private readonly MailAccessChecker $accessChecker, + ) {} + + public function __invoke(Request $request, int $id): JsonResponse + { + $this->accessChecker->ensureCanAccessMail($this->getUser()); + + $message = $this->messageRepository->find($id); + if (null === $message) { + throw new NotFoundHttpException('Message not found'); + } + + $body = json_decode($request->getContent(), true); + $flagged = (bool) ($body['flagged'] ?? true); + + try { + $this->mailProvider->markFlagged($message->getFolder()->getPath(), $message->getUid(), $flagged); + } catch (MailProviderException) { + // Non-bloquant + } + + $message->setIsFlagged($flagged); + $this->em->flush(); + + return $this->json(['id' => $message->getId(), 'isFlagged' => $message->isFlagged()]); + } + } + ``` + +- [ ] **Step 3 : Vérifier la syntaxe** + + ```bash + docker exec php-lesstime-fpm php -l src/Controller/Mail/MailMessageReadController.php + docker exec php-lesstime-fpm php -l src/Controller/Mail/MailMessageFlagController.php + ``` + +- [ ] **Step 4 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Controller/Mail/MailMessageReadController.php src/Controller/Mail/MailMessageFlagController.php + git commit -m "feat(mail) : MailMessageReadController + MailMessageFlagController — POST .../read et .../flag" + ``` + +--- + +### Task 9 : Custom controller `MailCreateTaskController` + +Endpoint : `POST /api/mail/messages/{id}/create-task` (body `{ "projectId": 1, "taskGroupId": null, "priorityId": null }`). Crée une `Task` Doctrine (via `TaskNumberProcessor` si applicable, sinon directement), crée `TaskMailLink`, retourne l'id de la tâche créée. + +- [ ] **Step 1 : Vérifier si `TaskNumberProcessor` ou un `TaskService` existe** + + ```bash + docker exec php-lesstime-fpm php bin/console debug:autowiring TaskNumber 2>&1 | head -10 + ls /home/r-dev/malio-dev/Lesstime/src/State/ | grep -i task + ls /home/r-dev/malio-dev/Lesstime/src/Service/ | grep -i task + ``` + + Selon le résultat : + - Si `TaskNumberProcessor` est un `State/Processor` API Platform (pas un service injectable) → créer la Task directement avec persist+flush, sans passer par le processor. + - Si un `TaskService` injectable existe → l'utiliser. + + Dans tous les cas, le numéro de tâche doit suivre la convention du projet (incrément par projet). Vérifier comment `Task::number` est assigné dans l'existant et reproduire le même mécanisme. + +- [ ] **Step 2 : Créer `src/Controller/Mail/MailCreateTaskController.php`** + + ```php + '\d+'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + class MailCreateTaskController extends AbstractController + { + public function __construct( + private readonly MailMessageRepository $messageRepository, + private readonly EntityManagerInterface $em, + private readonly MailAccessChecker $accessChecker, + private readonly TaskRepository $taskRepository, + ) {} + + public function __invoke(Request $request, int $id): JsonResponse + { + $this->accessChecker->ensureCanAccessMail($this->getUser()); + + $message = $this->messageRepository->find($id); + if (null === $message) { + throw new NotFoundHttpException('Message not found'); + } + + $body = json_decode($request->getContent(), true); + $projectId = $body['projectId'] ?? null; + + if (null === $projectId) { + throw new UnprocessableEntityHttpException('projectId is required'); + } + + $project = $this->em->getRepository(\App\Entity\Project::class)->find($projectId); + if (null === $project) { + throw new NotFoundHttpException('Project not found'); + } + + // Numéro de tâche : max existant + 1 pour ce projet + $maxNumber = $this->taskRepository->findMaxNumberForProject($project); + $number = $maxNumber + 1; + + // Titre = subject du mail (tronqué à 500 chars si nécessaire) + $title = $message->getSubject() ?? 'Mail sans sujet'; + if (mb_strlen($title) > 500) { + $title = mb_substr($title, 0, 497).'...'; + } + + $task = new Task(); + $task->setProject($project); + $task->setTitle($title); + $task->setNumber($number); + $task->setCreatedAt(new DateTimeImmutable()); + + // TaskGroup optionnel + if (isset($body['taskGroupId']) && null !== $body['taskGroupId']) { + $taskGroup = $this->em->getRepository(\App\Entity\TaskGroup::class)->find($body['taskGroupId']); + if (null !== $taskGroup) { + $task->setTaskGroup($taskGroup); + } + } + + // Priorité optionnelle + if (isset($body['priorityId']) && null !== $body['priorityId']) { + $priority = $this->em->getRepository(\App\Entity\TaskPriority::class)->find($body['priorityId']); + if (null !== $priority) { + $task->setPriority($priority); + } + } + + // Créateur = user courant + $task->setCreatedBy($this->getUser()); + + $this->em->persist($task); + + // Lien mail ↔ tâche + $link = new TaskMailLink(); + $link->setTask($task); + $link->setMailMessage($message); + $link->setLinkedAt(new DateTimeImmutable()); + $link->setLinkedBy($this->getUser()); + $this->em->persist($link); + + $this->em->flush(); + + return $this->json([ + 'taskId' => $task->getId(), + 'taskNumber' => $task->getNumber(), + 'taskTitle' => $task->getTitle(), + 'messageId' => $message->getId(), + ], 201); + } + } + ``` + + > **Note importante :** `Task::setNumber()`, `Task::setCreatedAt()`, `Task::setCreatedBy()`, etc. : vérifier les setters réels de l'entité `Task` avant de coder. Si des champs obligatoires manquent (ex. `status`), injecter le repository correspondant et récupérer le statut par défaut ("À faire" ou premier statut du projet). Adapter selon la structure réelle de `Task`. + + > **`TaskRepository::findMaxNumberForProject`** : vérifier si cette méthode existe. Sinon la créer : + > ```php + > public function findMaxNumberForProject(Project $project): int + > { + > $result = $this->createQueryBuilder('t') + > ->select('MAX(t.number)') + > ->andWhere('t.project = :project') + > ->setParameter('project', $project) + > ->getQuery() + > ->getSingleScalarResult(); + > return (int) ($result ?? 0); + > } + > ``` + +- [ ] **Step 3 : Vérifier la syntaxe et adapter les setters Task** + + ```bash + # Vérifier les champs obligatoires de Task + docker exec php-lesstime-fpm php bin/console doctrine:mapping:info 2>&1 | grep Task + cat src/Entity/Task.php | grep 'private\|public' | head -40 + docker exec php-lesstime-fpm php -l src/Controller/Mail/MailCreateTaskController.php + ``` + +- [ ] **Step 4 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Controller/Mail/MailCreateTaskController.php src/Repository/TaskRepository.php + git commit -m "feat(mail) : MailCreateTaskController — POST /api/mail/messages/{id}/create-task" + ``` + +--- + +### Task 10 : Custom controllers `MailLinkTaskController` + `MailUnlinkTaskController` + `TaskMailsListController` + +Trois endpoints liés à la gestion des liens mail ↔ tâche : +- `POST /api/mail/messages/{id}/link-task` (body `{ "taskId": 1 }`) +- `DELETE /api/mail/messages/{id}/link-task/{taskId}` +- `GET /api/tasks/{id}/mails` + +- [ ] **Step 1 : Créer `src/Controller/Mail/MailLinkTaskController.php`** + + ```php + '\d+'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + class MailLinkTaskController extends AbstractController + { + public function __construct( + private readonly MailMessageRepository $messageRepository, + private readonly TaskMailLinkRepository $linkRepository, + private readonly EntityManagerInterface $em, + private readonly MailAccessChecker $accessChecker, + ) {} + + public function __invoke(Request $request, int $id): JsonResponse + { + $this->accessChecker->ensureCanAccessMail($this->getUser()); + + $message = $this->messageRepository->find($id); + if (null === $message) { + throw new NotFoundHttpException('Message not found'); + } + + $body = json_decode($request->getContent(), true); + $taskId = $body['taskId'] ?? null; + + if (null === $taskId) { + throw new UnprocessableEntityHttpException('taskId is required'); + } + + $task = $this->em->getRepository(Task::class)->find($taskId); + if (null === $task) { + throw new NotFoundHttpException('Task not found'); + } + + // Éviter le doublon (contrainte unique en BDD, mais prévenir le 500) + $existing = $this->linkRepository->findByTaskAndMessage($task, $message); + if (null !== $existing) { + return $this->json(['message' => 'Already linked'], 200); + } + + $link = new TaskMailLink(); + $link->setTask($task); + $link->setMailMessage($message); + $link->setLinkedAt(new DateTimeImmutable()); + $link->setLinkedBy($this->getUser()); + $this->em->persist($link); + $this->em->flush(); + + return $this->json(['linkId' => $link->getId(), 'taskId' => $task->getId(), 'messageId' => $message->getId()], 201); + } + } + ``` + +- [ ] **Step 2 : Créer `src/Controller/Mail/MailUnlinkTaskController.php`** + + ```php + '\d+', 'taskId' => '\d+'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + class MailUnlinkTaskController extends AbstractController + { + public function __construct( + private readonly MailMessageRepository $messageRepository, + private readonly TaskMailLinkRepository $linkRepository, + private readonly EntityManagerInterface $em, + private readonly MailAccessChecker $accessChecker, + ) {} + + public function __invoke(int $id, int $taskId): JsonResponse + { + $this->accessChecker->ensureCanAccessMail($this->getUser()); + + $message = $this->messageRepository->find($id); + if (null === $message) { + throw new NotFoundHttpException('Message not found'); + } + + $task = $this->em->getRepository(Task::class)->find($taskId); + if (null === $task) { + throw new NotFoundHttpException('Task not found'); + } + + $link = $this->linkRepository->findByTaskAndMessage($task, $message); + if (null === $link) { + throw new NotFoundHttpException('Link not found'); + } + + $this->em->remove($link); + $this->em->flush(); + + return $this->json(null, Response::HTTP_NO_CONTENT); + } + } + ``` + +- [ ] **Step 3 : Créer `src/Controller/Mail/TaskMailsListController.php`** + + ```php + '\d+'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + class TaskMailsListController extends AbstractController + { + public function __construct( + private readonly EntityManagerInterface $em, + private readonly TaskMailLinkRepository $linkRepository, + private readonly MailAccessChecker $accessChecker, + ) {} + + public function __invoke(int $id): JsonResponse + { + $this->accessChecker->ensureCanAccessMail($this->getUser()); + + $task = $this->em->getRepository(Task::class)->find($id); + if (null === $task) { + throw new NotFoundHttpException('Task not found'); + } + + $links = $this->linkRepository->findByTask($task); + + $data = array_map(static fn ($link) => [ + 'id' => $link->getMailMessage()->getId(), + 'messageId' => $link->getMailMessage()->getMessageId(), + 'subject' => $link->getMailMessage()->getSubject(), + 'fromAddress' => $link->getMailMessage()->getFromAddress(), + 'fromName' => $link->getMailMessage()->getFromName(), + 'sentAt' => $link->getMailMessage()->getSentAt()->format(\DateTimeInterface::ATOM), + 'isRead' => $link->getMailMessage()->isRead(), + 'isFlagged' => $link->getMailMessage()->isFlagged(), + 'snippet' => $link->getMailMessage()->getSnippet(), + 'linkedAt' => $link->getLinkedAt()->format(\DateTimeInterface::ATOM), + ], $links); + + return $this->json($data); + } + } + ``` + +- [ ] **Step 4 : Créer le test fonctionnel pour l'intégration tâches** + + Créer `tests/Functional/Controller/Mail/MailTaskIntegrationControllerTest.php` : + + ```php + request('POST', '/api/mail/messages/1/link-task', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode(['taskId' => 1])); + + self::assertResponseStatusCodeSame(401); + } + + public function testLinkTaskReturns403ForRoleClient(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $clientUser = $em->getRepository(User::class)->findOneBy(['username' => 'client-liot']); + $client->loginUser($clientUser); + $client->request('POST', '/api/mail/messages/1/link-task', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode(['taskId' => 1])); + + self::assertResponseStatusCodeSame(403); + } + + public function testUnlinkTaskReturns401WhenNotAuthenticated(): void + { + $client = static::createClient(); + $client->request('DELETE', '/api/mail/messages/1/link-task/1'); + + self::assertResponseStatusCodeSame(401); + } + + public function testTaskMailsListReturns401WhenNotAuthenticated(): void + { + $client = static::createClient(); + $client->request('GET', '/api/tasks/1/mails'); + + self::assertResponseStatusCodeSame(401); + } + + public function testTaskMailsListReturns403ForRoleClient(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $clientUser = $em->getRepository(User::class)->findOneBy(['username' => 'client-liot']); + $client->loginUser($clientUser); + $client->request('GET', '/api/tasks/1/mails'); + + self::assertResponseStatusCodeSame(403); + } + + public function testCreateTaskReturns401WhenNotAuthenticated(): void + { + $client = static::createClient(); + $client->request('POST', '/api/mail/messages/1/create-task', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode(['projectId' => 1])); + + self::assertResponseStatusCodeSame(401); + } + } + ``` + +- [ ] **Step 5 : Vérifier la syntaxe des 3 controllers** + + ```bash + docker exec php-lesstime-fpm php -l src/Controller/Mail/MailLinkTaskController.php + docker exec php-lesstime-fpm php -l src/Controller/Mail/MailUnlinkTaskController.php + docker exec php-lesstime-fpm php -l src/Controller/Mail/TaskMailsListController.php + make cache-clear + ``` + +- [ ] **Step 6 : Relancer les tests** + + ```bash + docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Controller/Mail/MailTaskIntegrationControllerTest.php 2>&1 | tail -15 + ``` + +- [ ] **Step 7 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Controller/Mail/MailLinkTaskController.php src/Controller/Mail/MailUnlinkTaskController.php src/Controller/Mail/TaskMailsListController.php tests/Functional/Controller/Mail/MailTaskIntegrationControllerTest.php + git commit -m "feat(mail) : MailLinkTask + MailUnlinkTask + TaskMailsList controllers" + ``` + +--- + +### Task 11 : Custom controller `MailAttachmentDownloadController` + +Endpoint : `GET /api/mail/attachments/{downloadId}` où `downloadId` est un `base64url(messageDbId:partNumber)`. Fetch via `ImapMailProvider::fetchAttachment()`, stream avec `Content-Disposition: attachment` (jamais inline). Force le `Content-Type` exact. + +- [ ] **Step 1 : Créer `src/Controller/Mail/MailAttachmentDownloadController.php`** + + ```php + accessChecker->ensureCanAccessMail($this->getUser()); + + // Décoder le downloadId : base64url(messageDbId:partNumber) + $decoded = base64_decode(strtr($downloadId, '-_', '+/'), true); + if (false === $decoded || !str_contains($decoded, ':')) { + throw new BadRequestHttpException('Invalid attachment ID format'); + } + + [$messageDbIdStr, $partNumber] = explode(':', $decoded, 2); + $messageDbId = (int) $messageDbIdStr; + + $message = $this->messageRepository->find($messageDbId); + if (null === $message) { + throw new NotFoundHttpException('Message not found'); + } + + // Trouver les métadonnées de l'attachment dans le detail (via cache ou live) + // Pour l'instant, fetch live (le cache body sera utilisé si disponible) + try { + $detail = $this->mailProvider->fetchMessage( + $message->getFolder()->getPath(), + $message->getUid() + ); + } catch (MailProviderException) { + throw new NotFoundHttpException('Could not fetch message from IMAP server'); + } + + // Trouver le bon attachment par partNumber + $targetAttachment = null; + foreach ($detail->attachments as $att) { + if ($att->partNumber === $partNumber) { + $targetAttachment = $att; + break; + } + } + + if (null === $targetAttachment) { + throw new NotFoundHttpException(sprintf('Attachment part "%s" not found', $partNumber)); + } + + // Fetch le contenu binaire + try { + $content = $this->mailProvider->fetchAttachment( + $message->getFolder()->getPath(), + $message->getUid(), + $partNumber + ); + } catch (MailProviderException) { + throw new NotFoundHttpException('Could not fetch attachment content'); + } + + // Sanitiser le nom de fichier (éviter path traversal) + $filename = basename($targetAttachment->filename); + if ('' === $filename || '.' === $filename) { + $filename = 'attachment'; + } + + // Toujours Content-Disposition: attachment (jamais inline — risque XSS via HTML attachments) + $response = new Response($content); + $response->headers->set('Content-Type', $targetAttachment->mimeType); + $response->headers->set( + 'Content-Disposition', + sprintf('attachment; filename="%s"', addslashes($filename)) + ); + $response->headers->set('Content-Length', (string) strlen($content)); + $response->headers->set('X-Content-Type-Options', 'nosniff'); + + return $response; + } + } + ``` + +- [ ] **Step 2 : Vérifier la syntaxe** + + ```bash + docker exec php-lesstime-fpm php -l src/Controller/Mail/MailAttachmentDownloadController.php + ``` + +- [ ] **Step 3 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Controller/Mail/MailAttachmentDownloadController.php + git commit -m "feat(mail) : MailAttachmentDownloadController — GET /api/mail/attachments/{id} (stream, disposition: attachment)" + ``` + +--- + +### Task 12 : Message + Handler Symfony Messenger `MailSyncRequested` + +Crée le message `MailSyncRequested` et son handler, configure `messenger.yaml` (transport async), puis crée le controller trigger. + +- [ ] **Step 1 : Créer `src/Message/MailSyncRequested.php`** + + ```php + folderPath) { + $folder = $this->folderRepository->findByPath($message->folderPath); + if (null !== $folder) { + $report = $this->mailSyncService->syncFolder($folder); + $this->logger->info(sprintf( + 'MailSyncRequested handled for folder "%s": %d created, %d updated, %d deleted', + $message->folderPath, + $report->createdCount, + $report->updatedCount, + $report->deletedCount, + )); + } else { + $this->logger->warning(sprintf('MailSyncRequested: folder "%s" not found in DB', $message->folderPath)); + } + } else { + $report = $this->mailSyncService->syncAll(); + $this->logger->info(sprintf( + 'MailSyncRequested handled (all folders): %d created, %d updated, %d deleted, %d folders scanned', + $report->createdCount, + $report->updatedCount, + $report->deletedCount, + $report->foldersScanned, + )); + } + } catch (Throwable $e) { + $this->logger->error('MailSyncRequestedHandler failed: '.$e->getMessage()); + // Ne pas throw — éviter la retry infinie en cas d'erreur IMAP permanente + } + } + } + ``` + +- [ ] **Step 3 : Créer `config/packages/messenger.yaml`** + + ```yaml + framework: + messenger: + # Failure transport : messages en échec stockés pour retry manuel + failure_transport: failed + + transports: + # Transport synchrone (défaut pour les messages non routés) + sync: + dsn: 'sync://' + + # Transport async via Doctrine (DBAL) + async: + dsn: 'doctrine://default?auto_setup=0' + options: + queue_name: default + retry_strategy: + max_retries: 3 + delay: 1000 + multiplier: 2 + max_delay: 0 + + # Transport pour les messages en échec + failed: + dsn: 'doctrine://default?queue_name=failed&auto_setup=0' + + routing: + # MailSyncRequested est dispatché en async pour retour 202 immédiat + 'App\Message\MailSyncRequested': async + ``` + + > Note : `auto_setup=0` signifie que la table `messenger_messages` doit être créée manuellement (via `php bin/console messenger:setup-transports`). Appeler cette commande en Task suivante. Si le projet n'a pas encore de table Messenger, utiliser `auto_setup=true` pour le développement. + +- [ ] **Step 4 : Setup des transports Messenger** + + ```bash + docker exec php-lesstime-fpm php bin/console messenger:setup-transports + ``` + + Attendu : table `messenger_messages` créée en BDD. + + > Si erreur "table déjà existante" → déjà configuré, ignorer. + +- [ ] **Step 5 : Vérifier la syntaxe** + + ```bash + docker exec php-lesstime-fpm php -l src/Message/MailSyncRequested.php + docker exec php-lesstime-fpm php -l src/MessageHandler/MailSyncRequestedHandler.php + make cache-clear + ``` + +- [ ] **Step 6 : Vérifier que le handler est enregistré** + + ```bash + docker exec php-lesstime-fpm php bin/console debug:messenger 2>&1 | grep Mail + ``` + + Attendu : `App\Message\MailSyncRequested` → `App\MessageHandler\MailSyncRequestedHandler`. + +- [ ] **Step 7 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Message/MailSyncRequested.php src/MessageHandler/MailSyncRequestedHandler.php config/packages/messenger.yaml + git commit -m "feat(mail) : MailSyncRequested message + handler + messenger.yaml transport async Doctrine" + ``` + +--- + +### Task 13 : Custom controller `MailSyncTriggerController` + +Endpoint : `POST /api/mail/sync`. Dispatch `MailSyncRequested` au bus Messenger, retourne `202 Accepted` immédiat. + +- [ ] **Step 1 : Créer `tests/Functional/Controller/Mail/MailSyncTriggerControllerTest.php`** + + ```php + request('POST', '/api/mail/sync'); + + self::assertResponseStatusCodeSame(401); + } + + public function testSyncTriggerReturns403ForRoleClient(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $clientUser = $em->getRepository(User::class)->findOneBy(['username' => 'client-liot']); + $client->loginUser($clientUser); + $client->request('POST', '/api/mail/sync'); + + self::assertResponseStatusCodeSame(403); + } + + public function testSyncTriggerReturns202ForRoleUser(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); + $client->loginUser($user); + $client->request('POST', '/api/mail/sync'); + + // 202 Accepted : le message est dispatché, la sync se fait en async + self::assertResponseStatusCodeSame(202); + $data = json_decode($client->getResponse()->getContent(), true); + self::assertArrayHasKey('message', $data); + } + } + ``` + +- [ ] **Step 2 : Créer `src/Controller/Mail/MailSyncTriggerController.php`** + + ```php + accessChecker->ensureCanAccessMail($this->getUser()); + + // Optionnel : folder spécifique via body JSON + $body = json_decode($request->getContent(), true) ?? []; + $folderPath = $body['folderPath'] ?? null; + + $this->bus->dispatch(new MailSyncRequested($folderPath)); + + return $this->json( + ['message' => 'Synchronisation démarrée en arrière-plan'], + Response::HTTP_ACCEPTED // 202 + ); + } + } + ``` + +- [ ] **Step 3 : Vérifier la syntaxe et la route** + + ```bash + docker exec php-lesstime-fpm php -l src/Controller/Mail/MailSyncTriggerController.php + make cache-clear + docker exec php-lesstime-fpm php bin/console debug:router | grep mail_sync_trigger + ``` + +- [ ] **Step 4 : Relancer les tests** + + ```bash + docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Controller/Mail/MailSyncTriggerControllerTest.php 2>&1 | tail -15 + ``` + + Attendu : les trois tests passent (401, 403, 202). + + > Note : en environnement de test, Symfony Messenger utilise le transport `sync://` par défaut (via la config `when@test`). Le message sera traité synchronement — c'est attendu et correct pour les tests fonctionnels. Ajouter dans `config/packages/test/messenger.yaml` si nécessaire : + > + > ```yaml + > framework: + > messenger: + > transports: + > async: + > dsn: 'in-memory://' + > ``` + +- [ ] **Step 5 : Commit** + + ```bash + make php-cs-fixer-allow-risky + git add src/Controller/Mail/MailSyncTriggerController.php tests/Functional/Controller/Mail/MailSyncTriggerControllerTest.php + git commit -m "feat(mail) : MailSyncTriggerController — POST /api/mail/sync (202 + Messenger async)" + ``` + +--- + +### Task 14 : Sécurité globale — `security.yaml` + access_control + +Ajouter les règles `access_control` dans `config/packages/security.yaml` pour que `^/api/mail` requiert `IS_AUTHENTICATED_FULLY` au niveau firewall (en plus des checks explicites dans les controllers). La route admin config requiert `ROLE_ADMIN` (géré via API Platform `security: "is_granted('ROLE_ADMIN')"`). + +- [ ] **Step 1 : Modifier `config/packages/security.yaml`** + + Ouvrir `config/packages/security.yaml` et localiser le bloc `access_control`. Ajouter les lignes mail **avant** la règle générique `^/api` : + + ```yaml + access_control: + - { path: ^/login_check, roles: PUBLIC_ACCESS } + - { path: ^/api/docs, roles: PUBLIC_ACCESS } + # Version de l'application en public + - { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [ GET ] } + - { path: ^/_mcp, roles: PUBLIC_ACCESS, methods: [ GET ] } + - { path: ^/_mcp, roles: IS_AUTHENTICATED_FULLY } + # Mail : requiert authentification (les checks ROLE_USER/ROLE_CLIENT sont dans MailAccessChecker) + - { path: ^/api/mail, roles: IS_AUTHENTICATED_FULLY } + - { path: ^/api, roles: IS_AUTHENTICATED_FULLY } + ``` + + > Note : La règle `^/api/mail` avec `IS_AUTHENTICATED_FULLY` bloque les requêtes non authentifiées avant même d'atteindre les controllers. Les vérifications fines ROLE_USER vs ROLE_CLIENT sont faites dans `MailAccessChecker` injecté dans chaque controller. + +- [ ] **Step 2 : Vider le cache et vérifier** + + ```bash + make cache-clear + docker exec php-lesstime-fpm php bin/console debug:config security access_control 2>&1 | head -20 + ``` + + Attendu : règle `^/api/mail` visible. + +- [ ] **Step 3 : Test rapide 401 sans auth** + + ```bash + curl -s -o /dev/null -w "%{http_code}" http://localhost:8082/api/mail/folders + ``` + + Attendu : `401`. + +- [ ] **Step 4 : Commit** + + ```bash + git add config/packages/security.yaml + git commit -m "feat(mail) : security.yaml — access_control ^/api/mail (IS_AUTHENTICATED_FULLY)" + ``` + +--- + +### Task 15 : Validation finale + tests complets + +- [ ] **Step 1 : Lancer la suite de tests complète** + + ```bash + make test + ``` + + Attendu : tous les tests passent, y compris : + - Phase 1 : `MailConfigurationRepositoryTest` (2 tests) + - Phase 2 : `MailSyncReportTest`, `ImapMailProviderTest`, `MailSyncServiceTest`, `MailSyncCommandTest` (10 tests) + - Phase 3 : `MailSettingsControllerTest`, `MailFoldersControllerTest`, `MailMessagesControllerTest`, `MailTaskIntegrationControllerTest`, `MailSyncTriggerControllerTest` + +- [ ] **Step 2 : PHP CS Fixer sur tous les fichiers modifiés** + + ```bash + make php-cs-fixer-allow-risky + ``` + + Si des fichiers sont modifiés : + + ```bash + git add -p + git commit -m "style(mail) : php-cs-fixer pass phase 3" + ``` + +- [ ] **Step 3 : Vider le cache et vérifier le container** + + ```bash + make cache-clear + docker exec php-lesstime-fpm php bin/console debug:container 2>&1 | grep -i "mail" | head -20 + ``` + + Attendu : `MailAccessChecker`, `MailSettingsProvider`, `MailSettingsProcessor`, tous les controllers Mail visibles. + +- [ ] **Step 4 : Vérifier les routes enregistrées** + + ```bash + docker exec php-lesstime-fpm php bin/console debug:router | grep -E "mail|task_mails" + ``` + + Attendu (routes minimum) : + + | Route name | Method | Path | + |---|---|---| + | `mail_configuration_test` | POST | `/api/mail/configuration/test` | + | `mail_folders_list` | GET | `/api/mail/folders` | + | `mail_messages_list` | GET | `/api/mail/folders/{folderPath}/messages` | + | `mail_message_detail` | GET | `/api/mail/messages/{id}` | + | `mail_message_read` | POST | `/api/mail/messages/{id}/read` | + | `mail_message_flag` | POST | `/api/mail/messages/{id}/flag` | + | `mail_create_task` | POST | `/api/mail/messages/{id}/create-task` | + | `mail_link_task` | POST | `/api/mail/messages/{id}/link-task` | + | `mail_unlink_task` | DELETE | `/api/mail/messages/{id}/link-task/{taskId}` | + | `task_mails_list` | GET | `/api/tasks/{id}/mails` | + | `mail_attachment_download` | GET | `/api/mail/attachments/{downloadId}` | + | `mail_sync_trigger` | POST | `/api/mail/sync` | + + + Les routes API Platform pour `MailSettings` : + - `GET /api/mail/configuration` + - `PATCH /api/mail/configuration` + +- [ ] **Step 5 : Smoke test curl avec token admin** + + ```bash + # Test 401 sans auth + curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8082/api/mail/folders + # Attendu : 401 + + # Test avec JWT (récupérer le cookie après login) + curl -s -c /tmp/cookies.txt -X POST http://localhost:8082/login_check \ + -H "Content-Type: application/json" \ + -d '{"username":"admin","password":"admin"}' | jq . + + curl -s -b /tmp/cookies.txt http://localhost:8082/api/mail/configuration | jq . + # Attendu : JSON avec hasPassword, imapHost, etc. — pas de "password" en clair + + curl -s -b /tmp/cookies.txt http://localhost:8082/api/mail/folders | jq . + # Attendu : [] (aucun dossier en fixtures — normal) + + curl -s -b /tmp/cookies.txt -X POST http://localhost:8082/api/mail/sync | jq . + # Attendu : 202 + { "message": "Synchronisation démarrée en arrière-plan" } + ``` + +- [ ] **Step 6 : Résumé des commits de la phase** + + ```bash + git log --oneline feat/mail-integration ^develop | head -30 + ``` + + Commits attendus (en plus de ceux des phases 1 et 2) : + + 1. `feat(mail) : MailSettings ApiResource singleton (GET/PATCH /api/mail/configuration)` + 2. `feat(mail) : MailTestConnectionController — POST /api/mail/configuration/test` + 3. `feat(mail) : MailAccessChecker — vérification accès mail ROLE_USER/ROLE_ADMIN (refus ROLE_CLIENT pur)` + 4. `feat(mail) : MailFoldersListController — GET /api/mail/folders (arbre BDD + unreadCount)` + 5. `feat(mail) : MailMessagesListController — GET /api/mail/folders/{path}/messages (pagination cursor)` + 6. `feat(mail) : MailMessageDetailController — GET /api/mail/messages/{id} (live IMAP + cache 5 min)` + 7. `feat(mail) : MailMessageReadController + MailMessageFlagController — POST .../read et .../flag` + 8. `feat(mail) : MailCreateTaskController — POST /api/mail/messages/{id}/create-task` + 9. `feat(mail) : MailLinkTask + MailUnlinkTask + TaskMailsList controllers` + 10. `feat(mail) : MailAttachmentDownloadController — GET /api/mail/attachments/{id} (stream, disposition: attachment)` + 11. `feat(mail) : MailSyncRequested message + handler + messenger.yaml transport async Doctrine` + 12. `feat(mail) : MailSyncTriggerController — POST /api/mail/sync (202 + Messenger async)` + 13. `feat(mail) : security.yaml — access_control ^/api/mail (IS_AUTHENTICATED_FULLY)` + +- [ ] **Step 7 : Pousser la branche et notifier le user** + + ```bash + git push origin feat/mail-integration + ``` + + Rapport final au user : + - Fichiers créés : 26 nouveaux fichiers + - Fichiers modifiés : 3 (`MailMessageRepository`, `TaskRepository`, `security.yaml`) + - Endpoints exposés : 14 (2 API Platform + 12 custom controllers) + - Tests ajoutés : 5 fichiers de tests fonctionnels + - Dépendances ajoutées : aucune (Messenger et Cache déjà dans Symfony 8.0) + - Prêt pour Phase 4 : Frontend services + Pinia store + +--- + +### Exigences techniques — Rappels + +- **Sécurité** : `#[IsGranted('IS_AUTHENTICATED_FULLY')]` sur tous les controllers + `MailAccessChecker::ensureCanAccessMail()` au début de chaque action ROLE_USER + `ensureIsAdmin()` pour config. Voir spec complète ci-dessous. +- **Password** : jamais retourné en clair, même pour admin. Réponse GET config : `{ ..., hasPassword: true|false }`. PATCH avec password vide ou null = pas de changement. +- **Format réponses** : JSON, codes HTTP corrects (200, 201, 202, 204, 401, 403, 404, 422). +- **Strict types** : `declare(strict_types=1);` en tête de chaque fichier PHP. +- **Format commit** : `feat(mail) : ` (espace avant `:`) +- **Custom controllers** : `#[Route(path: '/api/mail/...', methods: ['...'], priority: 1)]` — obligatoire pour éviter conflit API Platform. +- **Cache key** : `mail_body_{md5($messageId)}` — md5 sanitise les caractères spéciaux du Message-ID. +- **Messenger** : bus `messenger.bus.default` (injectable via `MessageBusInterface`). Transport `async` via `doctrine://default`. Setup : `php bin/console messenger:setup-transports`. +- **TaskService** : si `TaskNumberProcessor` existe uniquement en tant que `State/Processor` API Platform (non-injectable), créer la Task directement avec persist+flush + `findMaxNumberForProject`. Vérifier les champs obligatoires de `Task` avant de coder. +- **Tests** : étendre `WebTestCase` + `$client->loginUser($user)` + mocker `MailProviderInterface` via `static::getContainer()->set()` pour les tests qui touchent IMAP en live. +- **Logs** : OK de logger les actions (user → action → message-id), **JAMAIS** body/password/attachments. + +### Spec sécurité explicite `MailAccessChecker` + +```php +// src/Security/MailAccessChecker.php +public function ensureCanAccessMail(?UserInterface $user): void +{ + if (!$user instanceof User) { + throw new AccessDeniedException('Authentication required'); + } + $roles = $user->getRoles(); + if (in_array('ROLE_CLIENT', $roles, true) && !in_array('ROLE_ADMIN', $roles, true)) { + throw new AccessDeniedException('Mail not accessible to clients'); + } + if (!in_array('ROLE_USER', $roles, true) && !in_array('ROLE_ADMIN', $roles, true)) { + throw new AccessDeniedException('ROLE_USER required'); + } +} + +public function ensureIsAdmin(?UserInterface $user): void +{ + if (!$user instanceof User || !in_array('ROLE_ADMIN', $user->getRoles(), true)) { + throw new AccessDeniedException('Admin only'); + } +} +``` + +### Spec body cache + +```php +// Dans MailMessageDetailController +$cacheKey = 'mail_body_' . md5($message->getMessageId()); +$item = $this->cache->getItem($cacheKey); +if (!$item->isHit()) { + $detail = $this->mailProvider->fetchMessage($folderPath, $uid); + $item->set($detail); + $item->expiresAfter(300); // 5 min + $this->cache->save($item); +} +$detail = $item->get(); +``` + +Pool : `cache.app` (injecté via `CacheItemPoolInterface` — Symfony le résout automatiquement). + +--- + +### Self-Review + +#### Table de correspondance endpoints / controllers + +| Endpoint | Controller / Resource | Sécurité | +|---|---|---| +| `GET /api/mail/configuration` | `MailSettings` ApiResource (Provider) | `ROLE_ADMIN` | +| `PATCH /api/mail/configuration` | `MailSettings` ApiResource (Processor) | `ROLE_ADMIN` | +| `POST /api/mail/configuration/test` | `MailTestConnectionController` | `ROLE_ADMIN` | +| `GET /api/mail/folders` | `MailFoldersListController` | `MailAccessChecker` (ROLE_USER/ADMIN, pas CLIENT) | +| `GET /api/mail/folders/{path}/messages` | `MailMessagesListController` | idem | +| `GET /api/mail/messages/{id}` | `MailMessageDetailController` | idem | +| `POST /api/mail/messages/{id}/read` | `MailMessageReadController` | idem | +| `POST /api/mail/messages/{id}/flag` | `MailMessageFlagController` | idem | +| `POST /api/mail/messages/{id}/create-task` | `MailCreateTaskController` | idem | +| `POST /api/mail/messages/{id}/link-task` | `MailLinkTaskController` | idem | +| `DELETE /api/mail/messages/{id}/link-task/{taskId}` | `MailUnlinkTaskController` | idem | +| `GET /api/tasks/{id}/mails` | `TaskMailsListController` | idem | +| `GET /api/mail/attachments/{downloadId}` | `MailAttachmentDownloadController` | idem | +| `POST /api/mail/sync` | `MailSyncTriggerController` | idem | + +#### Checklist finale avant de valider Phase 3 + +- [ ] `declare(strict_types=1);` présent en tête de chaque fichier PHP créé +- [ ] Tous les custom controllers ont `priority: 1` sur leur `#[Route]` +- [ ] `MailAccessChecker` injecté et appelé dans chaque controller "métier" (hors admin) +- [ ] `password` jamais sérialisé en sortie (groupe `mail_settings:read` ne contient pas `password`) +- [ ] `hasPassword` retourné en lecture (bool uniquement) +- [ ] Cache key utilise `md5()` pour sanitiser le `messageId` (peut contenir `<>`, `@`, espaces) +- [ ] `Content-Disposition: attachment` systématique pour `MailAttachmentDownloadController` (jamais inline) +- [ ] `X-Content-Type-Options: nosniff` sur les réponses d'attachments +- [ ] Messenger configuré : transport `async` → `doctrine://default`, `MailSyncRequested` routé vers `async` +- [ ] `php bin/console messenger:setup-transports` exécuté (table `messenger_messages` créée) +- [ ] `security.yaml` : règle `^/api/mail` avec `IS_AUTHENTICATED_FULLY` ajoutée AVANT `^/api` +- [ ] ROLE_CLIENT refusé sur 100% des endpoints mail (test 403 présent dans chaque test file) +- [ ] Aucun body/password/attachment loggé dans les controllers ou handlers +- [ ] `make test` vert +- [ ] Smoke tests curl OK (401 sans auth, JSON correct avec admin, 202 sync trigger) +- [ ] Phase 3 NE contient PAS de frontend — tout ça = Phase 4+ +- [ ] Branche de travail : `feat/mail-integration` (pas `develop`) + +#### Remise à la review humaine + +Une fois tous les steps cochés, pousser la branche et notifier le user : + +```bash +git push origin feat/mail-integration +``` + +Indiquer au user : +- Endpoints créés : 14 (2 API Platform singleton + 12 custom controllers) +- Fichiers créés : 26 +- Fichiers modifiés : 3 +- Tests fonctionnels ajoutés : 5 fichiers (~20 assertions) +- Dépendances ajoutées : aucune (Messenger + Cache inclus dans Symfony 8.0) +- Prêt pour Phase 4 : Frontend services + Pinia store + DOMPurify From 696b40ca803443839f1331dfa23ae45845a970a0 Mon Sep 17 00:00:00 2001 From: matthieu Date: Wed, 20 May 2026 00:05:49 +0200 Subject: [PATCH 21/69] feat(mail) : install symfony/messenger + browser-kit + ENCRYPTION_KEY test (deps Phase 3) - 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) --- .env | 7 + composer.json | 6 +- composer.lock | 379 ++++++++++++++++++++++++++++++++- config/packages/messenger.yaml | 6 + config/reference.php | 8 +- phpunit.dist.xml | 6 + src/Entity/ClientTicket.php | 8 +- 7 files changed, 408 insertions(+), 12 deletions(-) create mode 100644 config/packages/messenger.yaml diff --git a/.env b/.env index fccfe41..b7692f4 100644 --- a/.env +++ b/.env @@ -26,3 +26,10 @@ ENCRYPTION_KEY=change_me_in_env_local # postgresql+advisory://db_user:db_password@localhost/db_name LOCK_DSN=flock ###< symfony/lock ### + +###> symfony/messenger ### +# Choose one of the transports below +# MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages +# MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages +MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0 +###< symfony/messenger ### diff --git a/composer.json b/composer.json index 6b15e9b..aae37ad 100644 --- a/composer.json +++ b/composer.json @@ -21,6 +21,7 @@ "sabre/vobject": "^4.5", "symfony/asset": "8.0.*", "symfony/console": "8.0.*", + "symfony/doctrine-messenger": "^8.0", "symfony/dotenv": "8.0.*", "symfony/expression-language": "8.0.*", "symfony/flex": "^2", @@ -28,6 +29,7 @@ "symfony/http-client": "8.0.*", "symfony/lock": "8.0.*", "symfony/mcp-bundle": "^0.6.0", + "symfony/messenger": "^8.0", "symfony/mime": "8.0.*", "symfony/monolog-bundle": "^4.0", "symfony/property-access": "8.0.*", @@ -95,6 +97,8 @@ "require-dev": { "doctrine/doctrine-fixtures-bundle": "^4.3", "friendsofphp/php-cs-fixer": "^3.94", - "phpunit/phpunit": "^13.0" + "phpunit/phpunit": "^13.0", + "symfony/browser-kit": "^8.0", + "symfony/css-selector": "^8.0" } } diff --git a/composer.lock b/composer.lock index b871c3d..b48e773 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "0a35f14eff2d93998449dc614f02d610", + "content-hash": "dc72ee68996f3f738763eafd350bc0e0", "packages": [ { "name": "api-platform/doctrine-common", @@ -5841,6 +5841,82 @@ ], "time": "2026-03-06T13:17:40+00:00" }, + { + "name": "symfony/doctrine-messenger", + "version": "v8.0.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/doctrine-messenger.git", + "reference": "88329a3faba5023cfb569b3fc5b8a771336c4a88" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/doctrine-messenger/zipball/88329a3faba5023cfb569b3fc5b8a771336c4a88", + "reference": "88329a3faba5023cfb569b3fc5b8a771336c4a88", + "shasum": "" + }, + "require": { + "doctrine/dbal": "^4.3", + "php": ">=8.4", + "symfony/messenger": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "doctrine/persistence": "<1.3" + }, + "require-dev": { + "doctrine/persistence": "^1.3|^2|^3", + "symfony/property-access": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0" + }, + "type": "symfony-messenger-bridge", + "autoload": { + "psr-4": { + "Symfony\\Component\\Messenger\\Bridge\\Doctrine\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Doctrine Messenger Bridge", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/doctrine-messenger/tree/v8.0.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-02-20T07:51:53+00:00" + }, { "name": "symfony/dotenv", "version": "v8.0.7", @@ -7099,6 +7175,96 @@ ], "time": "2026-03-04T16:39:24+00:00" }, + { + "name": "symfony/messenger", + "version": "v8.0.11", + "source": { + "type": "git", + "url": "https://github.com/symfony/messenger.git", + "reference": "c451c175724fc781c777783aaec3b7999ceb0621" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/messenger/zipball/c451c175724fc781c777783aaec3b7999ceb0621", + "reference": "c451c175724fc781c777783aaec3b7999ceb0621", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/log": "^1|^2|^3", + "symfony/clock": "^7.4|^8.0" + }, + "conflict": { + "symfony/console": "<7.4", + "symfony/event-dispatcher-contracts": "<2.5", + "symfony/lock": "<7.4", + "symfony/serializer": "<7.4.4|>=8.0,<8.0.4" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0", + "symfony/routing": "^7.4|^8.0", + "symfony/serializer": "^7.4.4|^8.0.4", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Messenger\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Samuel Roze", + "email": "samuel.roze@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps applications send and receive messages to/from other applications or via message queues", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/messenger/tree/v8.0.11" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-13T12:07:53+00:00" + }, { "name": "symfony/mime", "version": "v8.0.7", @@ -13128,6 +13294,217 @@ ], "time": "2024-10-20T05:08:20+00:00" }, + { + "name": "symfony/browser-kit", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/browser-kit.git", + "reference": "f5a28fca785416cf489dd579011e74c831100cc3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/browser-kit/zipball/f5a28fca785416cf489dd579011e74c831100cc3", + "reference": "f5a28fca785416cf489dd579011e74c831100cc3", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/dom-crawler": "^7.4|^8.0" + }, + "require-dev": { + "symfony/css-selector": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\BrowserKit\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/browser-kit/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, + { + "name": "symfony/css-selector", + "version": "v8.0.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/css-selector.git", + "reference": "3665cfade90565430909b906394c73c8739e57d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/3665cfade90565430909b906394c73c8739e57d0", + "reference": "3665cfade90565430909b906394c73c8739e57d0", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Converts CSS selectors to XPath expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/css-selector/tree/v8.0.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-18T13:51:42+00:00" + }, + { + "name": "symfony/dom-crawler", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/dom-crawler.git", + "reference": "284ace90732b445b027728b5e0eec6418a17a364" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/284ace90732b445b027728b5e0eec6418a17a364", + "reference": "284ace90732b445b027728b5e0eec6418a17a364", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.0" + }, + "require-dev": { + "symfony/css-selector": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DomCrawler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases DOM navigation for HTML and XML documents", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dom-crawler/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, { "name": "symfony/process", "version": "v8.0.5", diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml new file mode 100644 index 0000000..e1aa710 --- /dev/null +++ b/config/packages/messenger.yaml @@ -0,0 +1,6 @@ +framework: + messenger: + transports: + sync: 'sync://' + + routing: {} diff --git a/config/reference.php b/config/reference.php index bd1717f..145877b 100644 --- a/config/reference.php +++ b/config/reference.php @@ -301,7 +301,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * }, * }, * translator?: bool|array{ // Translator configuration - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * fallbacks?: list, * logging?: bool|Param, // Default: false * formatter?: scalar|Param|null, // Default: "translator.formatter.default" @@ -413,7 +413,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * enabled?: bool|Param, // Default: true * }, * lock?: bool|string|array{ // Lock configuration - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * resources?: array>, * }, * semaphore?: bool|string|array{ // Semaphore configuration @@ -421,7 +421,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * resources?: array, * }, * messenger?: bool|array{ // Messenger configuration - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * routing?: array, * }>, @@ -1360,7 +1360,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * include_type?: bool|Param, // Always include @var in updates (including delete ones). // Default: false * }, * messenger?: bool|array{ - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * }, * elasticsearch?: bool|array{ * enabled?: bool|Param, // Default: false diff --git a/phpunit.dist.xml b/phpunit.dist.xml index bec7cd2..817239b 100644 --- a/phpunit.dist.xml +++ b/phpunit.dist.xml @@ -22,6 +22,12 @@ + + + + + + diff --git a/src/Entity/ClientTicket.php b/src/Entity/ClientTicket.php index 54a4d7e..b825c2f 100644 --- a/src/Entity/ClientTicket.php +++ b/src/Entity/ClientTicket.php @@ -47,12 +47,8 @@ use Symfony\Component\Validator\Constraints as Assert; order: ['createdAt' => 'DESC'], )] #[ORM\Entity(repositoryClass: ClientTicketRepository::class)] -#[ORM\Table( - name: 'client_ticket', - uniqueConstraints: [ - new ORM\UniqueConstraint(name: 'uniq_client_ticket_project_number', columns: ['project_id', 'number']), - ], -)] +#[ORM\Table(name: 'client_ticket')] +#[ORM\UniqueConstraint(name: 'uniq_client_ticket_project_number', columns: ['project_id', 'number'])] class ClientTicket { public const string TYPE_BUG = 'bug'; From 62e0bf5f11b6c6a0c8095dbae7dadb64d70f2bb0 Mon Sep 17 00:00:00 2001 From: matthieu Date: Wed, 20 May 2026 00:06:01 +0200 Subject: [PATCH 22/69] feat(mail) : MailSettings ApiResource singleton (GET/PATCH /api/mail/configuration) - 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) --- src/ApiResource/MailSettings.php | 69 ++++++++++ src/State/Mail/MailSettingsProcessor.php | 83 ++++++++++++ src/State/Mail/MailSettingsProvider.php | 39 ++++++ .../Mail/MailSettingsControllerTest.php | 121 ++++++++++++++++++ 4 files changed, 312 insertions(+) create mode 100644 src/ApiResource/MailSettings.php create mode 100644 src/State/Mail/MailSettingsProcessor.php create mode 100644 src/State/Mail/MailSettingsProvider.php create mode 100644 tests/Functional/Controller/Mail/MailSettingsControllerTest.php diff --git a/src/ApiResource/MailSettings.php b/src/ApiResource/MailSettings.php new file mode 100644 index 0000000..615b9d1 --- /dev/null +++ b/src/ApiResource/MailSettings.php @@ -0,0 +1,69 @@ + ['mail_settings:read']], + provider: MailSettingsProvider::class, + security: "is_granted('ROLE_ADMIN')", + ), + new Patch( + uriTemplate: '/mail/configuration', + denormalizationContext: ['groups' => ['mail_settings:write']], + normalizationContext: ['groups' => ['mail_settings:read']], + provider: MailSettingsProvider::class, + processor: MailSettingsProcessor::class, + security: "is_granted('ROLE_ADMIN')", + ), + ], +)] +final class MailSettings +{ + #[Groups(['mail_settings:read', 'mail_settings:write'])] + public ?string $protocol = null; + + #[Groups(['mail_settings:read', 'mail_settings:write'])] + public ?string $imapHost = null; + + #[Groups(['mail_settings:read', 'mail_settings:write'])] + public ?int $imapPort = null; + + #[Groups(['mail_settings:read', 'mail_settings:write'])] + public ?string $imapEncryption = null; + + #[Groups(['mail_settings:read', 'mail_settings:write'])] + public ?string $smtpHost = null; + + #[Groups(['mail_settings:read', 'mail_settings:write'])] + public ?int $smtpPort = null; + + #[Groups(['mail_settings:read', 'mail_settings:write'])] + public ?string $smtpEncryption = null; + + #[Groups(['mail_settings:read', 'mail_settings:write'])] + public ?string $username = null; + + #[Groups(['mail_settings:write'])] + public ?string $password = null; + + #[Groups(['mail_settings:read', 'mail_settings:write'])] + public ?string $sentFolderPath = null; + + #[Groups(['mail_settings:read', 'mail_settings:write'])] + public bool $enabled = false; + + #[Groups(['mail_settings:read'])] + public bool $hasPassword = false; +} diff --git a/src/State/Mail/MailSettingsProcessor.php b/src/State/Mail/MailSettingsProcessor.php new file mode 100644 index 0000000..9b7a7df --- /dev/null +++ b/src/State/Mail/MailSettingsProcessor.php @@ -0,0 +1,83 @@ +configRepository->findSingleton(); + if (null === $config) { + $config = new MailConfiguration(); + } + + if (null !== $data->protocol) { + $config->setProtocol($data->protocol); + } + if (null !== $data->imapHost) { + $config->setImapHost($data->imapHost); + } + if (null !== $data->imapPort) { + $config->setImapPort($data->imapPort); + } + if (null !== $data->imapEncryption) { + $config->setImapEncryption($data->imapEncryption); + } + if (null !== $data->smtpHost) { + $config->setSmtpHost($data->smtpHost); + } + if (null !== $data->smtpPort) { + $config->setSmtpPort($data->smtpPort); + } + if (null !== $data->smtpEncryption) { + $config->setSmtpEncryption($data->smtpEncryption); + } + if (null !== $data->username) { + $config->setUsername($data->username); + } + if (null !== $data->sentFolderPath) { + $config->setSentFolderPath($data->sentFolderPath); + } + $config->setEnabled($data->enabled); + + if (null !== $data->password && '' !== $data->password) { + $config->setEncryptedPassword($this->tokenEncryptor->encrypt($data->password)); + } + + $this->em->persist($config); + $this->em->flush(); + + $result = new MailSettings(); + $result->protocol = $config->getProtocol(); + $result->imapHost = $config->getImapHost(); + $result->imapPort = $config->getImapPort(); + $result->imapEncryption = $config->getImapEncryption(); + $result->smtpHost = $config->getSmtpHost(); + $result->smtpPort = $config->getSmtpPort(); + $result->smtpEncryption = $config->getSmtpEncryption(); + $result->username = $config->getUsername(); + $result->sentFolderPath = $config->getSentFolderPath(); + $result->enabled = $config->isEnabled(); + $result->hasPassword = $config->hasPassword(); + + return $result; + } +} diff --git a/src/State/Mail/MailSettingsProvider.php b/src/State/Mail/MailSettingsProvider.php new file mode 100644 index 0000000..9a60292 --- /dev/null +++ b/src/State/Mail/MailSettingsProvider.php @@ -0,0 +1,39 @@ +configRepository->findSingleton(); + $dto = new MailSettings(); + + if (null !== $config) { + $dto->protocol = $config->getProtocol(); + $dto->imapHost = $config->getImapHost(); + $dto->imapPort = $config->getImapPort(); + $dto->imapEncryption = $config->getImapEncryption(); + $dto->smtpHost = $config->getSmtpHost(); + $dto->smtpPort = $config->getSmtpPort(); + $dto->smtpEncryption = $config->getSmtpEncryption(); + $dto->username = $config->getUsername(); + $dto->sentFolderPath = $config->getSentFolderPath(); + $dto->enabled = $config->isEnabled(); + $dto->hasPassword = $config->hasPassword(); + } + + return $dto; + } +} diff --git a/tests/Functional/Controller/Mail/MailSettingsControllerTest.php b/tests/Functional/Controller/Mail/MailSettingsControllerTest.php new file mode 100644 index 0000000..63c1d0c --- /dev/null +++ b/tests/Functional/Controller/Mail/MailSettingsControllerTest.php @@ -0,0 +1,121 @@ +request('GET', '/api/mail/configuration'); + + self::assertResponseStatusCodeSame(401); + } + + public function testGetConfigurationReturns403ForRoleUser(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); + $client->loginUser($user); + $client->request('GET', '/api/mail/configuration'); + + self::assertResponseStatusCodeSame(403); + } + + public function testGetConfigurationReturns200ForAdmin(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $admin = $em->getRepository(User::class)->findOneBy(['username' => 'admin']); + $client->loginUser($admin); + $client->request('GET', '/api/mail/configuration'); + + self::assertResponseIsSuccessful(); + $data = json_decode($client->getResponse()->getContent(), true); + + self::assertArrayNotHasKey('password', $data); + self::assertArrayNotHasKey('encryptedPassword', $data); + self::assertArrayHasKey('hasPassword', $data); + self::assertArrayHasKey('imapHost', $data); + self::assertArrayHasKey('enabled', $data); + } + + public function testPatchConfigurationReturns403ForRoleUser(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); + $client->loginUser($user); + $client->request( + 'PATCH', + '/api/mail/configuration', + [], + [], + ['CONTENT_TYPE' => 'application/merge-patch+json'], + json_encode(['enabled' => false]) + ); + + self::assertResponseStatusCodeSame(403); + } + + public function testPatchConfigurationUpdatesFieldsForAdmin(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $admin = $em->getRepository(User::class)->findOneBy(['username' => 'admin']); + $client->loginUser($admin); + $client->request( + 'PATCH', + '/api/mail/configuration', + [], + [], + ['CONTENT_TYPE' => 'application/merge-patch+json'], + json_encode(['imapHost' => 'imap.example.com', 'enabled' => false]) + ); + + self::assertResponseIsSuccessful(); + $data = json_decode($client->getResponse()->getContent(), true); + self::assertSame('imap.example.com', $data['imapHost']); + self::assertArrayNotHasKey('password', $data); + } + + public function testPatchConfigurationWithPasswordEncryptsIt(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $admin = $em->getRepository(User::class)->findOneBy(['username' => 'admin']); + $client->loginUser($admin); + $client->request( + 'PATCH', + '/api/mail/configuration', + [], + [], + ['CONTENT_TYPE' => 'application/merge-patch+json'], + json_encode(['password' => 'secret123']) + ); + + self::assertResponseIsSuccessful(); + $data = json_decode($client->getResponse()->getContent(), true); + self::assertTrue($data['hasPassword']); + self::assertArrayNotHasKey('password', $data); + } +} From 412c412cbc497aa13c26b5cf45833053012aa159 Mon Sep 17 00:00:00 2001 From: matthieu Date: Wed, 20 May 2026 00:06:25 +0200 Subject: [PATCH 23/69] =?UTF-8?q?feat(mail)=20:=20MailTestConnectionContro?= =?UTF-8?q?ller=20=E2=80=94=20POST=20/api/mail/configuration/test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../Mail/MailTestConnectionController.php | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 src/Controller/Mail/MailTestConnectionController.php diff --git a/src/Controller/Mail/MailTestConnectionController.php b/src/Controller/Mail/MailTestConnectionController.php new file mode 100644 index 0000000..eedee6a --- /dev/null +++ b/src/Controller/Mail/MailTestConnectionController.php @@ -0,0 +1,44 @@ +mailProvider->listFolders(); + + return $this->json([ + 'ok' => true, + 'foldersCount' => count($folders), + ]); + } catch (MailProviderException) { + return $this->json([ + 'ok' => false, + 'error' => 'Connexion IMAP impossible. Vérifiez la configuration.', + ]); + } catch (Throwable) { + return $this->json([ + 'ok' => false, + 'error' => 'Erreur inattendue lors du test de connexion.', + ]); + } + } +} From 1c3ba9c33ca79fae34d02a373d26d2d34b85e030 Mon Sep 17 00:00:00 2001 From: matthieu Date: Wed, 20 May 2026 00:06:45 +0200 Subject: [PATCH 24/69] feat(mail) : MailAccessChecker - verification acces mail ROLE_USER/ROLE_ADMIN (refus ROLE_CLIENT pur) - 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) --- src/Security/MailAccessChecker.php | 53 ++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 src/Security/MailAccessChecker.php diff --git a/src/Security/MailAccessChecker.php b/src/Security/MailAccessChecker.php new file mode 100644 index 0000000..aa5073a --- /dev/null +++ b/src/Security/MailAccessChecker.php @@ -0,0 +1,53 @@ +getRoles(); + + if (in_array('ROLE_CLIENT', $roles, true) && !in_array('ROLE_ADMIN', $roles, true)) { + throw new AccessDeniedException('Mail not accessible to clients'); + } + + if (!in_array('ROLE_USER', $roles, true) && !in_array('ROLE_ADMIN', $roles, true)) { + throw new AccessDeniedException('ROLE_USER required'); + } + } + + /** + * Verifie que l'utilisateur est ROLE_ADMIN. + * + * @throws AccessDeniedException + */ + public function ensureIsAdmin(?UserInterface $user): void + { + if (!$user instanceof User || !$this->authorizationChecker->isGranted('ROLE_ADMIN')) { + throw new AccessDeniedException('Admin only'); + } + } +} From b1d6303afe14e66d4de7dd048d30b9c2dc385b83 Mon Sep 17 00:00:00 2001 From: matthieu Date: Wed, 20 May 2026 00:07:23 +0200 Subject: [PATCH 25/69] feat(mail) : MailFoldersListController - GET /api/mail/folders (arbre BDD + unreadCount) - 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) --- .../Mail/MailFoldersListController.php | 42 ++++++++++++++++ .../Mail/MailFoldersControllerTest.php | 50 +++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 src/Controller/Mail/MailFoldersListController.php create mode 100644 tests/Functional/Controller/Mail/MailFoldersControllerTest.php diff --git a/src/Controller/Mail/MailFoldersListController.php b/src/Controller/Mail/MailFoldersListController.php new file mode 100644 index 0000000..879ac19 --- /dev/null +++ b/src/Controller/Mail/MailFoldersListController.php @@ -0,0 +1,42 @@ +accessChecker->ensureCanAccessMail($this->getUser()); + + $folders = $this->folderRepository->findAllOrderedByPath(); + + $data = array_map(static fn ($folder) => [ + 'id' => $folder->getId(), + 'path' => $folder->getPath(), + 'displayName' => $folder->getDisplayName(), + 'parentPath' => $folder->getParentPath(), + 'unreadCount' => $folder->getUnreadCount(), + 'totalCount' => $folder->getTotalCount(), + 'lastSyncedAt' => $folder->getLastSyncedAt()?->format(DateTimeInterface::ATOM), + ], $folders); + + return $this->json($data); + } +} diff --git a/tests/Functional/Controller/Mail/MailFoldersControllerTest.php b/tests/Functional/Controller/Mail/MailFoldersControllerTest.php new file mode 100644 index 0000000..520942c --- /dev/null +++ b/tests/Functional/Controller/Mail/MailFoldersControllerTest.php @@ -0,0 +1,50 @@ +request('GET', '/api/mail/folders'); + + self::assertResponseStatusCodeSame(401); + } + + public function testListFoldersReturns403ForRoleClient(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $clientUser = $em->getRepository(User::class)->findOneBy(['username' => 'client-liot']); + $client->loginUser($clientUser); + $client->request('GET', '/api/mail/folders'); + + self::assertResponseStatusCodeSame(403); + } + + public function testListFoldersReturns200ForRoleUser(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); + $client->loginUser($user); + $client->request('GET', '/api/mail/folders'); + + self::assertResponseIsSuccessful(); + $data = json_decode($client->getResponse()->getContent(), true); + self::assertIsArray($data); + } +} From 7fb525595ed7f55262d114ba730c4bb26c235be2 Mon Sep 17 00:00:00 2001 From: matthieu Date: Wed, 20 May 2026 00:08:18 +0200 Subject: [PATCH 26/69] feat(mail) : MailMessagesListController - GET /api/mail/folders/{path}/messages (pagination cursor) - 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) --- .../Mail/MailMessagesListController.php | 65 +++++++++++++++++++ src/Repository/MailMessageRepository.php | 50 ++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 src/Controller/Mail/MailMessagesListController.php diff --git a/src/Controller/Mail/MailMessagesListController.php b/src/Controller/Mail/MailMessagesListController.php new file mode 100644 index 0000000..ccb4e02 --- /dev/null +++ b/src/Controller/Mail/MailMessagesListController.php @@ -0,0 +1,65 @@ + '.+'])] +#[IsGranted('IS_AUTHENTICATED_FULLY')] +class MailMessagesListController extends AbstractController +{ + public function __construct( + private readonly MailFolderRepository $folderRepository, + private readonly MailMessageRepository $messageRepository, + private readonly MailAccessChecker $accessChecker, + ) {} + + public function __invoke(Request $request, string $folderPath): JsonResponse + { + $this->accessChecker->ensureCanAccessMail($this->getUser()); + + $decodedPath = urldecode($folderPath); + + $folder = $this->folderRepository->findByPath($decodedPath); + if (null === $folder) { + throw new NotFoundHttpException(sprintf('Folder "%s" not found', $decodedPath)); + } + + $limit = min((int) $request->query->get('limit', 50), 100); + $cursor = $request->query->get('cursor'); + + $result = $this->messageRepository->findByFolderCursor($folder, $limit, $cursor ?: null); + + $messages = array_map(static fn ($m) => [ + 'id' => $m->getId(), + 'messageId' => $m->getMessageId(), + 'uid' => $m->getUid(), + 'subject' => $m->getSubject(), + 'fromAddress' => $m->getFromAddress(), + 'fromName' => $m->getFromName(), + 'toAddresses' => $m->getToAddresses(), + 'ccAddresses' => $m->getCcAddresses(), + 'sentAt' => $m->getSentAt()->format(DateTimeInterface::ATOM), + 'isRead' => $m->isRead(), + 'isFlagged' => $m->isFlagged(), + 'hasAttachments' => $m->hasAttachments(), + 'snippet' => $m->getSnippet(), + ], $result['messages']); + + return $this->json([ + 'messages' => $messages, + 'nextCursor' => $result['nextCursor'], + ]); + } +} diff --git a/src/Repository/MailMessageRepository.php b/src/Repository/MailMessageRepository.php index 2d9cf0e..78e07cd 100644 --- a/src/Repository/MailMessageRepository.php +++ b/src/Repository/MailMessageRepository.php @@ -6,6 +6,8 @@ namespace App\Repository; use App\Entity\MailFolder; use App\Entity\MailMessage; +use DateTimeImmutable; +use DateTimeInterface; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; @@ -99,4 +101,52 @@ class MailMessageRepository extends ServiceEntityRepository return array_column($rows, 'uid'); } + + /** + * Pagination cursor : retourne $limit messages apres le cursor (sentAt DESC, id DESC). + * Cursor format : base64url(sentAt_iso8601:id) - null pour la premiere page. + * + * @return array{messages: list, nextCursor: ?string} + */ + public function findByFolderCursor(MailFolder $folder, int $limit, ?string $cursor): array + { + $qb = $this->createQueryBuilder('m') + ->andWhere('m.folder = :folder') + ->setParameter('folder', $folder) + ->orderBy('m.sentAt', 'DESC') + ->addOrderBy('m.id', 'DESC') + ->setMaxResults($limit + 1) + ; + + if (null !== $cursor) { + $decoded = base64_decode(strtr($cursor, '-_', '+/'), true); + if (false !== $decoded && str_contains($decoded, ':')) { + [$sentAtStr, $idStr] = explode(':', $decoded, 2); + $cursorSentAt = DateTimeImmutable::createFromFormat(DateTimeInterface::ATOM, $sentAtStr); + $cursorId = (int) $idStr; + + if ($cursorSentAt instanceof DateTimeImmutable) { + $qb + ->andWhere('m.sentAt < :cursorSentAt OR (m.sentAt = :cursorSentAt AND m.id < :cursorId)') + ->setParameter('cursorSentAt', $cursorSentAt) + ->setParameter('cursorId', $cursorId) + ; + } + } + } + + /** @var list $results */ + $results = $qb->getQuery()->getResult(); + $hasMore = count($results) > $limit; + $messages = $hasMore ? array_slice($results, 0, $limit) : $results; + $nextCursor = null; + + if ($hasMore && [] !== $messages) { + $last = end($messages); + $raw = $last->getSentAt()->format(DateTimeInterface::ATOM).':'.$last->getId(); + $nextCursor = rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); + } + + return ['messages' => $messages, 'nextCursor' => $nextCursor]; + } } From 5ce7693343755f00add964c160f8934232e6c99c Mon Sep 17 00:00:00 2001 From: matthieu Date: Wed, 20 May 2026 00:09:30 +0200 Subject: [PATCH 27/69] feat(mail) : MailMessageDetailController - GET /api/mail/messages/{id} (live IMAP + cache 5 min) - 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) --- .../Mail/MailMessageDetailController.php | 87 +++++++++++++++++++ .../Mail/MailMessagesControllerTest.php | 48 ++++++++++ 2 files changed, 135 insertions(+) create mode 100644 src/Controller/Mail/MailMessageDetailController.php create mode 100644 tests/Functional/Controller/Mail/MailMessagesControllerTest.php diff --git a/src/Controller/Mail/MailMessageDetailController.php b/src/Controller/Mail/MailMessageDetailController.php new file mode 100644 index 0000000..c6a7e29 --- /dev/null +++ b/src/Controller/Mail/MailMessageDetailController.php @@ -0,0 +1,87 @@ + '\d+'])] +#[IsGranted('IS_AUTHENTICATED_FULLY')] +class MailMessageDetailController extends AbstractController +{ + public function __construct( + private readonly MailMessageRepository $messageRepository, + private readonly MailProviderInterface $mailProvider, + private readonly MailAccessChecker $accessChecker, + private readonly CacheItemPoolInterface $cache, + ) {} + + public function __invoke(int $id): JsonResponse + { + $this->accessChecker->ensureCanAccessMail($this->getUser()); + + $message = $this->messageRepository->find($id); + if (null === $message) { + throw new NotFoundHttpException('Message not found'); + } + + $cacheKey = 'mail_body_'.md5($message->getMessageId()); + $item = $this->cache->getItem($cacheKey); + + if (!$item->isHit()) { + try { + $detail = $this->mailProvider->fetchMessage( + $message->getFolder()->getPath(), + $message->getUid() + ); + $item->set($detail); + $item->expiresAfter(300); + $this->cache->save($item); + } catch (MailProviderException) { + throw new ServiceUnavailableHttpException(null, 'IMAP unavailable: could not fetch message body'); + } + } + + $detail = $item->get(); + + $messageId = $message->getId(); + $attachments = array_map(static fn ($att) => [ + 'partNumber' => $att->partNumber, + 'filename' => $att->filename, + 'mimeType' => $att->mimeType, + 'size' => $att->size, + 'downloadId' => rtrim(strtr(base64_encode($messageId.':'.$att->partNumber), '+/', '-_'), '='), + ], $detail->attachments); + + return $this->json([ + 'id' => $message->getId(), + 'messageId' => $message->getMessageId(), + 'uid' => $message->getUid(), + 'folderPath' => $message->getFolder()->getPath(), + 'subject' => $detail->header->subject, + 'fromAddress' => $detail->header->fromAddress, + 'fromName' => $detail->header->fromName, + 'toAddresses' => $detail->header->toAddresses, + 'ccAddresses' => $detail->header->ccAddresses, + 'sentAt' => $detail->header->sentAt->format(DateTimeInterface::ATOM), + 'isRead' => $message->isRead(), + 'isFlagged' => $message->isFlagged(), + 'hasAttachments' => $message->hasAttachments(), + 'bodyHtml' => $detail->bodyHtml, + 'bodyText' => $detail->bodyText, + 'attachments' => $attachments, + ]); + } +} diff --git a/tests/Functional/Controller/Mail/MailMessagesControllerTest.php b/tests/Functional/Controller/Mail/MailMessagesControllerTest.php new file mode 100644 index 0000000..f82e4f1 --- /dev/null +++ b/tests/Functional/Controller/Mail/MailMessagesControllerTest.php @@ -0,0 +1,48 @@ +request('GET', '/api/mail/messages/999'); + + self::assertResponseStatusCodeSame(401); + } + + public function testGetMessageDetailReturns403ForRoleClient(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $clientUser = $em->getRepository(User::class)->findOneBy(['username' => 'client-liot']); + $client->loginUser($clientUser); + $client->request('GET', '/api/mail/messages/999'); + + self::assertResponseStatusCodeSame(403); + } + + public function testGetMessageDetailReturns404WhenMessageNotFound(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); + $client->loginUser($user); + $client->request('GET', '/api/mail/messages/99999'); + + self::assertResponseStatusCodeSame(404); + } +} From f584ed96faaddd789184f95a169df37ede1b15b5 Mon Sep 17 00:00:00 2001 From: matthieu Date: Wed, 20 May 2026 00:10:06 +0200 Subject: [PATCH 28/69] feat(mail) : MailMessageReadController + MailMessageFlagController - POST .../read et .../flag - 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) --- .../Mail/MailMessageFlagController.php | 53 +++++++++++++++++++ .../Mail/MailMessageReadController.php | 53 +++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 src/Controller/Mail/MailMessageFlagController.php create mode 100644 src/Controller/Mail/MailMessageReadController.php diff --git a/src/Controller/Mail/MailMessageFlagController.php b/src/Controller/Mail/MailMessageFlagController.php new file mode 100644 index 0000000..262b13a --- /dev/null +++ b/src/Controller/Mail/MailMessageFlagController.php @@ -0,0 +1,53 @@ + '\d+'])] +#[IsGranted('IS_AUTHENTICATED_FULLY')] +class MailMessageFlagController extends AbstractController +{ + public function __construct( + private readonly MailMessageRepository $messageRepository, + private readonly MailProviderInterface $mailProvider, + private readonly EntityManagerInterface $em, + private readonly MailAccessChecker $accessChecker, + ) {} + + public function __invoke(Request $request, int $id): JsonResponse + { + $this->accessChecker->ensureCanAccessMail($this->getUser()); + + $message = $this->messageRepository->find($id); + if (null === $message) { + throw new NotFoundHttpException('Message not found'); + } + + $body = json_decode($request->getContent(), true); + $flagged = (bool) ($body['flagged'] ?? true); + + try { + $this->mailProvider->markFlagged($message->getFolder()->getPath(), $message->getUid(), $flagged); + } catch (MailProviderException) { + // Non bloquant + } + + $message->setIsFlagged($flagged); + $this->em->flush(); + + return $this->json(['id' => $message->getId(), 'isFlagged' => $message->isFlagged()]); + } +} diff --git a/src/Controller/Mail/MailMessageReadController.php b/src/Controller/Mail/MailMessageReadController.php new file mode 100644 index 0000000..3ec9504 --- /dev/null +++ b/src/Controller/Mail/MailMessageReadController.php @@ -0,0 +1,53 @@ + '\d+'])] +#[IsGranted('IS_AUTHENTICATED_FULLY')] +class MailMessageReadController extends AbstractController +{ + public function __construct( + private readonly MailMessageRepository $messageRepository, + private readonly MailProviderInterface $mailProvider, + private readonly EntityManagerInterface $em, + private readonly MailAccessChecker $accessChecker, + ) {} + + public function __invoke(Request $request, int $id): JsonResponse + { + $this->accessChecker->ensureCanAccessMail($this->getUser()); + + $message = $this->messageRepository->find($id); + if (null === $message) { + throw new NotFoundHttpException('Message not found'); + } + + $body = json_decode($request->getContent(), true); + $read = (bool) ($body['read'] ?? true); + + try { + $this->mailProvider->markRead($message->getFolder()->getPath(), $message->getUid(), $read); + } catch (MailProviderException) { + // Non bloquant : on met quand meme a jour la BDD (sync IMAP au prochain cycle) + } + + $message->setIsRead($read); + $this->em->flush(); + + return $this->json(['id' => $message->getId(), 'isRead' => $message->isRead()]); + } +} From c7d12f6acd461117aaab405b0df9a545a30e699d Mon Sep 17 00:00:00 2001 From: matthieu Date: Wed, 20 May 2026 00:10:53 +0200 Subject: [PATCH 29/69] feat(mail) : MailCreateTaskController - POST /api/mail/messages/{id}/create-task - 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) --- .../Mail/MailCreateTaskController.php | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 src/Controller/Mail/MailCreateTaskController.php diff --git a/src/Controller/Mail/MailCreateTaskController.php b/src/Controller/Mail/MailCreateTaskController.php new file mode 100644 index 0000000..651c661 --- /dev/null +++ b/src/Controller/Mail/MailCreateTaskController.php @@ -0,0 +1,105 @@ + '\d+'])] +#[IsGranted('IS_AUTHENTICATED_FULLY')] +class MailCreateTaskController extends AbstractController +{ + public function __construct( + private readonly MailMessageRepository $messageRepository, + private readonly EntityManagerInterface $em, + private readonly MailAccessChecker $accessChecker, + private readonly TaskRepository $taskRepository, + ) {} + + public function __invoke(Request $request, int $id): JsonResponse + { + $this->accessChecker->ensureCanAccessMail($this->getUser()); + + $message = $this->messageRepository->find($id); + if (null === $message) { + throw new NotFoundHttpException('Message not found'); + } + + $body = json_decode($request->getContent(), true); + $projectId = $body['projectId'] ?? null; + + if (null === $projectId) { + throw new UnprocessableEntityHttpException('projectId is required'); + } + + $project = $this->em->getRepository(Project::class)->find($projectId); + if (null === $project) { + throw new NotFoundHttpException('Project not found'); + } + + $title = $message->getSubject() ?? 'Mail sans sujet'; + if (mb_strlen($title) > 255) { + $title = mb_substr($title, 0, 252).'...'; + } + + $result = $this->em->wrapInTransaction(function () use ($project, $title, $body, $message) { + $maxNumber = $this->taskRepository->findMaxNumberByProjectForUpdate($project); + + $task = new Task(); + $task->setProject($project); + $task->setTitle($title); + $task->setNumber($maxNumber + 1); + + if (isset($body['taskGroupId']) && null !== $body['taskGroupId']) { + $taskGroup = $this->em->getRepository(TaskGroup::class)->find($body['taskGroupId']); + if (null !== $taskGroup) { + $task->setGroup($taskGroup); + } + } + + if (isset($body['priorityId']) && null !== $body['priorityId']) { + $priority = $this->em->getRepository(TaskPriority::class)->find($body['priorityId']); + if (null !== $priority) { + $task->setPriority($priority); + } + } + + $this->em->persist($task); + + $link = new TaskMailLink(); + $link->setTask($task); + $link->setMailMessage($message); + $link->setLinkedAt(new DateTimeImmutable()); + $link->setLinkedBy($this->getUser()); + $this->em->persist($link); + + $this->em->flush(); + + return $task; + }); + + return $this->json([ + 'taskId' => $result->getId(), + 'taskNumber' => $result->getNumber(), + 'taskTitle' => $result->getTitle(), + 'messageId' => $message->getId(), + ], 201); + } +} From 117175d4b15247c729c2e3c72d1b4c142bc78510 Mon Sep 17 00:00:00 2001 From: matthieu Date: Wed, 20 May 2026 00:12:10 +0200 Subject: [PATCH 30/69] feat(mail) : MailLinkTask + MailUnlinkTask + TaskMailsList controllers - 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) --- .../Mail/MailLinkTaskController.php | 69 ++++++++++++++++++ .../Mail/MailUnlinkTaskController.php | 54 ++++++++++++++ .../Mail/TaskMailsListController.php | 54 ++++++++++++++ .../MailTaskIntegrationControllerTest.php | 72 +++++++++++++++++++ 4 files changed, 249 insertions(+) create mode 100644 src/Controller/Mail/MailLinkTaskController.php create mode 100644 src/Controller/Mail/MailUnlinkTaskController.php create mode 100644 src/Controller/Mail/TaskMailsListController.php create mode 100644 tests/Functional/Controller/Mail/MailTaskIntegrationControllerTest.php diff --git a/src/Controller/Mail/MailLinkTaskController.php b/src/Controller/Mail/MailLinkTaskController.php new file mode 100644 index 0000000..86d96b8 --- /dev/null +++ b/src/Controller/Mail/MailLinkTaskController.php @@ -0,0 +1,69 @@ + '\d+'])] +#[IsGranted('IS_AUTHENTICATED_FULLY')] +class MailLinkTaskController extends AbstractController +{ + public function __construct( + private readonly MailMessageRepository $messageRepository, + private readonly TaskMailLinkRepository $linkRepository, + private readonly EntityManagerInterface $em, + private readonly MailAccessChecker $accessChecker, + ) {} + + public function __invoke(Request $request, int $id): JsonResponse + { + $this->accessChecker->ensureCanAccessMail($this->getUser()); + + $message = $this->messageRepository->find($id); + if (null === $message) { + throw new NotFoundHttpException('Message not found'); + } + + $body = json_decode($request->getContent(), true); + $taskId = $body['taskId'] ?? null; + + if (null === $taskId) { + throw new UnprocessableEntityHttpException('taskId is required'); + } + + $task = $this->em->getRepository(Task::class)->find($taskId); + if (null === $task) { + throw new NotFoundHttpException('Task not found'); + } + + $existing = $this->linkRepository->findByTaskAndMessage($task, $message); + if (null !== $existing) { + return $this->json(['message' => 'Already linked']); + } + + $link = new TaskMailLink(); + $link->setTask($task); + $link->setMailMessage($message); + $link->setLinkedAt(new DateTimeImmutable()); + $link->setLinkedBy($this->getUser()); + $this->em->persist($link); + $this->em->flush(); + + return $this->json(['linkId' => $link->getId(), 'taskId' => $task->getId(), 'messageId' => $message->getId()], 201); + } +} diff --git a/src/Controller/Mail/MailUnlinkTaskController.php b/src/Controller/Mail/MailUnlinkTaskController.php new file mode 100644 index 0000000..399959e --- /dev/null +++ b/src/Controller/Mail/MailUnlinkTaskController.php @@ -0,0 +1,54 @@ + '\d+', 'taskId' => '\d+'])] +#[IsGranted('IS_AUTHENTICATED_FULLY')] +class MailUnlinkTaskController extends AbstractController +{ + public function __construct( + private readonly MailMessageRepository $messageRepository, + private readonly TaskMailLinkRepository $linkRepository, + private readonly EntityManagerInterface $em, + private readonly MailAccessChecker $accessChecker, + ) {} + + public function __invoke(int $id, int $taskId): JsonResponse + { + $this->accessChecker->ensureCanAccessMail($this->getUser()); + + $message = $this->messageRepository->find($id); + if (null === $message) { + throw new NotFoundHttpException('Message not found'); + } + + $task = $this->em->getRepository(Task::class)->find($taskId); + if (null === $task) { + throw new NotFoundHttpException('Task not found'); + } + + $link = $this->linkRepository->findByTaskAndMessage($task, $message); + if (null === $link) { + throw new NotFoundHttpException('Link not found'); + } + + $this->em->remove($link); + $this->em->flush(); + + return $this->json(null, Response::HTTP_NO_CONTENT); + } +} diff --git a/src/Controller/Mail/TaskMailsListController.php b/src/Controller/Mail/TaskMailsListController.php new file mode 100644 index 0000000..6b2180e --- /dev/null +++ b/src/Controller/Mail/TaskMailsListController.php @@ -0,0 +1,54 @@ + '\d+'])] +#[IsGranted('IS_AUTHENTICATED_FULLY')] +class TaskMailsListController extends AbstractController +{ + public function __construct( + private readonly EntityManagerInterface $em, + private readonly TaskMailLinkRepository $linkRepository, + private readonly MailAccessChecker $accessChecker, + ) {} + + public function __invoke(int $id): JsonResponse + { + $this->accessChecker->ensureCanAccessMail($this->getUser()); + + $task = $this->em->getRepository(Task::class)->find($id); + if (null === $task) { + throw new NotFoundHttpException('Task not found'); + } + + $links = $this->linkRepository->findByTask($task); + + $data = array_map(static fn ($link) => [ + 'id' => $link->getMailMessage()->getId(), + 'messageId' => $link->getMailMessage()->getMessageId(), + 'subject' => $link->getMailMessage()->getSubject(), + 'fromAddress' => $link->getMailMessage()->getFromAddress(), + 'fromName' => $link->getMailMessage()->getFromName(), + 'sentAt' => $link->getMailMessage()->getSentAt()->format(DateTimeInterface::ATOM), + 'isRead' => $link->getMailMessage()->isRead(), + 'isFlagged' => $link->getMailMessage()->isFlagged(), + 'snippet' => $link->getMailMessage()->getSnippet(), + 'linkedAt' => $link->getLinkedAt()->format(DateTimeInterface::ATOM), + ], $links); + + return $this->json($data); + } +} diff --git a/tests/Functional/Controller/Mail/MailTaskIntegrationControllerTest.php b/tests/Functional/Controller/Mail/MailTaskIntegrationControllerTest.php new file mode 100644 index 0000000..83feeb5 --- /dev/null +++ b/tests/Functional/Controller/Mail/MailTaskIntegrationControllerTest.php @@ -0,0 +1,72 @@ +request('POST', '/api/mail/messages/1/link-task', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode(['taskId' => 1])); + + self::assertResponseStatusCodeSame(401); + } + + public function testLinkTaskReturns403ForRoleClient(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $clientUser = $em->getRepository(User::class)->findOneBy(['username' => 'client-liot']); + $client->loginUser($clientUser); + $client->request('POST', '/api/mail/messages/1/link-task', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode(['taskId' => 1])); + + self::assertResponseStatusCodeSame(403); + } + + public function testUnlinkTaskReturns401WhenNotAuthenticated(): void + { + $client = static::createClient(); + $client->request('DELETE', '/api/mail/messages/1/link-task/1'); + + self::assertResponseStatusCodeSame(401); + } + + public function testTaskMailsListReturns401WhenNotAuthenticated(): void + { + $client = static::createClient(); + $client->request('GET', '/api/tasks/1/mails'); + + self::assertResponseStatusCodeSame(401); + } + + public function testTaskMailsListReturns403ForRoleClient(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $clientUser = $em->getRepository(User::class)->findOneBy(['username' => 'client-liot']); + $client->loginUser($clientUser); + $client->request('GET', '/api/tasks/1/mails'); + + self::assertResponseStatusCodeSame(403); + } + + public function testCreateTaskReturns401WhenNotAuthenticated(): void + { + $client = static::createClient(); + $client->request('POST', '/api/mail/messages/1/create-task', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode(['projectId' => 1])); + + self::assertResponseStatusCodeSame(401); + } +} From f7f7a071628293cda9e12976dced5c0fe67c2a17 Mon Sep 17 00:00:00 2001 From: matthieu Date: Wed, 20 May 2026 00:12:38 +0200 Subject: [PATCH 31/69] feat(mail) : MailAttachmentDownloadController - GET /api/mail/attachments/{id} (stream, disposition: attachment) - 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) --- .../Mail/MailAttachmentDownloadController.php | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 src/Controller/Mail/MailAttachmentDownloadController.php diff --git a/src/Controller/Mail/MailAttachmentDownloadController.php b/src/Controller/Mail/MailAttachmentDownloadController.php new file mode 100644 index 0000000..f2be423 --- /dev/null +++ b/src/Controller/Mail/MailAttachmentDownloadController.php @@ -0,0 +1,93 @@ +accessChecker->ensureCanAccessMail($this->getUser()); + + $decoded = base64_decode(strtr($downloadId, '-_', '+/'), true); + if (false === $decoded || !str_contains($decoded, ':')) { + throw new BadRequestHttpException('Invalid attachment ID format'); + } + + [$messageDbIdStr, $partNumber] = explode(':', $decoded, 2); + $messageDbId = (int) $messageDbIdStr; + + $message = $this->messageRepository->find($messageDbId); + if (null === $message) { + throw new NotFoundHttpException('Message not found'); + } + + try { + $detail = $this->mailProvider->fetchMessage( + $message->getFolder()->getPath(), + $message->getUid() + ); + } catch (MailProviderException) { + throw new NotFoundHttpException('Could not fetch message from IMAP server'); + } + + $targetAttachment = null; + foreach ($detail->attachments as $att) { + if ($att->partNumber === $partNumber) { + $targetAttachment = $att; + + break; + } + } + + if (null === $targetAttachment) { + throw new NotFoundHttpException(sprintf('Attachment part "%s" not found', $partNumber)); + } + + try { + $content = $this->mailProvider->fetchAttachment( + $message->getFolder()->getPath(), + $message->getUid(), + $partNumber + ); + } catch (MailProviderException) { + throw new NotFoundHttpException('Could not fetch attachment content'); + } + + $filename = basename($targetAttachment->filename); + if ('' === $filename || '.' === $filename) { + $filename = 'attachment'; + } + + $response = new Response($content); + $response->headers->set('Content-Type', $targetAttachment->mimeType); + $response->headers->set( + 'Content-Disposition', + sprintf('attachment; filename="%s"', addslashes($filename)) + ); + $response->headers->set('Content-Length', (string) strlen($content)); + $response->headers->set('X-Content-Type-Options', 'nosniff'); + + return $response; + } +} From cc46dd915d5b81600d3dc847610743ed4dc31150 Mon Sep 17 00:00:00 2001 From: matthieu Date: Wed, 20 May 2026 00:14:47 +0200 Subject: [PATCH 32/69] feat(mail) : MailSyncRequested message + handler + messenger.yaml transport async Doctrine - 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) --- config/packages/messenger.yaml | 24 +++++++- migrations/Version20260519220000.php | 56 +++++++++++++++++++ src/Message/MailSyncRequested.php | 12 ++++ .../MailSyncRequestedHandler.php | 54 ++++++++++++++++++ 4 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 migrations/Version20260519220000.php create mode 100644 src/Message/MailSyncRequested.php create mode 100644 src/MessageHandler/MailSyncRequestedHandler.php diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml index e1aa710..3c1eeab 100644 --- a/config/packages/messenger.yaml +++ b/config/packages/messenger.yaml @@ -1,6 +1,28 @@ framework: messenger: + failure_transport: failed + transports: sync: 'sync://' - routing: {} + async: + dsn: '%env(MESSENGER_TRANSPORT_DSN)%' + options: + queue_name: default + retry_strategy: + max_retries: 3 + delay: 1000 + multiplier: 2 + max_delay: 0 + + failed: 'doctrine://default?queue_name=failed&auto_setup=0' + + routing: + 'App\Message\MailSyncRequested': async + +when@test: + framework: + messenger: + transports: + async: 'in-memory://' + failed: 'in-memory://' diff --git a/migrations/Version20260519220000.php b/migrations/Version20260519220000.php new file mode 100644 index 0000000..a8a7feb --- /dev/null +++ b/migrations/Version20260519220000.php @@ -0,0 +1,56 @@ +addSql(<<<'SQL' + CREATE TABLE IF NOT EXISTS messenger_messages ( + id BIGSERIAL PRIMARY KEY, + body TEXT NOT NULL, + headers TEXT NOT NULL, + queue_name VARCHAR(190) NOT NULL, + created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + available_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + delivered_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL + ) + SQL); + + $this->addSql('CREATE INDEX IF NOT EXISTS IDX_75EA56E0FB7336F0 ON messenger_messages (queue_name)'); + $this->addSql('CREATE INDEX IF NOT EXISTS IDX_75EA56E0E3BD61CE ON messenger_messages (available_at)'); + $this->addSql('CREATE INDEX IF NOT EXISTS IDX_75EA56E016BA31DB ON messenger_messages (delivered_at)'); + + $this->addSql(<<<'SQL' + CREATE OR REPLACE FUNCTION notify_messenger_messages() RETURNS TRIGGER AS $$ + BEGIN + PERFORM pg_notify('messenger_messages', NEW.queue_name::text); + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + SQL); + + $this->addSql('DROP TRIGGER IF EXISTS notify_trigger ON messenger_messages;'); + $this->addSql('CREATE TRIGGER notify_trigger AFTER INSERT OR UPDATE ON messenger_messages FOR EACH ROW EXECUTE PROCEDURE notify_messenger_messages();'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE IF EXISTS messenger_messages'); + $this->addSql('DROP FUNCTION IF EXISTS notify_messenger_messages()'); + } +} diff --git a/src/Message/MailSyncRequested.php b/src/Message/MailSyncRequested.php new file mode 100644 index 0000000..01ecb52 --- /dev/null +++ b/src/Message/MailSyncRequested.php @@ -0,0 +1,12 @@ +folderPath) { + $folder = $this->folderRepository->findByPath($message->folderPath); + if (null !== $folder) { + $report = $this->mailSyncService->syncFolder($folder); + $this->logger->info(sprintf( + 'MailSyncRequested handled for folder "%s": %d created, %d updated, %d deleted', + $message->folderPath, + $report->createdCount, + $report->updatedCount, + $report->deletedCount, + )); + } else { + $this->logger->warning(sprintf('MailSyncRequested: folder "%s" not found in DB', $message->folderPath)); + } + } else { + $report = $this->mailSyncService->syncAll(); + $this->logger->info(sprintf( + 'MailSyncRequested handled (all folders): %d created, %d updated, %d deleted, %d folders scanned', + $report->createdCount, + $report->updatedCount, + $report->deletedCount, + $report->foldersScanned, + )); + } + } catch (Throwable $e) { + $this->logger->error('MailSyncRequestedHandler failed: '.$e->getMessage()); + } + } +} From 6d420c86e85ab87a476b70129da63630cb482e6f Mon Sep 17 00:00:00 2001 From: matthieu Date: Wed, 20 May 2026 00:15:25 +0200 Subject: [PATCH 33/69] feat(mail) : MailSyncTriggerController - POST /api/mail/sync (202 + Messenger async) - 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) --- .../Mail/MailSyncTriggerController.php | 40 +++++++++++++++ .../Mail/MailSyncTriggerControllerTest.php | 50 +++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 src/Controller/Mail/MailSyncTriggerController.php create mode 100644 tests/Functional/Controller/Mail/MailSyncTriggerControllerTest.php diff --git a/src/Controller/Mail/MailSyncTriggerController.php b/src/Controller/Mail/MailSyncTriggerController.php new file mode 100644 index 0000000..165293b --- /dev/null +++ b/src/Controller/Mail/MailSyncTriggerController.php @@ -0,0 +1,40 @@ +accessChecker->ensureCanAccessMail($this->getUser()); + + $body = json_decode($request->getContent(), true) ?? []; + $folderPath = $body['folderPath'] ?? null; + + $this->bus->dispatch(new MailSyncRequested($folderPath)); + + return $this->json( + ['message' => 'Synchronisation démarrée en arrière-plan'], + Response::HTTP_ACCEPTED + ); + } +} diff --git a/tests/Functional/Controller/Mail/MailSyncTriggerControllerTest.php b/tests/Functional/Controller/Mail/MailSyncTriggerControllerTest.php new file mode 100644 index 0000000..54dbbc1 --- /dev/null +++ b/tests/Functional/Controller/Mail/MailSyncTriggerControllerTest.php @@ -0,0 +1,50 @@ +request('POST', '/api/mail/sync'); + + self::assertResponseStatusCodeSame(401); + } + + public function testSyncTriggerReturns403ForRoleClient(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $clientUser = $em->getRepository(User::class)->findOneBy(['username' => 'client-liot']); + $client->loginUser($clientUser); + $client->request('POST', '/api/mail/sync'); + + self::assertResponseStatusCodeSame(403); + } + + public function testSyncTriggerReturns202ForRoleUser(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); + $client->loginUser($user); + $client->request('POST', '/api/mail/sync'); + + self::assertResponseStatusCodeSame(202); + $data = json_decode($client->getResponse()->getContent(), true); + self::assertArrayHasKey('message', $data); + } +} From 8986f3cb0eb8b25debb5b872d392365e8232440d Mon Sep 17 00:00:00 2001 From: matthieu Date: Wed, 20 May 2026 00:15:49 +0200 Subject: [PATCH 34/69] feat(mail) : security.yaml - access_control ^/api/mail (IS_AUTHENTICATED_FULLY) - 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) --- config/packages/security.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/packages/security.yaml b/config/packages/security.yaml index a6fed5a..820b46a 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -64,6 +64,8 @@ security: - { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [ GET ] } - { path: ^/_mcp, roles: PUBLIC_ACCESS, methods: [ GET ] } - { path: ^/_mcp, roles: IS_AUTHENTICATED_FULLY } + # Mail : requiert authentification (les checks ROLE_USER/ROLE_CLIENT sont dans MailAccessChecker) + - { path: ^/api/mail, roles: IS_AUTHENTICATED_FULLY } - { path: ^/api, roles: IS_AUTHENTICATED_FULLY } when@test: From a440ce267f2f675ed8ea106be6d952796c2b2719 Mon Sep 17 00:00:00 2001 From: matthieu Date: Wed, 20 May 2026 00:21:50 +0200 Subject: [PATCH 35/69] =?UTF-8?q?docs(mail)=20:=20plan=20d=C3=A9taill?= =?UTF-8?q?=C3=A9=20Phase=204=20=E2=80=94=20services=20TS,=20store=20Pinia?= =?UTF-8?q?,=20DOMPurify=20(6=20tasks)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- ...026-05-19-mail-phase4-frontend-services.md | 1107 +++++++++++++++++ 1 file changed, 1107 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-19-mail-phase4-frontend-services.md diff --git a/docs/superpowers/plans/2026-05-19-mail-phase4-frontend-services.md b/docs/superpowers/plans/2026-05-19-mail-phase4-frontend-services.md new file mode 100644 index 0000000..2537255 --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-mail-phase4-frontend-services.md @@ -0,0 +1,1107 @@ +# Mail Integration — Phase 4 : Frontend Services + Store + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Mettre en place toute la couche frontend non-visuelle (types TS, service API, store Pinia, helper sanitization HTML) avant d'attaquer les composants UI en Phase 5. + +**Architecture:** `services/mail.ts` wrappe `useApi()` (pattern Lesstime), `stores/mail.ts` gère state global + polling unread 30s via `setInterval` natif (pas de VueUse — non installé dans le projet), `utils/sanitizeMailHtml.ts` applique DOMPurify avec config bloquante (scripts/iframes/on*/javascript:) + remplacement images distantes par placeholder anti-tracking. + +**Tech Stack:** Nuxt 4, Vue 3 Composition API, Pinia, TypeScript strict, DOMPurify. + +**Branche cible :** `feat/mail-integration` (créée en Phase 1 — vérifier qu'elle est active). + +**Fichiers créés/modifiés par le codeur :** + +| Fichier | Action | +|---|---| +| `frontend/services/dto/mail.ts` | Créer | +| `frontend/services/mail.ts` | Créer | +| `frontend/stores/mail.ts` | Créer | +| `frontend/utils/sanitizeMailHtml.ts` | Créer | +| `frontend/package.json` | Modifier (ajout dompurify + @types/dompurify) | + +--- + +### Task 1 : Vérification de l'environnement + install dompurify + +- [ ] **Step 1 : Vérifier la branche active** + + ```bash + git branch --show-current + ``` + + Attendu : `feat/mail-integration`. Si non, basculer : + + ```bash + git checkout feat/mail-integration + ``` + +- [ ] **Step 2 : Vérifier que dompurify n'est pas déjà installé** + + ```bash + grep -i dompurify /home/r-dev/malio-dev/Lesstime/frontend/package.json + ``` + + Attendu : aucune ligne. Si déjà présent, passer au Step 4. + +- [ ] **Step 3 : Installer dompurify et ses types** + + ```bash + cd /home/r-dev/malio-dev/Lesstime/frontend && npm install dompurify && npm install -D @types/dompurify + ``` + + Attendu : `package.json` mis à jour avec `"dompurify"` dans `dependencies` et `"@types/dompurify"` dans `devDependencies`. + +- [ ] **Step 4 : Vérifier que dompurify est importable (smoke check)** + + ```bash + cd /home/r-dev/malio-dev/Lesstime/frontend && node -e "import('dompurify').then(() => console.log('OK'))" + ``` + + Ou simplement vérifier la présence dans node_modules : + + ```bash + ls /home/r-dev/malio-dev/Lesstime/frontend/node_modules/dompurify/dist/ | head -5 + ``` + + Attendu : fichiers `.js` présents. + +- [ ] **Step 5 : Commit** + + ```bash + git add frontend/package.json frontend/package-lock.json + git commit -m "feat(mail) : install dompurify + types" + ``` + +--- + +### Task 2 : Types TS — `frontend/services/dto/mail.ts` + +Tous les types sont alignés sur les formats de réponses des endpoints Phase 3. Le pattern suit `frontend/services/dto/zimbra.ts` (types simples, pas d'héritage complexe) et `frontend/services/dto/task.ts` (champs optionnels explicites, `| null`). + +- [ ] **Step 1 : Créer `frontend/services/dto/mail.ts`** + + ```typescript + // Lecture de la configuration mail (singleton admin) + export type MailConfigurationDto = { + protocol: string | null + imapHost: string | null + imapPort: number | null + imapEncryption: string | null + smtpHost: string | null + smtpPort: number | null + smtpEncryption: string | null + username: string | null + sentFolderPath: string | null + enabled: boolean + hasPassword: boolean + // password JAMAIS présent dans les réponses GET + } + + // Input PATCH configuration (password optionnel, write-only) + export type MailConfigurationUpdateDto = { + protocol?: string | null + imapHost?: string | null + imapPort?: number | null + imapEncryption?: string | null + smtpHost?: string | null + smtpPort?: number | null + smtpEncryption?: string | null + username?: string | null + sentFolderPath?: string | null + enabled?: boolean + password?: string // write-only, jamais retourné + } + + // Résultat du test de connexion + export type MailTestConnectionResultDto = { + ok: boolean + foldersCount?: number + error?: string + } + + // Dossier mail (peut être imbriqué) + export type MailFolderDto = { + path: string + displayName: string + parentPath: string | null + unreadCount: number + totalCount: number + children?: MailFolderDto[] + } + + // En-tête d'un message (liste) + export type MailMessageHeaderDto = { + id: number + messageId: string // identifiant IMAP unique + folderPath: string + subject: string | null + fromName: string | null + fromEmail: string | null + toRecipients: MailAddressDto[] + ccRecipients: MailAddressDto[] + sentAt: string | null // ISO 8601 + receivedAt: string // ISO 8601 + isRead: boolean + isFlagged: boolean + hasAttachments: boolean + linkedTaskIds: number[] + } + + // Adresse mail (nom + email) + export type MailAddressDto = { + name: string | null + email: string + } + + // Pièce jointe (métadonnées uniquement, téléchargement via downloadId) + export type MailAttachmentDto = { + downloadId: string + filename: string + mimeType: string + size: number // octets + } + + // Détail complet d'un message (enrichi avec body + PJ) + export type MailMessageDetailDto = { + header: MailMessageHeaderDto + bodyHtml: string | null // HTML brut — TOUJOURS passer par sanitizeMailHtml() avant affichage + bodyText: string | null // Fallback texte plain + attachments: MailAttachmentDto[] + } + + // Page de messages paginée (cursor-based) + export type MailMessagesPageDto = { + items: MailMessageHeaderDto[] + nextCursor: string | null // null = plus de page suivante + total: number + } + + // Input : marquer lu/non-lu + export type MailMessageReadInput = { + read: boolean + } + + // Input : marquer étoilé/non-étoilé + export type MailMessageFlagInput = { + flagged: boolean + } + + // Input : créer une tâche depuis un mail + export type MailCreateTaskInput = { + projectId: number + taskGroupId?: number | null + priority?: string | null + } + + // Input : lier une tâche existante à un mail + export type MailLinkTaskInput = { + taskId: number + } + + // Résultat de la sync manuelle + export type MailSyncResultDto = { + dispatched: boolean + } + ``` + +- [ ] **Step 2 : Vérifier la syntaxe TypeScript (smoke)** + + ```bash + cd /home/r-dev/malio-dev/Lesstime/frontend && npx tsc --noEmit 2>&1 | grep "dto/mail" | head -20 + ``` + + Attendu : aucune erreur liée à `dto/mail.ts`. + +- [ ] **Step 3 : Commit** + + ```bash + git add frontend/services/dto/mail.ts + git commit -m "feat(mail) : types TS DTOs mail (config, folders, messages, attachments)" + ``` + +--- + +### Task 3 : Helper sanitization — `frontend/utils/sanitizeMailHtml.ts` + +Ce helper est critique pour la sécurité : tout corps HTML de mail doit transiter par cette fonction avant affichage dans le DOM. Il bloque les scripts, iframes, attributs événements, et remplace les images externes par un placeholder anti-tracking. + +- [ ] **Step 1 : Créer `frontend/utils/sanitizeMailHtml.ts`** + + ```typescript + import DOMPurify from 'dompurify' + + /** + * Options de sanitization du corps HTML d'un mail. + */ + export type SanitizeMailHtmlOptions = { + /** + * Si true, les images distantes (http/https) sont affichées directement. + * Par défaut false — les images distantes sont remplacées par un placeholder + * cliquable pour éviter le tracking par pixel. + */ + allowImages?: boolean + } + + /** + * Configuration DOMPurify bloquante pour les corps de mail. + * - Bloque les balises dangereuses : script, iframe, object, embed, style, link, meta, form, input + * - Bloque les attributs événements (on*) et les URI javascript: + * - Autorise les URI data: uniquement pour les images (PNG/JPEG/GIF/WEBP) — images inline CID + */ + const DOMPURIFY_CONFIG: DOMPurify.Config = { + FORBID_TAGS: [ + 'script', + 'iframe', + 'object', + 'embed', + 'style', + 'link', + 'meta', + 'form', + 'input', + 'button', + 'textarea', + 'select', + 'base', + 'applet', + ], + FORBID_ATTR: [ + 'onerror', + 'onload', + 'onclick', + 'onmouseover', + 'onmouseout', + 'onmouseenter', + 'onmouseleave', + 'onfocus', + 'onblur', + 'onchange', + 'onsubmit', + 'onreset', + 'onkeydown', + 'onkeyup', + 'onkeypress', + 'ondblclick', + 'oncontextmenu', + 'onwheel', + 'ondrag', + 'ondrop', + 'oncopy', + 'oncut', + 'onpaste', + 'action', + 'formaction', + 'xlink:href', + ], + ALLOWED_URI_REGEXP: /^(?:https?|mailto|tel|cid|data:image\/(?:png|jpeg|gif|webp)(?:;base64,)?)(?::|$)/i, + FORCE_BODY: true, + WHOLE_DOCUMENT: false, + } + + /** + * Remplace les balises avec src http(s):// par un bouton placeholder. + * Le src original est stocké en data-mail-image-src pour permettre l'affichage + * à la demande de l'utilisateur (Phase 5 — MailMessageViewer). + */ + function replaceRemoteImages(html: string): string { + // Utiliser un DOMParser côté client uniquement (SSR-safe : le guard process.client + // est géré par l'appelant dans un composant Vue — ce helper ne tourne que client-side) + const parser = new DOMParser() + const doc = parser.parseFromString(html, 'text/html') + const images = doc.querySelectorAll('img') + + images.forEach((img) => { + const src = img.getAttribute('src') ?? '' + const isRemote = /^https?:\/\//i.test(src) + if (!isRemote) return + + // Remplacer par un span cliquable (pas de