feat(mail) : migrate Mail integration into module (back)

LST-67 (2.5) backend. Behaviour-preserving move of the IMAP mail integration
into src/Module/Mail/. All /api/mail/* routes, securities (ROLE_CLIENT still
excluded via MailAccessChecker) and the async sync are unchanged.

- 4 entities + 4 repositories (Domain interfaces + Doctrine impls, bound).
  TaskMailLink.task now references TaskInterface (contract) instead of the
  concrete PM Task. Link/unlink/list-mails controllers load tasks via
  TaskRepositoryInterface; MailCreateTaskController keeps the concrete Task
  (instantiation) — documented Mail->PM coupling.
- Domain (MailProviderInterface, exception), Application (5 DTOs, MailSyncService,
  MailSyncRequested message + handler), Infrastructure (ImapMailProvider +
  MimeHeaderDecoder, MailAccessChecker, 2 console commands, 12 controllers,
  ApiPlatform state + MailSettings resource). TokenEncryptor stays shared.
- doctrine mapping Mail; messenger routing repointed; services.yaml repo +
  provider bindings; MailModule registered (id mail, mail.access/configure).
- #[Auditable] + Timestampable on MailConfiguration only (additive migration);
  IMAP data entities keep their own sync timestamps.

163 tests green, mapping valid, no route regression, cs-fixer clean.
This commit is contained in:
Matthieu
2026-06-20 19:44:19 +02:00
parent 57ccd9a740
commit 25d3a693f9
55 changed files with 453 additions and 209 deletions
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Module\Mail\Application\Dto;
final readonly class MailAttachmentDto
{
public function __construct(
public string $partNumber,
public string $filename,
public string $mimeType,
public int $size,
) {}
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Module\Mail\Application\Dto;
final readonly class MailFolderDto
{
public function __construct(
public string $path,
public string $displayName,
public ?string $parentPath,
public int $unreadCount,
public int $totalCount,
) {}
}
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Module\Mail\Application\Dto;
final readonly class MailMessageDetailDto
{
/**
* @param list<MailAttachmentDto> $attachments
*/
public function __construct(
public MailMessageHeaderDto $header,
public ?string $bodyHtml,
public ?string $bodyText,
public array $attachments,
) {}
}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Module\Mail\Application\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,
) {}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Module\Mail\Application\Dto;
use DateTimeImmutable;
final readonly class MailSyncReport
{
/**
* @param list<string> $errors
*/
public function __construct(
public int $createdCount,
public int $updatedCount,
public int $deletedCount,
public int $foldersScanned,
public array $errors,
public float $durationSeconds,
public DateTimeImmutable $startedAt,
public DateTimeImmutable $finishedAt,
) {}
}
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Module\Mail\Application\Message;
final readonly class MailSyncRequested
{
public function __construct(
public ?string $folderPath = null,
) {}
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Module\Mail\Application\MessageHandler;
use App\Module\Mail\Application\Message\MailSyncRequested;
use App\Module\Mail\Application\Service\MailSyncService;
use App\Module\Mail\Domain\Repository\MailFolderRepositoryInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Throwable;
#[AsMessageHandler]
final readonly class MailSyncRequestedHandler
{
public function __construct(
private MailSyncService $mailSyncService,
private MailFolderRepositoryInterface $folderRepository,
private LoggerInterface $logger,
) {}
public function __invoke(MailSyncRequested $message): void
{
try {
if (null !== $message->folderPath) {
$folder = $this->folderRepository->findByPath($message->folderPath);
if (null !== $folder) {
$report = $this->mailSyncService->syncFolder($folder);
$this->logger->info(sprintf(
'MailSyncRequested handled for folder "%s": %d created, %d updated, %d deleted',
$message->folderPath,
$report->createdCount,
$report->updatedCount,
$report->deletedCount,
));
} else {
$this->logger->warning(sprintf('MailSyncRequested: folder "%s" not found in DB', $message->folderPath));
}
} else {
$report = $this->mailSyncService->syncAll();
$this->logger->info(sprintf(
'MailSyncRequested handled (all folders): %d created, %d updated, %d deleted, %d folders scanned',
$report->createdCount,
$report->updatedCount,
$report->deletedCount,
$report->foldersScanned,
));
}
} catch (Throwable $e) {
$this->logger->error('MailSyncRequestedHandler failed: '.$e->getMessage());
}
}
}
@@ -0,0 +1,347 @@
<?php
declare(strict_types=1);
namespace App\Module\Mail\Application\Service;
use App\Module\Mail\Application\Dto\MailSyncReport;
use App\Module\Mail\Domain\Entity\MailFolder;
use App\Module\Mail\Domain\Entity\MailMessage;
use App\Module\Mail\Domain\Exception\MailProviderException;
use App\Module\Mail\Domain\Provider\MailProviderInterface;
use App\Module\Mail\Domain\Repository\MailConfigurationRepositoryInterface;
use App\Module\Mail\Domain\Repository\MailFolderRepositoryInterface;
use App\Module\Mail\Domain\Repository\MailMessageRepositoryInterface;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;
use Psr\Log\LoggerInterface;
use Symfony\Component\Lock\LockFactory;
use Throwable;
final class MailSyncService
{
private const int FLAGS_RESYNC_LIMIT = 200;
private const string LOCK_NAME = 'mail.sync';
private const float LOCK_TTL = 600.0;
public function __construct(
private readonly MailProviderInterface $provider,
private readonly MailConfigurationRepositoryInterface $configRepository,
private readonly MailFolderRepositoryInterface $folderRepository,
private readonly MailMessageRepositoryInterface $messageRepository,
private readonly EntityManagerInterface $entityManager,
private readonly LockFactory $lockFactory,
private readonly LoggerInterface $logger,
private readonly ManagerRegistry $managerRegistry,
) {}
public function syncAll(): MailSyncReport
{
$startedAt = new DateTimeImmutable();
$config = $this->configRepository->findSingleton();
if (null === $config || !$config->isEnabled()) {
$this->logger->info('mail.sync skipped: mail config is disabled or missing');
return $this->emptyReport($startedAt, []);
}
$lock = $this->lockFactory->createLock(self::LOCK_NAME, ttl: self::LOCK_TTL, autoRelease: true);
if (!$lock->acquire()) {
$this->logger->info('mail.sync skipped: another sync in progress');
return $this->emptyReport($startedAt, ['lock_not_acquired']);
}
try {
return $this->doSyncAll($startedAt);
} finally {
$this->provider->closeConnection();
$lock->release();
}
}
public function syncFolderStructure(): void
{
try {
$remoteFolders = $this->provider->listFolders();
} catch (MailProviderException $e) {
$this->logger->error('syncFolderStructure: listFolders failed: '.$e->getMessage());
return;
}
$remotePathSet = [];
foreach ($remoteFolders as $dto) {
$remotePathSet[$dto->path] = true;
$folder = $this->folderRepository->findByPath($dto->path);
if (null === $folder) {
$folder = new MailFolder();
$folder->setPath($dto->path);
}
$folder->setDisplayName($dto->displayName);
$folder->setParentPath($dto->parentPath);
$folder->setUnreadCount($dto->unreadCount);
$folder->setTotalCount($dto->totalCount);
$this->entityManager->persist($folder);
}
$this->entityManager->flush();
$allDbFolders = $this->folderRepository->findAllOrderedByPath();
foreach ($allDbFolders as $dbFolder) {
if (!isset($remotePathSet[$dbFolder->getPath()])) {
$this->logger->warning(sprintf(
'syncFolderStructure: folder "%s" no longer exists on server — keeping in DB for safety',
$dbFolder->getPath()
));
}
}
}
public function syncFolder(MailFolder $folder): MailSyncReport
{
$startedAt = new DateTimeImmutable();
$createdCount = 0;
$updatedCount = 0;
$deletedCount = 0;
$errors = [];
$remoteHeaders = null;
try {
$lastUid = $this->messageRepository->findMaxUidInFolder($folder);
$remoteHeaders = $this->provider->listMessages($folder->getPath(), limit: 5000, offset: 0);
foreach ($remoteHeaders as $header) {
if ($header->uid <= $lastUid) {
continue;
}
$existing = $this->messageRepository->findByFolderAndUid($folder, $header->uid);
if (null !== $existing) {
continue;
}
$message = new MailMessage();
$message->setFolder($folder);
$message->setUid($header->uid);
$message->setMessageId($header->messageId);
$message->setSubject($header->subject);
$message->setFromAddress($header->fromAddress);
$message->setFromName($header->fromName);
$message->setToAddresses($header->toAddresses);
$message->setCcAddresses($header->ccAddresses);
$message->setSentAt($header->sentAt);
$message->setIsRead($header->isRead);
$message->setIsFlagged($header->isFlagged);
$message->setHasAttachments($header->hasAttachments);
$message->setSnippet($header->snippet);
$message->setSyncedAt(new DateTimeImmutable());
$this->entityManager->persist($message);
++$createdCount;
}
$this->entityManager->flush();
} catch (MailProviderException $e) {
$this->logger->error(sprintf('syncFolder[%s] listMessages failed: %s', $folder->getPath(), $e->getMessage()));
$errors[] = $e->getMessage();
} catch (Throwable $e) {
$this->logger->error(sprintf('syncFolder[%s] unexpected error: %s', $folder->getPath(), $e->getMessage()));
$errors[] = $e->getMessage();
}
try {
$recentMessages = $this->messageRepository->findLastNByFolder($folder, self::FLAGS_RESYNC_LIMIT);
if (null !== $recentMessages && [] !== $recentMessages) {
$remoteByUid = [];
if (null !== $remoteHeaders) {
foreach ($remoteHeaders as $h) {
$remoteByUid[$h->uid] = $h;
}
}
foreach ($recentMessages as $dbMessage) {
$remote = $remoteByUid[$dbMessage->getUid()] ?? null;
if (null === $remote) {
continue;
}
$changed = false;
if ($dbMessage->isRead() !== $remote->isRead) {
$dbMessage->setIsRead($remote->isRead);
$changed = true;
}
if ($dbMessage->isFlagged() !== $remote->isFlagged) {
$dbMessage->setIsFlagged($remote->isFlagged);
$changed = true;
}
if ($changed) {
++$updatedCount;
}
}
$this->entityManager->flush();
}
} catch (Throwable $e) {
$this->logger->warning(sprintf('syncFolder[%s] flag resync failed: %s', $folder->getPath(), $e->getMessage()));
}
try {
$dbUids = $this->messageRepository->findAllUidsByFolder($folder);
if ([] !== $dbUids) {
if (null === $remoteHeaders) {
$remoteHeaders = $this->provider->listMessages($folder->getPath(), limit: 5000, offset: 0);
}
$remoteUidSet = [];
foreach ($remoteHeaders as $h) {
$remoteUidSet[$h->uid] = true;
}
$toDelete = array_filter($dbUids, static fn (int $uid) => !isset($remoteUidSet[$uid]));
$toDeleteCount = count($toDelete);
$dbTotal = count($dbUids);
if ($toDeleteCount > (int) ($dbTotal * 0.5)) {
$warningMsg = sprintf(
'syncFolder[%s] suppression guard triggered: %d/%d would be deleted (>50%%) — aborting deletions',
$folder->getPath(),
$toDeleteCount,
$dbTotal
);
$this->logger->warning($warningMsg);
$errors[] = $warningMsg;
} else {
foreach ($toDelete as $uid) {
$dbMessage = $this->messageRepository->findByFolderAndUid($folder, $uid);
if (null !== $dbMessage) {
$this->entityManager->remove($dbMessage);
++$deletedCount;
}
}
$this->entityManager->flush();
}
}
} catch (MailProviderException $e) {
$this->logger->error(sprintf('syncFolder[%s] deletion detection failed: %s', $folder->getPath(), $e->getMessage()));
$errors[] = $e->getMessage();
}
$finishedAt = new DateTimeImmutable();
return new MailSyncReport(
createdCount: $createdCount,
updatedCount: $updatedCount,
deletedCount: $deletedCount,
foldersScanned: 1,
errors: $errors,
durationSeconds: (float) ($finishedAt->getTimestamp() - $startedAt->getTimestamp()),
startedAt: $startedAt,
finishedAt: $finishedAt,
);
}
private function doSyncAll(DateTimeImmutable $startedAt): MailSyncReport
{
$this->syncFolderStructure();
$totalCreated = 0;
$totalUpdated = 0;
$totalDeleted = 0;
$totalFolders = 0;
$allErrors = [];
$folders = $this->folderRepository->findAllOrderedByPath();
foreach ($folders as $folder) {
try {
$report = $this->syncFolder($folder);
$totalCreated += $report->createdCount;
$totalUpdated += $report->updatedCount;
$totalDeleted += $report->deletedCount;
++$totalFolders;
$allErrors = array_merge($allErrors, $report->errors);
// A folder error can leave the reused IMAP connection in a bad
// selection state ("must be in SELECTED state", "empty response").
// Drop it so the next folder reconnects on a clean session.
if ([] !== $report->errors) {
$this->provider->closeConnection();
}
} catch (Throwable $e) {
$this->logger->error(sprintf('doSyncAll: syncFolder[%s] threw: %s', $folder->getPath(), $e->getMessage()));
$allErrors[] = $e->getMessage();
$this->provider->closeConnection();
}
// A failed flush closes the Doctrine EntityManager; without a reset
// every subsequent folder would fail with "EntityManager is closed".
// Reset it via the registry and stop the run cleanly — the next cron
// cycle resumes incrementally from where we left off.
if (!$this->entityManager->isOpen()) {
$this->logger->error('doSyncAll: EntityManager was closed mid-sync, resetting and aborting this run');
$this->managerRegistry->resetManager();
$allErrors[] = 'EntityManager closed mid-sync — run aborted, will resume next cycle';
break;
}
}
$finishedAt = new DateTimeImmutable();
$this->logger->info(sprintf(
'mail.sync done: %d created, %d updated, %d deleted, %d folders, %d errors, %.1fs',
$totalCreated,
$totalUpdated,
$totalDeleted,
$totalFolders,
count($allErrors),
(float) ($finishedAt->getTimestamp() - $startedAt->getTimestamp())
));
return new MailSyncReport(
createdCount: $totalCreated,
updatedCount: $totalUpdated,
deletedCount: $totalDeleted,
foldersScanned: $totalFolders,
errors: $allErrors,
durationSeconds: (float) ($finishedAt->getTimestamp() - $startedAt->getTimestamp()),
startedAt: $startedAt,
finishedAt: $finishedAt,
);
}
/**
* @param list<string> $errors
*/
private function emptyReport(DateTimeImmutable $startedAt, array $errors): MailSyncReport
{
$now = new DateTimeImmutable();
return new MailSyncReport(
createdCount: 0,
updatedCount: 0,
deletedCount: 0,
foldersScanned: 0,
errors: $errors,
durationSeconds: 0.0,
startedAt: $startedAt,
finishedAt: $now,
);
}
}
@@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
namespace App\Module\Mail\Domain\Entity;
use App\Module\Mail\Infrastructure\Doctrine\DoctrineMailConfigurationRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use Doctrine\ORM\Mapping as ORM;
#[Auditable]
#[ORM\Entity(repositoryClass: DoctrineMailConfigurationRepository::class)]
#[ORM\Table(name: 'mail_configuration')]
class MailConfiguration implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[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;
}
}
@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace App\Module\Mail\Domain\Entity;
use App\Module\Mail\Infrastructure\Doctrine\DoctrineMailFolderRepository;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: DoctrineMailFolderRepository::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;
}
}
@@ -0,0 +1,239 @@
<?php
declare(strict_types=1);
namespace App\Module\Mail\Domain\Entity;
use App\Module\Mail\Infrastructure\Doctrine\DoctrineMailMessageRepository;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: DoctrineMailMessageRepository::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')]
#[ORM\Index(columns: ['message_id'], name: 'idx_mail_message_message_id')]
class MailMessage
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 500)]
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;
}
}
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Module\Mail\Domain\Entity;
use App\Module\Mail\Infrastructure\Doctrine\DoctrineTaskMailLinkRepository;
use App\Shared\Domain\Contract\TaskInterface;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: DoctrineTaskMailLinkRepository::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: TaskInterface::class)]
#[ORM\JoinColumn(name: 'task_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private TaskInterface $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: UserInterface::class)]
#[ORM\JoinColumn(name: 'linked_by_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
private ?UserInterface $linkedBy = null;
public function getId(): ?int
{
return $this->id;
}
public function getTask(): TaskInterface
{
return $this->task;
}
public function setTask(TaskInterface $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(): ?UserInterface
{
return $this->linkedBy;
}
public function setLinkedBy(?UserInterface $linkedBy): static
{
$this->linkedBy = $linkedBy;
return $this;
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Module\Mail\Domain\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));
}
}
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Module\Mail\Domain\Provider;
use App\Module\Mail\Application\Dto\MailFolderDto;
use App\Module\Mail\Application\Dto\MailMessageDetailDto;
use App\Module\Mail\Application\Dto\MailMessageHeaderDto;
use App\Module\Mail\Domain\Exception\MailProviderException;
interface MailProviderInterface
{
/**
* Opens a connection using the stored configuration and returns the number
* of folders found. Used by the admin "test connection" endpoint, so it
* MUST work even when the configuration is not yet enabled.
*
* @throws MailProviderException
*/
public function testConnection(): int;
/**
* Releases any reused network connection held by the provider.
* Safe to call multiple times; a no-op if nothing is open.
*/
public function closeConnection(): void;
/**
* 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;
}
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Module\Mail\Domain\Repository;
use App\Module\Mail\Domain\Entity\MailConfiguration;
interface MailConfigurationRepositoryInterface
{
public function findSingleton(): ?MailConfiguration;
}
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Module\Mail\Domain\Repository;
use App\Module\Mail\Domain\Entity\MailFolder;
interface MailFolderRepositoryInterface
{
/**
* @return list<MailFolder>
*/
public function findAllOrderedByPath(): array;
public function findByPath(string $path): ?MailFolder;
}
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Module\Mail\Domain\Repository;
use App\Module\Mail\Domain\Entity\MailFolder;
use App\Module\Mail\Domain\Entity\MailMessage;
interface MailMessageRepositoryInterface
{
public function findById(int $id): ?MailMessage;
/**
* @return list<MailMessage>
*/
public function findAll(): array;
public function findByMessageId(string $messageId): ?MailMessage;
public function findByFolderAndUid(MailFolder $folder, int $uid): ?MailMessage;
/**
* @return list<MailMessage>
*/
public function findByFolderPaginated(MailFolder $folder, int $limit, int $offset): array;
public function countUnreadByFolder(MailFolder $folder): int;
public function findMaxUidInFolder(MailFolder $folder): int;
/**
* @return list<MailMessage>
*/
public function findLastNByFolder(MailFolder $folder, int $limit): array;
/**
* @return list<int>
*/
public function findAllUidsByFolder(MailFolder $folder): array;
/**
* Pagination cursor : retourne $limit messages apres le cursor (sentAt DESC, id DESC).
* Cursor format : base64url(sentAt_iso8601:id) - null pour la premiere page.
*
* @return array{messages: list<MailMessage>, nextCursor: ?string}
*/
public function findByFolderCursor(MailFolder $folder, int $limit, ?string $cursor): array;
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Module\Mail\Domain\Repository;
use App\Module\Mail\Domain\Entity\MailMessage;
use App\Module\Mail\Domain\Entity\TaskMailLink;
use App\Shared\Domain\Contract\TaskInterface;
interface TaskMailLinkRepositoryInterface
{
/**
* @return list<TaskMailLink>
*/
public function findByTask(TaskInterface $task): array;
public function findByTaskAndMessage(TaskInterface $task, MailMessage $message): ?TaskMailLink;
/**
* @return list<TaskMailLink>
*/
public function findByMessage(MailMessage $message): array;
}
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Module\Mail\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Patch;
use App\Module\Mail\Infrastructure\ApiPlatform\State\MailSettingsProcessor;
use App\Module\Mail\Infrastructure\ApiPlatform\State\MailSettingsProvider;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/mail/configuration',
normalizationContext: ['groups' => ['mail_settings:read']],
provider: MailSettingsProvider::class,
security: "is_granted('ROLE_ADMIN')",
),
new Patch(
uriTemplate: '/mail/configuration',
denormalizationContext: ['groups' => ['mail_settings:write']],
normalizationContext: ['groups' => ['mail_settings:read']],
provider: MailSettingsProvider::class,
processor: MailSettingsProcessor::class,
security: "is_granted('ROLE_ADMIN')",
),
],
)]
final class MailSettings
{
#[Groups(['mail_settings:read', 'mail_settings:write'])]
public ?string $protocol = null;
#[Groups(['mail_settings:read', 'mail_settings:write'])]
public ?string $imapHost = null;
#[Groups(['mail_settings:read', 'mail_settings:write'])]
public ?int $imapPort = null;
#[Groups(['mail_settings:read', 'mail_settings:write'])]
public ?string $imapEncryption = null;
#[Groups(['mail_settings:read', 'mail_settings:write'])]
public ?string $smtpHost = null;
#[Groups(['mail_settings:read', 'mail_settings:write'])]
public ?int $smtpPort = null;
#[Groups(['mail_settings:read', 'mail_settings:write'])]
public ?string $smtpEncryption = null;
#[Groups(['mail_settings:read', 'mail_settings:write'])]
public ?string $username = null;
#[Groups(['mail_settings:write'])]
public ?string $password = null;
#[Groups(['mail_settings:read', 'mail_settings:write'])]
public ?string $sentFolderPath = null;
#[Groups(['mail_settings:read', 'mail_settings:write'])]
public bool $enabled = false;
#[Groups(['mail_settings:read'])]
public bool $hasPassword = false;
}
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Module\Mail\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Mail\Domain\Entity\MailConfiguration;
use App\Module\Mail\Domain\Repository\MailConfigurationRepositoryInterface;
use App\Module\Mail\Infrastructure\ApiPlatform\Resource\MailSettings;
use App\Service\TokenEncryptor;
use Doctrine\ORM\EntityManagerInterface;
final readonly class MailSettingsProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $em,
private MailConfigurationRepositoryInterface $configRepository,
private TokenEncryptor $tokenEncryptor,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): MailSettings
{
assert($data instanceof MailSettings);
$config = $this->configRepository->findSingleton();
if (null === $config) {
$config = new MailConfiguration();
}
if (null !== $data->protocol) {
$config->setProtocol($data->protocol);
}
if (null !== $data->imapHost) {
$config->setImapHost($data->imapHost);
}
if (null !== $data->imapPort) {
$config->setImapPort($data->imapPort);
}
if (null !== $data->imapEncryption) {
$config->setImapEncryption($data->imapEncryption);
}
if (null !== $data->smtpHost) {
$config->setSmtpHost($data->smtpHost);
}
if (null !== $data->smtpPort) {
$config->setSmtpPort($data->smtpPort);
}
if (null !== $data->smtpEncryption) {
$config->setSmtpEncryption($data->smtpEncryption);
}
if (null !== $data->username) {
$config->setUsername($data->username);
}
if (null !== $data->sentFolderPath) {
$config->setSentFolderPath($data->sentFolderPath);
}
$config->setEnabled($data->enabled);
if (null !== $data->password && '' !== $data->password) {
$config->setEncryptedPassword($this->tokenEncryptor->encrypt($data->password));
}
$this->em->persist($config);
$this->em->flush();
$result = new MailSettings();
$result->protocol = $config->getProtocol();
$result->imapHost = $config->getImapHost();
$result->imapPort = $config->getImapPort();
$result->imapEncryption = $config->getImapEncryption();
$result->smtpHost = $config->getSmtpHost();
$result->smtpPort = $config->getSmtpPort();
$result->smtpEncryption = $config->getSmtpEncryption();
$result->username = $config->getUsername();
$result->sentFolderPath = $config->getSentFolderPath();
$result->enabled = $config->isEnabled();
$result->hasPassword = $config->hasPassword();
return $result;
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Module\Mail\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Module\Mail\Domain\Repository\MailConfigurationRepositoryInterface;
use App\Module\Mail\Infrastructure\ApiPlatform\Resource\MailSettings;
final readonly class MailSettingsProvider implements ProviderInterface
{
public function __construct(
private MailConfigurationRepositoryInterface $configRepository,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): MailSettings
{
$config = $this->configRepository->findSingleton();
$dto = new MailSettings();
if (null !== $config) {
$dto->protocol = $config->getProtocol();
$dto->imapHost = $config->getImapHost();
$dto->imapPort = $config->getImapPort();
$dto->imapEncryption = $config->getImapEncryption();
$dto->smtpHost = $config->getSmtpHost();
$dto->smtpPort = $config->getSmtpPort();
$dto->smtpEncryption = $config->getSmtpEncryption();
$dto->username = $config->getUsername();
$dto->sentFolderPath = $config->getSentFolderPath();
$dto->enabled = $config->isEnabled();
$dto->hasPassword = $config->hasPassword();
}
return $dto;
}
}
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Module\Mail\Infrastructure\Console;
use App\Module\Mail\Domain\Repository\MailMessageRepositoryInterface;
use App\Module\Mail\Infrastructure\Imap\MimeHeaderDecoder;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:mail:redecode-headers',
description: 'Re-décode les sujets et noms d\'expéditeur encodés en MIME (RFC 2047) déjà stockés en base',
)]
final class MailRedecodeHeadersCommand extends Command
{
public function __construct(
private readonly MailMessageRepositoryInterface $messageRepository,
private readonly EntityManagerInterface $entityManager,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption(
'dry-run',
null,
InputOption::VALUE_NONE,
'Affiche les changements sans écrire en base',
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$isDryRun = (bool) $input->getOption('dry-run');
$messages = $this->messageRepository->findAll();
$io->text(sprintf('%d message(s) à examiner...', count($messages)));
$changed = 0;
foreach ($messages as $message) {
$newSubject = MimeHeaderDecoder::decode($message->getSubject());
$newFromName = MimeHeaderDecoder::decode($message->getFromName());
$hasChange = $newSubject !== $message->getSubject() || $newFromName !== $message->getFromName();
if (!$hasChange) {
continue;
}
if ($io->isVerbose()) {
$io->text(sprintf(' - #%d : "%s" → "%s"', $message->getId(), (string) $message->getSubject(), (string) $newSubject));
}
if (!$isDryRun) {
$message->setSubject($newSubject);
$message->setFromName($newFromName);
}
++$changed;
}
if (!$isDryRun) {
$this->entityManager->flush();
}
$io->success(sprintf(
'%s%d en-tête(s) re-décodé(s).',
$isDryRun ? '[dry-run] ' : '',
$changed,
));
return Command::SUCCESS;
}
}
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\Module\Mail\Infrastructure\Console;
use App\Module\Mail\Application\Service\MailSyncService;
use App\Module\Mail\Domain\Repository\MailConfigurationRepositoryInterface;
use App\Module\Mail\Domain\Repository\MailFolderRepositoryInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:mail:sync',
description: 'Synchronise la boîte mail partagée OVH (IMAP) vers la base locale',
)]
final class MailSyncCommand extends Command
{
public function __construct(
private readonly MailSyncService $mailSyncService,
private readonly MailConfigurationRepositoryInterface $configRepository,
private readonly MailFolderRepositoryInterface $folderRepository,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption(
'folder',
null,
InputOption::VALUE_OPTIONAL,
'Synchronise uniquement le dossier spécifié (ex: INBOX)',
)
->addOption(
'dry-run',
null,
InputOption::VALUE_NONE,
'Simule la synchronisation sans écrire en base (lecture IMAP uniquement)',
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$config = $this->configRepository->findSingleton();
if (null === $config || !$config->isEnabled()) {
$io->info('Mail config disabled, skipping.');
return Command::SUCCESS;
}
$isDryRun = (bool) $input->getOption('dry-run');
$folderPath = $input->getOption('folder');
if ($isDryRun) {
$io->note('Mode --dry-run activé : aucune écriture en base.');
$io->success('Dry-run terminé — config IMAP active, aucune sync exécutée.');
return Command::SUCCESS;
}
$io->text('Démarrage de la synchronisation mail...');
$startTime = microtime(true);
if (null !== $folderPath) {
$folder = $this->folderRepository->findByPath((string) $folderPath);
if (null === $folder) {
$io->error(sprintf('Dossier "%s" introuvable en base — lance une sync complète au moins une fois.', $folderPath));
return Command::FAILURE;
}
$io->text(sprintf('Synchronisation du dossier : %s', $folderPath));
$report = $this->mailSyncService->syncFolder($folder);
} else {
$report = $this->mailSyncService->syncAll();
}
$elapsed = round(microtime(true) - $startTime, 2);
$io->success(sprintf(
'Sync terminée en %.1fs : %d créés, %d mis à jour, %d supprimés, %d dossiers scannés.',
$elapsed,
$report->createdCount,
$report->updatedCount,
$report->deletedCount,
$report->foldersScanned,
));
if ([] !== $report->errors) {
$io->warning(sprintf('%d erreur(s) :', count($report->errors)));
foreach ($report->errors as $error) {
$io->text(' - '.$error);
}
}
return [] === $report->errors ? Command::SUCCESS : Command::FAILURE;
}
}
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Module\Mail\Infrastructure\Controller;
use App\Module\Mail\Domain\Exception\MailProviderException;
use App\Module\Mail\Domain\Provider\MailProviderInterface;
use App\Module\Mail\Domain\Repository\MailMessageRepositoryInterface;
use App\Module\Mail\Infrastructure\Security\MailAccessChecker;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api/mail/attachments/{downloadId}', name: 'mail_attachment_download', methods: ['GET'], priority: 1)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class MailAttachmentDownloadController extends AbstractController
{
public function __construct(
private readonly MailMessageRepositoryInterface $messageRepository,
private readonly MailProviderInterface $mailProvider,
private readonly MailAccessChecker $accessChecker,
) {}
public function __invoke(string $downloadId): Response
{
$this->accessChecker->ensureCanAccessMail($this->getUser());
$decoded = base64_decode(strtr($downloadId, '-_', '+/'), true);
if (false === $decoded || !str_contains($decoded, ':')) {
throw new BadRequestHttpException('Invalid attachment ID format');
}
[$messageDbIdStr, $partNumber] = explode(':', $decoded, 2);
$messageDbId = (int) $messageDbIdStr;
$message = $this->messageRepository->findById($messageDbId);
if (null === $message) {
throw new NotFoundHttpException('Message not found');
}
try {
$detail = $this->mailProvider->fetchMessage(
$message->getFolder()->getPath(),
$message->getUid()
);
} catch (MailProviderException) {
throw new NotFoundHttpException('Could not fetch message from IMAP server');
}
$targetAttachment = null;
foreach ($detail->attachments as $att) {
if ($att->partNumber === $partNumber) {
$targetAttachment = $att;
break;
}
}
if (null === $targetAttachment) {
throw new NotFoundHttpException(sprintf('Attachment part "%s" not found', $partNumber));
}
try {
$content = $this->mailProvider->fetchAttachment(
$message->getFolder()->getPath(),
$message->getUid(),
$partNumber
);
} catch (MailProviderException) {
throw new NotFoundHttpException('Could not fetch attachment content');
}
$filename = basename($targetAttachment->filename);
if ('' === $filename || '.' === $filename) {
$filename = 'attachment';
}
$response = new Response($content);
$response->headers->set('Content-Type', $targetAttachment->mimeType);
$response->headers->set(
'Content-Disposition',
sprintf('attachment; filename="%s"', addslashes($filename))
);
$response->headers->set('Content-Length', (string) strlen($content));
$response->headers->set('X-Content-Type-Options', 'nosniff');
return $response;
}
}
@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace App\Module\Mail\Infrastructure\Controller;
use App\Module\Core\Domain\Entity\User;
use App\Module\Mail\Domain\Entity\TaskMailLink;
use App\Module\Mail\Domain\Repository\MailMessageRepositoryInterface;
use App\Module\Mail\Infrastructure\Security\MailAccessChecker;
use App\Module\ProjectManagement\Domain\Entity\Project;
use App\Module\ProjectManagement\Domain\Entity\Task;
use App\Module\ProjectManagement\Domain\Entity\TaskGroup;
use App\Module\ProjectManagement\Domain\Entity\TaskStatus;
use App\Module\ProjectManagement\Domain\Repository\TaskRepositoryInterface;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api/mail/messages/{id}/create-task', name: 'mail_create_task', methods: ['POST'], priority: 1, requirements: ['id' => '\d+'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class MailCreateTaskController extends AbstractController
{
public function __construct(
private readonly MailMessageRepositoryInterface $messageRepository,
private readonly EntityManagerInterface $em,
private readonly MailAccessChecker $accessChecker,
private readonly TaskRepositoryInterface $taskRepository,
) {}
public function __invoke(Request $request, int $id): JsonResponse
{
$this->accessChecker->ensureCanAccessMail($this->getUser());
$message = $this->messageRepository->findById($id);
if (null === $message) {
throw new NotFoundHttpException('Message not found');
}
$body = json_decode($request->getContent(), true);
$projectId = $body['projectId'] ?? null;
if (null === $projectId) {
throw new UnprocessableEntityHttpException('projectId is required');
}
$project = $this->em->getRepository(Project::class)->find($projectId);
if (null === $project) {
throw new NotFoundHttpException('Project not found');
}
$title = $message->getSubject() ?? 'Mail sans sujet';
if (mb_strlen($title) > 255) {
$title = mb_substr($title, 0, 252).'...';
}
$result = $this->em->wrapInTransaction(function () use ($project, $title, $body, $message) {
$maxNumber = $this->taskRepository->findMaxNumberByProjectForUpdate($project);
$task = new Task();
$task->setProject($project);
$task->setTitle($title);
$task->setNumber($maxNumber + 1);
if (isset($body['taskGroupId']) && null !== $body['taskGroupId']) {
$taskGroup = $this->em->getRepository(TaskGroup::class)->find($body['taskGroupId']);
if (null !== $taskGroup) {
$task->setGroup($taskGroup);
}
}
if (isset($body['assigneeId']) && null !== $body['assigneeId']) {
$assignee = $this->em->getRepository(User::class)->find($body['assigneeId']);
if (null !== $assignee) {
$task->setAssignee($assignee);
}
}
// Statut : celui fourni, sinon le premier statut du workflow du projet (par position)
$status = null;
if (isset($body['statusId']) && null !== $body['statusId']) {
$status = $this->em->getRepository(TaskStatus::class)->find($body['statusId']);
}
if (null === $status) {
$status = $project->getWorkflow()?->getStatuses()->first() ?: null;
}
if (null !== $status) {
$task->setStatus($status);
}
$this->em->persist($task);
$link = new TaskMailLink();
$link->setTask($task);
$link->setMailMessage($message);
$link->setLinkedAt(new DateTimeImmutable());
$link->setLinkedBy($this->getUser());
$this->em->persist($link);
$this->em->flush();
return $task;
});
return $this->json([
'taskId' => $result->getId(),
'taskNumber' => $result->getNumber(),
'taskTitle' => $result->getTitle(),
'messageId' => $message->getId(),
], 201);
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Module\Mail\Infrastructure\Controller;
use App\Module\Mail\Domain\Repository\MailFolderRepositoryInterface;
use App\Module\Mail\Infrastructure\Security\MailAccessChecker;
use DateTimeInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api/mail/folders', name: 'mail_folders_list', methods: ['GET'], priority: 1)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class MailFoldersListController extends AbstractController
{
public function __construct(
private readonly MailFolderRepositoryInterface $folderRepository,
private readonly MailAccessChecker $accessChecker,
) {}
public function __invoke(): JsonResponse
{
$this->accessChecker->ensureCanAccessMail($this->getUser());
$folders = $this->folderRepository->findAllOrderedByPath();
$data = array_map(static fn ($folder) => [
'id' => $folder->getId(),
'path' => $folder->getPath(),
'displayName' => $folder->getDisplayName(),
'parentPath' => $folder->getParentPath(),
'unreadCount' => $folder->getUnreadCount(),
'totalCount' => $folder->getTotalCount(),
'lastSyncedAt' => $folder->getLastSyncedAt()?->format(DateTimeInterface::ATOM),
], $folders);
return $this->json($data);
}
}
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Module\Mail\Infrastructure\Controller;
use App\Module\Mail\Domain\Entity\TaskMailLink;
use App\Module\Mail\Domain\Repository\MailMessageRepositoryInterface;
use App\Module\Mail\Domain\Repository\TaskMailLinkRepositoryInterface;
use App\Module\Mail\Infrastructure\Security\MailAccessChecker;
use App\Module\ProjectManagement\Domain\Repository\TaskRepositoryInterface;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api/mail/messages/{id}/link-task', name: 'mail_link_task', methods: ['POST'], priority: 1, requirements: ['id' => '\d+'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class MailLinkTaskController extends AbstractController
{
public function __construct(
private readonly MailMessageRepositoryInterface $messageRepository,
private readonly TaskMailLinkRepositoryInterface $linkRepository,
private readonly TaskRepositoryInterface $taskRepository,
private readonly EntityManagerInterface $em,
private readonly MailAccessChecker $accessChecker,
) {}
public function __invoke(Request $request, int $id): JsonResponse
{
$this->accessChecker->ensureCanAccessMail($this->getUser());
$message = $this->messageRepository->findById($id);
if (null === $message) {
throw new NotFoundHttpException('Message not found');
}
$body = json_decode($request->getContent(), true);
$taskId = $body['taskId'] ?? null;
if (null === $taskId) {
throw new UnprocessableEntityHttpException('taskId is required');
}
$task = $this->taskRepository->findById((int) $taskId);
if (null === $task) {
throw new NotFoundHttpException('Task not found');
}
$existing = $this->linkRepository->findByTaskAndMessage($task, $message);
if (null !== $existing) {
return $this->json(['message' => 'Already linked']);
}
$link = new TaskMailLink();
$link->setTask($task);
$link->setMailMessage($message);
$link->setLinkedAt(new DateTimeImmutable());
$link->setLinkedBy($this->getUser());
$this->em->persist($link);
$this->em->flush();
return $this->json(['linkId' => $link->getId(), 'taskId' => $task->getId(), 'messageId' => $message->getId()], 201);
}
}
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Module\Mail\Infrastructure\Controller;
use App\Module\Mail\Domain\Exception\MailProviderException;
use App\Module\Mail\Domain\Provider\MailProviderInterface;
use App\Module\Mail\Domain\Repository\MailMessageRepositoryInterface;
use App\Module\Mail\Infrastructure\Security\MailAccessChecker;
use DateTimeInterface;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api/mail/messages/{id}', name: 'mail_message_detail', methods: ['GET'], priority: 1, requirements: ['id' => '\d+'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class MailMessageDetailController extends AbstractController
{
public function __construct(
private readonly MailMessageRepositoryInterface $messageRepository,
private readonly MailProviderInterface $mailProvider,
private readonly MailAccessChecker $accessChecker,
private readonly CacheItemPoolInterface $cache,
) {}
public function __invoke(int $id): JsonResponse
{
$this->accessChecker->ensureCanAccessMail($this->getUser());
$message = $this->messageRepository->findById($id);
if (null === $message) {
throw new NotFoundHttpException('Message not found');
}
$cacheKey = 'mail_body_'.md5($message->getMessageId());
$item = $this->cache->getItem($cacheKey);
if (!$item->isHit()) {
try {
$detail = $this->mailProvider->fetchMessage(
$message->getFolder()->getPath(),
$message->getUid()
);
$item->set($detail);
$item->expiresAfter(300);
$this->cache->save($item);
} catch (MailProviderException) {
throw new ServiceUnavailableHttpException(null, 'IMAP unavailable: could not fetch message body');
}
}
$detail = $item->get();
$messageId = $message->getId();
$attachments = array_map(static fn ($att) => [
'partNumber' => $att->partNumber,
'filename' => $att->filename,
'mimeType' => $att->mimeType,
'size' => $att->size,
'downloadId' => rtrim(strtr(base64_encode($messageId.':'.$att->partNumber), '+/', '-_'), '='),
], $detail->attachments);
return $this->json([
'id' => $message->getId(),
'messageId' => $message->getMessageId(),
'uid' => $message->getUid(),
'folderPath' => $message->getFolder()->getPath(),
'subject' => $detail->header->subject,
'fromAddress' => $detail->header->fromAddress,
'fromName' => $detail->header->fromName,
'toAddresses' => $detail->header->toAddresses,
'ccAddresses' => $detail->header->ccAddresses,
'sentAt' => $detail->header->sentAt->format(DateTimeInterface::ATOM),
'isRead' => $message->isRead(),
'isFlagged' => $message->isFlagged(),
'hasAttachments' => $message->hasAttachments(),
'bodyHtml' => $detail->bodyHtml,
'bodyText' => $detail->bodyText,
'attachments' => $attachments,
]);
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Module\Mail\Infrastructure\Controller;
use App\Module\Mail\Domain\Exception\MailProviderException;
use App\Module\Mail\Domain\Provider\MailProviderInterface;
use App\Module\Mail\Domain\Repository\MailMessageRepositoryInterface;
use App\Module\Mail\Infrastructure\Security\MailAccessChecker;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api/mail/messages/{id}/flag', name: 'mail_message_flag', methods: ['POST'], priority: 1, requirements: ['id' => '\d+'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class MailMessageFlagController extends AbstractController
{
public function __construct(
private readonly MailMessageRepositoryInterface $messageRepository,
private readonly MailProviderInterface $mailProvider,
private readonly EntityManagerInterface $em,
private readonly MailAccessChecker $accessChecker,
) {}
public function __invoke(Request $request, int $id): JsonResponse
{
$this->accessChecker->ensureCanAccessMail($this->getUser());
$message = $this->messageRepository->findById($id);
if (null === $message) {
throw new NotFoundHttpException('Message not found');
}
$body = json_decode($request->getContent(), true);
$flagged = (bool) ($body['flagged'] ?? true);
try {
$this->mailProvider->markFlagged($message->getFolder()->getPath(), $message->getUid(), $flagged);
} catch (MailProviderException) {
// Non bloquant
}
$message->setIsFlagged($flagged);
$this->em->flush();
return $this->json(['id' => $message->getId(), 'isFlagged' => $message->isFlagged()]);
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Module\Mail\Infrastructure\Controller;
use App\Module\Mail\Domain\Exception\MailProviderException;
use App\Module\Mail\Domain\Provider\MailProviderInterface;
use App\Module\Mail\Domain\Repository\MailMessageRepositoryInterface;
use App\Module\Mail\Infrastructure\Security\MailAccessChecker;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api/mail/messages/{id}/read', name: 'mail_message_read', methods: ['POST'], priority: 1, requirements: ['id' => '\d+'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class MailMessageReadController extends AbstractController
{
public function __construct(
private readonly MailMessageRepositoryInterface $messageRepository,
private readonly MailProviderInterface $mailProvider,
private readonly EntityManagerInterface $em,
private readonly MailAccessChecker $accessChecker,
) {}
public function __invoke(Request $request, int $id): JsonResponse
{
$this->accessChecker->ensureCanAccessMail($this->getUser());
$message = $this->messageRepository->findById($id);
if (null === $message) {
throw new NotFoundHttpException('Message not found');
}
$body = json_decode($request->getContent(), true);
$read = (bool) ($body['read'] ?? true);
try {
$this->mailProvider->markRead($message->getFolder()->getPath(), $message->getUid(), $read);
} catch (MailProviderException) {
// Non bloquant : on met quand meme a jour la BDD (sync IMAP au prochain cycle)
}
$message->setIsRead($read);
$this->em->flush();
return $this->json(['id' => $message->getId(), 'isRead' => $message->isRead()]);
}
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Module\Mail\Infrastructure\Controller;
use App\Module\Mail\Domain\Repository\MailFolderRepositoryInterface;
use App\Module\Mail\Domain\Repository\MailMessageRepositoryInterface;
use App\Module\Mail\Infrastructure\Security\MailAccessChecker;
use DateTimeInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api/mail/folders/{folderPath}/messages', name: 'mail_messages_list', methods: ['GET'], priority: 1, requirements: ['folderPath' => '.+'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class MailMessagesListController extends AbstractController
{
public function __construct(
private readonly MailFolderRepositoryInterface $folderRepository,
private readonly MailMessageRepositoryInterface $messageRepository,
private readonly MailAccessChecker $accessChecker,
) {}
public function __invoke(Request $request, string $folderPath): JsonResponse
{
$this->accessChecker->ensureCanAccessMail($this->getUser());
$decodedPath = urldecode($folderPath);
$folder = $this->folderRepository->findByPath($decodedPath);
if (null === $folder) {
throw new NotFoundHttpException(sprintf('Folder "%s" not found', $decodedPath));
}
$limit = min((int) $request->query->get('limit', 50), 100);
$cursor = $request->query->get('cursor');
$result = $this->messageRepository->findByFolderCursor($folder, $limit, $cursor ?: null);
$messages = array_map(static fn ($m) => [
'id' => $m->getId(),
'messageId' => $m->getMessageId(),
'uid' => $m->getUid(),
'subject' => $m->getSubject(),
'fromAddress' => $m->getFromAddress(),
'fromName' => $m->getFromName(),
'toAddresses' => $m->getToAddresses(),
'ccAddresses' => $m->getCcAddresses(),
'sentAt' => $m->getSentAt()->format(DateTimeInterface::ATOM),
'isRead' => $m->isRead(),
'isFlagged' => $m->isFlagged(),
'hasAttachments' => $m->hasAttachments(),
'snippet' => $m->getSnippet(),
], $result['messages']);
return $this->json([
'messages' => $messages,
'nextCursor' => $result['nextCursor'],
]);
}
}
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Module\Mail\Infrastructure\Controller;
use App\Module\Mail\Application\Message\MailSyncRequested;
use App\Module\Mail\Infrastructure\Security\MailAccessChecker;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api/mail/sync', name: 'mail_sync_trigger', methods: ['POST'], priority: 1)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class MailSyncTriggerController extends AbstractController
{
public function __construct(
private readonly MessageBusInterface $bus,
private readonly MailAccessChecker $accessChecker,
) {}
public function __invoke(Request $request): JsonResponse
{
$this->accessChecker->ensureCanAccessMail($this->getUser());
$body = json_decode($request->getContent(), true) ?? [];
$folderPath = $body['folderPath'] ?? null;
$this->bus->dispatch(new MailSyncRequested($folderPath));
return $this->json(
['message' => 'Synchronisation démarrée en arrière-plan'],
Response::HTTP_ACCEPTED
);
}
}
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Module\Mail\Infrastructure\Controller;
use App\Module\Mail\Domain\Exception\MailProviderException;
use App\Module\Mail\Domain\Provider\MailProviderInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Throwable;
#[Route('/api/mail/configuration/test', name: 'mail_configuration_test', methods: ['POST'], priority: 1)]
#[IsGranted('ROLE_ADMIN')]
class MailTestConnectionController extends AbstractController
{
public function __construct(
private readonly MailProviderInterface $mailProvider,
) {}
public function __invoke(): JsonResponse
{
try {
$foldersCount = $this->mailProvider->testConnection();
return $this->json([
'ok' => true,
'foldersCount' => $foldersCount,
]);
} catch (MailProviderException $e) {
return $this->json([
'ok' => false,
'error' => 'Connexion IMAP impossible. Vérifiez la configuration.',
'detail' => $e->getMessage(),
]);
} catch (Throwable $e) {
return $this->json([
'ok' => false,
'error' => 'Erreur inattendue lors du test de connexion.',
'detail' => $e->getMessage(),
]);
}
}
}
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Module\Mail\Infrastructure\Controller;
use App\Module\Mail\Domain\Repository\MailMessageRepositoryInterface;
use App\Module\Mail\Domain\Repository\TaskMailLinkRepositoryInterface;
use App\Module\Mail\Infrastructure\Security\MailAccessChecker;
use App\Module\ProjectManagement\Domain\Repository\TaskRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api/mail/messages/{id}/link-task/{taskId}', name: 'mail_unlink_task', methods: ['DELETE'], priority: 1, requirements: ['id' => '\d+', 'taskId' => '\d+'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class MailUnlinkTaskController extends AbstractController
{
public function __construct(
private readonly MailMessageRepositoryInterface $messageRepository,
private readonly TaskMailLinkRepositoryInterface $linkRepository,
private readonly TaskRepositoryInterface $taskRepository,
private readonly EntityManagerInterface $em,
private readonly MailAccessChecker $accessChecker,
) {}
public function __invoke(int $id, int $taskId): JsonResponse
{
$this->accessChecker->ensureCanAccessMail($this->getUser());
$message = $this->messageRepository->findById($id);
if (null === $message) {
throw new NotFoundHttpException('Message not found');
}
$task = $this->taskRepository->findById($taskId);
if (null === $task) {
throw new NotFoundHttpException('Task not found');
}
$link = $this->linkRepository->findByTaskAndMessage($task, $message);
if (null === $link) {
throw new NotFoundHttpException('Link not found');
}
$this->em->remove($link);
$this->em->flush();
return $this->json(null, Response::HTTP_NO_CONTENT);
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Module\Mail\Infrastructure\Controller;
use App\Module\Mail\Domain\Repository\TaskMailLinkRepositoryInterface;
use App\Module\Mail\Infrastructure\Security\MailAccessChecker;
use App\Module\ProjectManagement\Domain\Repository\TaskRepositoryInterface;
use DateTimeInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api/tasks/{id}/mails', name: 'task_mails_list', methods: ['GET'], priority: 1, requirements: ['id' => '\d+'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class TaskMailsListController extends AbstractController
{
public function __construct(
private readonly TaskRepositoryInterface $taskRepository,
private readonly TaskMailLinkRepositoryInterface $linkRepository,
private readonly MailAccessChecker $accessChecker,
) {}
public function __invoke(int $id): JsonResponse
{
$this->accessChecker->ensureCanAccessMail($this->getUser());
$task = $this->taskRepository->findById($id);
if (null === $task) {
throw new NotFoundHttpException('Task not found');
}
$links = $this->linkRepository->findByTask($task);
$data = array_map(static fn ($link) => [
'id' => $link->getMailMessage()->getId(),
'messageId' => $link->getMailMessage()->getMessageId(),
'subject' => $link->getMailMessage()->getSubject(),
'fromAddress' => $link->getMailMessage()->getFromAddress(),
'fromName' => $link->getMailMessage()->getFromName(),
'sentAt' => $link->getMailMessage()->getSentAt()->format(DateTimeInterface::ATOM),
'isRead' => $link->getMailMessage()->isRead(),
'isFlagged' => $link->getMailMessage()->isFlagged(),
'snippet' => $link->getMailMessage()->getSnippet(),
'linkedAt' => $link->getLinkedAt()->format(DateTimeInterface::ATOM),
], $links);
return $this->json($data);
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Module\Mail\Infrastructure\Doctrine;
use App\Module\Mail\Domain\Entity\MailConfiguration;
use App\Module\Mail\Domain\Repository\MailConfigurationRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<MailConfiguration>
*/
class DoctrineMailConfigurationRepository extends ServiceEntityRepository implements MailConfigurationRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, MailConfiguration::class);
}
public function findSingleton(): ?MailConfiguration
{
return $this->createQueryBuilder('m')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult()
;
}
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Module\Mail\Infrastructure\Doctrine;
use App\Module\Mail\Domain\Entity\MailFolder;
use App\Module\Mail\Domain\Repository\MailFolderRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<MailFolder>
*/
class DoctrineMailFolderRepository extends ServiceEntityRepository implements MailFolderRepositoryInterface
{
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]);
}
}
@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace App\Module\Mail\Infrastructure\Doctrine;
use App\Module\Mail\Domain\Entity\MailFolder;
use App\Module\Mail\Domain\Entity\MailMessage;
use App\Module\Mail\Domain\Repository\MailMessageRepositoryInterface;
use DateTimeImmutable;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<MailMessage>
*/
class DoctrineMailMessageRepository extends ServiceEntityRepository implements MailMessageRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, MailMessage::class);
}
public function findById(int $id): ?MailMessage
{
return $this->find($id);
}
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()
;
}
public function findMaxUidInFolder(MailFolder $folder): int
{
$result = $this->createQueryBuilder('m')
->select('MAX(m.uid)')
->andWhere('m.folder = :folder')
->setParameter('folder', $folder)
->getQuery()
->getSingleScalarResult()
;
return (int) ($result ?? 0);
}
/**
* @return list<MailMessage>
*/
public function findLastNByFolder(MailFolder $folder, int $limit): array
{
return $this->createQueryBuilder('m')
->andWhere('m.folder = :folder')
->setParameter('folder', $folder)
->orderBy('m.sentAt', 'DESC')
->addOrderBy('m.id', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult()
;
}
/**
* @return list<int>
*/
public function findAllUidsByFolder(MailFolder $folder): array
{
$rows = $this->createQueryBuilder('m')
->select('m.uid')
->andWhere('m.folder = :folder')
->setParameter('folder', $folder)
->getQuery()
->getArrayResult()
;
return array_column($rows, 'uid');
}
/**
* Pagination cursor : retourne $limit messages apres le cursor (sentAt DESC, id DESC).
* Cursor format : base64url(sentAt_iso8601:id) - null pour la premiere page.
*
* @return array{messages: list<MailMessage>, nextCursor: ?string}
*/
public function findByFolderCursor(MailFolder $folder, int $limit, ?string $cursor): array
{
$qb = $this->createQueryBuilder('m')
->andWhere('m.folder = :folder')
->setParameter('folder', $folder)
->orderBy('m.sentAt', 'DESC')
->addOrderBy('m.id', 'DESC')
->setMaxResults($limit + 1)
;
if (null !== $cursor) {
$decoded = base64_decode(strtr($cursor, '-_', '+/'), true);
if (false !== $decoded && str_contains($decoded, ':')) {
[$sentAtStr, $idStr] = explode(':', $decoded, 2);
$cursorSentAt = DateTimeImmutable::createFromFormat(DateTimeInterface::ATOM, $sentAtStr);
$cursorId = (int) $idStr;
if ($cursorSentAt instanceof DateTimeImmutable) {
$qb
->andWhere('m.sentAt < :cursorSentAt OR (m.sentAt = :cursorSentAt AND m.id < :cursorId)')
->setParameter('cursorSentAt', $cursorSentAt)
->setParameter('cursorId', $cursorId)
;
}
}
}
/** @var list<MailMessage> $results */
$results = $qb->getQuery()->getResult();
$hasMore = count($results) > $limit;
$messages = $hasMore ? array_slice($results, 0, $limit) : $results;
$nextCursor = null;
if ($hasMore && [] !== $messages) {
$last = end($messages);
$raw = $last->getSentAt()->format(DateTimeInterface::ATOM).':'.$last->getId();
$nextCursor = rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
}
return ['messages' => $messages, 'nextCursor' => $nextCursor];
}
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Module\Mail\Infrastructure\Doctrine;
use App\Module\Mail\Domain\Entity\MailMessage;
use App\Module\Mail\Domain\Entity\TaskMailLink;
use App\Module\Mail\Domain\Repository\TaskMailLinkRepositoryInterface;
use App\Shared\Domain\Contract\TaskInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<TaskMailLink>
*/
class DoctrineTaskMailLinkRepository extends ServiceEntityRepository implements TaskMailLinkRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TaskMailLink::class);
}
/**
* @return list<TaskMailLink>
*/
public function findByTask(TaskInterface $task): array
{
return $this->createQueryBuilder('l')
->andWhere('l.task = :task')
->setParameter('task', $task)
->orderBy('l.linkedAt', 'DESC')
->getQuery()
->getResult()
;
}
public function findByTaskAndMessage(TaskInterface $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]);
}
}
@@ -0,0 +1,406 @@
<?php
declare(strict_types=1);
namespace App\Module\Mail\Infrastructure\Imap;
use App\Module\Mail\Application\Dto\MailAttachmentDto;
use App\Module\Mail\Application\Dto\MailFolderDto;
use App\Module\Mail\Application\Dto\MailMessageDetailDto;
use App\Module\Mail\Application\Dto\MailMessageHeaderDto;
use App\Module\Mail\Domain\Exception\MailProviderException;
use App\Module\Mail\Domain\Provider\MailProviderInterface;
use App\Module\Mail\Domain\Repository\MailConfigurationRepositoryInterface;
use App\Service\TokenEncryptor;
use DateTimeImmutable;
use Psr\Log\LoggerInterface;
use SodiumException;
use Throwable;
use Webklex\PHPIMAP\Client;
use Webklex\PHPIMAP\ClientManager;
use Webklex\PHPIMAP\IMAP;
final class ImapMailProvider implements MailProviderInterface
{
private ?Client $client = null;
public function __construct(
private readonly MailConfigurationRepositoryInterface $configRepository,
private readonly TokenEncryptor $tokenEncryptor,
private readonly LoggerInterface $logger,
) {}
/**
* Closes the reused IMAP connection. Call once at the end of a batch
* synchronisation to release the socket; HTTP requests can ignore it
* (the connection dies with the process).
*/
public function closeConnection(): void
{
if (null !== $this->client && $this->client->isConnected()) {
try {
$this->client->disconnect();
} catch (Throwable) {
// best effort
}
}
$this->client = null;
}
public function testConnection(): int
{
$client = $this->getClient(requireEnabled: false);
try {
$folders = $client->getFolders(false);
return count($folders);
} catch (Throwable $e) {
$this->logger->error('ImapMailProvider::testConnection failed: '.$e->getMessage());
throw MailProviderException::connectionFailed($e->getMessage());
}
}
public function listFolders(): array
{
$client = $this->getClient();
try {
$folders = $client->getFolders(false);
$result = [];
foreach ($folders as $folder) {
$path = $folder->path;
$parentPath = null;
$delimiter = $folder->delimiter ?? '.';
$lastDelim = strrpos($path, $delimiter);
if (false !== $lastDelim && $lastDelim > 0) {
$parentPath = substr($path, 0, $lastDelim);
}
$result[] = new MailFolderDto(
path: $path,
displayName: $folder->name,
parentPath: $parentPath,
unreadCount: (int) ($folder->status['unseen'] ?? 0),
totalCount: (int) ($folder->status['messages'] ?? 0),
);
}
return $result;
} catch (MailProviderException $e) {
throw $e;
} catch (Throwable $e) {
$this->logger->error('ImapMailProvider::listFolders failed: '.$e->getMessage());
throw MailProviderException::operationFailed('listFolders', $e->getMessage());
}
}
public function listMessages(string $folderPath, int $limit, int $offset): array
{
$client = $this->getClient();
try {
$folder = $client->getFolder($folderPath);
if (null === $folder) {
throw MailProviderException::operationFailed('listMessages', sprintf('Folder %s not found', $folderPath));
}
$messages = $folder->query()
->whereAll()
->setFetchBody(false)
->leaveUnread()
->setSequence(IMAP::ST_UID)
->get()
;
$result = [];
$items = array_slice($messages->toArray(), $offset, $limit);
foreach ($items as $message) {
$result[] = $this->buildHeaderDto($message, withSnippet: false);
}
return $result;
} catch (MailProviderException $e) {
throw $e;
} catch (Throwable $e) {
$this->logger->error(sprintf('ImapMailProvider::listMessages failed for folder %s: %s', $folderPath, $e->getMessage()));
throw MailProviderException::operationFailed('listMessages', $e->getMessage());
}
}
public function fetchMessage(string $folderPath, int $uid): MailMessageDetailDto
{
$client = $this->getClient();
try {
$folder = $client->getFolder($folderPath);
if (null === $folder) {
throw MailProviderException::operationFailed('fetchMessage', sprintf('Folder %s not found', $folderPath));
}
$message = $folder->query()->uid($uid)->leaveUnread()->setSequence(IMAP::ST_UID)->get()->first();
if (null === $message) {
throw MailProviderException::operationFailed('fetchMessage', sprintf('UID %d not found in folder %s', $uid, $folderPath));
}
$header = $this->buildHeaderDto($message);
$bodyHtml = $message->getHTMLBody(false) ?: null;
$bodyText = $message->getTextBody() ?: null;
$attachments = [];
foreach ($message->getAttachments() as $att) {
$attachments[] = new MailAttachmentDto(
partNumber: (string) ($att->part_number ?? '1'),
filename: $att->getName() ?? 'attachment',
mimeType: $att->getMimeType() ?? 'application/octet-stream',
size: $att->getSize() ?? 0,
);
}
return new MailMessageDetailDto(
header: $header,
bodyHtml: $bodyHtml,
bodyText: $bodyText,
attachments: $attachments,
);
} catch (MailProviderException $e) {
throw $e;
} catch (Throwable $e) {
$this->logger->error(sprintf('ImapMailProvider::fetchMessage failed uid=%d folder=%s: %s', $uid, $folderPath, $e->getMessage()));
throw MailProviderException::operationFailed('fetchMessage', $e->getMessage());
}
}
public function markRead(string $folderPath, int $uid, bool $read): void
{
$client = $this->getClient();
try {
$folder = $client->getFolder($folderPath);
if (null === $folder) {
throw MailProviderException::operationFailed('markRead', sprintf('Folder %s not found', $folderPath));
}
$message = $folder->query()->uid($uid)->leaveUnread()->setSequence(IMAP::ST_UID)->get()->first();
if (null === $message) {
throw MailProviderException::operationFailed('markRead', sprintf('UID %d not found', $uid));
}
if ($read) {
$message->setFlag('Seen');
} else {
$message->unsetFlag('Seen');
}
} catch (MailProviderException $e) {
throw $e;
} catch (Throwable $e) {
$this->logger->error(sprintf('ImapMailProvider::markRead failed uid=%d: %s', $uid, $e->getMessage()));
throw MailProviderException::operationFailed('markRead', $e->getMessage());
}
}
public function markFlagged(string $folderPath, int $uid, bool $flagged): void
{
$client = $this->getClient();
try {
$folder = $client->getFolder($folderPath);
if (null === $folder) {
throw MailProviderException::operationFailed('markFlagged', sprintf('Folder %s not found', $folderPath));
}
$message = $folder->query()->uid($uid)->leaveUnread()->setSequence(IMAP::ST_UID)->get()->first();
if (null === $message) {
throw MailProviderException::operationFailed('markFlagged', sprintf('UID %d not found', $uid));
}
if ($flagged) {
$message->setFlag('Flagged');
} else {
$message->unsetFlag('Flagged');
}
} catch (MailProviderException $e) {
throw $e;
} catch (Throwable $e) {
$this->logger->error(sprintf('ImapMailProvider::markFlagged failed uid=%d: %s', $uid, $e->getMessage()));
throw MailProviderException::operationFailed('markFlagged', $e->getMessage());
}
}
public function moveMessage(string $folderPath, int $uid, string $targetFolder): void
{
$client = $this->getClient();
try {
$folder = $client->getFolder($folderPath);
if (null === $folder) {
throw MailProviderException::operationFailed('moveMessage', sprintf('Folder %s not found', $folderPath));
}
$message = $folder->query()->uid($uid)->leaveUnread()->setSequence(IMAP::ST_UID)->get()->first();
if (null === $message) {
throw MailProviderException::operationFailed('moveMessage', sprintf('UID %d not found', $uid));
}
$message->moveToFolder($targetFolder);
} catch (MailProviderException $e) {
throw $e;
} catch (Throwable $e) {
$this->logger->error(sprintf('ImapMailProvider::moveMessage failed uid=%d: %s', $uid, $e->getMessage()));
throw MailProviderException::operationFailed('moveMessage', $e->getMessage());
}
}
public function fetchAttachment(string $folderPath, int $uid, string $partNumber): string
{
$client = $this->getClient();
try {
$folder = $client->getFolder($folderPath);
if (null === $folder) {
throw MailProviderException::operationFailed('fetchAttachment', sprintf('Folder %s not found', $folderPath));
}
$message = $folder->query()->uid($uid)->leaveUnread()->setSequence(IMAP::ST_UID)->get()->first();
if (null === $message) {
throw MailProviderException::operationFailed('fetchAttachment', sprintf('UID %d not found', $uid));
}
foreach ($message->getAttachments() as $att) {
if ((string) ($att->part_number ?? '1') === $partNumber) {
return (string) $att->getContent();
}
}
throw MailProviderException::operationFailed('fetchAttachment', sprintf('Part %s not found in UID %d', $partNumber, $uid));
} catch (MailProviderException $e) {
throw $e;
} catch (Throwable $e) {
$this->logger->error(sprintf('ImapMailProvider::fetchAttachment failed uid=%d part=%s: %s', $uid, $partNumber, $e->getMessage()));
throw MailProviderException::operationFailed('fetchAttachment', $e->getMessage());
}
}
private function getClient(bool $requireEnabled = true): Client
{
if (null !== $this->client && $this->client->isConnected()) {
return $this->client;
}
$config = $this->configRepository->findSingleton();
if (null === $config) {
throw MailProviderException::connectionFailed('Mail configuration is missing');
}
if ($requireEnabled && !$config->isEnabled()) {
throw MailProviderException::connectionFailed('Mail configuration is disabled');
}
if (null === $config->getEncryptedPassword()) {
throw MailProviderException::connectionFailed('No password configured');
}
$password = $this->tokenEncryptor->decrypt($config->getEncryptedPassword());
try {
$manager = new ClientManager();
$client = $manager->make([
'host' => $config->getImapHost(),
'port' => $config->getImapPort(),
'encryption' => $config->getImapEncryption(),
'validate_cert' => true,
'username' => $config->getUsername(),
'password' => $password,
'protocol' => 'imap',
]);
$client->connect();
} catch (Throwable $e) {
$this->logger->error('IMAP connection failed: '.$e->getMessage());
throw MailProviderException::connectionFailed($e->getMessage());
} finally {
try {
sodium_memzero($password);
} catch (SodiumException) {
// ignore: interned strings can't be zeroed
}
}
$this->client = $client;
return $client;
}
private function buildHeaderDto(mixed $message, bool $withSnippet = true): MailMessageHeaderDto
{
$from = $message->getFrom()->first();
$fromAddress = null !== $from ? (string) $from->mail : '';
$fromName = null !== $from && null !== $from->personal
? MimeHeaderDecoder::decode((string) $from->personal)
: null;
$toAddresses = [];
foreach ($message->getTo() as $addr) {
$toAddresses[] = (string) $addr->mail;
}
$ccAddresses = null;
$cc = $message->getCc();
if (null !== $cc && $cc->count() > 0) {
$ccAddresses = [];
foreach ($cc as $addr) {
$ccAddresses[] = (string) $addr->mail;
}
}
$sentAt = new DateTimeImmutable();
$dateAttr = $message->getDate();
if (null !== $dateAttr) {
try {
$sentAt = DateTimeImmutable::createFromInterface($dateAttr->toDate());
} catch (Throwable) {
// keep default when the header date is missing or unparsable
}
}
$snippet = null;
if ($withSnippet) {
$text = $message->getTextBody();
if (null !== $text && '' !== $text) {
$snippet = mb_substr(strip_tags($text), 0, 200);
}
}
return new MailMessageHeaderDto(
uid: (int) $message->getUid(),
messageId: (string) $message->getMessageId(),
subject: '' !== (string) $message->getSubject() ? MimeHeaderDecoder::decode((string) $message->getSubject()) : null,
fromAddress: $fromAddress,
fromName: $fromName,
toAddresses: $toAddresses,
ccAddresses: $ccAddresses,
sentAt: $sentAt,
isRead: $message->hasFlag('Seen'),
isFlagged: $message->hasFlag('Flagged'),
hasAttachments: $message->hasAttachments(),
snippet: $snippet,
);
}
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Module\Mail\Infrastructure\Imap;
use const ICONV_MIME_DECODE_CONTINUE_ON_ERROR;
/**
* Décode les en-têtes mail encodés en « encoded-words » MIME (RFC 2047),
* ex: "=?UTF-8?Q?Fwd=3A_Votre_inscription?=" → "Fwd: Votre inscription".
*
* Certains serveurs IMAP (OVH) renvoient les sujets / noms d'expéditeur
* encodés bruts ; webklex ne les décode pas systématiquement. Cet utilitaire
* normalise la sortie en UTF-8 lisible. Idempotent : un texte déjà décodé
* (sans séquence "=?") est retourné inchangé.
*/
final class MimeHeaderDecoder
{
public static function decode(?string $value): ?string
{
if (null === $value || '' === $value) {
return $value;
}
// Pas d'encoded-word → rien à faire (chemin rapide + idempotence).
if (!str_contains($value, '=?')) {
return $value;
}
$decoded = @iconv_mime_decode($value, ICONV_MIME_DECODE_CONTINUE_ON_ERROR, 'UTF-8');
if (false === $decoded || '' === trim($decoded)) {
// Fallback : mb_decode_mimeheader gère certains cas refusés par iconv.
$previous = mb_internal_encoding();
mb_internal_encoding('UTF-8');
try {
$decoded = mb_decode_mimeheader($value);
} finally {
mb_internal_encoding($previous);
}
}
return false === $decoded || '' === $decoded ? $value : $decoded;
}
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Module\Mail\Infrastructure\Security;
use App\Shared\Domain\Contract\UserInterface as SharedUserInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Core\User\UserInterface;
final readonly class MailAccessChecker
{
public function __construct(
private AuthorizationCheckerInterface $authorizationChecker,
) {}
/**
* Verifie que l'utilisateur courant peut acceder aux endpoints mail.
* Autorise : ROLE_USER, ROLE_ADMIN.
*
* @throws AccessDeniedException
*/
public function ensureCanAccessMail(?UserInterface $user): void
{
if (!$user instanceof SharedUserInterface) {
throw new AccessDeniedException('Authentication required');
}
$roles = $user->getRoles();
if (!in_array('ROLE_USER', $roles, true) && !in_array('ROLE_ADMIN', $roles, true)) {
throw new AccessDeniedException('ROLE_USER required');
}
}
/**
* Verifie que l'utilisateur est ROLE_ADMIN.
*
* @throws AccessDeniedException
*/
public function ensureIsAdmin(?UserInterface $user): void
{
if (!$user instanceof SharedUserInterface || !$this->authorizationChecker->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Admin only');
}
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Module\Mail;
use App\Shared\Domain\Module\ModuleInterface;
final class MailModule implements ModuleInterface
{
public static function id(): string
{
return 'mail';
}
public static function label(): string
{
return 'Messagerie';
}
public static function isRequired(): bool
{
return false;
}
/**
* Permissions RBAC fin du Module Mail.
*
* Additif : alimente le catalogue RBAC. La sécurité des opérations API
* reste en ROLE_USER/ROLE_ADMIN (non recâblée ici).
*
* @return list<array{code: string, label: string}>
*/
public static function permissions(): array
{
return [
['code' => 'mail.access', 'label' => 'Accéder à la messagerie'],
['code' => 'mail.configure', 'label' => 'Configurer la messagerie'],
];
}
}