# 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