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

  ```bash
  git -C /home/r-dev/malio-dev/Lesstime branch --show-current
  ```

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

  ```bash
  git -C /home/r-dev/malio-dev/Lesstime checkout feat/mail-integration
  ```

- [ ] **Step 2 : Vérifier que les fichiers Phase 4 existent**

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

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

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

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

  Si `en.json` existe, ajouter le même bloc avec traductions anglaises :

  ```json
  "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**

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

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

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

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

  Attendu : aucune erreur.

- [ ] **Step 3 : Commit**

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

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

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

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

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

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

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

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

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

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

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

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

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

  ```bash
  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](https://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.
