1108 lines
39 KiB
Markdown
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.
|