Migration modular monolith DDD (0.1 → 3.3) (#17)
Auto Tag Develop / tag (push) Successful in 9s
Auto Tag Develop / tag (push) Successful in 9s
## Migration modular monolith DDD — Lesstime (0.1 → 3.3) Cette MR regroupe l'intégralité de la refonte en monolithe modulaire (strangler progressif, additif). Elle remplace les MR stackées de Phase 1 (#12–#16), désormais incluses ici. **Ne pas merger avant validation fonctionnelle** : branche destinée à être testée telle quelle. ### Périmètre — 9 modules sous `src/Module/` | Phase | Module | Contenu | |------|--------|---------| | 0.1 | (socle) | infrastructure modulaire, `ModuleInterface`, mapping Doctrine par module | | 0.2 | (socle front) | auto-détection des layers Nuxt sous `frontend/modules/*` | | 1.1 | **Core** | Identité (User/Auth), Notifications, Notifier | | 1.2 | Core | RBAC fin (permissions `module.resource.action`, sidebar gated) | | 1.3 | Core | Audit log (`#[Auditable]`, listener, provider DBAL) | | 2.1 | **TimeTracking** | TimeEntry + MCP + export | | 2.2 | **ProjectManagement** | cœur métier Projets/Tâches + 38 MCP tools | | 2.3 | **Absence** | demandes, soldes, policies, justificatifs | | 2.4 | **Directory** | Clients (migrés) + **Prospects** (nouveau, conversion → Client) | | 2.5 | **Mail** | intégration IMAP OVH + liens tâches | | 2.6 | **Integration** | Gitea / BookStack / Zimbra / Share | | 3.1 | **Reporting** | rapports transverses (DBAL read-only, 0 import inter-module) | | 3.2 | **ClientPortal** | portail client (ROLE_CLIENT cloisonné, tickets, notifications) | | 3.3 | (finition) | nettoyage legacy — `src/Entity` vide, app 100% modulaire | ### Architecture - Découplage inter-modules par **contrats** (`UserInterface`, `ProjectInterface`, `TaskInterface`, `TaskTagInterface`, `ClientInterface`, `ClientTicketInterface`, `LeaveProfileInterface`) + `resolve_target_entities` 100% modulaire (aucune cible legacy). - Repositories : interface `Domain/Repository` + implémentation `Infrastructure/Doctrine`, bindées. - Reporting en DBAL read-only pur (aucun import d'entité d'un autre module). - Chaque migration de module : déplacement à comportement préservé (API publique et noms d'outils MCP inchangés), migrations **additives** uniquement (zéro destructif). ### Sécurité - ROLE_CLIENT cloisonné : un utilisateur client n'accède qu'à `/portal` et à ses propres tickets (filtrés par `allowedProjects`), interdit sur toute l'API interne. - Correctif : interdiction pour un client de créer un lien vers le partage SMB (upload uniquement). ### QA non-régression (branche reconstruite from scratch) - Migrations from scratch + fixtures : OK. - Compilation dev + prod : OK. - **180 tests PHPUnit verts**, php-cs-fixer clean, ~96 routes, **66 outils MCP** tous sous `App\Module\*`. - Smoke test runtime multi-rôles (admin / ROLE_USER / ROLE_CLIENT) : 44 vérifications HTTP, **0 écart**, cloisonnement client étanche. - Build Nuxt OK, 9 layers, 0 import legacy résiduel. ### Points à arbitrer (hors périmètre de cette migration) - Durcissement MCP/IDOR pré-existant (`userId` explicite sans scoping sur certains tools TimeTracking/Absence/TaskDocument) — ticket dédié recommandé. - Validation fonctionnelle de **Prospect** et **ClientPortal** (conçus depuis les specs disque). - **Harmonisation visuelle Malio finale** (3.3) — finition esthétique inter-modules laissée au PO. --- ## ⚠️ Déploiement / migration des données — à ne pas oublier ### 1. Resynchroniser les séquences PostgreSQL après tout import/restore de dump Si la prod (ou tout environnement) est **montée depuis un dump** (`pg_restore` / `COPY`), les lignes sont chargées avec leurs `id` explicites **sans avancer les séquences** → au premier `INSERT` : `duplicate key value violates unique constraint "..._pkey"` (constaté en local sur `notification`, `task`, `time_entry`…). À lancer **juste après chaque restore/import** : ```sql DO $$ DECLARE r RECORD; maxid BIGINT; seq TEXT; BEGIN FOR r IN SELECT table_name, column_name FROM information_schema.columns WHERE table_schema='public' LOOP seq := pg_get_serial_sequence(quote_ident(r.table_name), r.column_name); IF seq IS NOT NULL THEN EXECUTE format('SELECT COALESCE(MAX(%I),0) FROM %I', r.column_name, r.table_name) INTO maxid; PERFORM setval(seq, GREATEST(maxid,1), maxid > 0); END IF; END LOOP; END $$; ``` > Ne concerne **pas** une prod qui tourne déjà (séquences avancées organiquement) — uniquement le cas restore/import. Idempotent, sans risque. ### 2. Fix dénormalisation des collections typées-contrat (code, inclus dans la branche) Les relations **to-many** typées par une interface `Shared\Domain\Contract\*` (`TimeEntry::tags` → `TaskTagInterface`, `Task::collaborators` → `UserInterface`) étaient **indénormalisables par API Platform** (mono-valué OK via IRI, collection KO) → **tout POST/PATCH portant une telle collection renvoyait 400/500**. Corrigé par un dénormaliseur générique `ContractRelationDenormalizer` (réutilise `resolve_target_entities`, zéro couplage par-entité) + test fonctionnel de non-régression. --------- Co-authored-by: Matthieu <contact@malio.fr> Reviewed-on: #17
This commit was merged in pull request #17.
This commit is contained in:
@@ -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\Shared\Infrastructure\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\Shared\Infrastructure\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');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user