Files
Lesstime/docs/superpowers/plans/2026-05-19-mail-phase4-frontend-services.md

39 KiB

Mail Integration — Phase 4 : Frontend Services + Store

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: Mettre en place toute la couche frontend non-visuelle (types TS, service API, store Pinia, helper sanitization HTML) avant d'attaquer les composants UI en Phase 5.

Architecture: services/mail.ts wrappe useApi() (pattern Lesstime), stores/mail.ts gère state global + polling unread 30s via setInterval natif (pas de VueUse — non installé dans le projet), utils/sanitizeMailHtml.ts applique DOMPurify avec config bloquante (scripts/iframes/on*/javascript:) + remplacement images distantes par placeholder anti-tracking.

Tech Stack: Nuxt 4, Vue 3 Composition API, Pinia, TypeScript strict, DOMPurify.

Branche cible : feat/mail-integration (créée en Phase 1 — vérifier qu'elle est active).

Fichiers créés/modifiés par le codeur :

Fichier Action
frontend/services/dto/mail.ts Créer
frontend/services/mail.ts Créer
frontend/stores/mail.ts Créer
frontend/utils/sanitizeMailHtml.ts Créer
frontend/package.json Modifier (ajout dompurify + @types/dompurify)

Task 1 : Vérification de l'environnement + install dompurify

  • Step 1 : Vérifier la branche active

    git branch --show-current
    

    Attendu : feat/mail-integration. Si non, basculer :

    git checkout feat/mail-integration
    
  • Step 2 : Vérifier que dompurify n'est pas déjà installé

    grep -i dompurify /home/r-dev/malio-dev/Lesstime/frontend/package.json
    

    Attendu : aucune ligne. Si déjà présent, passer au Step 4.

  • Step 3 : Installer dompurify et ses types

    cd /home/r-dev/malio-dev/Lesstime/frontend && npm install dompurify && npm install -D @types/dompurify
    

    Attendu : package.json mis à jour avec "dompurify" dans dependencies et "@types/dompurify" dans devDependencies.

  • Step 4 : Vérifier que dompurify est importable (smoke check)

    cd /home/r-dev/malio-dev/Lesstime/frontend && node -e "import('dompurify').then(() => console.log('OK'))"
    

    Ou simplement vérifier la présence dans node_modules :

    ls /home/r-dev/malio-dev/Lesstime/frontend/node_modules/dompurify/dist/ | head -5
    

    Attendu : fichiers .js présents.

  • Step 5 : Commit

    git add frontend/package.json frontend/package-lock.json
    git commit -m "feat(mail) : install dompurify + types"
    

Task 2 : Types TS — frontend/services/dto/mail.ts

Tous les types sont alignés sur les formats de réponses des endpoints Phase 3. Le pattern suit frontend/services/dto/zimbra.ts (types simples, pas d'héritage complexe) et frontend/services/dto/task.ts (champs optionnels explicites, | null).

  • Step 1 : Créer frontend/services/dto/mail.ts

    // Lecture de la configuration mail (singleton admin)
    export type MailConfigurationDto = {
        protocol: string | null
        imapHost: string | null
        imapPort: number | null
        imapEncryption: string | null
        smtpHost: string | null
        smtpPort: number | null
        smtpEncryption: string | null
        username: string | null
        sentFolderPath: string | null
        enabled: boolean
        hasPassword: boolean
        // password JAMAIS présent dans les réponses GET
    }
    
    // Input PATCH configuration (password optionnel, write-only)
    export type MailConfigurationUpdateDto = {
        protocol?: string | null
        imapHost?: string | null
        imapPort?: number | null
        imapEncryption?: string | null
        smtpHost?: string | null
        smtpPort?: number | null
        smtpEncryption?: string | null
        username?: string | null
        sentFolderPath?: string | null
        enabled?: boolean
        password?: string       // write-only, jamais retourné
    }
    
    // Résultat du test de connexion
    export type MailTestConnectionResultDto = {
        ok: boolean
        foldersCount?: number
        error?: string
    }
    
    // Dossier mail (peut être imbriqué)
    export type MailFolderDto = {
        path: string
        displayName: string
        parentPath: string | null
        unreadCount: number
        totalCount: number
        children?: MailFolderDto[]
    }
    
    // En-tête d'un message (liste)
    export type MailMessageHeaderDto = {
        id: number
        messageId: string       // identifiant IMAP unique
        folderPath: string
        subject: string | null
        fromName: string | null
        fromEmail: string | null
        toRecipients: MailAddressDto[]
        ccRecipients: MailAddressDto[]
        sentAt: string | null   // ISO 8601
        receivedAt: string      // ISO 8601
        isRead: boolean
        isFlagged: boolean
        hasAttachments: boolean
        linkedTaskIds: number[]
    }
    
    // Adresse mail (nom + email)
    export type MailAddressDto = {
        name: string | null
        email: string
    }
    
    // Pièce jointe (métadonnées uniquement, téléchargement via downloadId)
    export type MailAttachmentDto = {
        downloadId: string
        filename: string
        mimeType: string
        size: number            // octets
    }
    
    // Détail complet d'un message (enrichi avec body + PJ)
    export type MailMessageDetailDto = {
        header: MailMessageHeaderDto
        bodyHtml: string | null    // HTML brut — TOUJOURS passer par sanitizeMailHtml() avant affichage
        bodyText: string | null    // Fallback texte plain
        attachments: MailAttachmentDto[]
    }
    
    // Page de messages paginée (cursor-based)
    export type MailMessagesPageDto = {
        items: MailMessageHeaderDto[]
        nextCursor: string | null   // null = plus de page suivante
        total: number
    }
    
    // Input : marquer lu/non-lu
    export type MailMessageReadInput = {
        read: boolean
    }
    
    // Input : marquer étoilé/non-étoilé
    export type MailMessageFlagInput = {
        flagged: boolean
    }
    
    // Input : créer une tâche depuis un mail
    export type MailCreateTaskInput = {
        projectId: number
        taskGroupId?: number | null
        priority?: string | null
    }
    
    // Input : lier une tâche existante à un mail
    export type MailLinkTaskInput = {
        taskId: number
    }
    
    // Résultat de la sync manuelle
    export type MailSyncResultDto = {
        dispatched: boolean
    }
    
  • Step 2 : Vérifier la syntaxe TypeScript (smoke)

    cd /home/r-dev/malio-dev/Lesstime/frontend && npx tsc --noEmit 2>&1 | grep "dto/mail" | head -20
    

    Attendu : aucune erreur liée à dto/mail.ts.

  • Step 3 : Commit

    git add frontend/services/dto/mail.ts
    git commit -m "feat(mail) : types TS DTOs mail (config, folders, messages, attachments)"
    

Task 3 : Helper sanitization — frontend/utils/sanitizeMailHtml.ts

Ce helper est critique pour la sécurité : tout corps HTML de mail doit transiter par cette fonction avant affichage dans le DOM. Il bloque les scripts, iframes, attributs événements, et remplace les images externes par un placeholder anti-tracking.

  • Step 1 : Créer frontend/utils/sanitizeMailHtml.ts

    import DOMPurify from 'dompurify'
    
    /**
     * Options de sanitization du corps HTML d'un mail.
     */
    export type SanitizeMailHtmlOptions = {
        /**
         * Si true, les images distantes (http/https) sont affichées directement.
         * Par défaut false — les images distantes sont remplacées par un placeholder
         * cliquable pour éviter le tracking par pixel.
         */
        allowImages?: boolean
    }
    
    /**
     * Configuration DOMPurify bloquante pour les corps de mail.
     * - Bloque les balises dangereuses : script, iframe, object, embed, style, link, meta, form, input
     * - Bloque les attributs événements (on*) et les URI javascript:
     * - Autorise les URI data: uniquement pour les images (PNG/JPEG/GIF/WEBP) — images inline CID
     */
    const DOMPURIFY_CONFIG: DOMPurify.Config = {
        FORBID_TAGS: [
            'script',
            'iframe',
            'object',
            'embed',
            'style',
            'link',
            'meta',
            'form',
            'input',
            'button',
            'textarea',
            'select',
            'base',
            'applet',
        ],
        FORBID_ATTR: [
            'onerror',
            'onload',
            'onclick',
            'onmouseover',
            'onmouseout',
            'onmouseenter',
            'onmouseleave',
            'onfocus',
            'onblur',
            'onchange',
            'onsubmit',
            'onreset',
            'onkeydown',
            'onkeyup',
            'onkeypress',
            'ondblclick',
            'oncontextmenu',
            'onwheel',
            'ondrag',
            'ondrop',
            'oncopy',
            'oncut',
            'onpaste',
            'action',
            'formaction',
            'xlink:href',
        ],
        ALLOWED_URI_REGEXP: /^(?:https?|mailto|tel|cid|data:image\/(?:png|jpeg|gif|webp)(?:;base64,)?)(?::|$)/i,
        FORCE_BODY: true,
        WHOLE_DOCUMENT: false,
    }
    
    /**
     * Remplace les balises <img> avec src http(s):// par un bouton placeholder.
     * Le src original est stocké en data-mail-image-src pour permettre l'affichage
     * à la demande de l'utilisateur (Phase 5 — MailMessageViewer).
     */
    function replaceRemoteImages(html: string): string {
        // Utiliser un DOMParser côté client uniquement (SSR-safe : le guard process.client
        // est géré par l'appelant dans un composant Vue — ce helper ne tourne que client-side)
        const parser = new DOMParser()
        const doc = parser.parseFromString(html, 'text/html')
        const images = doc.querySelectorAll('img')
    
        images.forEach((img) => {
            const src = img.getAttribute('src') ?? ''
            const isRemote = /^https?:\/\//i.test(src)
            if (!isRemote) return
    
            // Remplacer par un span cliquable (pas de <button> — DOMPurify le forbid)
            const placeholder = doc.createElement('span')
            placeholder.setAttribute('data-mail-image-src', src)
            placeholder.setAttribute('data-mail-image-placeholder', 'true')
            placeholder.setAttribute('title', src)
            placeholder.style.cssText = [
                'display: inline-flex',
                'align-items: center',
                'gap: 4px',
                'padding: 2px 6px',
                'border: 1px solid #d1d5db',
                'border-radius: 4px',
                'background: #f9fafb',
                'color: #6b7280',
                'font-size: 12px',
                'cursor: pointer',
                'user-select: none',
            ].join(';')
            placeholder.textContent = '[Image distante — cliquer pour afficher]'
    
            img.replaceWith(placeholder)
        })
    
        return doc.body.innerHTML
    }
    
    /**
     * Sanitize le HTML brut d'un corps de mail.
     *
     * - Bloque tous les vecteurs XSS connus (scripts, événements inline, iframes…)
     * - Par défaut, remplace les images distantes par un placeholder anti-tracking
     * - Utiliser allowImages: true uniquement si l'utilisateur a explicitement cliqué
     *   "Afficher les images" dans le lecteur de mail
     *
     * IMPORTANT : Cette fonction requiert un environnement navigateur (DOMParser, DOMPurify).
     * Ne pas appeler côté SSR — toujours dans un composant Vue avec `onMounted` ou dans
     * un computed côté client uniquement (`import.meta.client`).
     *
     * @param rawHtml - HTML brut tel que reçu de l'API backend
     * @param options - Options de sanitization
     * @returns HTML sanitizé, sûr pour injection via v-html
     */
    export function sanitizeMailHtml(
        rawHtml: string,
        options: SanitizeMailHtmlOptions = {},
    ): string {
        if (!rawHtml || rawHtml.trim() === '') return ''
    
        // Étape 1 : DOMPurify — supprime tous les vecteurs dangereux
        const sanitized = DOMPurify.sanitize(rawHtml, DOMPURIFY_CONFIG) as string
    
        // Étape 2 : Remplacement images distantes (anti-tracking)
        if (!options.allowImages) {
            return replaceRemoteImages(sanitized)
        }
    
        return sanitized
    }
    
    /**
     * Vérifie si un élément HTML est un placeholder d'image généré par sanitizeMailHtml.
     * Utile dans MailMessageViewer pour gérer le clic "Afficher l'image".
     */
    export function isMailImagePlaceholder(el: HTMLElement): boolean {
        return el.hasAttribute('data-mail-image-placeholder')
    }
    
    /**
     * Récupère le src original d'un placeholder d'image.
     */
    export function getMailImageSrc(el: HTMLElement): string | null {
        return el.getAttribute('data-mail-image-src')
    }
    
  • Step 2 : Vérification TypeScript

    cd /home/r-dev/malio-dev/Lesstime/frontend && npx tsc --noEmit 2>&1 | grep "sanitizeMailHtml" | head -10
    

    Attendu : aucune erreur.

  • Step 3 : Commit

    git add frontend/utils/sanitizeMailHtml.ts
    git commit -m "feat(mail) : helper sanitizeMailHtml — DOMPurify + placeholder images distantes"
    

Task 4 : Service API — frontend/services/mail.ts

Le service suit exactement le pattern Lesstime : fonction factory useMailService() qui instancie useApi() en interne, puis expose des méthodes typées. Calqué sur services/zimbra.ts (singleton config) et services/tasks.ts (CRUD paginé).

Note sur downloadAttachment : utiliser api.getBlob() qui retourne { data: Blob, headers: Headers } — méthode déjà disponible dans useApi() (voir composables/useApi.ts, ligne ~200).

  • Step 1 : Créer frontend/services/mail.ts

    import type {
        MailConfigurationDto,
        MailConfigurationUpdateDto,
        MailTestConnectionResultDto,
        MailFolderDto,
        MailMessageHeaderDto,
        MailMessageDetailDto,
        MailMessagesPageDto,
        MailMessageReadInput,
        MailMessageFlagInput,
        MailCreateTaskInput,
        MailLinkTaskInput,
        MailSyncResultDto,
    } from './dto/mail'
    import type { Task } from './dto/task'
    
    export function useMailService() {
        const api = useApi()
    
        // ─── Configuration (Admin) ────────────────────────────────────────────────
    
        /**
         * Récupère la configuration mail singleton.
         * Requiert ROLE_ADMIN — 403 sinon.
         */
        async function getConfiguration(): Promise<MailConfigurationDto> {
            return api.get<MailConfigurationDto>('/mail/configuration')
        }
    
        /**
         * Met à jour la configuration mail (PATCH merge).
         * Si payload.password est fourni, il sera chiffré côté backend.
         * Jamais retourné en clair dans la réponse.
         */
        async function updateConfiguration(
            payload: MailConfigurationUpdateDto,
        ): Promise<MailConfigurationDto> {
            return api.patch<MailConfigurationDto>(
                '/mail/configuration',
                payload as Record<string, unknown>,
                { toastSuccessKey: 'mail.configuration.saved' },
            )
        }
    
        /**
         * Teste la connexion IMAP avec la configuration actuelle.
         * Requiert ROLE_ADMIN.
         */
        async function testConfiguration(): Promise<MailTestConnectionResultDto> {
            return api.post<MailTestConnectionResultDto>('/mail/configuration/test', {})
        }
    
        // ─── Dossiers ─────────────────────────────────────────────────────────────
    
        /**
         * Liste tous les dossiers mail depuis la base (cache BDD, pas live IMAP).
         * Retourne une liste plate — la construction de l'arbre est faite dans le store
         * via le getter `folderTree`.
         */
        async function listFolders(): Promise<MailFolderDto[]> {
            return api.get<MailFolderDto[]>('/mail/folders')
        }
    
        // ─── Messages ─────────────────────────────────────────────────────────────
    
        /**
         * Liste les messages d'un dossier, paginés par cursor.
         * @param folderPath - Chemin du dossier (ex: "INBOX", "INBOX.Sent")
         * @param cursor - Opaque cursor retourné par la page précédente (undefined = première page)
         * @param limit - Nombre de messages par page (défaut backend : 50)
         */
        async function listMessages(
            folderPath: string,
            cursor?: string,
            limit?: number,
        ): Promise<MailMessagesPageDto> {
            const query: Record<string, unknown> = { folder: folderPath }
            if (cursor) query.cursor = cursor
            if (limit) query.limit = limit
            return api.get<MailMessagesPageDto>('/mail/messages', query)
        }
    
        /**
         * Récupère le détail complet d'un message (body live IMAP, cached 5 min).
         * @param id - ID BDD du message (MailMessage.id)
         */
        async function getMessage(id: number): Promise<MailMessageDetailDto> {
            return api.get<MailMessageDetailDto>(`/mail/messages/${id}`)
        }
    
        // ─── Actions sur les messages ─────────────────────────────────────────────
    
        /**
         * Marque un message comme lu ou non-lu.
         */
        async function markRead(id: number, read: boolean): Promise<MailMessageHeaderDto> {
            const payload: MailMessageReadInput = { read }
            return api.post<MailMessageHeaderDto>(
                `/mail/messages/${id}/read`,
                payload as Record<string, unknown>,
            )
        }
    
        /**
         * Marque un message comme étoilé ou non-étoilé.
         */
        async function markFlagged(id: number, flagged: boolean): Promise<MailMessageHeaderDto> {
            const payload: MailMessageFlagInput = { flagged }
            return api.post<MailMessageHeaderDto>(
                `/mail/messages/${id}/flag`,
                payload as Record<string, unknown>,
            )
        }
    
        // ─── Intégration tâches ───────────────────────────────────────────────────
    
        /**
         * Crée une nouvelle tâche à partir d'un mail (subject → titre, body → description).
         * @param mailId - ID BDD du message
         * @param input - Paramètres de la tâche à créer
         */
        async function createTaskFromMail(
            mailId: number,
            input: MailCreateTaskInput,
        ): Promise<Task> {
            return api.post<Task>(
                `/mail/messages/${mailId}/create-task`,
                input as Record<string, unknown>,
                { toastSuccessKey: 'mail.task.created' },
            )
        }
    
        /**
         * Lie un mail à une tâche existante.
         * @param mailId - ID BDD du message
         * @param taskId - ID de la tâche existante
         */
        async function linkTask(mailId: number, taskId: number): Promise<void> {
            const payload: MailLinkTaskInput = { taskId }
            await api.post<void>(
                `/mail/messages/${mailId}/link-task`,
                payload as Record<string, unknown>,
                { toastSuccessKey: 'mail.task.linked' },
            )
        }
    
        /**
         * Supprime le lien entre un mail et une tâche.
         * @param mailId - ID BDD du message
         * @param taskId - ID de la tâche
         */
        async function unlinkTask(mailId: number, taskId: number): Promise<void> {
            await api.delete<void>(`/mail/messages/${mailId}/link-task/${taskId}`, {}, {
                toastSuccessKey: 'mail.task.unlinked',
            })
        }
    
        /**
         * Liste les mails liés à une tâche (pour l'onglet "Mails" du TaskDrawer — Phase 6).
         * @param taskId - ID de la tâche
         */
        async function listMailsForTask(taskId: number): Promise<MailMessageHeaderDto[]> {
            return api.get<MailMessageHeaderDto[]>(`/tasks/${taskId}/mails`)
        }
    
        // ─── Pièces jointes ───────────────────────────────────────────────────────
    
        /**
         * Télécharge une pièce jointe et retourne le Blob + headers.
         * Content-Disposition: attachment est géré côté backend (jamais inline).
         * @param downloadId - Identifiant opaque retourné dans MailAttachmentDto.downloadId
         */
        async function downloadAttachment(
            downloadId: string,
        ): Promise<{ data: Blob; headers: Headers }> {
            return api.getBlob(`/mail/attachments/${downloadId}`)
        }
    
        // ─── Synchronisation ─────────────────────────────────────────────────────
    
        /**
         * Déclenche une synchronisation IMAP asynchrone via Symfony Messenger.
         * Retourne immédiatement ({ dispatched: true }) — la sync se fait en arrière-plan.
         */
        async function triggerSync(): Promise<MailSyncResultDto> {
            return api.post<MailSyncResultDto>('/mail/sync', {}, {
                toastSuccessKey: 'mail.sync.dispatched',
            })
        }
    
        return {
            // Config
            getConfiguration,
            updateConfiguration,
            testConfiguration,
            // Dossiers
            listFolders,
            // Messages
            listMessages,
            getMessage,
            // Actions
            markRead,
            markFlagged,
            // Tâches
            createTaskFromMail,
            linkTask,
            unlinkTask,
            listMailsForTask,
            // Pièces jointes
            downloadAttachment,
            // Sync
            triggerSync,
        }
    }
    
  • Step 2 : Vérification TypeScript

    cd /home/r-dev/malio-dev/Lesstime/frontend && npx tsc --noEmit 2>&1 | grep "services/mail" | head -20
    

    Attendu : aucune erreur.

  • Step 3 : Commit

    git add frontend/services/mail.ts
    git commit -m "feat(mail) : service API mail — listFolders/messages/getMessage/markRead/markFlagged/createTask/linkTask/downloadAttachment/triggerSync"
    

Task 5 : Store Pinia — frontend/stores/mail.ts

Store composition API, pattern defineStore('mail', () => { ... }) identique à stores/timer.ts. Le polling (30s) utilise setInterval natif + cleanup onScopeDispose — pas de VueUse (non installé). Le getter folderTree construit l'arbre depuis la liste plate en utilisant parentPath.

  • Step 1 : Créer frontend/stores/mail.ts

    import { defineStore } from 'pinia'
    import type {
        MailFolderDto,
        MailMessageHeaderDto,
        MailMessageDetailDto,
    } from '~/services/dto/mail'
    import { useMailService } from '~/services/mail'
    
    const POLL_INTERVAL_MS = 30 * 1000 // 30 secondes
    
    export const useMailStore = defineStore('mail', () => {
        // ─── State ────────────────────────────────────────────────────────────────
    
        /** Liste plate des dossiers (reçue de l'API) */
        const folders = ref<MailFolderDto[]>([])
    
        /** Chemin du dossier actuellement sélectionné */
        const selectedFolderPath = ref<string | null>(null)
    
        /** Messages du dossier sélectionné (accumulés pour infinite scroll) */
        const messages = ref<MailMessageHeaderDto[]>([])
    
        /** Cursor de pagination pour la page suivante (null = plus de données) */
        const messagesCursor = ref<string | null>(null)
    
        /** Chargement en cours (messages) */
        const messagesLoading = ref(false)
    
        /** ID du message sélectionné pour lecture */
        const selectedMessageId = ref<number | null>(null)
    
        /** Détail complet du message sélectionné (body + PJ) */
        const selectedMessageDetail = ref<MailMessageDetailDto | null>(null)
    
        /** Chargement du détail en cours */
        const detailLoading = ref(false)
    
        /** Sync IMAP en cours (déclenchée manuellement) */
        const syncing = ref(false)
    
        /** Nombre total de messages non lus (toutes boîtes confondues) */
        const globalUnreadCount = ref(0)
    
        /** Erreur courante (null si aucune) */
        const error = ref<string | null>(null)
    
        let pollTimer: ReturnType<typeof setInterval> | null = null
    
        // ─── Getters ──────────────────────────────────────────────────────────────
    
        /**
         * Nombre de non-lus dans INBOX uniquement (utilisé dans la sidebar).
         */
        const inboxUnread = computed(() => {
            const inbox = folders.value.find(
                (f) => f.path === 'INBOX' || f.path.toUpperCase() === 'INBOX',
            )
            return inbox?.unreadCount ?? 0
        })
    
        /**
         * Construit l'arbre de dossiers depuis la liste plate.
         * Les dossiers sans parentPath sont à la racine.
         * Les enfants sont triés alphabétiquement par displayName.
         */
        const folderTree = computed((): MailFolderDto[] => {
            const map = new Map<string, MailFolderDto>()
            const roots: MailFolderDto[] = []
    
            // Initialiser chaque dossier avec children vide
            folders.value.forEach((folder) => {
                map.set(folder.path, { ...folder, children: [] })
            })
    
            // Construire l'arbre
            map.forEach((folder) => {
                if (folder.parentPath && map.has(folder.parentPath)) {
                    const parent = map.get(folder.parentPath)!
                    parent.children = parent.children ?? []
                    parent.children.push(folder)
                } else {
                    roots.push(folder)
                }
            })
    
            // Trier les enfants alphabétiquement
            function sortChildren(nodes: MailFolderDto[]): MailFolderDto[] {
                return nodes
                    .map((n) => ({
                        ...n,
                        children: n.children ? sortChildren(n.children) : undefined,
                    }))
                    .sort((a, b) => a.displayName.localeCompare(b.displayName, 'fr'))
            }
    
            return sortChildren(roots)
        })
    
        /**
         * Indique si le cursor de pagination est disponible (plus de messages à charger).
         */
        const hasMoreMessages = computed(() => messagesCursor.value !== null)
    
        // ─── Actions ──────────────────────────────────────────────────────────────
    
        /**
         * Charge la liste des dossiers depuis l'API et met à jour globalUnreadCount.
         */
        async function fetchFolders(): Promise<void> {
            const service = useMailService()
            try {
                folders.value = await service.listFolders()
                globalUnreadCount.value = folders.value.reduce(
                    (sum, f) => sum + f.unreadCount,
                    0,
                )
            } catch {
                // Silently ignore polling errors (ne pas interrompre l'UX)
            }
        }
    
        /**
         * Sélectionne un dossier et charge ses messages (reset de la pagination).
         * @param path - Chemin du dossier (ex: "INBOX")
         */
        async function selectFolder(path: string): Promise<void> {
            if (selectedFolderPath.value === path) return
            selectedFolderPath.value = path
            messages.value = []
            messagesCursor.value = null
            selectedMessageId.value = null
            selectedMessageDetail.value = null
            await fetchMessages()
        }
    
        /**
         * Charge les messages du dossier sélectionné.
         * @param append - Si true, ajoute à la liste existante (infinite scroll). Si false, remplace.
         */
        async function fetchMessages(append = false): Promise<void> {
            if (!selectedFolderPath.value) return
            if (messagesLoading.value) return
    
            messagesLoading.value = true
            error.value = null
    
            const service = useMailService()
            try {
                const cursor = append ? (messagesCursor.value ?? undefined) : undefined
                const page = await service.listMessages(selectedFolderPath.value, cursor)
    
                if (append) {
                    messages.value = [...messages.value, ...page.items]
                } else {
                    messages.value = page.items
                }
                messagesCursor.value = page.nextCursor
            } catch (err) {
                error.value = err instanceof Error ? err.message : 'Erreur lors du chargement des messages.'
            } finally {
                messagesLoading.value = false
            }
        }
    
        /**
         * Sélectionne un message et charge son détail complet (body + PJ).
         * Marque automatiquement le message comme lu si ce n'est pas déjà le cas.
         * @param id - ID BDD du message
         */
        async function selectMessage(id: number): Promise<void> {
            if (selectedMessageId.value === id) return
            selectedMessageId.value = id
            selectedMessageDetail.value = null
            detailLoading.value = true
    
            const service = useMailService()
            try {
                const detail = await service.getMessage(id)
                selectedMessageDetail.value = detail
    
                // Auto-mark as read si nécessaire
                if (!detail.header.isRead) {
                    await markRead(id, true)
                }
            } finally {
                detailLoading.value = false
            }
        }
    
        /**
         * Marque un message comme lu ou non-lu.
         * Met à jour le state local (messages + detail) sans refetch.
         */
        async function markRead(id: number, read: boolean): Promise<void> {
            const service = useMailService()
            const updated = await service.markRead(id, read)
    
            // Mise à jour optimiste dans la liste
            const idx = messages.value.findIndex((m) => m.id === id)
            if (idx !== -1) {
                messages.value[idx] = { ...messages.value[idx], isRead: updated.isRead }
            }
    
            // Mise à jour dans le détail si ouvert
            if (selectedMessageDetail.value?.header.id === id) {
                selectedMessageDetail.value = {
                    ...selectedMessageDetail.value,
                    header: { ...selectedMessageDetail.value.header, isRead: updated.isRead },
                }
            }
    
            // Mettre à jour le compteur du dossier
            await _refreshFolderUnreadCount()
        }
    
        /**
         * Marque un message comme étoilé ou non-étoilé.
         * Met à jour le state local sans refetch.
         */
        async function markFlagged(id: number, flagged: boolean): Promise<void> {
            const service = useMailService()
            const updated = await service.markFlagged(id, flagged)
    
            const idx = messages.value.findIndex((m) => m.id === id)
            if (idx !== -1) {
                messages.value[idx] = { ...messages.value[idx], isFlagged: updated.isFlagged }
            }
    
            if (selectedMessageDetail.value?.header.id === id) {
                selectedMessageDetail.value = {
                    ...selectedMessageDetail.value,
                    header: { ...selectedMessageDetail.value.header, isFlagged: updated.isFlagged },
                }
            }
        }
    
        /**
         * Déclenche une synchronisation IMAP asynchrone.
         * Recharge les dossiers après 2s pour refléter les nouveaux messages.
         */
        async function triggerSync(): Promise<void> {
            if (syncing.value) return
            syncing.value = true
            const service = useMailService()
            try {
                await service.triggerSync()
                // Laisser le temps au handler Messenger de traiter
                setTimeout(async () => {
                    await fetchFolders()
                    if (selectedFolderPath.value) {
                        await fetchMessages(false)
                    }
                    syncing.value = false
                }, 2000)
            } catch {
                syncing.value = false
            }
        }
    
        /**
         * Démarre le polling toutes les 30s pour mettre à jour globalUnreadCount.
         * À appeler dans app.vue ou le layout default au login.
         * Idempotent : un seul timer actif à la fois.
         */
        function startPolling(): void {
            if (pollTimer) return
            fetchFolders() // Charge immédiatement
            pollTimer = setInterval(fetchFolders, POLL_INTERVAL_MS)
    
            // Cleanup automatique si le scope du store est détruit
            if (getCurrentScope()) {
                onScopeDispose(stopPolling)
            }
        }
    
        /**
         * Arrête le polling. À appeler au logout.
         */
        function stopPolling(): void {
            if (pollTimer) {
                clearInterval(pollTimer)
                pollTimer = null
            }
        }
    
        /**
         * Rafraîchit les compteurs non-lus du dossier actuel depuis l'API.
         * Usage interne — appelé après markRead.
         */
        async function _refreshFolderUnreadCount(): Promise<void> {
            const service = useMailService()
            try {
                const updatedFolders = await service.listFolders()
                folders.value = updatedFolders
                globalUnreadCount.value = updatedFolders.reduce(
                    (sum, f) => sum + f.unreadCount,
                    0,
                )
            } catch {
                // Silently ignore
            }
        }
    
        return {
            // State (readonly pour les consommateurs)
            folders: readonly(folders),
            selectedFolderPath: readonly(selectedFolderPath),
            messages: readonly(messages),
            messagesCursor: readonly(messagesCursor),
            messagesLoading: readonly(messagesLoading),
            selectedMessageId: readonly(selectedMessageId),
            selectedMessageDetail: readonly(selectedMessageDetail),
            detailLoading: readonly(detailLoading),
            syncing: readonly(syncing),
            globalUnreadCount: readonly(globalUnreadCount),
            error: readonly(error),
            // Getters
            inboxUnread,
            folderTree,
            hasMoreMessages,
            // Actions
            fetchFolders,
            selectFolder,
            fetchMessages,
            selectMessage,
            markRead,
            markFlagged,
            triggerSync,
            startPolling,
            stopPolling,
        }
    })
    
  • Step 2 : Vérification TypeScript

    cd /home/r-dev/malio-dev/Lesstime/frontend && npx tsc --noEmit 2>&1 | grep "stores/mail" | head -20
    

    Attendu : aucune erreur.

  • Step 3 : Commit

    git add frontend/stores/mail.ts
    git commit -m "feat(mail) : store Pinia useMailStore — folders, messages, polling 30s, markRead/markFlagged"
    

Task 6 : Validation TypeScript globale + smoke test

  • Step 1 : Vérification TypeScript globale

    cd /home/r-dev/malio-dev/Lesstime/frontend && npx tsc --noEmit 2>&1
    

    Attendu : aucune erreur. Si des erreurs apparaissent sur les fichiers créés, les corriger avant de continuer.

    Erreurs fréquentes à anticiper :

    • Property 'readonly' does not exist → vérifier que Vue readonly() est bien importé (auto-import Nuxt, normalement transparent)
    • Cannot find module 'dompurify' → vérifier que @types/dompurify est bien installé
    • Property 'children' does not exist → vérifier que MailFolderDto.children est bien MailFolderDto[] | undefined dans le DTO
  • Step 2 : Smoke test manuel — appel listFolders depuis devtools

    Ouvrir http://localhost:3002 dans le navigateur (avec l'utilisateur alice connecté), puis dans la console DevTools :

    const { useMailService } = await import('/services/mail.ts')
    const svc = useMailService()
    const folders = await svc.listFolders()
    console.log(folders)
    

    Attendu (si Phase 3 backend déployée) : tableau de dossiers. Attendu (si Phase 3 non encore déployée) : erreur 404 — normal à ce stade. Non attendu : erreur TypeScript ou erreur d'import.

    Alternative sans Phase 3 : vérifier que l'import ne crashe pas Nuxt :

    cd /home/r-dev/malio-dev/Lesstime/frontend && npx nuxt dev --port 3099 &
    # Attendre "Nuxt ready" puis ctrl+C
    # Si Nuxt démarre sans erreur de module, c'est OK
    
  • Step 3 : Vérification finale — liste des fichiers créés

    ls -la /home/r-dev/malio-dev/Lesstime/frontend/services/dto/mail.ts \
           /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
    

    Attendu : 4 fichiers présents.

  • Step 4 : Commit final si des corrections ont été apportées

    git add frontend/services/dto/mail.ts frontend/services/mail.ts frontend/stores/mail.ts frontend/utils/sanitizeMailHtml.ts
    git commit -m "fix(mail) : corrections TypeScript phase 4 post-typecheck"
    

    Ne commiter que si des corrections ont été nécessaires à l'étape précédente.


Exigences techniques

  • TypeScript strict — le projet est en mode strict (tsconfig.json hérité Nuxt)
  • 4 espaces d'indentation — convention Lesstime (cf. CLAUDE.md)
  • Pattern useApi() — wrappe $fetch, gère JWT cookie (credentials: include), toasts i18n, erreurs HTTP
  • PATCH utilise Content-Type: application/merge-patch+json (géré automatiquement par useApi().patch())
  • api.getBlob() pour downloadAttachment — retourne { data: Blob, headers: Headers }, signature disponible dans composables/useApi.ts
  • Store Pinia composition APIdefineStore('mail', () => { ... }), jamais options API
  • PollingsetInterval natif + clearInterval dans stopPolling() ; onScopeDispose pour cleanup automatique
  • DOMPurifyimport DOMPurify from 'dompurify' ; SSR-safe car appelé uniquement côté client dans les composants Vue (Phase 5)
  • readonly() sur les refs exposés par le store — empêche la mutation directe depuis les composants
  • Format commits : feat(mail) : <message> ou fix(mail) : <message> (espace avant :)
  • Chaque task = un ou plusieurs fichiers cohérents + un commit dédié
  • Smoke test final ne doit PAS modifier l'app en production (test dans devtools ou build temporaire)

Output attendu (résumé pour la review humaine)

À la fin de la Phase 4, les éléments suivants doivent être présents et valides :

Livrable Fichier Statut attendu
Types TS DTOs frontend/services/dto/mail.ts Créé, 0 erreur TS
Helper sanitization frontend/utils/sanitizeMailHtml.ts Créé, 0 erreur TS
Service API frontend/services/mail.ts Créé, 0 erreur TS
Store Pinia frontend/stores/mail.ts Créé, 0 erreur TS
Dépendance DOMPurify frontend/package.json dompurify dans dependencies, @types/dompurify dans devDependencies

Critère d'acceptation :

cd /home/r-dev/malio-dev/Lesstime/frontend && npx tsc --noEmit
# → Sortie vide (0 erreur)

La Phase 5 (UI principale /mail) peut commencer dès que cette commande passe sans erreur.