Compare commits

...

4 Commits

Author SHA1 Message Date
gitea-actions
325a7b07f9 chore: bump version to v0.4.7
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 52s
2026-05-21 09:42:51 +00:00
Matthieu
bcbc04325e feat(mail) : décodage des en-têtes MIME + aperçu inline des pièces jointes
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
- Décode les encoded-words MIME (RFC 2047) des sujets et noms d'expéditeur
  via App\Mail\MimeHeaderDecoder, appliqué dans ImapMailProvider (sync propre)
- Commande app:mail:redecode-headers (--dry-run) pour re-décoder l'existant en base
- Aperçu inline images + PDF en visionneuse modale plein écran (MailAttachmentPreview),
  téléchargement conservé pour les autres types
- Tests unitaires du décodeur + maj docs/mail-integration.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:42:38 +02:00
gitea-actions
8f2a688740 chore: bump version to v0.4.6
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 48s
2026-05-21 09:19:01 +00:00
Matthieu
6491943930 docs : ajoute la section Messagerie au centre d'aide + maj admin/intégrations (mail OVH)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:18:42 +02:00
13 changed files with 500 additions and 15 deletions

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.4.5'
app.version: '0.4.7'

View File

@@ -35,6 +35,7 @@
> 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)
> 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
> ```
> 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)
- Liste paginée des messages (infinite scroll, cursor-based)
- 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)
- Lien mail ↔ tâche (bidirectionnel)
- 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/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/stores/mail.ts` — store Pinia (folders, messages, polling)
- `frontend/services/mail.ts` — service API (toutes les méthodes)

View 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>

View File

@@ -1,5 +1,5 @@
<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 { useMailService } from '~/services/mail'
@@ -24,22 +24,101 @@ const sanitizedBody = computed((): string => {
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(
() => props.detail?.header.id,
() => {
showImages.value = false
previewOpen.value = false
revokePreview()
},
)
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 {
const { data } = await mailService.downloadAttachment(att.downloadId)
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')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
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)
triggerBlobDownload(data, filename)
} catch {
// L'erreur est gérée par useApi (toast automatique)
}
@@ -169,15 +248,31 @@ function joinAddresses(addresses: MailAddressDto[]): string {
: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)"
:title="isPreviewable(att.mimeType) ? t('mail.preview.open') : t('mail.actions.download')"
@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="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>
</div>
</div>
<MailAttachmentPreview
v-if="previewAtt"
v-model="previewOpen"
:filename="previewAtt.filename"
:mime-type="previewAtt.mimeType"
:url="previewUrl"
:loading="previewLoading"
@download="downloadFromPreview"
/>
</template>
</div>
</template>

View File

@@ -64,3 +64,13 @@ L'admin (`/admin`) est divisé en plusieurs onglets, chacun gérant une ressourc
- Configure le calendrier CalDav par défaut
- Test de connexion intégré
- 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)

View File

@@ -1,6 +1,6 @@
# 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
@@ -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
- **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)

View 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*.

View File

@@ -528,6 +528,12 @@
"list": "Aucun message dans ce dossier.",
"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": {
"expand": "Déplier le dossier",
"collapse": "Replier le dossier"

View File

@@ -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'] },
'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'] },
'10-messaging': { title: 'Messagerie', icon: 'mdi:email-outline', accent: 'from-teal-500 to-cyan-600', roles: ['admin', 'user'] },
}
const sections = computed<Section[]>(() => {

View 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;
}
}

View File

@@ -351,7 +351,9 @@ final class ImapMailProvider implements MailProviderInterface
{
$from = $message->getFrom()->first();
$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 = [];
foreach ($message->getTo() as $addr) {
@@ -388,7 +390,7 @@ final class ImapMailProvider implements MailProviderInterface
return new MailMessageHeaderDto(
uid: (int) $message->getUid(),
messageId: (string) $message->getMessageId(),
subject: '' !== (string) $message->getSubject() ? (string) $message->getSubject() : null,
subject: '' !== (string) $message->getSubject() ? MimeHeaderDecoder::decode((string) $message->getSubject()) : null,
fromAddress: $fromAddress,
fromName: $fromName,
toAddresses: $toAddresses,

View 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;
}
}

View 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));
}
}