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:
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user