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
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>
This commit is contained in:
@@ -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)
|
||||
|
||||
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">
|
||||
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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
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();
|
||||
$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,
|
||||
|
||||
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