Files
Lesstime/docs/superpowers/plans/2026-05-19-mail-phase4-frontend-services.md

1108 lines
39 KiB
Markdown

# 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 <img> 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 <button> — DOMPurify le forbid)
const placeholder = doc.createElement('span')
placeholder.setAttribute('data-mail-image-src', src)
placeholder.setAttribute('data-mail-image-placeholder', 'true')
placeholder.setAttribute('title', src)
placeholder.style.cssText = [
'display: inline-flex',
'align-items: center',
'gap: 4px',
'padding: 2px 6px',
'border: 1px solid #d1d5db',
'border-radius: 4px',
'background: #f9fafb',
'color: #6b7280',
'font-size: 12px',
'cursor: pointer',
'user-select: none',
].join(';')
placeholder.textContent = '[Image distante — cliquer pour afficher]'
img.replaceWith(placeholder)
})
return doc.body.innerHTML
}
/**
* Sanitize le HTML brut d'un corps de mail.
*
* - Bloque tous les vecteurs XSS connus (scripts, événements inline, iframes…)
* - Par défaut, remplace les images distantes par un placeholder anti-tracking
* - Utiliser allowImages: true uniquement si l'utilisateur a explicitement cliqué
* "Afficher les images" dans le lecteur de mail
*
* IMPORTANT : Cette fonction requiert un environnement navigateur (DOMParser, DOMPurify).
* Ne pas appeler côté SSR — toujours dans un composant Vue avec `onMounted` ou dans
* un computed côté client uniquement (`import.meta.client`).
*
* @param rawHtml - HTML brut tel que reçu de l'API backend
* @param options - Options de sanitization
* @returns HTML sanitizé, sûr pour injection via v-html
*/
export function sanitizeMailHtml(
rawHtml: string,
options: SanitizeMailHtmlOptions = {},
): string {
if (!rawHtml || rawHtml.trim() === '') return ''
// Étape 1 : DOMPurify — supprime tous les vecteurs dangereux
const sanitized = DOMPurify.sanitize(rawHtml, DOMPURIFY_CONFIG) as string
// Étape 2 : Remplacement images distantes (anti-tracking)
if (!options.allowImages) {
return replaceRemoteImages(sanitized)
}
return sanitized
}
/**
* Vérifie si un élément HTML est un placeholder d'image généré par sanitizeMailHtml.
* Utile dans MailMessageViewer pour gérer le clic "Afficher l'image".
*/
export function isMailImagePlaceholder(el: HTMLElement): boolean {
return el.hasAttribute('data-mail-image-placeholder')
}
/**
* Récupère le src original d'un placeholder d'image.
*/
export function getMailImageSrc(el: HTMLElement): string | null {
return el.getAttribute('data-mail-image-src')
}
```
- [ ] **Step 2 : Vérification TypeScript**
```bash
cd /home/r-dev/malio-dev/Lesstime/frontend && npx tsc --noEmit 2>&1 | grep "sanitizeMailHtml" | head -10
```
Attendu : aucune erreur.
- [ ] **Step 3 : Commit**
```bash
git add frontend/utils/sanitizeMailHtml.ts
git commit -m "feat(mail) : helper sanitizeMailHtml — DOMPurify + placeholder images distantes"
```
---
### Task 4 : Service API — `frontend/services/mail.ts`
Le service suit exactement le pattern Lesstime : fonction factory `useMailService()` qui instancie `useApi()` en interne, puis expose des méthodes typées. Calqué sur `services/zimbra.ts` (singleton config) et `services/tasks.ts` (CRUD paginé).
Note sur `downloadAttachment` : utiliser `api.getBlob()` qui retourne `{ data: Blob, headers: Headers }` — méthode déjà disponible dans `useApi()` (voir `composables/useApi.ts`, ligne ~200).
- [ ] **Step 1 : Créer `frontend/services/mail.ts`**
```typescript
import type {
MailConfigurationDto,
MailConfigurationUpdateDto,
MailTestConnectionResultDto,
MailFolderDto,
MailMessageHeaderDto,
MailMessageDetailDto,
MailMessagesPageDto,
MailMessageReadInput,
MailMessageFlagInput,
MailCreateTaskInput,
MailLinkTaskInput,
MailSyncResultDto,
} from './dto/mail'
import type { Task } from './dto/task'
export function useMailService() {
const api = useApi()
// ─── Configuration (Admin) ────────────────────────────────────────────────
/**
* Récupère la configuration mail singleton.
* Requiert ROLE_ADMIN — 403 sinon.
*/
async function getConfiguration(): Promise<MailConfigurationDto> {
return api.get<MailConfigurationDto>('/mail/configuration')
}
/**
* Met à jour la configuration mail (PATCH merge).
* Si payload.password est fourni, il sera chiffré côté backend.
* Jamais retourné en clair dans la réponse.
*/
async function updateConfiguration(
payload: MailConfigurationUpdateDto,
): Promise<MailConfigurationDto> {
return api.patch<MailConfigurationDto>(
'/mail/configuration',
payload as Record<string, unknown>,
{ toastSuccessKey: 'mail.configuration.saved' },
)
}
/**
* Teste la connexion IMAP avec la configuration actuelle.
* Requiert ROLE_ADMIN.
*/
async function testConfiguration(): Promise<MailTestConnectionResultDto> {
return api.post<MailTestConnectionResultDto>('/mail/configuration/test', {})
}
// ─── Dossiers ─────────────────────────────────────────────────────────────
/**
* Liste tous les dossiers mail depuis la base (cache BDD, pas live IMAP).
* Retourne une liste plate — la construction de l'arbre est faite dans le store
* via le getter `folderTree`.
*/
async function listFolders(): Promise<MailFolderDto[]> {
return api.get<MailFolderDto[]>('/mail/folders')
}
// ─── Messages ─────────────────────────────────────────────────────────────
/**
* Liste les messages d'un dossier, paginés par cursor.
* @param folderPath - Chemin du dossier (ex: "INBOX", "INBOX.Sent")
* @param cursor - Opaque cursor retourné par la page précédente (undefined = première page)
* @param limit - Nombre de messages par page (défaut backend : 50)
*/
async function listMessages(
folderPath: string,
cursor?: string,
limit?: number,
): Promise<MailMessagesPageDto> {
const query: Record<string, unknown> = { folder: folderPath }
if (cursor) query.cursor = cursor
if (limit) query.limit = limit
return api.get<MailMessagesPageDto>('/mail/messages', query)
}
/**
* Récupère le détail complet d'un message (body live IMAP, cached 5 min).
* @param id - ID BDD du message (MailMessage.id)
*/
async function getMessage(id: number): Promise<MailMessageDetailDto> {
return api.get<MailMessageDetailDto>(`/mail/messages/${id}`)
}
// ─── Actions sur les messages ─────────────────────────────────────────────
/**
* Marque un message comme lu ou non-lu.
*/
async function markRead(id: number, read: boolean): Promise<MailMessageHeaderDto> {
const payload: MailMessageReadInput = { read }
return api.post<MailMessageHeaderDto>(
`/mail/messages/${id}/read`,
payload as Record<string, unknown>,
)
}
/**
* Marque un message comme étoilé ou non-étoilé.
*/
async function markFlagged(id: number, flagged: boolean): Promise<MailMessageHeaderDto> {
const payload: MailMessageFlagInput = { flagged }
return api.post<MailMessageHeaderDto>(
`/mail/messages/${id}/flag`,
payload as Record<string, unknown>,
)
}
// ─── Intégration tâches ───────────────────────────────────────────────────
/**
* Crée une nouvelle tâche à partir d'un mail (subject → titre, body → description).
* @param mailId - ID BDD du message
* @param input - Paramètres de la tâche à créer
*/
async function createTaskFromMail(
mailId: number,
input: MailCreateTaskInput,
): Promise<Task> {
return api.post<Task>(
`/mail/messages/${mailId}/create-task`,
input as Record<string, unknown>,
{ toastSuccessKey: 'mail.task.created' },
)
}
/**
* Lie un mail à une tâche existante.
* @param mailId - ID BDD du message
* @param taskId - ID de la tâche existante
*/
async function linkTask(mailId: number, taskId: number): Promise<void> {
const payload: MailLinkTaskInput = { taskId }
await api.post<void>(
`/mail/messages/${mailId}/link-task`,
payload as Record<string, unknown>,
{ toastSuccessKey: 'mail.task.linked' },
)
}
/**
* Supprime le lien entre un mail et une tâche.
* @param mailId - ID BDD du message
* @param taskId - ID de la tâche
*/
async function unlinkTask(mailId: number, taskId: number): Promise<void> {
await api.delete<void>(`/mail/messages/${mailId}/link-task/${taskId}`, {}, {
toastSuccessKey: 'mail.task.unlinked',
})
}
/**
* Liste les mails liés à une tâche (pour l'onglet "Mails" du TaskDrawer — Phase 6).
* @param taskId - ID de la tâche
*/
async function listMailsForTask(taskId: number): Promise<MailMessageHeaderDto[]> {
return api.get<MailMessageHeaderDto[]>(`/tasks/${taskId}/mails`)
}
// ─── Pièces jointes ───────────────────────────────────────────────────────
/**
* Télécharge une pièce jointe et retourne le Blob + headers.
* Content-Disposition: attachment est géré côté backend (jamais inline).
* @param downloadId - Identifiant opaque retourné dans MailAttachmentDto.downloadId
*/
async function downloadAttachment(
downloadId: string,
): Promise<{ data: Blob; headers: Headers }> {
return api.getBlob(`/mail/attachments/${downloadId}`)
}
// ─── Synchronisation ─────────────────────────────────────────────────────
/**
* Déclenche une synchronisation IMAP asynchrone via Symfony Messenger.
* Retourne immédiatement ({ dispatched: true }) — la sync se fait en arrière-plan.
*/
async function triggerSync(): Promise<MailSyncResultDto> {
return api.post<MailSyncResultDto>('/mail/sync', {}, {
toastSuccessKey: 'mail.sync.dispatched',
})
}
return {
// Config
getConfiguration,
updateConfiguration,
testConfiguration,
// Dossiers
listFolders,
// Messages
listMessages,
getMessage,
// Actions
markRead,
markFlagged,
// Tâches
createTaskFromMail,
linkTask,
unlinkTask,
listMailsForTask,
// Pièces jointes
downloadAttachment,
// Sync
triggerSync,
}
}
```
- [ ] **Step 2 : Vérification TypeScript**
```bash
cd /home/r-dev/malio-dev/Lesstime/frontend && npx tsc --noEmit 2>&1 | grep "services/mail" | head -20
```
Attendu : aucune erreur.
- [ ] **Step 3 : Commit**
```bash
git add frontend/services/mail.ts
git commit -m "feat(mail) : service API mail — listFolders/messages/getMessage/markRead/markFlagged/createTask/linkTask/downloadAttachment/triggerSync"
```
---
### Task 5 : Store Pinia — `frontend/stores/mail.ts`
Store composition API, pattern `defineStore('mail', () => { ... })` identique à `stores/timer.ts`. Le polling (30s) utilise `setInterval` natif + cleanup `onScopeDispose` — pas de VueUse (non installé). Le getter `folderTree` construit l'arbre depuis la liste plate en utilisant `parentPath`.
- [ ] **Step 1 : Créer `frontend/stores/mail.ts`**
```typescript
import { defineStore } from 'pinia'
import type {
MailFolderDto,
MailMessageHeaderDto,
MailMessageDetailDto,
} from '~/services/dto/mail'
import { useMailService } from '~/services/mail'
const POLL_INTERVAL_MS = 30 * 1000 // 30 secondes
export const useMailStore = defineStore('mail', () => {
// ─── State ────────────────────────────────────────────────────────────────
/** Liste plate des dossiers (reçue de l'API) */
const folders = ref<MailFolderDto[]>([])
/** Chemin du dossier actuellement sélectionné */
const selectedFolderPath = ref<string | null>(null)
/** Messages du dossier sélectionné (accumulés pour infinite scroll) */
const messages = ref<MailMessageHeaderDto[]>([])
/** Cursor de pagination pour la page suivante (null = plus de données) */
const messagesCursor = ref<string | null>(null)
/** Chargement en cours (messages) */
const messagesLoading = ref(false)
/** ID du message sélectionné pour lecture */
const selectedMessageId = ref<number | null>(null)
/** Détail complet du message sélectionné (body + PJ) */
const selectedMessageDetail = ref<MailMessageDetailDto | null>(null)
/** Chargement du détail en cours */
const detailLoading = ref(false)
/** Sync IMAP en cours (déclenchée manuellement) */
const syncing = ref(false)
/** Nombre total de messages non lus (toutes boîtes confondues) */
const globalUnreadCount = ref(0)
/** Erreur courante (null si aucune) */
const error = ref<string | null>(null)
let pollTimer: ReturnType<typeof setInterval> | null = null
// ─── Getters ──────────────────────────────────────────────────────────────
/**
* Nombre de non-lus dans INBOX uniquement (utilisé dans la sidebar).
*/
const inboxUnread = computed(() => {
const inbox = folders.value.find(
(f) => f.path === 'INBOX' || f.path.toUpperCase() === 'INBOX',
)
return inbox?.unreadCount ?? 0
})
/**
* Construit l'arbre de dossiers depuis la liste plate.
* Les dossiers sans parentPath sont à la racine.
* Les enfants sont triés alphabétiquement par displayName.
*/
const folderTree = computed((): MailFolderDto[] => {
const map = new Map<string, MailFolderDto>()
const roots: MailFolderDto[] = []
// Initialiser chaque dossier avec children vide
folders.value.forEach((folder) => {
map.set(folder.path, { ...folder, children: [] })
})
// Construire l'arbre
map.forEach((folder) => {
if (folder.parentPath && map.has(folder.parentPath)) {
const parent = map.get(folder.parentPath)!
parent.children = parent.children ?? []
parent.children.push(folder)
} else {
roots.push(folder)
}
})
// Trier les enfants alphabétiquement
function sortChildren(nodes: MailFolderDto[]): MailFolderDto[] {
return nodes
.map((n) => ({
...n,
children: n.children ? sortChildren(n.children) : undefined,
}))
.sort((a, b) => a.displayName.localeCompare(b.displayName, 'fr'))
}
return sortChildren(roots)
})
/**
* Indique si le cursor de pagination est disponible (plus de messages à charger).
*/
const hasMoreMessages = computed(() => messagesCursor.value !== null)
// ─── Actions ──────────────────────────────────────────────────────────────
/**
* Charge la liste des dossiers depuis l'API et met à jour globalUnreadCount.
*/
async function fetchFolders(): Promise<void> {
const service = useMailService()
try {
folders.value = await service.listFolders()
globalUnreadCount.value = folders.value.reduce(
(sum, f) => sum + f.unreadCount,
0,
)
} catch {
// Silently ignore polling errors (ne pas interrompre l'UX)
}
}
/**
* Sélectionne un dossier et charge ses messages (reset de la pagination).
* @param path - Chemin du dossier (ex: "INBOX")
*/
async function selectFolder(path: string): Promise<void> {
if (selectedFolderPath.value === path) return
selectedFolderPath.value = path
messages.value = []
messagesCursor.value = null
selectedMessageId.value = null
selectedMessageDetail.value = null
await fetchMessages()
}
/**
* Charge les messages du dossier sélectionné.
* @param append - Si true, ajoute à la liste existante (infinite scroll). Si false, remplace.
*/
async function fetchMessages(append = false): Promise<void> {
if (!selectedFolderPath.value) return
if (messagesLoading.value) return
messagesLoading.value = true
error.value = null
const service = useMailService()
try {
const cursor = append ? (messagesCursor.value ?? undefined) : undefined
const page = await service.listMessages(selectedFolderPath.value, cursor)
if (append) {
messages.value = [...messages.value, ...page.items]
} else {
messages.value = page.items
}
messagesCursor.value = page.nextCursor
} catch (err) {
error.value = err instanceof Error ? err.message : 'Erreur lors du chargement des messages.'
} finally {
messagesLoading.value = false
}
}
/**
* Sélectionne un message et charge son détail complet (body + PJ).
* Marque automatiquement le message comme lu si ce n'est pas déjà le cas.
* @param id - ID BDD du message
*/
async function selectMessage(id: number): Promise<void> {
if (selectedMessageId.value === id) return
selectedMessageId.value = id
selectedMessageDetail.value = null
detailLoading.value = true
const service = useMailService()
try {
const detail = await service.getMessage(id)
selectedMessageDetail.value = detail
// Auto-mark as read si nécessaire
if (!detail.header.isRead) {
await markRead(id, true)
}
} finally {
detailLoading.value = false
}
}
/**
* Marque un message comme lu ou non-lu.
* Met à jour le state local (messages + detail) sans refetch.
*/
async function markRead(id: number, read: boolean): Promise<void> {
const service = useMailService()
const updated = await service.markRead(id, read)
// Mise à jour optimiste dans la liste
const idx = messages.value.findIndex((m) => m.id === id)
if (idx !== -1) {
messages.value[idx] = { ...messages.value[idx], isRead: updated.isRead }
}
// Mise à jour dans le détail si ouvert
if (selectedMessageDetail.value?.header.id === id) {
selectedMessageDetail.value = {
...selectedMessageDetail.value,
header: { ...selectedMessageDetail.value.header, isRead: updated.isRead },
}
}
// Mettre à jour le compteur du dossier
await _refreshFolderUnreadCount()
}
/**
* Marque un message comme étoilé ou non-étoilé.
* Met à jour le state local sans refetch.
*/
async function markFlagged(id: number, flagged: boolean): Promise<void> {
const service = useMailService()
const updated = await service.markFlagged(id, flagged)
const idx = messages.value.findIndex((m) => m.id === id)
if (idx !== -1) {
messages.value[idx] = { ...messages.value[idx], isFlagged: updated.isFlagged }
}
if (selectedMessageDetail.value?.header.id === id) {
selectedMessageDetail.value = {
...selectedMessageDetail.value,
header: { ...selectedMessageDetail.value.header, isFlagged: updated.isFlagged },
}
}
}
/**
* Déclenche une synchronisation IMAP asynchrone.
* Recharge les dossiers après 2s pour refléter les nouveaux messages.
*/
async function triggerSync(): Promise<void> {
if (syncing.value) return
syncing.value = true
const service = useMailService()
try {
await service.triggerSync()
// Laisser le temps au handler Messenger de traiter
setTimeout(async () => {
await fetchFolders()
if (selectedFolderPath.value) {
await fetchMessages(false)
}
syncing.value = false
}, 2000)
} catch {
syncing.value = false
}
}
/**
* Démarre le polling toutes les 30s pour mettre à jour globalUnreadCount.
* À appeler dans app.vue ou le layout default au login.
* Idempotent : un seul timer actif à la fois.
*/
function startPolling(): void {
if (pollTimer) return
fetchFolders() // Charge immédiatement
pollTimer = setInterval(fetchFolders, POLL_INTERVAL_MS)
// Cleanup automatique si le scope du store est détruit
if (getCurrentScope()) {
onScopeDispose(stopPolling)
}
}
/**
* Arrête le polling. À appeler au logout.
*/
function stopPolling(): void {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
/**
* Rafraîchit les compteurs non-lus du dossier actuel depuis l'API.
* Usage interne — appelé après markRead.
*/
async function _refreshFolderUnreadCount(): Promise<void> {
const service = useMailService()
try {
const updatedFolders = await service.listFolders()
folders.value = updatedFolders
globalUnreadCount.value = updatedFolders.reduce(
(sum, f) => sum + f.unreadCount,
0,
)
} catch {
// Silently ignore
}
}
return {
// State (readonly pour les consommateurs)
folders: readonly(folders),
selectedFolderPath: readonly(selectedFolderPath),
messages: readonly(messages),
messagesCursor: readonly(messagesCursor),
messagesLoading: readonly(messagesLoading),
selectedMessageId: readonly(selectedMessageId),
selectedMessageDetail: readonly(selectedMessageDetail),
detailLoading: readonly(detailLoading),
syncing: readonly(syncing),
globalUnreadCount: readonly(globalUnreadCount),
error: readonly(error),
// Getters
inboxUnread,
folderTree,
hasMoreMessages,
// Actions
fetchFolders,
selectFolder,
fetchMessages,
selectMessage,
markRead,
markFlagged,
triggerSync,
startPolling,
stopPolling,
}
})
```
- [ ] **Step 2 : Vérification TypeScript**
```bash
cd /home/r-dev/malio-dev/Lesstime/frontend && npx tsc --noEmit 2>&1 | grep "stores/mail" | head -20
```
Attendu : aucune erreur.
- [ ] **Step 3 : Commit**
```bash
git add frontend/stores/mail.ts
git commit -m "feat(mail) : store Pinia useMailStore — folders, messages, polling 30s, markRead/markFlagged"
```
---
### Task 6 : Validation TypeScript globale + smoke test
- [ ] **Step 1 : Vérification TypeScript globale**
```bash
cd /home/r-dev/malio-dev/Lesstime/frontend && npx tsc --noEmit 2>&1
```
Attendu : aucune erreur. Si des erreurs apparaissent sur les fichiers créés, les corriger avant de continuer.
Erreurs fréquentes à anticiper :
- `Property 'readonly' does not exist` → vérifier que Vue `readonly()` est bien importé (auto-import Nuxt, normalement transparent)
- `Cannot find module 'dompurify'` → vérifier que `@types/dompurify` est bien installé
- `Property 'children' does not exist` → vérifier que `MailFolderDto.children` est bien `MailFolderDto[] | undefined` dans le DTO
- [ ] **Step 2 : Smoke test manuel — appel listFolders depuis devtools**
Ouvrir `http://localhost:3002` dans le navigateur (avec l'utilisateur `alice` connecté), puis dans la console DevTools :
```javascript
const { useMailService } = await import('/services/mail.ts')
const svc = useMailService()
const folders = await svc.listFolders()
console.log(folders)
```
Attendu (si Phase 3 backend déployée) : tableau de dossiers.
Attendu (si Phase 3 non encore déployée) : erreur 404 — normal à ce stade.
Non attendu : erreur TypeScript ou erreur d'import.
Alternative sans Phase 3 : vérifier que l'import ne crashe pas Nuxt :
```bash
cd /home/r-dev/malio-dev/Lesstime/frontend && npx nuxt dev --port 3099 &
# Attendre "Nuxt ready" puis ctrl+C
# Si Nuxt démarre sans erreur de module, c'est OK
```
- [ ] **Step 3 : Vérification finale — liste des fichiers créés**
```bash
ls -la /home/r-dev/malio-dev/Lesstime/frontend/services/dto/mail.ts \
/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
```
Attendu : 4 fichiers présents.
- [ ] **Step 4 : Commit final si des corrections ont été apportées**
```bash
git add frontend/services/dto/mail.ts frontend/services/mail.ts frontend/stores/mail.ts frontend/utils/sanitizeMailHtml.ts
git commit -m "fix(mail) : corrections TypeScript phase 4 post-typecheck"
```
Ne commiter que si des corrections ont été nécessaires à l'étape précédente.
---
## Exigences techniques
- **TypeScript strict** — le projet est en mode strict (`tsconfig.json` hérité Nuxt)
- **4 espaces d'indentation** — convention Lesstime (cf. CLAUDE.md)
- **Pattern `useApi()`** — wrappe `$fetch`, gère JWT cookie (credentials: include), toasts i18n, erreurs HTTP
- **PATCH** utilise `Content-Type: application/merge-patch+json` (géré automatiquement par `useApi().patch()`)
- **`api.getBlob()`** pour `downloadAttachment` — retourne `{ data: Blob, headers: Headers }`, signature disponible dans `composables/useApi.ts`
- **Store Pinia composition API** — `defineStore('mail', () => { ... })`, jamais options API
- **Polling** — `setInterval` natif + `clearInterval` dans `stopPolling()` ; `onScopeDispose` pour cleanup automatique
- **DOMPurify** — `import DOMPurify from 'dompurify'` ; SSR-safe car appelé uniquement côté client dans les composants Vue (Phase 5)
- **`readonly()`** sur les refs exposés par le store — empêche la mutation directe depuis les composants
- **Format commits** : `feat(mail) : <message>` ou `fix(mail) : <message>` (espace avant `:`)
- **Chaque task = un ou plusieurs fichiers cohérents + un commit dédié**
- **Smoke test final** ne doit PAS modifier l'app en production (test dans devtools ou build temporaire)
---
## Output attendu (résumé pour la review humaine)
À la fin de la Phase 4, les éléments suivants doivent être présents et valides :
| Livrable | Fichier | Statut attendu |
|---|---|---|
| Types TS DTOs | `frontend/services/dto/mail.ts` | Créé, 0 erreur TS |
| Helper sanitization | `frontend/utils/sanitizeMailHtml.ts` | Créé, 0 erreur TS |
| Service API | `frontend/services/mail.ts` | Créé, 0 erreur TS |
| Store Pinia | `frontend/stores/mail.ts` | Créé, 0 erreur TS |
| Dépendance DOMPurify | `frontend/package.json` | `dompurify` dans dependencies, `@types/dompurify` dans devDependencies |
**Critère d'acceptation :**
```bash
cd /home/r-dev/malio-dev/Lesstime/frontend && npx tsc --noEmit
# → Sortie vide (0 erreur)
```
La Phase 5 (UI principale `/mail`) peut commencer dès que cette commande passe sans erreur.