Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
325a7b07f9 | ||
|
|
bcbc04325e |
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.4.6'
|
app.version: '0.4.7'
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
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()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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> {
|
async function handleDownload(downloadId: string, filename: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { data } = await mailService.downloadAttachment(downloadId)
|
const { data } = await mailService.downloadAttachment(downloadId)
|
||||||
const url = URL.createObjectURL(data)
|
triggerBlobDownload(data, filename)
|
||||||
const a = document.createElement('a')
|
|
||||||
a.href = url
|
|
||||||
a.download = filename
|
|
||||||
a.click()
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
} 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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
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