# 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