# 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**

  ```bash
  git branch --show-current
  ```

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

  ```bash
  git checkout feat/mail-integration
  ```

- [ ] **Step 2 : Vérifier que dompurify n'est pas déjà installé**

  ```bash
  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**

  ```bash
  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)**

  ```bash
  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 :

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

  Attendu : fichiers `.js` présents.

- [ ] **Step 5 : Commit**

  ```bash
  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`**

  ```typescript
  // 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)**

  ```bash
  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**

  ```bash
  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`**

  ```typescript
  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**

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

  Attendu : aucune erreur.

- [ ] **Step 3 : Commit**

  ```bash
  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`**

  ```typescript
  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**

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

  Attendu : aucune erreur.

- [ ] **Step 3 : Commit**

  ```bash
  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`**

  ```typescript
  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**

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

  Attendu : aucune erreur.

- [ ] **Step 3 : Commit**

  ```bash
  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**

  ```bash
  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 :

  ```javascript
  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 :

  ```bash
  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**

  ```bash
  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**

  ```bash
  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 API** — `defineStore('mail', () => { ... })`, jamais options API
- **Polling** — `setInterval` natif + `clearInterval` dans `stopPolling()` ; `onScopeDispose` pour cleanup automatique
- **DOMPurify** — `import 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 :**

```bash
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.
