Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
325a7b07f9 | ||
|
|
bcbc04325e | ||
|
|
8f2a688740 | ||
|
|
6491943930 | ||
|
|
a9f05fd819 | ||
|
|
925be5d181 | ||
|
|
5da165f739 |
@@ -101,7 +101,7 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
|||||||
- Middleware global `auth.global.ts` protège les routes
|
- Middleware global `auth.global.ts` protège les routes
|
||||||
- Traductions dans `frontend/i18n/locales/` (le module résout `langDir` depuis `i18n/`)
|
- Traductions dans `frontend/i18n/locales/` (le module résout `langDir` depuis `i18n/`)
|
||||||
- 4 espaces d'indentation
|
- 4 espaces d'indentation
|
||||||
- MalioSelect : options `{ label: string, value: string | number | null }` — supporte les valeurs `string` (donc les enums string comme `StatusCategory`), pas seulement `number`. Vérifié dans la source `@malio/layer-ui` (`Select.vue` : `modelValue: string | number | null`). L'option vide `null` n'est ajoutée que si `empty-option-label` est passé (ne pas le passer pour un champ requis)
|
- MalioSelect : options `{ label: string, value: string | number | null }` — accepte les valeurs **string** (enums string OK, ex `category`/`StatusCategory`), pas seulement `number` (vérifié dans la source `Select.vue` : `modelValue: string | number | null`). L'option vide `null` n'est ajoutée que si `empty-option-label` est passé (ne pas le passer pour un champ requis). Largeur via `group-class` (pas de prop `minWidth`/`min-width`). ⚠️ Le `COMPONENTS.md` de la lib est inexact sur ce composant (il indique une clé `text` et une prop `minWidth` inexistantes) : la clé d'affichage réelle est `label`. Ne jamais modifier la lib `malio-layer-ui` depuis ce projet.
|
||||||
- Portal client : pages sous `/portal/`, layout `portal.vue`, middleware redirige `ROLE_CLIENT` (sans `ROLE_ADMIN`) vers `/portal`
|
- Portal client : pages sous `/portal/`, layout `portal.vue`, middleware redirige `ROLE_CLIENT` (sans `ROLE_ADMIN`) vers `/portal`
|
||||||
- Users admin+client : ne pas bloquer — vérifier `ROLE_CLIENT && !ROLE_ADMIN` pour les restrictions
|
- Users admin+client : ne pas bloquer — vérifier `ROLE_CLIENT && !ROLE_ADMIN` pour les restrictions
|
||||||
|
|
||||||
|
|||||||
43
README.md
43
README.md
@@ -45,6 +45,10 @@ make install
|
|||||||
|
|
||||||
L'application est accessible sur **http://localhost:8082**.
|
L'application est accessible sur **http://localhost:8082**.
|
||||||
|
|
||||||
|
Les valeurs par défaut du `.env` committé suffisent pour démarrer en local. Pour la prod
|
||||||
|
(et pour activer la messagerie), surcharger les variables sensibles dans `.env.local` —
|
||||||
|
voir « Variables d'environnement » ci-dessous.
|
||||||
|
|
||||||
### Comptes de test (fixtures)
|
### Comptes de test (fixtures)
|
||||||
|
|
||||||
| Utilisateur | Mot de passe | Rôle | Détails |
|
| Utilisateur | Mot de passe | Rôle | Détails |
|
||||||
@@ -56,6 +60,25 @@ L'application est accessible sur **http://localhost:8082**.
|
|||||||
| `client-liot` | `client` | ROLE_CLIENT | Client LIOT (projet SIRH) |
|
| `client-liot` | `client` | ROLE_CLIENT | Client LIOT (projet SIRH) |
|
||||||
| `client-acme` | `client` | ROLE_CLIENT | Client ACME (projet CRM) |
|
| `client-acme` | `client` | ROLE_CLIENT | Client ACME (projet CRM) |
|
||||||
|
|
||||||
|
## Variables d'environnement
|
||||||
|
|
||||||
|
Les variables sont définies dans `.env` (committé, valeurs par défaut pour le dev) et
|
||||||
|
peuvent être surchargées dans `.env.local` (jamais committé). En prod, elles vont dans le
|
||||||
|
`.env` du serveur (`/var/www/lesstime/.env`, voir `infra/prod/.env.example`).
|
||||||
|
|
||||||
|
| Variable | Rôle | Défaut dev | À fixer en prod |
|
||||||
|
|----------|------|-----------|-----------------|
|
||||||
|
| `APP_SECRET` | Secret Symfony | placeholder | ✅ (hex 32) |
|
||||||
|
| `JWT_PASSPHRASE` | Passphrase des clés JWT | placeholder | ✅ |
|
||||||
|
| `DATABASE_URL` | Connexion PostgreSQL | container `db` | ✅ (`host.docker.internal`) |
|
||||||
|
| `CORS_ALLOW_ORIGIN` | Origines CORS autorisées | localhost | ✅ (domaine prod) |
|
||||||
|
| **`ENCRYPTION_KEY`** | **Clé hex 32 bytes chiffrant les credentials IMAP/SMTP (feature mail)** | placeholder | ✅ — doit rester **stable**, sinon les credentials mail stockés deviennent illisibles |
|
||||||
|
| **`LOCK_DSN`** | **Store de verrous Symfony pour la sync mail (anti-chevauchement)** | `flock` | `flock` suffit |
|
||||||
|
|
||||||
|
> **Messagerie** : `ENCRYPTION_KEY` et `LOCK_DSN` sont introduites par l'intégration mail.
|
||||||
|
> Détails de config et cron de synchronisation : `docs/mail-integration.md` et `docs/mail-cron-setup.md`.
|
||||||
|
> Générer une clé : `php -r "echo bin2hex(random_bytes(32));"`.
|
||||||
|
|
||||||
## Commandes
|
## Commandes
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
@@ -218,13 +241,19 @@ docker exec -u www-data php-lesstime-fpm php bin/console app:generate-api-token
|
|||||||
|
|
||||||
## Déploiement
|
## Déploiement
|
||||||
|
|
||||||
1. Déployer le code sur le serveur
|
La prod tourne en **Docker** : l'image est buildée par la CI Gitea sur push de tag `v*`
|
||||||
2. `composer install --no-dev --optimize-autoloader`
|
(`gitea.malio.fr/malio-dev/lesstime:<tag>`), puis déployée par le script `deploy.sh` sur
|
||||||
3. `php bin/console doctrine:migrations:migrate --no-interaction`
|
le serveur (dossier `/var/www/lesstime`, container `lesstime-app`).
|
||||||
4. `php bin/console cache:clear --env=prod`
|
|
||||||
5. `cd frontend && npm install && npm run build:dist`
|
```bash
|
||||||
6. `docker restart nginx-lesstime`
|
# Sur le serveur, depuis /var/www/lesstime
|
||||||
7. Ouvrir le port 8082 sur le firewall (LAN uniquement)
|
sudo ./deploy.sh # déploie la dernière image (latest)
|
||||||
|
sudo ./deploy.sh v0.4.2 # déploie une version précise
|
||||||
|
```
|
||||||
|
|
||||||
|
Le script active la maintenance, pull l'image, redémarre le container, lance les migrations
|
||||||
|
et vide le cache. Guide complet (première installation, BDD, Nginx, JWT, rollback) :
|
||||||
|
**`doc/deployment-docker.md`**.
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.4.4'
|
app.version: '0.4.7'
|
||||||
|
|||||||
@@ -3,44 +3,24 @@
|
|||||||
## Vue d'ensemble
|
## Vue d'ensemble
|
||||||
|
|
||||||
La synchronisation IMAP est déclenchée par un cron OS toutes les 10 minutes.
|
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.
|
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
|
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.
|
les runs de se chevaucher si une sync prend plus de 10 min.
|
||||||
|
|
||||||
|
> **Dev vs prod** — en dev le container s'appelle `php-lesstime-fpm` et on passe par `make`.
|
||||||
|
> En **production** le container s'appelle `lesstime-app` (service `app` du `docker-compose.yml`
|
||||||
|
> dans `/var/www/lesstime`), il n'y a **pas de `make`** : tout passe par `docker compose` / `docker exec`.
|
||||||
|
|
||||||
## Prérequis
|
## Prérequis
|
||||||
|
|
||||||
- Container `php-lesstime-fpm` démarré (`make start`)
|
- `MailConfiguration.enabled = true` (configurable depuis l'admin — onglet « Mail »)
|
||||||
- `MailConfiguration.enabled = true` (configurable depuis l'admin — Phase 7)
|
- `ENCRYPTION_KEY` (clé hex 32 bytes) défini dans l'environnement :
|
||||||
- `ENCRYPTION_KEY` défini dans `infra/dev/.env.docker.local` (ou production env)
|
- **dev** : `infra/dev/.env.docker.local`
|
||||||
|
- **prod** : `/var/www/lesstime/.env`
|
||||||
## Installation du cron
|
- Container démarré :
|
||||||
|
- **dev** : `make start` (container `php-lesstime-fpm`)
|
||||||
Sur la **machine hôte** (pas dans le container) :
|
- **prod** : déployé via `sudo ./deploy.sh` (container `lesstime-app`)
|
||||||
|
|
||||||
```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
|
## Variables d'environnement nécessaires
|
||||||
|
|
||||||
@@ -49,63 +29,100 @@ sudo crontab -u deploy -e
|
|||||||
| `ENCRYPTION_KEY` | Clé hex 32 bytes pour déchiffrer le password IMAP | `$(php -r "echo bin2hex(random_bytes(32));")` |
|
| `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) |
|
| `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.
|
La clé `ENCRYPTION_KEY` doit être **identique** à celle utilisée pour chiffrer le password
|
||||||
|
lors de la configuration depuis l'admin. Si elle change, les credentials stockés deviennent illisibles.
|
||||||
|
|
||||||
## Checklist setup production
|
---
|
||||||
|
|
||||||
1. [ ] Définir `ENCRYPTION_KEY` dans les variables d'environnement production
|
## Dev
|
||||||
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
|
### Lancer une sync à la main
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Sync complète (toutes les boîtes)
|
make mail-sync # sync complète (toutes les boîtes)
|
||||||
make mail-sync
|
make mail-sync FOLDER=INBOX # un seul dossier (doit déjà exister en base)
|
||||||
|
make mail-sync DRYRUN=1 # simulation (dry-run, pas d'écriture BDD)
|
||||||
|
```
|
||||||
|
|
||||||
# Sync d'un seul dossier (le dossier doit déjà exister en base)
|
Ou directement dans le container :
|
||||||
make mail-sync FOLDER=INBOX
|
|
||||||
|
|
||||||
# Simulation (dry-run, pas d'écriture BDD)
|
```bash
|
||||||
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
|
||||||
docker exec php-lesstime-fpm php bin/console app:mail:sync --folder=INBOX
|
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
|
docker exec php-lesstime-fpm php bin/console app:mail:sync --dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
## Logs
|
### Logs (dev)
|
||||||
|
|
||||||
Les logs Symfony sont dans `var/log/dev.log` (ou `prod.log` en production).
|
|
||||||
Suivre les logs en temps réel :
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make logs-dev
|
make logs-dev # tail -f var/log/dev.log
|
||||||
```
|
```
|
||||||
|
|
||||||
Les messages loggés par `MailSyncService` sont préfixés `mail.sync`.
|
Les messages loggés par `MailSyncService` sont préfixés `mail.sync`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Production
|
||||||
|
|
||||||
|
En prod, l'app tourne dans le container `lesstime-app` déployé par `sudo ./deploy.sh`
|
||||||
|
(dossier `/var/www/lesstime`). La commande s'exécute en tant que `www-data` (uid 33),
|
||||||
|
comme les migrations lancées par `deploy.sh`.
|
||||||
|
|
||||||
|
### Lancer une sync à la main
|
||||||
|
|
||||||
|
Depuis `/var/www/lesstime` :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo docker compose exec -T -u www-data app php bin/console app:mail:sync
|
||||||
|
sudo docker compose exec -T -u www-data app php bin/console app:mail:sync --folder=INBOX
|
||||||
|
sudo docker compose exec -T -u www-data app php bin/console app:mail:sync --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
### Installer le cron
|
||||||
|
|
||||||
|
Sur la **machine hôte** (pas dans le container). Comme `docker` requiert `sudo` en prod,
|
||||||
|
installer le cron sous root :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo crontab -e
|
||||||
|
```
|
||||||
|
|
||||||
|
Ajouter :
|
||||||
|
|
||||||
|
```cron
|
||||||
|
*/10 * * * * cd /var/www/lesstime && docker compose exec -T -u www-data app php bin/console app:mail:sync >> /var/log/lesstime-mail-sync.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
> Le crontab de root exécute déjà les commandes en root → pas de `sudo` à l'intérieur de la ligne cron.
|
||||||
|
> La commande est **idempotente** (UIDs uniques en base) : la relancer ne duplique pas les données.
|
||||||
|
|
||||||
|
### Logs (prod)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/lesstime
|
||||||
|
docker compose logs -f --tail=100 app # logs container
|
||||||
|
docker compose exec app cat var/log/prod.log # log Symfony (volume lesstime_logs)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checklist setup production
|
||||||
|
|
||||||
|
1. [ ] Définir `ENCRYPTION_KEY` (hex 32 bytes) et `LOCK_DSN=flock` dans `/var/www/lesstime/.env`
|
||||||
|
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. [ ] Lancer une sync manuelle pour valider (commande ci-dessus)
|
||||||
|
7. [ ] Installer le cron OS (voir « Installer le cron »)
|
||||||
|
8. [ ] Vérifier les logs après la première sync (`docker compose logs -f app`, chercher `mail.sync`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Sécurité
|
## Sécurité
|
||||||
|
|
||||||
- Le password IMAP est **toujours stocké chiffré** (libsodium secretbox)
|
- Le password IMAP est **toujours stocké chiffré** (libsodium secretbox)
|
||||||
- Les corps de mails, passwords et pièces jointes ne sont **jamais loggés**
|
- 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`)
|
- 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
|
- 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 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 (`frontend/utils/sanitizeMailHtml.ts`)
|
||||||
- Les corps de mails sont sanitisés via DOMPurify avant affichage (voir `frontend/utils/sanitizeMailHtml.ts`)
|
- Les pixels de tracking distants sont remplacés par un placeholder
|
||||||
- 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).
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
> make mail-sync # synchro complète
|
> 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 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)
|
> docker exec -i -u www-data php-lesstime-fpm php bin/console messenger:consume async -vv # worker (fait marcher le bouton)
|
||||||
|
> docker exec -i php-lesstime-fpm php bin/console app:mail:redecode-headers [--dry-run] # re-décode les en-têtes MIME déjà en base (backfill)
|
||||||
> make test # 33 tests
|
> 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.
|
> Fixtures `make fixtures` plantent sur un état legacy `workflow_id` (hors-scope mail) — configurer la boîte via l'UI admin.
|
||||||
@@ -45,6 +46,8 @@
|
|||||||
- Navigation par dossiers (arbre récursif avec compteurs non-lus)
|
- Navigation par dossiers (arbre récursif avec compteurs non-lus)
|
||||||
- Liste paginée des messages (infinite scroll, cursor-based)
|
- Liste paginée des messages (infinite scroll, cursor-based)
|
||||||
- Lecture des corps de mail sanitisés (DOMPurify — protection XSS + pixels tracking)
|
- Lecture des corps de mail sanitisés (DOMPurify — protection XSS + pixels tracking)
|
||||||
|
- Décodage des en-têtes MIME encodés (RFC 2047, ex `=?UTF-8?Q?...`) sur sujet + nom d'expéditeur (`App\Mail\MimeHeaderDecoder`, appliqué dans `ImapMailProvider`)
|
||||||
|
- Aperçu inline des pièces jointes images + PDF (visionneuse modale plein écran), téléchargement pour les autres types
|
||||||
- Création d'une tâche Lesstime depuis un mail (sujet → titre, texte → description)
|
- Création d'une tâche Lesstime depuis un mail (sujet → titre, texte → description)
|
||||||
- Lien mail ↔ tâche (bidirectionnel)
|
- Lien mail ↔ tâche (bidirectionnel)
|
||||||
- Onglet "Mails" dans le TaskDrawer pour retrouver les mails liés à une tâche
|
- Onglet "Mails" dans le TaskDrawer pour retrouver les mails liés à une tâche
|
||||||
@@ -107,7 +110,7 @@ Tous les endpoints `/api/mail/*` refusent explicitement `ROLE_CLIENT`.
|
|||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
- `frontend/pages/mail.vue` — page principale 3 colonnes
|
- `frontend/pages/mail.vue` — page principale 3 colonnes
|
||||||
- `frontend/components/mail/` — MailFolderTree, MailMessageList, MailMessageViewer, MailRefreshButton
|
- `frontend/components/mail/` — MailFolderTree, MailMessageList, MailMessageViewer, MailRefreshButton, MailAttachmentPreview (visionneuse modale image/PDF)
|
||||||
- `frontend/components/admin/AdminMailTab.vue` — onglet config admin
|
- `frontend/components/admin/AdminMailTab.vue` — onglet config admin
|
||||||
- `frontend/stores/mail.ts` — store Pinia (folders, messages, polling)
|
- `frontend/stores/mail.ts` — store Pinia (folders, messages, polling)
|
||||||
- `frontend/services/mail.ts` — service API (toutes les méthodes)
|
- `frontend/services/mail.ts` — service API (toutes les méthodes)
|
||||||
|
|||||||
@@ -50,8 +50,7 @@
|
|||||||
v-model="s.category"
|
v-model="s.category"
|
||||||
:options="categoryOptions"
|
:options="categoryOptions"
|
||||||
label="Catégorie"
|
label="Catégorie"
|
||||||
min-width="w-44"
|
group-class="w-48 shrink-0"
|
||||||
group-class="shrink-0"
|
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
121
frontend/components/mail/MailAttachmentPreview.vue
Normal file
121
frontend/components/mail/MailAttachmentPreview.vue
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
/** Ouverture de la visionneuse. */
|
||||||
|
modelValue: boolean
|
||||||
|
/** Nom du fichier affiché dans la barre. */
|
||||||
|
filename: string
|
||||||
|
/** Type MIME — détermine le rendu (image vs PDF). */
|
||||||
|
mimeType: string
|
||||||
|
/** Object URL du Blob de la pièce jointe. null tant que le contenu charge. */
|
||||||
|
url: string | null
|
||||||
|
/** Téléchargement en cours du contenu. */
|
||||||
|
loading: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
download: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const isImage = computed(() => props.mimeType.startsWith('image/'))
|
||||||
|
const isPdf = computed(() => props.mimeType === 'application/pdf')
|
||||||
|
|
||||||
|
function close(): void {
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent): void {
|
||||||
|
if (e.key === 'Escape') close()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(open) => {
|
||||||
|
if (open) {
|
||||||
|
window.addEventListener('keydown', onKeydown)
|
||||||
|
} else {
|
||||||
|
window.removeEventListener('keydown', onKeydown)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport v-if="modelValue" to="body">
|
||||||
|
<Transition name="mail-preview" appear>
|
||||||
|
<div class="fixed inset-0 z-50 flex flex-col bg-slate-900/80 backdrop-blur-sm">
|
||||||
|
<!-- Barre supérieure -->
|
||||||
|
<div class="flex flex-shrink-0 items-center justify-between gap-4 px-4 py-3 text-white">
|
||||||
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
|
<Icon
|
||||||
|
:name="isImage ? 'material-symbols:image-outline' : 'material-symbols:picture-as-pdf-outline'"
|
||||||
|
size="18"
|
||||||
|
class="flex-shrink-0 text-white/70"
|
||||||
|
/>
|
||||||
|
<span class="truncate text-sm font-medium">{{ filename }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-shrink-0 items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-white/90 transition-colors hover:bg-white/10"
|
||||||
|
@click="emit('download')"
|
||||||
|
>
|
||||||
|
<Icon name="material-symbols:download" size="18" />
|
||||||
|
<span class="hidden sm:inline">{{ t('mail.actions.download') }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md p-1.5 text-white/90 transition-colors hover:bg-white/10"
|
||||||
|
:aria-label="t('mail.preview.close')"
|
||||||
|
@click="close"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:close" size="20" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contenu -->
|
||||||
|
<div class="flex min-h-0 flex-1 items-center justify-center overflow-auto p-4" @click.self="close">
|
||||||
|
<div v-if="loading" class="flex flex-col items-center gap-3 text-white/70">
|
||||||
|
<Icon name="material-symbols:progress-activity" size="32" class="animate-spin" />
|
||||||
|
<span class="text-sm">{{ t('mail.preview.loading') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<img
|
||||||
|
v-else-if="isImage && url"
|
||||||
|
:src="url"
|
||||||
|
:alt="filename"
|
||||||
|
class="max-h-full max-w-full rounded-lg object-contain shadow-2xl"
|
||||||
|
>
|
||||||
|
|
||||||
|
<iframe
|
||||||
|
v-else-if="isPdf && url"
|
||||||
|
:src="url"
|
||||||
|
:title="filename"
|
||||||
|
class="h-full w-full max-w-5xl rounded-lg bg-white shadow-2xl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-else class="flex flex-col items-center gap-3 text-white/70">
|
||||||
|
<Icon name="material-symbols:visibility-off-outline" size="32" />
|
||||||
|
<span class="text-sm">{{ t('mail.preview.unavailable') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mail-preview-enter-active,
|
||||||
|
.mail-preview-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.mail-preview-enter-from,
|
||||||
|
.mail-preview-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { MailMessageDetailDto, MailAddressDto } from '~/services/dto/mail'
|
import type { MailMessageDetailDto, MailAddressDto, MailAttachmentDto } from '~/services/dto/mail'
|
||||||
import { sanitizeMailHtml } from '~/utils/sanitizeMailHtml'
|
import { sanitizeMailHtml } from '~/utils/sanitizeMailHtml'
|
||||||
import { useMailService } from '~/services/mail'
|
import { useMailService } from '~/services/mail'
|
||||||
|
|
||||||
@@ -24,22 +24,101 @@ const sanitizedBody = computed((): string => {
|
|||||||
return sanitizeMailHtml(props.detail.bodyHtml, { allowImages: showImages.value })
|
return sanitizeMailHtml(props.detail.bodyHtml, { allowImages: showImages.value })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ─── Pièces jointes : aperçu / téléchargement ──────────────────────────────
|
||||||
|
|
||||||
|
function isImage(mime: string): boolean {
|
||||||
|
return mime.startsWith('image/')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPdf(mime: string): boolean {
|
||||||
|
return mime === 'application/pdf'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPreviewable(mime: string): boolean {
|
||||||
|
return isImage(mime) || isPdf(mime)
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachmentIcon(mime: string): string {
|
||||||
|
if (isImage(mime)) return 'material-symbols:image-outline'
|
||||||
|
if (isPdf(mime)) return 'material-symbols:picture-as-pdf-outline'
|
||||||
|
return 'material-symbols:attach-file'
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewOpen = ref(false)
|
||||||
|
const previewLoading = ref(false)
|
||||||
|
const previewAtt = ref<MailAttachmentDto | null>(null)
|
||||||
|
const previewUrl = ref<string | null>(null)
|
||||||
|
let previewBlob: Blob | null = null
|
||||||
|
|
||||||
|
function revokePreview(): void {
|
||||||
|
if (previewUrl.value) {
|
||||||
|
URL.revokeObjectURL(previewUrl.value)
|
||||||
|
previewUrl.value = null
|
||||||
|
}
|
||||||
|
previewBlob = null
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.detail?.header.id,
|
() => props.detail?.header.id,
|
||||||
() => {
|
() => {
|
||||||
showImages.value = false
|
showImages.value = false
|
||||||
|
previewOpen.value = false
|
||||||
|
revokePreview()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
async function handleDownload(downloadId: string, filename: string): Promise<void> {
|
watch(previewOpen, (open) => {
|
||||||
|
if (!open) revokePreview()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(revokePreview)
|
||||||
|
|
||||||
|
async function handleAttachmentClick(att: MailAttachmentDto): Promise<void> {
|
||||||
|
if (!isPreviewable(att.mimeType)) {
|
||||||
|
await handleDownload(att.downloadId, att.filename)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
previewAtt.value = att
|
||||||
|
previewUrl.value = null
|
||||||
|
previewLoading.value = true
|
||||||
|
previewOpen.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data } = await mailService.downloadAttachment(downloadId)
|
const { data } = await mailService.downloadAttachment(att.downloadId)
|
||||||
const url = URL.createObjectURL(data)
|
previewBlob = data
|
||||||
|
previewUrl.value = URL.createObjectURL(data)
|
||||||
|
} catch {
|
||||||
|
// useApi affiche déjà le toast — on referme la visionneuse.
|
||||||
|
previewOpen.value = false
|
||||||
|
} finally {
|
||||||
|
previewLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadFromPreview(): void {
|
||||||
|
const att = previewAtt.value
|
||||||
|
if (!att) return
|
||||||
|
if (previewBlob) {
|
||||||
|
triggerBlobDownload(previewBlob, att.filename)
|
||||||
|
} else {
|
||||||
|
void handleDownload(att.downloadId, att.filename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerBlobDownload(blob: Blob, filename: string): void {
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
a.href = url
|
a.href = url
|
||||||
a.download = filename
|
a.download = filename
|
||||||
a.click()
|
a.click()
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDownload(downloadId: string, filename: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { data } = await mailService.downloadAttachment(downloadId)
|
||||||
|
triggerBlobDownload(data, filename)
|
||||||
} catch {
|
} catch {
|
||||||
// L'erreur est gérée par useApi (toast automatique)
|
// L'erreur est gérée par useApi (toast automatique)
|
||||||
}
|
}
|
||||||
@@ -169,15 +248,31 @@ function joinAddresses(addresses: MailAddressDto[]): string {
|
|||||||
:key="att.downloadId"
|
:key="att.downloadId"
|
||||||
type="button"
|
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"
|
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"
|
:title="isPreviewable(att.mimeType) ? t('mail.preview.open') : t('mail.actions.download')"
|
||||||
@click="handleDownload(att.downloadId, att.filename)"
|
@click="handleAttachmentClick(att)"
|
||||||
>
|
>
|
||||||
<Icon name="material-symbols:attach-file" size="14" class="flex-shrink-0 text-neutral-400" />
|
<Icon :name="attachmentIcon(att.mimeType)" size="14" class="flex-shrink-0 text-neutral-400" />
|
||||||
<span class="max-w-[180px] truncate">{{ att.filename }}</span>
|
<span class="max-w-[180px] truncate">{{ att.filename }}</span>
|
||||||
<span class="text-neutral-400">({{ Math.round(att.size / 1024) }} Ko)</span>
|
<span class="text-neutral-400">({{ Math.round(att.size / 1024) }} Ko)</span>
|
||||||
|
<Icon
|
||||||
|
v-if="isPreviewable(att.mimeType)"
|
||||||
|
name="material-symbols:visibility-outline"
|
||||||
|
size="13"
|
||||||
|
class="flex-shrink-0 text-neutral-400"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<MailAttachmentPreview
|
||||||
|
v-if="previewAtt"
|
||||||
|
v-model="previewOpen"
|
||||||
|
:filename="previewAtt.filename"
|
||||||
|
:mime-type="previewAtt.mimeType"
|
||||||
|
:url="previewUrl"
|
||||||
|
:loading="previewLoading"
|
||||||
|
@download="downloadFromPreview"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -64,3 +64,13 @@ L'admin (`/admin`) est divisé en plusieurs onglets, chacun gérant une ressourc
|
|||||||
- Configure le calendrier CalDav par défaut
|
- Configure le calendrier CalDav par défaut
|
||||||
- Test de connexion intégré
|
- Test de connexion intégré
|
||||||
- Active la **sync calendrier** sur les tâches planifiées
|
- Active la **sync calendrier** sur les tâches planifiées
|
||||||
|
|
||||||
|
## Onglet *Mail*
|
||||||
|
|
||||||
|
Configure la **boîte mail partagée** (OVH) lue dans la section *Messagerie*.
|
||||||
|
|
||||||
|
- **Réception (IMAP)** : hôte, port (993 par défaut), chiffrement (SSL / TLS / aucun)
|
||||||
|
- **Envoi (SMTP)** : hôte, port (465 par défaut), chiffrement
|
||||||
|
- **Identifiants** : nom d'utilisateur + mot de passe (chiffré côté serveur, jamais réaffiché — un indicateur signale qu'un mot de passe est déjà enregistré), et chemin du dossier *Envoyés*
|
||||||
|
- **Toggle `enabled`** : active la messagerie
|
||||||
|
- **Test de connexion** intégré (vérifie l'accès IMAP et compte les dossiers)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Intégrations
|
# Intégrations
|
||||||
|
|
||||||
Lesstime s'intègre avec **3 outils externes** pour fluidifier le workflow dev.
|
Lesstime s'intègre avec **4 outils externes** pour fluidifier le workflow dev.
|
||||||
|
|
||||||
## 🌳 Gitea
|
## 🌳 Gitea
|
||||||
|
|
||||||
@@ -64,3 +64,29 @@ Sur une tâche avec **scheduled start + end** :
|
|||||||
|
|
||||||
- **Pas de retour Zimbra → Lesstime** : si tu modifies l'événement dans Zimbra, Lesstime ne le voit pas
|
- **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)
|
- **Récurrences** : les patterns RRULE basiques sont supportés (daily, weekly avec jours, monthly)
|
||||||
|
|
||||||
|
## 📧 Messagerie (Mail OVH)
|
||||||
|
|
||||||
|
Boîte mail partagée OVH (IMAP) lue directement dans Lesstime.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
1. **Admin → Mail** :
|
||||||
|
- Réception **IMAP** (hôte, port, chiffrement) et envoi **SMTP** (hôte, port, chiffrement)
|
||||||
|
- Identifiants (mot de passe chiffré côté serveur) + dossier *Envoyés*
|
||||||
|
- **Test de connexion** intégré
|
||||||
|
2. Active la config (toggle `enabled`)
|
||||||
|
|
||||||
|
### Utilisation
|
||||||
|
|
||||||
|
- La section **Messagerie** (barre latérale) affiche dossiers, messages et lecteur
|
||||||
|
- **Synchronisation IMAP à la demande** via le bouton *Rafraîchir* (traitée en asynchrone par Messenger)
|
||||||
|
- Depuis un mail : **créer une tâche** pré-remplie ou **lier à une tâche** existante
|
||||||
|
- Badge de non-lus dans la barre latérale, rafraîchi automatiquement (toutes les 30 s)
|
||||||
|
|
||||||
|
> 📖 Le guide complet de la messagerie est dans la section *Messagerie*.
|
||||||
|
|
||||||
|
### Limites
|
||||||
|
|
||||||
|
- **Lecture seule** : pas de rédaction / réponse / suppression de mail depuis l'interface
|
||||||
|
- Réservée aux rôles **admin** et **user** (pas les clients)
|
||||||
|
|||||||
40
frontend/content/help/10-messaging.md
Normal file
40
frontend/content/help/10-messaging.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Messagerie
|
||||||
|
|
||||||
|
Lesstime intègre une **boîte mail partagée** (OVH, protocole IMAP) directement dans l'application. Tu lis les mails de l'équipe et tu les transformes en tâches sans quitter Lesstime.
|
||||||
|
|
||||||
|
> 📥 La messagerie est accessible depuis l'entrée **Messagerie** de la barre latérale (icône enveloppe). Un **badge** y affiche le nombre de mails non lus, toutes boîtes confondues.
|
||||||
|
|
||||||
|
> 🛡️ Réservée aux rôles **ROLE_ADMIN** et **ROLE_USER**. Les utilisateurs *client* sont redirigés vers leur portail.
|
||||||
|
|
||||||
|
## L'interface
|
||||||
|
|
||||||
|
L'écran est organisé en **3 colonnes** :
|
||||||
|
|
||||||
|
1. **Dossiers** — l'arborescence de la boîte (INBOX, Envoyés, sous-dossiers…), avec le compteur de non-lus par dossier. INBOX est sélectionné par défaut.
|
||||||
|
2. **Messages** — la liste du dossier sélectionné (expéditeur, objet, date). Les mails non lus sont mis en avant. Un bouton **Charger plus** récupère les messages suivants (pagination).
|
||||||
|
3. **Lecteur** — le mail sélectionné : en-tête (expéditeur, destinataires, date), corps du message et **pièces jointes**.
|
||||||
|
|
||||||
|
## Lire un message
|
||||||
|
|
||||||
|
- Clique sur un message dans la liste : son détail s'affiche et il est **automatiquement marqué comme lu**.
|
||||||
|
- Tu peux le repasser **non lu** ou l'**étoiler** (flag) pour le retrouver plus tard.
|
||||||
|
- Les **pièces jointes** sont listées dans le lecteur : clique pour les télécharger, les images peuvent être prévisualisées.
|
||||||
|
|
||||||
|
## Synchronisation
|
||||||
|
|
||||||
|
- Le bouton **Rafraîchir** (en haut de l'écran) déclenche une **synchronisation IMAP à la demande** : Lesstime va chercher les nouveaux mails sur le serveur. Le traitement est asynchrone, la liste se met à jour quelques secondes après.
|
||||||
|
- En arrière-plan, le **compteur de non-lus** de la barre latérale se rafraîchit automatiquement (toutes les 30 s).
|
||||||
|
|
||||||
|
## Transformer un mail en action
|
||||||
|
|
||||||
|
Depuis le lecteur, deux boutons relient un mail au suivi de projet :
|
||||||
|
|
||||||
|
- **Créer une tâche** — ouvre une tâche pré-remplie à partir du mail (objet, contenu). Tu choisis le projet et les métadonnées, le mail reste lié à la tâche.
|
||||||
|
- **Lier à une tâche** — rattache le mail à une tâche **existante**.
|
||||||
|
|
||||||
|
> 💡 C'est le pont entre la boîte mail de l'équipe et le kanban : une demande reçue par mail devient une tâche traçable en deux clics.
|
||||||
|
|
||||||
|
## Limites
|
||||||
|
|
||||||
|
- **Lecture seule** : l'interface ne permet pas (encore) de **rédiger, répondre ou transférer** un mail, ni de supprimer un message.
|
||||||
|
- La configuration du serveur (IMAP/SMTP, identifiants) se fait dans **Admin → Mail** — voir la section *Administration*.
|
||||||
@@ -528,6 +528,12 @@
|
|||||||
"list": "Aucun message dans ce dossier.",
|
"list": "Aucun message dans ce dossier.",
|
||||||
"viewer": "Sélectionnez un message pour le lire."
|
"viewer": "Sélectionnez un message pour le lire."
|
||||||
},
|
},
|
||||||
|
"preview": {
|
||||||
|
"open": "Prévisualiser",
|
||||||
|
"close": "Fermer l'aperçu",
|
||||||
|
"loading": "Chargement de l'aperçu…",
|
||||||
|
"unavailable": "Aperçu indisponible pour ce type de fichier."
|
||||||
|
},
|
||||||
"folderTree": {
|
"folderTree": {
|
||||||
"expand": "Déplier le dossier",
|
"expand": "Déplier le dossier",
|
||||||
"collapse": "Replier le dossier"
|
"collapse": "Replier le dossier"
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const META: Record<string, { title: string, icon: string, accent: string, roles:
|
|||||||
'07-admin': { title: 'Administration', icon: 'mdi:shield-crown-outline', accent: 'from-rose-500 to-pink-600', roles: ['admin'] },
|
'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'] },
|
'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'] },
|
'09-mcp-api': { title: 'Token MCP & API', icon: 'mdi:robot-outline', accent: 'from-slate-700 to-slate-900', roles: ['admin', 'user'] },
|
||||||
|
'10-messaging': { title: 'Messagerie', icon: 'mdi:email-outline', accent: 'from-teal-500 to-cyan-600', roles: ['admin', 'user'] },
|
||||||
}
|
}
|
||||||
|
|
||||||
const sections = computed<Section[]>(() => {
|
const sections = computed<Section[]>(() => {
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ JWT_COOKIE_SAMESITE=lax
|
|||||||
JWT_TOKEN_TTL=86400
|
JWT_TOKEN_TTL=86400
|
||||||
JWT_COOKIE_TTL=86400
|
JWT_COOKIE_TTL=86400
|
||||||
|
|
||||||
|
# Mail (intégration IMAP/SMTP)
|
||||||
|
# Clé hex 32 bytes chiffrant les credentials mail stockés en base.
|
||||||
|
# Générer : php -r "echo bin2hex(random_bytes(32));" — doit rester STABLE.
|
||||||
|
ENCRYPTION_KEY=change-me
|
||||||
|
# Store de verrous Symfony pour la sync mail (anti-chevauchement du cron).
|
||||||
|
LOCK_DSN=flock
|
||||||
|
|
||||||
# CORS
|
# CORS
|
||||||
CORS_ALLOW_ORIGIN='^https?://project\.malio-dev\.fr$'
|
CORS_ALLOW_ORIGIN='^https?://project\.malio-dev\.fr$'
|
||||||
|
|
||||||
|
|||||||
84
src/Command/MailRedecodeHeadersCommand.php
Normal file
84
src/Command/MailRedecodeHeadersCommand.php
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Mail\MimeHeaderDecoder;
|
||||||
|
use App\Repository\MailMessageRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:mail:redecode-headers',
|
||||||
|
description: 'Re-décode les sujets et noms d\'expéditeur encodés en MIME (RFC 2047) déjà stockés en base',
|
||||||
|
)]
|
||||||
|
final class MailRedecodeHeadersCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly MailMessageRepository $messageRepository,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->addOption(
|
||||||
|
'dry-run',
|
||||||
|
null,
|
||||||
|
InputOption::VALUE_NONE,
|
||||||
|
'Affiche les changements sans écrire en base',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
$isDryRun = (bool) $input->getOption('dry-run');
|
||||||
|
|
||||||
|
$messages = $this->messageRepository->findAll();
|
||||||
|
$io->text(sprintf('%d message(s) à examiner...', count($messages)));
|
||||||
|
|
||||||
|
$changed = 0;
|
||||||
|
|
||||||
|
foreach ($messages as $message) {
|
||||||
|
$newSubject = MimeHeaderDecoder::decode($message->getSubject());
|
||||||
|
$newFromName = MimeHeaderDecoder::decode($message->getFromName());
|
||||||
|
|
||||||
|
$hasChange = $newSubject !== $message->getSubject() || $newFromName !== $message->getFromName();
|
||||||
|
|
||||||
|
if (!$hasChange) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($io->isVerbose()) {
|
||||||
|
$io->text(sprintf(' - #%d : "%s" → "%s"', $message->getId(), (string) $message->getSubject(), (string) $newSubject));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$isDryRun) {
|
||||||
|
$message->setSubject($newSubject);
|
||||||
|
$message->setFromName($newFromName);
|
||||||
|
}
|
||||||
|
|
||||||
|
++$changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$isDryRun) {
|
||||||
|
$this->entityManager->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->success(sprintf(
|
||||||
|
'%s%d en-tête(s) re-décodé(s).',
|
||||||
|
$isDryRun ? '[dry-run] ' : '',
|
||||||
|
$changed,
|
||||||
|
));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -351,7 +351,9 @@ final class ImapMailProvider implements MailProviderInterface
|
|||||||
{
|
{
|
||||||
$from = $message->getFrom()->first();
|
$from = $message->getFrom()->first();
|
||||||
$fromAddress = null !== $from ? (string) $from->mail : '';
|
$fromAddress = null !== $from ? (string) $from->mail : '';
|
||||||
$fromName = null !== $from && null !== $from->personal ? (string) $from->personal : null;
|
$fromName = null !== $from && null !== $from->personal
|
||||||
|
? MimeHeaderDecoder::decode((string) $from->personal)
|
||||||
|
: null;
|
||||||
|
|
||||||
$toAddresses = [];
|
$toAddresses = [];
|
||||||
foreach ($message->getTo() as $addr) {
|
foreach ($message->getTo() as $addr) {
|
||||||
@@ -388,7 +390,7 @@ final class ImapMailProvider implements MailProviderInterface
|
|||||||
return new MailMessageHeaderDto(
|
return new MailMessageHeaderDto(
|
||||||
uid: (int) $message->getUid(),
|
uid: (int) $message->getUid(),
|
||||||
messageId: (string) $message->getMessageId(),
|
messageId: (string) $message->getMessageId(),
|
||||||
subject: '' !== (string) $message->getSubject() ? (string) $message->getSubject() : null,
|
subject: '' !== (string) $message->getSubject() ? MimeHeaderDecoder::decode((string) $message->getSubject()) : null,
|
||||||
fromAddress: $fromAddress,
|
fromAddress: $fromAddress,
|
||||||
fromName: $fromName,
|
fromName: $fromName,
|
||||||
toAddresses: $toAddresses,
|
toAddresses: $toAddresses,
|
||||||
|
|||||||
47
src/Mail/MimeHeaderDecoder.php
Normal file
47
src/Mail/MimeHeaderDecoder.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use const ICONV_MIME_DECODE_CONTINUE_ON_ERROR;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Décode les en-têtes mail encodés en « encoded-words » MIME (RFC 2047),
|
||||||
|
* ex: "=?UTF-8?Q?Fwd=3A_Votre_inscription?=" → "Fwd: Votre inscription".
|
||||||
|
*
|
||||||
|
* Certains serveurs IMAP (OVH) renvoient les sujets / noms d'expéditeur
|
||||||
|
* encodés bruts ; webklex ne les décode pas systématiquement. Cet utilitaire
|
||||||
|
* normalise la sortie en UTF-8 lisible. Idempotent : un texte déjà décodé
|
||||||
|
* (sans séquence "=?") est retourné inchangé.
|
||||||
|
*/
|
||||||
|
final class MimeHeaderDecoder
|
||||||
|
{
|
||||||
|
public static function decode(?string $value): ?string
|
||||||
|
{
|
||||||
|
if (null === $value || '' === $value) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pas d'encoded-word → rien à faire (chemin rapide + idempotence).
|
||||||
|
if (!str_contains($value, '=?')) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = @iconv_mime_decode($value, ICONV_MIME_DECODE_CONTINUE_ON_ERROR, 'UTF-8');
|
||||||
|
|
||||||
|
if (false === $decoded || '' === trim($decoded)) {
|
||||||
|
// Fallback : mb_decode_mimeheader gère certains cas refusés par iconv.
|
||||||
|
$previous = mb_internal_encoding();
|
||||||
|
mb_internal_encoding('UTF-8');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$decoded = mb_decode_mimeheader($value);
|
||||||
|
} finally {
|
||||||
|
mb_internal_encoding($previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false === $decoded || '' === $decoded ? $value : $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
50
tests/Unit/Mail/MimeHeaderDecoderTest.php
Normal file
50
tests/Unit/Mail/MimeHeaderDecoderTest.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Mail;
|
||||||
|
|
||||||
|
use App\Mail\MimeHeaderDecoder;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
class MimeHeaderDecoderTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testDecodesQEncodedSubject(): void
|
||||||
|
{
|
||||||
|
self::assertSame(
|
||||||
|
'Fwd: Votre inscription',
|
||||||
|
MimeHeaderDecoder::decode('=?UTF-8?Q?Fwd=3A_Votre_inscription?='),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDecodesBEncodedSubjectWithAccents(): void
|
||||||
|
{
|
||||||
|
// "Réunion été" encodé en Base64 UTF-8
|
||||||
|
self::assertSame(
|
||||||
|
'Réunion été',
|
||||||
|
MimeHeaderDecoder::decode('=?UTF-8?B?UsOpdW5pb24gw6l0w6k=?='),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIsIdempotentOnPlainText(): void
|
||||||
|
{
|
||||||
|
self::assertSame('Christian ROY', MimeHeaderDecoder::decode('Christian ROY'));
|
||||||
|
self::assertSame('TR: Bail commercial', MimeHeaderDecoder::decode('TR: Bail commercial'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPreservesNullAndEmpty(): void
|
||||||
|
{
|
||||||
|
self::assertNull(MimeHeaderDecoder::decode(null));
|
||||||
|
self::assertSame('', MimeHeaderDecoder::decode(''));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFallsBackToOriginalWhenUndecodable(): void
|
||||||
|
{
|
||||||
|
// Charset inconnu : on ne perd pas la valeur d'origine.
|
||||||
|
$value = '=?unknown-charset?Q?test?=';
|
||||||
|
self::assertNotSame('', MimeHeaderDecoder::decode($value));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user