diff --git a/docs/superpowers/plans/2026-05-19-mail-phase4-frontend-services.md b/docs/superpowers/plans/2026-05-19-mail-phase4-frontend-services.md
new file mode 100644
index 0000000..2537255
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-19-mail-phase4-frontend-services.md
@@ -0,0 +1,1107 @@
+# 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
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