1633 lines
44 KiB
Markdown
1633 lines
44 KiB
Markdown
# 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**
|
|
|
|
```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
|
|
<?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**
|
|
|
|
```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
|
|
<?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 `MailConfigurationRepository`**
|
|
|
|
Créer `src/Repository/MailConfigurationRepository.php` :
|
|
|
|
```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**
|
|
|
|
```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
|
|
<?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 `MailFolderRepository`**
|
|
|
|
Créer `src/Repository/MailFolderRepository.php` :
|
|
|
|
```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**
|
|
|
|
```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
|
|
<?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 `MailMessageRepository`**
|
|
|
|
Créer `src/Repository/MailMessageRepository.php` :
|
|
|
|
```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**
|
|
|
|
```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
|
|
<?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 `TaskMailLinkRepository`**
|
|
|
|
Créer `src/Repository/TaskMailLinkRepository.php` :
|
|
|
|
```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**
|
|
|
|
```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<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>.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
|
|
<?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 `MailMessageHeaderDto`**
|
|
|
|
Créer `src/Mail/Dto/MailMessageHeaderDto.php` :
|
|
|
|
```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 `MailAttachmentDto`**
|
|
|
|
Créer `src/Mail/Dto/MailAttachmentDto.php` :
|
|
|
|
```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 `MailMessageDetailDto`**
|
|
|
|
Créer `src/Mail/Dto/MailMessageDetailDto.php` :
|
|
|
|
```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**
|
|
|
|
```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
|
|
<?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 `MailProviderInterface`**
|
|
|
|
Créer `src/Mail/MailProviderInterface.php` :
|
|
|
|
```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**
|
|
|
|
```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
|