1204 lines
49 KiB
Markdown
1204 lines
49 KiB
Markdown
# Mail Integration — Phase 5 : UI principale /mail (3 colonnes)
|
|
|
|
> **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:** Construire la page `/mail` en layout 3 colonnes (dossiers / liste / lecteur) avec composants Vue réutilisables, en branchant le store `useMailStore` créé en Phase 4. Sanitize HTML body via DOMPurify. Refus visuel pour ROLE_CLIENT.
|
|
|
|
**Architecture:** 1 page Nuxt `pages/mail.vue`, 4 composants sous `components/mail/`, 1 composable helper `composables/useSystemFolderLabel.ts`. Mapping noms dossiers système (`INBOX` → "Boîte de réception") côté front. Deep-link `?messageId=X` pour sélectionner un mail à l'ouverture. Pas de mode édition/réponse en MVP.
|
|
|
|
**Tech Stack:** Nuxt 4, Vue 3 Composition API + `<script setup lang="ts">`, Pinia (useMailStore), @malio/layer-ui (MalioButton, MalioButtonIcon), Tailwind CSS, DOMPurify (via `utils/sanitizeMailHtml`), @nuxt/icon.
|
|
|
|
**Branche cible :** `feat/mail-integration` (vérifier qu'elle est active avant de commencer).
|
|
|
|
**Fichiers créés/modifiés par le codeur :**
|
|
|
|
| Fichier | Action |
|
|
|---|---|
|
|
| `frontend/pages/mail.vue` | Créer |
|
|
| `frontend/components/mail/MailFolderTree.vue` | Créer |
|
|
| `frontend/components/mail/MailMessageList.vue` | Créer |
|
|
| `frontend/components/mail/MailMessageViewer.vue` | Créer |
|
|
| `frontend/components/mail/MailRefreshButton.vue` | Créer |
|
|
| `frontend/composables/useSystemFolderLabel.ts` | Créer |
|
|
| `frontend/i18n/locales/fr.json` | Modifier (ajout clés `mail.*`) |
|
|
|
|
---
|
|
|
|
### Task 1 : Vérification de l'environnement
|
|
|
|
- [ ] **Step 1 : Vérifier la branche active**
|
|
|
|
```bash
|
|
git -C /home/r-dev/malio-dev/Lesstime branch --show-current
|
|
```
|
|
|
|
Attendu : `feat/mail-integration`. Si non, basculer :
|
|
|
|
```bash
|
|
git -C /home/r-dev/malio-dev/Lesstime checkout feat/mail-integration
|
|
```
|
|
|
|
- [ ] **Step 2 : Vérifier que les fichiers Phase 4 existent**
|
|
|
|
```bash
|
|
ls -la /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 \
|
|
/home/r-dev/malio-dev/Lesstime/frontend/services/dto/mail.ts
|
|
```
|
|
|
|
Attendu : les 4 fichiers présents. Si absents, Phase 4 n'a pas été complétée — arrêter et implémenter Phase 4 d'abord.
|
|
|
|
- [ ] **Step 3 : Vérifier que TypeScript est propre (baseline)**
|
|
|
|
```bash
|
|
cd /home/r-dev/malio-dev/Lesstime/frontend && npx tsc --noEmit 2>&1 | head -20
|
|
```
|
|
|
|
Attendu : aucune erreur sur les fichiers Phase 4. Si des erreurs existent, les noter pour les distinguer des erreurs Phase 5.
|
|
|
|
---
|
|
|
|
### Task 2 : Clés i18n `mail.*` — `frontend/i18n/locales/fr.json`
|
|
|
|
Les clés sont ajoutées dans `fr.json` (et `en.json` si présent — vérifier avec `ls frontend/i18n/locales/`). Le bloc `mail` est inséré après le dernier bloc de premier niveau existant (actuellement après `help` ou le dernier bloc de la spec existante).
|
|
|
|
Pattern existant observé : blocs de premier niveau avec sous-clés plates ou imbriquées (voir `taskDocuments`, `timeEntries`, `myTasks`).
|
|
|
|
- [ ] **Step 1 : Ajouter le bloc `mail` dans `frontend/i18n/locales/fr.json`**
|
|
|
|
Ajouter avant la fermeture `}` finale du JSON :
|
|
|
|
```json
|
|
"mail": {
|
|
"title": "Messagerie",
|
|
"folders": "Dossiers",
|
|
"messages": "Messages",
|
|
"viewer": "Lecture",
|
|
"empty": {
|
|
"folder": "Aucun dossier disponible.",
|
|
"list": "Aucun message dans ce dossier.",
|
|
"viewer": "Sélectionnez un message pour le lire."
|
|
},
|
|
"systemFolder": {
|
|
"inbox": "Boîte de réception",
|
|
"sent": "Éléments envoyés",
|
|
"drafts": "Brouillons",
|
|
"archive": "Archives",
|
|
"trash": "Corbeille",
|
|
"junk": "Indésirables"
|
|
},
|
|
"actions": {
|
|
"refresh": "Actualiser",
|
|
"createTask": "Créer une tâche",
|
|
"linkTask": "Lier à une tâche",
|
|
"markRead": "Marquer comme lu",
|
|
"markUnread": "Marquer comme non lu",
|
|
"flag": "Marquer important",
|
|
"unflag": "Retirer l'importance",
|
|
"download": "Télécharger",
|
|
"showImages": "Afficher les images"
|
|
},
|
|
"errors": {
|
|
"syncFailed": "Erreur lors de la synchronisation.",
|
|
"fetchFailed": "Impossible de charger les messages.",
|
|
"notAuthorized": "Vous n'avez pas accès à la messagerie."
|
|
},
|
|
"configuration": {
|
|
"saved": "Configuration mail enregistrée."
|
|
},
|
|
"task": {
|
|
"created": "Tâche créée depuis le mail.",
|
|
"linked": "Mail lié à la tâche.",
|
|
"unlinked": "Lien supprimé."
|
|
},
|
|
"sync": {
|
|
"dispatched": "Synchronisation lancée en arrière-plan."
|
|
},
|
|
"attachments": "Pièces jointes",
|
|
"noAttachments": "Aucune pièce jointe.",
|
|
"from": "De",
|
|
"to": "À",
|
|
"cc": "Cc",
|
|
"date": "Date",
|
|
"subject": "Sujet",
|
|
"noSubject": "(Sans objet)",
|
|
"loadMore": "Charger plus",
|
|
"loading": "Chargement…",
|
|
"hasAttachments": "Pièces jointes",
|
|
"unread": "non lu | non lus"
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2 : Si `frontend/i18n/locales/en.json` existe, ajouter le bloc traduit**
|
|
|
|
```bash
|
|
ls /home/r-dev/malio-dev/Lesstime/frontend/i18n/locales/
|
|
```
|
|
|
|
Si `en.json` existe, ajouter le même bloc avec traductions anglaises :
|
|
|
|
```json
|
|
"mail": {
|
|
"title": "Mail",
|
|
"folders": "Folders",
|
|
"messages": "Messages",
|
|
"viewer": "Reader",
|
|
"empty": {
|
|
"folder": "No folders available.",
|
|
"list": "No messages in this folder.",
|
|
"viewer": "Select a message to read it."
|
|
},
|
|
"systemFolder": {
|
|
"inbox": "Inbox",
|
|
"sent": "Sent",
|
|
"drafts": "Drafts",
|
|
"archive": "Archive",
|
|
"trash": "Trash",
|
|
"junk": "Junk"
|
|
},
|
|
"actions": {
|
|
"refresh": "Refresh",
|
|
"createTask": "Create task",
|
|
"linkTask": "Link to task",
|
|
"markRead": "Mark as read",
|
|
"markUnread": "Mark as unread",
|
|
"flag": "Mark as important",
|
|
"unflag": "Remove importance",
|
|
"download": "Download",
|
|
"showImages": "Show images"
|
|
},
|
|
"errors": {
|
|
"syncFailed": "Synchronization failed.",
|
|
"fetchFailed": "Unable to load messages.",
|
|
"notAuthorized": "You do not have access to mail."
|
|
},
|
|
"configuration": {
|
|
"saved": "Mail configuration saved."
|
|
},
|
|
"task": {
|
|
"created": "Task created from mail.",
|
|
"linked": "Mail linked to task.",
|
|
"unlinked": "Link removed."
|
|
},
|
|
"sync": {
|
|
"dispatched": "Sync launched in background."
|
|
},
|
|
"attachments": "Attachments",
|
|
"noAttachments": "No attachments.",
|
|
"from": "From",
|
|
"to": "To",
|
|
"cc": "Cc",
|
|
"date": "Date",
|
|
"subject": "Subject",
|
|
"noSubject": "(No subject)",
|
|
"loadMore": "Load more",
|
|
"loading": "Loading…",
|
|
"hasAttachments": "Attachments",
|
|
"unread": "unread"
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3 : Valider que le JSON est syntaxiquement correct**
|
|
|
|
```bash
|
|
node -e "JSON.parse(require('fs').readFileSync('/home/r-dev/malio-dev/Lesstime/frontend/i18n/locales/fr.json', 'utf8')); console.log('JSON OK')"
|
|
```
|
|
|
|
Attendu : `JSON OK`. Si erreur, corriger la virgule manquante ou le brace mal fermé.
|
|
|
|
- [ ] **Step 4 : Commit**
|
|
|
|
```bash
|
|
git -C /home/r-dev/malio-dev/Lesstime add frontend/i18n/locales/fr.json frontend/i18n/locales/en.json 2>/dev/null; true
|
|
git -C /home/r-dev/malio-dev/Lesstime commit -m "feat(mail) : clés i18n mail.* (titres, vides, dossiers système, actions, erreurs)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3 : Composable `useSystemFolderLabel.ts`
|
|
|
|
Ce composable centralise le mapping entre les chemins de dossiers système IMAP (case-sensitive selon serveur) et leurs labels i18n. Il est utilisé par `MailFolderTree.vue`.
|
|
|
|
- [ ] **Step 1 : Créer `frontend/composables/useSystemFolderLabel.ts`**
|
|
|
|
```typescript
|
|
/**
|
|
* Mapping des chemins de dossiers système IMAP vers les clés i18n.
|
|
* Les clés sont normalisées en minuscules pour la comparaison.
|
|
* Couvre les variantes OVH courantes (INBOX, INBOX.Sent, Sent, etc.)
|
|
*/
|
|
const SYSTEM_FOLDER_MAP: Record<string, string> = {
|
|
'inbox': 'mail.systemFolder.inbox',
|
|
'sent': 'mail.systemFolder.sent',
|
|
'inbox.sent': 'mail.systemFolder.sent',
|
|
'sent messages': 'mail.systemFolder.sent',
|
|
'drafts': 'mail.systemFolder.drafts',
|
|
'inbox.drafts': 'mail.systemFolder.drafts',
|
|
'archive': 'mail.systemFolder.archive',
|
|
'archives': 'mail.systemFolder.archive',
|
|
'inbox.archive': 'mail.systemFolder.archive',
|
|
'trash': 'mail.systemFolder.trash',
|
|
'deleted': 'mail.systemFolder.trash',
|
|
'deleted items': 'mail.systemFolder.trash',
|
|
'inbox.trash': 'mail.systemFolder.trash',
|
|
'junk': 'mail.systemFolder.junk',
|
|
'junk e-mail': 'mail.systemFolder.junk',
|
|
'spam': 'mail.systemFolder.junk',
|
|
'inbox.junk': 'mail.systemFolder.junk',
|
|
}
|
|
|
|
/**
|
|
* Icônes Material Symbols associées aux dossiers système.
|
|
* Pour les dossiers non reconnus : null → utiliser une icône générique.
|
|
*/
|
|
const SYSTEM_FOLDER_ICONS: Record<string, string> = {
|
|
'mail.systemFolder.inbox': 'material-symbols:inbox-outline',
|
|
'mail.systemFolder.sent': 'material-symbols:send-outline',
|
|
'mail.systemFolder.drafts': 'material-symbols:draft-outline',
|
|
'mail.systemFolder.archive': 'material-symbols:archive-outline',
|
|
'mail.systemFolder.trash': 'material-symbols:delete-outline',
|
|
'mail.systemFolder.junk': 'material-symbols:report-outline',
|
|
}
|
|
|
|
const DEFAULT_FOLDER_ICON = 'material-symbols:folder-outline'
|
|
|
|
export function useSystemFolderLabel() {
|
|
const { t } = useI18n()
|
|
|
|
/**
|
|
* Retourne le label traduit d'un dossier système, ou son displayName si inconnu.
|
|
* @param path - Chemin IMAP du dossier (ex: "INBOX", "INBOX.Sent")
|
|
* @param displayName - Nom affiché par défaut si non reconnu
|
|
*/
|
|
function getFolderLabel(path: string, displayName: string): string {
|
|
const key = SYSTEM_FOLDER_MAP[path.toLowerCase()]
|
|
return key ? t(key) : displayName
|
|
}
|
|
|
|
/**
|
|
* Retourne le nom de l'icône Material Symbols pour un dossier.
|
|
* @param path - Chemin IMAP du dossier
|
|
*/
|
|
function getFolderIcon(path: string): string {
|
|
const key = SYSTEM_FOLDER_MAP[path.toLowerCase()]
|
|
return key ? (SYSTEM_FOLDER_ICONS[key] ?? DEFAULT_FOLDER_ICON) : DEFAULT_FOLDER_ICON
|
|
}
|
|
|
|
/**
|
|
* Indique si un dossier est un dossier système reconnu.
|
|
*/
|
|
function isSystemFolder(path: string): boolean {
|
|
return path.toLowerCase() in SYSTEM_FOLDER_MAP
|
|
}
|
|
|
|
return {
|
|
getFolderLabel,
|
|
getFolderIcon,
|
|
isSystemFolder,
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2 : Vérification TypeScript**
|
|
|
|
```bash
|
|
cd /home/r-dev/malio-dev/Lesstime/frontend && npx tsc --noEmit 2>&1 | grep "useSystemFolderLabel" | head -10
|
|
```
|
|
|
|
Attendu : aucune erreur.
|
|
|
|
- [ ] **Step 3 : Commit**
|
|
|
|
```bash
|
|
git -C /home/r-dev/malio-dev/Lesstime add frontend/composables/useSystemFolderLabel.ts
|
|
git -C /home/r-dev/malio-dev/Lesstime commit -m "feat(mail) : composable useSystemFolderLabel — mapping dossiers système IMAP → i18n + icônes"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4 : Composant `MailRefreshButton.vue`
|
|
|
|
Petit composant indépendant, sans dépendances sur les autres composants mail — à créer en premier pour qu'il soit disponible dans `mail.vue`.
|
|
|
|
- [ ] **Step 1 : Créer `frontend/components/mail/MailRefreshButton.vue`**
|
|
|
|
```vue
|
|
<script setup lang="ts">
|
|
import { useMailStore } from '~/stores/mail'
|
|
|
|
const store = useMailStore()
|
|
const { syncing } = storeToRefs(store)
|
|
|
|
const { t } = useI18n()
|
|
|
|
async function handleRefresh(): Promise<void> {
|
|
await store.triggerSync()
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<MalioButton
|
|
:label="t('mail.actions.refresh')"
|
|
variant="secondary"
|
|
icon-name="material-symbols:refresh"
|
|
icon-position="left"
|
|
:icon-size="16"
|
|
:disabled="syncing"
|
|
@click="handleRefresh"
|
|
/>
|
|
</template>
|
|
```
|
|
|
|
- [ ] **Step 2 : Commit**
|
|
|
|
```bash
|
|
git -C /home/r-dev/malio-dev/Lesstime add frontend/components/mail/MailRefreshButton.vue
|
|
git -C /home/r-dev/malio-dev/Lesstime commit -m "feat(mail) : MailRefreshButton — bouton sync manuel, disabled pendant syncing"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5 : Composant `MailFolderTree.vue`
|
|
|
|
Arbre récursif des dossiers mail. Reçoit `folderTree` (computed du store, déjà arborifié). Émet `select` avec le `path` du dossier cliqué.
|
|
|
|
- [ ] **Step 1 : Créer `frontend/components/mail/MailFolderTree.vue`**
|
|
|
|
```vue
|
|
<script setup lang="ts">
|
|
import type { MailFolderDto } from '~/services/dto/mail'
|
|
|
|
const props = defineProps<{
|
|
/** Arbre de dossiers (getter folderTree du store) */
|
|
folders: MailFolderDto[]
|
|
/** Chemin du dossier actuellement sélectionné */
|
|
selectedPath: string | null
|
|
/** Niveau de profondeur pour l'indentation (usage récursif interne) */
|
|
depth?: number
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
select: [path: string]
|
|
}>()
|
|
|
|
const { getFolderLabel, getFolderIcon } = useSystemFolderLabel()
|
|
const { t } = useI18n()
|
|
|
|
const depth = computed(() => props.depth ?? 0)
|
|
|
|
function handleSelect(path: string): void {
|
|
emit('select', path)
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<div v-if="folders.length === 0 && depth === 0" class="px-3 py-4 text-sm text-neutral-400 italic">
|
|
{{ t('mail.empty.folder') }}
|
|
</div>
|
|
|
|
<template v-else>
|
|
<button
|
|
v-for="folder in folders"
|
|
:key="folder.path"
|
|
type="button"
|
|
class="w-full text-left"
|
|
@click="handleSelect(folder.path)"
|
|
>
|
|
<!-- Entrée dossier -->
|
|
<div
|
|
class="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm transition-colors"
|
|
:class="[
|
|
{ 'pl-[calc(0.75rem_+_theme(spacing[3])_*_v-bind(depth))]': depth > 0 },
|
|
selectedPath === folder.path
|
|
? 'bg-primary-100 text-primary-700 font-medium'
|
|
: 'text-neutral-700 hover:bg-neutral-100',
|
|
]"
|
|
:style="depth > 0 ? { paddingLeft: `${0.75 + depth * 0.75}rem` } : {}"
|
|
>
|
|
<!-- Icône dossier -->
|
|
<Icon
|
|
:name="getFolderIcon(folder.path)"
|
|
size="16"
|
|
class="flex-shrink-0"
|
|
:class="selectedPath === folder.path ? 'text-primary-600' : 'text-neutral-400'"
|
|
/>
|
|
|
|
<!-- Label -->
|
|
<span class="flex-1 truncate">
|
|
{{ getFolderLabel(folder.path, folder.displayName) }}
|
|
</span>
|
|
|
|
<!-- Badge non-lus -->
|
|
<span
|
|
v-if="folder.unreadCount > 0"
|
|
class="ml-auto flex-shrink-0 rounded-full bg-primary-500 px-1.5 py-0.5 text-xs font-bold text-white"
|
|
>
|
|
{{ folder.unreadCount > 99 ? '99+' : folder.unreadCount }}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Sous-dossiers récursifs -->
|
|
<MailFolderTree
|
|
v-if="folder.children && folder.children.length > 0"
|
|
:folders="folder.children"
|
|
:selected-path="selectedPath"
|
|
:depth="depth + 1"
|
|
@select="handleSelect"
|
|
/>
|
|
</button>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
```
|
|
|
|
Note technique : l'indentation dynamique par CSS `calc` avec `v-bind(depth)` n'est pas fiable dans Tailwind — utiliser `:style` natif comme ci-dessus (`paddingLeft: depth * 0.75rem`). La récursivité via `<MailFolderTree>` fonctionne car Nuxt auto-importe les composants.
|
|
|
|
- [ ] **Step 2 : Commit**
|
|
|
|
```bash
|
|
git -C /home/r-dev/malio-dev/Lesstime add frontend/components/mail/MailFolderTree.vue
|
|
git -C /home/r-dev/malio-dev/Lesstime commit -m "feat(mail) : MailFolderTree — arbre récursif dossiers, badges unread, icônes système"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6 : Composant `MailMessageList.vue`
|
|
|
|
Liste paginée des messages du dossier sélectionné. Infinite scroll via `IntersectionObserver` sur un sentinel div en bas de liste. Indicateurs visuels : point ● (non lu), étoile ⭐ (flagged), trombone 📎 (hasAttachments), date relative.
|
|
|
|
- [ ] **Step 1 : Créer `frontend/components/mail/MailMessageList.vue`**
|
|
|
|
```vue
|
|
<script setup lang="ts">
|
|
import type { MailMessageHeaderDto } from '~/services/dto/mail'
|
|
|
|
const props = defineProps<{
|
|
messages: MailMessageHeaderDto[]
|
|
selectedId: number | null
|
|
loading: boolean
|
|
hasMore: boolean
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
select: [id: number]
|
|
loadMore: []
|
|
}>()
|
|
|
|
const { t } = useI18n()
|
|
|
|
// ─── Infinite scroll via IntersectionObserver ──────────────────────────────
|
|
|
|
const sentinelRef = ref<HTMLDivElement | null>(null)
|
|
let observer: IntersectionObserver | null = null
|
|
|
|
onMounted(() => {
|
|
if (!sentinelRef.value) return
|
|
observer = new IntersectionObserver(
|
|
(entries) => {
|
|
const entry = entries[0]
|
|
if (entry?.isIntersecting && props.hasMore && !props.loading) {
|
|
emit('loadMore')
|
|
}
|
|
},
|
|
{ threshold: 0.1 },
|
|
)
|
|
observer.observe(sentinelRef.value)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
observer?.disconnect()
|
|
observer = null
|
|
})
|
|
|
|
// ─── Date relative ──────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Formate une date ISO en date relative (il y a X minutes/heures/jours).
|
|
* Utilise Intl.RelativeTimeFormat avec la locale du navigateur.
|
|
*/
|
|
function formatRelative(isoDate: string | null): string {
|
|
if (!isoDate) return ''
|
|
const date = new Date(isoDate)
|
|
const now = new Date()
|
|
const diffMs = date.getTime() - now.getTime()
|
|
const diffSeconds = Math.round(diffMs / 1000)
|
|
const diffMinutes = Math.round(diffSeconds / 60)
|
|
const diffHours = Math.round(diffMinutes / 60)
|
|
const diffDays = Math.round(diffHours / 24)
|
|
|
|
const rtf = new Intl.RelativeTimeFormat('fr', { numeric: 'auto' })
|
|
|
|
if (Math.abs(diffMinutes) < 1) return rtf.format(diffSeconds, 'second')
|
|
if (Math.abs(diffHours) < 1) return rtf.format(diffMinutes, 'minute')
|
|
if (Math.abs(diffDays) < 1) return rtf.format(diffHours, 'hour')
|
|
if (Math.abs(diffDays) < 30) return rtf.format(diffDays, 'day')
|
|
|
|
// Au-delà de 30 jours : date courte
|
|
return date.toLocaleDateString('fr', { day: '2-digit', month: 'short', year: 'numeric' })
|
|
}
|
|
|
|
/**
|
|
* Retourne un label court pour l'expéditeur (nom si disponible, sinon email).
|
|
*/
|
|
function getSenderLabel(msg: MailMessageHeaderDto): string {
|
|
return msg.fromName ?? msg.fromEmail ?? ''
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="flex h-full flex-col overflow-hidden">
|
|
<!-- État vide -->
|
|
<div
|
|
v-if="!loading && messages.length === 0"
|
|
class="flex flex-1 items-center justify-center text-sm text-neutral-400 italic px-4 text-center"
|
|
>
|
|
{{ t('mail.empty.list') }}
|
|
</div>
|
|
|
|
<!-- Liste des messages -->
|
|
<div v-else class="flex-1 overflow-y-auto divide-y divide-neutral-100">
|
|
<button
|
|
v-for="msg in messages"
|
|
:key="msg.id"
|
|
type="button"
|
|
class="flex w-full gap-3 px-3 py-3 text-left transition-colors hover:bg-neutral-50 focus:outline-none"
|
|
:class="[
|
|
selectedId === msg.id ? 'bg-primary-50 border-l-2 border-primary-500' : '',
|
|
!msg.isRead ? 'bg-white' : 'bg-neutral-50/50',
|
|
]"
|
|
@click="emit('select', msg.id)"
|
|
>
|
|
<!-- Indicateur non-lu -->
|
|
<div class="mt-1.5 flex-shrink-0">
|
|
<span
|
|
class="block h-2 w-2 rounded-full"
|
|
:class="msg.isRead ? 'bg-transparent' : 'bg-primary-500'"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Contenu -->
|
|
<div class="min-w-0 flex-1">
|
|
<!-- Ligne 1 : expéditeur + date -->
|
|
<div class="flex items-center justify-between gap-2">
|
|
<span
|
|
class="truncate text-sm"
|
|
:class="msg.isRead ? 'text-neutral-600 font-normal' : 'text-neutral-900 font-semibold'"
|
|
>
|
|
{{ getSenderLabel(msg) }}
|
|
</span>
|
|
<span class="flex-shrink-0 text-xs text-neutral-400">
|
|
{{ formatRelative(msg.sentAt ?? msg.receivedAt) }}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Ligne 2 : sujet -->
|
|
<p
|
|
class="truncate text-sm"
|
|
:class="msg.isRead ? 'text-neutral-500' : 'text-neutral-800 font-medium'"
|
|
>
|
|
{{ msg.subject ?? t('mail.noSubject') }}
|
|
</p>
|
|
|
|
<!-- Ligne 3 : indicateurs -->
|
|
<div class="mt-0.5 flex items-center gap-1.5">
|
|
<Icon
|
|
v-if="msg.isFlagged"
|
|
name="material-symbols:star"
|
|
size="14"
|
|
class="text-amber-400 flex-shrink-0"
|
|
/>
|
|
<Icon
|
|
v-if="msg.hasAttachments"
|
|
name="material-symbols:attach-file"
|
|
size="14"
|
|
class="text-neutral-400 flex-shrink-0"
|
|
/>
|
|
<span v-if="msg.linkedTaskIds.length > 0" class="text-xs text-primary-400">
|
|
<Icon name="material-symbols:task-outline" size="14" class="flex-shrink-0" />
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
|
|
<!-- Sentinel infinite scroll -->
|
|
<div ref="sentinelRef" class="h-px" />
|
|
|
|
<!-- Spinner chargement page suivante -->
|
|
<div v-if="loading && messages.length > 0" class="flex items-center justify-center py-4">
|
|
<Icon name="material-symbols:progress-activity" size="20" class="animate-spin text-neutral-400" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Premier chargement -->
|
|
<div v-if="loading && messages.length === 0" class="flex flex-1 items-center justify-center">
|
|
<Icon name="material-symbols:progress-activity" size="24" class="animate-spin text-neutral-400" />
|
|
</div>
|
|
</div>
|
|
</template>
|
|
```
|
|
|
|
- [ ] **Step 2 : Commit**
|
|
|
|
```bash
|
|
git -C /home/r-dev/malio-dev/Lesstime add frontend/components/mail/MailMessageList.vue
|
|
git -C /home/r-dev/malio-dev/Lesstime commit -m "feat(mail) : MailMessageList — liste paginée infinite scroll, indicateurs lu/étoilé/PJ/date relative"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7 : Composant `MailMessageViewer.vue`
|
|
|
|
Lecteur de mail complet. Reçoit le détail complet du message sélectionné. Sanitize le HTML body via `sanitizeMailHtml`. Gère le toggle "Afficher les images distantes". Actions : Créer tâche, Lier à tâche, Marquer lu/non-lu, Étoiler. Téléchargement PJ.
|
|
|
|
- [ ] **Step 1 : Créer `frontend/components/mail/MailMessageViewer.vue`**
|
|
|
|
```vue
|
|
<script setup lang="ts">
|
|
import type { MailMessageDetailDto } from '~/services/dto/mail'
|
|
import { sanitizeMailHtml } from '~/utils/sanitizeMailHtml'
|
|
import { useMailService } from '~/services/mail'
|
|
import { useMailStore } from '~/stores/mail'
|
|
|
|
const props = defineProps<{
|
|
/** Détail complet du message. null = aucun message sélectionné. */
|
|
detail: MailMessageDetailDto | null
|
|
loading: boolean
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
/** Demande d'ouverture du modal "Créer tâche depuis ce mail" */
|
|
createTask: [mailId: number]
|
|
/** Demande d'ouverture du modal "Lier à une tâche existante" */
|
|
linkTask: [mailId: number]
|
|
}>()
|
|
|
|
const { t } = useI18n()
|
|
const store = useMailStore()
|
|
const mailService = useMailService()
|
|
|
|
// ─── Toggle images distantes ──────────────────────────────────────────────
|
|
|
|
const showImages = ref(false)
|
|
|
|
// ─── HTML sanitizé (client-side uniquement — projet SPA, pas de SSR) ──────
|
|
|
|
const sanitizedBody = computed((): string => {
|
|
if (!props.detail?.bodyHtml) return ''
|
|
return sanitizeMailHtml(props.detail.bodyHtml, { allowImages: showImages.value })
|
|
})
|
|
|
|
// Reset toggle images quand on change de message
|
|
watch(() => props.detail?.header.id, () => {
|
|
showImages.value = false
|
|
})
|
|
|
|
// ─── Actions ──────────────────────────────────────────────────────────────
|
|
|
|
async function handleMarkReadToggle(): Promise<void> {
|
|
if (!props.detail) return
|
|
const id = props.detail.header.id
|
|
const currentlyRead = props.detail.header.isRead
|
|
await store.markRead(id, !currentlyRead)
|
|
}
|
|
|
|
async function handleFlagToggle(): Promise<void> {
|
|
if (!props.detail) return
|
|
const id = props.detail.header.id
|
|
const currentlyFlagged = props.detail.header.isFlagged
|
|
await store.markFlagged(id, !currentlyFlagged)
|
|
}
|
|
|
|
async function handleDownload(downloadId: string, filename: string): Promise<void> {
|
|
try {
|
|
const { data } = await mailService.downloadAttachment(downloadId)
|
|
const url = URL.createObjectURL(data)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = filename
|
|
a.click()
|
|
URL.revokeObjectURL(url)
|
|
} catch {
|
|
// L'erreur est gérée par useApi (toast automatique)
|
|
}
|
|
}
|
|
|
|
// ─── Formatage ────────────────────────────────────────────────────────────
|
|
|
|
function formatDate(iso: string | null): string {
|
|
if (!iso) return ''
|
|
return new Date(iso).toLocaleString('fr', {
|
|
dateStyle: 'long',
|
|
timeStyle: 'short',
|
|
})
|
|
}
|
|
|
|
function joinAddresses(
|
|
addresses: Array<{ name: string | null; email: string }>,
|
|
): string {
|
|
return addresses
|
|
.map((a) => (a.name ? `${a.name} <${a.email}>` : a.email))
|
|
.join(', ')
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="flex h-full flex-col overflow-hidden">
|
|
<!-- État vide -->
|
|
<div
|
|
v-if="!detail && !loading"
|
|
class="flex flex-1 items-center justify-center text-sm text-neutral-400 italic px-8 text-center"
|
|
>
|
|
{{ t('mail.empty.viewer') }}
|
|
</div>
|
|
|
|
<!-- Chargement -->
|
|
<div v-else-if="loading" class="flex flex-1 items-center justify-center">
|
|
<Icon name="material-symbols:progress-activity" size="28" class="animate-spin text-neutral-400" />
|
|
</div>
|
|
|
|
<!-- Message chargé -->
|
|
<template v-else-if="detail">
|
|
<!-- Header message -->
|
|
<div class="flex-shrink-0 border-b border-neutral-200 px-4 py-3 space-y-1.5">
|
|
<!-- Sujet -->
|
|
<h2 class="text-base font-semibold text-neutral-900 break-words">
|
|
{{ detail.header.subject ?? t('mail.noSubject') }}
|
|
</h2>
|
|
|
|
<!-- Métadonnées expéditeur/destinataires -->
|
|
<dl class="text-xs text-neutral-500 space-y-0.5">
|
|
<div class="flex gap-1.5">
|
|
<dt class="font-medium text-neutral-600 w-5 flex-shrink-0">{{ t('mail.from') }}</dt>
|
|
<dd class="break-all">
|
|
{{
|
|
detail.header.fromName
|
|
? `${detail.header.fromName} <${detail.header.fromEmail}>`
|
|
: (detail.header.fromEmail ?? '')
|
|
}}
|
|
</dd>
|
|
</div>
|
|
<div v-if="detail.header.toRecipients.length > 0" class="flex gap-1.5">
|
|
<dt class="font-medium text-neutral-600 w-5 flex-shrink-0">{{ t('mail.to') }}</dt>
|
|
<dd class="break-all">{{ joinAddresses(detail.header.toRecipients) }}</dd>
|
|
</div>
|
|
<div v-if="detail.header.ccRecipients.length > 0" class="flex gap-1.5">
|
|
<dt class="font-medium text-neutral-600 w-5 flex-shrink-0">{{ t('mail.cc') }}</dt>
|
|
<dd class="break-all">{{ joinAddresses(detail.header.ccRecipients) }}</dd>
|
|
</div>
|
|
<div class="flex gap-1.5">
|
|
<dt class="font-medium text-neutral-600 w-5 flex-shrink-0">{{ t('mail.date') }}</dt>
|
|
<dd>{{ formatDate(detail.header.sentAt ?? detail.header.receivedAt) }}</dd>
|
|
</div>
|
|
</dl>
|
|
|
|
<!-- Barre d'actions -->
|
|
<div class="flex flex-wrap items-center gap-2 pt-1">
|
|
<MalioButton
|
|
:label="t('mail.actions.createTask')"
|
|
variant="primary"
|
|
icon-name="material-symbols:add-task-outline"
|
|
icon-position="left"
|
|
:icon-size="14"
|
|
@click="emit('createTask', detail.header.id)"
|
|
/>
|
|
<MalioButton
|
|
:label="t('mail.actions.linkTask')"
|
|
variant="secondary"
|
|
icon-name="material-symbols:link"
|
|
icon-position="left"
|
|
:icon-size="14"
|
|
@click="emit('linkTask', detail.header.id)"
|
|
/>
|
|
<MalioButton
|
|
:label="detail.header.isRead ? t('mail.actions.markUnread') : t('mail.actions.markRead')"
|
|
variant="tertiary"
|
|
:icon-name="detail.header.isRead ? 'material-symbols:mark-email-unread-outline' : 'material-symbols:mark-email-read-outline'"
|
|
icon-position="left"
|
|
:icon-size="14"
|
|
@click="handleMarkReadToggle"
|
|
/>
|
|
<MalioButton
|
|
:label="detail.header.isFlagged ? t('mail.actions.unflag') : t('mail.actions.flag')"
|
|
variant="tertiary"
|
|
:icon-name="detail.header.isFlagged ? 'material-symbols:star' : 'material-symbols:star-outline'"
|
|
icon-position="left"
|
|
:icon-size="14"
|
|
@click="handleFlagToggle"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Corps du message -->
|
|
<div class="flex-1 overflow-y-auto px-4 py-3">
|
|
<!-- Bannière "Afficher les images" -->
|
|
<div
|
|
v-if="!showImages && detail.bodyHtml"
|
|
class="mb-3 flex items-center gap-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm"
|
|
>
|
|
<Icon name="material-symbols:image-outline" size="16" class="text-amber-500 flex-shrink-0" />
|
|
<span class="flex-1 text-amber-700">Les images distantes sont masquées pour votre sécurité.</span>
|
|
<button
|
|
type="button"
|
|
class="text-xs font-medium text-amber-700 underline hover:text-amber-900 transition-colors"
|
|
@click="showImages = true"
|
|
>
|
|
{{ t('mail.actions.showImages') }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Corps HTML sanitizé -->
|
|
<div
|
|
v-if="detail.bodyHtml"
|
|
class="prose prose-sm max-w-none text-neutral-800"
|
|
v-html="sanitizedBody"
|
|
/>
|
|
|
|
<!-- Fallback texte plain -->
|
|
<pre
|
|
v-else-if="detail.bodyText"
|
|
class="whitespace-pre-wrap font-sans text-sm text-neutral-700"
|
|
>{{ detail.bodyText }}</pre>
|
|
</div>
|
|
|
|
<!-- Pièces jointes -->
|
|
<div
|
|
v-if="detail.attachments.length > 0"
|
|
class="flex-shrink-0 border-t border-neutral-200 px-4 py-3"
|
|
>
|
|
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
|
{{ t('mail.attachments') }} ({{ detail.attachments.length }})
|
|
</p>
|
|
<div class="flex flex-wrap gap-2">
|
|
<button
|
|
v-for="att in detail.attachments"
|
|
:key="att.downloadId"
|
|
type="button"
|
|
class="flex items-center gap-1.5 rounded border border-neutral-200 bg-neutral-50 px-2.5 py-1.5 text-xs text-neutral-700 transition-colors hover:bg-neutral-100 hover:border-neutral-300"
|
|
:title="att.filename"
|
|
@click="handleDownload(att.downloadId, att.filename)"
|
|
>
|
|
<Icon name="material-symbols:attach-file" size="14" class="flex-shrink-0 text-neutral-400" />
|
|
<span class="max-w-[180px] truncate">{{ att.filename }}</span>
|
|
<span class="text-neutral-400">({{ Math.round(att.size / 1024) }} Ko)</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
```
|
|
|
|
Points clés :
|
|
- `v-html="sanitizedBody"` est le **seul** `v-html` du composant, et il reçoit toujours la sortie de `sanitizeMailHtml` — jamais le `rawHtml` brut directement.
|
|
- La bannière "Afficher les images" est visible dès qu'il y a un `bodyHtml` et que `showImages` est false.
|
|
- `handleDownload` crée un lien `<a>` temporaire pour déclencher le téléchargement du Blob retourné par `mailService.downloadAttachment`.
|
|
|
|
- [ ] **Step 2 : Commit**
|
|
|
|
```bash
|
|
git -C /home/r-dev/malio-dev/Lesstime add frontend/components/mail/MailMessageViewer.vue
|
|
git -C /home/r-dev/malio-dev/Lesstime commit -m "feat(mail) : MailMessageViewer — header, body sanitizé DOMPurify, PJ téléchargeables, 4 actions"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8 : Page `pages/mail.vue`
|
|
|
|
Page principale en layout 3 colonnes. Intègre les 4 composants créés ci-dessus. Gère le deep-link `?messageId=X`. Vérifie le rôle ROLE_CLIENT (redirection `/portal`). Le middleware global `auth.global.ts` gère déjà l'authentification de base — ici on ajoute seulement le check spécifique ROLE_CLIENT dans `onMounted`.
|
|
|
|
- [ ] **Step 1 : Créer `frontend/pages/mail.vue`**
|
|
|
|
```vue
|
|
<script setup lang="ts">
|
|
import { useMailStore } from '~/stores/mail'
|
|
|
|
const { t } = useI18n()
|
|
const router = useRouter()
|
|
const route = useRoute()
|
|
const auth = useAuthStore()
|
|
|
|
useHead({ title: t('mail.title') })
|
|
|
|
// ─── Contrôle d'accès ROLE_CLIENT ─────────────────────────────────────────
|
|
// Le middleware global gère auth. Ici : ROLE_CLIENT (sans ROLE_ADMIN) → /portal.
|
|
// Vérifié dans onMounted pour être sûr que auth est initialisé (SPA).
|
|
|
|
const isClientOnly = computed(() =>
|
|
auth.user?.roles?.includes('ROLE_CLIENT') === true
|
|
&& auth.user?.roles?.includes('ROLE_ADMIN') !== true,
|
|
)
|
|
|
|
if (isClientOnly.value) {
|
|
await navigateTo('/portal')
|
|
}
|
|
|
|
// ─── Store ────────────────────────────────────────────────────────────────
|
|
|
|
const store = useMailStore()
|
|
const {
|
|
folderTree,
|
|
selectedFolderPath,
|
|
messages,
|
|
messagesLoading,
|
|
hasMoreMessages,
|
|
selectedMessageId,
|
|
selectedMessageDetail,
|
|
detailLoading,
|
|
} = storeToRefs(store)
|
|
|
|
// ─── Init : charge les dossiers + deep-link ───────────────────────────────
|
|
|
|
onMounted(async () => {
|
|
// Double-check rôle après hydratation (SPA guard)
|
|
if (isClientOnly.value) {
|
|
router.replace('/portal')
|
|
return
|
|
}
|
|
|
|
// Charger les dossiers si pas encore chargés
|
|
if (folderTree.value.length === 0) {
|
|
await store.fetchFolders()
|
|
}
|
|
|
|
// Sélectionner INBOX par défaut
|
|
if (!selectedFolderPath.value && folderTree.value.length > 0) {
|
|
const inbox = folderTree.value.find((f) => f.path.toUpperCase() === 'INBOX')
|
|
await store.selectFolder(inbox?.path ?? folderTree.value[0]!.path)
|
|
}
|
|
|
|
// Deep-link : ?messageId=X → sélectionner automatiquement ce message
|
|
const messageIdParam = route.query.messageId
|
|
if (messageIdParam) {
|
|
const id = parseInt(String(messageIdParam), 10)
|
|
if (!isNaN(id)) {
|
|
await store.selectMessage(id)
|
|
}
|
|
}
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
// Ne pas arrêter le polling ici — il est géré globalement par le layout (Phase 7)
|
|
// La page peut être démonter sans couper le compteur de non-lus global
|
|
})
|
|
|
|
// ─── Handlers ─────────────────────────────────────────────────────────────
|
|
|
|
async function handleFolderSelect(path: string): Promise<void> {
|
|
await store.selectFolder(path)
|
|
// Nettoyer le query param messageId si présent
|
|
if (route.query.messageId) {
|
|
router.replace({ query: { ...route.query, messageId: undefined } })
|
|
}
|
|
}
|
|
|
|
async function handleMessageSelect(id: number): Promise<void> {
|
|
await store.selectMessage(id)
|
|
}
|
|
|
|
function handleLoadMore(): void {
|
|
store.fetchMessages(true)
|
|
}
|
|
|
|
// Phase 6 : ces handlers seront branchés sur les modals MailCreateTaskModal / MailLinkTaskModal
|
|
function handleCreateTask(mailId: number): void {
|
|
// TODO Phase 6 : ouvrir MailCreateTaskModal
|
|
console.warn('[mail] handleCreateTask mailId=', mailId, '— modal à implémenter en Phase 6')
|
|
}
|
|
|
|
function handleLinkTask(mailId: number): void {
|
|
// TODO Phase 6 : ouvrir MailLinkTaskModal
|
|
console.warn('[mail] handleLinkTask mailId=', mailId, '— modal à implémenter en Phase 6')
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="flex h-full flex-col overflow-hidden">
|
|
<!-- Topbar page -->
|
|
<div class="flex flex-shrink-0 items-center justify-between border-b border-neutral-200 bg-white px-4 py-3">
|
|
<h1 class="text-lg font-semibold text-neutral-900">
|
|
{{ t('mail.title') }}
|
|
</h1>
|
|
<MailRefreshButton />
|
|
</div>
|
|
|
|
<!-- Layout 3 colonnes -->
|
|
<div class="flex flex-1 overflow-hidden">
|
|
<!-- Colonne 1 : Dossiers (fixe, largeur 220px) -->
|
|
<aside class="w-[220px] flex-shrink-0 overflow-y-auto border-r border-neutral-200 bg-neutral-50 py-2">
|
|
<p class="mb-1 px-3 text-xs font-semibold uppercase tracking-wide text-neutral-400">
|
|
{{ t('mail.folders') }}
|
|
</p>
|
|
<MailFolderTree
|
|
:folders="folderTree"
|
|
:selected-path="selectedFolderPath"
|
|
@select="handleFolderSelect"
|
|
/>
|
|
</aside>
|
|
|
|
<!-- Colonne 2 : Liste des messages (fixe, largeur 320px) -->
|
|
<div class="w-[320px] flex-shrink-0 overflow-hidden border-r border-neutral-200 bg-white">
|
|
<div class="flex items-center justify-between border-b border-neutral-100 px-3 py-2">
|
|
<p class="text-xs font-semibold uppercase tracking-wide text-neutral-400">
|
|
{{ t('mail.messages') }}
|
|
</p>
|
|
</div>
|
|
<div class="h-[calc(100%-37px)]">
|
|
<MailMessageList
|
|
:messages="messages"
|
|
:selected-id="selectedMessageId"
|
|
:loading="messagesLoading"
|
|
:has-more="hasMoreMessages"
|
|
@select="handleMessageSelect"
|
|
@load-more="handleLoadMore"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Colonne 3 : Lecteur (flex, prend le reste) -->
|
|
<div class="flex-1 overflow-hidden bg-white">
|
|
<MailMessageViewer
|
|
:detail="selectedMessageDetail"
|
|
:loading="detailLoading"
|
|
@create-task="handleCreateTask"
|
|
@link-task="handleLinkTask"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
```
|
|
|
|
Points clés :
|
|
- `await navigateTo('/portal')` au niveau du `<script setup>` (avant `onMounted`) pour un redirect immédiat si ROLE_CLIENT détecté à l'hydratation.
|
|
- Le double-check dans `onMounted` couvre le cas où `auth` n'est pas encore résolu au premier rendu SPA.
|
|
- Les `console.warn` pour les handlers Phase 6 sont intentionnels (placeholder visible en dev).
|
|
- `h-[calc(100%-37px)]` : la topbar de la colonne liste fait ~37px (py-2 + texte xs) — ajuster si besoin après inspection visuelle.
|
|
|
|
- [ ] **Step 2 : Commit**
|
|
|
|
```bash
|
|
git -C /home/r-dev/malio-dev/Lesstime add frontend/pages/mail.vue
|
|
git -C /home/r-dev/malio-dev/Lesstime commit -m "feat(mail) : page /mail — layout 3 colonnes, deep-link messageId, refus ROLE_CLIENT"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9 : Validation TypeScript globale + test manuel
|
|
|
|
- [ ] **Step 1 : TypeScript strict — zéro erreur sur les fichiers Phase 5**
|
|
|
|
```bash
|
|
cd /home/r-dev/malio-dev/Lesstime/frontend && npx tsc --noEmit 2>&1
|
|
```
|
|
|
|
Attendu : sortie vide (0 erreur). Erreurs fréquentes à anticiper et corriger :
|
|
|
|
- `Property 'XXX' does not exist on type 'DeepReadonly<...>'` → les refs du store sont `readonly()` — utiliser `.value` directement ou `storeToRefs()` (déjà fait dans `mail.vue`).
|
|
- `Cannot find name 'useSystemFolderLabel'` → vérifier que le composable est dans `composables/` (auto-importé par Nuxt).
|
|
- `'MailFolderTree' is not defined` → Nuxt auto-importe les composants de `components/` — vérifier le nom du fichier (PascalCase).
|
|
- `Property 'depth' does not exist` → dans `MailFolderTree`, `depth` est une prop avec valeur par défaut — s'assurer que le type est `number | undefined` dans `defineProps`.
|
|
|
|
- [ ] **Step 2 : Démarrer le serveur de développement**
|
|
|
|
```bash
|
|
cd /home/r-dev/malio-dev/Lesstime && make dev-nuxt
|
|
```
|
|
|
|
Attendu : `Nuxt ready` sur `http://localhost:3002`.
|
|
|
|
- [ ] **Step 3 : Tests manuels (checklist)**
|
|
|
|
Se connecter avec `alice` / `alice` (ROLE_USER) :
|
|
|
|
- [ ] Naviguer vers `http://localhost:3002/mail` → page visible, layout 3 colonnes affiché
|
|
- [ ] Les 3 colonnes sont présentes (même si vides — backend Phase 3 peut ne pas être déployé)
|
|
- [ ] Bouton "Actualiser" visible en haut à droite
|
|
- [ ] Console DevTools : aucune erreur Vue/Nuxt (les `console.warn` Phase 6 sont acceptables)
|
|
- [ ] TypeScript : aucune erreur rouge dans l'IDE
|
|
|
|
Se connecter avec `client-liot` / `client` (ROLE_CLIENT) :
|
|
|
|
- [ ] Naviguer vers `http://localhost:3002/mail` → redirect automatique vers `/portal`
|
|
- [ ] Pas d'affichage momentané de la page mail avant le redirect
|
|
|
|
Test anti-XSS (si backend Phase 3 disponible) :
|
|
|
|
- [ ] Injecter `<script>alert(1)</script>` dans un corps de mail (via devtools ou fixture)
|
|
- [ ] Ouvrir le mail dans le lecteur → aucune alerte JS, contenu rendu inoffensif
|
|
- [ ] Vérifier dans le DOM inspecté que la balise `<script>` est absente
|
|
|
|
Test images distantes :
|
|
|
|
- [ ] Un mail avec `<img src="https://example.com/pixel.gif">` dans le body
|
|
- [ ] À l'ouverture : placeholder `[Image distante — cliquer pour afficher]` visible à la place
|
|
- [ ] Cliquer sur "Afficher les images" → l'image est chargée (si réseau disponible)
|
|
|
|
- [ ] **Step 4 : Commit de correction si nécessaire**
|
|
|
|
Si des bugs TypeScript ou visuels ont été détectés et corrigés :
|
|
|
|
```bash
|
|
git -C /home/r-dev/malio-dev/Lesstime add frontend/pages/mail.vue frontend/components/mail/ frontend/composables/useSystemFolderLabel.ts
|
|
git -C /home/r-dev/malio-dev/Lesstime commit -m "fix(mail) : corrections post-test Phase 5 (TypeScript + rendu)"
|
|
```
|
|
|
|
---
|
|
|
|
## Exigences techniques
|
|
|
|
- `<script setup lang="ts">` partout — aucun `defineComponent`, aucun Options API
|
|
- **4 espaces d'indentation** — convention Lesstime (cf. CLAUDE.md)
|
|
- **Imports Pinia :** `const store = useMailStore()` puis `const { ... } = storeToRefs(store)` pour la réactivité des refs
|
|
- **`v-html` uniquement** avec sortie de `sanitizeMailHtml()` — jamais avec `rawHtml` brut
|
|
- **Images distantes :** toujours bloquées par défaut via `sanitizeMailHtml({ allowImages: false })` — toggle explicite par l'utilisateur
|
|
- **Tailwind :** classes du design system observé dans les composants existants (`bg-primary-*`, `text-neutral-*`, `border-neutral-*`, `rounded-md`, spacing 3/4)
|
|
- **Icons :** `<Icon name="material-symbols:..." />` — vérifier que les icônes existent sur [icones.js.org](https://icones.js.org/) (collection `material-symbols`)
|
|
- **Date relative :** `Intl.RelativeTimeFormat` natif, locale `fr` — aucune dépendance externe
|
|
- **DOMPurify :** import dans `utils/sanitizeMailHtml.ts` (déjà créé Phase 4) — ne pas reimporter DOMPurify directement dans les composants
|
|
- **SSR :** le projet est SPA (Nuxt 4 SSR off), donc `sanitizeMailHtml` peut être appelé dans `computed` directement sans guard `import.meta.client`
|
|
- **ROLE_CLIENT :** double protection — middleware global (`auth.global.ts`) + check explicite dans `pages/mail.vue`
|
|
- **Format commits :** `feat(mail) : <message>` (espace avant `:`), 1 commit par composant/fichier
|
|
|
|
---
|
|
|
|
## Fichiers créés/modifiés — récapitulatif
|
|
|
|
| Fichier | Type | Action |
|
|
|---|---|---|
|
|
| `frontend/pages/mail.vue` | Page Nuxt | Créer |
|
|
| `frontend/components/mail/MailFolderTree.vue` | Composant Vue | Créer |
|
|
| `frontend/components/mail/MailMessageList.vue` | Composant Vue | Créer |
|
|
| `frontend/components/mail/MailMessageViewer.vue` | Composant Vue | Créer |
|
|
| `frontend/components/mail/MailRefreshButton.vue` | Composant Vue | Créer |
|
|
| `frontend/composables/useSystemFolderLabel.ts` | Composable | Créer |
|
|
| `frontend/i18n/locales/fr.json` | i18n | Modifier (ajout bloc `mail`) |
|
|
| `frontend/i18n/locales/en.json` | i18n | Modifier si fichier existe |
|
|
|
|
**Fichiers Phase 4 utilisés (ne pas modifier) :**
|
|
|
|
| Fichier | Utilisation dans Phase 5 |
|
|
|---|---|
|
|
| `frontend/stores/mail.ts` | `useMailStore()` — store central |
|
|
| `frontend/services/mail.ts` | `useMailService().downloadAttachment()` dans `MailMessageViewer` |
|
|
| `frontend/utils/sanitizeMailHtml.ts` | `sanitizeMailHtml()` dans `MailMessageViewer` |
|
|
| `frontend/services/dto/mail.ts` | Types `MailFolderDto`, `MailMessageHeaderDto`, `MailMessageDetailDto` |
|
|
|
|
---
|
|
|
|
## Output attendu — critères d'acceptation Phase 5
|
|
|
|
| Critère | Commande / vérification |
|
|
|---|---|
|
|
| TypeScript strict : 0 erreur | `cd frontend && npx tsc --noEmit` → sortie vide |
|
|
| Page `/mail` accessible ROLE_USER | `http://localhost:3002/mail` avec `alice` → page visible |
|
|
| ROLE_CLIENT redirigé `/portal` | Login avec `client-liot`, naviguer `/mail` → redirect `/portal` |
|
|
| Pas d'XSS via body mail | Injecter `<script>alert(1)</script>` → aucune alerte |
|
|
| Images distantes bloquées par défaut | Body avec `<img src="https://...">` → placeholder visible |
|
|
| Infinite scroll fonctionnel | Scroll jusqu'en bas de la liste → chargement page suivante |
|
|
| Deep-link `?messageId=X` | `http://localhost:3002/mail?messageId=42` → message 42 sélectionné |
|
|
| Pas d'erreur console Vue | DevTools → onglet Console → aucune erreur rouge |
|
|
|
|
La Phase 6 (Intégration tâches : MailCreateTaskModal, MailLinkTaskModal, onglet "Mails" TaskDrawer) peut commencer dès que tous ces critères sont satisfaits.
|