2411 lines
91 KiB
Markdown
2411 lines
91 KiB
Markdown
# Mail Integration — Phase 3 : API Backend
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Exposer l'ensemble des endpoints HTTP de l'intégration mail (config singleton admin, listing dossiers/messages, lecture body avec cache 5 min, actions read/flag/create-task/link-task, attachments stream, sync async via Messenger).
|
|
|
|
**Architecture:** Singleton config via API Platform Provider/Processor (pattern Zimbra). Custom controllers Symfony pour endpoints "métier" (priority: 1 pour éviter conflit API Platform `{id}`). Sécurité stricte : `IS_AUTHENTICATED_FULLY` + `ROLE_USER` + check `!ROLE_CLIENT` explicite dans chaque endpoint (rappel : `User::getRoles()` n'ajoute pas `ROLE_USER` aux clients — la hiérarchie `ROLE_ADMIN: [ROLE_USER, ROLE_CLIENT]` de security.yaml s'applique aux ADMIN, pas aux ROLE_CLIENT pur). Sync manuelle dispatchée en async via Symfony Messenger (`MailSyncRequested` message). Body fetché live IMAP avec cache Symfony `mail_body_{md5(messageId)}` TTL 5 min.
|
|
|
|
**Tech Stack:** Symfony 8.0, API Platform 4.2, Symfony Messenger, Symfony Cache (pool `cache.app`, APCu ou filesystem selon config), LexikJWT pour l'auth (cookie BEARER).
|
|
|
|
**Branche cible :** `feat/mail-integration` (créée en Phase 1 — vérifier qu'elle est active).
|
|
|
|
**Fichiers créés/modifiés par le codeur :**
|
|
|
|
| Fichier | Action |
|
|
|---|---|
|
|
| `src/ApiResource/MailSettings.php` | Créer |
|
|
| `src/State/Mail/MailSettingsProvider.php` | Créer |
|
|
| `src/State/Mail/MailSettingsProcessor.php` | Créer |
|
|
| `src/Controller/Mail/MailTestConnectionController.php` | Créer |
|
|
| `src/Security/MailAccessChecker.php` | Créer |
|
|
| `src/Controller/Mail/MailFoldersListController.php` | Créer |
|
|
| `src/Controller/Mail/MailMessagesListController.php` | Créer |
|
|
| `src/Repository/MailMessageRepository.php` | Modifier (ajout `findByFolderCursor`) |
|
|
| `src/Controller/Mail/MailMessageDetailController.php` | Créer |
|
|
| `src/Controller/Mail/MailMessageReadController.php` | Créer |
|
|
| `src/Controller/Mail/MailMessageFlagController.php` | Créer |
|
|
| `src/Controller/Mail/MailCreateTaskController.php` | Créer |
|
|
| `src/Controller/Mail/MailLinkTaskController.php` | Créer |
|
|
| `src/Controller/Mail/MailUnlinkTaskController.php` | Créer |
|
|
| `src/Controller/Mail/TaskMailsListController.php` | Créer |
|
|
| `src/Controller/Mail/MailAttachmentDownloadController.php` | Créer |
|
|
| `src/Message/MailSyncRequested.php` | Créer |
|
|
| `src/MessageHandler/MailSyncRequestedHandler.php` | Créer |
|
|
| `src/Controller/Mail/MailSyncTriggerController.php` | Créer |
|
|
| `config/packages/messenger.yaml` | Créer |
|
|
| `config/packages/security.yaml` | Modifier (ajout access_control mail) |
|
|
| `tests/Functional/Controller/Mail/MailSettingsControllerTest.php` | Créer |
|
|
| `tests/Functional/Controller/Mail/MailFoldersControllerTest.php` | Créer |
|
|
| `tests/Functional/Controller/Mail/MailMessagesControllerTest.php` | Créer |
|
|
| `tests/Functional/Controller/Mail/MailTaskIntegrationControllerTest.php` | Créer |
|
|
| `tests/Functional/Controller/Mail/MailSyncTriggerControllerTest.php` | Créer |
|
|
|
|
---
|
|
|
|
### Task 1 : Préparation — branche + dossiers
|
|
|
|
- [ ] **Step 1 : Vérifier la branche active**
|
|
|
|
```bash
|
|
git branch --show-current
|
|
```
|
|
|
|
Attendu : `feat/mail-integration`. Si non, basculer :
|
|
|
|
```bash
|
|
git checkout feat/mail-integration
|
|
```
|
|
|
|
- [ ] **Step 2 : Créer les dossiers namespace**
|
|
|
|
```bash
|
|
mkdir -p src/State/Mail
|
|
mkdir -p src/Controller/Mail
|
|
mkdir -p src/Message
|
|
mkdir -p src/MessageHandler
|
|
mkdir -p src/Security
|
|
mkdir -p tests/Functional/Controller/Mail
|
|
```
|
|
|
|
- [ ] **Step 3 : Vérifier que `src/Security/` existe déjà (ApiTokenAuthenticator)**
|
|
|
|
```bash
|
|
ls src/Security/
|
|
```
|
|
|
|
Attendu : `ApiTokenAuthenticator.php` présent. Le dossier existe déjà — pas besoin de le recréer.
|
|
|
|
- [ ] **Step 4 : Vérifier l'autowiring de base**
|
|
|
|
```bash
|
|
docker exec php-lesstime-fpm php bin/console debug:autowiring MailConfigurationRepository 2>&1 | head -5
|
|
docker exec php-lesstime-fpm php bin/console debug:autowiring MailSyncService 2>&1 | head -5
|
|
docker exec php-lesstime-fpm php bin/console debug:autowiring MailProviderInterface 2>&1 | head -5
|
|
```
|
|
|
|
Attendu : les trois services sont présents (construits en Phase 1 et 2).
|
|
|
|
---
|
|
|
|
### Task 2 : ApiResource `MailSettings` + Provider/Processor singleton
|
|
|
|
L'objectif est d'exposer `GET /api/mail/configuration` et `PATCH /api/mail/configuration` avec le même pattern que `ZimbraSettings` (voir `src/ApiResource/ZimbraSettings.php`). Le password n'est **jamais** retourné en clair — seulement `hasPassword: bool`.
|
|
|
|
- [ ] **Step 1 : Écrire le test fonctionnel (TDD — doit échouer)**
|
|
|
|
Créer `tests/Functional/Controller/Mail/MailSettingsControllerTest.php` :
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Functional\Controller\Mail;
|
|
|
|
use App\Entity\User;
|
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
|
|
|
class MailSettingsControllerTest extends WebTestCase
|
|
{
|
|
public function testGetConfigurationReturns401WhenNotAuthenticated(): void
|
|
{
|
|
$client = static::createClient();
|
|
$client->request('GET', '/api/mail/configuration');
|
|
|
|
self::assertResponseStatusCodeSame(401);
|
|
}
|
|
|
|
public function testGetConfigurationReturns403ForRoleUser(): void
|
|
{
|
|
$client = static::createClient();
|
|
$container = static::getContainer();
|
|
$em = $container->get('doctrine.orm.entity_manager');
|
|
|
|
$user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
|
|
$client->loginUser($user);
|
|
$client->request('GET', '/api/mail/configuration');
|
|
|
|
self::assertResponseStatusCodeSame(403);
|
|
}
|
|
|
|
public function testGetConfigurationReturns200ForAdmin(): void
|
|
{
|
|
$client = static::createClient();
|
|
$container = static::getContainer();
|
|
$em = $container->get('doctrine.orm.entity_manager');
|
|
|
|
$admin = $em->getRepository(User::class)->findOneBy(['username' => 'admin']);
|
|
$client->loginUser($admin);
|
|
$client->request('GET', '/api/mail/configuration');
|
|
|
|
self::assertResponseIsSuccessful();
|
|
$data = json_decode($client->getResponse()->getContent(), true);
|
|
|
|
// Password jamais en clair
|
|
self::assertArrayNotHasKey('password', $data);
|
|
self::assertArrayNotHasKey('encryptedPassword', $data);
|
|
self::assertArrayHasKey('hasPassword', $data);
|
|
self::assertArrayHasKey('imapHost', $data);
|
|
self::assertArrayHasKey('enabled', $data);
|
|
}
|
|
|
|
public function testPatchConfigurationReturns403ForRoleUser(): void
|
|
{
|
|
$client = static::createClient();
|
|
$container = static::getContainer();
|
|
$em = $container->get('doctrine.orm.entity_manager');
|
|
|
|
$user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
|
|
$client->loginUser($user);
|
|
$client->request(
|
|
'PATCH',
|
|
'/api/mail/configuration',
|
|
[],
|
|
[],
|
|
['CONTENT_TYPE' => 'application/merge-patch+json'],
|
|
json_encode(['enabled' => false])
|
|
);
|
|
|
|
self::assertResponseStatusCodeSame(403);
|
|
}
|
|
|
|
public function testPatchConfigurationUpdatesFieldsForAdmin(): void
|
|
{
|
|
$client = static::createClient();
|
|
$container = static::getContainer();
|
|
$em = $container->get('doctrine.orm.entity_manager');
|
|
|
|
$admin = $em->getRepository(User::class)->findOneBy(['username' => 'admin']);
|
|
$client->loginUser($admin);
|
|
$client->request(
|
|
'PATCH',
|
|
'/api/mail/configuration',
|
|
[],
|
|
[],
|
|
['CONTENT_TYPE' => 'application/merge-patch+json'],
|
|
json_encode(['imapHost' => 'imap.example.com', 'enabled' => false])
|
|
);
|
|
|
|
self::assertResponseIsSuccessful();
|
|
$data = json_decode($client->getResponse()->getContent(), true);
|
|
self::assertSame('imap.example.com', $data['imapHost']);
|
|
self::assertArrayNotHasKey('password', $data);
|
|
}
|
|
|
|
public function testPatchConfigurationWithPasswordEncryptsIt(): void
|
|
{
|
|
$client = static::createClient();
|
|
$container = static::getContainer();
|
|
$em = $container->get('doctrine.orm.entity_manager');
|
|
|
|
$admin = $em->getRepository(User::class)->findOneBy(['username' => 'admin']);
|
|
$client->loginUser($admin);
|
|
$client->request(
|
|
'PATCH',
|
|
'/api/mail/configuration',
|
|
[],
|
|
[],
|
|
['CONTENT_TYPE' => 'application/merge-patch+json'],
|
|
json_encode(['password' => 'secret123'])
|
|
);
|
|
|
|
self::assertResponseIsSuccessful();
|
|
$data = json_decode($client->getResponse()->getContent(), true);
|
|
self::assertTrue($data['hasPassword']);
|
|
self::assertArrayNotHasKey('password', $data);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2 : Lancer le test pour vérifier qu'il échoue**
|
|
|
|
```bash
|
|
docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Controller/Mail/MailSettingsControllerTest.php 2>&1 | tail -20
|
|
```
|
|
|
|
Attendu : 404 (route non créée).
|
|
|
|
- [ ] **Step 3 : Créer `src/ApiResource/MailSettings.php`**
|
|
|
|
Calqué exactement sur `src/ApiResource/ZimbraSettings.php`, mais pour `MailConfiguration` :
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\ApiResource;
|
|
|
|
use ApiPlatform\Metadata\ApiResource;
|
|
use ApiPlatform\Metadata\Get;
|
|
use ApiPlatform\Metadata\Patch;
|
|
use App\State\Mail\MailSettingsProcessor;
|
|
use App\State\Mail\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;
|
|
|
|
/** Write-only: jamais retourné en lecture */
|
|
#[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;
|
|
|
|
/** Lecture seule : indique si un password chiffré existe */
|
|
#[Groups(['mail_settings:read'])]
|
|
public bool $hasPassword = false;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4 : Créer `src/State/Mail/MailSettingsProvider.php`**
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\State\Mail;
|
|
|
|
use ApiPlatform\Metadata\Operation;
|
|
use ApiPlatform\State\ProviderInterface;
|
|
use App\ApiResource\MailSettings;
|
|
use App\Repository\MailConfigurationRepository;
|
|
|
|
final readonly class MailSettingsProvider implements ProviderInterface
|
|
{
|
|
public function __construct(
|
|
private MailConfigurationRepository $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();
|
|
// password JAMAIS retourné
|
|
}
|
|
|
|
return $dto;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5 : Créer `src/State/Mail/MailSettingsProcessor.php`**
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\State\Mail;
|
|
|
|
use ApiPlatform\Metadata\Operation;
|
|
use ApiPlatform\State\ProcessorInterface;
|
|
use App\ApiResource\MailSettings;
|
|
use App\Entity\MailConfiguration;
|
|
use App\Repository\MailConfigurationRepository;
|
|
use App\Service\TokenEncryptor;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
|
|
final readonly class MailSettingsProcessor implements ProcessorInterface
|
|
{
|
|
public function __construct(
|
|
private EntityManagerInterface $em,
|
|
private MailConfigurationRepository $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);
|
|
|
|
// Password : seulement si fourni non-vide
|
|
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();
|
|
// password JAMAIS retourné
|
|
|
|
return $result;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6 : Vérifier la syntaxe PHP**
|
|
|
|
```bash
|
|
docker exec php-lesstime-fpm php -l src/ApiResource/MailSettings.php
|
|
docker exec php-lesstime-fpm php -l src/State/Mail/MailSettingsProvider.php
|
|
docker exec php-lesstime-fpm php -l src/State/Mail/MailSettingsProcessor.php
|
|
```
|
|
|
|
Attendu : `No syntax errors detected` pour les trois.
|
|
|
|
- [ ] **Step 7 : Vider le cache et relancer les tests**
|
|
|
|
```bash
|
|
make cache-clear
|
|
docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Controller/Mail/MailSettingsControllerTest.php 2>&1 | tail -20
|
|
```
|
|
|
|
Attendu : tous les tests passent.
|
|
|
|
- [ ] **Step 8 : Commit**
|
|
|
|
```bash
|
|
make php-cs-fixer-allow-risky
|
|
git add src/ApiResource/MailSettings.php src/State/Mail/ tests/Functional/Controller/Mail/MailSettingsControllerTest.php
|
|
git commit -m "feat(mail) : MailSettings ApiResource singleton (GET/PATCH /api/mail/configuration)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3 : Custom controller `MailTestConnectionController`
|
|
|
|
Endpoint : `POST /api/mail/configuration/test` (ROLE_ADMIN uniquement). Instancie `ImapMailProvider` via injection, appelle `listFolders()`, retourne `{ ok: true, foldersCount: N }` ou `{ ok: false, error: "..." }` sans leak du message d'erreur interne.
|
|
|
|
- [ ] **Step 1 : Créer `src/Controller/Mail/MailTestConnectionController.php`**
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Controller\Mail;
|
|
|
|
use App\Mail\Exception\MailProviderException;
|
|
use App\Mail\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 {
|
|
$folders = $this->mailProvider->listFolders();
|
|
|
|
return $this->json([
|
|
'ok' => true,
|
|
'foldersCount' => count($folders),
|
|
]);
|
|
} catch (MailProviderException $e) {
|
|
return $this->json([
|
|
'ok' => false,
|
|
'error' => 'Connexion IMAP impossible. Vérifiez la configuration.',
|
|
], 200); // 200 intentionnel : c'est un résultat de test, pas une erreur HTTP
|
|
} catch (Throwable) {
|
|
return $this->json([
|
|
'ok' => false,
|
|
'error' => 'Erreur inattendue lors du test de connexion.',
|
|
], 200);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
> Note : `priority: 1` est **obligatoire** pour éviter le conflit avec la route API Platform `{id}` qui capte tous les paths sous `/api/mail/`.
|
|
|
|
- [ ] **Step 2 : Vérifier la syntaxe PHP**
|
|
|
|
```bash
|
|
docker exec php-lesstime-fpm php -l src/Controller/Mail/MailTestConnectionController.php
|
|
```
|
|
|
|
- [ ] **Step 3 : Vérifier que la route est enregistrée**
|
|
|
|
```bash
|
|
docker exec php-lesstime-fpm php bin/console debug:router | grep mail_configuration_test
|
|
```
|
|
|
|
Attendu : route `mail_configuration_test` visible avec méthode POST.
|
|
|
|
- [ ] **Step 4 : Commit**
|
|
|
|
```bash
|
|
make php-cs-fixer-allow-risky
|
|
git add src/Controller/Mail/MailTestConnectionController.php
|
|
git commit -m "feat(mail) : MailTestConnectionController — POST /api/mail/configuration/test"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4 : Service `MailAccessChecker`
|
|
|
|
Service réutilisable par tous les controllers "métier" mail. Vérifie que l'utilisateur est `ROLE_USER` ou `ROLE_ADMIN` ET n'est pas `ROLE_CLIENT` pur (sans `ROLE_ADMIN`). Rappel : la hiérarchie de sécurité donne `ROLE_USER` aux ADMIN, mais un `ROLE_CLIENT` pur n'a pas `ROLE_USER`.
|
|
|
|
- [ ] **Step 1 : Créer `src/Security/MailAccessChecker.php`**
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Security;
|
|
|
|
use App\Entity\User;
|
|
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,
|
|
) {}
|
|
|
|
/**
|
|
* Vérifie que l'utilisateur courant peut accéder aux endpoints mail.
|
|
* Autorisé : ROLE_USER, ROLE_ADMIN.
|
|
* Refusé : ROLE_CLIENT pur (sans ROLE_ADMIN), non authentifié.
|
|
*
|
|
* @throws AccessDeniedException
|
|
*/
|
|
public function ensureCanAccessMail(?UserInterface $user): void
|
|
{
|
|
if (!$user instanceof User) {
|
|
throw new AccessDeniedException('Authentication required');
|
|
}
|
|
|
|
$roles = $user->getRoles();
|
|
|
|
if (in_array('ROLE_CLIENT', $roles, true) && !in_array('ROLE_ADMIN', $roles, true)) {
|
|
throw new AccessDeniedException('Mail not accessible to clients');
|
|
}
|
|
|
|
if (!in_array('ROLE_USER', $roles, true) && !in_array('ROLE_ADMIN', $roles, true)) {
|
|
throw new AccessDeniedException('ROLE_USER required');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Vérifie que l'utilisateur est ROLE_ADMIN.
|
|
*
|
|
* @throws AccessDeniedException
|
|
*/
|
|
public function ensureIsAdmin(?UserInterface $user): void
|
|
{
|
|
if (!$user instanceof User || !$this->authorizationChecker->isGranted('ROLE_ADMIN')) {
|
|
throw new AccessDeniedException('Admin only');
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2 : Vérifier la syntaxe PHP**
|
|
|
|
```bash
|
|
docker exec php-lesstime-fpm php -l src/Security/MailAccessChecker.php
|
|
```
|
|
|
|
- [ ] **Step 3 : Commit**
|
|
|
|
```bash
|
|
make php-cs-fixer-allow-risky
|
|
git add src/Security/MailAccessChecker.php
|
|
git commit -m "feat(mail) : MailAccessChecker — vérification accès mail ROLE_USER/ROLE_ADMIN (refus ROLE_CLIENT pur)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5 : Custom controller `MailFoldersListController`
|
|
|
|
Endpoint : `GET /api/mail/folders`. Lit la BDD (pas l'IMAP live), retourne l'arbre des dossiers avec `unreadCount`.
|
|
|
|
- [ ] **Step 1 : Créer `tests/Functional/Controller/Mail/MailFoldersControllerTest.php`**
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Functional\Controller\Mail;
|
|
|
|
use App\Entity\User;
|
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
|
|
|
class MailFoldersControllerTest extends WebTestCase
|
|
{
|
|
public function testListFoldersReturns401WhenNotAuthenticated(): void
|
|
{
|
|
$client = static::createClient();
|
|
$client->request('GET', '/api/mail/folders');
|
|
|
|
self::assertResponseStatusCodeSame(401);
|
|
}
|
|
|
|
public function testListFoldersReturns403ForRoleClient(): void
|
|
{
|
|
$client = static::createClient();
|
|
$container = static::getContainer();
|
|
$em = $container->get('doctrine.orm.entity_manager');
|
|
|
|
$clientUser = $em->getRepository(User::class)->findOneBy(['username' => 'client-liot']);
|
|
$client->loginUser($clientUser);
|
|
$client->request('GET', '/api/mail/folders');
|
|
|
|
self::assertResponseStatusCodeSame(403);
|
|
}
|
|
|
|
public function testListFoldersReturns200ForRoleUser(): void
|
|
{
|
|
$client = static::createClient();
|
|
$container = static::getContainer();
|
|
$em = $container->get('doctrine.orm.entity_manager');
|
|
|
|
$user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
|
|
$client->loginUser($user);
|
|
$client->request('GET', '/api/mail/folders');
|
|
|
|
self::assertResponseIsSuccessful();
|
|
$data = json_decode($client->getResponse()->getContent(), true);
|
|
self::assertIsArray($data);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2 : Créer `src/Controller/Mail/MailFoldersListController.php`**
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Controller\Mail;
|
|
|
|
use App\Repository\MailFolderRepository;
|
|
use App\Security\MailAccessChecker;
|
|
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 MailFolderRepository $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);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3 : Vérifier la syntaxe et la route**
|
|
|
|
```bash
|
|
docker exec php-lesstime-fpm php -l src/Controller/Mail/MailFoldersListController.php
|
|
make cache-clear
|
|
docker exec php-lesstime-fpm php bin/console debug:router | grep mail_folders_list
|
|
```
|
|
|
|
- [ ] **Step 4 : Relancer les tests**
|
|
|
|
```bash
|
|
docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Controller/Mail/MailFoldersControllerTest.php 2>&1 | tail -15
|
|
```
|
|
|
|
Attendu : tous les tests passent.
|
|
|
|
- [ ] **Step 5 : Commit**
|
|
|
|
```bash
|
|
make php-cs-fixer-allow-risky
|
|
git add src/Controller/Mail/MailFoldersListController.php tests/Functional/Controller/Mail/MailFoldersControllerTest.php
|
|
git commit -m "feat(mail) : MailFoldersListController — GET /api/mail/folders (arbre BDD + unreadCount)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6 : Custom controller `MailMessagesListController` + pagination cursor
|
|
|
|
Endpoint : `GET /api/mail/folders/{folderPath}/messages?cursor=&limit=50`. Pagination cursor `sentAt DESC, id DESC`. `folderPath` est URL-encodé.
|
|
|
|
- [ ] **Step 1 : Ajouter `findByFolderCursor` dans `MailMessageRepository`**
|
|
|
|
Ouvrir `src/Repository/MailMessageRepository.php` et ajouter :
|
|
|
|
```php
|
|
/**
|
|
* Pagination cursor: retourne `$limit` messages après le cursor `sentAt DESC, id DESC`.
|
|
* Cursor format: "sentAt_iso8601:id" — null = première 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); // +1 pour détecter s'il y a une page suivante
|
|
|
|
if (null !== $cursor) {
|
|
// cursor = base64url(sentAt_iso:id)
|
|
$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];
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2 : Créer `src/Controller/Mail/MailMessagesListController.php`**
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Controller\Mail;
|
|
|
|
use App\Repository\MailFolderRepository;
|
|
use App\Repository\MailMessageRepository;
|
|
use App\Security\MailAccessChecker;
|
|
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 MailFolderRepository $folderRepository,
|
|
private readonly MailMessageRepository $messageRepository,
|
|
private readonly MailAccessChecker $accessChecker,
|
|
) {}
|
|
|
|
public function __invoke(Request $request, string $folderPath): JsonResponse
|
|
{
|
|
$this->accessChecker->ensureCanAccessMail($this->getUser());
|
|
|
|
// folderPath peut être URL-encodé si contient des slashes
|
|
$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'],
|
|
]);
|
|
}
|
|
}
|
|
```
|
|
|
|
> Note : `requirements: ['folderPath' => '.+']` permet à `folderPath` de contenir des slashes (ex. `INBOX/Subfolder`). Les slashes seront capturés dans le paramètre de route.
|
|
|
|
- [ ] **Step 3 : Vérifier la syntaxe et la route**
|
|
|
|
```bash
|
|
docker exec php-lesstime-fpm php -l src/Controller/Mail/MailMessagesListController.php
|
|
make cache-clear
|
|
docker exec php-lesstime-fpm php bin/console debug:router | grep mail_messages_list
|
|
```
|
|
|
|
- [ ] **Step 4 : Commit**
|
|
|
|
```bash
|
|
make php-cs-fixer-allow-risky
|
|
git add src/Controller/Mail/MailMessagesListController.php src/Repository/MailMessageRepository.php
|
|
git commit -m "feat(mail) : MailMessagesListController — GET /api/mail/folders/{path}/messages (pagination cursor)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7 : Custom controller `MailMessageDetailController` (body live + cache 5 min)
|
|
|
|
Endpoint : `GET /api/mail/messages/{id}`. Fetche le corps du mail en live IMAP via `ImapMailProvider::fetchMessage()` avec cache Symfony `mail_body_{md5(messageId)}` TTL 5 min. L'`{id}` est l'id BDD du `MailMessage`.
|
|
|
|
- [ ] **Step 1 : Créer `tests/Functional/Controller/Mail/MailMessagesControllerTest.php`**
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Functional\Controller\Mail;
|
|
|
|
use App\Entity\User;
|
|
use App\Mail\MailProviderInterface;
|
|
use App\Mail\Dto\MailMessageDetailDto;
|
|
use App\Mail\Dto\MailMessageHeaderDto;
|
|
use DateTimeImmutable;
|
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
|
|
|
class MailMessagesControllerTest extends WebTestCase
|
|
{
|
|
public function testGetMessageDetailReturns401WhenNotAuthenticated(): void
|
|
{
|
|
$client = static::createClient();
|
|
$client->request('GET', '/api/mail/messages/999');
|
|
|
|
self::assertResponseStatusCodeSame(401);
|
|
}
|
|
|
|
public function testGetMessageDetailReturns403ForRoleClient(): void
|
|
{
|
|
$client = static::createClient();
|
|
$container = static::getContainer();
|
|
$em = $container->get('doctrine.orm.entity_manager');
|
|
|
|
$clientUser = $em->getRepository(User::class)->findOneBy(['username' => 'client-liot']);
|
|
$client->loginUser($clientUser);
|
|
$client->request('GET', '/api/mail/messages/999');
|
|
|
|
self::assertResponseStatusCodeSame(403);
|
|
}
|
|
|
|
public function testGetMessageDetailReturns404WhenMessageNotFound(): void
|
|
{
|
|
$client = static::createClient();
|
|
$container = static::getContainer();
|
|
$em = $container->get('doctrine.orm.entity_manager');
|
|
|
|
$user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
|
|
$client->loginUser($user);
|
|
$client->request('GET', '/api/mail/messages/99999');
|
|
|
|
self::assertResponseStatusCodeSame(404);
|
|
}
|
|
|
|
public function testMarkReadReturns401WhenNotAuthenticated(): void
|
|
{
|
|
$client = static::createClient();
|
|
$client->request('POST', '/api/mail/messages/1/read', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode(['read' => true]));
|
|
|
|
self::assertResponseStatusCodeSame(401);
|
|
}
|
|
|
|
public function testMarkReadReturns403ForRoleClient(): void
|
|
{
|
|
$client = static::createClient();
|
|
$container = static::getContainer();
|
|
$em = $container->get('doctrine.orm.entity_manager');
|
|
|
|
$clientUser = $em->getRepository(User::class)->findOneBy(['username' => 'client-liot']);
|
|
$client->loginUser($clientUser);
|
|
$client->request('POST', '/api/mail/messages/1/read', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode(['read' => true]));
|
|
|
|
self::assertResponseStatusCodeSame(403);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2 : Créer `src/Controller/Mail/MailMessageDetailController.php`**
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Controller\Mail;
|
|
|
|
use App\Mail\Exception\MailProviderException;
|
|
use App\Mail\MailProviderInterface;
|
|
use App\Repository\MailMessageRepository;
|
|
use App\Security\MailAccessChecker;
|
|
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 MailMessageRepository $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->find($id);
|
|
if (null === $message) {
|
|
throw new NotFoundHttpException('Message not found');
|
|
}
|
|
|
|
// Cache key basé sur messageId (md5 pour sanitiser les caractères spéciaux)
|
|
$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); // 5 minutes
|
|
$this->cache->save($item);
|
|
} catch (MailProviderException $e) {
|
|
throw new ServiceUnavailableHttpException(null, 'IMAP unavailable: could not fetch message body');
|
|
}
|
|
}
|
|
|
|
$detail = $item->get();
|
|
|
|
// Sérialiser les attachments (sans contenu binaire — seulement metadata)
|
|
$attachments = array_map(static fn ($att) => [
|
|
'partNumber' => $att->partNumber,
|
|
'filename' => $att->filename,
|
|
'mimeType' => $att->mimeType,
|
|
'size' => $att->size,
|
|
// URL téléchargement : /api/mail/attachments/{messageId_base64}:{partNumber_base64}
|
|
'downloadId' => rtrim(strtr(base64_encode($message->getId().':'.$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, // ATTENTION : côté frontend, sanitiser via DOMPurify
|
|
'bodyText' => $detail->bodyText,
|
|
'attachments' => $attachments,
|
|
]);
|
|
}
|
|
}
|
|
```
|
|
|
|
> Note sur le `CacheItemPoolInterface` : Symfony injectera le pool par défaut `cache.app`. Aucune configuration supplémentaire nécessaire — `cache.app` est défini dans le framework par défaut.
|
|
|
|
- [ ] **Step 3 : Vérifier la syntaxe et la route**
|
|
|
|
```bash
|
|
docker exec php-lesstime-fpm php -l src/Controller/Mail/MailMessageDetailController.php
|
|
make cache-clear
|
|
docker exec php-lesstime-fpm php bin/console debug:router | grep mail_message_detail
|
|
```
|
|
|
|
- [ ] **Step 4 : Relancer les tests**
|
|
|
|
```bash
|
|
docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Controller/Mail/MailMessagesControllerTest.php 2>&1 | tail -20
|
|
```
|
|
|
|
Attendu : les tests 401/403/404 passent.
|
|
|
|
- [ ] **Step 5 : Commit**
|
|
|
|
```bash
|
|
make php-cs-fixer-allow-risky
|
|
git add src/Controller/Mail/MailMessageDetailController.php tests/Functional/Controller/Mail/MailMessagesControllerTest.php
|
|
git commit -m "feat(mail) : MailMessageDetailController — GET /api/mail/messages/{id} (live IMAP + cache 5 min)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8 : Custom controllers `MailMessageReadController` + `MailMessageFlagController`
|
|
|
|
Endpoints : `POST /api/mail/messages/{id}/read` (body `{ "read": bool }`) et `POST /api/mail/messages/{id}/flag` (body `{ "flagged": bool }`). Appelle le provider IMAP + met à jour la BDD.
|
|
|
|
- [ ] **Step 1 : Créer `src/Controller/Mail/MailMessageReadController.php`**
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Controller\Mail;
|
|
|
|
use App\Mail\Exception\MailProviderException;
|
|
use App\Mail\MailProviderInterface;
|
|
use App\Repository\MailMessageRepository;
|
|
use App\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 MailMessageRepository $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->find($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 même à jour la BDD (sync IMAP au prochain cycle)
|
|
}
|
|
|
|
$message->setIsRead($read);
|
|
$this->em->flush();
|
|
|
|
return $this->json(['id' => $message->getId(), 'isRead' => $message->isRead()]);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2 : Créer `src/Controller/Mail/MailMessageFlagController.php`**
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Controller\Mail;
|
|
|
|
use App\Mail\Exception\MailProviderException;
|
|
use App\Mail\MailProviderInterface;
|
|
use App\Repository\MailMessageRepository;
|
|
use App\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 MailMessageRepository $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->find($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()]);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3 : Vérifier la syntaxe**
|
|
|
|
```bash
|
|
docker exec php-lesstime-fpm php -l src/Controller/Mail/MailMessageReadController.php
|
|
docker exec php-lesstime-fpm php -l src/Controller/Mail/MailMessageFlagController.php
|
|
```
|
|
|
|
- [ ] **Step 4 : Commit**
|
|
|
|
```bash
|
|
make php-cs-fixer-allow-risky
|
|
git add src/Controller/Mail/MailMessageReadController.php src/Controller/Mail/MailMessageFlagController.php
|
|
git commit -m "feat(mail) : MailMessageReadController + MailMessageFlagController — POST .../read et .../flag"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9 : Custom controller `MailCreateTaskController`
|
|
|
|
Endpoint : `POST /api/mail/messages/{id}/create-task` (body `{ "projectId": 1, "taskGroupId": null, "priorityId": null }`). Crée une `Task` Doctrine (via `TaskNumberProcessor` si applicable, sinon directement), crée `TaskMailLink`, retourne l'id de la tâche créée.
|
|
|
|
- [ ] **Step 1 : Vérifier si `TaskNumberProcessor` ou un `TaskService` existe**
|
|
|
|
```bash
|
|
docker exec php-lesstime-fpm php bin/console debug:autowiring TaskNumber 2>&1 | head -10
|
|
ls /home/r-dev/malio-dev/Lesstime/src/State/ | grep -i task
|
|
ls /home/r-dev/malio-dev/Lesstime/src/Service/ | grep -i task
|
|
```
|
|
|
|
Selon le résultat :
|
|
- Si `TaskNumberProcessor` est un `State/Processor` API Platform (pas un service injectable) → créer la Task directement avec persist+flush, sans passer par le processor.
|
|
- Si un `TaskService` injectable existe → l'utiliser.
|
|
|
|
Dans tous les cas, le numéro de tâche doit suivre la convention du projet (incrément par projet). Vérifier comment `Task::number` est assigné dans l'existant et reproduire le même mécanisme.
|
|
|
|
- [ ] **Step 2 : Créer `src/Controller/Mail/MailCreateTaskController.php`**
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Controller\Mail;
|
|
|
|
use App\Entity\Task;
|
|
use App\Entity\TaskMailLink;
|
|
use App\Repository\MailMessageRepository;
|
|
use App\Repository\TaskGroupRepository;
|
|
use App\Repository\TaskPriorityRepository;
|
|
use App\Repository\TaskRepository;
|
|
use App\Security\MailAccessChecker;
|
|
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 MailMessageRepository $messageRepository,
|
|
private readonly EntityManagerInterface $em,
|
|
private readonly MailAccessChecker $accessChecker,
|
|
private readonly TaskRepository $taskRepository,
|
|
) {}
|
|
|
|
public function __invoke(Request $request, int $id): JsonResponse
|
|
{
|
|
$this->accessChecker->ensureCanAccessMail($this->getUser());
|
|
|
|
$message = $this->messageRepository->find($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(\App\Entity\Project::class)->find($projectId);
|
|
if (null === $project) {
|
|
throw new NotFoundHttpException('Project not found');
|
|
}
|
|
|
|
// Numéro de tâche : max existant + 1 pour ce projet
|
|
$maxNumber = $this->taskRepository->findMaxNumberForProject($project);
|
|
$number = $maxNumber + 1;
|
|
|
|
// Titre = subject du mail (tronqué à 500 chars si nécessaire)
|
|
$title = $message->getSubject() ?? 'Mail sans sujet';
|
|
if (mb_strlen($title) > 500) {
|
|
$title = mb_substr($title, 0, 497).'...';
|
|
}
|
|
|
|
$task = new Task();
|
|
$task->setProject($project);
|
|
$task->setTitle($title);
|
|
$task->setNumber($number);
|
|
$task->setCreatedAt(new DateTimeImmutable());
|
|
|
|
// TaskGroup optionnel
|
|
if (isset($body['taskGroupId']) && null !== $body['taskGroupId']) {
|
|
$taskGroup = $this->em->getRepository(\App\Entity\TaskGroup::class)->find($body['taskGroupId']);
|
|
if (null !== $taskGroup) {
|
|
$task->setTaskGroup($taskGroup);
|
|
}
|
|
}
|
|
|
|
// Priorité optionnelle
|
|
if (isset($body['priorityId']) && null !== $body['priorityId']) {
|
|
$priority = $this->em->getRepository(\App\Entity\TaskPriority::class)->find($body['priorityId']);
|
|
if (null !== $priority) {
|
|
$task->setPriority($priority);
|
|
}
|
|
}
|
|
|
|
// Créateur = user courant
|
|
$task->setCreatedBy($this->getUser());
|
|
|
|
$this->em->persist($task);
|
|
|
|
// Lien mail ↔ tâche
|
|
$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([
|
|
'taskId' => $task->getId(),
|
|
'taskNumber' => $task->getNumber(),
|
|
'taskTitle' => $task->getTitle(),
|
|
'messageId' => $message->getId(),
|
|
], 201);
|
|
}
|
|
}
|
|
```
|
|
|
|
> **Note importante :** `Task::setNumber()`, `Task::setCreatedAt()`, `Task::setCreatedBy()`, etc. : vérifier les setters réels de l'entité `Task` avant de coder. Si des champs obligatoires manquent (ex. `status`), injecter le repository correspondant et récupérer le statut par défaut ("À faire" ou premier statut du projet). Adapter selon la structure réelle de `Task`.
|
|
|
|
> **`TaskRepository::findMaxNumberForProject`** : vérifier si cette méthode existe. Sinon la créer :
|
|
> ```php
|
|
> public function findMaxNumberForProject(Project $project): int
|
|
> {
|
|
> $result = $this->createQueryBuilder('t')
|
|
> ->select('MAX(t.number)')
|
|
> ->andWhere('t.project = :project')
|
|
> ->setParameter('project', $project)
|
|
> ->getQuery()
|
|
> ->getSingleScalarResult();
|
|
> return (int) ($result ?? 0);
|
|
> }
|
|
> ```
|
|
|
|
- [ ] **Step 3 : Vérifier la syntaxe et adapter les setters Task**
|
|
|
|
```bash
|
|
# Vérifier les champs obligatoires de Task
|
|
docker exec php-lesstime-fpm php bin/console doctrine:mapping:info 2>&1 | grep Task
|
|
cat src/Entity/Task.php | grep 'private\|public' | head -40
|
|
docker exec php-lesstime-fpm php -l src/Controller/Mail/MailCreateTaskController.php
|
|
```
|
|
|
|
- [ ] **Step 4 : Commit**
|
|
|
|
```bash
|
|
make php-cs-fixer-allow-risky
|
|
git add src/Controller/Mail/MailCreateTaskController.php src/Repository/TaskRepository.php
|
|
git commit -m "feat(mail) : MailCreateTaskController — POST /api/mail/messages/{id}/create-task"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10 : Custom controllers `MailLinkTaskController` + `MailUnlinkTaskController` + `TaskMailsListController`
|
|
|
|
Trois endpoints liés à la gestion des liens mail ↔ tâche :
|
|
- `POST /api/mail/messages/{id}/link-task` (body `{ "taskId": 1 }`)
|
|
- `DELETE /api/mail/messages/{id}/link-task/{taskId}`
|
|
- `GET /api/tasks/{id}/mails`
|
|
|
|
- [ ] **Step 1 : Créer `src/Controller/Mail/MailLinkTaskController.php`**
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Controller\Mail;
|
|
|
|
use App\Entity\Task;
|
|
use App\Entity\TaskMailLink;
|
|
use App\Repository\MailMessageRepository;
|
|
use App\Repository\TaskMailLinkRepository;
|
|
use App\Security\MailAccessChecker;
|
|
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 MailMessageRepository $messageRepository,
|
|
private readonly TaskMailLinkRepository $linkRepository,
|
|
private readonly EntityManagerInterface $em,
|
|
private readonly MailAccessChecker $accessChecker,
|
|
) {}
|
|
|
|
public function __invoke(Request $request, int $id): JsonResponse
|
|
{
|
|
$this->accessChecker->ensureCanAccessMail($this->getUser());
|
|
|
|
$message = $this->messageRepository->find($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->em->getRepository(Task::class)->find($taskId);
|
|
if (null === $task) {
|
|
throw new NotFoundHttpException('Task not found');
|
|
}
|
|
|
|
// Éviter le doublon (contrainte unique en BDD, mais prévenir le 500)
|
|
$existing = $this->linkRepository->findByTaskAndMessage($task, $message);
|
|
if (null !== $existing) {
|
|
return $this->json(['message' => 'Already linked'], 200);
|
|
}
|
|
|
|
$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);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2 : Créer `src/Controller/Mail/MailUnlinkTaskController.php`**
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Controller\Mail;
|
|
|
|
use App\Entity\Task;
|
|
use App\Repository\MailMessageRepository;
|
|
use App\Repository\TaskMailLinkRepository;
|
|
use App\Security\MailAccessChecker;
|
|
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 MailMessageRepository $messageRepository,
|
|
private readonly TaskMailLinkRepository $linkRepository,
|
|
private readonly EntityManagerInterface $em,
|
|
private readonly MailAccessChecker $accessChecker,
|
|
) {}
|
|
|
|
public function __invoke(int $id, int $taskId): JsonResponse
|
|
{
|
|
$this->accessChecker->ensureCanAccessMail($this->getUser());
|
|
|
|
$message = $this->messageRepository->find($id);
|
|
if (null === $message) {
|
|
throw new NotFoundHttpException('Message not found');
|
|
}
|
|
|
|
$task = $this->em->getRepository(Task::class)->find($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);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3 : Créer `src/Controller/Mail/TaskMailsListController.php`**
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Controller\Mail;
|
|
|
|
use App\Entity\Task;
|
|
use App\Repository\TaskMailLinkRepository;
|
|
use App\Security\MailAccessChecker;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
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 EntityManagerInterface $em,
|
|
private readonly TaskMailLinkRepository $linkRepository,
|
|
private readonly MailAccessChecker $accessChecker,
|
|
) {}
|
|
|
|
public function __invoke(int $id): JsonResponse
|
|
{
|
|
$this->accessChecker->ensureCanAccessMail($this->getUser());
|
|
|
|
$task = $this->em->getRepository(Task::class)->find($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);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4 : Créer le test fonctionnel pour l'intégration tâches**
|
|
|
|
Créer `tests/Functional/Controller/Mail/MailTaskIntegrationControllerTest.php` :
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Functional\Controller\Mail;
|
|
|
|
use App\Entity\User;
|
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
|
|
|
class MailTaskIntegrationControllerTest extends WebTestCase
|
|
{
|
|
public function testLinkTaskReturns401WhenNotAuthenticated(): void
|
|
{
|
|
$client = static::createClient();
|
|
$client->request('POST', '/api/mail/messages/1/link-task', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode(['taskId' => 1]));
|
|
|
|
self::assertResponseStatusCodeSame(401);
|
|
}
|
|
|
|
public function testLinkTaskReturns403ForRoleClient(): void
|
|
{
|
|
$client = static::createClient();
|
|
$container = static::getContainer();
|
|
$em = $container->get('doctrine.orm.entity_manager');
|
|
|
|
$clientUser = $em->getRepository(User::class)->findOneBy(['username' => 'client-liot']);
|
|
$client->loginUser($clientUser);
|
|
$client->request('POST', '/api/mail/messages/1/link-task', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode(['taskId' => 1]));
|
|
|
|
self::assertResponseStatusCodeSame(403);
|
|
}
|
|
|
|
public function testUnlinkTaskReturns401WhenNotAuthenticated(): void
|
|
{
|
|
$client = static::createClient();
|
|
$client->request('DELETE', '/api/mail/messages/1/link-task/1');
|
|
|
|
self::assertResponseStatusCodeSame(401);
|
|
}
|
|
|
|
public function testTaskMailsListReturns401WhenNotAuthenticated(): void
|
|
{
|
|
$client = static::createClient();
|
|
$client->request('GET', '/api/tasks/1/mails');
|
|
|
|
self::assertResponseStatusCodeSame(401);
|
|
}
|
|
|
|
public function testTaskMailsListReturns403ForRoleClient(): void
|
|
{
|
|
$client = static::createClient();
|
|
$container = static::getContainer();
|
|
$em = $container->get('doctrine.orm.entity_manager');
|
|
|
|
$clientUser = $em->getRepository(User::class)->findOneBy(['username' => 'client-liot']);
|
|
$client->loginUser($clientUser);
|
|
$client->request('GET', '/api/tasks/1/mails');
|
|
|
|
self::assertResponseStatusCodeSame(403);
|
|
}
|
|
|
|
public function testCreateTaskReturns401WhenNotAuthenticated(): void
|
|
{
|
|
$client = static::createClient();
|
|
$client->request('POST', '/api/mail/messages/1/create-task', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode(['projectId' => 1]));
|
|
|
|
self::assertResponseStatusCodeSame(401);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5 : Vérifier la syntaxe des 3 controllers**
|
|
|
|
```bash
|
|
docker exec php-lesstime-fpm php -l src/Controller/Mail/MailLinkTaskController.php
|
|
docker exec php-lesstime-fpm php -l src/Controller/Mail/MailUnlinkTaskController.php
|
|
docker exec php-lesstime-fpm php -l src/Controller/Mail/TaskMailsListController.php
|
|
make cache-clear
|
|
```
|
|
|
|
- [ ] **Step 6 : Relancer les tests**
|
|
|
|
```bash
|
|
docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Controller/Mail/MailTaskIntegrationControllerTest.php 2>&1 | tail -15
|
|
```
|
|
|
|
- [ ] **Step 7 : Commit**
|
|
|
|
```bash
|
|
make php-cs-fixer-allow-risky
|
|
git add src/Controller/Mail/MailLinkTaskController.php src/Controller/Mail/MailUnlinkTaskController.php src/Controller/Mail/TaskMailsListController.php tests/Functional/Controller/Mail/MailTaskIntegrationControllerTest.php
|
|
git commit -m "feat(mail) : MailLinkTask + MailUnlinkTask + TaskMailsList controllers"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 11 : Custom controller `MailAttachmentDownloadController`
|
|
|
|
Endpoint : `GET /api/mail/attachments/{downloadId}` où `downloadId` est un `base64url(messageDbId:partNumber)`. Fetch via `ImapMailProvider::fetchAttachment()`, stream avec `Content-Disposition: attachment` (jamais inline). Force le `Content-Type` exact.
|
|
|
|
- [ ] **Step 1 : Créer `src/Controller/Mail/MailAttachmentDownloadController.php`**
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Controller\Mail;
|
|
|
|
use App\Mail\Exception\MailProviderException;
|
|
use App\Mail\MailProviderInterface;
|
|
use App\Repository\MailMessageRepository;
|
|
use App\Security\MailAccessChecker;
|
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
|
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 MailMessageRepository $messageRepository,
|
|
private readonly MailProviderInterface $mailProvider,
|
|
private readonly MailAccessChecker $accessChecker,
|
|
) {}
|
|
|
|
public function __invoke(string $downloadId): Response
|
|
{
|
|
$this->accessChecker->ensureCanAccessMail($this->getUser());
|
|
|
|
// Décoder le downloadId : base64url(messageDbId:partNumber)
|
|
$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->find($messageDbId);
|
|
if (null === $message) {
|
|
throw new NotFoundHttpException('Message not found');
|
|
}
|
|
|
|
// Trouver les métadonnées de l'attachment dans le detail (via cache ou live)
|
|
// Pour l'instant, fetch live (le cache body sera utilisé si disponible)
|
|
try {
|
|
$detail = $this->mailProvider->fetchMessage(
|
|
$message->getFolder()->getPath(),
|
|
$message->getUid()
|
|
);
|
|
} catch (MailProviderException) {
|
|
throw new NotFoundHttpException('Could not fetch message from IMAP server');
|
|
}
|
|
|
|
// Trouver le bon attachment par partNumber
|
|
$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));
|
|
}
|
|
|
|
// Fetch le contenu binaire
|
|
try {
|
|
$content = $this->mailProvider->fetchAttachment(
|
|
$message->getFolder()->getPath(),
|
|
$message->getUid(),
|
|
$partNumber
|
|
);
|
|
} catch (MailProviderException) {
|
|
throw new NotFoundHttpException('Could not fetch attachment content');
|
|
}
|
|
|
|
// Sanitiser le nom de fichier (éviter path traversal)
|
|
$filename = basename($targetAttachment->filename);
|
|
if ('' === $filename || '.' === $filename) {
|
|
$filename = 'attachment';
|
|
}
|
|
|
|
// Toujours Content-Disposition: attachment (jamais inline — risque XSS via HTML attachments)
|
|
$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;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2 : Vérifier la syntaxe**
|
|
|
|
```bash
|
|
docker exec php-lesstime-fpm php -l src/Controller/Mail/MailAttachmentDownloadController.php
|
|
```
|
|
|
|
- [ ] **Step 3 : Commit**
|
|
|
|
```bash
|
|
make php-cs-fixer-allow-risky
|
|
git add src/Controller/Mail/MailAttachmentDownloadController.php
|
|
git commit -m "feat(mail) : MailAttachmentDownloadController — GET /api/mail/attachments/{id} (stream, disposition: attachment)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 12 : Message + Handler Symfony Messenger `MailSyncRequested`
|
|
|
|
Crée le message `MailSyncRequested` et son handler, configure `messenger.yaml` (transport async), puis crée le controller trigger.
|
|
|
|
- [ ] **Step 1 : Créer `src/Message/MailSyncRequested.php`**
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Message;
|
|
|
|
final readonly class MailSyncRequested
|
|
{
|
|
public function __construct(
|
|
/** Optionnel : si null, sync tous les dossiers. Sinon, sync uniquement ce dossier. */
|
|
public ?string $folderPath = null,
|
|
) {}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2 : Créer `src/MessageHandler/MailSyncRequestedHandler.php`**
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\MessageHandler;
|
|
|
|
use App\Message\MailSyncRequested;
|
|
use App\Repository\MailFolderRepository;
|
|
use App\Service\MailSyncService;
|
|
use Psr\Log\LoggerInterface;
|
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
|
use Throwable;
|
|
|
|
#[AsMessageHandler]
|
|
final readonly class MailSyncRequestedHandler
|
|
{
|
|
public function __construct(
|
|
private MailSyncService $mailSyncService,
|
|
private MailFolderRepository $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());
|
|
// Ne pas throw — éviter la retry infinie en cas d'erreur IMAP permanente
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3 : Créer `config/packages/messenger.yaml`**
|
|
|
|
```yaml
|
|
framework:
|
|
messenger:
|
|
# Failure transport : messages en échec stockés pour retry manuel
|
|
failure_transport: failed
|
|
|
|
transports:
|
|
# Transport synchrone (défaut pour les messages non routés)
|
|
sync:
|
|
dsn: 'sync://'
|
|
|
|
# Transport async via Doctrine (DBAL)
|
|
async:
|
|
dsn: 'doctrine://default?auto_setup=0'
|
|
options:
|
|
queue_name: default
|
|
retry_strategy:
|
|
max_retries: 3
|
|
delay: 1000
|
|
multiplier: 2
|
|
max_delay: 0
|
|
|
|
# Transport pour les messages en échec
|
|
failed:
|
|
dsn: 'doctrine://default?queue_name=failed&auto_setup=0'
|
|
|
|
routing:
|
|
# MailSyncRequested est dispatché en async pour retour 202 immédiat
|
|
'App\Message\MailSyncRequested': async
|
|
```
|
|
|
|
> Note : `auto_setup=0` signifie que la table `messenger_messages` doit être créée manuellement (via `php bin/console messenger:setup-transports`). Appeler cette commande en Task suivante. Si le projet n'a pas encore de table Messenger, utiliser `auto_setup=true` pour le développement.
|
|
|
|
- [ ] **Step 4 : Setup des transports Messenger**
|
|
|
|
```bash
|
|
docker exec php-lesstime-fpm php bin/console messenger:setup-transports
|
|
```
|
|
|
|
Attendu : table `messenger_messages` créée en BDD.
|
|
|
|
> Si erreur "table déjà existante" → déjà configuré, ignorer.
|
|
|
|
- [ ] **Step 5 : Vérifier la syntaxe**
|
|
|
|
```bash
|
|
docker exec php-lesstime-fpm php -l src/Message/MailSyncRequested.php
|
|
docker exec php-lesstime-fpm php -l src/MessageHandler/MailSyncRequestedHandler.php
|
|
make cache-clear
|
|
```
|
|
|
|
- [ ] **Step 6 : Vérifier que le handler est enregistré**
|
|
|
|
```bash
|
|
docker exec php-lesstime-fpm php bin/console debug:messenger 2>&1 | grep Mail
|
|
```
|
|
|
|
Attendu : `App\Message\MailSyncRequested` → `App\MessageHandler\MailSyncRequestedHandler`.
|
|
|
|
- [ ] **Step 7 : Commit**
|
|
|
|
```bash
|
|
make php-cs-fixer-allow-risky
|
|
git add src/Message/MailSyncRequested.php src/MessageHandler/MailSyncRequestedHandler.php config/packages/messenger.yaml
|
|
git commit -m "feat(mail) : MailSyncRequested message + handler + messenger.yaml transport async Doctrine"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 13 : Custom controller `MailSyncTriggerController`
|
|
|
|
Endpoint : `POST /api/mail/sync`. Dispatch `MailSyncRequested` au bus Messenger, retourne `202 Accepted` immédiat.
|
|
|
|
- [ ] **Step 1 : Créer `tests/Functional/Controller/Mail/MailSyncTriggerControllerTest.php`**
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Functional\Controller\Mail;
|
|
|
|
use App\Entity\User;
|
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
|
|
|
class MailSyncTriggerControllerTest extends WebTestCase
|
|
{
|
|
public function testSyncTriggerReturns401WhenNotAuthenticated(): void
|
|
{
|
|
$client = static::createClient();
|
|
$client->request('POST', '/api/mail/sync');
|
|
|
|
self::assertResponseStatusCodeSame(401);
|
|
}
|
|
|
|
public function testSyncTriggerReturns403ForRoleClient(): void
|
|
{
|
|
$client = static::createClient();
|
|
$container = static::getContainer();
|
|
$em = $container->get('doctrine.orm.entity_manager');
|
|
|
|
$clientUser = $em->getRepository(User::class)->findOneBy(['username' => 'client-liot']);
|
|
$client->loginUser($clientUser);
|
|
$client->request('POST', '/api/mail/sync');
|
|
|
|
self::assertResponseStatusCodeSame(403);
|
|
}
|
|
|
|
public function testSyncTriggerReturns202ForRoleUser(): void
|
|
{
|
|
$client = static::createClient();
|
|
$container = static::getContainer();
|
|
$em = $container->get('doctrine.orm.entity_manager');
|
|
|
|
$user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
|
|
$client->loginUser($user);
|
|
$client->request('POST', '/api/mail/sync');
|
|
|
|
// 202 Accepted : le message est dispatché, la sync se fait en async
|
|
self::assertResponseStatusCodeSame(202);
|
|
$data = json_decode($client->getResponse()->getContent(), true);
|
|
self::assertArrayHasKey('message', $data);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2 : Créer `src/Controller/Mail/MailSyncTriggerController.php`**
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Controller\Mail;
|
|
|
|
use App\Message\MailSyncRequested;
|
|
use App\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());
|
|
|
|
// Optionnel : folder spécifique via body JSON
|
|
$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 // 202
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3 : Vérifier la syntaxe et la route**
|
|
|
|
```bash
|
|
docker exec php-lesstime-fpm php -l src/Controller/Mail/MailSyncTriggerController.php
|
|
make cache-clear
|
|
docker exec php-lesstime-fpm php bin/console debug:router | grep mail_sync_trigger
|
|
```
|
|
|
|
- [ ] **Step 4 : Relancer les tests**
|
|
|
|
```bash
|
|
docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Controller/Mail/MailSyncTriggerControllerTest.php 2>&1 | tail -15
|
|
```
|
|
|
|
Attendu : les trois tests passent (401, 403, 202).
|
|
|
|
> Note : en environnement de test, Symfony Messenger utilise le transport `sync://` par défaut (via la config `when@test`). Le message sera traité synchronement — c'est attendu et correct pour les tests fonctionnels. Ajouter dans `config/packages/test/messenger.yaml` si nécessaire :
|
|
>
|
|
> ```yaml
|
|
> framework:
|
|
> messenger:
|
|
> transports:
|
|
> async:
|
|
> dsn: 'in-memory://'
|
|
> ```
|
|
|
|
- [ ] **Step 5 : Commit**
|
|
|
|
```bash
|
|
make php-cs-fixer-allow-risky
|
|
git add src/Controller/Mail/MailSyncTriggerController.php tests/Functional/Controller/Mail/MailSyncTriggerControllerTest.php
|
|
git commit -m "feat(mail) : MailSyncTriggerController — POST /api/mail/sync (202 + Messenger async)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 14 : Sécurité globale — `security.yaml` + access_control
|
|
|
|
Ajouter les règles `access_control` dans `config/packages/security.yaml` pour que `^/api/mail` requiert `IS_AUTHENTICATED_FULLY` au niveau firewall (en plus des checks explicites dans les controllers). La route admin config requiert `ROLE_ADMIN` (géré via API Platform `security: "is_granted('ROLE_ADMIN')"`).
|
|
|
|
- [ ] **Step 1 : Modifier `config/packages/security.yaml`**
|
|
|
|
Ouvrir `config/packages/security.yaml` et localiser le bloc `access_control`. Ajouter les lignes mail **avant** la règle générique `^/api` :
|
|
|
|
```yaml
|
|
access_control:
|
|
- { path: ^/login_check, roles: PUBLIC_ACCESS }
|
|
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
|
|
# Version de l'application en public
|
|
- { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [ GET ] }
|
|
- { path: ^/_mcp, roles: PUBLIC_ACCESS, methods: [ GET ] }
|
|
- { path: ^/_mcp, roles: IS_AUTHENTICATED_FULLY }
|
|
# Mail : requiert authentification (les checks ROLE_USER/ROLE_CLIENT sont dans MailAccessChecker)
|
|
- { path: ^/api/mail, roles: IS_AUTHENTICATED_FULLY }
|
|
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
|
|
```
|
|
|
|
> Note : La règle `^/api/mail` avec `IS_AUTHENTICATED_FULLY` bloque les requêtes non authentifiées avant même d'atteindre les controllers. Les vérifications fines ROLE_USER vs ROLE_CLIENT sont faites dans `MailAccessChecker` injecté dans chaque controller.
|
|
|
|
- [ ] **Step 2 : Vider le cache et vérifier**
|
|
|
|
```bash
|
|
make cache-clear
|
|
docker exec php-lesstime-fpm php bin/console debug:config security access_control 2>&1 | head -20
|
|
```
|
|
|
|
Attendu : règle `^/api/mail` visible.
|
|
|
|
- [ ] **Step 3 : Test rapide 401 sans auth**
|
|
|
|
```bash
|
|
curl -s -o /dev/null -w "%{http_code}" http://localhost:8082/api/mail/folders
|
|
```
|
|
|
|
Attendu : `401`.
|
|
|
|
- [ ] **Step 4 : Commit**
|
|
|
|
```bash
|
|
git add config/packages/security.yaml
|
|
git commit -m "feat(mail) : security.yaml — access_control ^/api/mail (IS_AUTHENTICATED_FULLY)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 15 : Validation finale + tests complets
|
|
|
|
- [ ] **Step 1 : Lancer la suite de tests complète**
|
|
|
|
```bash
|
|
make test
|
|
```
|
|
|
|
Attendu : tous les tests passent, y compris :
|
|
- Phase 1 : `MailConfigurationRepositoryTest` (2 tests)
|
|
- Phase 2 : `MailSyncReportTest`, `ImapMailProviderTest`, `MailSyncServiceTest`, `MailSyncCommandTest` (10 tests)
|
|
- Phase 3 : `MailSettingsControllerTest`, `MailFoldersControllerTest`, `MailMessagesControllerTest`, `MailTaskIntegrationControllerTest`, `MailSyncTriggerControllerTest`
|
|
|
|
- [ ] **Step 2 : PHP CS Fixer sur tous les fichiers modifiés**
|
|
|
|
```bash
|
|
make php-cs-fixer-allow-risky
|
|
```
|
|
|
|
Si des fichiers sont modifiés :
|
|
|
|
```bash
|
|
git add -p
|
|
git commit -m "style(mail) : php-cs-fixer pass phase 3"
|
|
```
|
|
|
|
- [ ] **Step 3 : Vider le cache et vérifier le container**
|
|
|
|
```bash
|
|
make cache-clear
|
|
docker exec php-lesstime-fpm php bin/console debug:container 2>&1 | grep -i "mail" | head -20
|
|
```
|
|
|
|
Attendu : `MailAccessChecker`, `MailSettingsProvider`, `MailSettingsProcessor`, tous les controllers Mail visibles.
|
|
|
|
- [ ] **Step 4 : Vérifier les routes enregistrées**
|
|
|
|
```bash
|
|
docker exec php-lesstime-fpm php bin/console debug:router | grep -E "mail|task_mails"
|
|
```
|
|
|
|
Attendu (routes minimum) :
|
|
|
|
| Route name | Method | Path |
|
|
|---|---|---|
|
|
| `mail_configuration_test` | POST | `/api/mail/configuration/test` |
|
|
| `mail_folders_list` | GET | `/api/mail/folders` |
|
|
| `mail_messages_list` | GET | `/api/mail/folders/{folderPath}/messages` |
|
|
| `mail_message_detail` | GET | `/api/mail/messages/{id}` |
|
|
| `mail_message_read` | POST | `/api/mail/messages/{id}/read` |
|
|
| `mail_message_flag` | POST | `/api/mail/messages/{id}/flag` |
|
|
| `mail_create_task` | POST | `/api/mail/messages/{id}/create-task` |
|
|
| `mail_link_task` | POST | `/api/mail/messages/{id}/link-task` |
|
|
| `mail_unlink_task` | DELETE | `/api/mail/messages/{id}/link-task/{taskId}` |
|
|
| `task_mails_list` | GET | `/api/tasks/{id}/mails` |
|
|
| `mail_attachment_download` | GET | `/api/mail/attachments/{downloadId}` |
|
|
| `mail_sync_trigger` | POST | `/api/mail/sync` |
|
|
|
|
+ Les routes API Platform pour `MailSettings` :
|
|
- `GET /api/mail/configuration`
|
|
- `PATCH /api/mail/configuration`
|
|
|
|
- [ ] **Step 5 : Smoke test curl avec token admin**
|
|
|
|
```bash
|
|
# Test 401 sans auth
|
|
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8082/api/mail/folders
|
|
# Attendu : 401
|
|
|
|
# Test avec JWT (récupérer le cookie après login)
|
|
curl -s -c /tmp/cookies.txt -X POST http://localhost:8082/login_check \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"username":"admin","password":"admin"}' | jq .
|
|
|
|
curl -s -b /tmp/cookies.txt http://localhost:8082/api/mail/configuration | jq .
|
|
# Attendu : JSON avec hasPassword, imapHost, etc. — pas de "password" en clair
|
|
|
|
curl -s -b /tmp/cookies.txt http://localhost:8082/api/mail/folders | jq .
|
|
# Attendu : [] (aucun dossier en fixtures — normal)
|
|
|
|
curl -s -b /tmp/cookies.txt -X POST http://localhost:8082/api/mail/sync | jq .
|
|
# Attendu : 202 + { "message": "Synchronisation démarrée en arrière-plan" }
|
|
```
|
|
|
|
- [ ] **Step 6 : Résumé des commits de la phase**
|
|
|
|
```bash
|
|
git log --oneline feat/mail-integration ^develop | head -30
|
|
```
|
|
|
|
Commits attendus (en plus de ceux des phases 1 et 2) :
|
|
|
|
1. `feat(mail) : MailSettings ApiResource singleton (GET/PATCH /api/mail/configuration)`
|
|
2. `feat(mail) : MailTestConnectionController — POST /api/mail/configuration/test`
|
|
3. `feat(mail) : MailAccessChecker — vérification accès mail ROLE_USER/ROLE_ADMIN (refus ROLE_CLIENT pur)`
|
|
4. `feat(mail) : MailFoldersListController — GET /api/mail/folders (arbre BDD + unreadCount)`
|
|
5. `feat(mail) : MailMessagesListController — GET /api/mail/folders/{path}/messages (pagination cursor)`
|
|
6. `feat(mail) : MailMessageDetailController — GET /api/mail/messages/{id} (live IMAP + cache 5 min)`
|
|
7. `feat(mail) : MailMessageReadController + MailMessageFlagController — POST .../read et .../flag`
|
|
8. `feat(mail) : MailCreateTaskController — POST /api/mail/messages/{id}/create-task`
|
|
9. `feat(mail) : MailLinkTask + MailUnlinkTask + TaskMailsList controllers`
|
|
10. `feat(mail) : MailAttachmentDownloadController — GET /api/mail/attachments/{id} (stream, disposition: attachment)`
|
|
11. `feat(mail) : MailSyncRequested message + handler + messenger.yaml transport async Doctrine`
|
|
12. `feat(mail) : MailSyncTriggerController — POST /api/mail/sync (202 + Messenger async)`
|
|
13. `feat(mail) : security.yaml — access_control ^/api/mail (IS_AUTHENTICATED_FULLY)`
|
|
|
|
- [ ] **Step 7 : Pousser la branche et notifier le user**
|
|
|
|
```bash
|
|
git push origin feat/mail-integration
|
|
```
|
|
|
|
Rapport final au user :
|
|
- Fichiers créés : 26 nouveaux fichiers
|
|
- Fichiers modifiés : 3 (`MailMessageRepository`, `TaskRepository`, `security.yaml`)
|
|
- Endpoints exposés : 14 (2 API Platform + 12 custom controllers)
|
|
- Tests ajoutés : 5 fichiers de tests fonctionnels
|
|
- Dépendances ajoutées : aucune (Messenger et Cache déjà dans Symfony 8.0)
|
|
- Prêt pour Phase 4 : Frontend services + Pinia store
|
|
|
|
---
|
|
|
|
### Exigences techniques — Rappels
|
|
|
|
- **Sécurité** : `#[IsGranted('IS_AUTHENTICATED_FULLY')]` sur tous les controllers + `MailAccessChecker::ensureCanAccessMail()` au début de chaque action ROLE_USER + `ensureIsAdmin()` pour config. Voir spec complète ci-dessous.
|
|
- **Password** : jamais retourné en clair, même pour admin. Réponse GET config : `{ ..., hasPassword: true|false }`. PATCH avec password vide ou null = pas de changement.
|
|
- **Format réponses** : JSON, codes HTTP corrects (200, 201, 202, 204, 401, 403, 404, 422).
|
|
- **Strict types** : `declare(strict_types=1);` en tête de chaque fichier PHP.
|
|
- **Format commit** : `feat(mail) : <message>` (espace avant `:`)
|
|
- **Custom controllers** : `#[Route(path: '/api/mail/...', methods: ['...'], priority: 1)]` — obligatoire pour éviter conflit API Platform.
|
|
- **Cache key** : `mail_body_{md5($messageId)}` — md5 sanitise les caractères spéciaux du Message-ID.
|
|
- **Messenger** : bus `messenger.bus.default` (injectable via `MessageBusInterface`). Transport `async` via `doctrine://default`. Setup : `php bin/console messenger:setup-transports`.
|
|
- **TaskService** : si `TaskNumberProcessor` existe uniquement en tant que `State/Processor` API Platform (non-injectable), créer la Task directement avec persist+flush + `findMaxNumberForProject`. Vérifier les champs obligatoires de `Task` avant de coder.
|
|
- **Tests** : étendre `WebTestCase` + `$client->loginUser($user)` + mocker `MailProviderInterface` via `static::getContainer()->set()` pour les tests qui touchent IMAP en live.
|
|
- **Logs** : OK de logger les actions (user → action → message-id), **JAMAIS** body/password/attachments.
|
|
|
|
### Spec sécurité explicite `MailAccessChecker`
|
|
|
|
```php
|
|
// src/Security/MailAccessChecker.php
|
|
public function ensureCanAccessMail(?UserInterface $user): void
|
|
{
|
|
if (!$user instanceof User) {
|
|
throw new AccessDeniedException('Authentication required');
|
|
}
|
|
$roles = $user->getRoles();
|
|
if (in_array('ROLE_CLIENT', $roles, true) && !in_array('ROLE_ADMIN', $roles, true)) {
|
|
throw new AccessDeniedException('Mail not accessible to clients');
|
|
}
|
|
if (!in_array('ROLE_USER', $roles, true) && !in_array('ROLE_ADMIN', $roles, true)) {
|
|
throw new AccessDeniedException('ROLE_USER required');
|
|
}
|
|
}
|
|
|
|
public function ensureIsAdmin(?UserInterface $user): void
|
|
{
|
|
if (!$user instanceof User || !in_array('ROLE_ADMIN', $user->getRoles(), true)) {
|
|
throw new AccessDeniedException('Admin only');
|
|
}
|
|
}
|
|
```
|
|
|
|
### Spec body cache
|
|
|
|
```php
|
|
// Dans MailMessageDetailController
|
|
$cacheKey = 'mail_body_' . md5($message->getMessageId());
|
|
$item = $this->cache->getItem($cacheKey);
|
|
if (!$item->isHit()) {
|
|
$detail = $this->mailProvider->fetchMessage($folderPath, $uid);
|
|
$item->set($detail);
|
|
$item->expiresAfter(300); // 5 min
|
|
$this->cache->save($item);
|
|
}
|
|
$detail = $item->get();
|
|
```
|
|
|
|
Pool : `cache.app` (injecté via `CacheItemPoolInterface` — Symfony le résout automatiquement).
|
|
|
|
---
|
|
|
|
### Self-Review
|
|
|
|
#### Table de correspondance endpoints / controllers
|
|
|
|
| Endpoint | Controller / Resource | Sécurité |
|
|
|---|---|---|
|
|
| `GET /api/mail/configuration` | `MailSettings` ApiResource (Provider) | `ROLE_ADMIN` |
|
|
| `PATCH /api/mail/configuration` | `MailSettings` ApiResource (Processor) | `ROLE_ADMIN` |
|
|
| `POST /api/mail/configuration/test` | `MailTestConnectionController` | `ROLE_ADMIN` |
|
|
| `GET /api/mail/folders` | `MailFoldersListController` | `MailAccessChecker` (ROLE_USER/ADMIN, pas CLIENT) |
|
|
| `GET /api/mail/folders/{path}/messages` | `MailMessagesListController` | idem |
|
|
| `GET /api/mail/messages/{id}` | `MailMessageDetailController` | idem |
|
|
| `POST /api/mail/messages/{id}/read` | `MailMessageReadController` | idem |
|
|
| `POST /api/mail/messages/{id}/flag` | `MailMessageFlagController` | idem |
|
|
| `POST /api/mail/messages/{id}/create-task` | `MailCreateTaskController` | idem |
|
|
| `POST /api/mail/messages/{id}/link-task` | `MailLinkTaskController` | idem |
|
|
| `DELETE /api/mail/messages/{id}/link-task/{taskId}` | `MailUnlinkTaskController` | idem |
|
|
| `GET /api/tasks/{id}/mails` | `TaskMailsListController` | idem |
|
|
| `GET /api/mail/attachments/{downloadId}` | `MailAttachmentDownloadController` | idem |
|
|
| `POST /api/mail/sync` | `MailSyncTriggerController` | idem |
|
|
|
|
#### Checklist finale avant de valider Phase 3
|
|
|
|
- [ ] `declare(strict_types=1);` présent en tête de chaque fichier PHP créé
|
|
- [ ] Tous les custom controllers ont `priority: 1` sur leur `#[Route]`
|
|
- [ ] `MailAccessChecker` injecté et appelé dans chaque controller "métier" (hors admin)
|
|
- [ ] `password` jamais sérialisé en sortie (groupe `mail_settings:read` ne contient pas `password`)
|
|
- [ ] `hasPassword` retourné en lecture (bool uniquement)
|
|
- [ ] Cache key utilise `md5()` pour sanitiser le `messageId` (peut contenir `<>`, `@`, espaces)
|
|
- [ ] `Content-Disposition: attachment` systématique pour `MailAttachmentDownloadController` (jamais inline)
|
|
- [ ] `X-Content-Type-Options: nosniff` sur les réponses d'attachments
|
|
- [ ] Messenger configuré : transport `async` → `doctrine://default`, `MailSyncRequested` routé vers `async`
|
|
- [ ] `php bin/console messenger:setup-transports` exécuté (table `messenger_messages` créée)
|
|
- [ ] `security.yaml` : règle `^/api/mail` avec `IS_AUTHENTICATED_FULLY` ajoutée AVANT `^/api`
|
|
- [ ] ROLE_CLIENT refusé sur 100% des endpoints mail (test 403 présent dans chaque test file)
|
|
- [ ] Aucun body/password/attachment loggé dans les controllers ou handlers
|
|
- [ ] `make test` vert
|
|
- [ ] Smoke tests curl OK (401 sans auth, JSON correct avec admin, 202 sync trigger)
|
|
- [ ] Phase 3 NE contient PAS de frontend — tout ça = Phase 4+
|
|
- [ ] Branche de travail : `feat/mail-integration` (pas `develop`)
|
|
|
|
#### Remise à la review humaine
|
|
|
|
Une fois tous les steps cochés, pousser la branche et notifier le user :
|
|
|
|
```bash
|
|
git push origin feat/mail-integration
|
|
```
|
|
|
|
Indiquer au user :
|
|
- Endpoints créés : 14 (2 API Platform singleton + 12 custom controllers)
|
|
- Fichiers créés : 26
|
|
- Fichiers modifiés : 3
|
|
- Tests fonctionnels ajoutés : 5 fichiers (~20 assertions)
|
|
- Dépendances ajoutées : aucune (Messenger + Cache inclus dans Symfony 8.0)
|
|
- Prêt pour Phase 4 : Frontend services + Pinia store + DOMPurify
|