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