49 KiB
Mail Integration — Phase 5 : UI principale /mail (3 colonnes)
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Construire la page /mail en layout 3 colonnes (dossiers / liste / lecteur) avec composants Vue réutilisables, en branchant le store useMailStore créé en Phase 4. Sanitize HTML body via DOMPurify. Refus visuel pour ROLE_CLIENT.
Architecture: 1 page Nuxt pages/mail.vue, 4 composants sous components/mail/, 1 composable helper composables/useSystemFolderLabel.ts. Mapping noms dossiers système (INBOX → "Boîte de réception") côté front. Deep-link ?messageId=X pour sélectionner un mail à l'ouverture. Pas de mode édition/réponse en MVP.
Tech Stack: Nuxt 4, Vue 3 Composition API + <script setup lang="ts">, Pinia (useMailStore), @malio/layer-ui (MalioButton, MalioButtonIcon), Tailwind CSS, DOMPurify (via utils/sanitizeMailHtml), @nuxt/icon.
Branche cible : feat/mail-integration (vérifier qu'elle est active avant de commencer).
Fichiers créés/modifiés par le codeur :
| Fichier | Action |
|---|---|
frontend/pages/mail.vue |
Créer |
frontend/components/mail/MailFolderTree.vue |
Créer |
frontend/components/mail/MailMessageList.vue |
Créer |
frontend/components/mail/MailMessageViewer.vue |
Créer |
frontend/components/mail/MailRefreshButton.vue |
Créer |
frontend/composables/useSystemFolderLabel.ts |
Créer |
frontend/i18n/locales/fr.json |
Modifier (ajout clés mail.*) |
Task 1 : Vérification de l'environnement
-
Step 1 : Vérifier la branche active
git -C /home/r-dev/malio-dev/Lesstime branch --show-currentAttendu :
feat/mail-integration. Si non, basculer :git -C /home/r-dev/malio-dev/Lesstime checkout feat/mail-integration -
Step 2 : Vérifier que les fichiers Phase 4 existent
ls -la /home/r-dev/malio-dev/Lesstime/frontend/services/mail.ts \ /home/r-dev/malio-dev/Lesstime/frontend/stores/mail.ts \ /home/r-dev/malio-dev/Lesstime/frontend/utils/sanitizeMailHtml.ts \ /home/r-dev/malio-dev/Lesstime/frontend/services/dto/mail.tsAttendu : les 4 fichiers présents. Si absents, Phase 4 n'a pas été complétée — arrêter et implémenter Phase 4 d'abord.
-
Step 3 : Vérifier que TypeScript est propre (baseline)
cd /home/r-dev/malio-dev/Lesstime/frontend && npx tsc --noEmit 2>&1 | head -20Attendu : aucune erreur sur les fichiers Phase 4. Si des erreurs existent, les noter pour les distinguer des erreurs Phase 5.
Task 2 : Clés i18n mail.* — frontend/i18n/locales/fr.json
Les clés sont ajoutées dans fr.json (et en.json si présent — vérifier avec ls frontend/i18n/locales/). Le bloc mail est inséré après le dernier bloc de premier niveau existant (actuellement après help ou le dernier bloc de la spec existante).
Pattern existant observé : blocs de premier niveau avec sous-clés plates ou imbriquées (voir taskDocuments, timeEntries, myTasks).
-
Step 1 : Ajouter le bloc
maildansfrontend/i18n/locales/fr.jsonAjouter avant la fermeture
}finale du JSON :"mail": { "title": "Messagerie", "folders": "Dossiers", "messages": "Messages", "viewer": "Lecture", "empty": { "folder": "Aucun dossier disponible.", "list": "Aucun message dans ce dossier.", "viewer": "Sélectionnez un message pour le lire." }, "systemFolder": { "inbox": "Boîte de réception", "sent": "Éléments envoyés", "drafts": "Brouillons", "archive": "Archives", "trash": "Corbeille", "junk": "Indésirables" }, "actions": { "refresh": "Actualiser", "createTask": "Créer une tâche", "linkTask": "Lier à une tâche", "markRead": "Marquer comme lu", "markUnread": "Marquer comme non lu", "flag": "Marquer important", "unflag": "Retirer l'importance", "download": "Télécharger", "showImages": "Afficher les images" }, "errors": { "syncFailed": "Erreur lors de la synchronisation.", "fetchFailed": "Impossible de charger les messages.", "notAuthorized": "Vous n'avez pas accès à la messagerie." }, "configuration": { "saved": "Configuration mail enregistrée." }, "task": { "created": "Tâche créée depuis le mail.", "linked": "Mail lié à la tâche.", "unlinked": "Lien supprimé." }, "sync": { "dispatched": "Synchronisation lancée en arrière-plan." }, "attachments": "Pièces jointes", "noAttachments": "Aucune pièce jointe.", "from": "De", "to": "À", "cc": "Cc", "date": "Date", "subject": "Sujet", "noSubject": "(Sans objet)", "loadMore": "Charger plus", "loading": "Chargement…", "hasAttachments": "Pièces jointes", "unread": "non lu | non lus" } -
Step 2 : Si
frontend/i18n/locales/en.jsonexiste, ajouter le bloc traduitls /home/r-dev/malio-dev/Lesstime/frontend/i18n/locales/Si
en.jsonexiste, ajouter le même bloc avec traductions anglaises :"mail": { "title": "Mail", "folders": "Folders", "messages": "Messages", "viewer": "Reader", "empty": { "folder": "No folders available.", "list": "No messages in this folder.", "viewer": "Select a message to read it." }, "systemFolder": { "inbox": "Inbox", "sent": "Sent", "drafts": "Drafts", "archive": "Archive", "trash": "Trash", "junk": "Junk" }, "actions": { "refresh": "Refresh", "createTask": "Create task", "linkTask": "Link to task", "markRead": "Mark as read", "markUnread": "Mark as unread", "flag": "Mark as important", "unflag": "Remove importance", "download": "Download", "showImages": "Show images" }, "errors": { "syncFailed": "Synchronization failed.", "fetchFailed": "Unable to load messages.", "notAuthorized": "You do not have access to mail." }, "configuration": { "saved": "Mail configuration saved." }, "task": { "created": "Task created from mail.", "linked": "Mail linked to task.", "unlinked": "Link removed." }, "sync": { "dispatched": "Sync launched in background." }, "attachments": "Attachments", "noAttachments": "No attachments.", "from": "From", "to": "To", "cc": "Cc", "date": "Date", "subject": "Subject", "noSubject": "(No subject)", "loadMore": "Load more", "loading": "Loading…", "hasAttachments": "Attachments", "unread": "unread" } -
Step 3 : Valider que le JSON est syntaxiquement correct
node -e "JSON.parse(require('fs').readFileSync('/home/r-dev/malio-dev/Lesstime/frontend/i18n/locales/fr.json', 'utf8')); console.log('JSON OK')"Attendu :
JSON OK. Si erreur, corriger la virgule manquante ou le brace mal fermé. -
Step 4 : Commit
git -C /home/r-dev/malio-dev/Lesstime add frontend/i18n/locales/fr.json frontend/i18n/locales/en.json 2>/dev/null; true git -C /home/r-dev/malio-dev/Lesstime commit -m "feat(mail) : clés i18n mail.* (titres, vides, dossiers système, actions, erreurs)"
Task 3 : Composable useSystemFolderLabel.ts
Ce composable centralise le mapping entre les chemins de dossiers système IMAP (case-sensitive selon serveur) et leurs labels i18n. Il est utilisé par MailFolderTree.vue.
-
Step 1 : Créer
frontend/composables/useSystemFolderLabel.ts/** * Mapping des chemins de dossiers système IMAP vers les clés i18n. * Les clés sont normalisées en minuscules pour la comparaison. * Couvre les variantes OVH courantes (INBOX, INBOX.Sent, Sent, etc.) */ const SYSTEM_FOLDER_MAP: Record<string, string> = { 'inbox': 'mail.systemFolder.inbox', 'sent': 'mail.systemFolder.sent', 'inbox.sent': 'mail.systemFolder.sent', 'sent messages': 'mail.systemFolder.sent', 'drafts': 'mail.systemFolder.drafts', 'inbox.drafts': 'mail.systemFolder.drafts', 'archive': 'mail.systemFolder.archive', 'archives': 'mail.systemFolder.archive', 'inbox.archive': 'mail.systemFolder.archive', 'trash': 'mail.systemFolder.trash', 'deleted': 'mail.systemFolder.trash', 'deleted items': 'mail.systemFolder.trash', 'inbox.trash': 'mail.systemFolder.trash', 'junk': 'mail.systemFolder.junk', 'junk e-mail': 'mail.systemFolder.junk', 'spam': 'mail.systemFolder.junk', 'inbox.junk': 'mail.systemFolder.junk', } /** * Icônes Material Symbols associées aux dossiers système. * Pour les dossiers non reconnus : null → utiliser une icône générique. */ const SYSTEM_FOLDER_ICONS: Record<string, string> = { 'mail.systemFolder.inbox': 'material-symbols:inbox-outline', 'mail.systemFolder.sent': 'material-symbols:send-outline', 'mail.systemFolder.drafts': 'material-symbols:draft-outline', 'mail.systemFolder.archive': 'material-symbols:archive-outline', 'mail.systemFolder.trash': 'material-symbols:delete-outline', 'mail.systemFolder.junk': 'material-symbols:report-outline', } const DEFAULT_FOLDER_ICON = 'material-symbols:folder-outline' export function useSystemFolderLabel() { const { t } = useI18n() /** * Retourne le label traduit d'un dossier système, ou son displayName si inconnu. * @param path - Chemin IMAP du dossier (ex: "INBOX", "INBOX.Sent") * @param displayName - Nom affiché par défaut si non reconnu */ function getFolderLabel(path: string, displayName: string): string { const key = SYSTEM_FOLDER_MAP[path.toLowerCase()] return key ? t(key) : displayName } /** * Retourne le nom de l'icône Material Symbols pour un dossier. * @param path - Chemin IMAP du dossier */ function getFolderIcon(path: string): string { const key = SYSTEM_FOLDER_MAP[path.toLowerCase()] return key ? (SYSTEM_FOLDER_ICONS[key] ?? DEFAULT_FOLDER_ICON) : DEFAULT_FOLDER_ICON } /** * Indique si un dossier est un dossier système reconnu. */ function isSystemFolder(path: string): boolean { return path.toLowerCase() in SYSTEM_FOLDER_MAP } return { getFolderLabel, getFolderIcon, isSystemFolder, } } -
Step 2 : Vérification TypeScript
cd /home/r-dev/malio-dev/Lesstime/frontend && npx tsc --noEmit 2>&1 | grep "useSystemFolderLabel" | head -10Attendu : aucune erreur.
-
Step 3 : Commit
git -C /home/r-dev/malio-dev/Lesstime add frontend/composables/useSystemFolderLabel.ts git -C /home/r-dev/malio-dev/Lesstime commit -m "feat(mail) : composable useSystemFolderLabel — mapping dossiers système IMAP → i18n + icônes"
Task 4 : Composant MailRefreshButton.vue
Petit composant indépendant, sans dépendances sur les autres composants mail — à créer en premier pour qu'il soit disponible dans mail.vue.
-
Step 1 : Créer
frontend/components/mail/MailRefreshButton.vue<script setup lang="ts"> import { useMailStore } from '~/stores/mail' const store = useMailStore() const { syncing } = storeToRefs(store) const { t } = useI18n() async function handleRefresh(): Promise<void> { await store.triggerSync() } </script> <template> <MalioButton :label="t('mail.actions.refresh')" variant="secondary" icon-name="material-symbols:refresh" icon-position="left" :icon-size="16" :disabled="syncing" @click="handleRefresh" /> </template> -
Step 2 : Commit
git -C /home/r-dev/malio-dev/Lesstime add frontend/components/mail/MailRefreshButton.vue git -C /home/r-dev/malio-dev/Lesstime commit -m "feat(mail) : MailRefreshButton — bouton sync manuel, disabled pendant syncing"
Task 5 : Composant MailFolderTree.vue
Arbre récursif des dossiers mail. Reçoit folderTree (computed du store, déjà arborifié). Émet select avec le path du dossier cliqué.
-
Step 1 : Créer
frontend/components/mail/MailFolderTree.vue<script setup lang="ts"> import type { MailFolderDto } from '~/services/dto/mail' const props = defineProps<{ /** Arbre de dossiers (getter folderTree du store) */ folders: MailFolderDto[] /** Chemin du dossier actuellement sélectionné */ selectedPath: string | null /** Niveau de profondeur pour l'indentation (usage récursif interne) */ depth?: number }>() const emit = defineEmits<{ select: [path: string] }>() const { getFolderLabel, getFolderIcon } = useSystemFolderLabel() const { t } = useI18n() const depth = computed(() => props.depth ?? 0) function handleSelect(path: string): void { emit('select', path) } </script> <template> <div> <div v-if="folders.length === 0 && depth === 0" class="px-3 py-4 text-sm text-neutral-400 italic"> {{ t('mail.empty.folder') }} </div> <template v-else> <button v-for="folder in folders" :key="folder.path" type="button" class="w-full text-left" @click="handleSelect(folder.path)" > <!-- Entrée dossier --> <div class="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm transition-colors" :class="[ { 'pl-[calc(0.75rem_+_theme(spacing[3])_*_v-bind(depth))]': depth > 0 }, selectedPath === folder.path ? 'bg-primary-100 text-primary-700 font-medium' : 'text-neutral-700 hover:bg-neutral-100', ]" :style="depth > 0 ? { paddingLeft: `${0.75 + depth * 0.75}rem` } : {}" > <!-- Icône dossier --> <Icon :name="getFolderIcon(folder.path)" size="16" class="flex-shrink-0" :class="selectedPath === folder.path ? 'text-primary-600' : 'text-neutral-400'" /> <!-- Label --> <span class="flex-1 truncate"> {{ getFolderLabel(folder.path, folder.displayName) }} </span> <!-- Badge non-lus --> <span v-if="folder.unreadCount > 0" class="ml-auto flex-shrink-0 rounded-full bg-primary-500 px-1.5 py-0.5 text-xs font-bold text-white" > {{ folder.unreadCount > 99 ? '99+' : folder.unreadCount }} </span> </div> <!-- Sous-dossiers récursifs --> <MailFolderTree v-if="folder.children && folder.children.length > 0" :folders="folder.children" :selected-path="selectedPath" :depth="depth + 1" @select="handleSelect" /> </button> </template> </div> </template>Note technique : l'indentation dynamique par CSS
calcavecv-bind(depth)n'est pas fiable dans Tailwind — utiliser:stylenatif comme ci-dessus (paddingLeft: depth * 0.75rem). La récursivité via<MailFolderTree>fonctionne car Nuxt auto-importe les composants. -
Step 2 : Commit
git -C /home/r-dev/malio-dev/Lesstime add frontend/components/mail/MailFolderTree.vue git -C /home/r-dev/malio-dev/Lesstime commit -m "feat(mail) : MailFolderTree — arbre récursif dossiers, badges unread, icônes système"
Task 6 : Composant MailMessageList.vue
Liste paginée des messages du dossier sélectionné. Infinite scroll via IntersectionObserver sur un sentinel div en bas de liste. Indicateurs visuels : point ● (non lu), étoile ⭐ (flagged), trombone 📎 (hasAttachments), date relative.
-
Step 1 : Créer
frontend/components/mail/MailMessageList.vue<script setup lang="ts"> import type { MailMessageHeaderDto } from '~/services/dto/mail' const props = defineProps<{ messages: MailMessageHeaderDto[] selectedId: number | null loading: boolean hasMore: boolean }>() const emit = defineEmits<{ select: [id: number] loadMore: [] }>() const { t } = useI18n() // ─── Infinite scroll via IntersectionObserver ────────────────────────────── const sentinelRef = ref<HTMLDivElement | null>(null) let observer: IntersectionObserver | null = null onMounted(() => { if (!sentinelRef.value) return observer = new IntersectionObserver( (entries) => { const entry = entries[0] if (entry?.isIntersecting && props.hasMore && !props.loading) { emit('loadMore') } }, { threshold: 0.1 }, ) observer.observe(sentinelRef.value) }) onBeforeUnmount(() => { observer?.disconnect() observer = null }) // ─── Date relative ────────────────────────────────────────────────────────── /** * Formate une date ISO en date relative (il y a X minutes/heures/jours). * Utilise Intl.RelativeTimeFormat avec la locale du navigateur. */ function formatRelative(isoDate: string | null): string { if (!isoDate) return '' const date = new Date(isoDate) const now = new Date() const diffMs = date.getTime() - now.getTime() const diffSeconds = Math.round(diffMs / 1000) const diffMinutes = Math.round(diffSeconds / 60) const diffHours = Math.round(diffMinutes / 60) const diffDays = Math.round(diffHours / 24) const rtf = new Intl.RelativeTimeFormat('fr', { numeric: 'auto' }) if (Math.abs(diffMinutes) < 1) return rtf.format(diffSeconds, 'second') if (Math.abs(diffHours) < 1) return rtf.format(diffMinutes, 'minute') if (Math.abs(diffDays) < 1) return rtf.format(diffHours, 'hour') if (Math.abs(diffDays) < 30) return rtf.format(diffDays, 'day') // Au-delà de 30 jours : date courte return date.toLocaleDateString('fr', { day: '2-digit', month: 'short', year: 'numeric' }) } /** * Retourne un label court pour l'expéditeur (nom si disponible, sinon email). */ function getSenderLabel(msg: MailMessageHeaderDto): string { return msg.fromName ?? msg.fromEmail ?? '' } </script> <template> <div class="flex h-full flex-col overflow-hidden"> <!-- État vide --> <div v-if="!loading && messages.length === 0" class="flex flex-1 items-center justify-center text-sm text-neutral-400 italic px-4 text-center" > {{ t('mail.empty.list') }} </div> <!-- Liste des messages --> <div v-else class="flex-1 overflow-y-auto divide-y divide-neutral-100"> <button v-for="msg in messages" :key="msg.id" type="button" class="flex w-full gap-3 px-3 py-3 text-left transition-colors hover:bg-neutral-50 focus:outline-none" :class="[ selectedId === msg.id ? 'bg-primary-50 border-l-2 border-primary-500' : '', !msg.isRead ? 'bg-white' : 'bg-neutral-50/50', ]" @click="emit('select', msg.id)" > <!-- Indicateur non-lu --> <div class="mt-1.5 flex-shrink-0"> <span class="block h-2 w-2 rounded-full" :class="msg.isRead ? 'bg-transparent' : 'bg-primary-500'" /> </div> <!-- Contenu --> <div class="min-w-0 flex-1"> <!-- Ligne 1 : expéditeur + date --> <div class="flex items-center justify-between gap-2"> <span class="truncate text-sm" :class="msg.isRead ? 'text-neutral-600 font-normal' : 'text-neutral-900 font-semibold'" > {{ getSenderLabel(msg) }} </span> <span class="flex-shrink-0 text-xs text-neutral-400"> {{ formatRelative(msg.sentAt ?? msg.receivedAt) }} </span> </div> <!-- Ligne 2 : sujet --> <p class="truncate text-sm" :class="msg.isRead ? 'text-neutral-500' : 'text-neutral-800 font-medium'" > {{ msg.subject ?? t('mail.noSubject') }} </p> <!-- Ligne 3 : indicateurs --> <div class="mt-0.5 flex items-center gap-1.5"> <Icon v-if="msg.isFlagged" name="material-symbols:star" size="14" class="text-amber-400 flex-shrink-0" /> <Icon v-if="msg.hasAttachments" name="material-symbols:attach-file" size="14" class="text-neutral-400 flex-shrink-0" /> <span v-if="msg.linkedTaskIds.length > 0" class="text-xs text-primary-400"> <Icon name="material-symbols:task-outline" size="14" class="flex-shrink-0" /> </span> </div> </div> </button> <!-- Sentinel infinite scroll --> <div ref="sentinelRef" class="h-px" /> <!-- Spinner chargement page suivante --> <div v-if="loading && messages.length > 0" class="flex items-center justify-center py-4"> <Icon name="material-symbols:progress-activity" size="20" class="animate-spin text-neutral-400" /> </div> </div> <!-- Premier chargement --> <div v-if="loading && messages.length === 0" class="flex flex-1 items-center justify-center"> <Icon name="material-symbols:progress-activity" size="24" class="animate-spin text-neutral-400" /> </div> </div> </template> -
Step 2 : Commit
git -C /home/r-dev/malio-dev/Lesstime add frontend/components/mail/MailMessageList.vue git -C /home/r-dev/malio-dev/Lesstime commit -m "feat(mail) : MailMessageList — liste paginée infinite scroll, indicateurs lu/étoilé/PJ/date relative"
Task 7 : Composant MailMessageViewer.vue
Lecteur de mail complet. Reçoit le détail complet du message sélectionné. Sanitize le HTML body via sanitizeMailHtml. Gère le toggle "Afficher les images distantes". Actions : Créer tâche, Lier à tâche, Marquer lu/non-lu, Étoiler. Téléchargement PJ.
-
Step 1 : Créer
frontend/components/mail/MailMessageViewer.vue<script setup lang="ts"> import type { MailMessageDetailDto } from '~/services/dto/mail' import { sanitizeMailHtml } from '~/utils/sanitizeMailHtml' import { useMailService } from '~/services/mail' import { useMailStore } from '~/stores/mail' const props = defineProps<{ /** Détail complet du message. null = aucun message sélectionné. */ detail: MailMessageDetailDto | null loading: boolean }>() const emit = defineEmits<{ /** Demande d'ouverture du modal "Créer tâche depuis ce mail" */ createTask: [mailId: number] /** Demande d'ouverture du modal "Lier à une tâche existante" */ linkTask: [mailId: number] }>() const { t } = useI18n() const store = useMailStore() const mailService = useMailService() // ─── Toggle images distantes ────────────────────────────────────────────── const showImages = ref(false) // ─── HTML sanitizé (client-side uniquement — projet SPA, pas de SSR) ────── const sanitizedBody = computed((): string => { if (!props.detail?.bodyHtml) return '' return sanitizeMailHtml(props.detail.bodyHtml, { allowImages: showImages.value }) }) // Reset toggle images quand on change de message watch(() => props.detail?.header.id, () => { showImages.value = false }) // ─── Actions ────────────────────────────────────────────────────────────── async function handleMarkReadToggle(): Promise<void> { if (!props.detail) return const id = props.detail.header.id const currentlyRead = props.detail.header.isRead await store.markRead(id, !currentlyRead) } async function handleFlagToggle(): Promise<void> { if (!props.detail) return const id = props.detail.header.id const currentlyFlagged = props.detail.header.isFlagged await store.markFlagged(id, !currentlyFlagged) } async function handleDownload(downloadId: string, filename: string): Promise<void> { try { const { data } = await mailService.downloadAttachment(downloadId) const url = URL.createObjectURL(data) const a = document.createElement('a') a.href = url a.download = filename a.click() URL.revokeObjectURL(url) } catch { // L'erreur est gérée par useApi (toast automatique) } } // ─── Formatage ──────────────────────────────────────────────────────────── function formatDate(iso: string | null): string { if (!iso) return '' return new Date(iso).toLocaleString('fr', { dateStyle: 'long', timeStyle: 'short', }) } function joinAddresses( addresses: Array<{ name: string | null; email: string }>, ): string { return addresses .map((a) => (a.name ? `${a.name} <${a.email}>` : a.email)) .join(', ') } </script> <template> <div class="flex h-full flex-col overflow-hidden"> <!-- État vide --> <div v-if="!detail && !loading" class="flex flex-1 items-center justify-center text-sm text-neutral-400 italic px-8 text-center" > {{ t('mail.empty.viewer') }} </div> <!-- Chargement --> <div v-else-if="loading" class="flex flex-1 items-center justify-center"> <Icon name="material-symbols:progress-activity" size="28" class="animate-spin text-neutral-400" /> </div> <!-- Message chargé --> <template v-else-if="detail"> <!-- Header message --> <div class="flex-shrink-0 border-b border-neutral-200 px-4 py-3 space-y-1.5"> <!-- Sujet --> <h2 class="text-base font-semibold text-neutral-900 break-words"> {{ detail.header.subject ?? t('mail.noSubject') }} </h2> <!-- Métadonnées expéditeur/destinataires --> <dl class="text-xs text-neutral-500 space-y-0.5"> <div class="flex gap-1.5"> <dt class="font-medium text-neutral-600 w-5 flex-shrink-0">{{ t('mail.from') }}</dt> <dd class="break-all"> {{ detail.header.fromName ? `${detail.header.fromName} <${detail.header.fromEmail}>` : (detail.header.fromEmail ?? '') }} </dd> </div> <div v-if="detail.header.toRecipients.length > 0" class="flex gap-1.5"> <dt class="font-medium text-neutral-600 w-5 flex-shrink-0">{{ t('mail.to') }}</dt> <dd class="break-all">{{ joinAddresses(detail.header.toRecipients) }}</dd> </div> <div v-if="detail.header.ccRecipients.length > 0" class="flex gap-1.5"> <dt class="font-medium text-neutral-600 w-5 flex-shrink-0">{{ t('mail.cc') }}</dt> <dd class="break-all">{{ joinAddresses(detail.header.ccRecipients) }}</dd> </div> <div class="flex gap-1.5"> <dt class="font-medium text-neutral-600 w-5 flex-shrink-0">{{ t('mail.date') }}</dt> <dd>{{ formatDate(detail.header.sentAt ?? detail.header.receivedAt) }}</dd> </div> </dl> <!-- Barre d'actions --> <div class="flex flex-wrap items-center gap-2 pt-1"> <MalioButton :label="t('mail.actions.createTask')" variant="primary" icon-name="material-symbols:add-task-outline" icon-position="left" :icon-size="14" @click="emit('createTask', detail.header.id)" /> <MalioButton :label="t('mail.actions.linkTask')" variant="secondary" icon-name="material-symbols:link" icon-position="left" :icon-size="14" @click="emit('linkTask', detail.header.id)" /> <MalioButton :label="detail.header.isRead ? t('mail.actions.markUnread') : t('mail.actions.markRead')" variant="tertiary" :icon-name="detail.header.isRead ? 'material-symbols:mark-email-unread-outline' : 'material-symbols:mark-email-read-outline'" icon-position="left" :icon-size="14" @click="handleMarkReadToggle" /> <MalioButton :label="detail.header.isFlagged ? t('mail.actions.unflag') : t('mail.actions.flag')" variant="tertiary" :icon-name="detail.header.isFlagged ? 'material-symbols:star' : 'material-symbols:star-outline'" icon-position="left" :icon-size="14" @click="handleFlagToggle" /> </div> </div> <!-- Corps du message --> <div class="flex-1 overflow-y-auto px-4 py-3"> <!-- Bannière "Afficher les images" --> <div v-if="!showImages && detail.bodyHtml" class="mb-3 flex items-center gap-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm" > <Icon name="material-symbols:image-outline" size="16" class="text-amber-500 flex-shrink-0" /> <span class="flex-1 text-amber-700">Les images distantes sont masquées pour votre sécurité.</span> <button type="button" class="text-xs font-medium text-amber-700 underline hover:text-amber-900 transition-colors" @click="showImages = true" > {{ t('mail.actions.showImages') }} </button> </div> <!-- Corps HTML sanitizé --> <div v-if="detail.bodyHtml" class="prose prose-sm max-w-none text-neutral-800" v-html="sanitizedBody" /> <!-- Fallback texte plain --> <pre v-else-if="detail.bodyText" class="whitespace-pre-wrap font-sans text-sm text-neutral-700" >{{ detail.bodyText }}</pre> </div> <!-- Pièces jointes --> <div v-if="detail.attachments.length > 0" class="flex-shrink-0 border-t border-neutral-200 px-4 py-3" > <p class="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-500"> {{ t('mail.attachments') }} ({{ detail.attachments.length }}) </p> <div class="flex flex-wrap gap-2"> <button v-for="att in detail.attachments" :key="att.downloadId" type="button" class="flex items-center gap-1.5 rounded border border-neutral-200 bg-neutral-50 px-2.5 py-1.5 text-xs text-neutral-700 transition-colors hover:bg-neutral-100 hover:border-neutral-300" :title="att.filename" @click="handleDownload(att.downloadId, att.filename)" > <Icon name="material-symbols:attach-file" size="14" class="flex-shrink-0 text-neutral-400" /> <span class="max-w-[180px] truncate">{{ att.filename }}</span> <span class="text-neutral-400">({{ Math.round(att.size / 1024) }} Ko)</span> </button> </div> </div> </template> </div> </template>Points clés :
v-html="sanitizedBody"est le seulv-htmldu composant, et il reçoit toujours la sortie desanitizeMailHtml— jamais lerawHtmlbrut directement.- La bannière "Afficher les images" est visible dès qu'il y a un
bodyHtmlet queshowImagesest false. handleDownloadcrée un lien<a>temporaire pour déclencher le téléchargement du Blob retourné parmailService.downloadAttachment.
-
Step 2 : Commit
git -C /home/r-dev/malio-dev/Lesstime add frontend/components/mail/MailMessageViewer.vue git -C /home/r-dev/malio-dev/Lesstime commit -m "feat(mail) : MailMessageViewer — header, body sanitizé DOMPurify, PJ téléchargeables, 4 actions"
Task 8 : Page pages/mail.vue
Page principale en layout 3 colonnes. Intègre les 4 composants créés ci-dessus. Gère le deep-link ?messageId=X. Vérifie le rôle ROLE_CLIENT (redirection /portal). Le middleware global auth.global.ts gère déjà l'authentification de base — ici on ajoute seulement le check spécifique ROLE_CLIENT dans onMounted.
-
Step 1 : Créer
frontend/pages/mail.vue<script setup lang="ts"> import { useMailStore } from '~/stores/mail' const { t } = useI18n() const router = useRouter() const route = useRoute() const auth = useAuthStore() useHead({ title: t('mail.title') }) // ─── Contrôle d'accès ROLE_CLIENT ───────────────────────────────────────── // Le middleware global gère auth. Ici : ROLE_CLIENT (sans ROLE_ADMIN) → /portal. // Vérifié dans onMounted pour être sûr que auth est initialisé (SPA). const isClientOnly = computed(() => auth.user?.roles?.includes('ROLE_CLIENT') === true && auth.user?.roles?.includes('ROLE_ADMIN') !== true, ) if (isClientOnly.value) { await navigateTo('/portal') } // ─── Store ──────────────────────────────────────────────────────────────── const store = useMailStore() const { folderTree, selectedFolderPath, messages, messagesLoading, hasMoreMessages, selectedMessageId, selectedMessageDetail, detailLoading, } = storeToRefs(store) // ─── Init : charge les dossiers + deep-link ─────────────────────────────── onMounted(async () => { // Double-check rôle après hydratation (SPA guard) if (isClientOnly.value) { router.replace('/portal') return } // Charger les dossiers si pas encore chargés if (folderTree.value.length === 0) { await store.fetchFolders() } // Sélectionner INBOX par défaut if (!selectedFolderPath.value && folderTree.value.length > 0) { const inbox = folderTree.value.find((f) => f.path.toUpperCase() === 'INBOX') await store.selectFolder(inbox?.path ?? folderTree.value[0]!.path) } // Deep-link : ?messageId=X → sélectionner automatiquement ce message const messageIdParam = route.query.messageId if (messageIdParam) { const id = parseInt(String(messageIdParam), 10) if (!isNaN(id)) { await store.selectMessage(id) } } }) onBeforeUnmount(() => { // Ne pas arrêter le polling ici — il est géré globalement par le layout (Phase 7) // La page peut être démonter sans couper le compteur de non-lus global }) // ─── Handlers ───────────────────────────────────────────────────────────── async function handleFolderSelect(path: string): Promise<void> { await store.selectFolder(path) // Nettoyer le query param messageId si présent if (route.query.messageId) { router.replace({ query: { ...route.query, messageId: undefined } }) } } async function handleMessageSelect(id: number): Promise<void> { await store.selectMessage(id) } function handleLoadMore(): void { store.fetchMessages(true) } // Phase 6 : ces handlers seront branchés sur les modals MailCreateTaskModal / MailLinkTaskModal function handleCreateTask(mailId: number): void { // TODO Phase 6 : ouvrir MailCreateTaskModal console.warn('[mail] handleCreateTask mailId=', mailId, '— modal à implémenter en Phase 6') } function handleLinkTask(mailId: number): void { // TODO Phase 6 : ouvrir MailLinkTaskModal console.warn('[mail] handleLinkTask mailId=', mailId, '— modal à implémenter en Phase 6') } </script> <template> <div class="flex h-full flex-col overflow-hidden"> <!-- Topbar page --> <div class="flex flex-shrink-0 items-center justify-between border-b border-neutral-200 bg-white px-4 py-3"> <h1 class="text-lg font-semibold text-neutral-900"> {{ t('mail.title') }} </h1> <MailRefreshButton /> </div> <!-- Layout 3 colonnes --> <div class="flex flex-1 overflow-hidden"> <!-- Colonne 1 : Dossiers (fixe, largeur 220px) --> <aside class="w-[220px] flex-shrink-0 overflow-y-auto border-r border-neutral-200 bg-neutral-50 py-2"> <p class="mb-1 px-3 text-xs font-semibold uppercase tracking-wide text-neutral-400"> {{ t('mail.folders') }} </p> <MailFolderTree :folders="folderTree" :selected-path="selectedFolderPath" @select="handleFolderSelect" /> </aside> <!-- Colonne 2 : Liste des messages (fixe, largeur 320px) --> <div class="w-[320px] flex-shrink-0 overflow-hidden border-r border-neutral-200 bg-white"> <div class="flex items-center justify-between border-b border-neutral-100 px-3 py-2"> <p class="text-xs font-semibold uppercase tracking-wide text-neutral-400"> {{ t('mail.messages') }} </p> </div> <div class="h-[calc(100%-37px)]"> <MailMessageList :messages="messages" :selected-id="selectedMessageId" :loading="messagesLoading" :has-more="hasMoreMessages" @select="handleMessageSelect" @load-more="handleLoadMore" /> </div> </div> <!-- Colonne 3 : Lecteur (flex, prend le reste) --> <div class="flex-1 overflow-hidden bg-white"> <MailMessageViewer :detail="selectedMessageDetail" :loading="detailLoading" @create-task="handleCreateTask" @link-task="handleLinkTask" /> </div> </div> </div> </template>Points clés :
await navigateTo('/portal')au niveau du<script setup>(avantonMounted) pour un redirect immédiat si ROLE_CLIENT détecté à l'hydratation.- Le double-check dans
onMountedcouvre le cas oùauthn'est pas encore résolu au premier rendu SPA. - Les
console.warnpour les handlers Phase 6 sont intentionnels (placeholder visible en dev). h-[calc(100%-37px)]: la topbar de la colonne liste fait ~37px (py-2 + texte xs) — ajuster si besoin après inspection visuelle.
-
Step 2 : Commit
git -C /home/r-dev/malio-dev/Lesstime add frontend/pages/mail.vue git -C /home/r-dev/malio-dev/Lesstime commit -m "feat(mail) : page /mail — layout 3 colonnes, deep-link messageId, refus ROLE_CLIENT"
Task 9 : Validation TypeScript globale + test manuel
-
Step 1 : TypeScript strict — zéro erreur sur les fichiers Phase 5
cd /home/r-dev/malio-dev/Lesstime/frontend && npx tsc --noEmit 2>&1Attendu : sortie vide (0 erreur). Erreurs fréquentes à anticiper et corriger :
Property 'XXX' does not exist on type 'DeepReadonly<...>'→ les refs du store sontreadonly()— utiliser.valuedirectement oustoreToRefs()(déjà fait dansmail.vue).Cannot find name 'useSystemFolderLabel'→ vérifier que le composable est danscomposables/(auto-importé par Nuxt).'MailFolderTree' is not defined→ Nuxt auto-importe les composants decomponents/— vérifier le nom du fichier (PascalCase).Property 'depth' does not exist→ dansMailFolderTree,depthest une prop avec valeur par défaut — s'assurer que le type estnumber | undefineddansdefineProps.
-
Step 2 : Démarrer le serveur de développement
cd /home/r-dev/malio-dev/Lesstime && make dev-nuxtAttendu :
Nuxt readysurhttp://localhost:3002. -
Step 3 : Tests manuels (checklist)
Se connecter avec
alice/alice(ROLE_USER) :- Naviguer vers
http://localhost:3002/mail→ page visible, layout 3 colonnes affiché - Les 3 colonnes sont présentes (même si vides — backend Phase 3 peut ne pas être déployé)
- Bouton "Actualiser" visible en haut à droite
- Console DevTools : aucune erreur Vue/Nuxt (les
console.warnPhase 6 sont acceptables) - TypeScript : aucune erreur rouge dans l'IDE
Se connecter avec
client-liot/client(ROLE_CLIENT) :- Naviguer vers
http://localhost:3002/mail→ redirect automatique vers/portal - Pas d'affichage momentané de la page mail avant le redirect
Test anti-XSS (si backend Phase 3 disponible) :
- Injecter
<script>alert(1)</script>dans un corps de mail (via devtools ou fixture) - Ouvrir le mail dans le lecteur → aucune alerte JS, contenu rendu inoffensif
- Vérifier dans le DOM inspecté que la balise
<script>est absente
Test images distantes :
- Un mail avec
<img src="https://example.com/pixel.gif">dans le body - À l'ouverture : placeholder
[Image distante — cliquer pour afficher]visible à la place - Cliquer sur "Afficher les images" → l'image est chargée (si réseau disponible)
- Naviguer vers
-
Step 4 : Commit de correction si nécessaire
Si des bugs TypeScript ou visuels ont été détectés et corrigés :
git -C /home/r-dev/malio-dev/Lesstime add frontend/pages/mail.vue frontend/components/mail/ frontend/composables/useSystemFolderLabel.ts git -C /home/r-dev/malio-dev/Lesstime commit -m "fix(mail) : corrections post-test Phase 5 (TypeScript + rendu)"
Exigences techniques
<script setup lang="ts">partout — aucundefineComponent, aucun Options API- 4 espaces d'indentation — convention Lesstime (cf. CLAUDE.md)
- Imports Pinia :
const store = useMailStore()puisconst { ... } = storeToRefs(store)pour la réactivité des refs v-htmluniquement avec sortie desanitizeMailHtml()— jamais avecrawHtmlbrut- Images distantes : toujours bloquées par défaut via
sanitizeMailHtml({ allowImages: false })— toggle explicite par l'utilisateur - Tailwind : classes du design system observé dans les composants existants (
bg-primary-*,text-neutral-*,border-neutral-*,rounded-md, spacing 3/4) - Icons :
<Icon name="material-symbols:..." />— vérifier que les icônes existent sur icones.js.org (collectionmaterial-symbols) - Date relative :
Intl.RelativeTimeFormatnatif, localefr— aucune dépendance externe - DOMPurify : import dans
utils/sanitizeMailHtml.ts(déjà créé Phase 4) — ne pas reimporter DOMPurify directement dans les composants - SSR : le projet est SPA (Nuxt 4 SSR off), donc
sanitizeMailHtmlpeut être appelé danscomputeddirectement sans guardimport.meta.client - ROLE_CLIENT : double protection — middleware global (
auth.global.ts) + check explicite danspages/mail.vue - Format commits :
feat(mail) : <message>(espace avant:), 1 commit par composant/fichier
Fichiers créés/modifiés — récapitulatif
| Fichier | Type | Action |
|---|---|---|
frontend/pages/mail.vue |
Page Nuxt | Créer |
frontend/components/mail/MailFolderTree.vue |
Composant Vue | Créer |
frontend/components/mail/MailMessageList.vue |
Composant Vue | Créer |
frontend/components/mail/MailMessageViewer.vue |
Composant Vue | Créer |
frontend/components/mail/MailRefreshButton.vue |
Composant Vue | Créer |
frontend/composables/useSystemFolderLabel.ts |
Composable | Créer |
frontend/i18n/locales/fr.json |
i18n | Modifier (ajout bloc mail) |
frontend/i18n/locales/en.json |
i18n | Modifier si fichier existe |
Fichiers Phase 4 utilisés (ne pas modifier) :
| Fichier | Utilisation dans Phase 5 |
|---|---|
frontend/stores/mail.ts |
useMailStore() — store central |
frontend/services/mail.ts |
useMailService().downloadAttachment() dans MailMessageViewer |
frontend/utils/sanitizeMailHtml.ts |
sanitizeMailHtml() dans MailMessageViewer |
frontend/services/dto/mail.ts |
Types MailFolderDto, MailMessageHeaderDto, MailMessageDetailDto |
Output attendu — critères d'acceptation Phase 5
| Critère | Commande / vérification |
|---|---|
| TypeScript strict : 0 erreur | cd frontend && npx tsc --noEmit → sortie vide |
Page /mail accessible ROLE_USER |
http://localhost:3002/mail avec alice → page visible |
ROLE_CLIENT redirigé /portal |
Login avec client-liot, naviguer /mail → redirect /portal |
| Pas d'XSS via body mail | Injecter <script>alert(1)</script> → aucune alerte |
| Images distantes bloquées par défaut | Body avec <img src="https://..."> → placeholder visible |
| Infinite scroll fonctionnel | Scroll jusqu'en bas de la liste → chargement page suivante |
Deep-link ?messageId=X |
http://localhost:3002/mail?messageId=42 → message 42 sélectionné |
| Pas d'erreur console Vue | DevTools → onglet Console → aucune erreur rouge |
La Phase 6 (Intégration tâches : MailCreateTaskModal, MailLinkTaskModal, onglet "Mails" TaskDrawer) peut commencer dès que tous ces critères sont satisfaits.