Files
Lesstime/docs/superpowers/plans/2026-05-19-mail-phase5-ui-main.md

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

    Attendu : 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.ts
    

    Attendu : 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 -20
    

    Attendu : 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 mail dans frontend/i18n/locales/fr.json

    Ajouter 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.json existe, ajouter le bloc traduit

    ls /home/r-dev/malio-dev/Lesstime/frontend/i18n/locales/
    

    Si en.json existe, 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 -10
    

    Attendu : 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 calc avec v-bind(depth) n'est pas fiable dans Tailwind — utiliser :style natif 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 seul v-html du composant, et il reçoit toujours la sortie de sanitizeMailHtml — jamais le rawHtml brut directement.
    • La bannière "Afficher les images" est visible dès qu'il y a un bodyHtml et que showImages est false.
    • handleDownload crée un lien <a> temporaire pour déclencher le téléchargement du Blob retourné par mailService.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> (avant onMounted) pour un redirect immédiat si ROLE_CLIENT détecté à l'hydratation.
    • Le double-check dans onMounted couvre le cas où auth n'est pas encore résolu au premier rendu SPA.
    • Les console.warn pour 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>&1
    

    Attendu : sortie vide (0 erreur). Erreurs fréquentes à anticiper et corriger :

    • Property 'XXX' does not exist on type 'DeepReadonly<...>' → les refs du store sont readonly() — utiliser .value directement ou storeToRefs() (déjà fait dans mail.vue).
    • Cannot find name 'useSystemFolderLabel' → vérifier que le composable est dans composables/ (auto-importé par Nuxt).
    • 'MailFolderTree' is not defined → Nuxt auto-importe les composants de components/ — vérifier le nom du fichier (PascalCase).
    • Property 'depth' does not exist → dans MailFolderTree, depth est une prop avec valeur par défaut — s'assurer que le type est number | undefined dans defineProps.
  • Step 2 : Démarrer le serveur de développement

    cd /home/r-dev/malio-dev/Lesstime && make dev-nuxt
    

    Attendu : Nuxt ready sur http://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.warn Phase 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)
  • 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 — aucun defineComponent, aucun Options API
  • 4 espaces d'indentation — convention Lesstime (cf. CLAUDE.md)
  • Imports Pinia : const store = useMailStore() puis const { ... } = storeToRefs(store) pour la réactivité des refs
  • v-html uniquement avec sortie de sanitizeMailHtml() — jamais avec rawHtml brut
  • 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 (collection material-symbols)
  • Date relative : Intl.RelativeTimeFormat natif, locale fr — 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 sanitizeMailHtml peut être appelé dans computed directement sans guard import.meta.client
  • ROLE_CLIENT : double protection — middleware global (auth.global.ts) + check explicite dans pages/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.