Compare commits
77 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8be96bce0c | ||
|
|
fb97b8d4e3 | ||
|
|
913d3b7d93 | ||
| 90bf46f598 | |||
| e5c5371c74 | |||
| 40a1d737f3 | |||
| a40d11503a | |||
| 760649170e | |||
| b0f05da84a | |||
| c75dfa0371 | |||
| c6fa5a534e | |||
| 17b5fa2340 | |||
| 79d3414824 | |||
| f313e74c9e | |||
| 7a682b4662 | |||
| d6f430ca35 | |||
| 4d7ff9be26 | |||
| 7c0d3372a9 | |||
| d36429f058 | |||
| 28b673eec8 | |||
| bad292a316 | |||
| 273234626f | |||
| 96c7d902e7 | |||
| f62c790449 | |||
| 13cec9a46a | |||
| d676fdcb0c | |||
| bfcf712123 | |||
| 622fcf72c1 | |||
| 67e73a52d7 | |||
| aa175063dc | |||
| 9aa14d38a9 | |||
| 95a98012ad | |||
| 535753b189 | |||
| e710f57c49 | |||
| 73f0adc761 | |||
| 2e0f5b4e30 | |||
| 33e4e79f8e | |||
| bfa155d060 | |||
| e7224765b1 | |||
| fe07398059 | |||
| a440ce267f | |||
| 8986f3cb0e | |||
| 6d420c86e8 | |||
| cc46dd915d | |||
| f7f7a07162 | |||
| 117175d4b1 | |||
| c7d12f6acd | |||
| f584ed96fa | |||
| 5ce7693343 | |||
| 7fb525595e | |||
| b1d6303afe | |||
| 1c3ba9c33c | |||
| 412c412cbc | |||
| 62e0bf5f11 | |||
| 696b40ca80 | |||
| cbbc491d69 | |||
| 26fab44dab | |||
| 0028b489e4 | |||
| 1fb7460f8e | |||
| c47434b502 | |||
| f245863b78 | |||
| b546f528df | |||
| b5b4288cc0 | |||
| 3a2d8d5bde | |||
| 23191bdab6 | |||
| 5f92cbbf4f | |||
| f80680e874 | |||
| 697197864f | |||
| 0da26ff418 | |||
| cd9c16a990 | |||
| 0c597bc653 | |||
| 0c80159d7e | |||
| 3cac87aa24 | |||
| 07b7d054d5 | |||
| 361cc8cfab | |||
| 930e1a1e37 | |||
| 55301c9c63 |
14
.env
14
.env
@@ -20,4 +20,16 @@ JWT_COOKIE_TTL=86400
|
||||
|
||||
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8"
|
||||
|
||||
ENCRYPTION_KEY=change_me_in_env_local
|
||||
ENCRYPTION_KEY=change_me_in_env_local
|
||||
###> symfony/lock ###
|
||||
# Choose one of the stores below
|
||||
# postgresql+advisory://db_user:db_password@localhost/db_name
|
||||
LOCK_DSN=flock
|
||||
###< symfony/lock ###
|
||||
|
||||
###> symfony/messenger ###
|
||||
# Choose one of the transports below
|
||||
# MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages
|
||||
# MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages
|
||||
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
|
||||
###< symfony/messenger ###
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Application de gestion de projet. Monorepo Symfony 8 (API Platform 4) + Nuxt 4.
|
||||
|
||||
> **WIP — Intégration Mail (branche `feat/mail-integration`)** : client mail OVH IMAP. Avant de toucher au mail, lire `docs/mail-integration.md` (section « Statut & reprise » = bugs déjà corrigés, points en suspens, commandes). Code : `src/Mail/`, `src/Service/MailSyncService.php`, `src/Controller/Mail/`, `frontend/{services,stores,components}/mail*`.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Backend** : PHP 8.4, Symfony 8.0, API Platform 4, Doctrine ORM, PostgreSQL 16
|
||||
|
||||
@@ -21,6 +21,7 @@ Application de gestion de projet avec suivi du temps et portail client.
|
||||
- Profil utilisateur avec avatar (crop circulaire)
|
||||
- Notifications temps réel
|
||||
- Intégration Gitea (issues, repos)
|
||||
- Intégration Mail IMAP (boîte partagée OVH, voir `docs/mail-integration.md`)
|
||||
- Serveur MCP pour assistants IA
|
||||
- Multi-langue (i18n)
|
||||
|
||||
@@ -73,6 +74,7 @@ make shell-root # Shell root dans le container PHP
|
||||
make dev-nuxt # Dev server Nuxt (hot reload, port 3002)
|
||||
make cache-clear # Vider le cache Symfony
|
||||
make logs-dev # Tail logs Symfony
|
||||
make mail-sync # Synchroniser la boîte mail IMAP (voir docs/mail-cron-setup.md)
|
||||
```
|
||||
|
||||
### Base de données
|
||||
|
||||
@@ -21,12 +21,15 @@
|
||||
"sabre/vobject": "^4.5",
|
||||
"symfony/asset": "8.0.*",
|
||||
"symfony/console": "8.0.*",
|
||||
"symfony/doctrine-messenger": "^8.0",
|
||||
"symfony/dotenv": "8.0.*",
|
||||
"symfony/expression-language": "8.0.*",
|
||||
"symfony/flex": "^2",
|
||||
"symfony/framework-bundle": "8.0.*",
|
||||
"symfony/http-client": "8.0.*",
|
||||
"symfony/lock": "8.0.*",
|
||||
"symfony/mcp-bundle": "^0.6.0",
|
||||
"symfony/messenger": "^8.0",
|
||||
"symfony/mime": "8.0.*",
|
||||
"symfony/monolog-bundle": "^4.0",
|
||||
"symfony/property-access": "8.0.*",
|
||||
@@ -36,7 +39,8 @@
|
||||
"symfony/security-bundle": "8.0.*",
|
||||
"symfony/serializer": "8.0.*",
|
||||
"symfony/validator": "8.0.*",
|
||||
"symfony/yaml": "8.0.*"
|
||||
"symfony/yaml": "8.0.*",
|
||||
"webklex/php-imap": "^6.2"
|
||||
},
|
||||
"config": {
|
||||
"allow-plugins": {
|
||||
@@ -93,6 +97,8 @@
|
||||
"require-dev": {
|
||||
"doctrine/doctrine-fixtures-bundle": "^4.3",
|
||||
"friendsofphp/php-cs-fixer": "^3.94",
|
||||
"phpunit/phpunit": "^13.0"
|
||||
"phpunit/phpunit": "^13.0",
|
||||
"symfony/browser-kit": "^8.0",
|
||||
"symfony/css-selector": "^8.0"
|
||||
}
|
||||
}
|
||||
|
||||
1343
composer.lock
generated
1343
composer.lock
generated
File diff suppressed because it is too large
Load Diff
2
config/packages/lock.yaml
Normal file
2
config/packages/lock.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
framework:
|
||||
lock: '%env(LOCK_DSN)%'
|
||||
33
config/packages/messenger.yaml
Normal file
33
config/packages/messenger.yaml
Normal file
@@ -0,0 +1,33 @@
|
||||
framework:
|
||||
messenger:
|
||||
failure_transport: failed
|
||||
|
||||
transports:
|
||||
sync: 'sync://'
|
||||
|
||||
async:
|
||||
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
|
||||
options:
|
||||
queue_name: default
|
||||
retry_strategy:
|
||||
max_retries: 3
|
||||
delay: 1000
|
||||
multiplier: 2
|
||||
max_delay: 0
|
||||
|
||||
failed: 'doctrine://default?queue_name=failed&auto_setup=0'
|
||||
|
||||
routing:
|
||||
# Sync à la demande (bouton « rafraîchir ») : exécutée pendant la requête HTTP
|
||||
# pour que le re-fetch du front voie immédiatement les nouveaux mails, sans worker
|
||||
# messenger:consume à maintenir. La sync de fond reste assurée par le cron OS
|
||||
# (app:mail:sync, synchrone, indépendant du bus). Repasser à `async` + worker si
|
||||
# la boîte grossit au point que la sync à la demande approche le timeout PHP.
|
||||
'App\Message\MailSyncRequested': sync
|
||||
|
||||
when@test:
|
||||
framework:
|
||||
messenger:
|
||||
transports:
|
||||
async: 'in-memory://'
|
||||
failed: 'in-memory://'
|
||||
@@ -64,6 +64,8 @@ security:
|
||||
- { 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 }
|
||||
|
||||
when@test:
|
||||
|
||||
5
config/packages/translation.yaml
Normal file
5
config/packages/translation.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
framework:
|
||||
default_locale: en
|
||||
translator:
|
||||
default_path: '%kernel.project_dir%/translations'
|
||||
providers:
|
||||
@@ -301,7 +301,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* },
|
||||
* },
|
||||
* translator?: bool|array{ // Translator configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* fallbacks?: list<scalar|Param|null>,
|
||||
* logging?: bool|Param, // Default: false
|
||||
* formatter?: scalar|Param|null, // Default: "translator.formatter.default"
|
||||
@@ -413,7 +413,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* },
|
||||
* lock?: bool|string|array{ // Lock configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* resources?: array<string, string|list<scalar|Param|null>>,
|
||||
* },
|
||||
* semaphore?: bool|string|array{ // Semaphore configuration
|
||||
@@ -421,7 +421,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* resources?: array<string, scalar|Param|null>,
|
||||
* },
|
||||
* messenger?: bool|array{ // Messenger configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* routing?: array<string, string|array{ // Default: []
|
||||
* senders?: list<scalar|Param|null>,
|
||||
* }>,
|
||||
@@ -1360,7 +1360,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* include_type?: bool|Param, // Always include @var in updates (including delete ones). // Default: false
|
||||
* },
|
||||
* messenger?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* },
|
||||
* elasticsearch?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.4.0'
|
||||
app.version: '0.4.2'
|
||||
|
||||
111
docs/mail-cron-setup.md
Normal file
111
docs/mail-cron-setup.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# Mail Integration — Configuration cron OS
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
La synchronisation IMAP est déclenchée par un cron OS toutes les 10 minutes.
|
||||
Elle appelle la commande Symfony `app:mail:sync` qui s'exécute dans le container PHP.
|
||||
|
||||
Un Symfony Lock (`mail.sync`, TTL 10 min, store `flock` via `LOCK_DSN=flock`) empêche
|
||||
les runs de se chevaucher si une sync prend plus de 10 min.
|
||||
|
||||
## Prérequis
|
||||
|
||||
- Container `php-lesstime-fpm` démarré (`make start`)
|
||||
- `MailConfiguration.enabled = true` (configurable depuis l'admin — Phase 7)
|
||||
- `ENCRYPTION_KEY` défini dans `infra/dev/.env.docker.local` (ou production env)
|
||||
|
||||
## Installation du cron
|
||||
|
||||
Sur la **machine hôte** (pas dans le container) :
|
||||
|
||||
```bash
|
||||
crontab -e
|
||||
```
|
||||
|
||||
Ajouter la ligne suivante (adapter le chemin) :
|
||||
|
||||
```cron
|
||||
*/10 * * * * cd /home/r-dev/malio-dev/Lesstime && make mail-sync >> /var/log/lesstime-mail-sync.log 2>&1
|
||||
```
|
||||
|
||||
Ou directement via `docker exec` (sans dépendance à `make`) :
|
||||
|
||||
```cron
|
||||
*/10 * * * * docker exec php-lesstime-fpm php bin/console app:mail:sync >> /var/log/lesstime-mail-sync.log 2>&1
|
||||
```
|
||||
|
||||
### Avec un utilisateur système dédié
|
||||
|
||||
Si le cron est configuré pour un utilisateur système spécifique (ex: `www-data` ou `deploy`) :
|
||||
|
||||
```bash
|
||||
sudo crontab -u deploy -e
|
||||
```
|
||||
|
||||
## Variables d'environnement nécessaires
|
||||
|
||||
| Variable | Description | Exemple |
|
||||
|---|---|---|
|
||||
| `ENCRYPTION_KEY` | Clé hex 32 bytes pour déchiffrer le password IMAP | `$(php -r "echo bin2hex(random_bytes(32));")` |
|
||||
| `LOCK_DSN` | DSN du store de verrous Symfony | `flock` (défaut, fichier local) |
|
||||
|
||||
La clé doit être la même que celle utilisée pour chiffrer le password lors de la configuration.
|
||||
|
||||
## Checklist setup production
|
||||
|
||||
1. [ ] Définir `ENCRYPTION_KEY` dans les variables d'environnement production
|
||||
2. [ ] Créer le compte mail dédié (ex: `lesstime@votre-domaine.fr`) chez OVH
|
||||
3. [ ] Accéder à `/admin` → onglet "Mail" → renseigner les credentials IMAP/SMTP
|
||||
4. [ ] Cliquer "Tester la connexion" → vérifier le succès
|
||||
5. [ ] Cocher "Activer la synchronisation" → Enregistrer
|
||||
6. [ ] Installer le cron OS (voir section "Installation du cron")
|
||||
7. [ ] Vérifier les logs après la première sync : `make logs-dev` (chercher `mail.sync`)
|
||||
|
||||
## Commandes utiles
|
||||
|
||||
```bash
|
||||
# Sync complète (toutes les boîtes)
|
||||
make mail-sync
|
||||
|
||||
# Sync d'un seul dossier (le dossier doit déjà exister en base)
|
||||
make mail-sync FOLDER=INBOX
|
||||
|
||||
# Simulation (dry-run, pas d'écriture BDD)
|
||||
make mail-sync DRYRUN=1
|
||||
|
||||
# Directement dans le container
|
||||
docker exec php-lesstime-fpm php bin/console app:mail:sync
|
||||
docker exec php-lesstime-fpm php bin/console app:mail:sync --folder=INBOX
|
||||
docker exec php-lesstime-fpm php bin/console app:mail:sync --dry-run
|
||||
```
|
||||
|
||||
## Logs
|
||||
|
||||
Les logs Symfony sont dans `var/log/dev.log` (ou `prod.log` en production).
|
||||
Suivre les logs en temps réel :
|
||||
|
||||
```bash
|
||||
make logs-dev
|
||||
```
|
||||
|
||||
Les messages loggés par `MailSyncService` sont préfixés `mail.sync`.
|
||||
|
||||
## Sécurité
|
||||
|
||||
- Le password IMAP est **toujours stocké chiffré** (libsodium secretbox)
|
||||
- Les corps de mails, passwords et pièces jointes ne sont **jamais loggés**
|
||||
- Le lock `flock` évite les runs parallèles (fichier dans `/tmp/sf.mail.sync.<hash>.lock`)
|
||||
|
||||
## Rappels sécurité
|
||||
|
||||
- La page `/mail` et tous les endpoints `/api/mail/*` sont refusés aux `ROLE_CLIENT` exclusifs
|
||||
- Le sidebar "Messagerie" est masqué pour les utilisateurs ROLE_CLIENT sans ROLE_USER
|
||||
- Le password IMAP est chiffré via libsodium secretbox avant stockage (jamais en clair en base)
|
||||
- Les corps de mails sont sanitisés via DOMPurify avant affichage (voir `frontend/utils/sanitizeMailHtml.ts`)
|
||||
- Les pixels tracking distants sont remplacés par un placeholder
|
||||
- Aucun body mail, password ou contenu de pièce jointe n'est loggé
|
||||
|
||||
## Production
|
||||
|
||||
En production, préférer un cron système ou un job scheduler (Kubernetes CronJob, ECS Scheduled Task, etc.).
|
||||
La commande est idempotente : relancer plusieurs fois ne duplique pas les données (UIDs uniques en base).
|
||||
147
docs/mail-integration.md
Normal file
147
docs/mail-integration.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# Intégration Mail — Vue d'ensemble
|
||||
|
||||
> ## 🟢 Statut & reprise (handoff — MAJ 2026-05-20)
|
||||
>
|
||||
> **Branche** : `feat/mail-integration` · **MR Gitea** : https://gitea.malio.fr/MALIO-DEV/Lesstime/pulls/5 (base `develop`)
|
||||
> Construit en 7 phases (plans dans `docs/superpowers/plans/2026-05-19-mail-phase*.md`).
|
||||
>
|
||||
> ### Ce qui marche (testé contre une vraie boîte OVH `contact@malio.fr`)
|
||||
> - Connexion IMAP + test connexion (admin → `/admin` onglet Mail)
|
||||
> - Synchro complète multi-dossiers : **456 messages / 57 dossiers** ramenés, ne crashe plus
|
||||
> - Lecture dossiers/messages dans `/mail`, arbre repliable (chevrons, sous-dossiers masqués par défaut)
|
||||
> - Lecture d'un mail, sanitization DOMPurify
|
||||
> - Création/lien tâche depuis un mail
|
||||
>
|
||||
> ### Bugs déjà corrigés ce soir (NE PAS ré-investiguer)
|
||||
> Tous dans `ImapMailProvider` / `MailSyncService` — les tests mockaient le provider, donc le fetch réel n'avait jamais été exercé avant le test live :
|
||||
> 1. Requête sans critère → `BAD parse error: zero-length content` → `whereAll()`
|
||||
> 2. `getDate()`/`getSubject()` renvoient des `Attribute` webklex v6 → casts explicites
|
||||
> 3. Séquence par défaut `ST_MSGN` → `peek()` faisait un STORE rejeté par OVH (`flag could not be removed`) → forcé `ST_UID` partout
|
||||
> 4. Snippet via `getTextBody()` = fetch du corps de chaque mail (sync 179s + peek) → `setFetchBody(false)`, snippet désactivé au listing
|
||||
> 5. Test connexion exigeait `enabled=true` → découplé via `getClient(requireEnabled:false)` + `testConnection()`
|
||||
> 6. Contrainte UNIQUE globale sur `message_id` → fausse pour IMAP (même Message-ID dans plusieurs dossiers) → fermait l'EntityManager → cascade. **Migration `Version20260520061736`** : index simple. Garde anti-cascade dans `MailSyncService` (reset `ManagerRegistry`).
|
||||
> 7. 139 connexions IMAP (une/dossier) → throttling OVH → réutilisation d'1 connexion (`closeConnection()` sur l'interface) + reconnexion ciblée après dossier en erreur.
|
||||
> - Contrat front/back réaligné dans `frontend/services/mail.ts` (route `/mail/folders/{path}/messages`, mapping `messages→items`, `fromAddress→fromEmail`, détail plat→imbriqué).
|
||||
>
|
||||
> ### Points en suspens / à savoir
|
||||
> - **Mise à jour auto** = cron OS lançant `make mail-sync` toutes les 10 min (cf `docs/mail-cron-setup.md`). **Pas configuré en dev** — lancer à la main.
|
||||
> - **Bouton "Actualiser"** : dispatch async Messenger (`MailSyncRequested → async`). Sans worker `messenger:consume async` qui tourne, les demandes s'empilent sans s'exécuter. En prod : supervisor. En dev : lancer un worker.
|
||||
> - **~7 dossiers/139** à encodage spécial (ex: `INBOX/RH/.../SÉBASTIEN` en UTF7-modifié) ou réponses vides sont skippés proprement et réessayés au cycle suivant. Edge case webklex non bloquant.
|
||||
> - **Dépendance** : `webklex/php-imap ^6.2` tire des paquets Laravel (`illuminate/*` via `carbon ^3`) dans ce projet Symfony — fonctionnel mais à valider en review.
|
||||
> - 6 PHPUnit Notices (mocks sans expectations) non bloquantes.
|
||||
>
|
||||
> ### Commandes utiles
|
||||
> ```bash
|
||||
> make mail-sync # synchro complète
|
||||
> docker exec -i -u www-data php-lesstime-fpm php bin/console app:mail:sync --folder=INBOX -v
|
||||
> docker exec -i -u www-data php-lesstime-fpm php bin/console messenger:consume async -vv # worker (fait marcher le bouton)
|
||||
> make test # 33 tests
|
||||
> ```
|
||||
> Fixtures `make fixtures` plantent sur un état legacy `workflow_id` (hors-scope mail) — configurer la boîte via l'UI admin.
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
- Lecture de la boîte mail partagée (IMAP) depuis Lesstime
|
||||
- Navigation par dossiers (arbre récursif avec compteurs non-lus)
|
||||
- Liste paginée des messages (infinite scroll, cursor-based)
|
||||
- Lecture des corps de mail sanitisés (DOMPurify — protection XSS + pixels tracking)
|
||||
- Création d'une tâche Lesstime depuis un mail (sujet → titre, texte → description)
|
||||
- Lien mail ↔ tâche (bidirectionnel)
|
||||
- Onglet "Mails" dans le TaskDrawer pour retrouver les mails liés à une tâche
|
||||
- Synchronisation IMAP automatique via cron OS (toutes les 10 min)
|
||||
- Déclenchement manuel de sync depuis l'UI (bouton Refresh)
|
||||
- Badge non-lus en temps réel dans la sidebar (polling 30s)
|
||||
|
||||
## Endpoints API
|
||||
|
||||
| Méthode | URL | Rôle | Description |
|
||||
|---------|-----|------|-------------|
|
||||
| GET | `/api/mail/configuration` | ROLE_ADMIN | Lire la config singleton |
|
||||
| PATCH | `/api/mail/configuration` | ROLE_ADMIN | Mettre à jour la config |
|
||||
| POST | `/api/mail/configuration/test` | ROLE_ADMIN | Tester la connexion IMAP |
|
||||
| GET | `/api/mail/folders` | ROLE_USER | Arbre des dossiers + unread |
|
||||
| GET | `/api/mail/messages` | ROLE_USER | Liste paginée (param: folder, cursor, limit) |
|
||||
| GET | `/api/mail/messages/{id}` | ROLE_USER | Détail + body (cached 5 min) |
|
||||
| POST | `/api/mail/messages/{id}/read` | ROLE_USER | Marquer lu/non-lu |
|
||||
| POST | `/api/mail/messages/{id}/flag` | ROLE_USER | Marquer étoilé/non-étoilé |
|
||||
| POST | `/api/mail/messages/{id}/create-task` | ROLE_USER | Créer tâche depuis mail |
|
||||
| POST | `/api/mail/messages/{id}/link-task` | ROLE_USER | Lier mail à tâche existante |
|
||||
| DELETE | `/api/mail/messages/{id}/link-task/{taskId}` | ROLE_USER | Supprimer le lien |
|
||||
| GET | `/api/tasks/{id}/mails` | ROLE_USER | Mails liés à une tâche |
|
||||
| GET | `/api/mail/attachments/{id}` | ROLE_USER | Télécharger une pièce jointe |
|
||||
| POST | `/api/mail/sync` | ROLE_USER | Déclencher sync async (Messenger) |
|
||||
|
||||
Tous les endpoints `/api/mail/*` refusent explicitement `ROLE_CLIENT`.
|
||||
|
||||
## Sécurité
|
||||
|
||||
- ROLE_CLIENT exclusif : accès refusé à tous les endpoints mail et à la page `/mail`
|
||||
- Le sidebar "Messagerie" est masqué pour les ROLE_CLIENT
|
||||
- Password IMAP chiffré via libsodium secretbox (env `ENCRYPTION_KEY`)
|
||||
- Corps de mail sanitisés via DOMPurify (`sanitizeMailHtml.ts`) — script/iframe/object/embed/on*/javascript: bloqués
|
||||
- Pixels tracking distants (img src http) remplacés par placeholder
|
||||
- Aucun body, password ou contenu de pièce jointe dans les logs
|
||||
|
||||
## Dépendances
|
||||
|
||||
### Backend
|
||||
- `webklex/php-imap` : client IMAP PHP
|
||||
- `symfony/lock` : Symfony Lock pour éviter les syncs parallèles
|
||||
- `symfony/messenger` : dispatch asynchrone `MailSyncRequested`
|
||||
- `libsodium` (ext PHP) : chiffrement du password IMAP
|
||||
|
||||
### Frontend
|
||||
- `dompurify` + `@types/dompurify` : sanitization HTML des corps de mail
|
||||
|
||||
## Fichiers clés
|
||||
|
||||
### Backend
|
||||
- `src/Entity/MailConfiguration.php` — entité singleton (credentials, enabled)
|
||||
- `src/Entity/MailFolder.php` — dossier IMAP synced
|
||||
- `src/Entity/MailMessage.php` — message IMAP synced (headers, flags)
|
||||
- `src/Entity/TaskMailLink.php` — lien tâche ↔ mail
|
||||
- `src/Mail/ImapMailProvider.php` — implémentation IMAP (webklex)
|
||||
- `src/Service/MailSyncService.php` — algorithme de sync (UID FETCH, resync flags)
|
||||
- `src/Controller/Mail/` — controllers custom (test, folders, messages, sync)
|
||||
- `src/State/Mail/` — providers/processors API Platform (configuration)
|
||||
|
||||
### Frontend
|
||||
- `frontend/pages/mail.vue` — page principale 3 colonnes
|
||||
- `frontend/components/mail/` — MailFolderTree, MailMessageList, MailMessageViewer, MailRefreshButton
|
||||
- `frontend/components/admin/AdminMailTab.vue` — onglet config admin
|
||||
- `frontend/stores/mail.ts` — store Pinia (folders, messages, polling)
|
||||
- `frontend/services/mail.ts` — service API (toutes les méthodes)
|
||||
- `frontend/services/dto/mail.ts` — types TypeScript
|
||||
- `frontend/utils/sanitizeMailHtml.ts` — DOMPurify wrapper
|
||||
|
||||
## Synchronisation cron
|
||||
|
||||
Voir `docs/mail-cron-setup.md` pour la configuration détaillée.
|
||||
|
||||
Résumé :
|
||||
|
||||
```bash
|
||||
# Cron OS (toutes les 10 min)
|
||||
*/10 * * * * cd /path/to/Lesstime && make mail-sync >> /var/log/lesstime-mail-sync.log 2>&1
|
||||
|
||||
# Commandes Makefile
|
||||
make mail-sync # Sync complète
|
||||
make mail-sync FOLDER=INBOX # Sync d'un dossier
|
||||
make mail-sync DRYRUN=1 # Simulation sans écriture
|
||||
```
|
||||
|
||||
## Configuration admin
|
||||
|
||||
1. Aller sur `/admin` → onglet "Mail"
|
||||
2. Renseigner les credentials IMAP/SMTP (OVH : `ssl0.ovh.net`, port 993/465, SSL)
|
||||
3. Cliquer "Tester la connexion"
|
||||
4. Activer la synchronisation → Enregistrer
|
||||
5. Configurer le cron OS
|
||||
|
||||
## Variables d'environnement
|
||||
|
||||
| Variable | Description | Obligatoire |
|
||||
|----------|-------------|-------------|
|
||||
| `ENCRYPTION_KEY` | Clé hex 32 bytes libsodium pour chiffrer le password IMAP | Oui |
|
||||
| `LOCK_DSN` | DSN Symfony Lock (défaut: `flock`) | Non |
|
||||
| `MESSENGER_TRANSPORT_DSN` | Transport Messenger pour sync async | Recommandé (prod) |
|
||||
@@ -0,0 +1,264 @@
|
||||
# Mail Integration — Master Plan
|
||||
|
||||
> **Master plan** : ce document décrit le découpage en phases. Chaque phase aura son propre plan détaillé (rédigé par un subagent rédacteur) puis sera implémentée par un subagent codeur, en cycle.
|
||||
|
||||
**Spec source** : `docs/superpowers/specs/2026-05-19-mail-integration-design.md`
|
||||
|
||||
**Goal** : Ajouter à Lesstime un client mail intégré pour une boîte partagée OVH (IMAP/SMTP), avec lecture inbox/dossiers et création/lien tâche depuis un mail.
|
||||
|
||||
**Stratégie** : 7 phases séquentielles, dépendances claires, chaque phase = working software testable. Cycle par phase : rédacteur → codeur → review humaine → phase suivante.
|
||||
|
||||
---
|
||||
|
||||
## Cartographie des phases
|
||||
|
||||
```
|
||||
Phase 1 (Backend foundations) ──┐
|
||||
├─→ Phase 2 (IMAP provider + sync) ──┐
|
||||
│ ├─→ Phase 3 (API backend) ──┐
|
||||
│ │ │
|
||||
└─→─────────────────────────────────────────────────────────────────┤
|
||||
│
|
||||
Phase 4 (Frontend services + store) ←──────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
├─→ Phase 5 (UI principale 3 colonnes)
|
||||
│
|
||||
├─→ Phase 6 (Intégration tâches : modals, onglet TaskDrawer)
|
||||
│
|
||||
└─→ Phase 7 (Admin config + sidebar + polish)
|
||||
```
|
||||
|
||||
Chaque phase produit du logiciel fonctionnel (testable, mergeable) sans casser les précédentes.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Backend Foundations
|
||||
|
||||
**Plan détaillé attendu** : `docs/superpowers/plans/2026-05-19-mail-phase1-foundations.md`
|
||||
|
||||
**Scope** :
|
||||
- Entité `MailConfiguration` (singleton, fields complets de la spec, `encryptedPassword` via `TokenEncryptor`)
|
||||
- Entité `MailFolder`
|
||||
- Entité `MailMessage`
|
||||
- Entité `TaskMailLink` (avec unique constraint)
|
||||
- Repositories : `MailConfigurationRepository::findSingleton()`, `MailFolderRepository`, `MailMessageRepository`, `TaskMailLinkRepository`
|
||||
- Migration Doctrine unique créant les 4 tables (raw SQL)
|
||||
- DTOs sous `src/Mail/Dto/` : `MailFolderDto`, `MailMessageHeaderDto`, `MailMessageDetailDto`, `MailAttachmentDto`
|
||||
- Interface `App\Mail\MailProviderInterface` (signatures uniquement, pas d'impl)
|
||||
- Exception `App\Mail\Exception\MailProviderException`
|
||||
- Tests unitaires repositories (au moins le pattern singleton)
|
||||
|
||||
**Critère d'acceptation** :
|
||||
- `make migration-migrate` passe sans erreur
|
||||
- `php bin/console doctrine:schema:validate` OK
|
||||
- `make test` vert (au moins les tests créés)
|
||||
- Fixture `MailConfiguration` désactivée (OVH defaults) ajoutée
|
||||
|
||||
**Dépendances** : aucune (point d'entrée).
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — IMAP Provider + Sync
|
||||
|
||||
**Plan détaillé attendu** : `docs/superpowers/plans/2026-05-19-mail-phase2-imap-sync.md`
|
||||
|
||||
**Scope** :
|
||||
- Ajout dépendance Composer `webklex/php-imap` (vérifier compat PHP 8.4)
|
||||
- Implémentation `App\Mail\ImapMailProvider implements MailProviderInterface`
|
||||
- Lecture config via `MailConfigurationRepository::findSingleton()`
|
||||
- Déchiffrement password via `TokenEncryptor`
|
||||
- `listFolders`, `listMessages`, `fetchMessage`, `markRead`, `markFlagged`, `moveMessage`, `fetchAttachment`
|
||||
- Wrapping erreurs en `MailProviderException`
|
||||
- `App\Service\MailSyncService`
|
||||
- `syncAll(): MailSyncReport`
|
||||
- `syncFolder(string $folderPath): MailSyncReport`
|
||||
- `syncFolderStructure(): void`
|
||||
- Algorithme exact de la spec (UID FETCH lastUid+1:*, resync flags N=200 derniers, detect suppressions avec garde 50%)
|
||||
- DTO `MailSyncReport` (count créés / mis à jour / supprimés / errors)
|
||||
- Symfony Lock (`mail.sync`, TTL 10 min)
|
||||
- Commande console `app:mail:sync` (avec option `--folder=...`)
|
||||
- Documentation cron OS + cible Makefile `make mail-sync`
|
||||
- Tests : ImapMailProvider mocké via fixture serveur ou interface, MailSyncService avec provider mocké
|
||||
|
||||
**Critère d'acceptation** :
|
||||
- `php bin/console app:mail:sync --dry-run` fonctionne contre une fake config
|
||||
- Tests `make test` verts
|
||||
- `make mail-sync` documentée dans Makefile
|
||||
|
||||
**Dépendances** : Phase 1.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — API Backend
|
||||
|
||||
**Plan détaillé attendu** : `docs/superpowers/plans/2026-05-19-mail-phase3-api.md`
|
||||
|
||||
**Scope** :
|
||||
- API Platform ressources :
|
||||
- `GET /api/mail/configuration` (ROLE_ADMIN) — singleton provider
|
||||
- `PATCH /api/mail/configuration` (ROLE_ADMIN) — processor (jamais retourner password en clair, accepter nouveau password à chiffrer)
|
||||
- Custom controllers (priority: 1) :
|
||||
- `POST /api/mail/configuration/test` (ROLE_ADMIN) — test connexion
|
||||
- `GET /api/mail/folders` (ROLE_USER, refus ROLE_CLIENT explicite) — arbre + unreadCount depuis BDD
|
||||
- `GET /api/mail/folders/{path}/messages?page&limit` — pagination cursor `sentAt DESC, id DESC`
|
||||
- `GET /api/mail/messages/{id}` — fetch live IMAP + cache Symfony `mail_body_{messageId}` TTL 5 min
|
||||
- `POST /api/mail/messages/{id}/read` (body `{ read: bool }`)
|
||||
- `POST /api/mail/messages/{id}/flag`
|
||||
- `POST /api/mail/messages/{id}/create-task` (body `{ projectId, taskGroupId?, priority? }`)
|
||||
- `POST /api/mail/messages/{id}/link-task` (body `{ taskId }`)
|
||||
- `DELETE /api/mail/messages/{id}/link-task/{taskId}`
|
||||
- `GET /api/tasks/{id}/mails`
|
||||
- `GET /api/mail/attachments/{id}` — stream, `Content-Disposition: attachment`, jamais inline
|
||||
- `POST /api/mail/sync` — async via Messenger
|
||||
- Message + Handler Symfony Messenger `MailSyncRequested`
|
||||
- Sécurité : `#[IsGranted('IS_AUTHENTICATED_FULLY')]` + check `ROLE_USER && !ROLE_CLIENT` explicite
|
||||
- Tests fonctionnels endpoints (auth, format réponses, ROLE_CLIENT refusé)
|
||||
|
||||
**Critère d'acceptation** :
|
||||
- Tous endpoints répondent corrects status/format
|
||||
- Tests `make test` verts
|
||||
- ROLE_CLIENT refusé sur 100% des endpoints mail
|
||||
- Password jamais leak dans les réponses
|
||||
|
||||
**Dépendances** : Phase 1, Phase 2.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Frontend Services + Store
|
||||
|
||||
**Plan détaillé attendu** : `docs/superpowers/plans/2026-05-19-mail-phase4-frontend-services.md`
|
||||
|
||||
**Scope** :
|
||||
- Install npm `dompurify` + types
|
||||
- `frontend/services/dto/mail.ts` : tous les types TS
|
||||
- `frontend/services/mail.ts` : méthodes API (suivre pattern `tasks.ts`)
|
||||
- `listFolders`, `listMessages`, `getMessage`, `markRead`, `markFlagged`
|
||||
- `createTaskFromMail`, `linkTask`, `unlinkTask`, `listMailsForTask`
|
||||
- `triggerSync`
|
||||
- `getConfiguration`, `updateConfiguration`, `testConfiguration`
|
||||
- `downloadAttachment` (retourne Blob)
|
||||
- Store Pinia `frontend/stores/useMailStore.ts`
|
||||
- State : `folders`, `selectedFolderPath`, `messages[]`, `selectedMessageId`, `selectedMessageDetail`, `loading`, `syncing`, `globalUnreadCount`
|
||||
- Actions correspondantes
|
||||
- Polling `pollUnreadCount()` toutes les 30s (start/stop)
|
||||
- Sanitization helper `frontend/utils/sanitizeMailHtml.ts` (DOMPurify avec config bloquante : script/iframe/object/embed/on*/javascript:, strip ou placeholder pour `<img src="http(s)://...">` distants)
|
||||
|
||||
**Critère d'acceptation** :
|
||||
- `cd frontend && npx tsc --noEmit` OK
|
||||
- Test manuel d'un appel `mail.listFolders()` depuis devtools renvoie 401 si pas authentifié, 200 sinon
|
||||
|
||||
**Dépendances** : Phase 3 (les endpoints doivent exister).
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — UI principale (page /mail)
|
||||
|
||||
**Plan détaillé attendu** : `docs/superpowers/plans/2026-05-19-mail-phase5-ui-main.md`
|
||||
|
||||
**Scope** :
|
||||
- Page `frontend/pages/mail.vue` — layout 3 colonnes (dossiers / liste / lecteur), responsive
|
||||
- Composants `frontend/components/mail/` :
|
||||
- `MailFolderTree.vue` — arbre récursif avec badges unread, sélection
|
||||
- `MailMessageList.vue` — liste paginée (infinite scroll), indicateurs lu/étoilé/PJ, formatage relatif des dates
|
||||
- `MailMessageViewer.vue` — header (de/à/cc/date) + body sanitizé via DOMPurify + liste PJ téléchargeables + actions (Créer tâche / Lier / Marquer lu/non-lu / Étoiler)
|
||||
- `MailRefreshButton.vue` — bouton sync manuel, désactivé pendant `syncing`
|
||||
- i18n clés `mail.*` dans `frontend/i18n/locales/fr.json` (et `en.json` si présent) : titres, vides, actions, erreurs
|
||||
- Mapping noms dossiers système (`INBOX`, `Sent`, `Drafts`, `Archive`, `Trash`, `Junk`) → labels traduits
|
||||
- Gestion query param `?messageId=X` pour deep-link vers un mail (selection auto à l'ouverture)
|
||||
- Refus visuel pour ROLE_CLIENT (le middleware backend bloque déjà, mais ajouter check côté router/middleware Nuxt)
|
||||
|
||||
**Critère d'acceptation** :
|
||||
- Page accessible à `/mail` pour ROLE_USER/ROLE_ADMIN
|
||||
- ROLE_CLIENT redirigé vers `/portal`
|
||||
- Pas d'XSS via body mail (test manuel avec un mail contenant `<script>alert(1)</script>`)
|
||||
- Pixels tracking distants remplacés par placeholder
|
||||
|
||||
**Dépendances** : Phase 4.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Intégration Tâches
|
||||
|
||||
**Plan détaillé attendu** : `docs/superpowers/plans/2026-05-19-mail-phase6-task-integration.md`
|
||||
|
||||
**Scope** :
|
||||
- `frontend/components/mail/MailCreateTaskModal.vue` — wrapper du `TaskDrawer` existant pré-rempli :
|
||||
- Titre = subject
|
||||
- Description = body plain text
|
||||
- Picker projet + groupe + priorité
|
||||
- À la création : appelle `POST /api/mail/messages/{id}/create-task`, ferme modal, redirige ou affiche succès
|
||||
- `frontend/components/mail/MailLinkTaskModal.vue` — autocomplete sur tâches existantes (filter par projet, statut non-archivé)
|
||||
- Onglet **"Mails"** sur `TaskDrawer.vue` :
|
||||
- Nouvelle section affichée à côté Documents / Time tracking / etc.
|
||||
- Liste `MailMessage` liés à la tâche (via `GET /api/tasks/{id}/mails`)
|
||||
- Item cliquable → `router.push('/mail?messageId=' + id)`
|
||||
- Bouton "Lier un mail" → ouvre un picker mail (TBD selon ergonomie : modal recherche ou redirige vers /mail)
|
||||
- Tests manuels : créer tâche depuis mail, lier mail à tâche existante, voir mail depuis onglet tâche
|
||||
|
||||
**Critère d'acceptation** :
|
||||
- Workflow complet : mail → "Créer tâche" → tâche créée et liée → visible dans onglet "Mails" du TaskDrawer
|
||||
- Workflow : tâche existante → "Lier mail" → mail apparaît dans onglet
|
||||
|
||||
**Dépendances** : Phase 5.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — Admin Config + Sidebar + Polish
|
||||
|
||||
**Plan détaillé attendu** : `docs/superpowers/plans/2026-05-19-mail-phase7-admin-polish.md`
|
||||
|
||||
**Scope** :
|
||||
- `frontend/components/admin/AdminMailTab.vue` (calqué sur `AdminZimbraTab.vue`) :
|
||||
- Form : protocol (imap pour MVP), imapHost/Port/Encryption, smtpHost/Port/Encryption, username, password (write-only, `hasPassword: true` côté GET), sentFolderPath, enabled toggle
|
||||
- Bouton "Tester la connexion" → `POST /api/mail/configuration/test`
|
||||
- Indicateur OVH defaults pré-remplis (`ssl0.ovh.net:993/465`)
|
||||
- Ajout onglet `AdminMailTab` dans la page admin (selon pattern existant)
|
||||
- Lien sidebar dans le layout default :
|
||||
- Icône `material-symbols:mail-outline`
|
||||
- Label traduit
|
||||
- Badge unread (count `useMailStore.globalUnreadCount`)
|
||||
- Visible uniquement pour `ROLE_USER && !ROLE_CLIENT`
|
||||
- Lifecycle polling 30s : start dans `app.vue` ou layout default, stop au logout
|
||||
- Documentation finale :
|
||||
- README ou `docs/` : section "Mail integration" (cron OS, variables config, sécurité)
|
||||
- Makefile : `make mail-sync` documentée
|
||||
- Vérification finale tracking pixels (relire `sanitizeMailHtml.ts` + tester)
|
||||
- QA passe : workflow end-to-end depuis vraie boîte OVH (si dispo) ou IMAP test (greenmail/dovecot local)
|
||||
|
||||
**Critère d'acceptation** :
|
||||
- Admin peut configurer la boîte, tester, activer
|
||||
- Sidebar affiche badge unread temps réel (30s polling)
|
||||
- Doc d'install à jour
|
||||
- Aucun warning console front, aucun ERROR PHP dans `make logs-dev`
|
||||
|
||||
**Dépendances** : Phase 5 (sidebar utilise le store), Phase 3 (admin API).
|
||||
|
||||
---
|
||||
|
||||
## Conventions communes à toutes les phases
|
||||
|
||||
- **TDD** : test rouge → code → test vert → commit
|
||||
- **Strict types** PHP (`declare(strict_types=1)`) en tête de chaque fichier
|
||||
- **PHP CS Fixer** : `make php-cs-fixer-allow-risky` avant chaque commit
|
||||
- **Commits** : format `<type>(mail) : <message>` (espace avant `:`)
|
||||
- **Branche** : `feat/mail-integration` (créée au début de Phase 1)
|
||||
- **Pas de jamais logger** : bodies, password, attachments
|
||||
- **Review humaine entre chaque phase** : le user valide avant lancement phase suivante
|
||||
|
||||
---
|
||||
|
||||
## Cycle d'exécution
|
||||
|
||||
Pour chaque phase N :
|
||||
|
||||
1. **Spawn subagent rédacteur** (`feature-dev:code-architect`)
|
||||
- Input : ce master plan + spec + scope phase N
|
||||
- Output : `docs/superpowers/plans/2026-05-19-mail-phaseN-*.md` au format `writing-plans` (tasks bite-sized, fichiers exacts, code complet, commandes test)
|
||||
|
||||
2. **Spawn subagent codeur** (`ruflo-core:coder`)
|
||||
- Input : plan détaillé phase N
|
||||
- Output : code + tests + commits (TDD strict)
|
||||
|
||||
3. **Review humaine** : user valide ou demande corrections
|
||||
|
||||
4. **Phase suivante** uniquement si OK
|
||||
1632
docs/superpowers/plans/2026-05-19-mail-phase1-foundations.md
Normal file
1632
docs/superpowers/plans/2026-05-19-mail-phase1-foundations.md
Normal file
File diff suppressed because it is too large
Load Diff
1781
docs/superpowers/plans/2026-05-19-mail-phase2-imap-sync.md
Normal file
1781
docs/superpowers/plans/2026-05-19-mail-phase2-imap-sync.md
Normal file
File diff suppressed because it is too large
Load Diff
2410
docs/superpowers/plans/2026-05-19-mail-phase3-api.md
Normal file
2410
docs/superpowers/plans/2026-05-19-mail-phase3-api.md
Normal file
File diff suppressed because it is too large
Load Diff
1107
docs/superpowers/plans/2026-05-19-mail-phase4-frontend-services.md
Normal file
1107
docs/superpowers/plans/2026-05-19-mail-phase4-frontend-services.md
Normal file
File diff suppressed because it is too large
Load Diff
1203
docs/superpowers/plans/2026-05-19-mail-phase5-ui-main.md
Normal file
1203
docs/superpowers/plans/2026-05-19-mail-phase5-ui-main.md
Normal file
File diff suppressed because it is too large
Load Diff
1509
docs/superpowers/plans/2026-05-19-mail-phase6-task-integration.md
Normal file
1509
docs/superpowers/plans/2026-05-19-mail-phase6-task-integration.md
Normal file
File diff suppressed because it is too large
Load Diff
526
docs/superpowers/plans/2026-05-19-mail-phase7-admin-polish.md
Normal file
526
docs/superpowers/plans/2026-05-19-mail-phase7-admin-polish.md
Normal file
@@ -0,0 +1,526 @@
|
||||
# Mail Integration — Phase 7 : Admin Config + Sidebar + Polish
|
||||
|
||||
> **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:** Finaliser l'intégration mail avec l'UI admin de configuration, le lien sidebar avec badge unread temps réel (polling 30s), et la documentation utilisateur/opérationnelle finale.
|
||||
|
||||
**Architecture:** Onglet `AdminMailTab.vue` calqué sur `AdminZimbraTab.vue` (form IMAP/SMTP/credentials, bouton test connexion). Lien sidebar dans `layouts/default.vue` (visible ROLE_USER+ROLE_ADMIN seulement, masqué ROLE_CLIENT pur). Polling start au login / stop au logout via layout. Documentation finale dans `docs/` + section README mail.
|
||||
|
||||
**Tech Stack:** Nuxt 4, Vue 3 Composition API, @malio/layer-ui, Pinia (useMailStore).
|
||||
|
||||
---
|
||||
|
||||
## Fichiers créés / modifiés
|
||||
|
||||
| Fichier | Action |
|
||||
|---------|--------|
|
||||
| `frontend/components/admin/AdminMailTab.vue` | **Créer** |
|
||||
| `frontend/pages/admin.vue` | **Modifier** (ajout onglet mail) |
|
||||
| `frontend/layouts/default.vue` | **Modifier** (lien sidebar + polling lifecycle) |
|
||||
| `frontend/i18n/locales/fr.json` | **Modifier** (clés mail.admin.* + mail.sidebar.*) |
|
||||
| `frontend/i18n/locales/en.json` | **Modifier si présent** |
|
||||
| `docs/mail-cron-setup.md` | **Modifier** (enrichir checklist prod + sécurité) |
|
||||
| `docs/mail-integration.md` | **Créer** (doc complète intégration) |
|
||||
|
||||
---
|
||||
|
||||
## Task 1 : Composant `AdminMailTab.vue`
|
||||
|
||||
**Fichier cible :** `frontend/components/admin/AdminMailTab.vue`
|
||||
|
||||
**Modèle de référence :** `frontend/components/admin/AdminZimbraTab.vue` — reproduire exactement le même pattern (reactive form, hasPassword, isSaving/isTesting, loadSettings onMounted, handleSave/handleTest).
|
||||
|
||||
**Service à utiliser :** `useMailService()` depuis `~/services/mail` — méthodes `getConfiguration`, `updateConfiguration`, `testConfiguration`.
|
||||
|
||||
**DTOs :** `MailConfigurationDto`, `MailConfigurationUpdateDto`, `MailTestConnectionResultDto` depuis `~/services/dto/mail`.
|
||||
|
||||
### Étapes
|
||||
|
||||
- [ ] Créer `frontend/components/admin/AdminMailTab.vue`
|
||||
- [ ] Déclarer le reactive form avec tous les champs de `MailConfigurationDto` (sauf `hasPassword`, qui est en lecture seule) :
|
||||
```
|
||||
protocol: '' (lecture seule "imap" en MVP — champ disabled)
|
||||
imapHost: ''
|
||||
imapPort: 993 (default OVH)
|
||||
imapEncryption: 'ssl' (default OVH)
|
||||
smtpHost: ''
|
||||
smtpPort: 465 (default OVH)
|
||||
smtpEncryption: 'ssl' (default OVH)
|
||||
username: ''
|
||||
password: '' (write-only — jamais pré-rempli)
|
||||
sentFolderPath: '' (ex: "Sent Messages" ou "INBOX.Sent")
|
||||
enabled: false
|
||||
```
|
||||
- [ ] `hasPassword` : `ref<boolean>(false)` — alimenté par `getConfiguration().hasPassword`
|
||||
- [ ] `isSaving` : `ref<boolean>(false)`, `isTesting` : `ref<boolean>(false)`
|
||||
- [ ] `testResult` : `ref<boolean | null>(null)` — réinitialisé à null au handleSave
|
||||
- [ ] `loadSettings()` :
|
||||
```ts
|
||||
async function loadSettings(): Promise<void> {
|
||||
const config = await getConfiguration()
|
||||
form.protocol = config.protocol ?? 'imap'
|
||||
form.imapHost = config.imapHost ?? ''
|
||||
form.imapPort = config.imapPort ?? 993
|
||||
form.imapEncryption = config.imapEncryption ?? 'ssl'
|
||||
form.smtpHost = config.smtpHost ?? ''
|
||||
form.smtpPort = config.smtpPort ?? 465
|
||||
form.smtpEncryption = config.smtpEncryption ?? 'ssl'
|
||||
form.username = config.username ?? ''
|
||||
form.sentFolderPath = config.sentFolderPath ?? ''
|
||||
form.enabled = config.enabled
|
||||
hasPassword.value = config.hasPassword
|
||||
// password jamais pré-rempli
|
||||
}
|
||||
```
|
||||
- [ ] `handleSave()` : construit un `MailConfigurationUpdateDto` — inclure `password` uniquement si `form.password` est non-vide, sinon omettre le champ. Après save réussi : `hasPassword.value = result.hasPassword`, vider `form.password`, `testResult.value = null`
|
||||
- [ ] `handleTest()` : appelle `testConfiguration()`, `testResult.value = result.ok`. Le champ `result.error` est affiché en sous-texte si `testResult.value === false`
|
||||
- [ ] Template — sections IMAP et SMTP avec labels traduits :
|
||||
- Titre `h2` : `$t('mail.admin.title')`
|
||||
- Section IMAP (`fieldset` ou `div` avec titre `$t('mail.admin.imapSection')`) :
|
||||
- `MalioInputText` pour `imapHost` + helper text `$t('mail.admin.ovhDefaultsHelp')` sous le champ (texte gris : `ssl0.ovh.net`)
|
||||
- `input[type=number]` natif pour `imapPort` (MalioInputText n'accepte pas les number — voir convention CLAUDE.md)
|
||||
- `select` natif pour `imapEncryption` (options : `ssl`, `tls`, `none`)
|
||||
- Section SMTP (`$t('mail.admin.smtpSection')`) :
|
||||
- `MalioInputText` pour `smtpHost`
|
||||
- `input[type=number]` natif pour `smtpPort`
|
||||
- `select` natif pour `smtpEncryption` (options : `ssl`, `tls`, `none`)
|
||||
- Credentials :
|
||||
- `MalioInputText` pour `username`
|
||||
- `MalioInputPassword` pour `password` + indicateur `hasPassword` (même pattern que `AdminZimbraTab.vue` : `<p v-if="hasPassword && !form.password">{{ $t('mail.admin.passwordSet') }}</p>`)
|
||||
- `MalioInputText` pour `sentFolderPath` (placeholder: `Sent Messages`)
|
||||
- `label` + checkbox natif pour `enabled` : `$t('mail.admin.enabled')`
|
||||
- Boutons côte à côte :
|
||||
- `MalioButton` submit `$t('mail.admin.save')` `:disabled="isSaving"` → `handleSave`
|
||||
- `MalioButton` variant tertiary `$t('mail.admin.test')` `:disabled="isTesting"` → `handleTest`
|
||||
- Résultat test : `<p v-if="testResult !== null">` coloré vert/rouge selon valeur — si false ET `testError`, afficher `testError` sous le résultat
|
||||
- [ ] `onMounted(() => { loadSettings() })`
|
||||
- [ ] Vérifier indentation 4 espaces, pas d'imports inutilisés, TypeScript strict
|
||||
|
||||
---
|
||||
|
||||
## Task 2 : Intégration `AdminMailTab` dans `pages/admin.vue`
|
||||
|
||||
**Fichier cible :** `frontend/pages/admin.vue`
|
||||
|
||||
Le pattern actuel utilise un tableau `tabs as const` + `activeTab` ref + v-if par composant. Il suffit d'ajouter l'entrée mail à la fin.
|
||||
|
||||
### Étapes
|
||||
|
||||
- [ ] Ouvrir `frontend/pages/admin.vue`
|
||||
- [ ] Dans le tableau `tabs`, ajouter à la fin :
|
||||
```ts
|
||||
{ key: 'mail', label: 'Mail' },
|
||||
```
|
||||
Remarque : les labels dans `tabs` sont des string litéraux inline (cf. autres onglets comme `'Zimbra'`), pas de i18n ici.
|
||||
- [ ] Le type `TabKey` est inféré automatiquement via `typeof tabs[number]['key']` — pas de changement nécessaire
|
||||
- [ ] Dans le template, après `<AdminZimbraTab v-if="activeTab === 'zimbra'" />`, ajouter :
|
||||
```html
|
||||
<AdminMailTab v-if="activeTab === 'mail'" />
|
||||
```
|
||||
- [ ] Vérifier que Nuxt auto-importe `AdminMailTab` (fichier dans `components/admin/` → auto-import OK)
|
||||
- [ ] Test manuel : naviguer vers `/admin`, cliquer l'onglet "Mail", vérifier que le form se charge sans erreur 403 si connecté ROLE_ADMIN
|
||||
|
||||
---
|
||||
|
||||
## Task 3 : Lien sidebar dans `layouts/default.vue`
|
||||
|
||||
**Fichier cible :** `frontend/layouts/default.vue`
|
||||
|
||||
Le composant `SidebarLink` accepte `to`, `icon`, `label`, `collapsed`. Il n'a pas de prop `badge` native — vérifier dans `@malio/layer-ui/COMPONENTS.md` si une prop badge existe. Si non, wrapper manuel avec un `<div class="relative">` + badge absolu.
|
||||
|
||||
### Étapes
|
||||
|
||||
- [ ] Lire `frontend/node_modules/@malio/layer-ui/COMPONENTS.md` pour vérifier les props de `SidebarLink` (présence prop `badge` ou `badgeCount`)
|
||||
- [ ] **Cas A — SidebarLink a une prop badge :**
|
||||
Utiliser directement :
|
||||
```html
|
||||
<SidebarLink
|
||||
v-if="isMailVisible"
|
||||
to="/mail"
|
||||
icon="material-symbols:mail-outline"
|
||||
label="$t('mail.sidebar.title')"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
:badge="mailStore.globalUnreadCount > 0 ? mailStore.globalUnreadCount : undefined"
|
||||
aria-label="$t('mail.sidebar.ariaLabel')"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
```
|
||||
- [ ] **Cas B — SidebarLink n'a pas de prop badge (plus probable) :**
|
||||
Wrapper avec badge manuel :
|
||||
```html
|
||||
<div v-if="isMailVisible" class="relative">
|
||||
<SidebarLink
|
||||
to="/mail"
|
||||
icon="material-symbols:mail-outline"
|
||||
:label="$t('mail.sidebar.title')"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<span
|
||||
v-if="mailStore.globalUnreadCount > 0"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
|
||||
:aria-label="`${mailStore.globalUnreadCount} messages non lus`"
|
||||
>
|
||||
{{ mailStore.globalUnreadCount > 99 ? '99+' : mailStore.globalUnreadCount }}
|
||||
</span>
|
||||
</div>
|
||||
```
|
||||
- [ ] Dans `<script setup>`, ajouter :
|
||||
```ts
|
||||
const mailStore = useMailStore()
|
||||
```
|
||||
- [ ] Définir le computed `isMailVisible` :
|
||||
```ts
|
||||
const isMailVisible = computed(() => {
|
||||
const roles: string[] = auth.user?.roles ?? []
|
||||
// Visible si ROLE_USER (ou ROLE_ADMIN) mais pas ROLE_CLIENT exclusif
|
||||
const isClient = roles.includes('ROLE_CLIENT') && !roles.includes('ROLE_ADMIN') && !roles.includes('ROLE_USER')
|
||||
return !isClient && (roles.includes('ROLE_USER') || roles.includes('ROLE_ADMIN'))
|
||||
})
|
||||
```
|
||||
- [ ] Placer le lien sidebar **après** `SidebarLink to="/my-tasks"` et **avant** `SidebarLink to="/projects"` (ordre logique : dashboard → mes tâches → mail → projets → suivi de temps → admin)
|
||||
- [ ] Vérifier responsive : en mode collapsed (`sidebarIsCollapsed = true`), le badge doit rester visible et accessible
|
||||
- [ ] Test manuel : utilisateur ROLE_CLIENT seul → lien absent. Utilisateur ROLE_USER → lien visible. Badge rouge si `globalUnreadCount > 0`
|
||||
|
||||
---
|
||||
|
||||
## Task 4 : Lifecycle polling start/stop
|
||||
|
||||
**Fichier cible :** `frontend/layouts/default.vue`
|
||||
|
||||
Le store `useMailStore` expose `startPolling()` (idempotent — guard `if (pollTimer) return`) et `stopPolling()`. Le polling doit démarrer au montage du layout (si l'utilisateur est autorisé) et s'arrêter au logout.
|
||||
|
||||
### Étapes
|
||||
|
||||
- [ ] Dans `onMounted` de `layouts/default.vue` (qui contient déjà `timerStore.fetchActive()`), ajouter après :
|
||||
```ts
|
||||
if (isMailVisible.value) {
|
||||
mailStore.startPolling()
|
||||
}
|
||||
```
|
||||
- [ ] Vérifier que `isMailVisible` est disponible dans le même scope (oui, c'est un computed défini dans `<script setup>`)
|
||||
- [ ] Pour le stop au logout : dans `useAuthStore`, le logout vide l'user. Watcher sur `auth.user` dans le layout :
|
||||
```ts
|
||||
watch(() => auth.user, (user) => {
|
||||
if (!user) {
|
||||
mailStore.stopPolling()
|
||||
} else if (isMailVisible.value) {
|
||||
mailStore.startPolling()
|
||||
}
|
||||
})
|
||||
```
|
||||
- [ ] Vérifier l'idempotence : `startPolling()` dans le store a déjà `if (pollTimer) return` — naviguer entre les pages ne crée pas plusieurs timers
|
||||
- [ ] `onUnmounted` dans le layout n'est pas nécessaire car le layout persiste toute la session ; le watch sur `auth.user` suffit
|
||||
- [ ] Test manuel : ouvrir devtools → Network → vérifier un seul appel `GET /api/mail/folders` toutes les 30s, pas de rafale
|
||||
|
||||
---
|
||||
|
||||
## Task 5 : i18n additionnels Phase 7
|
||||
|
||||
**Fichiers cibles :** `frontend/i18n/locales/fr.json` (et `en.json` si présent)
|
||||
|
||||
### Clés à ajouter (section `mail` — fusionner avec les clés existantes des phases précédentes)
|
||||
|
||||
```json
|
||||
{
|
||||
"mail": {
|
||||
"sidebar": {
|
||||
"title": "Messagerie",
|
||||
"ariaLabel": "Accès à la messagerie, {count} messages non lus"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Configuration messagerie",
|
||||
"protocol": "Protocole",
|
||||
"imapSection": "Réception (IMAP)",
|
||||
"smtpSection": "Envoi (SMTP)",
|
||||
"host": "Serveur",
|
||||
"port": "Port",
|
||||
"encryption": "Chiffrement",
|
||||
"username": "Adresse e-mail",
|
||||
"password": "Mot de passe",
|
||||
"passwordSet": "Mot de passe déjà configuré — laisser vide pour conserver",
|
||||
"sentFolderPath": "Dossier des envois",
|
||||
"enabled": "Activer la synchronisation mail",
|
||||
"test": "Tester la connexion",
|
||||
"testSuccess": "Connexion IMAP réussie",
|
||||
"testFailed": "Échec de connexion",
|
||||
"save": "Enregistrer",
|
||||
"saveSuccess": "Configuration enregistrée",
|
||||
"ovhDefaultsHelp": "OVH : ssl0.ovh.net (port 993 IMAP / 465 SMTP)"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Étapes
|
||||
|
||||
- [ ] Ouvrir `frontend/i18n/locales/fr.json`
|
||||
- [ ] Localiser la section `mail` existante (créée en Phase 4/5)
|
||||
- [ ] Fusionner les clés `mail.sidebar.*` et `mail.admin.*` sans écraser les clés existantes
|
||||
- [ ] Si `en.json` existe : ajouter les équivalents anglais (traduction directe — pas d'approximation)
|
||||
- [ ] Vérifier la cohérence JSON (virgules, pas de clés dupliquées)
|
||||
- [ ] `make dev-nuxt` → console browser → 0 warning `[vue-i18n] Missing locale message`
|
||||
|
||||
---
|
||||
|
||||
## Task 6 : Documentation finale
|
||||
|
||||
### 6a — Enrichir `docs/mail-cron-setup.md`
|
||||
|
||||
**Fichier cible :** `docs/mail-cron-setup.md`
|
||||
|
||||
Ce fichier existe déjà (créé Phase 2). Ajouter les sections manquantes :
|
||||
|
||||
- [ ] Ajouter section **"Checklist setup production"** après la section "Variables d'environnement" :
|
||||
```markdown
|
||||
## Checklist setup production
|
||||
|
||||
1. [ ] Définir `ENCRYPTION_KEY` dans les variables d'environnement production
|
||||
2. [ ] Créer le compte mail dédié (ex: `lesstime@votre-domaine.fr`) chez OVH
|
||||
3. [ ] Accéder à `/admin` → onglet "Mail" → renseigner les credentials IMAP/SMTP
|
||||
4. [ ] Cliquer "Tester la connexion" → vérifier le succès
|
||||
5. [ ] Cocher "Activer la synchronisation" → Enregistrer
|
||||
6. [ ] Installer le cron OS (voir section "Installation du cron")
|
||||
7. [ ] Vérifier les logs après la première sync : `make logs-dev` (chercher `mail.sync`)
|
||||
```
|
||||
- [ ] Ajouter section **"Sécurité"** (si absente ou incomplète) :
|
||||
```markdown
|
||||
## Rappels sécurité
|
||||
|
||||
- La page `/mail` et tous les endpoints `/api/mail/*` sont refusés aux `ROLE_CLIENT` exclusifs
|
||||
- Le sidebar "Messagerie" est masqué pour les utilisateurs ROLE_CLIENT sans ROLE_USER
|
||||
- Le password IMAP est chiffré via libsodium secretbox avant stockage (jamais en clair en base)
|
||||
- Les corps de mails sont sanitisés via DOMPurify avant affichage (voir `frontend/utils/sanitizeMailHtml.ts`)
|
||||
- Les pixels tracking distants sont remplacés par un placeholder
|
||||
- Aucun body mail, password ou contenu de pièce jointe n'est loggé
|
||||
```
|
||||
|
||||
### 6b — Créer `docs/mail-integration.md`
|
||||
|
||||
**Fichier cible :** `docs/mail-integration.md`
|
||||
|
||||
- [ ] Créer le fichier avec les sections suivantes :
|
||||
|
||||
```markdown
|
||||
# Intégration Mail — Vue d'ensemble
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
- Lecture de la boîte mail partagée (IMAP) depuis Lesstime
|
||||
- Navigation par dossiers (arbre récursif avec compteurs non-lus)
|
||||
- Liste paginée des messages (infinite scroll, cursor-based)
|
||||
- Lecture des corps de mail sanitisés (DOMPurify — protection XSS + pixels tracking)
|
||||
- Création d'une tâche Lesstime depuis un mail (sujet → titre, texte → description)
|
||||
- Lien mail ↔ tâche (bidirectionnel)
|
||||
- Onglet "Mails" dans le TaskDrawer pour retrouver les mails liés à une tâche
|
||||
- Synchronisation IMAP automatique via cron OS (toutes les 10 min)
|
||||
- Déclenchement manuel de sync depuis l'UI (bouton Refresh)
|
||||
- Badge non-lus en temps réel dans la sidebar (polling 30s)
|
||||
|
||||
## Endpoints API
|
||||
|
||||
| Méthode | URL | Rôle | Description |
|
||||
|---------|-----|------|-------------|
|
||||
| GET | `/api/mail/configuration` | ROLE_ADMIN | Lire la config singleton |
|
||||
| PATCH | `/api/mail/configuration` | ROLE_ADMIN | Mettre à jour la config |
|
||||
| POST | `/api/mail/configuration/test` | ROLE_ADMIN | Tester la connexion IMAP |
|
||||
| GET | `/api/mail/folders` | ROLE_USER | Arbre des dossiers + unread |
|
||||
| GET | `/api/mail/messages` | ROLE_USER | Liste paginée (param: folder, cursor, limit) |
|
||||
| GET | `/api/mail/messages/{id}` | ROLE_USER | Détail + body (cached 5 min) |
|
||||
| POST | `/api/mail/messages/{id}/read` | ROLE_USER | Marquer lu/non-lu |
|
||||
| POST | `/api/mail/messages/{id}/flag` | ROLE_USER | Marquer étoilé/non-étoilé |
|
||||
| POST | `/api/mail/messages/{id}/create-task` | ROLE_USER | Créer tâche depuis mail |
|
||||
| POST | `/api/mail/messages/{id}/link-task` | ROLE_USER | Lier mail à tâche existante |
|
||||
| DELETE | `/api/mail/messages/{id}/link-task/{taskId}` | ROLE_USER | Supprimer le lien |
|
||||
| GET | `/api/tasks/{id}/mails` | ROLE_USER | Mails liés à une tâche |
|
||||
| GET | `/api/mail/attachments/{id}` | ROLE_USER | Télécharger une pièce jointe |
|
||||
| POST | `/api/mail/sync` | ROLE_USER | Déclencher sync async (Messenger) |
|
||||
|
||||
Tous les endpoints `/api/mail/*` refusent explicitement `ROLE_CLIENT`.
|
||||
|
||||
## Sécurité
|
||||
|
||||
- ROLE_CLIENT exclusif : accès refusé à tous les endpoints mail et à la page `/mail`
|
||||
- Le sidebar "Messagerie" est masqué pour les ROLE_CLIENT
|
||||
- Password IMAP chiffré via libsodium secretbox (env `ENCRYPTION_KEY`)
|
||||
- Corps de mail sanitisés via DOMPurify (`sanitizeMailHtml.ts`) — script/iframe/object/embed/on*/javascript: bloqués
|
||||
- Pixels tracking distants (img src http) remplacés par placeholder
|
||||
- Aucun body, password ou contenu de pièce jointe dans les logs
|
||||
|
||||
## Dépendances
|
||||
|
||||
### Backend
|
||||
- `webklex/php-imap` : client IMAP PHP
|
||||
- `symfony/lock` : Symfony Lock pour éviter les syncs parallèles
|
||||
- `symfony/messenger` : dispatch asynchrone `MailSyncRequested`
|
||||
- `libsodium` (ext PHP) : chiffrement du password IMAP
|
||||
|
||||
### Frontend
|
||||
- `dompurify` + `@types/dompurify` : sanitization HTML des corps de mail
|
||||
|
||||
## Fichiers clés
|
||||
|
||||
### Backend
|
||||
- `src/Entity/MailConfiguration.php` — entité singleton (credentials, enabled)
|
||||
- `src/Entity/MailFolder.php` — dossier IMAP synced
|
||||
- `src/Entity/MailMessage.php` — message IMAP synced (headers, flags)
|
||||
- `src/Entity/TaskMailLink.php` — lien tâche ↔ mail
|
||||
- `src/Mail/ImapMailProvider.php` — implémentation IMAP (webklex)
|
||||
- `src/Service/MailSyncService.php` — algorithme de sync (UID FETCH, resync flags)
|
||||
- `src/Controller/Mail/` — controllers custom (test, folders, messages, sync)
|
||||
- `src/State/Mail/` — providers/processors API Platform (configuration)
|
||||
|
||||
### Frontend
|
||||
- `frontend/pages/mail.vue` — page principale 3 colonnes
|
||||
- `frontend/components/mail/` — MailFolderTree, MailMessageList, MailMessageViewer, MailRefreshButton
|
||||
- `frontend/components/admin/AdminMailTab.vue` — onglet config admin
|
||||
- `frontend/stores/mail.ts` — store Pinia (folders, messages, polling)
|
||||
- `frontend/services/mail.ts` — service API (toutes les méthodes)
|
||||
- `frontend/services/dto/mail.ts` — types TypeScript
|
||||
- `frontend/utils/sanitizeMailHtml.ts` — DOMPurify wrapper
|
||||
|
||||
## Synchronisation cron
|
||||
|
||||
Voir `docs/mail-cron-setup.md` pour la configuration détaillée.
|
||||
|
||||
Résumé :
|
||||
```bash
|
||||
# Cron OS (toutes les 10 min)
|
||||
*/10 * * * * cd /path/to/Lesstime && make mail-sync >> /var/log/lesstime-mail-sync.log 2>&1
|
||||
|
||||
# Commandes Makefile
|
||||
make mail-sync # Sync complète
|
||||
make mail-sync FOLDER=INBOX # Sync d'un dossier
|
||||
make mail-sync DRYRUN=1 # Simulation sans écriture
|
||||
```
|
||||
|
||||
## Configuration admin
|
||||
|
||||
1. Aller sur `/admin` → onglet "Mail"
|
||||
2. Renseigner les credentials IMAP/SMTP (OVH : `ssl0.ovh.net`, port 993/465, SSL)
|
||||
3. Cliquer "Tester la connexion"
|
||||
4. Activer la synchronisation → Enregistrer
|
||||
5. Configurer le cron OS
|
||||
|
||||
## Variables d'environnement
|
||||
|
||||
| Variable | Description | Obligatoire |
|
||||
|----------|-------------|-------------|
|
||||
| `ENCRYPTION_KEY` | Clé hex 32 bytes libsodium pour chiffrer le password IMAP | Oui |
|
||||
| `LOCK_DSN` | DSN Symfony Lock (défaut: `flock`) | Non |
|
||||
| `MESSENGER_TRANSPORT_DSN` | Transport Messenger pour sync async | Recommandé (prod) |
|
||||
```
|
||||
|
||||
### 6c — Vérifier `make mail-sync` dans le README
|
||||
|
||||
- [ ] Ouvrir `README.md` à la racine de Lesstime
|
||||
- [ ] Vérifier si une section mail ou une mention de `make mail-sync` existe déjà
|
||||
- [ ] Si absente : ajouter dans la section des commandes Makefile une ligne documentant `make mail-sync` avec la description courte (cf. le commentaire déjà présent dans le makefile)
|
||||
|
||||
---
|
||||
|
||||
## Task 7 : Vérifications sécurité finales
|
||||
|
||||
### Étapes
|
||||
|
||||
- [ ] Ouvrir `frontend/utils/sanitizeMailHtml.ts` — vérifier la config DOMPurify :
|
||||
- `FORBID_TAGS` doit inclure : `script`, `iframe`, `object`, `embed`, `form`, `input`
|
||||
- `FORBID_ATTR` doit inclure tous les handlers `on*` + `javascript:` dans `href`/`src`
|
||||
- Les `<img src="http(s)://...">` distants sont remplacés par un placeholder (pas juste supprimés)
|
||||
- Si manquant, noter la correction mais ne pas modifier (la correction est documentée ici pour le codeur)
|
||||
- [ ] Test injection XSS manuel (dans la console browser, sur la page `/mail`) :
|
||||
```js
|
||||
import('/utils/sanitizeMailHtml').then(m => {
|
||||
console.log(m.sanitizeMailHtml('<script>alert(1)</script><img src=x onerror=alert(2)><iframe src="javascript:alert(3)"></iframe>'))
|
||||
})
|
||||
```
|
||||
Résultat attendu : chaîne sans `<script>`, sans `onerror`, sans `<iframe>`
|
||||
- [ ] Grep logs — confirmer aucun body/password/attachment dans les logs :
|
||||
```bash
|
||||
grep -rn "bodyHtml\|bodyText\|password\|attachment.*content" src/Mail/ src/Service/MailSyncService.php src/Controller/Mail/ --include="*.php"
|
||||
```
|
||||
Vérifier que les occurrences trouvées sont uniquement des définitions de propriétés, jamais passées à un logger
|
||||
- [ ] Vérifier que `GET /api/mail/configuration` ne retourne jamais de champ `password` dans la réponse JSON (tester avec `curl -s http://localhost:8082/api/mail/configuration -H "Cookie: BEARER=..."` ou équivalent)
|
||||
- [ ] Vérifier que `POST /api/mail/folders` avec un cookie ROLE_CLIENT retourne bien 403
|
||||
|
||||
---
|
||||
|
||||
## Task 8 : QA passe end-to-end
|
||||
|
||||
### Étapes
|
||||
|
||||
- [ ] `make test` → 0 failure, 0 error
|
||||
- [ ] `make php-cs-fixer-allow-risky` → idempotent (0 fichier modifié)
|
||||
- [ ] `cd frontend && npx tsc --noEmit` → 0 erreur TypeScript
|
||||
- [ ] `make dev-nuxt` → démarrage OK, 0 erreur console browser au load de `/mail`
|
||||
- [ ] **Workflow admin :**
|
||||
- Se connecter en admin
|
||||
- Aller sur `/admin` → onglet "Mail"
|
||||
- Renseigner `imapHost = ssl0.ovh.net`, `imapPort = 993`, `imapEncryption = ssl`, `username = test@example.com`, `password = test`
|
||||
- Cliquer "Tester la connexion" → résultat affiché (succès ou échec selon config réelle)
|
||||
- Enregistrer → toast "Configuration enregistrée"
|
||||
- Rechargement de la page → les champs sont pré-remplis, indicateur "Mot de passe déjà configuré" visible
|
||||
- [ ] **Workflow sidebar :**
|
||||
- Se connecter en ROLE_USER
|
||||
- Vérifier que le lien "Messagerie" est visible dans la sidebar
|
||||
- Vérifier le badge si `globalUnreadCount > 0`
|
||||
- Se connecter en ROLE_CLIENT → vérifier l'absence du lien sidebar
|
||||
- [ ] **Workflow polling :**
|
||||
- Ouvrir les DevTools → Network → filtrer sur `mail/folders`
|
||||
- Rester sur une page 90s → exactement 3 appels (1 immédiat + 2 toutes les 30s)
|
||||
- Naviguer entre `/mail` et `/my-tasks` → pas de rafale, pas de duplication du polling
|
||||
- [ ] **Workflow complet mail → tâche (régression Phase 6) :**
|
||||
- Ouvrir un mail dans `/mail`
|
||||
- Cliquer "Créer tâche" → modal → sélectionner projet → créer
|
||||
- Tâche apparaît dans `/my-tasks` avec le mail lié
|
||||
- Depuis le TaskDrawer de la tâche → onglet "Mails" → mail visible → cliquer → redirection `/mail?messageId=X`
|
||||
- [ ] **Simulation sync :**
|
||||
- `make mail-sync DRYRUN=1` → commande retourne 0, pas d'erreur Symfony
|
||||
|
||||
---
|
||||
|
||||
## Task 9 : Cleanup final
|
||||
|
||||
### Étapes
|
||||
|
||||
- [ ] Grep debug dans tous les fichiers mail frontend :
|
||||
```bash
|
||||
grep -rn "console\.log\|console\.warn\|console\.error\|debugger" frontend/components/mail/ frontend/components/admin/AdminMailTab.vue frontend/stores/mail.ts frontend/services/mail.ts frontend/utils/sanitizeMailHtml.ts
|
||||
```
|
||||
Supprimer toute occurrence (sauf `console.error` intentionnel avec commentaire explicatif)
|
||||
- [ ] Grep TODO/FIXME/HACK :
|
||||
```bash
|
||||
grep -rn "TODO\|FIXME\|HACK\|XXX" frontend/components/mail/ frontend/components/admin/AdminMailTab.vue frontend/stores/mail.ts frontend/services/mail.ts
|
||||
```
|
||||
Résoudre ou supprimer chaque occurrence
|
||||
- [ ] Vérifier qu'aucun import inutilisé ne traîne dans `AdminMailTab.vue` et les fichiers modifiés dans `layouts/default.vue`
|
||||
- [ ] `cd frontend && npx tsc --noEmit` → toujours 0 erreur après cleanup
|
||||
- [ ] Si des modifications ont été faites depuis le dernier commit Phase 6, créer un commit final :
|
||||
```
|
||||
feat(mail) : Phase 7 — admin config tab, sidebar badge, polling lifecycle
|
||||
docs(mail) : documentation intégration mail complète
|
||||
```
|
||||
(deux commits séparés si les changements sont distincts)
|
||||
|
||||
---
|
||||
|
||||
## Critères d'acceptation (Phase 7 complète)
|
||||
|
||||
- [ ] Admin peut accéder à `/admin` → onglet "Mail" → configurer IMAP/SMTP → tester → activer
|
||||
- [ ] Le sidebar affiche un badge unread actualisé toutes les 30s pour ROLE_USER/ROLE_ADMIN
|
||||
- [ ] Le sidebar "Messagerie" est invisible pour ROLE_CLIENT exclusif
|
||||
- [ ] `make test` vert
|
||||
- [ ] `npx tsc --noEmit` 0 erreur
|
||||
- [ ] 0 warning console browser au chargement
|
||||
- [ ] 0 ERROR PHP dans `make logs-dev` pendant le workflow normal
|
||||
- [ ] `docs/mail-integration.md` complet et accessible
|
||||
- [ ] `docs/mail-cron-setup.md` enrichi avec checklist prod et rappels sécurité
|
||||
|
||||
---
|
||||
|
||||
## Dépendances
|
||||
|
||||
- **Phase 5** (store `useMailStore` avec `startPolling`/`stopPolling` + page `/mail`) — DONE
|
||||
- **Phase 6** (intégration tâches) — DONE
|
||||
- **Phase 3** (endpoints `/api/mail/configuration` GET/PATCH/test, ROLE_CLIENT refusé) — DONE
|
||||
- **Phase 4** (services `getConfiguration`, `updateConfiguration`, `testConfiguration`, DTOs) — DONE
|
||||
231
frontend/components/admin/AdminMailTab.vue
Normal file
231
frontend/components/admin/AdminMailTab.vue
Normal file
@@ -0,0 +1,231 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-neutral-900">{{ $t('mail.admin.title') }}</h2>
|
||||
|
||||
<form class="mt-6 max-w-lg space-y-6" @submit.prevent="handleSave">
|
||||
<!-- Section IMAP (réception) -->
|
||||
<fieldset class="space-y-4">
|
||||
<legend class="text-sm font-bold text-neutral-700">{{ $t('mail.admin.imapSection') }}</legend>
|
||||
|
||||
<div>
|
||||
<MalioInputText
|
||||
v-model="form.imapHost"
|
||||
:label="$t('mail.admin.host')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-neutral-500">{{ $t('mail.admin.ovhDefaultsHelp') }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-neutral-700">{{ $t('mail.admin.port') }}</label>
|
||||
<input
|
||||
v-model.number="form.imapPort"
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
class="mt-1 w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-neutral-700">{{ $t('mail.admin.encryption') }}</label>
|
||||
<select
|
||||
v-model="form.imapEncryption"
|
||||
class="mt-1 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
>
|
||||
<option value="ssl">SSL</option>
|
||||
<option value="tls">TLS</option>
|
||||
<option value="none">Aucun</option>
|
||||
</select>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Section SMTP (envoi) -->
|
||||
<fieldset class="space-y-4">
|
||||
<legend class="text-sm font-bold text-neutral-700">{{ $t('mail.admin.smtpSection') }}</legend>
|
||||
|
||||
<MalioInputText
|
||||
v-model="form.smtpHost"
|
||||
:label="$t('mail.admin.host')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-neutral-700">{{ $t('mail.admin.port') }}</label>
|
||||
<input
|
||||
v-model.number="form.smtpPort"
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
class="mt-1 w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-neutral-700">{{ $t('mail.admin.encryption') }}</label>
|
||||
<select
|
||||
v-model="form.smtpEncryption"
|
||||
class="mt-1 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
>
|
||||
<option value="ssl">SSL</option>
|
||||
<option value="tls">TLS</option>
|
||||
<option value="none">Aucun</option>
|
||||
</select>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Credentials -->
|
||||
<fieldset class="space-y-4">
|
||||
<legend class="text-sm font-bold text-neutral-700">{{ $t('mail.admin.username') }}</legend>
|
||||
|
||||
<MalioInputText
|
||||
v-model="form.username"
|
||||
:label="$t('mail.admin.username')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<MalioInputPassword
|
||||
v-model="form.password"
|
||||
:label="$t('mail.admin.password')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<p v-if="hasPassword && !form.password" class="mt-1 text-xs text-green-600">
|
||||
{{ $t('mail.admin.passwordSet') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<MalioInputText
|
||||
v-model="form.sentFolderPath"
|
||||
:label="$t('mail.admin.sentFolderPath')"
|
||||
placeholder="Sent Messages"
|
||||
input-class="w-full"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<input v-model="form.enabled" type="checkbox" class="rounded border-neutral-300" />
|
||||
<span class="text-sm">{{ $t('mail.admin.enabled') }}</span>
|
||||
</label>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<MalioButton
|
||||
:label="$t('mail.admin.save')"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isSaving"
|
||||
@click="handleSave"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="$t('mail.admin.test')"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isTesting"
|
||||
@click="handleTest"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="testResult !== null">
|
||||
<p
|
||||
class="text-sm font-medium"
|
||||
:class="testResult ? 'text-green-600' : 'text-red-600'"
|
||||
>
|
||||
{{ testResult ? $t('mail.admin.testSuccess') : $t('mail.admin.testFailed') }}
|
||||
</p>
|
||||
<p v-if="testResult === false && testError" class="mt-1 text-xs text-neutral-500">
|
||||
{{ testError }}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMailService } from '~/services/mail'
|
||||
|
||||
const { getConfiguration, updateConfiguration, testConfiguration } = useMailService()
|
||||
|
||||
const form = reactive({
|
||||
protocol: 'imap',
|
||||
imapHost: '',
|
||||
imapPort: 993,
|
||||
imapEncryption: 'ssl',
|
||||
smtpHost: '',
|
||||
smtpPort: 465,
|
||||
smtpEncryption: 'ssl',
|
||||
username: '',
|
||||
password: '',
|
||||
sentFolderPath: '',
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
const hasPassword = ref<boolean>(false)
|
||||
const isSaving = ref<boolean>(false)
|
||||
const isTesting = ref<boolean>(false)
|
||||
const testResult = ref<boolean | null>(null)
|
||||
const testError = ref<string | null>(null)
|
||||
|
||||
async function loadSettings(): Promise<void> {
|
||||
const config = await getConfiguration()
|
||||
form.protocol = config.protocol ?? 'imap'
|
||||
form.imapHost = config.imapHost ?? ''
|
||||
form.imapPort = config.imapPort ?? 993
|
||||
form.imapEncryption = config.imapEncryption ?? 'ssl'
|
||||
form.smtpHost = config.smtpHost ?? ''
|
||||
form.smtpPort = config.smtpPort ?? 465
|
||||
form.smtpEncryption = config.smtpEncryption ?? 'ssl'
|
||||
form.username = config.username ?? ''
|
||||
form.sentFolderPath = config.sentFolderPath ?? ''
|
||||
form.enabled = config.enabled
|
||||
hasPassword.value = config.hasPassword
|
||||
// password jamais pré-rempli
|
||||
}
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
isSaving.value = true
|
||||
testResult.value = null
|
||||
testError.value = null
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
protocol: form.protocol,
|
||||
imapHost: form.imapHost.trim() || null,
|
||||
imapPort: form.imapPort,
|
||||
imapEncryption: form.imapEncryption,
|
||||
smtpHost: form.smtpHost.trim() || null,
|
||||
smtpPort: form.smtpPort,
|
||||
smtpEncryption: form.smtpEncryption,
|
||||
username: form.username.trim() || null,
|
||||
sentFolderPath: form.sentFolderPath.trim() || null,
|
||||
enabled: form.enabled,
|
||||
}
|
||||
if (form.password) {
|
||||
payload.password = form.password
|
||||
}
|
||||
const result = await updateConfiguration(payload)
|
||||
hasPassword.value = result.hasPassword
|
||||
form.password = ''
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTest(): Promise<void> {
|
||||
isTesting.value = true
|
||||
testResult.value = null
|
||||
testError.value = null
|
||||
try {
|
||||
const result = await testConfiguration()
|
||||
testResult.value = result.ok
|
||||
if (!result.ok && result.error) {
|
||||
testError.value = result.error
|
||||
}
|
||||
} catch {
|
||||
testResult.value = false
|
||||
} finally {
|
||||
isTesting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSettings()
|
||||
})
|
||||
</script>
|
||||
251
frontend/components/mail/MailCreateTaskModal.vue
Normal file
251
frontend/components/mail/MailCreateTaskModal.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<script setup lang="ts">
|
||||
import type { MailMessageDetailDto } from '~/services/dto/mail'
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import type { TaskGroup } from '~/services/dto/task-group'
|
||||
import type { TaskPriority } from '~/services/dto/task-priority'
|
||||
import { useMailService } from '~/services/mail'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
import { useTaskGroupService } from '~/services/task-groups'
|
||||
import { useTaskPriorityService } from '~/services/task-priorities'
|
||||
|
||||
const props = defineProps<{
|
||||
/** v-model: true = modal ouvert */
|
||||
modelValue: boolean
|
||||
/** ID BDD du message source */
|
||||
messageId: number
|
||||
/** Détail du message (pour afficher sujet/expéditeur en lecture seule) */
|
||||
messageDetail: MailMessageDetailDto | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
/** Émis après création réussie — payload = tâche créée */
|
||||
created: [task: Task]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const mailService = useMailService()
|
||||
const projectService = useProjectService()
|
||||
const taskGroupService = useTaskGroupService()
|
||||
const priorityService = useTaskPriorityService()
|
||||
|
||||
// ─── État formulaire ──────────────────────────────────────────────────────
|
||||
|
||||
const projectId = ref<number | null>(null)
|
||||
const taskGroupId = ref<number | null>(null)
|
||||
const priorityId = ref<number | null>(null)
|
||||
const isSubmitting = ref(false)
|
||||
const touchedProject = ref(false)
|
||||
|
||||
// ─── Données de référence ─────────────────────────────────────────────────
|
||||
|
||||
const projects = ref<Project[]>([])
|
||||
const groups = ref<TaskGroup[]>([])
|
||||
const priorities = ref<TaskPriority[]>([])
|
||||
const loadingGroups = ref(false)
|
||||
|
||||
const projectOptions = computed(() =>
|
||||
projects.value.map(p => ({ label: p.name, value: p.id })),
|
||||
)
|
||||
|
||||
const groupOptions = computed(() =>
|
||||
groups.value.filter(g => !g.archived).map(g => ({ label: g.title, value: g.id })),
|
||||
)
|
||||
|
||||
const priorityOptions = computed(() =>
|
||||
priorities.value.map(p => ({ label: p.label, value: p.id })),
|
||||
)
|
||||
|
||||
// ─── Chargement initial ───────────────────────────────────────────────────
|
||||
|
||||
onMounted(async () => {
|
||||
const [projs, prios] = await Promise.all([
|
||||
projectService.getAll({ archived: false }),
|
||||
priorityService.getAll(),
|
||||
])
|
||||
projects.value = projs
|
||||
priorities.value = prios
|
||||
})
|
||||
|
||||
// Recharger les groupes quand le projet change
|
||||
watch(projectId, async (pid) => {
|
||||
taskGroupId.value = null
|
||||
groups.value = []
|
||||
if (!pid) return
|
||||
loadingGroups.value = true
|
||||
try {
|
||||
groups.value = await taskGroupService.getByProject(pid)
|
||||
} finally {
|
||||
loadingGroups.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// Reset formulaire à l'ouverture
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
projectId.value = null
|
||||
taskGroupId.value = null
|
||||
priorityId.value = null
|
||||
touchedProject.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Actions ──────────────────────────────────────────────────────────────
|
||||
|
||||
function close(): void {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
touchedProject.value = true
|
||||
if (!projectId.value) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const task = await mailService.createTaskFromMail(props.messageId, {
|
||||
projectId: projectId.value,
|
||||
taskGroupId: taskGroupId.value ?? undefined,
|
||||
priority: priorityId.value ? `/api/task_priorities/${priorityId.value}` : undefined,
|
||||
})
|
||||
emit('created', task)
|
||||
close()
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport v-if="modelValue" to="body">
|
||||
<Transition name="mail-modal" appear>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||
@click="close"
|
||||
/>
|
||||
|
||||
<!-- Modal -->
|
||||
<div
|
||||
class="relative z-10 w-full max-w-lg rounded-2xl bg-white shadow-2xl ring-1 ring-black/5 overflow-hidden"
|
||||
style="max-height: min(90vh, 640px)"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-neutral-100 bg-neutral-50/80 px-6 py-4">
|
||||
<h2 class="text-base font-bold text-neutral-900">
|
||||
{{ t('mail.createTaskModal.title') }}
|
||||
</h2>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:close"
|
||||
aria-label="Fermer"
|
||||
variant="ghost"
|
||||
icon-size="20"
|
||||
@click="close"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Corps -->
|
||||
<div class="overflow-y-auto px-6 py-5 space-y-5">
|
||||
<!-- Info mail source (lecture seule) -->
|
||||
<div
|
||||
v-if="messageDetail"
|
||||
class="rounded-lg border border-neutral-200 bg-neutral-50 px-4 py-3 text-sm"
|
||||
>
|
||||
<p class="font-medium text-neutral-800 truncate">
|
||||
{{ messageDetail.header.subject ?? t('mail.noSubject') }}
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-neutral-500 truncate">
|
||||
{{ messageDetail.header.fromName ?? messageDetail.header.fromEmail }}
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-neutral-400 italic">
|
||||
{{ t('mail.createTaskModal.titleHint') }}
|
||||
</p>
|
||||
<p class="text-xs text-neutral-400 italic">
|
||||
{{ t('mail.createTaskModal.descriptionHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Sélection projet -->
|
||||
<div>
|
||||
<MalioSelect
|
||||
v-model="projectId"
|
||||
:options="projectOptions"
|
||||
:label="t('mail.createTaskModal.projectLabel')"
|
||||
:empty-option-label="t('mail.createTaskModal.projectPlaceholder')"
|
||||
min-width="w-full"
|
||||
/>
|
||||
<p
|
||||
v-if="touchedProject && !projectId"
|
||||
class="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{{ t('mail.createTaskModal.projectLabel').replace(' *', '') }} requis
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Sélection groupe (optionnel, chargé après projet) -->
|
||||
<div v-if="projectId">
|
||||
<MalioSelect
|
||||
v-model="taskGroupId"
|
||||
:options="groupOptions"
|
||||
:label="t('mail.createTaskModal.groupLabel')"
|
||||
:empty-option-label="t('mail.createTaskModal.groupPlaceholder')"
|
||||
min-width="w-full"
|
||||
:disabled="loadingGroups"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Sélection priorité (optionnelle) — MalioSelect car les values sont number | null -->
|
||||
<div>
|
||||
<MalioSelect
|
||||
v-model="priorityId"
|
||||
:options="priorityOptions"
|
||||
:label="t('mail.createTaskModal.priorityLabel')"
|
||||
:empty-option-label="t('mail.createTaskModal.priorityPlaceholder')"
|
||||
min-width="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex justify-end gap-3 border-t border-neutral-100 px-6 py-4">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
label="Annuler"
|
||||
button-class="w-auto px-4"
|
||||
@click="close"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('mail.createTaskModal.submit')"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mail-modal-enter-active,
|
||||
.mail-modal-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.mail-modal-enter-active > div:last-child,
|
||||
.mail-modal-leave-active > div:last-child {
|
||||
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.mail-modal-enter-from,
|
||||
.mail-modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.mail-modal-enter-from > div:last-child {
|
||||
transform: scale(0.95) translateY(8px);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
123
frontend/components/mail/MailFolderTree.vue
Normal file
123
frontend/components/mail/MailFolderTree.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import type { MailFolderDto } from '~/services/dto/mail'
|
||||
|
||||
const props = defineProps<{
|
||||
/** Arbre de dossiers (getter folderTree du store) */
|
||||
folders: readonly MailFolderDto[]
|
||||
/** Chemin du dossier actuellement sélectionné */
|
||||
selectedPath: string | null
|
||||
/** Niveau de profondeur pour l'indentation (usage récursif interne) */
|
||||
depth?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [path: string]
|
||||
}>()
|
||||
|
||||
const { getFolderLabel, getFolderIcon } = useSystemFolderLabel()
|
||||
const { t } = useI18n()
|
||||
|
||||
const currentDepth = computed(() => props.depth ?? 0)
|
||||
|
||||
// Dossiers dépliés (repliés par défaut → seuls les dossiers racine sont visibles).
|
||||
const expanded = ref<Set<string>>(new Set())
|
||||
|
||||
function isExpanded(path: string): boolean {
|
||||
return expanded.value.has(path)
|
||||
}
|
||||
|
||||
function toggleExpanded(path: string): void {
|
||||
const next = new Set(expanded.value)
|
||||
if (next.has(path)) {
|
||||
next.delete(path)
|
||||
} else {
|
||||
next.add(path)
|
||||
}
|
||||
expanded.value = next
|
||||
}
|
||||
|
||||
function hasChildren(folder: MailFolderDto): boolean {
|
||||
return !!folder.children && folder.children.length > 0
|
||||
}
|
||||
|
||||
function handleSelect(path: string): void {
|
||||
emit('select', path)
|
||||
}
|
||||
|
||||
function paddingStyle(): Record<string, string> {
|
||||
const depth = currentDepth.value
|
||||
return { paddingLeft: `${0.5 + depth * 0.75}rem` }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-if="folders.length === 0 && currentDepth === 0"
|
||||
class="px-3 py-4 text-sm text-neutral-400 italic"
|
||||
>
|
||||
{{ t('mail.empty.folder') }}
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-for="folder in folders" :key="folder.path">
|
||||
<div
|
||||
class="flex items-center gap-1 rounded-md pr-2 py-1.5 text-sm transition-colors"
|
||||
:class="
|
||||
selectedPath === folder.path
|
||||
? 'bg-primary-100 text-primary-700 font-medium'
|
||||
: 'text-neutral-700 hover:bg-neutral-100'
|
||||
"
|
||||
:style="paddingStyle()"
|
||||
>
|
||||
<button
|
||||
v-if="hasChildren(folder)"
|
||||
type="button"
|
||||
class="flex-shrink-0 rounded p-0.5 hover:bg-neutral-200"
|
||||
:aria-label="isExpanded(folder.path) ? t('mail.folderTree.collapse') : t('mail.folderTree.expand')"
|
||||
@click.stop="toggleExpanded(folder.path)"
|
||||
>
|
||||
<Icon
|
||||
:name="isExpanded(folder.path) ? 'material-symbols:keyboard-arrow-down' : 'material-symbols:chevron-right'"
|
||||
size="16"
|
||||
class="text-neutral-400"
|
||||
/>
|
||||
</button>
|
||||
<span v-else class="inline-block w-[22px] flex-shrink-0" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-1 items-center gap-2 text-left min-w-0"
|
||||
@click="handleSelect(folder.path)"
|
||||
>
|
||||
<Icon
|
||||
:name="getFolderIcon(folder.path)"
|
||||
size="16"
|
||||
class="flex-shrink-0"
|
||||
:class="selectedPath === folder.path ? 'text-primary-600' : 'text-neutral-400'"
|
||||
/>
|
||||
|
||||
<span class="flex-1 truncate">
|
||||
{{ getFolderLabel(folder.path, folder.displayName) }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="folder.unreadCount > 0"
|
||||
class="ml-auto flex-shrink-0 rounded-full bg-primary-500 px-1.5 py-0.5 text-xs font-bold text-white"
|
||||
>
|
||||
{{ folder.unreadCount > 99 ? '99+' : folder.unreadCount }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<MailFolderTree
|
||||
v-if="hasChildren(folder) && isExpanded(folder.path)"
|
||||
:folders="folder.children"
|
||||
:selected-path="selectedPath"
|
||||
:depth="currentDepth + 1"
|
||||
@select="handleSelect"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
266
frontend/components/mail/MailLinkTaskModal.vue
Normal file
266
frontend/components/mail/MailLinkTaskModal.vue
Normal file
@@ -0,0 +1,266 @@
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import { useMailService } from '~/services/mail'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
/** ID BDD du message à lier */
|
||||
messageId: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
/** Émis après liaison réussie — payload = id de la tâche liée */
|
||||
linked: [taskId: number]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const mailService = useMailService()
|
||||
const taskService = useTaskService()
|
||||
const projectService = useProjectService()
|
||||
|
||||
// ─── État recherche ───────────────────────────────────────────────────────
|
||||
|
||||
const searchQuery = ref('')
|
||||
const filterProjectId = ref<number | null>(null)
|
||||
const results = ref<Task[]>([])
|
||||
const selectedTask = ref<Task | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
// ─── Projets pour le filtre ───────────────────────────────────────────────
|
||||
|
||||
const projects = ref<Project[]>([])
|
||||
|
||||
const projectFilterOptions = computed(() =>
|
||||
projects.value.map(p => ({ label: p.name, value: p.id })),
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
projects.value = await projectService.getAll({ archived: false })
|
||||
})
|
||||
|
||||
// ─── Debounce recherche ───────────────────────────────────────────────────
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
watch([searchQuery, filterProjectId], () => {
|
||||
selectedTask.value = null
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
void runSearch()
|
||||
}, 300)
|
||||
})
|
||||
|
||||
async function runSearch(): Promise<void> {
|
||||
const q = searchQuery.value.trim()
|
||||
if (!q && !filterProjectId.value) {
|
||||
results.value = []
|
||||
return
|
||||
}
|
||||
isLoading.value = true
|
||||
try {
|
||||
const params: Record<string, string | number | boolean | string[]> = {
|
||||
archived: false,
|
||||
}
|
||||
if (q) params['title'] = q
|
||||
if (filterProjectId.value) params['project'] = `/api/projects/${filterProjectId.value}`
|
||||
results.value = await taskService.getFiltered(params)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Reset à l'ouverture ──────────────────────────────────────────────────
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
searchQuery.value = ''
|
||||
filterProjectId.value = null
|
||||
results.value = []
|
||||
selectedTask.value = null
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
})
|
||||
|
||||
// ─── Actions ──────────────────────────────────────────────────────────────
|
||||
|
||||
function close(): void {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
function selectTask(task: Task): void {
|
||||
selectedTask.value = task
|
||||
}
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
if (!selectedTask.value) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await mailService.linkTask(props.messageId, selectedTask.value.id)
|
||||
emit('linked', selectedTask.value.id)
|
||||
close()
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport v-if="modelValue" to="body">
|
||||
<Transition name="mail-modal" appear>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div
|
||||
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||
@click="close"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="relative z-10 w-full max-w-lg rounded-2xl bg-white shadow-2xl ring-1 ring-black/5 overflow-hidden"
|
||||
style="max-height: min(90vh, 640px)"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-neutral-100 bg-neutral-50/80 px-6 py-4">
|
||||
<h2 class="text-base font-bold text-neutral-900">
|
||||
{{ t('mail.linkTaskModal.title') }}
|
||||
</h2>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:close"
|
||||
aria-label="Fermer"
|
||||
variant="ghost"
|
||||
icon-size="20"
|
||||
@click="close"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Corps -->
|
||||
<div class="overflow-y-auto px-6 py-5 space-y-4">
|
||||
<!-- Filtre projet -->
|
||||
<MalioSelect
|
||||
v-model="filterProjectId"
|
||||
:options="projectFilterOptions"
|
||||
:label="t('mail.linkTaskModal.projectFilter')"
|
||||
:empty-option-label="t('mail.linkTaskModal.projectAll')"
|
||||
min-width="w-full"
|
||||
/>
|
||||
|
||||
<!-- Recherche tâche -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">
|
||||
{{ t('mail.linkTaskModal.title') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('mail.linkTaskModal.searchPlaceholder')"
|
||||
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Résultats -->
|
||||
<div class="max-h-64 overflow-y-auto rounded-md border border-neutral-200">
|
||||
<!-- Chargement -->
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex items-center justify-center py-6 text-sm text-neutral-400"
|
||||
>
|
||||
<Icon name="material-symbols:progress-activity" size="18" class="mr-2 animate-spin" />
|
||||
{{ t('mail.linkTaskModal.loading') }}
|
||||
</div>
|
||||
|
||||
<!-- Vide -->
|
||||
<div
|
||||
v-else-if="!isLoading && results.length === 0 && (searchQuery.trim() || filterProjectId)"
|
||||
class="py-6 text-center text-sm text-neutral-400 italic"
|
||||
>
|
||||
{{ t('mail.linkTaskModal.empty') }}
|
||||
</div>
|
||||
|
||||
<!-- Liste résultats -->
|
||||
<button
|
||||
v-for="task in results"
|
||||
:key="task.id"
|
||||
type="button"
|
||||
class="flex w-full items-start gap-3 px-4 py-3 text-left text-sm transition-colors hover:bg-neutral-50"
|
||||
:class="selectedTask?.id === task.id
|
||||
? 'bg-primary-50 border-l-2 border-primary-500'
|
||||
: 'border-l-2 border-transparent'"
|
||||
@click="selectTask(task)"
|
||||
>
|
||||
<Icon
|
||||
name="material-symbols:task-outline"
|
||||
size="16"
|
||||
class="mt-0.5 flex-shrink-0 text-neutral-400"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate font-medium text-neutral-800">
|
||||
{{ task.title }}
|
||||
</p>
|
||||
<p
|
||||
v-if="task.project"
|
||||
class="truncate text-xs text-neutral-500"
|
||||
>
|
||||
{{ task.project.name }}
|
||||
<span v-if="task.project.code && task.number">
|
||||
— {{ task.project.code }}-{{ task.number }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<Icon
|
||||
v-if="selectedTask?.id === task.id"
|
||||
name="material-symbols:check-circle"
|
||||
size="16"
|
||||
class="flex-shrink-0 text-primary-500"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex justify-end gap-3 border-t border-neutral-100 px-6 py-4">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
label="Annuler"
|
||||
button-class="w-auto px-4"
|
||||
@click="close"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('mail.linkTaskModal.submit')"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="!selectedTask || isSubmitting"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mail-modal-enter-active,
|
||||
.mail-modal-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.mail-modal-enter-active > div:last-child,
|
||||
.mail-modal-leave-active > div:last-child {
|
||||
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.mail-modal-enter-from,
|
||||
.mail-modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.mail-modal-enter-from > div:last-child {
|
||||
transform: scale(0.95) translateY(8px);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
151
frontend/components/mail/MailMessageList.vue
Normal file
151
frontend/components/mail/MailMessageList.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
import type { MailMessageHeaderDto } from '~/services/dto/mail'
|
||||
|
||||
const props = defineProps<{
|
||||
messages: readonly MailMessageHeaderDto[]
|
||||
selectedId: number | null
|
||||
loading: boolean
|
||||
hasMore: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [id: number]
|
||||
loadMore: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const sentinelRef = ref<HTMLDivElement | null>(null)
|
||||
let observer: IntersectionObserver | null = null
|
||||
|
||||
onMounted(() => {
|
||||
if (!sentinelRef.value) return
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const entry = entries[0]
|
||||
if (entry?.isIntersecting && props.hasMore && !props.loading) {
|
||||
emit('loadMore')
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
)
|
||||
observer.observe(sentinelRef.value)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
observer?.disconnect()
|
||||
observer = null
|
||||
})
|
||||
|
||||
/**
|
||||
* Formate une date ISO en date relative (il y a X minutes/heures/jours).
|
||||
* Utilise Intl.RelativeTimeFormat avec la locale fr.
|
||||
*/
|
||||
function formatRelative(isoDate: string | null): string {
|
||||
if (!isoDate) return ''
|
||||
const date = new Date(isoDate)
|
||||
const now = new Date()
|
||||
const diffMs = date.getTime() - now.getTime()
|
||||
const diffSeconds = Math.round(diffMs / 1000)
|
||||
const diffMinutes = Math.round(diffSeconds / 60)
|
||||
const diffHours = Math.round(diffMinutes / 60)
|
||||
const diffDays = Math.round(diffHours / 24)
|
||||
|
||||
const rtf = new Intl.RelativeTimeFormat('fr', { numeric: 'auto' })
|
||||
|
||||
if (Math.abs(diffMinutes) < 1) return rtf.format(diffSeconds, 'second')
|
||||
if (Math.abs(diffHours) < 1) return rtf.format(diffMinutes, 'minute')
|
||||
if (Math.abs(diffDays) < 1) return rtf.format(diffHours, 'hour')
|
||||
if (Math.abs(diffDays) < 30) return rtf.format(diffDays, 'day')
|
||||
|
||||
return date.toLocaleDateString('fr', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
function getSenderLabel(msg: MailMessageHeaderDto): string {
|
||||
return msg.fromName ?? msg.fromEmail ?? ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col overflow-hidden">
|
||||
<div
|
||||
v-if="!loading && messages.length === 0"
|
||||
class="flex flex-1 items-center justify-center text-sm text-neutral-400 italic px-4 text-center"
|
||||
>
|
||||
{{ t('mail.empty.list') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="flex-1 overflow-y-auto divide-y divide-neutral-100">
|
||||
<button
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
type="button"
|
||||
class="flex w-full gap-3 px-3 py-3 text-left transition-colors hover:bg-neutral-50 focus:outline-none"
|
||||
:class="[
|
||||
selectedId === msg.id ? 'bg-primary-50 border-l-2 border-primary-500' : '',
|
||||
!msg.isRead ? 'bg-white' : 'bg-neutral-50/50',
|
||||
]"
|
||||
@click="emit('select', msg.id)"
|
||||
>
|
||||
<div class="mt-1.5 flex-shrink-0">
|
||||
<span
|
||||
class="block h-2 w-2 rounded-full"
|
||||
:class="msg.isRead ? 'bg-transparent' : 'bg-primary-500'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span
|
||||
class="truncate text-sm"
|
||||
:class="msg.isRead ? 'text-neutral-600 font-normal' : 'text-neutral-900 font-semibold'"
|
||||
>
|
||||
{{ getSenderLabel(msg) }}
|
||||
</span>
|
||||
<span class="flex-shrink-0 text-xs text-neutral-400">
|
||||
{{ formatRelative(msg.sentAt ?? msg.receivedAt) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="truncate text-sm"
|
||||
:class="msg.isRead ? 'text-neutral-500' : 'text-neutral-800 font-medium'"
|
||||
>
|
||||
{{ msg.subject ?? t('mail.noSubject') }}
|
||||
</p>
|
||||
|
||||
<div class="mt-0.5 flex items-center gap-1.5">
|
||||
<Icon
|
||||
v-if="msg.isFlagged"
|
||||
name="material-symbols:star"
|
||||
size="14"
|
||||
class="text-amber-400 flex-shrink-0"
|
||||
/>
|
||||
<Icon
|
||||
v-if="msg.hasAttachments"
|
||||
name="material-symbols:attach-file"
|
||||
size="14"
|
||||
class="text-neutral-400 flex-shrink-0"
|
||||
/>
|
||||
<Icon
|
||||
v-if="msg.linkedTaskIds.length > 0"
|
||||
name="material-symbols:task-outline"
|
||||
size="14"
|
||||
class="text-primary-400 flex-shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div ref="sentinelRef" class="h-px" />
|
||||
|
||||
<div v-if="loading && messages.length > 0" class="flex items-center justify-center py-4">
|
||||
<Icon name="material-symbols:progress-activity" size="20" class="animate-spin text-neutral-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading && messages.length === 0" class="flex flex-1 items-center justify-center">
|
||||
<Icon name="material-symbols:progress-activity" size="24" class="animate-spin text-neutral-400" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
183
frontend/components/mail/MailMessageViewer.vue
Normal file
183
frontend/components/mail/MailMessageViewer.vue
Normal file
@@ -0,0 +1,183 @@
|
||||
<script setup lang="ts">
|
||||
import type { MailMessageDetailDto, MailAddressDto } from '~/services/dto/mail'
|
||||
import { sanitizeMailHtml } from '~/utils/sanitizeMailHtml'
|
||||
import { useMailService } from '~/services/mail'
|
||||
|
||||
const props = defineProps<{
|
||||
/** Détail complet du message. null = aucun message sélectionné. */
|
||||
detail: MailMessageDetailDto | null
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
createTask: [mailId: number]
|
||||
linkTask: [mailId: number]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const mailService = useMailService()
|
||||
|
||||
const showImages = ref(false)
|
||||
|
||||
const sanitizedBody = computed((): string => {
|
||||
if (!props.detail?.bodyHtml) return ''
|
||||
return sanitizeMailHtml(props.detail.bodyHtml, { allowImages: showImages.value })
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.detail?.header.id,
|
||||
() => {
|
||||
showImages.value = false
|
||||
},
|
||||
)
|
||||
|
||||
async function handleDownload(downloadId: string, filename: string): Promise<void> {
|
||||
try {
|
||||
const { data } = await mailService.downloadAttachment(downloadId)
|
||||
const url = URL.createObjectURL(data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
// L'erreur est gérée par useApi (toast automatique)
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return ''
|
||||
return new Date(iso).toLocaleString('fr', {
|
||||
dateStyle: 'long',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
}
|
||||
|
||||
function joinAddresses(addresses: MailAddressDto[]): string {
|
||||
return addresses
|
||||
.map((a) => (a.name ? `${a.name} <${a.email}>` : a.email))
|
||||
.join(', ')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col overflow-hidden">
|
||||
<div
|
||||
v-if="!detail && !loading"
|
||||
class="flex flex-1 items-center justify-center text-sm text-neutral-400 italic px-8 text-center"
|
||||
>
|
||||
{{ t('mail.empty.viewer') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="loading" class="flex flex-1 items-center justify-center">
|
||||
<Icon name="material-symbols:progress-activity" size="28" class="animate-spin text-neutral-400" />
|
||||
</div>
|
||||
|
||||
<template v-else-if="detail">
|
||||
<div class="flex-shrink-0 border-b border-neutral-200 px-4 py-3 space-y-1.5">
|
||||
<h2 class="text-base font-semibold text-neutral-900 break-words">
|
||||
{{ detail.header.subject ?? t('mail.noSubject') }}
|
||||
</h2>
|
||||
|
||||
<dl class="text-xs text-neutral-500 space-y-0.5">
|
||||
<div class="flex gap-1.5">
|
||||
<dt class="font-medium text-neutral-600 w-5 flex-shrink-0">{{ t('mail.from') }}</dt>
|
||||
<dd class="break-all">
|
||||
{{
|
||||
detail.header.fromName
|
||||
? `${detail.header.fromName} <${detail.header.fromEmail}>`
|
||||
: (detail.header.fromEmail ?? '')
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
<div v-if="detail.header.toRecipients.length > 0" class="flex gap-1.5">
|
||||
<dt class="font-medium text-neutral-600 w-5 flex-shrink-0">{{ t('mail.to') }}</dt>
|
||||
<dd class="break-all">{{ joinAddresses(detail.header.toRecipients) }}</dd>
|
||||
</div>
|
||||
<div v-if="detail.header.ccRecipients.length > 0" class="flex gap-1.5">
|
||||
<dt class="font-medium text-neutral-600 w-5 flex-shrink-0">{{ t('mail.cc') }}</dt>
|
||||
<dd class="break-all">{{ joinAddresses(detail.header.ccRecipients) }}</dd>
|
||||
</div>
|
||||
<div class="flex gap-1.5">
|
||||
<dt class="font-medium text-neutral-600 w-5 flex-shrink-0">{{ t('mail.date') }}</dt>
|
||||
<dd>{{ formatDate(detail.header.sentAt ?? detail.header.receivedAt) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 pt-1">
|
||||
<MalioButton
|
||||
:label="t('mail.actions.createTask')"
|
||||
variant="primary"
|
||||
icon-name="material-symbols:add-task-outline"
|
||||
icon-position="left"
|
||||
:icon-size="13"
|
||||
button-class="text-xs px-2.5 py-1"
|
||||
@click="emit('createTask', detail.header.id)"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('mail.actions.linkTask')"
|
||||
variant="secondary"
|
||||
icon-name="material-symbols:link"
|
||||
icon-position="left"
|
||||
:icon-size="13"
|
||||
button-class="text-xs px-2.5 py-1"
|
||||
@click="emit('linkTask', detail.header.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-4 py-3">
|
||||
<div
|
||||
v-if="!showImages && detail.bodyHtml"
|
||||
class="mb-3 flex items-center gap-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm"
|
||||
>
|
||||
<Icon name="material-symbols:image-outline" size="16" class="text-amber-500 flex-shrink-0" />
|
||||
<span class="flex-1 text-amber-700">
|
||||
{{ t('mail.remoteImagesBlocked') }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs font-medium text-amber-700 underline hover:text-amber-900 transition-colors"
|
||||
@click="showImages = true"
|
||||
>
|
||||
{{ t('mail.actions.showImages') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="detail.bodyHtml"
|
||||
class="prose prose-sm max-w-none text-neutral-800"
|
||||
v-html="sanitizedBody"
|
||||
/>
|
||||
|
||||
<pre
|
||||
v-else-if="detail.bodyText"
|
||||
class="whitespace-pre-wrap font-sans text-sm text-neutral-700"
|
||||
>{{ detail.bodyText }}</pre>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="detail.attachments.length > 0"
|
||||
class="flex-shrink-0 border-t border-neutral-200 px-4 py-3"
|
||||
>
|
||||
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||
{{ t('mail.attachments') }} ({{ detail.attachments.length }})
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="att in detail.attachments"
|
||||
:key="att.downloadId"
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 rounded border border-neutral-200 bg-neutral-50 px-2.5 py-1.5 text-xs text-neutral-700 transition-colors hover:bg-neutral-100 hover:border-neutral-300"
|
||||
:title="att.filename"
|
||||
@click="handleDownload(att.downloadId, att.filename)"
|
||||
>
|
||||
<Icon name="material-symbols:attach-file" size="14" class="flex-shrink-0 text-neutral-400" />
|
||||
<span class="max-w-[180px] truncate">{{ att.filename }}</span>
|
||||
<span class="text-neutral-400">({{ Math.round(att.size / 1024) }} Ko)</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
228
frontend/components/mail/MailPickerModal.vue
Normal file
228
frontend/components/mail/MailPickerModal.vue
Normal file
@@ -0,0 +1,228 @@
|
||||
<script setup lang="ts">
|
||||
import type { MailMessageHeaderDto } from '~/services/dto/mail'
|
||||
import { useMailService } from '~/services/mail'
|
||||
import { useMailStore } from '~/stores/mail'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
/** ID de la tâche cible (destinataire du lien) */
|
||||
taskId: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
/** Émis après liaison réussie — payload = id du message lié */
|
||||
linked: [messageId: number]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const mailService = useMailService()
|
||||
const mailStore = useMailStore()
|
||||
|
||||
// ─── État ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const searchQuery = ref('')
|
||||
const allMessages = ref<MailMessageHeaderDto[]>([])
|
||||
const selectedMessage = ref<MailMessageHeaderDto | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
// ─── Filtrage local (pas d'appel API par frappe — les messages sont déjà chargés) ──
|
||||
|
||||
const filteredMessages = computed(() => {
|
||||
const q = searchQuery.value.toLowerCase().trim()
|
||||
if (!q) return allMessages.value
|
||||
return allMessages.value.filter(
|
||||
(m) =>
|
||||
(m.subject ?? '').toLowerCase().includes(q)
|
||||
|| (m.fromName ?? '').toLowerCase().includes(q)
|
||||
|| (m.fromEmail ?? '').toLowerCase().includes(q),
|
||||
)
|
||||
})
|
||||
|
||||
// ─── Chargement à l'ouverture ─────────────────────────────────────────────
|
||||
|
||||
watch(() => props.modelValue, async (open) => {
|
||||
if (!open) return
|
||||
searchQuery.value = ''
|
||||
selectedMessage.value = null
|
||||
isLoading.value = true
|
||||
try {
|
||||
// Utiliser le dossier actuellement sélectionné dans le store si disponible,
|
||||
// sinon fallback sur INBOX.
|
||||
const folderPath = mailStore.selectedFolderPath ?? 'INBOX'
|
||||
const page = await mailService.listMessages(folderPath, undefined, 50)
|
||||
allMessages.value = page.items
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Actions ──────────────────────────────────────────────────────────────
|
||||
|
||||
function close(): void {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
function selectMessage(msg: MailMessageHeaderDto): void {
|
||||
selectedMessage.value = msg
|
||||
}
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
if (!selectedMessage.value) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await mailService.linkTask(selectedMessage.value.id, props.taskId)
|
||||
emit('linked', selectedMessage.value.id)
|
||||
close()
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Formatage ────────────────────────────────────────────────────────────
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return ''
|
||||
return new Date(iso).toLocaleDateString('fr', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport v-if="modelValue" to="body">
|
||||
<Transition name="mail-modal" appear>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div
|
||||
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||
@click="close"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="relative z-10 w-full max-w-lg rounded-2xl bg-white shadow-2xl ring-1 ring-black/5 overflow-hidden"
|
||||
style="max-height: min(90vh, 640px)"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-neutral-100 bg-neutral-50/80 px-6 py-4">
|
||||
<h2 class="text-base font-bold text-neutral-900">
|
||||
{{ t('mail.pickerModal.title') }}
|
||||
</h2>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:close"
|
||||
aria-label="Fermer"
|
||||
variant="ghost"
|
||||
icon-size="20"
|
||||
@click="close"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Corps -->
|
||||
<div class="overflow-y-auto px-6 py-5 space-y-4">
|
||||
<!-- Recherche locale -->
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('mail.pickerModal.searchPlaceholder')"
|
||||
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
|
||||
<!-- Résultats -->
|
||||
<div class="max-h-80 overflow-y-auto rounded-md border border-neutral-200 divide-y divide-neutral-100">
|
||||
<!-- Chargement -->
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex items-center justify-center py-8 text-sm text-neutral-400"
|
||||
>
|
||||
<Icon name="material-symbols:progress-activity" size="18" class="mr-2 animate-spin" />
|
||||
{{ t('mail.pickerModal.loading') }}
|
||||
</div>
|
||||
|
||||
<!-- Vide -->
|
||||
<div
|
||||
v-else-if="filteredMessages.length === 0"
|
||||
class="py-8 text-center text-sm text-neutral-400 italic"
|
||||
>
|
||||
{{ t('mail.pickerModal.empty') }}
|
||||
</div>
|
||||
|
||||
<!-- Liste -->
|
||||
<button
|
||||
v-for="msg in filteredMessages"
|
||||
:key="msg.id"
|
||||
type="button"
|
||||
class="flex w-full items-start gap-3 px-4 py-3 text-left text-sm transition-colors hover:bg-neutral-50"
|
||||
:class="selectedMessage?.id === msg.id
|
||||
? 'bg-primary-50 border-l-2 border-primary-500'
|
||||
: 'border-l-2 border-transparent'"
|
||||
@click="selectMessage(msg)"
|
||||
>
|
||||
<Icon
|
||||
name="material-symbols:mail-outline"
|
||||
size="16"
|
||||
class="mt-0.5 flex-shrink-0 text-neutral-400"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate font-medium text-neutral-800">
|
||||
{{ msg.subject ?? t('mail.noSubject') }}
|
||||
</p>
|
||||
<p class="flex items-center gap-2 text-xs text-neutral-500">
|
||||
<span class="truncate">{{ msg.fromName ?? msg.fromEmail }}</span>
|
||||
<span class="flex-shrink-0">·</span>
|
||||
<span class="flex-shrink-0">{{ formatDate(msg.sentAt ?? msg.receivedAt) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<Icon
|
||||
v-if="selectedMessage?.id === msg.id"
|
||||
name="material-symbols:check-circle"
|
||||
size="16"
|
||||
class="flex-shrink-0 text-primary-500"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex justify-end gap-3 border-t border-neutral-100 px-6 py-4">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
label="Annuler"
|
||||
button-class="w-auto px-4"
|
||||
@click="close"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('mail.pickerModal.submit')"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="!selectedMessage || isSubmitting"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mail-modal-enter-active,
|
||||
.mail-modal-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.mail-modal-enter-active > div:last-child,
|
||||
.mail-modal-leave-active > div:last-child {
|
||||
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.mail-modal-enter-from,
|
||||
.mail-modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.mail-modal-enter-from > div:last-child {
|
||||
transform: scale(0.95) translateY(8px);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
24
frontend/components/mail/MailRefreshButton.vue
Normal file
24
frontend/components/mail/MailRefreshButton.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { useMailStore } from '~/stores/mail'
|
||||
|
||||
const store = useMailStore()
|
||||
const { syncing } = storeToRefs(store)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
async function handleRefresh(): Promise<void> {
|
||||
await store.triggerSync()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MalioButton
|
||||
:label="t('mail.actions.refresh')"
|
||||
variant="secondary"
|
||||
icon-name="material-symbols:refresh"
|
||||
icon-position="left"
|
||||
:icon-size="16"
|
||||
:disabled="syncing"
|
||||
@click="handleRefresh"
|
||||
/>
|
||||
</template>
|
||||
@@ -60,16 +60,16 @@
|
||||
<div class="border-b border-neutral-100 -mx-4 px-4 sm:-mx-8 sm:px-8 mb-4">
|
||||
<nav class="flex gap-6">
|
||||
<button
|
||||
v-for="tab in ['details', 'planning']"
|
||||
v-for="tab in availableTabs"
|
||||
:key="tab"
|
||||
type="button"
|
||||
class="px-1 pb-3 text-sm font-semibold transition"
|
||||
:class="activeTab === tab
|
||||
? 'border-b-2 border-primary-500 text-primary-500'
|
||||
: 'text-neutral-500 hover:text-neutral-700'"
|
||||
@click="activeTab = tab as 'details' | 'planning'"
|
||||
@click="activeTab = tab as 'details' | 'planning' | 'mails'"
|
||||
>
|
||||
{{ $t(`tasks.${tab}Tab`) }}
|
||||
{{ tab === 'mails' ? $t('mail.taskTab.title') : $t(`tasks.${tab}Tab`) }}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -433,6 +433,76 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Onglet Mails -->
|
||||
<div v-show="activeTab === 'mails'" class="space-y-4">
|
||||
<!-- Chargement -->
|
||||
<div v-if="mailsLoading" class="flex items-center justify-center py-8">
|
||||
<Icon name="material-symbols:progress-activity" size="24" class="animate-spin text-neutral-400" />
|
||||
</div>
|
||||
|
||||
<!-- Vide -->
|
||||
<div
|
||||
v-else-if="linkedMails.length === 0"
|
||||
class="flex flex-col items-center justify-center gap-3 py-8 text-center"
|
||||
>
|
||||
<Icon name="material-symbols:mail-outline" size="32" class="text-neutral-300" />
|
||||
<p class="text-sm text-neutral-400 italic">{{ $t('mail.taskTab.empty') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Liste mails liés -->
|
||||
<div v-else class="divide-y divide-neutral-100 rounded-lg border border-neutral-200">
|
||||
<NuxtLink
|
||||
v-for="mail in linkedMails"
|
||||
:key="mail.id"
|
||||
:to="`/mail?messageId=${mail.id}`"
|
||||
class="flex items-start gap-3 px-4 py-3 text-sm transition-colors hover:bg-neutral-50"
|
||||
:title="$t('mail.taskTab.openInMailer')"
|
||||
>
|
||||
<Icon
|
||||
name="material-symbols:mail-outline"
|
||||
size="16"
|
||||
class="mt-0.5 flex-shrink-0 text-neutral-400"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate font-medium text-neutral-800">
|
||||
{{ mail.subject ?? $t('mail.noSubject') }}
|
||||
</p>
|
||||
<p class="flex items-center gap-2 text-xs text-neutral-500">
|
||||
<span class="truncate">{{ mail.fromName ?? mail.fromEmail }}</span>
|
||||
<span>·</span>
|
||||
<span class="flex-shrink-0">{{ formatMailDate(mail.sentAt ?? mail.receivedAt) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<Icon
|
||||
name="material-symbols:open-in-new"
|
||||
size="14"
|
||||
class="flex-shrink-0 text-neutral-300"
|
||||
/>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- Bouton lier un mail -->
|
||||
<div class="pt-2">
|
||||
<MalioButton
|
||||
:label="$t('mail.taskTab.linkButton')"
|
||||
variant="secondary"
|
||||
icon-name="material-symbols:link"
|
||||
icon-position="left"
|
||||
:icon-size="14"
|
||||
button-class="w-auto"
|
||||
@click="showMailPickerModal = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Modal picker mail -->
|
||||
<MailPickerModal
|
||||
v-if="task"
|
||||
v-model="showMailPickerModal"
|
||||
:task-id="task.id"
|
||||
@linked="handleMailLinked"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div
|
||||
class="mt-6 flex items-center border-t border-neutral-100 pt-5"
|
||||
@@ -513,6 +583,8 @@ import { useTaskService } from '~/services/tasks'
|
||||
import { useTaskRecurrenceService } from '~/services/task-recurrences'
|
||||
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import { useMailService } from '~/services/mail'
|
||||
import type { MailMessageHeaderDto } from '~/services/dto/mail'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
@@ -545,7 +617,14 @@ function close() {
|
||||
const isEditing = computed(() => !!props.task)
|
||||
const isSubmitting = ref(false)
|
||||
const confirmDeleteOpen = ref(false)
|
||||
const activeTab = ref<'details' | 'planning'>('details')
|
||||
const activeTab = ref<'details' | 'planning' | 'mails'>('details')
|
||||
|
||||
// ─── Onglet Mails ─────────────────────────────────────────────────────────
|
||||
|
||||
const mailService = useMailService()
|
||||
const linkedMails = ref<MailMessageHeaderDto[]>([])
|
||||
const mailsLoading = ref(false)
|
||||
const showMailPickerModal = ref(false)
|
||||
|
||||
const giteaUrl = ref('')
|
||||
const { getSettings: getGiteaSettings } = useGiteaService()
|
||||
@@ -765,6 +844,7 @@ watch(() => props.modelValue, async (open) => {
|
||||
activeTab.value = 'details'
|
||||
confirmDeleteDocOpen.value = false
|
||||
documentToDelete.value = null
|
||||
linkedMails.value = []
|
||||
populateForm(props.task)
|
||||
const pid = resolvedProjectId.value
|
||||
if (pid) {
|
||||
@@ -823,6 +903,49 @@ watch(() => form.projectId, async (pid) => {
|
||||
const authStore = useAuthStore()
|
||||
const isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||
|
||||
const isClientOnly = computed(() =>
|
||||
authStore.user?.roles?.includes('ROLE_CLIENT') === true
|
||||
&& authStore.user?.roles?.includes('ROLE_ADMIN') !== true,
|
||||
)
|
||||
const isMailUser = computed(() => !isClientOnly.value)
|
||||
|
||||
const availableTabs = computed(() => {
|
||||
const base: Array<'details' | 'planning' | 'mails'> = ['details', 'planning']
|
||||
if (isEditing.value && isMailUser.value) base.push('mails')
|
||||
return base
|
||||
})
|
||||
|
||||
async function loadLinkedMails(): Promise<void> {
|
||||
if (!props.task || !isMailUser.value) return
|
||||
mailsLoading.value = true
|
||||
try {
|
||||
linkedMails.value = await mailService.listMailsForTask(props.task.id)
|
||||
} catch {
|
||||
linkedMails.value = []
|
||||
} finally {
|
||||
mailsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(activeTab, async (tab) => {
|
||||
if (tab === 'mails' && props.task) {
|
||||
await loadLinkedMails()
|
||||
}
|
||||
})
|
||||
|
||||
async function handleMailLinked(): Promise<void> {
|
||||
showMailPickerModal.value = false
|
||||
await loadLinkedMails()
|
||||
}
|
||||
|
||||
function formatMailDate(iso: string | null): string {
|
||||
if (!iso) return ''
|
||||
return new Date(iso).toLocaleDateString('fr', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
})
|
||||
}
|
||||
|
||||
function ticketStatusClass(status: string): string {
|
||||
switch (status) {
|
||||
case 'new': return 'bg-blue-100 text-blue-700'
|
||||
|
||||
@@ -13,6 +13,14 @@
|
||||
<h1 class="text-lg font-bold tracking-tight">Lesstime</h1>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-4 text-xl text-white sm:gap-8">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:help-circle-outline"
|
||||
aria-label="Centre d'aide"
|
||||
variant="ghost"
|
||||
icon-size="22"
|
||||
button-class="text-white/70 hover:bg-primary-600 hover:text-white"
|
||||
@click="navigateTo('/help')"
|
||||
/>
|
||||
<MalioButtonIcon
|
||||
:icon="ui.darkMode ? 'mdi:weather-sunny' : 'mdi:weather-night'"
|
||||
:aria-label="ui.darkMode ? 'Mode clair' : 'Mode sombre'"
|
||||
|
||||
75
frontend/composables/useSystemFolderLabel.ts
Normal file
75
frontend/composables/useSystemFolderLabel.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Mapping des chemins de dossiers système IMAP vers les clés i18n.
|
||||
* Les clés sont normalisées en minuscules pour la comparaison.
|
||||
* Couvre les variantes OVH courantes (INBOX, INBOX.Sent, Sent, etc.)
|
||||
*/
|
||||
const SYSTEM_FOLDER_MAP: Record<string, string> = {
|
||||
'inbox': 'mail.systemFolder.inbox',
|
||||
'sent': 'mail.systemFolder.sent',
|
||||
'inbox.sent': 'mail.systemFolder.sent',
|
||||
'sent messages': 'mail.systemFolder.sent',
|
||||
'drafts': 'mail.systemFolder.drafts',
|
||||
'inbox.drafts': 'mail.systemFolder.drafts',
|
||||
'archive': 'mail.systemFolder.archive',
|
||||
'archives': 'mail.systemFolder.archive',
|
||||
'inbox.archive': 'mail.systemFolder.archive',
|
||||
'trash': 'mail.systemFolder.trash',
|
||||
'deleted': 'mail.systemFolder.trash',
|
||||
'deleted items': 'mail.systemFolder.trash',
|
||||
'inbox.trash': 'mail.systemFolder.trash',
|
||||
'junk': 'mail.systemFolder.junk',
|
||||
'junk e-mail': 'mail.systemFolder.junk',
|
||||
'spam': 'mail.systemFolder.junk',
|
||||
'inbox.junk': 'mail.systemFolder.junk',
|
||||
}
|
||||
|
||||
/**
|
||||
* Icônes Material Symbols associées aux dossiers système.
|
||||
* Pour les dossiers non reconnus : utiliser une icône générique.
|
||||
*/
|
||||
const SYSTEM_FOLDER_ICONS: Record<string, string> = {
|
||||
'mail.systemFolder.inbox': 'material-symbols:inbox-outline',
|
||||
'mail.systemFolder.sent': 'material-symbols:send-outline',
|
||||
'mail.systemFolder.drafts': 'material-symbols:draft-outline',
|
||||
'mail.systemFolder.archive': 'material-symbols:archive-outline',
|
||||
'mail.systemFolder.trash': 'material-symbols:delete-outline',
|
||||
'mail.systemFolder.junk': 'material-symbols:report-outline',
|
||||
}
|
||||
|
||||
const DEFAULT_FOLDER_ICON = 'material-symbols:folder-outline'
|
||||
|
||||
export function useSystemFolderLabel() {
|
||||
const { t } = useI18n()
|
||||
|
||||
/**
|
||||
* Retourne le label traduit d'un dossier système, ou son displayName si inconnu.
|
||||
* @param path - Chemin IMAP du dossier (ex: "INBOX", "INBOX.Sent")
|
||||
* @param displayName - Nom affiché par défaut si non reconnu
|
||||
*/
|
||||
function getFolderLabel(path: string, displayName: string): string {
|
||||
const key = SYSTEM_FOLDER_MAP[path.toLowerCase()]
|
||||
return key ? t(key) : displayName
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne le nom de l'icône Material Symbols pour un dossier.
|
||||
* @param path - Chemin IMAP du dossier
|
||||
*/
|
||||
function getFolderIcon(path: string): string {
|
||||
const key = SYSTEM_FOLDER_MAP[path.toLowerCase()]
|
||||
return key ? (SYSTEM_FOLDER_ICONS[key] ?? DEFAULT_FOLDER_ICON) : DEFAULT_FOLDER_ICON
|
||||
}
|
||||
|
||||
/**
|
||||
* Indique si un dossier est un dossier système reconnu.
|
||||
*/
|
||||
function isSystemFolder(path: string): boolean {
|
||||
return path.toLowerCase() in SYSTEM_FOLDER_MAP
|
||||
}
|
||||
|
||||
return {
|
||||
getFolderLabel,
|
||||
getFolderIcon,
|
||||
isSystemFolder,
|
||||
}
|
||||
}
|
||||
27
frontend/content/help/01-getting-started.md
Normal file
27
frontend/content/help/01-getting-started.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Bienvenue dans Lesstime
|
||||
|
||||
Lesstime est un outil de **gestion de projets** qui combine 4 grandes capacités :
|
||||
|
||||
- 🗂️ **Gestion de projets** avec kanban personnalisable (workflows)
|
||||
- ✅ **Suivi de tâches** avec assignations, priorités, efforts, deadlines, tags
|
||||
- ⏱️ **Time tracking** intégré, lié aux projets et aux tâches
|
||||
- 🎫 **Portail client** pour que tes clients déposent leurs tickets
|
||||
|
||||
## Comprendre les rôles
|
||||
|
||||
| Rôle | Accès |
|
||||
|---|---|
|
||||
| **Admin** | Tout : projets, utilisateurs, intégrations, workflows |
|
||||
| **User** | Ses tâches, time tracking, projets auxquels il a accès |
|
||||
| **Client** | Portal dédié — tickets sur ses projets uniquement |
|
||||
|
||||
## Vues principales
|
||||
|
||||
- **Dashboard** : vue d'ensemble personnelle (KPIs, tâches du jour)
|
||||
- **Mes tâches** : kanban perso groupé par catégorie, toutes projets confondus
|
||||
- **Projets** : un kanban par projet, statuts du workflow associé
|
||||
- **Time tracking** : timer, time entries, vue mois
|
||||
- **Admin** : gestion globale (visible uniquement par les admins)
|
||||
- **Portal** : interface dédiée aux utilisateurs ROLE_CLIENT
|
||||
|
||||
> 💡 **Astuce** : utilise l'avatar en haut à droite pour accéder à ton profil et y générer un **token MCP** (cf. section *Token MCP & API*) pour piloter Lesstime depuis Claude / Cursor.
|
||||
58
frontend/content/help/02-projects-workflows.md
Normal file
58
frontend/content/help/02-projects-workflows.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Projets & Workflows
|
||||
|
||||
## Qu'est-ce qu'un projet ?
|
||||
|
||||
Un projet regroupe un ensemble de **tâches**, **time entries** et éventuellement **tickets client**. Il est défini par :
|
||||
|
||||
- Un **code court** (2-10 lettres majuscules, ex: `SIRH`, `CRM`) qui préfixe les numéros de tâches
|
||||
- Un **client** optionnel (ou interne si null)
|
||||
- Une **couleur** d'identification
|
||||
- Un **workflow** (obligatoire) qui définit ses colonnes kanban
|
||||
|
||||
## Qu'est-ce qu'un workflow ?
|
||||
|
||||
Un **workflow** est un *jeu de statuts kanban* réutilisable. Au lieu d'avoir une liste globale de statuts comme dans la plupart des outils, chaque projet a son propre kanban adapté à sa façon de travailler.
|
||||
|
||||
### Exemple
|
||||
|
||||
| Workflow | Statuts |
|
||||
|---|---|
|
||||
| **Standard** (par défaut) | À faire → En cours → Bloqué → En attente de validation → Terminé |
|
||||
| **DevKanban** | Backlog → Spec → In Dev → Review PR → QA → Done |
|
||||
| **Support** | Nouveau → Diagnostic → Résolu |
|
||||
|
||||
Tu peux créer autant de workflows que tu veux depuis **Admin → Workflows**.
|
||||
|
||||
## Les 5 catégories canoniques
|
||||
|
||||
Chaque statut, peu importe son workflow, appartient à **une catégorie canonique** parmi :
|
||||
|
||||
| Catégorie | Description |
|
||||
|---|---|
|
||||
| `todo` | À faire — pas encore commencé |
|
||||
| `in_progress` | En cours — quelqu'un bosse dessus |
|
||||
| `blocked` | Bloqué — attente d'une dépendance |
|
||||
| `review` | En validation — relecture, PR, QA |
|
||||
| `done` | Terminé — close |
|
||||
|
||||
> 🎯 **Pourquoi des catégories ?** Pour que la vue *Mes tâches* puisse regrouper des tâches venant de projets avec des workflows différents (ex: une tâche "In Dev" de DevKanban et "En cours" de Standard apparaissent dans la même colonne `in_progress`).
|
||||
|
||||
## Changer le workflow d'un projet
|
||||
|
||||
1. Ouvrir le projet → **Modifier le projet** (drawer)
|
||||
2. Section **Workflow** → cliquer sur **Changer de workflow**
|
||||
3. Sélectionner le workflow cible
|
||||
4. **Mapper chaque statut source vers un statut cible** (le mapping est pré-rempli automatiquement par catégorie)
|
||||
5. **Confirmer** — toutes les tâches migrent dans une seule transaction
|
||||
|
||||
### Règles du mapping
|
||||
|
||||
- ✅ Chaque statut actuellement utilisé par une tâche **doit** être mappé (sinon erreur 422)
|
||||
- ✅ Un statut peut être mappé vers `null` → la tâche passe en backlog (sans statut)
|
||||
- ❌ Tu ne peux pas mapper vers un statut qui n'appartient pas au workflow cible
|
||||
|
||||
## Supprimer un workflow
|
||||
|
||||
Tu peux supprimer un workflow uniquement s'il n'est **lié à aucun projet** (HTTP 409 sinon). Réassigne d'abord les projets vers un autre workflow.
|
||||
|
||||
> ⚠️ Le workflow **Standard** ne peut pas être supprimé tant qu'il reste le défaut (un seul workflow peut avoir `isDefault=true` à la fois, garanti par un listener Doctrine).
|
||||
60
frontend/content/help/03-my-tasks.md
Normal file
60
frontend/content/help/03-my-tasks.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Mes tâches & Dashboard
|
||||
|
||||
## Vue *Mes tâches*
|
||||
|
||||
Accessible via la sidebar, c'est ta vue **transverse** : toutes les tâches dont tu es l'**assigné** ou un **collaborateur**, peu importe le projet.
|
||||
|
||||
### Deux modes d'affichage
|
||||
|
||||
#### 1. Kanban (par défaut)
|
||||
|
||||
Regroupé par les **5 catégories canoniques** :
|
||||
|
||||
```
|
||||
À faire → En cours → Bloqué → En validation → Terminé
|
||||
```
|
||||
|
||||
Chaque card affiche :
|
||||
- Le **code projet + numéro** (ex: `SIRH-12`) coloré selon le projet
|
||||
- Un **badge statut** (utile quand des tâches de projets différents cohabitent)
|
||||
- Priorité, tags, deadline, icônes (sync calendrier, récurrence, collaborateurs)
|
||||
- L'**avatar de l'assigné** + bouton timer (▶ / ⏹)
|
||||
|
||||
> 💡 Le **drag-to-status** est intentionnellement désactivé dans *Mes tâches* — pour changer un statut, ouvre la tâche (la valeur dépend du workflow du projet, pas de la catégorie).
|
||||
|
||||
#### 2. Liste
|
||||
|
||||
Vue tableau triable, avec **bulk actions** :
|
||||
- Cocher plusieurs tâches → barre d'actions en haut
|
||||
- Changer statut (désactivé si tâches de **projets différents**), assigné, priorité, effort, groupe
|
||||
- Supprimer en lot
|
||||
|
||||
### Filtres disponibles
|
||||
|
||||
| Filtre | Notes |
|
||||
|---|---|
|
||||
| **Projet** | Restreint à un projet précis |
|
||||
| **Groupe** | Disponible uniquement si un projet est sélectionné |
|
||||
| **Tag** | Tags globaux |
|
||||
| **Priorité / Effort** | |
|
||||
| **Assigné** | Par défaut : toi-même |
|
||||
|
||||
### Tri (vue liste uniquement)
|
||||
|
||||
- Par **deadline** (les plus proches en premier)
|
||||
- Par **scheduled start** (planification calendrier)
|
||||
|
||||
## Vue *Backlog*
|
||||
|
||||
Sous le kanban, les tâches **sans statut** apparaissent dans la section *Backlog*. Pratique pour les idées non encore qualifiées.
|
||||
|
||||
## Dashboard
|
||||
|
||||
Le **dashboard** (page d'accueil après login) affiche :
|
||||
|
||||
- 📊 **KPIs personnels** : tâches en cours / à faire / en retard
|
||||
- 📈 **Charts** : répartition par statut, par priorité, time tracking cette semaine
|
||||
- 🔔 **Notifications** : assignations, commentaires (cf. cloche en topbar)
|
||||
- ⏱ **Timer actif** s'il y en a un
|
||||
|
||||
> 💡 Tu peux changer le filtre user du dashboard via le sélecteur en haut pour voir les KPIs d'un collègue (utile pour les leads).
|
||||
59
frontend/content/help/04-time-tracking.md
Normal file
59
frontend/content/help/04-time-tracking.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Time tracking
|
||||
|
||||
## Le timer
|
||||
|
||||
Le timer **flottant** est accessible depuis la sidebar ou directement depuis une tâche.
|
||||
|
||||
### Démarrer un timer
|
||||
|
||||
Trois façons :
|
||||
|
||||
1. **Depuis une TaskCard** : clique sur l'icône ▶ à droite de la card
|
||||
2. **Depuis le détail d'une tâche** : bouton *Démarrer le timer*
|
||||
3. **Manuellement** : depuis */time-tracking*, créer une time entry sans tâche
|
||||
|
||||
### Arrêter
|
||||
|
||||
- Clique sur ⏹ sur la card de la tâche en cours
|
||||
- Ou depuis la sidebar (icône timer pulsante en orange `#F18619`)
|
||||
|
||||
> 💡 Un seul timer actif à la fois. Démarrer un nouveau timer arrête automatiquement le précédent.
|
||||
|
||||
## Time entries
|
||||
|
||||
Chaque entrée a :
|
||||
|
||||
| Champ | Description |
|
||||
|---|---|
|
||||
| **Titre** | Description courte (ex: "Réunion daily") |
|
||||
| **Projet** | Obligatoire |
|
||||
| **Tâche** | Optionnel — lie l'entrée à une tâche précise |
|
||||
| **Tags** | Pour catégoriser (ex: "Backend", "Réunion") |
|
||||
| **Début / Fin** | Datetimes — la durée est calculée |
|
||||
| **User** | Qui a fait le travail |
|
||||
|
||||
### Vue *Time tracking*
|
||||
|
||||
Disponible en deux modes :
|
||||
|
||||
- **Vue semaine** : ligne par ligne, par jour
|
||||
- **Vue mois** : agrégation mensuelle, totaux par projet et par tag
|
||||
|
||||
### Filtres
|
||||
|
||||
- **Projet** (server-side)
|
||||
- **Tag** (server-side)
|
||||
- **User** (admin uniquement)
|
||||
- **Période** (date début / date fin)
|
||||
|
||||
## Édition
|
||||
|
||||
- Clique sur une time entry → drawer d'édition
|
||||
- Tu peux modifier projet, tâche, tags, dates a posteriori
|
||||
- La suppression est libre — pense à exporter avant si nécessaire
|
||||
|
||||
## Tags
|
||||
|
||||
Les tags sont **globaux** (partagés entre tous les projets, comme les statuts l'étaient avant les workflows). Définis depuis **Admin → Tags**.
|
||||
|
||||
> 📊 **Cas d'usage typique** : créer un tag par typologie d'activité (Dev, Réunion, Support, Veille) pour pouvoir agréger ton temps en fin de mois.
|
||||
62
frontend/content/help/05-tasks-detail.md
Normal file
62
frontend/content/help/05-tasks-detail.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Détail d'une tâche
|
||||
|
||||
## Champs principaux
|
||||
|
||||
| Champ | Notes |
|
||||
|---|---|
|
||||
| **Numéro** | Auto-incrémenté **par projet** (ex: `SIRH-1`, `SIRH-2`, `CRM-1`…) |
|
||||
| **Titre** | Obligatoire |
|
||||
| **Description** | Markdown supporté (preview disponible) |
|
||||
| **Statut** | Doit appartenir au workflow du projet (sinon erreur 422) |
|
||||
| **Priorité** | Basse / Moyenne / Haute — couleurs personnalisables |
|
||||
| **Effort** | S / M / L / XL / XXL — pour estimer la charge |
|
||||
| **Assigné** | Un seul user responsable |
|
||||
| **Collaborateurs** | Multiples — visibles via icône `mdi:account-group` |
|
||||
| **Groupe** | Optionnel — regroupe des tâches au sein d'un projet |
|
||||
| **Tags** | Globaux, plusieurs par tâche |
|
||||
| **Deadline** | Date — un badge coloré apparaît sur la card |
|
||||
| **Scheduled start / end** | Planification calendrier (sync optionnelle) |
|
||||
|
||||
## Récurrence
|
||||
|
||||
Une tâche peut être **récurrente** (icône 🔁 sur la card) :
|
||||
|
||||
- **Type** : quotidien, hebdomadaire, mensuel
|
||||
- **Intervalle** : tous les N jours/semaines/mois
|
||||
- **Jours de la semaine** (pour le mode hebdomadaire) : `monday`, `tuesday`, etc.
|
||||
|
||||
Chaque occurrence est gérée séparément ; cocher une tâche récurrente comme *Terminée* peut générer l'occurrence suivante selon le pattern.
|
||||
|
||||
## Sync calendrier
|
||||
|
||||
Si Zimbra est configuré (cf. Intégrations), tu peux activer **Sync calendrier** sur une tâche planifiée pour qu'elle apparaisse dans ton calendrier Zimbra (CalDav).
|
||||
|
||||
Icônes correspondantes :
|
||||
- 🟢 `mdi:calendar-check` → sync OK
|
||||
- 🔴 `mdi:alert-circle` → erreur de sync (passe sur l'icône pour le détail)
|
||||
|
||||
## Documents
|
||||
|
||||
Chaque tâche peut avoir des **documents attachés** (PDF, images, etc.) :
|
||||
|
||||
- Drag & drop dans la tâche pour uploader
|
||||
- Validation du **MIME type côté serveur** (pas seulement l'extension)
|
||||
- Téléchargement via lien dédié
|
||||
|
||||
## Liaison Gitea (si configuré)
|
||||
|
||||
Si le projet a un repo Gitea lié, tu peux :
|
||||
|
||||
- **Créer une branche** depuis la tâche : `feature/` `fix/` `refactor/` `hotfix/` `chore/` (5 types disponibles)
|
||||
- Convention de nommage : `<type>/<CODE>-<NUMBER>-<slug>` (ex: `feature/SIRH-12-add-login`)
|
||||
- **Voir les PRs** liées (état CI inclus)
|
||||
|
||||
## Liaison ticket client
|
||||
|
||||
Si la tâche découle d'un ticket client, l'icône 👤 (`heroicons:user-circle`) bleue apparaît avec le numéro du ticket (ex: `CT-001`).
|
||||
|
||||
## Commentaires & notifications
|
||||
|
||||
- Ajouter un commentaire notifie les watchers (assigné, collaborateurs)
|
||||
- Les @mentions notifient l'utilisateur cité
|
||||
- La cloche en topbar (`NotificationBell`) liste toutes les notifications non lues
|
||||
43
frontend/content/help/06-client-portal.md
Normal file
43
frontend/content/help/06-client-portal.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Portal client
|
||||
|
||||
> 🎫 Section dédiée aux utilisateurs avec le rôle **ROLE_CLIENT**.
|
||||
|
||||
## Accès
|
||||
|
||||
Les utilisateurs *client* sont **automatiquement redirigés vers `/portal`** après login. Ils ne voient pas les vues internes (projets, time tracking, admin).
|
||||
|
||||
## Ce que voit un client
|
||||
|
||||
- 📋 La liste de ses **projets autorisés** (définis par l'admin dans le user)
|
||||
- 🎫 Sur chaque projet, la liste de ses **tickets** (ses créations uniquement)
|
||||
- ➕ Le bouton **Nouveau ticket** sur chaque projet
|
||||
|
||||
## Soumettre un ticket
|
||||
|
||||
Depuis `/portal/projects/<id>/new-ticket` :
|
||||
|
||||
| Champ | Description |
|
||||
|---|---|
|
||||
| **Type** | `bug` / `improvement` / `other` |
|
||||
| **Titre** | Court et descriptif |
|
||||
| **Description** | Détails — markdown supporté |
|
||||
| **URL** | Optionnel — page où le problème se manifeste |
|
||||
|
||||
Le ticket est automatiquement numéroté **par projet** (ex: `CT-001`).
|
||||
|
||||
## Statuts d'un ticket
|
||||
|
||||
| Statut | Visible côté client | Signification |
|
||||
|---|---|---|
|
||||
| `new` | Oui | Reçu, pas encore traité |
|
||||
| `in_progress` | Oui | Une tâche interne y est liée |
|
||||
| `done` | Oui | Résolu et clôturé |
|
||||
| `rejected` | Oui | Non retenu (avec commentaire explicatif) |
|
||||
|
||||
Le `statusComment` est visible par le client quand fourni.
|
||||
|
||||
## Côté équipe interne
|
||||
|
||||
- Les tickets apparaissent dans **Admin → Tickets client**
|
||||
- On peut **transformer un ticket en tâche** (la tâche garde une référence au ticket — icône 👤 bleue sur la card)
|
||||
- Le client voit l'avancement passer en `in_progress` automatiquement quand une tâche est liée
|
||||
66
frontend/content/help/07-admin.md
Normal file
66
frontend/content/help/07-admin.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Administration
|
||||
|
||||
> 🛡️ Section visible uniquement par les utilisateurs **ROLE_ADMIN**.
|
||||
|
||||
L'admin (`/admin`) est divisé en plusieurs onglets, chacun gérant une ressource globale ou une intégration.
|
||||
|
||||
## Onglet *Clients*
|
||||
|
||||
- Liste des clients (entreprise / organisation)
|
||||
- Champs : nom, email, téléphone, adresse
|
||||
- Lier un client à des projets
|
||||
|
||||
## Onglet *Workflows*
|
||||
|
||||
⭐ **Nouveau** — remplace l'ancien onglet *Statuts*.
|
||||
|
||||
- Lister les workflows existants
|
||||
- **Créer un workflow** : nom, isDefault (un seul à la fois), liste de statuts éditables inline
|
||||
- Chaque statut : libellé, couleur, position, **catégorie** (5 valeurs canoniques), isFinal
|
||||
- **Éditer** un workflow modifie les statuts (sync intelligent : create / update / delete par diff)
|
||||
|
||||
> ⚠️ Supprimer un workflow lié à un projet renvoie une erreur **409**. Réassigne d'abord les projets.
|
||||
|
||||
## Onglet *Efforts*
|
||||
|
||||
- Tailles d'effort (S, M, L, XL, XXL)
|
||||
- Globales (partagées entre tous les projets)
|
||||
|
||||
## Onglet *Priorités*
|
||||
|
||||
- Niveaux de priorité (Basse, Moyenne, Haute) + couleur
|
||||
- Une priorité "Haute" affiche un drapeau rouge `mdi:flag-variant` sur la card
|
||||
|
||||
## Onglet *Tags*
|
||||
|
||||
- Tags globaux (tâches **et** time entries)
|
||||
- Couleur personnalisable
|
||||
- Pas de hiérarchie (flat list)
|
||||
|
||||
## Onglet *Utilisateurs*
|
||||
|
||||
- Créer / éditer / désactiver
|
||||
- Rôles : `ROLE_ADMIN`, `ROLE_USER`, `ROLE_CLIENT`
|
||||
- **ROLE_CLIENT** : associer un *client* et une liste de *projets autorisés*
|
||||
- Reset password depuis l'admin
|
||||
|
||||
> 🔐 Un user *admin+client* (les deux rôles) **n'est pas bloqué** par le middleware portal — le check est sur `ROLE_CLIENT && !ROLE_ADMIN`.
|
||||
|
||||
## Onglet *Gitea*
|
||||
|
||||
- URL serveur + token API
|
||||
- Lier un projet à un repo : `giteaOwner` + `giteaRepo`
|
||||
- Active les fonctionnalités branches / PRs sur les tâches
|
||||
|
||||
## Onglet *BookStack*
|
||||
|
||||
- URL + token API
|
||||
- Lier un projet à un **shelf** BookStack (`bookstackShelfId`)
|
||||
- Les tâches peuvent être liées à des pages BookStack (cf. `TaskBookStackLink`)
|
||||
|
||||
## Onglet *Zimbra*
|
||||
|
||||
- URL serveur + credentials (chiffrés via libsodium)
|
||||
- Configure le calendrier CalDav par défaut
|
||||
- Test de connexion intégré
|
||||
- Active la **sync calendrier** sur les tâches planifiées
|
||||
66
frontend/content/help/08-integrations.md
Normal file
66
frontend/content/help/08-integrations.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Intégrations
|
||||
|
||||
Lesstime s'intègre avec **3 outils externes** pour fluidifier le workflow dev.
|
||||
|
||||
## 🌳 Gitea
|
||||
|
||||
Lesstime parle à un serveur Gitea pour automatiser les conventions de branches et suivre les PRs.
|
||||
|
||||
### Configuration
|
||||
|
||||
1. **Admin → Gitea** : URL serveur + token API
|
||||
2. Sur un projet : définir `giteaOwner` (org/user) et `giteaRepo` (nom du repo)
|
||||
|
||||
### Utilisation
|
||||
|
||||
Sur une tâche, le panneau Gitea propose :
|
||||
|
||||
- **Créer une branche** : choisir un type (`feature` / `fix` / `refactor` / `hotfix` / `chore`)
|
||||
- La branche est nommée automatiquement : `<type>/<PROJECT_CODE>-<NUMBER>-<slug-du-titre>`
|
||||
- **Lister les PRs liées** : par convention, toute PR qui contient `<PROJECT_CODE>-<NUMBER>` dans son nom ou sa description est reliée
|
||||
- **État CI** : ✅ ou ❌ affiché si le repo a des Actions/Workflows configurées
|
||||
|
||||
> 💡 La convention `<PROJECT_CODE>-<NUMBER>` permet à Gitea et Lesstime de se synchroniser **sans webhook** — juste par parsing des noms.
|
||||
|
||||
## 📚 BookStack
|
||||
|
||||
Lien tâche → documentation.
|
||||
|
||||
### Configuration
|
||||
|
||||
1. **Admin → BookStack** : URL + token (token ID + token secret, chiffrés via libsodium)
|
||||
2. Sur un projet : définir `bookstackShelfId` + `bookstackShelfName`
|
||||
|
||||
### Utilisation
|
||||
|
||||
- Depuis une tâche : bouton **Lier à une page BookStack**
|
||||
- Sélectionner la page dans le shelf du projet
|
||||
- Le lien est bidirectionnel (BookStack peut afficher les tâches liées)
|
||||
|
||||
## 📅 Zimbra (CalDav)
|
||||
|
||||
Sync calendrier pour les tâches planifiées.
|
||||
|
||||
### Configuration
|
||||
|
||||
1. **Admin → Zimbra** :
|
||||
- URL serveur (ex: `https://mail.ovh.com`)
|
||||
- Username (ex: `lesstime@ovh.fr`)
|
||||
- Password (chiffré côté serveur)
|
||||
- Calendar path (ex: `/dav/lesstime@ovh.fr/Calendar/`)
|
||||
- **Test de connexion** intégré
|
||||
2. Active la config (toggle `enabled`)
|
||||
|
||||
### Utilisation
|
||||
|
||||
Sur une tâche avec **scheduled start + end** :
|
||||
|
||||
1. Cocher **Sync calendrier**
|
||||
2. Au save, Lesstime crée/met à jour l'événement CalDav
|
||||
3. L'icône `mdi:calendar-check` (verte) apparaît sur la card si succès
|
||||
4. L'icône `mdi:alert-circle` (rouge) apparaît si erreur — passe dessus pour voir le détail
|
||||
|
||||
### Limites
|
||||
|
||||
- **Pas de retour Zimbra → Lesstime** : si tu modifies l'événement dans Zimbra, Lesstime ne le voit pas
|
||||
- **Récurrences** : les patterns RRULE basiques sont supportés (daily, weekly avec jours, monthly)
|
||||
97
frontend/content/help/09-mcp-api.md
Normal file
97
frontend/content/help/09-mcp-api.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# Token MCP & API
|
||||
|
||||
Lesstime expose un serveur **MCP** (Model Context Protocol) qui permet à un assistant IA (Claude, Cursor, etc.) de piloter ton instance Lesstime — créer des tâches, lire des projets, démarrer un timer, etc.
|
||||
|
||||
## Générer ton token
|
||||
|
||||
1. Va sur **Profil** (avatar → Profil)
|
||||
2. Section **Token MCP** → **Générer un token**
|
||||
3. **Copie le token immédiatement** — il ne sera plus affiché ensuite
|
||||
|
||||
> 🔐 **Sécurité** : Le token donne accès à toutes les actions de ton compte. Ne le partage jamais. Tu peux le régénérer à tout moment (l'ancien sera révoqué).
|
||||
|
||||
## Configurer Claude Code
|
||||
|
||||
Dans `.mcp.json` (à la racine de ton projet) :
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"lesstime": {
|
||||
"type": "http",
|
||||
"url": "https://ton-instance-lesstime/_mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer TON_TOKEN_ICI"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Pour une instance locale :
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"lesstime-local": {
|
||||
"command": "docker",
|
||||
"args": ["exec", "-i", "php-lesstime-fpm", "php", "bin/console", "mcp:server"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Tools disponibles (27 au total)
|
||||
|
||||
### Projets
|
||||
|
||||
- `list-projects`, `get-project`, `create-project`, `update-project`
|
||||
|
||||
### Tâches
|
||||
|
||||
- `list-tasks` (avec filtres : projet, assigné, statut, archived…)
|
||||
- `get-task`, `create-task`, `update-task`, `delete-task`
|
||||
|
||||
### Métadonnées
|
||||
|
||||
- `list-statuses` (param **`projectId`** optionnel — sans : tous les statuts ; avec : statuts du workflow du projet)
|
||||
- `list-priorities`, `list-efforts`, `list-tags`
|
||||
|
||||
### Workflows ⭐ Nouveau
|
||||
|
||||
- `list-workflows` — liste tous les workflows avec leurs statuts groupés
|
||||
- `switch-project-workflow` (ROLE_ADMIN) — change le workflow d'un projet avec mapping
|
||||
|
||||
### Time tracking
|
||||
|
||||
- `list-time-entries`, `create-time-entry`, `update-time-entry`, `delete-time-entry`
|
||||
|
||||
### Récurrence
|
||||
|
||||
- `create-task-recurrence`, `update-task-recurrence`, `delete-task-recurrence`
|
||||
|
||||
### Groupes / Users / Clients
|
||||
|
||||
- `list-groups`, `create-group`, `update-group`
|
||||
- `list-users`, `list-clients`
|
||||
|
||||
## Règles importantes
|
||||
|
||||
> ⚠️ **Statut hors workflow rejeté** : si tu appelles `create-task` ou `update-task` avec un `status` qui n'appartient pas au workflow du projet, l'appel est rejeté avec **422 Validation error**. Utilise `list-statuses(projectId)` pour découvrir les statuts valides du projet.
|
||||
|
||||
## Exemples de prompts
|
||||
|
||||
```
|
||||
"Crée une tâche dans Lesstime sur le projet SIRH avec le titre
|
||||
'Ajouter l'export PDF' et la priorité Haute, assignée à alice"
|
||||
```
|
||||
|
||||
```
|
||||
"Liste mes tâches en cours dans le projet CRM"
|
||||
```
|
||||
|
||||
```
|
||||
"Démarre un timer sur la tâche SIRH-12 avec le tag Backend"
|
||||
```
|
||||
|
||||
L'agent appelle les bons tools tout seul si la description est claire.
|
||||
@@ -492,5 +492,127 @@
|
||||
"weekly": "Hebdomadaire",
|
||||
"monthly": "Mensuel",
|
||||
"yearly": "Annuel"
|
||||
},
|
||||
"mail": {
|
||||
"title": "Messagerie",
|
||||
"sidebar": {
|
||||
"title": "Messagerie",
|
||||
"ariaLabel": "Accès à la messagerie, {count} messages non lus"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Configuration messagerie",
|
||||
"protocol": "Protocole",
|
||||
"imapSection": "Réception (IMAP)",
|
||||
"smtpSection": "Envoi (SMTP)",
|
||||
"host": "Serveur",
|
||||
"port": "Port",
|
||||
"encryption": "Chiffrement",
|
||||
"username": "Adresse e-mail",
|
||||
"password": "Mot de passe",
|
||||
"passwordSet": "Mot de passe déjà configuré — laisser vide pour conserver",
|
||||
"sentFolderPath": "Dossier des envois",
|
||||
"enabled": "Activer la synchronisation mail",
|
||||
"test": "Tester la connexion",
|
||||
"testSuccess": "Connexion IMAP réussie",
|
||||
"testFailed": "Échec de connexion",
|
||||
"save": "Enregistrer",
|
||||
"saveSuccess": "Configuration enregistrée",
|
||||
"ovhDefaultsHelp": "OVH : ssl0.ovh.net (port 993 IMAP / 465 SMTP)"
|
||||
},
|
||||
"folders": "Dossiers",
|
||||
"messages": "Messages",
|
||||
"viewer": "Lecture",
|
||||
"empty": {
|
||||
"folder": "Aucun dossier disponible.",
|
||||
"list": "Aucun message dans ce dossier.",
|
||||
"viewer": "Sélectionnez un message pour le lire."
|
||||
},
|
||||
"folderTree": {
|
||||
"expand": "Déplier le dossier",
|
||||
"collapse": "Replier le dossier"
|
||||
},
|
||||
"systemFolder": {
|
||||
"inbox": "Boîte de réception",
|
||||
"sent": "Éléments envoyés",
|
||||
"drafts": "Brouillons",
|
||||
"archive": "Archives",
|
||||
"trash": "Corbeille",
|
||||
"junk": "Indésirables"
|
||||
},
|
||||
"actions": {
|
||||
"refresh": "Actualiser",
|
||||
"createTask": "Créer une tâche",
|
||||
"linkTask": "Lier à une tâche",
|
||||
"markRead": "Marquer comme lu",
|
||||
"markUnread": "Marquer comme non lu",
|
||||
"flag": "Marquer important",
|
||||
"unflag": "Retirer l'importance",
|
||||
"download": "Télécharger",
|
||||
"showImages": "Afficher les images"
|
||||
},
|
||||
"errors": {
|
||||
"syncFailed": "Erreur lors de la synchronisation.",
|
||||
"fetchFailed": "Impossible de charger les messages.",
|
||||
"notAuthorized": "Vous n'avez pas accès à la messagerie."
|
||||
},
|
||||
"configuration": {
|
||||
"saved": "Configuration mail enregistrée."
|
||||
},
|
||||
"task": {
|
||||
"created": "Tâche créée depuis le mail.",
|
||||
"linked": "Mail lié à la tâche.",
|
||||
"unlinked": "Lien supprimé."
|
||||
},
|
||||
"createTaskModal": {
|
||||
"title": "Créer une tâche depuis ce mail",
|
||||
"submit": "Créer la tâche",
|
||||
"projectLabel": "Projet *",
|
||||
"projectPlaceholder": "Sélectionner un projet",
|
||||
"groupLabel": "Groupe (optionnel)",
|
||||
"groupPlaceholder": "Aucun groupe",
|
||||
"priorityLabel": "Priorité (optionnelle)",
|
||||
"priorityPlaceholder": "Aucune priorité",
|
||||
"titleHint": "Le titre sera rempli depuis le sujet du mail.",
|
||||
"descriptionHint": "La description sera remplie depuis le corps du mail."
|
||||
},
|
||||
"linkTaskModal": {
|
||||
"title": "Lier à une tâche existante",
|
||||
"submit": "Lier la tâche",
|
||||
"searchPlaceholder": "Rechercher une tâche par titre…",
|
||||
"projectFilter": "Filtrer par projet",
|
||||
"projectAll": "Tous les projets",
|
||||
"empty": "Aucune tâche correspondante.",
|
||||
"loading": "Recherche en cours…"
|
||||
},
|
||||
"pickerModal": {
|
||||
"title": "Lier un mail à cette tâche",
|
||||
"searchPlaceholder": "Rechercher un mail (sujet, expéditeur)…",
|
||||
"empty": "Aucun mail correspondant.",
|
||||
"loading": "Chargement des mails…",
|
||||
"submit": "Lier ce mail"
|
||||
},
|
||||
"taskTab": {
|
||||
"title": "Mails",
|
||||
"empty": "Aucun mail lié à cette tâche.",
|
||||
"linkButton": "Lier un mail",
|
||||
"openInMailer": "Ouvrir dans la messagerie",
|
||||
"unlinkConfirm": "Délier ce mail ?"
|
||||
},
|
||||
"sync": {
|
||||
"dispatched": "Synchronisation lancée en arrière-plan."
|
||||
},
|
||||
"attachments": "Pièces jointes",
|
||||
"noAttachments": "Aucune pièce jointe.",
|
||||
"from": "De",
|
||||
"to": "À",
|
||||
"cc": "Cc",
|
||||
"date": "Date",
|
||||
"subject": "Sujet",
|
||||
"noSubject": "(Sans objet)",
|
||||
"loadMore": "Charger plus",
|
||||
"loading": "Chargement…",
|
||||
"hasAttachments": "Pièces jointes",
|
||||
"unread": "non lu | non lus",
|
||||
"remoteImagesBlocked": "Les images distantes sont masquées pour votre sécurité."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,23 @@
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<div v-if="isMailVisible" class="relative">
|
||||
<SidebarLink
|
||||
to="/mail"
|
||||
icon="mdi:email-outline"
|
||||
:label="$t('mail.sidebar.title')"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<span
|
||||
v-if="mailStore.globalUnreadCount > 0"
|
||||
class="pointer-events-none absolute right-3 top-1/2 flex h-5 min-w-5 -translate-y-1/2 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
|
||||
:class="{ 'right-1 top-1 translate-y-0': sidebarIsCollapsed }"
|
||||
:aria-label="`${mailStore.globalUnreadCount} messages non lus`"
|
||||
>
|
||||
{{ mailStore.globalUnreadCount > 99 ? '99+' : mailStore.globalUnreadCount }}
|
||||
</span>
|
||||
</div>
|
||||
<SidebarLink
|
||||
to="/projects"
|
||||
icon="mdi:folder-outline"
|
||||
@@ -162,9 +179,18 @@ import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const ui = useUiStore()
|
||||
const mailStore = useMailStore()
|
||||
const {version} = useAppVersion()
|
||||
const route = useRoute()
|
||||
|
||||
const isMailVisible = computed(() => {
|
||||
const roles: string[] = auth.user?.roles ?? []
|
||||
const isClientOnly = roles.includes('ROLE_CLIENT')
|
||||
&& !roles.includes('ROLE_ADMIN')
|
||||
&& !roles.includes('ROLE_USER')
|
||||
return !isClientOnly && (roles.includes('ROLE_USER') || roles.includes('ROLE_ADMIN'))
|
||||
})
|
||||
|
||||
// On mobile, sidebar is always expanded (not collapsed icon mode)
|
||||
const sidebarIsCollapsed = computed(() => {
|
||||
if (ui.sidebarOpen) return false
|
||||
@@ -207,6 +233,17 @@ watch(
|
||||
|
||||
onMounted(() => {
|
||||
timerStore.fetchActive()
|
||||
if (isMailVisible.value) {
|
||||
mailStore.startPolling()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => auth.user, (user) => {
|
||||
if (!user) {
|
||||
mailStore.stopPolling()
|
||||
} else if (isMailVisible.value) {
|
||||
mailStore.startPolling()
|
||||
}
|
||||
})
|
||||
|
||||
const completeDrawerOpen = ref(false)
|
||||
|
||||
33
frontend/package-lock.json
generated
33
frontend/package-lock.json
generated
@@ -15,6 +15,7 @@
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@vuepic/vue-datepicker": "^12.1.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"dompurify": "^3.4.5",
|
||||
"marked": "^18.0.0",
|
||||
"nuxt": "^4.3.1",
|
||||
"nuxt-toast": "^1.4.0",
|
||||
@@ -23,6 +24,9 @@
|
||||
"vue-advanced-cropper": "^2.8.9",
|
||||
"vue-chartjs": "^5.3.3",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dompurify": "^3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
@@ -5859,6 +5863,16 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/dompurify": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
|
||||
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/trusted-types": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/esrecurse": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
||||
@@ -5905,6 +5919,13 @@
|
||||
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/web-bluetooth": {
|
||||
"version": "0.0.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
|
||||
@@ -8100,6 +8121,15 @@
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.5",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz",
|
||||
"integrity": "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
@@ -12223,7 +12253,6 @@
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
|
||||
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"orderedmap": "^2.0.0"
|
||||
}
|
||||
@@ -12244,7 +12273,6 @@
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
@@ -12278,7 +12306,6 @@
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz",
|
||||
"integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.20.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@vuepic/vue-datepicker": "^12.1.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"dompurify": "^3.4.5",
|
||||
"marked": "^18.0.0",
|
||||
"nuxt": "^4.3.1",
|
||||
"nuxt-toast": "^1.4.0",
|
||||
@@ -27,5 +28,8 @@
|
||||
"vue-advanced-cropper": "^2.8.9",
|
||||
"vue-chartjs": "^5.3.3",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dompurify": "^3.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<AdminGiteaTab v-if="activeTab === 'gitea'" />
|
||||
<AdminBookStackTab v-if="activeTab === 'bookstack'" />
|
||||
<AdminZimbraTab v-if="activeTab === 'zimbra'" />
|
||||
<AdminMailTab v-if="activeTab === 'mail'" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -48,6 +49,7 @@ const tabs = [
|
||||
{ key: 'gitea', label: 'Gitea' },
|
||||
{ key: 'bookstack', label: 'BookStack' },
|
||||
{ key: 'zimbra', label: 'Zimbra' },
|
||||
{ key: 'mail', label: 'Mail' },
|
||||
] as const
|
||||
|
||||
type TabKey = typeof tabs[number]['key']
|
||||
|
||||
168
frontend/pages/help.vue
Normal file
168
frontend/pages/help.vue
Normal file
@@ -0,0 +1,168 @@
|
||||
<script setup lang="ts">
|
||||
import { marked } from 'marked'
|
||||
|
||||
useHead({ title: 'Aide' })
|
||||
|
||||
type Section = {
|
||||
id: string
|
||||
title: string
|
||||
icon: string
|
||||
accent: string
|
||||
roles: ('admin' | 'user' | 'client')[]
|
||||
content: string
|
||||
}
|
||||
|
||||
const rawModules = import.meta.glob('~/content/help/*.md', { eager: true, query: '?raw', import: 'default' }) as Record<string, string>
|
||||
|
||||
const META: Record<string, { title: string, icon: string, accent: string, roles: ('admin' | 'user' | 'client')[] }> = {
|
||||
'01-getting-started': { title: 'Bienvenue', icon: 'mdi:hand-wave', accent: 'from-amber-400 to-rose-500', roles: ['admin', 'user', 'client'] },
|
||||
'02-projects-workflows': { title: 'Projets & Workflows', icon: 'mdi:view-column-outline', accent: 'from-indigo-500 to-fuchsia-500', roles: ['admin', 'user'] },
|
||||
'03-my-tasks': { title: 'Mes tâches', icon: 'mdi:checkbox-marked-circle-outline', accent: 'from-sky-500 to-cyan-500', roles: ['admin', 'user'] },
|
||||
'04-time-tracking': { title: 'Time tracking', icon: 'mdi:timer-outline', accent: 'from-emerald-500 to-teal-500', roles: ['admin', 'user'] },
|
||||
'05-tasks-detail': { title: 'Tâches en détail', icon: 'mdi:file-document-edit-outline', accent: 'from-violet-500 to-purple-600', roles: ['admin', 'user'] },
|
||||
'06-client-portal': { title: 'Portal client', icon: 'mdi:account-tie-outline', accent: 'from-orange-500 to-amber-500', roles: ['admin', 'client'] },
|
||||
'07-admin': { title: 'Administration', icon: 'mdi:shield-crown-outline', accent: 'from-rose-500 to-pink-600', roles: ['admin'] },
|
||||
'08-integrations': { title: 'Intégrations', icon: 'mdi:puzzle-outline', accent: 'from-blue-500 to-indigo-500', roles: ['admin', 'user'] },
|
||||
'09-mcp-api': { title: 'Token MCP & API', icon: 'mdi:robot-outline', accent: 'from-slate-700 to-slate-900', roles: ['admin', 'user'] },
|
||||
}
|
||||
|
||||
const sections = computed<Section[]>(() => {
|
||||
return Object.entries(rawModules).map(([path, raw]) => {
|
||||
const id = path.split('/').pop()!.replace(/\.md$/, '')
|
||||
const meta = META[id] ?? { title: id, icon: 'mdi:file-document-outline', accent: 'from-neutral-500 to-neutral-700', roles: ['admin', 'user', 'client'] as ('admin' | 'user' | 'client')[] }
|
||||
return { id, ...meta, content: raw }
|
||||
}).sort((a, b) => a.id.localeCompare(b.id))
|
||||
})
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
const userRole = computed<'admin' | 'user' | 'client'>(() => {
|
||||
const roles = auth.user?.roles ?? []
|
||||
if (roles.includes('ROLE_ADMIN')) return 'admin'
|
||||
if (roles.includes('ROLE_CLIENT')) return 'client'
|
||||
return 'user'
|
||||
})
|
||||
|
||||
const visibleSections = computed(() =>
|
||||
sections.value.filter(s => s.roles.includes(userRole.value)),
|
||||
)
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const activeId = ref(visibleSections.value[0]?.id ?? '')
|
||||
|
||||
onMounted(() => {
|
||||
const hash = (route.query.section as string) ?? route.hash.replace('#', '')
|
||||
if (hash && visibleSections.value.some(s => s.id === hash)) {
|
||||
activeId.value = hash
|
||||
}
|
||||
})
|
||||
|
||||
watch(activeId, (id) => {
|
||||
router.replace({ query: { ...route.query, section: id } })
|
||||
})
|
||||
|
||||
const activeSection = computed(() => visibleSections.value.find(s => s.id === activeId.value) ?? visibleSections.value[0])
|
||||
|
||||
const renderedHtml = computed(() => {
|
||||
if (!activeSection.value) return ''
|
||||
return marked.parse(activeSection.value.content, { async: false }) as string
|
||||
})
|
||||
|
||||
const prevSection = computed(() => {
|
||||
const idx = visibleSections.value.findIndex(s => s.id === activeId.value)
|
||||
return idx > 0 ? visibleSections.value[idx - 1] : null
|
||||
})
|
||||
|
||||
const nextSection = computed(() => {
|
||||
const idx = visibleSections.value.findIndex(s => s.id === activeId.value)
|
||||
return idx >= 0 && idx < visibleSections.value.length - 1 ? visibleSections.value[idx + 1] : null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-[calc(100vh-60px)] flex-col lg:flex-row">
|
||||
<!-- Sidebar -->
|
||||
<aside class="shrink-0 border-b border-neutral-200 bg-gradient-to-b from-white to-neutral-50 px-3 py-4 lg:w-72 lg:border-b-0 lg:border-r lg:px-4 lg:py-6">
|
||||
<div class="mb-4 flex items-center gap-2 lg:mb-6">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-primary-500 to-primary-700 text-white shadow-sm">
|
||||
<Icon name="mdi:lifebuoy" size="20" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-base font-bold text-neutral-900">Centre d'aide</h1>
|
||||
<p class="text-xs text-neutral-500">Lesstime — Guide utilisateur</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="flex flex-row gap-1 overflow-x-auto pb-1 lg:flex-col lg:overflow-visible lg:pb-0">
|
||||
<button
|
||||
v-for="section in visibleSections"
|
||||
:key="section.id"
|
||||
type="button"
|
||||
class="group flex shrink-0 items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm font-medium transition-all lg:shrink"
|
||||
:class="activeId === section.id
|
||||
? 'bg-white text-neutral-900 shadow-sm ring-1 ring-neutral-200'
|
||||
: 'text-neutral-600 hover:bg-white hover:text-neutral-900'"
|
||||
@click="activeId = section.id"
|
||||
>
|
||||
<span
|
||||
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br text-white shadow-sm"
|
||||
:class="section.accent"
|
||||
>
|
||||
<Icon :name="section.icon" size="16" />
|
||||
</span>
|
||||
<span class="whitespace-nowrap lg:whitespace-normal">{{ section.title }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- Content -->
|
||||
<main class="flex-1 px-4 py-6 sm:px-8 lg:px-12 lg:py-10">
|
||||
<div v-if="activeSection" class="mx-auto max-w-3xl">
|
||||
<!-- Hero header -->
|
||||
<div
|
||||
class="mb-8 overflow-hidden rounded-2xl bg-gradient-to-br p-6 text-white shadow-lg sm:p-8"
|
||||
:class="activeSection.accent"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-white/20 backdrop-blur-sm">
|
||||
<Icon :name="activeSection.icon" size="28" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-white/80">Section</p>
|
||||
<h2 class="text-2xl font-bold tracking-tight sm:text-3xl">{{ activeSection.title }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Markdown content -->
|
||||
<article
|
||||
class="prose prose-neutral max-w-none prose-headings:font-bold prose-headings:tracking-tight prose-h1:hidden prose-h2:mt-10 prose-h2:border-b prose-h2:border-neutral-200 prose-h2:pb-2 prose-h3:text-neutral-800 prose-a:text-primary-600 prose-strong:text-neutral-900 prose-code:rounded prose-code:bg-neutral-100 prose-code:px-1.5 prose-code:py-0.5 prose-code:text-sm prose-code:font-medium prose-code:text-rose-600 prose-code:before:content-none prose-code:after:content-none prose-pre:rounded-xl prose-pre:bg-slate-900 prose-table:border prose-table:border-neutral-200 prose-th:bg-neutral-50 prose-th:px-3 prose-th:py-2 prose-td:px-3 prose-td:py-2 prose-blockquote:rounded-r-lg prose-blockquote:border-l-4 prose-blockquote:border-amber-400 prose-blockquote:bg-amber-50 prose-blockquote:px-4 prose-blockquote:py-2 prose-blockquote:not-italic prose-blockquote:text-amber-900"
|
||||
v-html="renderedHtml"
|
||||
/>
|
||||
|
||||
<!-- Footer nav -->
|
||||
<div class="mt-12 flex items-center justify-between border-t border-neutral-200 pt-6">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-neutral-900 disabled:invisible"
|
||||
:disabled="!prevSection"
|
||||
@click="prevSection && (activeId = prevSection.id)"
|
||||
>
|
||||
<Icon name="mdi:arrow-left" size="18" />
|
||||
<span>{{ prevSection?.title ?? '' }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-neutral-900 disabled:invisible"
|
||||
:disabled="!nextSection"
|
||||
@click="nextSection && (activeId = nextSection.id)"
|
||||
>
|
||||
<span>{{ nextSection?.title ?? '' }}</span>
|
||||
<Icon name="mdi:arrow-right" size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
182
frontend/pages/mail.vue
Normal file
182
frontend/pages/mail.vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import { useMailStore } from '~/stores/mail'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
|
||||
useHead({ title: t('mail.title') })
|
||||
|
||||
// ─── Contrôle d'accès ROLE_CLIENT ─────────────────────────────────────────
|
||||
// Le middleware global gère auth + ROLE_CLIENT → /portal. Ici : double check
|
||||
// en SPA car la session peut être hydratée après le rendu initial.
|
||||
|
||||
const isClientOnly = computed(() =>
|
||||
auth.user?.roles?.includes('ROLE_CLIENT') === true
|
||||
&& auth.user?.roles?.includes('ROLE_ADMIN') !== true,
|
||||
)
|
||||
|
||||
if (isClientOnly.value) {
|
||||
await navigateTo('/portal')
|
||||
}
|
||||
|
||||
// ─── Store ────────────────────────────────────────────────────────────────
|
||||
|
||||
const store = useMailStore()
|
||||
const {
|
||||
folderTree,
|
||||
selectedFolderPath,
|
||||
messages,
|
||||
messagesLoading,
|
||||
hasMoreMessages,
|
||||
selectedMessageId,
|
||||
selectedMessageDetail,
|
||||
detailLoading,
|
||||
} = storeToRefs(store)
|
||||
|
||||
// ─── Init : charge les dossiers + deep-link ───────────────────────────────
|
||||
|
||||
onMounted(async () => {
|
||||
if (isClientOnly.value) {
|
||||
router.replace('/portal')
|
||||
return
|
||||
}
|
||||
|
||||
if (folderTree.value.length === 0) {
|
||||
await store.fetchFolders()
|
||||
}
|
||||
|
||||
if (!selectedFolderPath.value && folderTree.value.length > 0) {
|
||||
const inbox = folderTree.value.find((f) => f.path.toUpperCase() === 'INBOX')
|
||||
const first = folderTree.value[0]
|
||||
const target = inbox?.path ?? first?.path
|
||||
if (target) {
|
||||
await store.selectFolder(target)
|
||||
}
|
||||
}
|
||||
|
||||
const messageIdParam = route.query.messageId
|
||||
if (messageIdParam) {
|
||||
const id = parseInt(String(messageIdParam), 10)
|
||||
if (!isNaN(id)) {
|
||||
await store.selectMessage(id)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Handlers ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleFolderSelect(path: string): Promise<void> {
|
||||
await store.selectFolder(path)
|
||||
if (route.query.messageId) {
|
||||
const nextQuery = { ...route.query }
|
||||
delete nextQuery.messageId
|
||||
router.replace({ query: nextQuery })
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMessageSelect(id: number): Promise<void> {
|
||||
await store.selectMessage(id)
|
||||
}
|
||||
|
||||
function handleLoadMore(): void {
|
||||
store.fetchMessages(true)
|
||||
}
|
||||
|
||||
// ─── Modals Phase 6 ────────────────────────────────────────────────────────
|
||||
|
||||
const showCreateTaskModal = ref(false)
|
||||
const showLinkTaskModal = ref(false)
|
||||
const activeMailIdForModal = ref<number | null>(null)
|
||||
|
||||
function handleCreateTask(mailId: number): void {
|
||||
activeMailIdForModal.value = mailId
|
||||
showCreateTaskModal.value = true
|
||||
}
|
||||
|
||||
function handleLinkTask(mailId: number): void {
|
||||
activeMailIdForModal.value = mailId
|
||||
showLinkTaskModal.value = true
|
||||
}
|
||||
|
||||
function handleTaskCreated(_task: Task): void {
|
||||
showCreateTaskModal.value = false
|
||||
// La tâche est créée et liée côté backend — toast géré par useMailService.createTaskFromMail
|
||||
}
|
||||
|
||||
function handleTaskLinked(_taskId: number): void {
|
||||
showLinkTaskModal.value = false
|
||||
// Toast géré par useMailService.linkTask
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col overflow-hidden">
|
||||
<div class="flex flex-shrink-0 items-center justify-between border-b border-neutral-200 bg-white px-4 py-3">
|
||||
<h1 class="text-lg font-semibold text-neutral-900">
|
||||
{{ t('mail.title') }}
|
||||
</h1>
|
||||
<MailRefreshButton />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<aside class="w-[220px] flex-shrink-0 overflow-y-auto border-r border-neutral-200 bg-neutral-50 py-2">
|
||||
<p class="mb-1 px-3 text-xs font-semibold uppercase tracking-wide text-neutral-400">
|
||||
{{ t('mail.folders') }}
|
||||
</p>
|
||||
<MailFolderTree
|
||||
:folders="folderTree"
|
||||
:selected-path="selectedFolderPath"
|
||||
@select="handleFolderSelect"
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<div class="flex w-[320px] flex-shrink-0 flex-col overflow-hidden border-r border-neutral-200 bg-white">
|
||||
<div class="flex flex-shrink-0 items-center justify-between border-b border-neutral-100 px-3 py-2">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-neutral-400">
|
||||
{{ t('mail.messages') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<MailMessageList
|
||||
:messages="messages"
|
||||
:selected-id="selectedMessageId"
|
||||
:loading="messagesLoading"
|
||||
:has-more="hasMoreMessages"
|
||||
@select="handleMessageSelect"
|
||||
@load-more="handleLoadMore"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-hidden bg-white">
|
||||
<MailMessageViewer
|
||||
:detail="selectedMessageDetail"
|
||||
:loading="detailLoading"
|
||||
@create-task="handleCreateTask"
|
||||
@link-task="handleLinkTask"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal créer tâche depuis mail -->
|
||||
<MailCreateTaskModal
|
||||
v-if="activeMailIdForModal !== null"
|
||||
v-model="showCreateTaskModal"
|
||||
:message-id="activeMailIdForModal"
|
||||
:message-detail="selectedMessageDetail"
|
||||
@created="handleTaskCreated"
|
||||
/>
|
||||
|
||||
<!-- Modal lier mail à tâche -->
|
||||
<MailLinkTaskModal
|
||||
v-if="activeMailIdForModal !== null"
|
||||
v-model="showLinkTaskModal"
|
||||
:message-id="activeMailIdForModal"
|
||||
@linked="handleTaskLinked"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
121
frontend/services/dto/mail.ts
Normal file
121
frontend/services/dto/mail.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
// Lecture de la configuration mail (singleton admin)
|
||||
export type MailConfigurationDto = {
|
||||
protocol: string | null
|
||||
imapHost: string | null
|
||||
imapPort: number | null
|
||||
imapEncryption: string | null
|
||||
smtpHost: string | null
|
||||
smtpPort: number | null
|
||||
smtpEncryption: string | null
|
||||
username: string | null
|
||||
sentFolderPath: string | null
|
||||
enabled: boolean
|
||||
hasPassword: boolean
|
||||
// password JAMAIS présent dans les réponses GET
|
||||
}
|
||||
|
||||
// Input PATCH configuration (password optionnel, write-only)
|
||||
export type MailConfigurationUpdateDto = {
|
||||
protocol?: string | null
|
||||
imapHost?: string | null
|
||||
imapPort?: number | null
|
||||
imapEncryption?: string | null
|
||||
smtpHost?: string | null
|
||||
smtpPort?: number | null
|
||||
smtpEncryption?: string | null
|
||||
username?: string | null
|
||||
sentFolderPath?: string | null
|
||||
enabled?: boolean
|
||||
password?: string // write-only, jamais retourné
|
||||
}
|
||||
|
||||
// Résultat du test de connexion
|
||||
export type MailTestConnectionResultDto = {
|
||||
ok: boolean
|
||||
foldersCount?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
// Dossier mail (peut être imbriqué)
|
||||
export type MailFolderDto = {
|
||||
path: string
|
||||
displayName: string
|
||||
parentPath: string | null
|
||||
unreadCount: number
|
||||
totalCount: number
|
||||
children?: MailFolderDto[]
|
||||
}
|
||||
|
||||
// Adresse mail (nom + email)
|
||||
export type MailAddressDto = {
|
||||
name: string | null
|
||||
email: string
|
||||
}
|
||||
|
||||
// En-tête d'un message (liste)
|
||||
export type MailMessageHeaderDto = {
|
||||
id: number
|
||||
messageId: string // identifiant IMAP unique
|
||||
folderPath: string
|
||||
subject: string | null
|
||||
fromName: string | null
|
||||
fromEmail: string | null
|
||||
toRecipients: MailAddressDto[]
|
||||
ccRecipients: MailAddressDto[]
|
||||
sentAt: string | null // ISO 8601
|
||||
receivedAt: string // ISO 8601
|
||||
isRead: boolean
|
||||
isFlagged: boolean
|
||||
hasAttachments: boolean
|
||||
linkedTaskIds: number[]
|
||||
}
|
||||
|
||||
// Pièce jointe (métadonnées uniquement, téléchargement via downloadId)
|
||||
export type MailAttachmentDto = {
|
||||
downloadId: string
|
||||
filename: string
|
||||
mimeType: string
|
||||
size: number // octets
|
||||
}
|
||||
|
||||
// Détail complet d'un message (enrichi avec body + PJ)
|
||||
export type MailMessageDetailDto = {
|
||||
header: MailMessageHeaderDto
|
||||
bodyHtml: string | null // HTML brut — TOUJOURS passer par sanitizeMailHtml() avant affichage
|
||||
bodyText: string | null // Fallback texte plain
|
||||
attachments: MailAttachmentDto[]
|
||||
}
|
||||
|
||||
// Page de messages paginée (cursor-based)
|
||||
export type MailMessagesPageDto = {
|
||||
items: MailMessageHeaderDto[]
|
||||
nextCursor: string | null // null = plus de page suivante
|
||||
total: number
|
||||
}
|
||||
|
||||
// Input : marquer lu/non-lu
|
||||
export type MailMessageReadInput = {
|
||||
read: boolean
|
||||
}
|
||||
|
||||
// Input : marquer étoilé/non-étoilé
|
||||
export type MailMessageFlagInput = {
|
||||
flagged: boolean
|
||||
}
|
||||
|
||||
// Input : créer une tâche depuis un mail
|
||||
export type MailCreateTaskInput = {
|
||||
projectId: number
|
||||
taskGroupId?: number | null
|
||||
priority?: string | null
|
||||
}
|
||||
|
||||
// Input : lier une tâche existante à un mail
|
||||
export type MailLinkTaskInput = {
|
||||
taskId: number
|
||||
}
|
||||
|
||||
// Résultat de la sync manuelle
|
||||
export type MailSyncResultDto = {
|
||||
dispatched: boolean
|
||||
}
|
||||
276
frontend/services/mail.ts
Normal file
276
frontend/services/mail.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import type {
|
||||
MailConfigurationDto,
|
||||
MailConfigurationUpdateDto,
|
||||
MailTestConnectionResultDto,
|
||||
MailFolderDto,
|
||||
MailMessageHeaderDto,
|
||||
MailMessageDetailDto,
|
||||
MailMessagesPageDto,
|
||||
MailMessageReadInput,
|
||||
MailMessageFlagInput,
|
||||
MailCreateTaskInput,
|
||||
MailLinkTaskInput,
|
||||
MailSyncResultDto,
|
||||
} from './dto/mail'
|
||||
import type { Task } from './dto/task'
|
||||
|
||||
type BackendMailMessage = {
|
||||
id: number
|
||||
messageId: string
|
||||
uid: number
|
||||
folderPath?: string
|
||||
subject: string | null
|
||||
fromAddress: string | null
|
||||
fromName: string | null
|
||||
toAddresses: string[] | null
|
||||
ccAddresses: string[] | null
|
||||
sentAt: string | null
|
||||
isRead: boolean
|
||||
isFlagged: boolean
|
||||
hasAttachments: boolean
|
||||
snippet?: string | null
|
||||
linkedTaskIds?: number[]
|
||||
}
|
||||
|
||||
function toAddressList(values: string[] | null | undefined): { email: string; name: string | null }[] {
|
||||
return (values ?? []).map((email) => ({ email, name: null }))
|
||||
}
|
||||
|
||||
function mapHeader(m: BackendMailMessage, fallbackFolderPath = ''): MailMessageHeaderDto {
|
||||
return {
|
||||
id: m.id,
|
||||
messageId: m.messageId,
|
||||
folderPath: m.folderPath ?? fallbackFolderPath,
|
||||
subject: m.subject,
|
||||
fromName: m.fromName,
|
||||
fromEmail: m.fromAddress,
|
||||
toRecipients: toAddressList(m.toAddresses),
|
||||
ccRecipients: toAddressList(m.ccAddresses),
|
||||
sentAt: m.sentAt,
|
||||
receivedAt: m.sentAt ?? '',
|
||||
isRead: m.isRead,
|
||||
isFlagged: m.isFlagged,
|
||||
hasAttachments: m.hasAttachments,
|
||||
linkedTaskIds: m.linkedTaskIds ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
export function useMailService() {
|
||||
const api = useApi()
|
||||
|
||||
// ─── Configuration (Admin) ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Récupère la configuration mail singleton.
|
||||
* Requiert ROLE_ADMIN — 403 sinon.
|
||||
*/
|
||||
async function getConfiguration(): Promise<MailConfigurationDto> {
|
||||
return api.get<MailConfigurationDto>('/mail/configuration')
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour la configuration mail (PATCH merge).
|
||||
* Si payload.password est fourni, il sera chiffré côté backend.
|
||||
* Jamais retourné en clair dans la réponse.
|
||||
*/
|
||||
async function updateConfiguration(
|
||||
payload: MailConfigurationUpdateDto,
|
||||
): Promise<MailConfigurationDto> {
|
||||
return api.patch<MailConfigurationDto>(
|
||||
'/mail/configuration',
|
||||
payload as Record<string, unknown>,
|
||||
{ toastSuccessKey: 'mail.configuration.saved' },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Teste la connexion IMAP avec la configuration actuelle.
|
||||
* Requiert ROLE_ADMIN.
|
||||
*/
|
||||
async function testConfiguration(): Promise<MailTestConnectionResultDto> {
|
||||
return api.post<MailTestConnectionResultDto>('/mail/configuration/test', {})
|
||||
}
|
||||
|
||||
// ─── Dossiers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Liste tous les dossiers mail depuis la base (cache BDD, pas live IMAP).
|
||||
* Retourne une liste plate — la construction de l'arbre est faite dans le store
|
||||
* via le getter `folderTree`.
|
||||
*/
|
||||
async function listFolders(): Promise<MailFolderDto[]> {
|
||||
return api.get<MailFolderDto[]>('/mail/folders')
|
||||
}
|
||||
|
||||
// ─── Messages ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Liste les messages d'un dossier, paginés par cursor.
|
||||
* @param folderPath - Chemin du dossier (ex: "INBOX", "INBOX.Sent")
|
||||
* @param cursor - Opaque cursor retourné par la page précédente (undefined = première page)
|
||||
* @param limit - Nombre de messages par page (défaut backend : 50)
|
||||
*/
|
||||
async function listMessages(
|
||||
folderPath: string,
|
||||
cursor?: string,
|
||||
limit?: number,
|
||||
): Promise<MailMessagesPageDto> {
|
||||
const query: Record<string, unknown> = {}
|
||||
if (cursor) query.cursor = cursor
|
||||
if (limit) query.limit = limit
|
||||
const path = `/mail/folders/${encodeURIComponent(folderPath)}/messages`
|
||||
const response = await api.get<{ messages: BackendMailMessage[]; nextCursor: string | null }>(
|
||||
path,
|
||||
query,
|
||||
)
|
||||
return {
|
||||
items: response.messages.map((m) => mapHeader(m, folderPath)),
|
||||
nextCursor: response.nextCursor,
|
||||
total: response.messages.length,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère le détail complet d'un message (body live IMAP, cached 5 min).
|
||||
* @param id - ID BDD du message (MailMessage.id)
|
||||
*/
|
||||
async function getMessage(id: number): Promise<MailMessageDetailDto> {
|
||||
const response = await api.get<
|
||||
BackendMailMessage & {
|
||||
bodyHtml: string | null
|
||||
bodyText: string | null
|
||||
attachments: MailMessageDetailDto['attachments']
|
||||
}
|
||||
>(`/mail/messages/${id}`)
|
||||
return {
|
||||
header: mapHeader(response),
|
||||
bodyHtml: response.bodyHtml,
|
||||
bodyText: response.bodyText,
|
||||
attachments: response.attachments,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Actions sur les messages ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Marque un message comme lu ou non-lu.
|
||||
*/
|
||||
async function markRead(id: number, read: boolean): Promise<MailMessageHeaderDto> {
|
||||
const payload: MailMessageReadInput = { read }
|
||||
return api.post<MailMessageHeaderDto>(
|
||||
`/mail/messages/${id}/read`,
|
||||
payload as unknown as Record<string, unknown>,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Marque un message comme étoilé ou non-étoilé.
|
||||
*/
|
||||
async function markFlagged(id: number, flagged: boolean): Promise<MailMessageHeaderDto> {
|
||||
const payload: MailMessageFlagInput = { flagged }
|
||||
return api.post<MailMessageHeaderDto>(
|
||||
`/mail/messages/${id}/flag`,
|
||||
payload as unknown as Record<string, unknown>,
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Intégration tâches ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Crée une nouvelle tâche à partir d'un mail (subject → titre, body → description).
|
||||
* @param mailId - ID BDD du message
|
||||
* @param input - Paramètres de la tâche à créer
|
||||
*/
|
||||
async function createTaskFromMail(
|
||||
mailId: number,
|
||||
input: MailCreateTaskInput,
|
||||
): Promise<Task> {
|
||||
return api.post<Task>(
|
||||
`/mail/messages/${mailId}/create-task`,
|
||||
input as unknown as Record<string, unknown>,
|
||||
{ toastSuccessKey: 'mail.task.created' },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lie un mail à une tâche existante.
|
||||
* @param mailId - ID BDD du message
|
||||
* @param taskId - ID de la tâche existante
|
||||
*/
|
||||
async function linkTask(mailId: number, taskId: number): Promise<void> {
|
||||
const payload: MailLinkTaskInput = { taskId }
|
||||
await api.post<void>(
|
||||
`/mail/messages/${mailId}/link-task`,
|
||||
payload as unknown as Record<string, unknown>,
|
||||
{ toastSuccessKey: 'mail.task.linked' },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime le lien entre un mail et une tâche.
|
||||
* @param mailId - ID BDD du message
|
||||
* @param taskId - ID de la tâche
|
||||
*/
|
||||
async function unlinkTask(mailId: number, taskId: number): Promise<void> {
|
||||
await api.delete<void>(`/mail/messages/${mailId}/link-task/${taskId}`, {}, {
|
||||
toastSuccessKey: 'mail.task.unlinked',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste les mails liés à une tâche (pour l'onglet "Mails" du TaskDrawer — Phase 6).
|
||||
* @param taskId - ID de la tâche
|
||||
*/
|
||||
async function listMailsForTask(taskId: number): Promise<MailMessageHeaderDto[]> {
|
||||
return api.get<MailMessageHeaderDto[]>(`/tasks/${taskId}/mails`)
|
||||
}
|
||||
|
||||
// ─── Pièces jointes ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Télécharge une pièce jointe et retourne le Blob + headers.
|
||||
* Content-Disposition: attachment est géré côté backend (jamais inline).
|
||||
* @param downloadId - Identifiant opaque retourné dans MailAttachmentDto.downloadId
|
||||
*/
|
||||
async function downloadAttachment(
|
||||
downloadId: string,
|
||||
): Promise<{ data: Blob; headers: Headers }> {
|
||||
return api.getBlob(`/mail/attachments/${downloadId}`)
|
||||
}
|
||||
|
||||
// ─── Synchronisation ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Déclenche une synchronisation IMAP asynchrone via Symfony Messenger.
|
||||
* Retourne immédiatement ({ dispatched: true }) — la sync se fait en arrière-plan.
|
||||
*/
|
||||
async function triggerSync(): Promise<MailSyncResultDto> {
|
||||
return api.post<MailSyncResultDto>('/mail/sync', {}, {
|
||||
toastSuccessKey: 'mail.sync.dispatched',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
// Config
|
||||
getConfiguration,
|
||||
updateConfiguration,
|
||||
testConfiguration,
|
||||
// Dossiers
|
||||
listFolders,
|
||||
// Messages
|
||||
listMessages,
|
||||
getMessage,
|
||||
// Actions
|
||||
markRead,
|
||||
markFlagged,
|
||||
// Tâches
|
||||
createTaskFromMail,
|
||||
linkTask,
|
||||
unlinkTask,
|
||||
listMailsForTask,
|
||||
// Pièces jointes
|
||||
downloadAttachment,
|
||||
// Sync
|
||||
triggerSync,
|
||||
}
|
||||
}
|
||||
332
frontend/stores/mail.ts
Normal file
332
frontend/stores/mail.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import type {
|
||||
MailFolderDto,
|
||||
MailMessageHeaderDto,
|
||||
MailMessageDetailDto,
|
||||
} from '~/services/dto/mail'
|
||||
import { useMailService } from '~/services/mail'
|
||||
|
||||
const POLL_INTERVAL_MS = 30 * 1000 // 30 secondes
|
||||
|
||||
export const useMailStore = defineStore('mail', () => {
|
||||
// ─── State ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Liste plate des dossiers (reçue de l'API) */
|
||||
const folders = ref<MailFolderDto[]>([])
|
||||
|
||||
/** Chemin du dossier actuellement sélectionné */
|
||||
const selectedFolderPath = ref<string | null>(null)
|
||||
|
||||
/** Messages du dossier sélectionné (accumulés pour infinite scroll) */
|
||||
const messages = ref<MailMessageHeaderDto[]>([])
|
||||
|
||||
/** Cursor de pagination pour la page suivante (null = plus de données) */
|
||||
const messagesCursor = ref<string | null>(null)
|
||||
|
||||
/** Chargement en cours (messages) */
|
||||
const messagesLoading = ref(false)
|
||||
|
||||
/** ID du message sélectionné pour lecture */
|
||||
const selectedMessageId = ref<number | null>(null)
|
||||
|
||||
/** Détail complet du message sélectionné (body + PJ) */
|
||||
const selectedMessageDetail = ref<MailMessageDetailDto | null>(null)
|
||||
|
||||
/** Chargement du détail en cours */
|
||||
const detailLoading = ref(false)
|
||||
|
||||
/** Sync IMAP en cours (déclenchée manuellement) */
|
||||
const syncing = ref(false)
|
||||
|
||||
/** Nombre total de messages non lus (toutes boîtes confondues) */
|
||||
const globalUnreadCount = ref(0)
|
||||
|
||||
/** Erreur courante (null si aucune) */
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// ─── Getters ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Nombre de non-lus dans INBOX uniquement (utilisé dans la sidebar).
|
||||
*/
|
||||
const inboxUnread = computed(() => {
|
||||
const inbox = folders.value.find(
|
||||
(f) => f.path === 'INBOX' || f.path.toUpperCase() === 'INBOX',
|
||||
)
|
||||
return inbox?.unreadCount ?? 0
|
||||
})
|
||||
|
||||
/**
|
||||
* Construit l'arbre de dossiers depuis la liste plate.
|
||||
* Les dossiers sans parentPath sont à la racine.
|
||||
* Les enfants sont triés alphabétiquement par displayName.
|
||||
*/
|
||||
const folderTree = computed((): MailFolderDto[] => {
|
||||
const map = new Map<string, MailFolderDto>()
|
||||
const roots: MailFolderDto[] = []
|
||||
|
||||
// Initialiser chaque dossier avec children vide
|
||||
folders.value.forEach((folder) => {
|
||||
map.set(folder.path, { ...folder, children: [] })
|
||||
})
|
||||
|
||||
// Construire l'arbre
|
||||
map.forEach((folder) => {
|
||||
if (folder.parentPath && map.has(folder.parentPath)) {
|
||||
const parent = map.get(folder.parentPath)!
|
||||
parent.children = parent.children ?? []
|
||||
parent.children.push(folder)
|
||||
} else {
|
||||
roots.push(folder)
|
||||
}
|
||||
})
|
||||
|
||||
// Trier les enfants alphabétiquement
|
||||
function sortChildren(nodes: MailFolderDto[]): MailFolderDto[] {
|
||||
return nodes
|
||||
.map((n) => ({
|
||||
...n,
|
||||
children: n.children ? sortChildren(n.children) : undefined,
|
||||
}))
|
||||
.sort((a, b) => a.displayName.localeCompare(b.displayName, 'fr'))
|
||||
}
|
||||
|
||||
return sortChildren(roots)
|
||||
})
|
||||
|
||||
/**
|
||||
* Indique si le cursor de pagination est disponible (plus de messages à charger).
|
||||
*/
|
||||
const hasMoreMessages = computed(() => messagesCursor.value !== null)
|
||||
|
||||
// ─── Actions ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Charge la liste des dossiers depuis l'API et met à jour globalUnreadCount.
|
||||
*/
|
||||
async function fetchFolders(): Promise<void> {
|
||||
const service = useMailService()
|
||||
try {
|
||||
folders.value = await service.listFolders()
|
||||
globalUnreadCount.value = folders.value.reduce(
|
||||
(sum, f) => sum + f.unreadCount,
|
||||
0,
|
||||
)
|
||||
} catch {
|
||||
// Silently ignore polling errors (ne pas interrompre l'UX)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge les messages du dossier sélectionné.
|
||||
* @param append - Si true, ajoute à la liste existante (infinite scroll). Si false, remplace.
|
||||
*/
|
||||
async function fetchMessages(append = false): Promise<void> {
|
||||
if (!selectedFolderPath.value) return
|
||||
if (messagesLoading.value) return
|
||||
|
||||
messagesLoading.value = true
|
||||
error.value = null
|
||||
|
||||
const service = useMailService()
|
||||
try {
|
||||
const cursor = append ? (messagesCursor.value ?? undefined) : undefined
|
||||
const page = await service.listMessages(selectedFolderPath.value, cursor)
|
||||
|
||||
if (append) {
|
||||
messages.value = [...messages.value, ...page.items]
|
||||
} else {
|
||||
messages.value = page.items
|
||||
}
|
||||
messagesCursor.value = page.nextCursor
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Erreur lors du chargement des messages.'
|
||||
} finally {
|
||||
messagesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sélectionne un dossier et charge ses messages (reset de la pagination).
|
||||
* @param path - Chemin du dossier (ex: "INBOX")
|
||||
*/
|
||||
async function selectFolder(path: string): Promise<void> {
|
||||
if (selectedFolderPath.value === path) return
|
||||
selectedFolderPath.value = path
|
||||
messages.value = []
|
||||
messagesCursor.value = null
|
||||
selectedMessageId.value = null
|
||||
selectedMessageDetail.value = null
|
||||
await fetchMessages()
|
||||
}
|
||||
|
||||
/**
|
||||
* Marque un message comme lu ou non-lu.
|
||||
* Met à jour le state local (messages + detail) sans refetch.
|
||||
*/
|
||||
async function markRead(id: number, read: boolean): Promise<void> {
|
||||
const service = useMailService()
|
||||
const updated = await service.markRead(id, read)
|
||||
|
||||
// Mise à jour optimiste dans la liste
|
||||
const idx = messages.value.findIndex((m) => m.id === id)
|
||||
if (idx !== -1) {
|
||||
messages.value[idx] = { ...messages.value[idx], isRead: updated.isRead }
|
||||
}
|
||||
|
||||
// Mise à jour dans le détail si ouvert
|
||||
if (selectedMessageDetail.value?.header.id === id) {
|
||||
selectedMessageDetail.value = {
|
||||
...selectedMessageDetail.value,
|
||||
header: { ...selectedMessageDetail.value.header, isRead: updated.isRead },
|
||||
}
|
||||
}
|
||||
|
||||
// Mettre à jour le compteur du dossier
|
||||
await _refreshFolderUnreadCount()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sélectionne un message et charge son détail complet (body + PJ).
|
||||
* Marque automatiquement le message comme lu si ce n'est pas déjà le cas.
|
||||
* @param id - ID BDD du message
|
||||
*/
|
||||
async function selectMessage(id: number): Promise<void> {
|
||||
if (selectedMessageId.value === id) return
|
||||
selectedMessageId.value = id
|
||||
selectedMessageDetail.value = null
|
||||
detailLoading.value = true
|
||||
|
||||
const service = useMailService()
|
||||
try {
|
||||
const detail = await service.getMessage(id)
|
||||
selectedMessageDetail.value = detail
|
||||
|
||||
// Auto-mark as read si nécessaire
|
||||
if (!detail.header.isRead) {
|
||||
await markRead(id, true)
|
||||
}
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marque un message comme étoilé ou non-étoilé.
|
||||
* Met à jour le state local sans refetch.
|
||||
*/
|
||||
async function markFlagged(id: number, flagged: boolean): Promise<void> {
|
||||
const service = useMailService()
|
||||
const updated = await service.markFlagged(id, flagged)
|
||||
|
||||
const idx = messages.value.findIndex((m) => m.id === id)
|
||||
if (idx !== -1) {
|
||||
messages.value[idx] = { ...messages.value[idx], isFlagged: updated.isFlagged }
|
||||
}
|
||||
|
||||
if (selectedMessageDetail.value?.header.id === id) {
|
||||
selectedMessageDetail.value = {
|
||||
...selectedMessageDetail.value,
|
||||
header: { ...selectedMessageDetail.value.header, isFlagged: updated.isFlagged },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Déclenche une synchronisation IMAP asynchrone.
|
||||
* Recharge les dossiers après 2s pour refléter les nouveaux messages.
|
||||
*/
|
||||
async function triggerSync(): Promise<void> {
|
||||
if (syncing.value) return
|
||||
syncing.value = true
|
||||
const service = useMailService()
|
||||
try {
|
||||
await service.triggerSync()
|
||||
// Laisser le temps au handler Messenger de traiter
|
||||
setTimeout(async () => {
|
||||
await fetchFolders()
|
||||
if (selectedFolderPath.value) {
|
||||
await fetchMessages(false)
|
||||
}
|
||||
syncing.value = false
|
||||
}, 2000)
|
||||
} catch {
|
||||
syncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrête le polling. À appeler au logout.
|
||||
*/
|
||||
function stopPolling(): void {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Démarre le polling toutes les 30s pour mettre à jour globalUnreadCount.
|
||||
* À appeler dans app.vue ou le layout default au login.
|
||||
* Idempotent : un seul timer actif à la fois.
|
||||
*/
|
||||
function startPolling(): void {
|
||||
if (pollTimer) return
|
||||
fetchFolders() // Charge immédiatement
|
||||
pollTimer = setInterval(fetchFolders, POLL_INTERVAL_MS)
|
||||
|
||||
// Cleanup automatique si le scope du store est détruit
|
||||
if (getCurrentScope()) {
|
||||
onScopeDispose(stopPolling)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rafraîchit les compteurs non-lus du dossier actuel depuis l'API.
|
||||
* Usage interne — appelé après markRead.
|
||||
*/
|
||||
async function _refreshFolderUnreadCount(): Promise<void> {
|
||||
const service = useMailService()
|
||||
try {
|
||||
const updatedFolders = await service.listFolders()
|
||||
folders.value = updatedFolders
|
||||
globalUnreadCount.value = updatedFolders.reduce(
|
||||
(sum, f) => sum + f.unreadCount,
|
||||
0,
|
||||
)
|
||||
} catch {
|
||||
// Silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State (readonly pour les consommateurs)
|
||||
folders: readonly(folders),
|
||||
selectedFolderPath: readonly(selectedFolderPath),
|
||||
messages: readonly(messages),
|
||||
messagesCursor: readonly(messagesCursor),
|
||||
messagesLoading: readonly(messagesLoading),
|
||||
selectedMessageId: readonly(selectedMessageId),
|
||||
selectedMessageDetail: readonly(selectedMessageDetail),
|
||||
detailLoading: readonly(detailLoading),
|
||||
syncing: readonly(syncing),
|
||||
globalUnreadCount: readonly(globalUnreadCount),
|
||||
error: readonly(error),
|
||||
// Getters
|
||||
inboxUnread,
|
||||
folderTree,
|
||||
hasMoreMessages,
|
||||
// Actions
|
||||
fetchFolders,
|
||||
selectFolder,
|
||||
fetchMessages,
|
||||
selectMessage,
|
||||
markRead,
|
||||
markFlagged,
|
||||
triggerSync,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
}
|
||||
})
|
||||
160
frontend/utils/sanitizeMailHtml.ts
Normal file
160
frontend/utils/sanitizeMailHtml.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import DOMPurify, { type Config as DOMPurifyConfig } from 'dompurify'
|
||||
|
||||
/**
|
||||
* Options de sanitization du corps HTML d'un mail.
|
||||
*/
|
||||
export type SanitizeMailHtmlOptions = {
|
||||
/**
|
||||
* Si true, les images distantes (http/https) sont affichées directement.
|
||||
* Par défaut false — les images distantes sont remplacées par un placeholder
|
||||
* cliquable pour éviter le tracking par pixel.
|
||||
*/
|
||||
allowImages?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration DOMPurify bloquante pour les corps de mail.
|
||||
* - Bloque les balises dangereuses : script, iframe, object, embed, style, link, meta, form, input
|
||||
* - Bloque les attributs événements (on*) et les URI javascript:
|
||||
* - Autorise les URI data: uniquement pour les images (PNG/JPEG/GIF/WEBP) — images inline CID
|
||||
*/
|
||||
const DOMPURIFY_CONFIG: DOMPurifyConfig = {
|
||||
FORBID_TAGS: [
|
||||
'script',
|
||||
'iframe',
|
||||
'object',
|
||||
'embed',
|
||||
'style',
|
||||
'link',
|
||||
'meta',
|
||||
'form',
|
||||
'input',
|
||||
'button',
|
||||
'textarea',
|
||||
'select',
|
||||
'base',
|
||||
'applet',
|
||||
],
|
||||
FORBID_ATTR: [
|
||||
'onerror',
|
||||
'onload',
|
||||
'onclick',
|
||||
'onmouseover',
|
||||
'onmouseout',
|
||||
'onmouseenter',
|
||||
'onmouseleave',
|
||||
'onfocus',
|
||||
'onblur',
|
||||
'onchange',
|
||||
'onsubmit',
|
||||
'onreset',
|
||||
'onkeydown',
|
||||
'onkeyup',
|
||||
'onkeypress',
|
||||
'ondblclick',
|
||||
'oncontextmenu',
|
||||
'onwheel',
|
||||
'ondrag',
|
||||
'ondrop',
|
||||
'oncopy',
|
||||
'oncut',
|
||||
'onpaste',
|
||||
'action',
|
||||
'formaction',
|
||||
'xlink:href',
|
||||
],
|
||||
ALLOWED_URI_REGEXP: /^(?:https?|mailto|tel|cid|data:image\/(?:png|jpeg|gif|webp)(?:;base64,)?)(?::|$)/i,
|
||||
FORCE_BODY: true,
|
||||
WHOLE_DOCUMENT: false,
|
||||
}
|
||||
|
||||
/**
|
||||
* Remplace les balises <img> avec src http(s):// par un bouton placeholder.
|
||||
* Le src original est stocké en data-mail-image-src pour permettre l'affichage
|
||||
* à la demande de l'utilisateur (Phase 5 — MailMessageViewer).
|
||||
*/
|
||||
function replaceRemoteImages(html: string): string {
|
||||
// Utiliser un DOMParser côté client uniquement (SSR-safe : le guard process.client
|
||||
// est géré par l'appelant dans un composant Vue — ce helper ne tourne que client-side)
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(html, 'text/html')
|
||||
const images = doc.querySelectorAll('img')
|
||||
|
||||
images.forEach((img) => {
|
||||
const src = img.getAttribute('src') ?? ''
|
||||
const isRemote = /^https?:\/\//i.test(src)
|
||||
if (!isRemote) return
|
||||
|
||||
// Remplacer par un span cliquable (pas de <button> — DOMPurify le forbid)
|
||||
const placeholder = doc.createElement('span')
|
||||
placeholder.setAttribute('data-mail-image-src', src)
|
||||
placeholder.setAttribute('data-mail-image-placeholder', 'true')
|
||||
placeholder.setAttribute('title', src)
|
||||
placeholder.style.cssText = [
|
||||
'display: inline-flex',
|
||||
'align-items: center',
|
||||
'gap: 4px',
|
||||
'padding: 2px 6px',
|
||||
'border: 1px solid #d1d5db',
|
||||
'border-radius: 4px',
|
||||
'background: #f9fafb',
|
||||
'color: #6b7280',
|
||||
'font-size: 12px',
|
||||
'cursor: pointer',
|
||||
'user-select: none',
|
||||
].join(';')
|
||||
placeholder.textContent = '[Image distante — cliquer pour afficher]'
|
||||
|
||||
img.replaceWith(placeholder)
|
||||
})
|
||||
|
||||
return doc.body.innerHTML
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize le HTML brut d'un corps de mail.
|
||||
*
|
||||
* - Bloque tous les vecteurs XSS connus (scripts, événements inline, iframes…)
|
||||
* - Par défaut, remplace les images distantes par un placeholder anti-tracking
|
||||
* - Utiliser allowImages: true uniquement si l'utilisateur a explicitement cliqué
|
||||
* "Afficher les images" dans le lecteur de mail
|
||||
*
|
||||
* IMPORTANT : Cette fonction requiert un environnement navigateur (DOMParser, DOMPurify).
|
||||
* Ne pas appeler côté SSR — toujours dans un composant Vue avec `onMounted` ou dans
|
||||
* un computed côté client uniquement (`import.meta.client`).
|
||||
*
|
||||
* @param rawHtml - HTML brut tel que reçu de l'API backend
|
||||
* @param options - Options de sanitization
|
||||
* @returns HTML sanitizé, sûr pour injection via v-html
|
||||
*/
|
||||
export function sanitizeMailHtml(
|
||||
rawHtml: string,
|
||||
options: SanitizeMailHtmlOptions = {},
|
||||
): string {
|
||||
if (!rawHtml || rawHtml.trim() === '') return ''
|
||||
|
||||
// Étape 1 : DOMPurify — supprime tous les vecteurs dangereux
|
||||
const sanitized = DOMPurify.sanitize(rawHtml, DOMPURIFY_CONFIG) as string
|
||||
|
||||
// Étape 2 : Remplacement images distantes (anti-tracking)
|
||||
if (!options.allowImages) {
|
||||
return replaceRemoteImages(sanitized)
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si un élément HTML est un placeholder d'image généré par sanitizeMailHtml.
|
||||
* Utile dans MailMessageViewer pour gérer le clic "Afficher l'image".
|
||||
*/
|
||||
export function isMailImagePlaceholder(el: HTMLElement): boolean {
|
||||
return el.hasAttribute('data-mail-image-placeholder')
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère le src original d'un placeholder d'image.
|
||||
*/
|
||||
export function getMailImageSrc(el: HTMLElement): string | null {
|
||||
return el.getAttribute('data-mail-image-src')
|
||||
}
|
||||
6
makefile
6
makefile
@@ -122,5 +122,11 @@ php-cs-fixer-allow-risky:
|
||||
test:
|
||||
$(EXEC_PHP) php -d memory_limit="512M" vendor/bin/phpunit $(FILES)
|
||||
|
||||
## Synchronise la boîte mail IMAP vers la base locale (cron OS toutes les 10 min)
|
||||
## Passer FOLDER=INBOX pour cibler un seul dossier. Ex: make mail-sync FOLDER=INBOX
|
||||
## Passer DRYRUN=1 pour simuler sans écrire. Ex: make mail-sync DRYRUN=1
|
||||
mail-sync:
|
||||
$(SYMFONY_CONSOLE) app:mail:sync $(if $(FOLDER),--folder=$(FOLDER),) $(if $(DRYRUN),--dry-run,)
|
||||
|
||||
wait:
|
||||
sleep 10
|
||||
|
||||
115
migrations/Version20260519211723.php
Normal file
115
migrations/Version20260519211723.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260519211723 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Mail integration: create mail_configuration, mail_folder, mail_message, task_mail_link tables';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE mail_configuration (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
protocol VARCHAR(10) NOT NULL,
|
||||
imap_host VARCHAR(255) DEFAULT NULL,
|
||||
imap_port INT NOT NULL,
|
||||
imap_encryption VARCHAR(10) NOT NULL,
|
||||
smtp_host VARCHAR(255) DEFAULT NULL,
|
||||
smtp_port INT NOT NULL,
|
||||
smtp_encryption VARCHAR(10) NOT NULL,
|
||||
username VARCHAR(255) DEFAULT NULL,
|
||||
encrypted_password TEXT DEFAULT NULL,
|
||||
sent_folder_path VARCHAR(255) NOT NULL,
|
||||
enabled BOOLEAN NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE mail_folder (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
path VARCHAR(500) NOT NULL,
|
||||
display_name VARCHAR(255) NOT NULL,
|
||||
parent_path VARCHAR(500) DEFAULT NULL,
|
||||
unread_count INT NOT NULL,
|
||||
total_count INT NOT NULL,
|
||||
last_synced_at TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_319BB6A6B548B0F ON mail_folder (path)');
|
||||
$this->addSql('CREATE INDEX idx_mail_folder_parent_path ON mail_folder (parent_path)');
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE mail_message (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
message_id VARCHAR(500) NOT NULL,
|
||||
folder_id INT NOT NULL,
|
||||
uid INT NOT NULL,
|
||||
subject VARCHAR(500) DEFAULT NULL,
|
||||
from_address VARCHAR(255) NOT NULL,
|
||||
from_name VARCHAR(255) DEFAULT NULL,
|
||||
to_addresses JSON NOT NULL,
|
||||
cc_addresses JSON DEFAULT NULL,
|
||||
sent_at TIMESTAMP(0) WITH TIME ZONE NOT NULL,
|
||||
is_read BOOLEAN NOT NULL,
|
||||
is_flagged BOOLEAN NOT NULL,
|
||||
has_attachments BOOLEAN NOT NULL,
|
||||
snippet TEXT DEFAULT NULL,
|
||||
synced_at TIMESTAMP(0) WITH TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_6C00B110537A1329 ON mail_message (message_id)');
|
||||
$this->addSql('CREATE UNIQUE INDEX uq_mail_message_folder_uid ON mail_message (folder_id, uid)');
|
||||
$this->addSql('CREATE INDEX IDX_6C00B110162CB942 ON mail_message (folder_id)');
|
||||
$this->addSql('CREATE INDEX idx_mail_message_sent_at ON mail_message (sent_at)');
|
||||
$this->addSql('CREATE INDEX idx_mail_message_is_read ON mail_message (is_read)');
|
||||
$this->addSql('ALTER TABLE mail_message ADD CONSTRAINT FK_6C00B110162CB942 FOREIGN KEY (folder_id) REFERENCES mail_folder (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE task_mail_link (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
task_id INT NOT NULL,
|
||||
mail_message_id INT NOT NULL,
|
||||
linked_at TIMESTAMP(0) WITH TIME ZONE NOT NULL,
|
||||
linked_by_id INT DEFAULT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql('CREATE UNIQUE INDEX uq_task_mail_link ON task_mail_link (task_id, mail_message_id)');
|
||||
$this->addSql('CREATE INDEX IDX_E4FDC7C98DB60186 ON task_mail_link (task_id)');
|
||||
$this->addSql('CREATE INDEX IDX_E4FDC7C987B9F9D5 ON task_mail_link (mail_message_id)');
|
||||
$this->addSql('CREATE INDEX IDX_E4FDC7C91AE3CFF3 ON task_mail_link (linked_by_id)');
|
||||
$this->addSql('ALTER TABLE task_mail_link ADD CONSTRAINT FK_E4FDC7C98DB60186 FOREIGN KEY (task_id) REFERENCES task (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE task_mail_link ADD CONSTRAINT FK_E4FDC7C987B9F9D5 FOREIGN KEY (mail_message_id) REFERENCES mail_message (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE task_mail_link ADD CONSTRAINT FK_E4FDC7C91AE3CFF3 FOREIGN KEY (linked_by_id) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE task_mail_link DROP CONSTRAINT FK_E4FDC7C98DB60186');
|
||||
$this->addSql('ALTER TABLE task_mail_link DROP CONSTRAINT FK_E4FDC7C987B9F9D5');
|
||||
$this->addSql('ALTER TABLE task_mail_link DROP CONSTRAINT FK_E4FDC7C91AE3CFF3');
|
||||
$this->addSql('DROP TABLE task_mail_link');
|
||||
|
||||
$this->addSql('ALTER TABLE mail_message DROP CONSTRAINT FK_6C00B110162CB942');
|
||||
$this->addSql('DROP TABLE mail_message');
|
||||
|
||||
$this->addSql('DROP TABLE mail_folder');
|
||||
|
||||
$this->addSql('DROP TABLE mail_configuration');
|
||||
}
|
||||
}
|
||||
56
migrations/Version20260519220000.php
Normal file
56
migrations/Version20260519220000.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Cree la table messenger_messages pour le transport async Symfony Messenger.
|
||||
*/
|
||||
final class Version20260519220000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Cree la table messenger_messages pour le transport async Doctrine de Symfony Messenger.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS messenger_messages (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
body TEXT NOT NULL,
|
||||
headers TEXT NOT NULL,
|
||||
queue_name VARCHAR(190) NOT NULL,
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
available_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
delivered_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS IDX_75EA56E0FB7336F0 ON messenger_messages (queue_name)');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS IDX_75EA56E0E3BD61CE ON messenger_messages (available_at)');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS IDX_75EA56E016BA31DB ON messenger_messages (delivered_at)');
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE OR REPLACE FUNCTION notify_messenger_messages() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
PERFORM pg_notify('messenger_messages', NEW.queue_name::text);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
SQL);
|
||||
|
||||
$this->addSql('DROP TRIGGER IF EXISTS notify_trigger ON messenger_messages;');
|
||||
$this->addSql('CREATE TRIGGER notify_trigger AFTER INSERT OR UPDATE ON messenger_messages FOR EACH ROW EXECUTE PROCEDURE notify_messenger_messages();');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE IF EXISTS messenger_messages');
|
||||
$this->addSql('DROP FUNCTION IF EXISTS notify_messenger_messages()');
|
||||
}
|
||||
}
|
||||
31
migrations/Version20260520061736.php
Normal file
31
migrations/Version20260520061736.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260520061736 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'mail_message.message_id: drop global UNIQUE (un même Message-ID existe dans plusieurs dossiers IMAP), conserver un index simple';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP INDEX uniq_6c00b110537a1329');
|
||||
$this->addSql('CREATE INDEX idx_mail_message_message_id ON mail_message (message_id)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP INDEX idx_mail_message_message_id');
|
||||
$this->addSql('CREATE UNIQUE INDEX uniq_6c00b110537a1329 ON mail_message (message_id)');
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,19 @@
|
||||
<ini name="error_reporting" value="-1" />
|
||||
<server name="APP_ENV" value="test" force="true" />
|
||||
<server name="SHELL_VERBOSITY" value="-1" />
|
||||
<server name="KERNEL_CLASS" value="App\Kernel" />
|
||||
|
||||
<!-- ###+ symfony/lock ### -->
|
||||
<!-- Choose one of the stores below -->
|
||||
<!-- postgresql+advisory://db_user:db_password@localhost/db_name -->
|
||||
<env name="LOCK_DSN" value="flock"/>
|
||||
<!-- ###- symfony/lock ### -->
|
||||
|
||||
<!-- ###+ symfony/messenger ### -->
|
||||
<env name="MESSENGER_TRANSPORT_DSN" value="doctrine://default?auto_setup=0"/>
|
||||
<!-- ###- symfony/messenger ### -->
|
||||
|
||||
<env name="ENCRYPTION_KEY" value="ccd250183ea853179562d458e645585f3d46ddebb0701743236196f60fc1a0b8"/>
|
||||
</php>
|
||||
|
||||
<testsuites>
|
||||
|
||||
69
src/ApiResource/MailSettings.php
Normal file
69
src/ApiResource/MailSettings.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?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;
|
||||
|
||||
#[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;
|
||||
}
|
||||
110
src/Command/MailSyncCommand.php
Normal file
110
src/Command/MailSyncCommand.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Repository\MailConfigurationRepository;
|
||||
use App\Repository\MailFolderRepository;
|
||||
use App\Service\MailSyncService;
|
||||
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 MailConfigurationRepository $configRepository,
|
||||
private readonly MailFolderRepository $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;
|
||||
}
|
||||
}
|
||||
93
src/Controller/Mail/MailAttachmentDownloadController.php
Normal file
93
src/Controller/Mail/MailAttachmentDownloadController.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?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\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());
|
||||
|
||||
$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');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
105
src/Controller/Mail/MailCreateTaskController.php
Normal file
105
src/Controller/Mail/MailCreateTaskController.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Mail;
|
||||
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Task;
|
||||
use App\Entity\TaskGroup;
|
||||
use App\Entity\TaskMailLink;
|
||||
use App\Entity\TaskPriority;
|
||||
use App\Repository\MailMessageRepository;
|
||||
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(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['priorityId']) && null !== $body['priorityId']) {
|
||||
$priority = $this->em->getRepository(TaskPriority::class)->find($body['priorityId']);
|
||||
if (null !== $priority) {
|
||||
$task->setPriority($priority);
|
||||
}
|
||||
}
|
||||
|
||||
$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);
|
||||
}
|
||||
}
|
||||
42
src/Controller/Mail/MailFoldersListController.php
Normal file
42
src/Controller/Mail/MailFoldersListController.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Mail;
|
||||
|
||||
use App\Repository\MailFolderRepository;
|
||||
use App\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 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);
|
||||
}
|
||||
}
|
||||
69
src/Controller/Mail/MailLinkTaskController.php
Normal file
69
src/Controller/Mail/MailLinkTaskController.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?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');
|
||||
}
|
||||
|
||||
$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);
|
||||
}
|
||||
}
|
||||
87
src/Controller/Mail/MailMessageDetailController.php
Normal file
87
src/Controller/Mail/MailMessageDetailController.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?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 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 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');
|
||||
}
|
||||
|
||||
$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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
53
src/Controller/Mail/MailMessageFlagController.php
Normal file
53
src/Controller/Mail/MailMessageFlagController.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?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()]);
|
||||
}
|
||||
}
|
||||
53
src/Controller/Mail/MailMessageReadController.php
Normal file
53
src/Controller/Mail/MailMessageReadController.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?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 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()]);
|
||||
}
|
||||
}
|
||||
65
src/Controller/Mail/MailMessagesListController.php
Normal file
65
src/Controller/Mail/MailMessagesListController.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Mail;
|
||||
|
||||
use App\Repository\MailFolderRepository;
|
||||
use App\Repository\MailMessageRepository;
|
||||
use App\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 MailFolderRepository $folderRepository,
|
||||
private readonly MailMessageRepository $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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
40
src/Controller/Mail/MailSyncTriggerController.php
Normal file
40
src/Controller/Mail/MailSyncTriggerController.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?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());
|
||||
|
||||
$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
|
||||
);
|
||||
}
|
||||
}
|
||||
46
src/Controller/Mail/MailTestConnectionController.php
Normal file
46
src/Controller/Mail/MailTestConnectionController.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?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 {
|
||||
$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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
54
src/Controller/Mail/MailUnlinkTaskController.php
Normal file
54
src/Controller/Mail/MailUnlinkTaskController.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
54
src/Controller/Mail/TaskMailsListController.php
Normal file
54
src/Controller/Mail/TaskMailsListController.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Mail;
|
||||
|
||||
use App\Entity\Task;
|
||||
use App\Repository\TaskMailLinkRepository;
|
||||
use App\Security\MailAccessChecker;
|
||||
use DateTimeInterface;
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace App\DataFixtures;
|
||||
|
||||
use App\Entity\Client;
|
||||
use App\Entity\ClientTicket;
|
||||
use App\Entity\MailConfiguration;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Task;
|
||||
use App\Entity\TaskEffort;
|
||||
@@ -706,6 +707,21 @@ class AppFixtures extends Fixture
|
||||
$taskRecurring->setRecurrence($recurrence);
|
||||
$manager->persist($taskRecurring);
|
||||
|
||||
// =============================================
|
||||
// Mail Configuration
|
||||
// =============================================
|
||||
$mailConfig = new MailConfiguration();
|
||||
$mailConfig->setImapHost('ssl0.ovh.net');
|
||||
$mailConfig->setImapPort(993);
|
||||
$mailConfig->setImapEncryption('ssl');
|
||||
$mailConfig->setSmtpHost('ssl0.ovh.net');
|
||||
$mailConfig->setSmtpPort(465);
|
||||
$mailConfig->setSmtpEncryption('ssl');
|
||||
$mailConfig->setUsername('lesstime@ovh.fr');
|
||||
$mailConfig->setSentFolderPath('Sent');
|
||||
$mailConfig->setEnabled(false);
|
||||
$manager->persist($mailConfig);
|
||||
|
||||
$manager->flush();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,12 +47,8 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
order: ['createdAt' => 'DESC'],
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: ClientTicketRepository::class)]
|
||||
#[ORM\Table(
|
||||
name: 'client_ticket',
|
||||
uniqueConstraints: [
|
||||
new ORM\UniqueConstraint(name: 'uniq_client_ticket_project_number', columns: ['project_id', 'number']),
|
||||
],
|
||||
)]
|
||||
#[ORM\Table(name: 'client_ticket')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_client_ticket_project_number', columns: ['project_id', 'number'])]
|
||||
class ClientTicket
|
||||
{
|
||||
public const string TYPE_BUG = 'bug';
|
||||
|
||||
193
src/Entity/MailConfiguration.php
Normal file
193
src/Entity/MailConfiguration.php
Normal file
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\MailConfigurationRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: MailConfigurationRepository::class)]
|
||||
#[ORM\Table(name: 'mail_configuration')]
|
||||
class MailConfiguration
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 10)]
|
||||
private string $protocol = 'imap';
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $imapHost = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private int $imapPort = 993;
|
||||
|
||||
#[ORM\Column(length: 10)]
|
||||
private string $imapEncryption = 'ssl';
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $smtpHost = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private int $smtpPort = 465;
|
||||
|
||||
#[ORM\Column(length: 10)]
|
||||
private string $smtpEncryption = 'ssl';
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $username = null;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $encryptedPassword = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
private string $sentFolderPath = 'Sent';
|
||||
|
||||
#[ORM\Column(type: 'boolean')]
|
||||
private bool $enabled = false;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getProtocol(): string
|
||||
{
|
||||
return $this->protocol;
|
||||
}
|
||||
|
||||
public function setProtocol(string $protocol): static
|
||||
{
|
||||
$this->protocol = $protocol;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getImapHost(): ?string
|
||||
{
|
||||
return $this->imapHost;
|
||||
}
|
||||
|
||||
public function setImapHost(?string $imapHost): static
|
||||
{
|
||||
$this->imapHost = $imapHost;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getImapPort(): int
|
||||
{
|
||||
return $this->imapPort;
|
||||
}
|
||||
|
||||
public function setImapPort(int $imapPort): static
|
||||
{
|
||||
$this->imapPort = $imapPort;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getImapEncryption(): string
|
||||
{
|
||||
return $this->imapEncryption;
|
||||
}
|
||||
|
||||
public function setImapEncryption(string $imapEncryption): static
|
||||
{
|
||||
$this->imapEncryption = $imapEncryption;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSmtpHost(): ?string
|
||||
{
|
||||
return $this->smtpHost;
|
||||
}
|
||||
|
||||
public function setSmtpHost(?string $smtpHost): static
|
||||
{
|
||||
$this->smtpHost = $smtpHost;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSmtpPort(): int
|
||||
{
|
||||
return $this->smtpPort;
|
||||
}
|
||||
|
||||
public function setSmtpPort(int $smtpPort): static
|
||||
{
|
||||
$this->smtpPort = $smtpPort;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSmtpEncryption(): string
|
||||
{
|
||||
return $this->smtpEncryption;
|
||||
}
|
||||
|
||||
public function setSmtpEncryption(string $smtpEncryption): static
|
||||
{
|
||||
$this->smtpEncryption = $smtpEncryption;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUsername(): ?string
|
||||
{
|
||||
return $this->username;
|
||||
}
|
||||
|
||||
public function setUsername(?string $username): static
|
||||
{
|
||||
$this->username = $username;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEncryptedPassword(): ?string
|
||||
{
|
||||
return $this->encryptedPassword;
|
||||
}
|
||||
|
||||
public function setEncryptedPassword(?string $encryptedPassword): static
|
||||
{
|
||||
$this->encryptedPassword = $encryptedPassword;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSentFolderPath(): string
|
||||
{
|
||||
return $this->sentFolderPath;
|
||||
}
|
||||
|
||||
public function setSentFolderPath(string $sentFolderPath): static
|
||||
{
|
||||
$this->sentFolderPath = $sentFolderPath;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
public function setEnabled(bool $enabled): static
|
||||
{
|
||||
$this->enabled = $enabled;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function hasPassword(): bool
|
||||
{
|
||||
return null !== $this->encryptedPassword;
|
||||
}
|
||||
}
|
||||
115
src/Entity/MailFolder.php
Normal file
115
src/Entity/MailFolder.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\MailFolderRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: MailFolderRepository::class)]
|
||||
#[ORM\Table(name: 'mail_folder')]
|
||||
#[ORM\Index(columns: ['parent_path'], name: 'idx_mail_folder_parent_path')]
|
||||
class MailFolder
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 500, unique: true)]
|
||||
private string $path;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
private string $displayName;
|
||||
|
||||
#[ORM\Column(length: 500, nullable: true)]
|
||||
private ?string $parentPath = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private int $unreadCount = 0;
|
||||
|
||||
#[ORM\Column]
|
||||
private int $totalCount = 0;
|
||||
|
||||
#[ORM\Column(type: 'datetimetz_immutable', nullable: true)]
|
||||
private ?DateTimeImmutable $lastSyncedAt = null;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getPath(): string
|
||||
{
|
||||
return $this->path;
|
||||
}
|
||||
|
||||
public function setPath(string $path): static
|
||||
{
|
||||
$this->path = $path;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDisplayName(): string
|
||||
{
|
||||
return $this->displayName;
|
||||
}
|
||||
|
||||
public function setDisplayName(string $displayName): static
|
||||
{
|
||||
$this->displayName = $displayName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getParentPath(): ?string
|
||||
{
|
||||
return $this->parentPath;
|
||||
}
|
||||
|
||||
public function setParentPath(?string $parentPath): static
|
||||
{
|
||||
$this->parentPath = $parentPath;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUnreadCount(): int
|
||||
{
|
||||
return $this->unreadCount;
|
||||
}
|
||||
|
||||
public function setUnreadCount(int $unreadCount): static
|
||||
{
|
||||
$this->unreadCount = $unreadCount;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTotalCount(): int
|
||||
{
|
||||
return $this->totalCount;
|
||||
}
|
||||
|
||||
public function setTotalCount(int $totalCount): static
|
||||
{
|
||||
$this->totalCount = $totalCount;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLastSyncedAt(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->lastSyncedAt;
|
||||
}
|
||||
|
||||
public function setLastSyncedAt(?DateTimeImmutable $lastSyncedAt): static
|
||||
{
|
||||
$this->lastSyncedAt = $lastSyncedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
239
src/Entity/MailMessage.php
Normal file
239
src/Entity/MailMessage.php
Normal file
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\MailMessageRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: MailMessageRepository::class)]
|
||||
#[ORM\Table(name: 'mail_message')]
|
||||
#[ORM\UniqueConstraint(name: 'uq_mail_message_folder_uid', columns: ['folder_id', 'uid'])]
|
||||
#[ORM\Index(columns: ['sent_at'], name: 'idx_mail_message_sent_at')]
|
||||
#[ORM\Index(columns: ['is_read'], name: 'idx_mail_message_is_read')]
|
||||
#[ORM\Index(columns: ['message_id'], name: 'idx_mail_message_message_id')]
|
||||
class MailMessage
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 500)]
|
||||
private string $messageId;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: MailFolder::class)]
|
||||
#[ORM\JoinColumn(name: 'folder_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private MailFolder $folder;
|
||||
|
||||
#[ORM\Column]
|
||||
private int $uid;
|
||||
|
||||
#[ORM\Column(length: 500, nullable: true)]
|
||||
private ?string $subject = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
private string $fromAddress;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $fromName = null;
|
||||
|
||||
#[ORM\Column(type: 'json')]
|
||||
private array $toAddresses = [];
|
||||
|
||||
#[ORM\Column(type: 'json', nullable: true)]
|
||||
private ?array $ccAddresses = null;
|
||||
|
||||
#[ORM\Column(type: 'datetimetz_immutable')]
|
||||
private DateTimeImmutable $sentAt;
|
||||
|
||||
#[ORM\Column(type: 'boolean')]
|
||||
private bool $isRead = false;
|
||||
|
||||
#[ORM\Column(type: 'boolean')]
|
||||
private bool $isFlagged = false;
|
||||
|
||||
#[ORM\Column(type: 'boolean')]
|
||||
private bool $hasAttachments = false;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $snippet = null;
|
||||
|
||||
#[ORM\Column(type: 'datetimetz_immutable')]
|
||||
private DateTimeImmutable $syncedAt;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getMessageId(): string
|
||||
{
|
||||
return $this->messageId;
|
||||
}
|
||||
|
||||
public function setMessageId(string $messageId): static
|
||||
{
|
||||
$this->messageId = $messageId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFolder(): MailFolder
|
||||
{
|
||||
return $this->folder;
|
||||
}
|
||||
|
||||
public function setFolder(MailFolder $folder): static
|
||||
{
|
||||
$this->folder = $folder;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUid(): int
|
||||
{
|
||||
return $this->uid;
|
||||
}
|
||||
|
||||
public function setUid(int $uid): static
|
||||
{
|
||||
$this->uid = $uid;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSubject(): ?string
|
||||
{
|
||||
return $this->subject;
|
||||
}
|
||||
|
||||
public function setSubject(?string $subject): static
|
||||
{
|
||||
$this->subject = $subject;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFromAddress(): string
|
||||
{
|
||||
return $this->fromAddress;
|
||||
}
|
||||
|
||||
public function setFromAddress(string $fromAddress): static
|
||||
{
|
||||
$this->fromAddress = $fromAddress;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFromName(): ?string
|
||||
{
|
||||
return $this->fromName;
|
||||
}
|
||||
|
||||
public function setFromName(?string $fromName): static
|
||||
{
|
||||
$this->fromName = $fromName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getToAddresses(): array
|
||||
{
|
||||
return $this->toAddresses;
|
||||
}
|
||||
|
||||
public function setToAddresses(array $toAddresses): static
|
||||
{
|
||||
$this->toAddresses = $toAddresses;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCcAddresses(): ?array
|
||||
{
|
||||
return $this->ccAddresses;
|
||||
}
|
||||
|
||||
public function setCcAddresses(?array $ccAddresses): static
|
||||
{
|
||||
$this->ccAddresses = $ccAddresses;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSentAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->sentAt;
|
||||
}
|
||||
|
||||
public function setSentAt(DateTimeImmutable $sentAt): static
|
||||
{
|
||||
$this->sentAt = $sentAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isRead(): bool
|
||||
{
|
||||
return $this->isRead;
|
||||
}
|
||||
|
||||
public function setIsRead(bool $isRead): static
|
||||
{
|
||||
$this->isRead = $isRead;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isFlagged(): bool
|
||||
{
|
||||
return $this->isFlagged;
|
||||
}
|
||||
|
||||
public function setIsFlagged(bool $isFlagged): static
|
||||
{
|
||||
$this->isFlagged = $isFlagged;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function hasAttachments(): bool
|
||||
{
|
||||
return $this->hasAttachments;
|
||||
}
|
||||
|
||||
public function setHasAttachments(bool $hasAttachments): static
|
||||
{
|
||||
$this->hasAttachments = $hasAttachments;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSnippet(): ?string
|
||||
{
|
||||
return $this->snippet;
|
||||
}
|
||||
|
||||
public function setSnippet(?string $snippet): static
|
||||
{
|
||||
$this->snippet = $snippet;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSyncedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->syncedAt;
|
||||
}
|
||||
|
||||
public function setSyncedAt(DateTimeImmutable $syncedAt): static
|
||||
{
|
||||
$this->syncedAt = $syncedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
88
src/Entity/TaskMailLink.php
Normal file
88
src/Entity/TaskMailLink.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\TaskMailLinkRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: TaskMailLinkRepository::class)]
|
||||
#[ORM\Table(name: 'task_mail_link')]
|
||||
#[ORM\UniqueConstraint(name: 'uq_task_mail_link', columns: ['task_id', 'mail_message_id'])]
|
||||
class TaskMailLink
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Task::class)]
|
||||
#[ORM\JoinColumn(name: 'task_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Task $task;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: MailMessage::class)]
|
||||
#[ORM\JoinColumn(name: 'mail_message_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private MailMessage $mailMessage;
|
||||
|
||||
#[ORM\Column(type: 'datetimetz_immutable')]
|
||||
private DateTimeImmutable $linkedAt;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(name: 'linked_by_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?User $linkedBy = null;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getTask(): Task
|
||||
{
|
||||
return $this->task;
|
||||
}
|
||||
|
||||
public function setTask(Task $task): static
|
||||
{
|
||||
$this->task = $task;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getMailMessage(): MailMessage
|
||||
{
|
||||
return $this->mailMessage;
|
||||
}
|
||||
|
||||
public function setMailMessage(MailMessage $mailMessage): static
|
||||
{
|
||||
$this->mailMessage = $mailMessage;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLinkedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->linkedAt;
|
||||
}
|
||||
|
||||
public function setLinkedAt(DateTimeImmutable $linkedAt): static
|
||||
{
|
||||
$this->linkedAt = $linkedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLinkedBy(): ?User
|
||||
{
|
||||
return $this->linkedBy;
|
||||
}
|
||||
|
||||
public function setLinkedBy(?User $linkedBy): static
|
||||
{
|
||||
$this->linkedBy = $linkedBy;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
15
src/Mail/Dto/MailAttachmentDto.php
Normal file
15
src/Mail/Dto/MailAttachmentDto.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail\Dto;
|
||||
|
||||
final readonly class MailAttachmentDto
|
||||
{
|
||||
public function __construct(
|
||||
public string $partNumber,
|
||||
public string $filename,
|
||||
public string $mimeType,
|
||||
public int $size,
|
||||
) {}
|
||||
}
|
||||
16
src/Mail/Dto/MailFolderDto.php
Normal file
16
src/Mail/Dto/MailFolderDto.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail\Dto;
|
||||
|
||||
final readonly class MailFolderDto
|
||||
{
|
||||
public function __construct(
|
||||
public string $path,
|
||||
public string $displayName,
|
||||
public ?string $parentPath,
|
||||
public int $unreadCount,
|
||||
public int $totalCount,
|
||||
) {}
|
||||
}
|
||||
18
src/Mail/Dto/MailMessageDetailDto.php
Normal file
18
src/Mail/Dto/MailMessageDetailDto.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail\Dto;
|
||||
|
||||
final readonly class MailMessageDetailDto
|
||||
{
|
||||
/**
|
||||
* @param list<MailAttachmentDto> $attachments
|
||||
*/
|
||||
public function __construct(
|
||||
public MailMessageHeaderDto $header,
|
||||
public ?string $bodyHtml,
|
||||
public ?string $bodyText,
|
||||
public array $attachments,
|
||||
) {}
|
||||
}
|
||||
25
src/Mail/Dto/MailMessageHeaderDto.php
Normal file
25
src/Mail/Dto/MailMessageHeaderDto.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail\Dto;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
final readonly class MailMessageHeaderDto
|
||||
{
|
||||
public function __construct(
|
||||
public int $uid,
|
||||
public string $messageId,
|
||||
public ?string $subject,
|
||||
public string $fromAddress,
|
||||
public ?string $fromName,
|
||||
public array $toAddresses,
|
||||
public ?array $ccAddresses,
|
||||
public DateTimeImmutable $sentAt,
|
||||
public bool $isRead,
|
||||
public bool $isFlagged,
|
||||
public bool $hasAttachments,
|
||||
public ?string $snippet,
|
||||
) {}
|
||||
}
|
||||
24
src/Mail/Dto/MailSyncReport.php
Normal file
24
src/Mail/Dto/MailSyncReport.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail\Dto;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
final readonly class MailSyncReport
|
||||
{
|
||||
/**
|
||||
* @param list<string> $errors
|
||||
*/
|
||||
public function __construct(
|
||||
public int $createdCount,
|
||||
public int $updatedCount,
|
||||
public int $deletedCount,
|
||||
public int $foldersScanned,
|
||||
public array $errors,
|
||||
public float $durationSeconds,
|
||||
public DateTimeImmutable $startedAt,
|
||||
public DateTimeImmutable $finishedAt,
|
||||
) {}
|
||||
}
|
||||
20
src/Mail/Exception/MailProviderException.php
Normal file
20
src/Mail/Exception/MailProviderException.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class MailProviderException extends RuntimeException
|
||||
{
|
||||
public static function connectionFailed(string $reason): self
|
||||
{
|
||||
return new self(sprintf('Mail provider connection failed: %s', $reason));
|
||||
}
|
||||
|
||||
public static function operationFailed(string $operation, string $reason): self
|
||||
{
|
||||
return new self(sprintf('Mail provider operation "%s" failed: %s', $operation, $reason));
|
||||
}
|
||||
}
|
||||
403
src/Mail/ImapMailProvider.php
Normal file
403
src/Mail/ImapMailProvider.php
Normal file
@@ -0,0 +1,403 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Mail\Dto\MailAttachmentDto;
|
||||
use App\Mail\Dto\MailFolderDto;
|
||||
use App\Mail\Dto\MailMessageDetailDto;
|
||||
use App\Mail\Dto\MailMessageHeaderDto;
|
||||
use App\Mail\Exception\MailProviderException;
|
||||
use App\Repository\MailConfigurationRepository;
|
||||
use App\Service\TokenEncryptor;
|
||||
use DateTimeImmutable;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use SodiumException;
|
||||
use Throwable;
|
||||
use Webklex\PHPIMAP\Client;
|
||||
use Webklex\PHPIMAP\ClientManager;
|
||||
use Webklex\PHPIMAP\IMAP;
|
||||
|
||||
final class ImapMailProvider implements MailProviderInterface
|
||||
{
|
||||
private ?Client $client = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly MailConfigurationRepository $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 ? (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() ? (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,
|
||||
);
|
||||
}
|
||||
}
|
||||
81
src/Mail/MailProviderInterface.php
Normal file
81
src/Mail/MailProviderInterface.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Mail\Dto\MailFolderDto;
|
||||
use App\Mail\Dto\MailMessageDetailDto;
|
||||
use App\Mail\Dto\MailMessageHeaderDto;
|
||||
use App\Mail\Exception\MailProviderException;
|
||||
|
||||
interface MailProviderInterface
|
||||
{
|
||||
/**
|
||||
* Opens a connection using the stored configuration and returns the number
|
||||
* of folders found. Used by the admin "test connection" endpoint, so it
|
||||
* MUST work even when the configuration is not yet enabled.
|
||||
*
|
||||
* @throws MailProviderException
|
||||
*/
|
||||
public function testConnection(): int;
|
||||
|
||||
/**
|
||||
* Releases any reused network connection held by the provider.
|
||||
* Safe to call multiple times; a no-op if nothing is open.
|
||||
*/
|
||||
public function closeConnection(): void;
|
||||
|
||||
/**
|
||||
* Returns the full folder tree of the configured mailbox.
|
||||
*
|
||||
* @return list<MailFolderDto>
|
||||
*
|
||||
* @throws MailProviderException
|
||||
*/
|
||||
public function listFolders(): array;
|
||||
|
||||
/**
|
||||
* Returns a paginated list of message headers for the given folder.
|
||||
*
|
||||
* @return list<MailMessageHeaderDto>
|
||||
*
|
||||
* @throws MailProviderException
|
||||
*/
|
||||
public function listMessages(string $folderPath, int $limit, int $offset): array;
|
||||
|
||||
/**
|
||||
* Fetches the full message (headers + body + attachments list) by UID.
|
||||
*
|
||||
* @throws MailProviderException
|
||||
*/
|
||||
public function fetchMessage(string $folderPath, int $uid): MailMessageDetailDto;
|
||||
|
||||
/**
|
||||
* Marks a message as read or unread on the IMAP server.
|
||||
*
|
||||
* @throws MailProviderException
|
||||
*/
|
||||
public function markRead(string $folderPath, int $uid, bool $read): void;
|
||||
|
||||
/**
|
||||
* Marks a message as flagged (starred) or unflagged on the IMAP server.
|
||||
*
|
||||
* @throws MailProviderException
|
||||
*/
|
||||
public function markFlagged(string $folderPath, int $uid, bool $flagged): void;
|
||||
|
||||
/**
|
||||
* Moves a message from one folder to another on the IMAP server.
|
||||
*
|
||||
* @throws MailProviderException
|
||||
*/
|
||||
public function moveMessage(string $folderPath, int $uid, string $targetFolder): void;
|
||||
|
||||
/**
|
||||
* Fetches the raw binary content of an attachment by its MIME part number.
|
||||
*
|
||||
* @throws MailProviderException
|
||||
*/
|
||||
public function fetchAttachment(string $folderPath, int $uid, string $partNumber): string;
|
||||
}
|
||||
12
src/Message/MailSyncRequested.php
Normal file
12
src/Message/MailSyncRequested.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Message;
|
||||
|
||||
final readonly class MailSyncRequested
|
||||
{
|
||||
public function __construct(
|
||||
public ?string $folderPath = null,
|
||||
) {}
|
||||
}
|
||||
54
src/MessageHandler/MailSyncRequestedHandler.php
Normal file
54
src/MessageHandler/MailSyncRequestedHandler.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?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());
|
||||
}
|
||||
}
|
||||
}
|
||||
26
src/Repository/MailConfigurationRepository.php
Normal file
26
src/Repository/MailConfigurationRepository.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\MailConfiguration;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class MailConfigurationRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, MailConfiguration::class);
|
||||
}
|
||||
|
||||
public function findSingleton(): ?MailConfiguration
|
||||
{
|
||||
return $this->createQueryBuilder('m')
|
||||
->setMaxResults(1)
|
||||
->getQuery()
|
||||
->getOneOrNullResult()
|
||||
;
|
||||
}
|
||||
}
|
||||
34
src/Repository/MailFolderRepository.php
Normal file
34
src/Repository/MailFolderRepository.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\MailFolder;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class MailFolderRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, MailFolder::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<MailFolder>
|
||||
*/
|
||||
public function findAllOrderedByPath(): array
|
||||
{
|
||||
return $this->createQueryBuilder('f')
|
||||
->orderBy('f.path', 'ASC')
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
}
|
||||
|
||||
public function findByPath(string $path): ?MailFolder
|
||||
{
|
||||
return $this->findOneBy(['path' => $path]);
|
||||
}
|
||||
}
|
||||
152
src/Repository/MailMessageRepository.php
Normal file
152
src/Repository/MailMessageRepository.php
Normal file
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\MailFolder;
|
||||
use App\Entity\MailMessage;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class MailMessageRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, MailMessage::class);
|
||||
}
|
||||
|
||||
public function findByMessageId(string $messageId): ?MailMessage
|
||||
{
|
||||
return $this->findOneBy(['messageId' => $messageId]);
|
||||
}
|
||||
|
||||
public function findByFolderAndUid(MailFolder $folder, int $uid): ?MailMessage
|
||||
{
|
||||
return $this->findOneBy(['folder' => $folder, 'uid' => $uid]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<MailMessage>
|
||||
*/
|
||||
public function findByFolderPaginated(MailFolder $folder, int $limit, int $offset): array
|
||||
{
|
||||
return $this->createQueryBuilder('m')
|
||||
->andWhere('m.folder = :folder')
|
||||
->setParameter('folder', $folder)
|
||||
->orderBy('m.sentAt', 'DESC')
|
||||
->addOrderBy('m.id', 'DESC')
|
||||
->setMaxResults($limit)
|
||||
->setFirstResult($offset)
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
}
|
||||
|
||||
public function countUnreadByFolder(MailFolder $folder): int
|
||||
{
|
||||
return (int) $this->createQueryBuilder('m')
|
||||
->select('COUNT(m.id)')
|
||||
->andWhere('m.folder = :folder')
|
||||
->andWhere('m.isRead = false')
|
||||
->setParameter('folder', $folder)
|
||||
->getQuery()
|
||||
->getSingleScalarResult()
|
||||
;
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
}
|
||||
46
src/Repository/TaskMailLinkRepository.php
Normal file
46
src/Repository/TaskMailLinkRepository.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\MailMessage;
|
||||
use App\Entity\Task;
|
||||
use App\Entity\TaskMailLink;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class TaskMailLinkRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, TaskMailLink::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<TaskMailLink>
|
||||
*/
|
||||
public function findByTask(Task $task): array
|
||||
{
|
||||
return $this->createQueryBuilder('l')
|
||||
->andWhere('l.task = :task')
|
||||
->setParameter('task', $task)
|
||||
->orderBy('l.linkedAt', 'DESC')
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
}
|
||||
|
||||
public function findByTaskAndMessage(Task $task, MailMessage $message): ?TaskMailLink
|
||||
{
|
||||
return $this->findOneBy(['task' => $task, 'mailMessage' => $message]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<TaskMailLink>
|
||||
*/
|
||||
public function findByMessage(MailMessage $message): array
|
||||
{
|
||||
return $this->findBy(['mailMessage' => $message]);
|
||||
}
|
||||
}
|
||||
53
src/Security/MailAccessChecker.php
Normal file
53
src/Security/MailAccessChecker.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?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,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Verifie que l'utilisateur courant peut acceder aux endpoints mail.
|
||||
* Autorise : ROLE_USER, ROLE_ADMIN.
|
||||
* Refuse : ROLE_CLIENT pur (sans ROLE_ADMIN), non authentifie.
|
||||
*
|
||||
* @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');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifie 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
347
src/Service/MailSyncService.php
Normal file
347
src/Service/MailSyncService.php
Normal file
@@ -0,0 +1,347 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\MailFolder;
|
||||
use App\Entity\MailMessage;
|
||||
use App\Mail\Dto\MailSyncReport;
|
||||
use App\Mail\Exception\MailProviderException;
|
||||
use App\Mail\MailProviderInterface;
|
||||
use App\Repository\MailConfigurationRepository;
|
||||
use App\Repository\MailFolderRepository;
|
||||
use App\Repository\MailMessageRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Lock\LockFactory;
|
||||
use Throwable;
|
||||
|
||||
final class MailSyncService
|
||||
{
|
||||
private const int FLAGS_RESYNC_LIMIT = 200;
|
||||
private const string LOCK_NAME = 'mail.sync';
|
||||
private const float LOCK_TTL = 600.0;
|
||||
|
||||
public function __construct(
|
||||
private readonly MailProviderInterface $provider,
|
||||
private readonly MailConfigurationRepository $configRepository,
|
||||
private readonly MailFolderRepository $folderRepository,
|
||||
private readonly MailMessageRepository $messageRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LockFactory $lockFactory,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly ManagerRegistry $managerRegistry,
|
||||
) {}
|
||||
|
||||
public function syncAll(): MailSyncReport
|
||||
{
|
||||
$startedAt = new DateTimeImmutable();
|
||||
|
||||
$config = $this->configRepository->findSingleton();
|
||||
|
||||
if (null === $config || !$config->isEnabled()) {
|
||||
$this->logger->info('mail.sync skipped: mail config is disabled or missing');
|
||||
|
||||
return $this->emptyReport($startedAt, []);
|
||||
}
|
||||
|
||||
$lock = $this->lockFactory->createLock(self::LOCK_NAME, ttl: self::LOCK_TTL, autoRelease: true);
|
||||
|
||||
if (!$lock->acquire()) {
|
||||
$this->logger->info('mail.sync skipped: another sync in progress');
|
||||
|
||||
return $this->emptyReport($startedAt, ['lock_not_acquired']);
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->doSyncAll($startedAt);
|
||||
} finally {
|
||||
$this->provider->closeConnection();
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
public function syncFolderStructure(): void
|
||||
{
|
||||
try {
|
||||
$remoteFolders = $this->provider->listFolders();
|
||||
} catch (MailProviderException $e) {
|
||||
$this->logger->error('syncFolderStructure: listFolders failed: '.$e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$remotePathSet = [];
|
||||
|
||||
foreach ($remoteFolders as $dto) {
|
||||
$remotePathSet[$dto->path] = true;
|
||||
$folder = $this->folderRepository->findByPath($dto->path);
|
||||
|
||||
if (null === $folder) {
|
||||
$folder = new MailFolder();
|
||||
$folder->setPath($dto->path);
|
||||
}
|
||||
|
||||
$folder->setDisplayName($dto->displayName);
|
||||
$folder->setParentPath($dto->parentPath);
|
||||
$folder->setUnreadCount($dto->unreadCount);
|
||||
$folder->setTotalCount($dto->totalCount);
|
||||
|
||||
$this->entityManager->persist($folder);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$allDbFolders = $this->folderRepository->findAllOrderedByPath();
|
||||
|
||||
foreach ($allDbFolders as $dbFolder) {
|
||||
if (!isset($remotePathSet[$dbFolder->getPath()])) {
|
||||
$this->logger->warning(sprintf(
|
||||
'syncFolderStructure: folder "%s" no longer exists on server — keeping in DB for safety',
|
||||
$dbFolder->getPath()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function syncFolder(MailFolder $folder): MailSyncReport
|
||||
{
|
||||
$startedAt = new DateTimeImmutable();
|
||||
$createdCount = 0;
|
||||
$updatedCount = 0;
|
||||
$deletedCount = 0;
|
||||
$errors = [];
|
||||
$remoteHeaders = null;
|
||||
|
||||
try {
|
||||
$lastUid = $this->messageRepository->findMaxUidInFolder($folder);
|
||||
$remoteHeaders = $this->provider->listMessages($folder->getPath(), limit: 5000, offset: 0);
|
||||
|
||||
foreach ($remoteHeaders as $header) {
|
||||
if ($header->uid <= $lastUid) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$existing = $this->messageRepository->findByFolderAndUid($folder, $header->uid);
|
||||
if (null !== $existing) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$message = new MailMessage();
|
||||
$message->setFolder($folder);
|
||||
$message->setUid($header->uid);
|
||||
$message->setMessageId($header->messageId);
|
||||
$message->setSubject($header->subject);
|
||||
$message->setFromAddress($header->fromAddress);
|
||||
$message->setFromName($header->fromName);
|
||||
$message->setToAddresses($header->toAddresses);
|
||||
$message->setCcAddresses($header->ccAddresses);
|
||||
$message->setSentAt($header->sentAt);
|
||||
$message->setIsRead($header->isRead);
|
||||
$message->setIsFlagged($header->isFlagged);
|
||||
$message->setHasAttachments($header->hasAttachments);
|
||||
$message->setSnippet($header->snippet);
|
||||
$message->setSyncedAt(new DateTimeImmutable());
|
||||
|
||||
$this->entityManager->persist($message);
|
||||
++$createdCount;
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
} catch (MailProviderException $e) {
|
||||
$this->logger->error(sprintf('syncFolder[%s] listMessages failed: %s', $folder->getPath(), $e->getMessage()));
|
||||
$errors[] = $e->getMessage();
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error(sprintf('syncFolder[%s] unexpected error: %s', $folder->getPath(), $e->getMessage()));
|
||||
$errors[] = $e->getMessage();
|
||||
}
|
||||
|
||||
try {
|
||||
$recentMessages = $this->messageRepository->findLastNByFolder($folder, self::FLAGS_RESYNC_LIMIT);
|
||||
|
||||
if (null !== $recentMessages && [] !== $recentMessages) {
|
||||
$remoteByUid = [];
|
||||
if (null !== $remoteHeaders) {
|
||||
foreach ($remoteHeaders as $h) {
|
||||
$remoteByUid[$h->uid] = $h;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($recentMessages as $dbMessage) {
|
||||
$remote = $remoteByUid[$dbMessage->getUid()] ?? null;
|
||||
if (null === $remote) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$changed = false;
|
||||
|
||||
if ($dbMessage->isRead() !== $remote->isRead) {
|
||||
$dbMessage->setIsRead($remote->isRead);
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
if ($dbMessage->isFlagged() !== $remote->isFlagged) {
|
||||
$dbMessage->setIsFlagged($remote->isFlagged);
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
if ($changed) {
|
||||
++$updatedCount;
|
||||
}
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->warning(sprintf('syncFolder[%s] flag resync failed: %s', $folder->getPath(), $e->getMessage()));
|
||||
}
|
||||
|
||||
try {
|
||||
$dbUids = $this->messageRepository->findAllUidsByFolder($folder);
|
||||
|
||||
if ([] !== $dbUids) {
|
||||
if (null === $remoteHeaders) {
|
||||
$remoteHeaders = $this->provider->listMessages($folder->getPath(), limit: 5000, offset: 0);
|
||||
}
|
||||
|
||||
$remoteUidSet = [];
|
||||
foreach ($remoteHeaders as $h) {
|
||||
$remoteUidSet[$h->uid] = true;
|
||||
}
|
||||
|
||||
$toDelete = array_filter($dbUids, static fn (int $uid) => !isset($remoteUidSet[$uid]));
|
||||
$toDeleteCount = count($toDelete);
|
||||
$dbTotal = count($dbUids);
|
||||
|
||||
if ($toDeleteCount > (int) ($dbTotal * 0.5)) {
|
||||
$warningMsg = sprintf(
|
||||
'syncFolder[%s] suppression guard triggered: %d/%d would be deleted (>50%%) — aborting deletions',
|
||||
$folder->getPath(),
|
||||
$toDeleteCount,
|
||||
$dbTotal
|
||||
);
|
||||
$this->logger->warning($warningMsg);
|
||||
$errors[] = $warningMsg;
|
||||
} else {
|
||||
foreach ($toDelete as $uid) {
|
||||
$dbMessage = $this->messageRepository->findByFolderAndUid($folder, $uid);
|
||||
|
||||
if (null !== $dbMessage) {
|
||||
$this->entityManager->remove($dbMessage);
|
||||
++$deletedCount;
|
||||
}
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
}
|
||||
} catch (MailProviderException $e) {
|
||||
$this->logger->error(sprintf('syncFolder[%s] deletion detection failed: %s', $folder->getPath(), $e->getMessage()));
|
||||
$errors[] = $e->getMessage();
|
||||
}
|
||||
|
||||
$finishedAt = new DateTimeImmutable();
|
||||
|
||||
return new MailSyncReport(
|
||||
createdCount: $createdCount,
|
||||
updatedCount: $updatedCount,
|
||||
deletedCount: $deletedCount,
|
||||
foldersScanned: 1,
|
||||
errors: $errors,
|
||||
durationSeconds: (float) ($finishedAt->getTimestamp() - $startedAt->getTimestamp()),
|
||||
startedAt: $startedAt,
|
||||
finishedAt: $finishedAt,
|
||||
);
|
||||
}
|
||||
|
||||
private function doSyncAll(DateTimeImmutable $startedAt): MailSyncReport
|
||||
{
|
||||
$this->syncFolderStructure();
|
||||
|
||||
$totalCreated = 0;
|
||||
$totalUpdated = 0;
|
||||
$totalDeleted = 0;
|
||||
$totalFolders = 0;
|
||||
$allErrors = [];
|
||||
|
||||
$folders = $this->folderRepository->findAllOrderedByPath();
|
||||
|
||||
foreach ($folders as $folder) {
|
||||
try {
|
||||
$report = $this->syncFolder($folder);
|
||||
$totalCreated += $report->createdCount;
|
||||
$totalUpdated += $report->updatedCount;
|
||||
$totalDeleted += $report->deletedCount;
|
||||
++$totalFolders;
|
||||
$allErrors = array_merge($allErrors, $report->errors);
|
||||
// A folder error can leave the reused IMAP connection in a bad
|
||||
// selection state ("must be in SELECTED state", "empty response").
|
||||
// Drop it so the next folder reconnects on a clean session.
|
||||
if ([] !== $report->errors) {
|
||||
$this->provider->closeConnection();
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error(sprintf('doSyncAll: syncFolder[%s] threw: %s', $folder->getPath(), $e->getMessage()));
|
||||
$allErrors[] = $e->getMessage();
|
||||
$this->provider->closeConnection();
|
||||
}
|
||||
|
||||
// A failed flush closes the Doctrine EntityManager; without a reset
|
||||
// every subsequent folder would fail with "EntityManager is closed".
|
||||
// Reset it via the registry and stop the run cleanly — the next cron
|
||||
// cycle resumes incrementally from where we left off.
|
||||
if (!$this->entityManager->isOpen()) {
|
||||
$this->logger->error('doSyncAll: EntityManager was closed mid-sync, resetting and aborting this run');
|
||||
$this->managerRegistry->resetManager();
|
||||
$allErrors[] = 'EntityManager closed mid-sync — run aborted, will resume next cycle';
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$finishedAt = new DateTimeImmutable();
|
||||
|
||||
$this->logger->info(sprintf(
|
||||
'mail.sync done: %d created, %d updated, %d deleted, %d folders, %d errors, %.1fs',
|
||||
$totalCreated,
|
||||
$totalUpdated,
|
||||
$totalDeleted,
|
||||
$totalFolders,
|
||||
count($allErrors),
|
||||
(float) ($finishedAt->getTimestamp() - $startedAt->getTimestamp())
|
||||
));
|
||||
|
||||
return new MailSyncReport(
|
||||
createdCount: $totalCreated,
|
||||
updatedCount: $totalUpdated,
|
||||
deletedCount: $totalDeleted,
|
||||
foldersScanned: $totalFolders,
|
||||
errors: $allErrors,
|
||||
durationSeconds: (float) ($finishedAt->getTimestamp() - $startedAt->getTimestamp()),
|
||||
startedAt: $startedAt,
|
||||
finishedAt: $finishedAt,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $errors
|
||||
*/
|
||||
private function emptyReport(DateTimeImmutable $startedAt, array $errors): MailSyncReport
|
||||
{
|
||||
$now = new DateTimeImmutable();
|
||||
|
||||
return new MailSyncReport(
|
||||
createdCount: 0,
|
||||
updatedCount: 0,
|
||||
deletedCount: 0,
|
||||
foldersScanned: 0,
|
||||
errors: $errors,
|
||||
durationSeconds: 0.0,
|
||||
startedAt: $startedAt,
|
||||
finishedAt: $now,
|
||||
);
|
||||
}
|
||||
}
|
||||
83
src/State/Mail/MailSettingsProcessor.php
Normal file
83
src/State/Mail/MailSettingsProcessor.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?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);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
39
src/State/Mail/MailSettingsProvider.php
Normal file
39
src/State/Mail/MailSettingsProvider.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?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();
|
||||
}
|
||||
|
||||
return $dto;
|
||||
}
|
||||
}
|
||||
25
symfony.lock
25
symfony.lock
@@ -169,6 +169,18 @@
|
||||
".editorconfig"
|
||||
]
|
||||
},
|
||||
"symfony/lock": {
|
||||
"version": "8.0",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "5.2",
|
||||
"ref": "8e937ff2b4735d110af1770f242c1107fdab4c8e"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/lock.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/mcp-bundle": {
|
||||
"version": "v0.6.0"
|
||||
},
|
||||
@@ -222,6 +234,19 @@
|
||||
"config/routes/security.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/translation": {
|
||||
"version": "8.0",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "6.3",
|
||||
"ref": "620a1b84865ceb2ba304c8f8bf2a185fbf32a843"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/translation.yaml",
|
||||
"translations/.gitignore"
|
||||
]
|
||||
},
|
||||
"symfony/uid": {
|
||||
"version": "8.0",
|
||||
"recipe": {
|
||||
|
||||
40
tests/Functional/Command/MailSyncCommandTest.php
Normal file
40
tests/Functional/Command/MailSyncCommandTest.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Functional\Command;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class MailSyncCommandTest extends KernelTestCase
|
||||
{
|
||||
public function testCommandExitsSuccessWhenMailDisabled(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
$application = new Application(self::$kernel);
|
||||
$command = $application->find('app:mail:sync');
|
||||
$tester = new CommandTester($command);
|
||||
|
||||
$exitCode = $tester->execute([]);
|
||||
|
||||
self::assertSame(0, $exitCode);
|
||||
self::assertStringContainsString('disabled', strtolower($tester->getDisplay()));
|
||||
}
|
||||
|
||||
public function testCommandDryRunExitsSuccess(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
$application = new Application(self::$kernel);
|
||||
$command = $application->find('app:mail:sync');
|
||||
$tester = new CommandTester($command);
|
||||
|
||||
$exitCode = $tester->execute(['--dry-run' => true]);
|
||||
|
||||
self::assertSame(0, $exitCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Functional\Controller\Mail;
|
||||
|
||||
use App\Entity\User;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Functional\Controller\Mail;
|
||||
|
||||
use App\Entity\User;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
121
tests/Functional/Controller/Mail/MailSettingsControllerTest.php
Normal file
121
tests/Functional/Controller/Mail/MailSettingsControllerTest.php
Normal file
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Functional\Controller\Mail;
|
||||
|
||||
use App\Entity\User;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
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);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user