diff --git a/frontend/stores/mail.ts b/frontend/stores/mail.ts new file mode 100644 index 0000000..05d89e0 --- /dev/null +++ b/frontend/stores/mail.ts @@ -0,0 +1,332 @@ +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([]) + + /** Chemin du dossier actuellement sélectionné */ + const selectedFolderPath = ref(null) + + /** Messages du dossier sélectionné (accumulés pour infinite scroll) */ + const messages = ref([]) + + /** Cursor de pagination pour la page suivante (null = plus de données) */ + const messagesCursor = ref(null) + + /** Chargement en cours (messages) */ + const messagesLoading = ref(false) + + /** ID du message sélectionné pour lecture */ + const selectedMessageId = ref(null) + + /** Détail complet du message sélectionné (body + PJ) */ + const selectedMessageDetail = ref(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(null) + + let pollTimer: ReturnType | 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() + 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 { + 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) + } + } + + /** + * 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 { + 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 dossier et charge ses messages (reset de la pagination). + * @param path - Chemin du dossier (ex: "INBOX") + */ + async function selectFolder(path: string): Promise { + if (selectedFolderPath.value === path) return + selectedFolderPath.value = path + messages.value = [] + messagesCursor.value = null + selectedMessageId.value = null + selectedMessageDetail.value = null + await fetchMessages() + } + + /** + * 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 { + 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() + } + + /** + * 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 { + 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 étoilé ou non-étoilé. + * Met à jour le state local sans refetch. + */ + async function markFlagged(id: number, flagged: boolean): Promise { + 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 { + 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 + } + } + + /** + * Arrête le polling. À appeler au logout. + */ + function stopPolling(): void { + if (pollTimer) { + clearInterval(pollTimer) + pollTimer = null + } + } + + /** + * 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) + } + } + + /** + * Rafraîchit les compteurs non-lus du dossier actuel depuis l'API. + * Usage interne — appelé après markRead. + */ + async function _refreshFolderUnreadCount(): Promise { + 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, + } +})