44 KiB
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<timestamp>.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
git checkout feat/mail-integration 2>/dev/null || git checkout -b feat/mail-integration -
Step 2 : Créer les dossiers namespace
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.jsonet confirmer que"App\\": "src/"est présent dansautoload.psr-4. Si oui, rien à faire (les sous-dossierssrc/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
declare(strict_types=1);
namespace App\Tests\Unit\Repository;
use App\Entity\MailConfiguration;
use App\Repository\MailConfigurationRepository;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
class MailConfigurationRepositoryTest extends KernelTestCase
{
private MailConfigurationRepository $repository;
protected function setUp(): void
{
self::bootKernel();
$container = static::getContainer();
$this->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
docker exec php-lesstime-fpm php bin/phpunit tests/Unit/Repository/MailConfigurationRepositoryTest.php 2>&1 | tail -20Attendu : erreur de classe manquante (
App\Entity\MailConfiguration). -
Step 3 : Créer l'entité
MailConfigurationCréer
src/Entity/MailConfiguration.php:<?php declare(strict_types=1); namespace App\Entity; use App\Repository\MailConfigurationRepository; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: MailConfigurationRepository::class)] #[ORM\Table(name: 'mail_configuration')] class MailConfiguration { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] private ?int $id = null; #[ORM\Column(length: 10)] private string $protocol = 'imap'; #[ORM\Column(length: 255, nullable: true)] private ?string $imapHost = null; #[ORM\Column] private int $imapPort = 993; #[ORM\Column(length: 10)] private string $imapEncryption = 'ssl'; #[ORM\Column(length: 255, nullable: true)] private ?string $smtpHost = null; #[ORM\Column] private int $smtpPort = 465; #[ORM\Column(length: 10)] private string $smtpEncryption = 'ssl'; #[ORM\Column(length: 255, nullable: true)] private ?string $username = null; #[ORM\Column(type: 'text', nullable: true)] private ?string $encryptedPassword = null; #[ORM\Column(length: 255)] private string $sentFolderPath = 'Sent'; #[ORM\Column(type: 'boolean')] private bool $enabled = false; public function getId(): ?int { return $this->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
MailConfigurationRepositoryCréer
src/Repository/MailConfigurationRepository.php:<?php declare(strict_types=1); namespace App\Repository; use App\Entity\MailConfiguration; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; class MailConfigurationRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) { parent::__construct($registry, MailConfiguration::class); } public function findSingleton(): ?MailConfiguration { return $this->createQueryBuilder('m') ->setMaxResults(1) ->getQuery() ->getOneOrNullResult() ; } } -
Step 5 : Lancer le test pour vérifier qu'il passe
docker exec php-lesstime-fpm php bin/phpunit tests/Unit/Repository/MailConfigurationRepositoryTest.php 2>&1 | tail -20Attendu :
OK (2 tests, 2 assertions).Note : si le test
testFindSingletonReturnsNullWhenEmptyéchoue car des données persistent entre tests, vérifier queDAMA\DoctrineTestBundle\Doctrine\DBAL\StaticDriverest bien configuré dansconfig/packages/test/dama_doctrine_test_bundle.yaml. Si non configuré, ajouter le@runInSeparateProcessen dernier recours — mais normalement le projet utilise déjà DAMA. -
Step 6 : Commit
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é
MailFolderCréer
src/Entity/MailFolder.php:<?php declare(strict_types=1); namespace App\Entity; use App\Repository\MailFolderRepository; use DateTimeImmutable; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: MailFolderRepository::class)] #[ORM\Table(name: 'mail_folder')] #[ORM\Index(columns: ['parent_path'], name: 'idx_mail_folder_parent_path')] class MailFolder { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] private ?int $id = null; #[ORM\Column(length: 500, unique: true)] private string $path; #[ORM\Column(length: 255)] private string $displayName; #[ORM\Column(length: 500, nullable: true)] private ?string $parentPath = null; #[ORM\Column] private int $unreadCount = 0; #[ORM\Column] private int $totalCount = 0; #[ORM\Column(type: 'datetimetz_immutable', nullable: true)] private ?DateTimeImmutable $lastSyncedAt = null; public function getId(): ?int { return $this->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
MailFolderRepositoryCréer
src/Repository/MailFolderRepository.php:<?php declare(strict_types=1); namespace App\Repository; use App\Entity\MailFolder; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; class MailFolderRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) { parent::__construct($registry, MailFolder::class); } /** * @return list<MailFolder> */ 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
docker exec php-lesstime-fpm php -l src/Entity/MailFolder.php docker exec php-lesstime-fpm php -l src/Repository/MailFolderRepository.phpAttendu :
No syntax errors detected. -
Step 4 : Commit
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é
MailMessageCréer
src/Entity/MailMessage.php:<?php declare(strict_types=1); namespace App\Entity; use App\Repository\MailMessageRepository; use DateTimeImmutable; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: MailMessageRepository::class)] #[ORM\Table(name: 'mail_message')] #[ORM\UniqueConstraint(name: 'uq_mail_message_folder_uid', columns: ['folder_id', 'uid'])] #[ORM\Index(columns: ['sent_at'], name: 'idx_mail_message_sent_at')] #[ORM\Index(columns: ['is_read'], name: 'idx_mail_message_is_read')] class MailMessage { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] private ?int $id = null; #[ORM\Column(length: 500, unique: true)] private string $messageId; #[ORM\ManyToOne(targetEntity: MailFolder::class)] #[ORM\JoinColumn(name: 'folder_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] private MailFolder $folder; #[ORM\Column] private int $uid; #[ORM\Column(length: 500, nullable: true)] private ?string $subject = null; #[ORM\Column(length: 255)] private string $fromAddress; #[ORM\Column(length: 255, nullable: true)] private ?string $fromName = null; #[ORM\Column(type: 'json')] private array $toAddresses = []; #[ORM\Column(type: 'json', nullable: true)] private ?array $ccAddresses = null; #[ORM\Column(type: 'datetimetz_immutable')] private DateTimeImmutable $sentAt; #[ORM\Column(type: 'boolean')] private bool $isRead = false; #[ORM\Column(type: 'boolean')] private bool $isFlagged = false; #[ORM\Column(type: 'boolean')] private bool $hasAttachments = false; #[ORM\Column(type: 'text', nullable: true)] private ?string $snippet = null; #[ORM\Column(type: 'datetimetz_immutable')] private DateTimeImmutable $syncedAt; public function getId(): ?int { return $this->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
MailMessageRepositoryCréer
src/Repository/MailMessageRepository.php:<?php declare(strict_types=1); namespace App\Repository; use App\Entity\MailFolder; use App\Entity\MailMessage; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; class MailMessageRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) { parent::__construct($registry, MailMessage::class); } public function findByMessageId(string $messageId): ?MailMessage { return $this->findOneBy(['messageId' => $messageId]); } public function findByFolderAndUid(MailFolder $folder, int $uid): ?MailMessage { return $this->findOneBy(['folder' => $folder, 'uid' => $uid]); } /** * @return list<MailMessage> */ 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
docker exec php-lesstime-fpm php -l src/Entity/MailMessage.php docker exec php-lesstime-fpm php -l src/Repository/MailMessageRepository.phpAttendu :
No syntax errors detected. -
Step 4 : Commit
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é
TaskMailLinkCréer
src/Entity/TaskMailLink.php:<?php declare(strict_types=1); namespace App\Entity; use App\Repository\TaskMailLinkRepository; use DateTimeImmutable; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: TaskMailLinkRepository::class)] #[ORM\Table(name: 'task_mail_link')] #[ORM\UniqueConstraint(name: 'uq_task_mail_link', columns: ['task_id', 'mail_message_id'])] class TaskMailLink { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] private ?int $id = null; #[ORM\ManyToOne(targetEntity: Task::class)] #[ORM\JoinColumn(name: 'task_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] private Task $task; #[ORM\ManyToOne(targetEntity: MailMessage::class)] #[ORM\JoinColumn(name: 'mail_message_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] private MailMessage $mailMessage; #[ORM\Column(type: 'datetimetz_immutable')] private DateTimeImmutable $linkedAt; #[ORM\ManyToOne(targetEntity: User::class)] #[ORM\JoinColumn(name: 'linked_by_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] private ?User $linkedBy = null; public function getId(): ?int { return $this->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
TaskMailLinkRepositoryCréer
src/Repository/TaskMailLinkRepository.php:<?php declare(strict_types=1); namespace App\Repository; use App\Entity\MailMessage; use App\Entity\Task; use App\Entity\TaskMailLink; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; class TaskMailLinkRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) { parent::__construct($registry, TaskMailLink::class); } /** * @return list<TaskMailLink> */ 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<TaskMailLink> */ public function findByMessage(MailMessage $message): array { return $this->findBy(['mailMessage' => $message]); } } -
Step 3 : Vérifier la syntaxe PHP
docker exec php-lesstime-fpm php -l src/Entity/TaskMailLink.php docker exec php-lesstime-fpm php -l src/Repository/TaskMailLinkRepository.phpAttendu :
No syntax errors detected. -
Step 4 : Commit
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
docker exec php-lesstime-fpm php bin/console doctrine:migrations:generateCela crée
migrations/Version<timestamp>.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<timestamp>.phpet remplacer les méthodesup()etdown()par :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
make migration-migrateAttendu : migration exécutée sans erreur SQL.
-
Step 4 : Valider le schéma Doctrine
docker exec php-lesstime-fpm php bin/console doctrine:schema:validateAttendu :
[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
jsonvsjsonb), ajuster l'attribut#[ORM\Column(type: 'json')]en#[ORM\Column(type: 'jsonb')]dansMailMessage.phpsi le mapping Doctrine du projet est configuré pourjsonb. Dans le doute,jsonest le type Doctrine standard et PostgreSQL accepte les deux. -
Step 5 : Commit
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
MailFolderDtoCréer
src/Mail/Dto/MailFolderDto.php:<?php declare(strict_types=1); namespace App\Mail\Dto; final readonly class MailFolderDto { public function __construct( public string $path, public string $displayName, public ?string $parentPath, public int $unreadCount, public int $totalCount, ) {} } -
Step 2 : Créer
MailMessageHeaderDtoCréer
src/Mail/Dto/MailMessageHeaderDto.php:<?php declare(strict_types=1); namespace App\Mail\Dto; use DateTimeImmutable; final readonly class MailMessageHeaderDto { public function __construct( public int $uid, public string $messageId, public ?string $subject, public string $fromAddress, public ?string $fromName, public array $toAddresses, public ?array $ccAddresses, public DateTimeImmutable $sentAt, public bool $isRead, public bool $isFlagged, public bool $hasAttachments, public ?string $snippet, ) {} } -
Step 3 : Créer
MailAttachmentDtoCréer
src/Mail/Dto/MailAttachmentDto.php:<?php declare(strict_types=1); namespace App\Mail\Dto; final readonly class MailAttachmentDto { public function __construct( public string $partNumber, public string $filename, public string $mimeType, public int $size, ) {} } -
Step 4 : Créer
MailMessageDetailDtoCréer
src/Mail/Dto/MailMessageDetailDto.php:<?php declare(strict_types=1); namespace App\Mail\Dto; final readonly class MailMessageDetailDto { /** * @param list<MailAttachmentDto> $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
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.phpAttendu :
No syntax errors detectedpour chacun. -
Step 6 : Commit
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
MailProviderExceptionCréer
src/Mail/Exception/MailProviderException.php:<?php declare(strict_types=1); namespace App\Mail\Exception; use RuntimeException; final class MailProviderException extends RuntimeException { public static function connectionFailed(string $reason): self { return new self(sprintf('Mail provider connection failed: %s', $reason)); } public static function operationFailed(string $operation, string $reason): self { return new self(sprintf('Mail provider operation "%s" failed: %s', $operation, $reason)); } } -
Step 2 : Créer
MailProviderInterfaceCréer
src/Mail/MailProviderInterface.php:<?php declare(strict_types=1); namespace App\Mail; use App\Mail\Dto\MailAttachmentDto; use App\Mail\Dto\MailFolderDto; use App\Mail\Dto\MailMessageDetailDto; use App\Mail\Dto\MailMessageHeaderDto; use App\Mail\Exception\MailProviderException; interface MailProviderInterface { /** * Returns the full folder tree of the configured mailbox. * * @return list<MailFolderDto> * * @throws MailProviderException */ public function listFolders(): array; /** * Returns a paginated list of message headers for the given folder. * * @return list<MailMessageHeaderDto> * * @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
docker exec php-lesstime-fpm php -l src/Mail/Exception/MailProviderException.php docker exec php-lesstime-fpm php -l src/Mail/MailProviderInterface.phpAttendu :
No syntax errors detected. -
Step 4 : Commit
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.phpOuvrir
src/DataFixtures/AppFixtures.php.Ajouter l'import en tête du bloc
use(aprèsuse App\Entity\ZimbraConfiguration;) :use App\Entity\MailConfiguration;Puis ajouter le bloc fixture juste avant
$manager->flush();(après le bloc TaskRecurrence existant) :// ============================================= // 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
docker exec php-lesstime-fpm php -l src/DataFixtures/AppFixtures.phpAttendu :
No syntax errors detected. -
Step 3 : Charger les fixtures pour valider
make fixturesAttendu : pas d'erreur. Vérifier en BDD :
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
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
docker exec php-lesstime-fpm php bin/console doctrine:schema:validateAttendu :
[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
jsonvsjsonb, colonnes manquantes, etc.) avant de continuer. -
Step 2 : Lancer la suite de tests complète
make testAttendu : tous les tests passent (au minimum les 2 tests
MailConfigurationRepositoryTest). -
Step 3 : PHP CS Fixer sur l'ensemble des fichiers modifiés
make php-cs-fixer-allow-riskySi des fichiers sont modifiés par le fixer, les re-stageer et committer :
git add -p git commit -m "style(mail) : php-cs-fixer pass" -
Step 4 : Vider le cache Symfony
make cache-clearAttendu : pas d'erreur de configuration ou de service manquant.
-
Step 5 : Vérification rapide du conteneur de services
docker exec php-lesstime-fpm php bin/console debug:autowiring MailConfiguration 2>&1 | head -20Attendu :
App\Repository\MailConfigurationRepositoryapparaît dans la liste des services autowirables. -
Step 6 : Résumé des commits de la phase
Vérifier l'historique :
git log --oneline feat/mail-integration ^develop | head -20Les commits attendus (dans l'ordre chronologique) :
feat(mail) : MailConfiguration entity + repository + singleton testfeat(mail) : MailFolder entity + repositoryfeat(mail) : MailMessage entity + repositoryfeat(mail) : TaskMailLink entity + repositoryfeat(mail) : migration — 4 tables mail_configuration, mail_folder, mail_message, task_mail_linkfeat(mail) : DTOs — MailFolderDto, MailMessageHeaderDto, MailAttachmentDto, MailMessageDetailDtofeat(mail) : MailProviderInterface + MailProviderExceptionfeat(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(sauftask_mail_link.linked_by_id→ON DELETE SET NULL) UNIQUE INDEXsurmail_folder.path,mail_message.message_id,(mail_message.folder_id, mail_message.uid),(task_mail_link.task_id, task_mail_link.mail_message_id)INDEXsurmail_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.encryptedPasswordestnullpar défaut en fixture (pas de mot de passe en clair)- DTOs déclarés
final readonly class(PHP 8.2+) MailProviderInterfacecontient exactement 7 méthodes :listFolders,listMessages,fetchMessage,markRead,markFlagged,moveMessage,fetchAttachmentmake testvert (2 tests minimum)doctrine:schema:validateOK- Aucune logique métier IMAP implémentée (Phase 2)
feat/mail-integrationest la branche de travail (pasdevelop)
Remise à la review humaine
Une fois tous les steps cochés, pousser la branche et notifier le user :
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