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

1204 lines
49 KiB
Markdown

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