feat(mail) : extract Mail front into Nuxt module layer

LST-67 (2.5) front. Completes the Mail module.

- New frontend/modules/mail/ layer (auto-detected): /mail page (3 columns),
  7 components, mail service + DTO, mail store (folders/messages/unread polling).
- sanitizeMailHtml util and useSystemFolderLabel composable stay global;
  AdminMailTab stays in /admin (service import repointed).
- Consumers repointed: AdminMailTab and PM TaskModal -> ~/modules/mail/...;
  the store is auto-imported (Pinia storesDirs) so the layout badge/polling is
  unchanged.
- /mail gated by the mail module: sidebar.php item with module=mail (so
  SidebarFilter disables /mail when the module is off); the layout filters /mail
  from the API sections to avoid a visual duplicate. ROLE_CLIENT exclusion kept.
- i18n key sidebar.general.mail added.

nuxt build passes; /mail and all other routes preserved.
This commit is contained in:
Matthieu
2026-06-20 19:52:13 +02:00
parent 25d3a693f9
commit bb7d7e7953
17 changed files with 34 additions and 22 deletions
+1 -1
View File
@@ -140,7 +140,7 @@
</template>
<script setup lang="ts">
import { useMailService } from '~/services/mail'
import { useMailService } from '~/modules/mail/services/mail'
const { getConfiguration, updateConfiguration, testConfiguration } = useMailService()
@@ -1,121 +0,0 @@
<script setup lang="ts">
const props = defineProps<{
/** Ouverture de la visionneuse. */
modelValue: boolean
/** Nom du fichier affiché dans la barre. */
filename: string
/** Type MIME — détermine le rendu (image vs PDF). */
mimeType: string
/** Object URL du Blob de la pièce jointe. null tant que le contenu charge. */
url: string | null
/** Téléchargement en cours du contenu. */
loading: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
download: []
}>()
const { t } = useI18n()
const isImage = computed(() => props.mimeType.startsWith('image/'))
const isPdf = computed(() => props.mimeType === 'application/pdf')
function close(): void {
emit('update:modelValue', false)
}
function onKeydown(e: KeyboardEvent): void {
if (e.key === 'Escape') close()
}
watch(
() => props.modelValue,
(open) => {
if (open) {
window.addEventListener('keydown', onKeydown)
} else {
window.removeEventListener('keydown', onKeydown)
}
},
)
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
</script>
<template>
<Teleport v-if="modelValue" to="body">
<Transition name="mail-preview" appear>
<div class="fixed inset-0 z-50 flex flex-col bg-slate-900/80 backdrop-blur-sm">
<!-- Barre supérieure -->
<div class="flex flex-shrink-0 items-center justify-between gap-4 px-4 py-3 text-white">
<div class="flex min-w-0 items-center gap-2">
<Icon
:name="isImage ? 'material-symbols:image-outline' : 'material-symbols:picture-as-pdf-outline'"
size="18"
class="flex-shrink-0 text-white/70"
/>
<span class="truncate text-sm font-medium">{{ filename }}</span>
</div>
<div class="flex flex-shrink-0 items-center gap-1">
<button
type="button"
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-white/90 transition-colors hover:bg-white/10"
@click="emit('download')"
>
<Icon name="material-symbols:download" size="18" />
<span class="hidden sm:inline">{{ t('mail.actions.download') }}</span>
</button>
<button
type="button"
class="rounded-md p-1.5 text-white/90 transition-colors hover:bg-white/10"
:aria-label="t('mail.preview.close')"
@click="close"
>
<Icon name="mdi:close" size="20" />
</button>
</div>
</div>
<!-- Contenu -->
<div class="flex min-h-0 flex-1 items-center justify-center overflow-auto p-4" @click.self="close">
<div v-if="loading" class="flex flex-col items-center gap-3 text-white/70">
<Icon name="material-symbols:progress-activity" size="32" class="animate-spin" />
<span class="text-sm">{{ t('mail.preview.loading') }}</span>
</div>
<img
v-else-if="isImage && url"
:src="url"
:alt="filename"
class="max-h-full max-w-full rounded-lg object-contain shadow-2xl"
>
<iframe
v-else-if="isPdf && url"
:src="url"
:title="filename"
class="h-full w-full max-w-5xl rounded-lg bg-white shadow-2xl"
/>
<div v-else class="flex flex-col items-center gap-3 text-white/70">
<Icon name="material-symbols:visibility-off-outline" size="32" />
<span class="text-sm">{{ t('mail.preview.unavailable') }}</span>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.mail-preview-enter-active,
.mail-preview-leave-active {
transition: opacity 0.2s ease;
}
.mail-preview-enter-from,
.mail-preview-leave-to {
opacity: 0;
}
</style>
@@ -1,144 +0,0 @@
<script setup lang="ts">
import type { MailMessageDetailDto } from '~/services/dto/mail'
import type { Task } from '~/modules/project-management/services/dto/task'
import type { Project } from '~/modules/project-management/services/dto/project'
import type { TaskGroup } from '~/modules/project-management/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data'
import { useMailService } from '~/services/mail'
import { useProjectService } from '~/modules/project-management/services/projects'
import { useTaskGroupService } from '~/modules/project-management/services/task-groups'
import { useUserService } from '~/services/users'
import { useAuthStore } from '~/shared/stores/auth'
const props = defineProps<{
modelValue: boolean
messageId: number
messageDetail: MailMessageDetailDto | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
created: [task: Task]
}>()
const { t } = useI18n()
const auth = useAuthStore()
const mailService = useMailService()
const projectService = useProjectService()
const taskGroupService = useTaskGroupService()
const userService = useUserService()
const projectId = ref<number | null>(null)
const taskGroupId = ref<number | null>(null)
const assigneeId = ref<number | null>(null)
const statusId = ref<number | null>(null)
const isSubmitting = ref(false)
const touchedProject = ref(false)
const projects = ref<Project[]>([])
const groups = ref<TaskGroup[]>([])
const users = ref<UserData[]>([])
const loadingGroups = ref(false)
const projectOptions = computed(() => projects.value.map(p => ({ label: p.name, value: p.id })))
const groupOptions = computed(() => groups.value.filter(g => !g.archived).map(g => ({ label: g.title, value: g.id })))
const userOptions = computed(() => users.value.map(u => ({ label: u.username, value: u.id })))
const selectedProject = computed(() => projects.value.find(p => p.id === projectId.value) ?? null)
const statusOptions = computed(() =>
(selectedProject.value?.workflow?.statuses ?? []).map(s => ({ label: s.label, value: s.id })),
)
onMounted(async () => {
const [projs, us] = await Promise.all([
projectService.getAll({ archived: false }),
userService.getAll(),
])
projects.value = projs
users.value = us
})
watch(projectId, async (pid) => {
taskGroupId.value = null
statusId.value = selectedProject.value?.workflow?.statuses?.[0]?.id ?? null
groups.value = []
if (!pid) return
loadingGroups.value = true
try {
groups.value = await taskGroupService.getByProject(pid)
} finally {
loadingGroups.value = false
}
})
watch(() => props.modelValue, (open) => {
if (open) {
projectId.value = null
taskGroupId.value = null
statusId.value = null
assigneeId.value = auth.user?.id ?? null
touchedProject.value = false
}
})
function close(): void {
emit('update:modelValue', false)
}
async function handleSubmit(): Promise<void> {
touchedProject.value = true
if (!projectId.value) return
isSubmitting.value = true
try {
const task = await mailService.createTaskFromMail(props.messageId, {
projectId: projectId.value,
taskGroupId: taskGroupId.value ?? undefined,
assigneeId: assigneeId.value ?? undefined,
statusId: statusId.value ?? undefined,
})
emit('created', task)
close()
} finally {
isSubmitting.value = false
}
}
</script>
<template>
<AppModal
:model-value="modelValue"
width="lg"
:title="t('mail.createTaskModal.title')"
@update:model-value="emit('update:modelValue', $event)"
>
<div class="space-y-5">
<div v-if="messageDetail" class="rounded-lg border border-neutral-200 bg-neutral-50 px-4 py-3 text-sm">
<p class="truncate font-medium text-neutral-800">{{ messageDetail.header.subject ?? t('mail.noSubject') }}</p>
<p class="mt-0.5 truncate text-xs text-neutral-500">{{ messageDetail.header.fromName ?? messageDetail.header.fromEmail }}</p>
<p class="mt-2 text-xs italic text-neutral-400">{{ t('mail.createTaskModal.titleHint') }}</p>
<p class="text-xs italic text-neutral-400">{{ t('mail.createTaskModal.descriptionHint') }}</p>
</div>
<div>
<MalioSelect v-model="projectId" :options="projectOptions" :label="t('mail.createTaskModal.projectLabel')" :empty-option-label="t('mail.createTaskModal.projectPlaceholder')" group-class="w-full" />
<p v-if="touchedProject && !projectId" class="mt-1 text-xs text-red-500">{{ t('mail.createTaskModal.projectLabel').replace(' *', '') }} requis</p>
</div>
<div v-if="projectId">
<MalioSelect v-model="taskGroupId" :options="groupOptions" :label="t('mail.createTaskModal.groupLabel')" :empty-option-label="t('mail.createTaskModal.groupPlaceholder')" group-class="w-full" :disabled="loadingGroups" />
</div>
<div v-if="projectId">
<MalioSelect v-model="statusId" :options="statusOptions" :label="t('mail.createTaskModal.statusLabel')" group-class="w-full" />
</div>
<div>
<MalioSelect v-model="assigneeId" :options="userOptions" :label="t('mail.createTaskModal.assigneeLabel')" :empty-option-label="t('mail.createTaskModal.assigneePlaceholder')" group-class="w-full" />
</div>
</div>
<template #footer>
<MalioButton variant="tertiary" label="Annuler" button-class="w-auto px-4" @click="close" />
<MalioButton :label="t('mail.createTaskModal.submit')" button-class="w-auto px-6" :disabled="isSubmitting" @click="handleSubmit" />
</template>
</AppModal>
</template>
-123
View File
@@ -1,123 +0,0 @@
<script setup lang="ts">
import type { MailFolderDto } from '~/services/dto/mail'
const props = defineProps<{
/** Arbre de dossiers (getter folderTree du store) */
folders: readonly 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 currentDepth = computed(() => props.depth ?? 0)
// Dossiers dépliés (repliés par défaut → seuls les dossiers racine sont visibles).
const expanded = ref<Set<string>>(new Set())
function isExpanded(path: string): boolean {
return expanded.value.has(path)
}
function toggleExpanded(path: string): void {
const next = new Set(expanded.value)
if (next.has(path)) {
next.delete(path)
} else {
next.add(path)
}
expanded.value = next
}
function hasChildren(folder: MailFolderDto): boolean {
return !!folder.children && folder.children.length > 0
}
function handleSelect(path: string): void {
emit('select', path)
}
function paddingStyle(): Record<string, string> {
const depth = currentDepth.value
return { paddingLeft: `${0.5 + depth * 0.75}rem` }
}
</script>
<template>
<div>
<div
v-if="folders.length === 0 && currentDepth === 0"
class="px-3 py-4 text-sm text-neutral-400 italic"
>
{{ t('mail.empty.folder') }}
</div>
<template v-else>
<div v-for="folder in folders" :key="folder.path">
<div
class="flex items-center gap-1 rounded-md pr-2 py-1.5 text-sm transition-colors"
:class="
selectedPath === folder.path
? 'bg-primary-100 text-primary-700 font-medium'
: 'text-neutral-700 hover:bg-neutral-100'
"
:style="paddingStyle()"
>
<button
v-if="hasChildren(folder)"
type="button"
class="flex-shrink-0 rounded p-0.5 hover:bg-neutral-200"
:aria-label="isExpanded(folder.path) ? t('mail.folderTree.collapse') : t('mail.folderTree.expand')"
@click.stop="toggleExpanded(folder.path)"
>
<Icon
:name="isExpanded(folder.path) ? 'material-symbols:keyboard-arrow-down' : 'material-symbols:chevron-right'"
size="16"
class="text-neutral-400"
/>
</button>
<span v-else class="inline-block w-[22px] flex-shrink-0" />
<button
type="button"
class="flex flex-1 items-center gap-2 text-left min-w-0"
@click="handleSelect(folder.path)"
>
<Icon
:name="getFolderIcon(folder.path)"
size="16"
class="flex-shrink-0"
:class="selectedPath === folder.path ? 'text-primary-600' : 'text-neutral-400'"
/>
<span class="flex-1 truncate">
{{ getFolderLabel(folder.path, folder.displayName) }}
</span>
<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>
</button>
</div>
<MailFolderTree
v-if="hasChildren(folder) && isExpanded(folder.path)"
:folders="folder.children"
:selected-path="selectedPath"
:depth="currentDepth + 1"
@select="handleSelect"
/>
</div>
</template>
</div>
</template>
@@ -1,266 +0,0 @@
<script setup lang="ts">
import type { Task } from '~/modules/project-management/services/dto/task'
import type { Project } from '~/modules/project-management/services/dto/project'
import { useMailService } from '~/services/mail'
import { useTaskService } from '~/modules/project-management/services/tasks'
import { useProjectService } from '~/modules/project-management/services/projects'
const props = defineProps<{
modelValue: boolean
/** ID BDD du message à lier */
messageId: number
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
/** Émis après liaison réussie — payload = id de la tâche liée */
linked: [taskId: number]
}>()
const { t } = useI18n()
const mailService = useMailService()
const taskService = useTaskService()
const projectService = useProjectService()
// ─── État recherche ───────────────────────────────────────────────────────
const searchQuery = ref('')
const filterProjectId = ref<number | null>(null)
const results = ref<Task[]>([])
const selectedTask = ref<Task | null>(null)
const isLoading = ref(false)
const isSubmitting = ref(false)
// ─── Projets pour le filtre ───────────────────────────────────────────────
const projects = ref<Project[]>([])
const projectFilterOptions = computed(() =>
projects.value.map(p => ({ label: p.name, value: p.id })),
)
onMounted(async () => {
projects.value = await projectService.getAll({ archived: false })
})
// ─── Debounce recherche ───────────────────────────────────────────────────
let debounceTimer: ReturnType<typeof setTimeout> | null = null
watch([searchQuery, filterProjectId], () => {
selectedTask.value = null
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
void runSearch()
}, 300)
})
async function runSearch(): Promise<void> {
const q = searchQuery.value.trim()
if (!q && !filterProjectId.value) {
results.value = []
return
}
isLoading.value = true
try {
const params: Record<string, string | number | boolean | string[]> = {
archived: false,
}
if (q) params['title'] = q
if (filterProjectId.value) params['project'] = `/api/projects/${filterProjectId.value}`
results.value = await taskService.getFiltered(params)
} finally {
isLoading.value = false
}
}
// ─── Reset à l'ouverture ──────────────────────────────────────────────────
watch(() => props.modelValue, (open) => {
if (open) {
searchQuery.value = ''
filterProjectId.value = null
results.value = []
selectedTask.value = null
}
})
onBeforeUnmount(() => {
if (debounceTimer) clearTimeout(debounceTimer)
})
// ─── Actions ──────────────────────────────────────────────────────────────
function close(): void {
emit('update:modelValue', false)
}
function selectTask(task: Task): void {
selectedTask.value = task
}
async function handleSubmit(): Promise<void> {
if (!selectedTask.value) return
isSubmitting.value = true
try {
await mailService.linkTask(props.messageId, selectedTask.value.id)
emit('linked', selectedTask.value.id)
close()
} finally {
isSubmitting.value = false
}
}
</script>
<template>
<Teleport v-if="modelValue" to="body">
<Transition name="mail-modal" appear>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
@click="close"
/>
<div
class="relative z-10 w-full max-w-lg rounded-2xl bg-white shadow-2xl ring-1 ring-black/5 overflow-hidden"
style="max-height: min(90vh, 640px)"
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-neutral-100 bg-neutral-50/80 px-6 py-4">
<h2 class="text-base font-bold text-neutral-900">
{{ t('mail.linkTaskModal.title') }}
</h2>
<MalioButtonIcon
icon="mdi:close"
aria-label="Fermer"
variant="ghost"
icon-size="20"
@click="close"
/>
</div>
<!-- Corps -->
<div class="overflow-y-auto px-6 py-5 space-y-4">
<!-- Filtre projet -->
<MalioSelect
v-model="filterProjectId"
:options="projectFilterOptions"
:label="t('mail.linkTaskModal.projectFilter')"
:empty-option-label="t('mail.linkTaskModal.projectAll')"
group-class="w-full"
/>
<!-- Recherche tâche -->
<div>
<label class="mb-1 block text-sm font-medium text-neutral-700">
{{ t('mail.linkTaskModal.title') }}
</label>
<input
v-model="searchQuery"
type="text"
:placeholder="t('mail.linkTaskModal.searchPlaceholder')"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
/>
</div>
<!-- Résultats -->
<div class="max-h-64 overflow-y-auto rounded-md border border-neutral-200">
<!-- Chargement -->
<div
v-if="isLoading"
class="flex items-center justify-center py-6 text-sm text-neutral-400"
>
<Icon name="material-symbols:progress-activity" size="18" class="mr-2 animate-spin" />
{{ t('mail.linkTaskModal.loading') }}
</div>
<!-- Vide -->
<div
v-else-if="!isLoading && results.length === 0 && (searchQuery.trim() || filterProjectId)"
class="py-6 text-center text-sm text-neutral-400 italic"
>
{{ t('mail.linkTaskModal.empty') }}
</div>
<!-- Liste résultats -->
<button
v-for="task in results"
:key="task.id"
type="button"
class="flex w-full items-start gap-3 px-4 py-3 text-left text-sm transition-colors hover:bg-neutral-50"
:class="selectedTask?.id === task.id
? 'bg-primary-50 border-l-2 border-primary-500'
: 'border-l-2 border-transparent'"
@click="selectTask(task)"
>
<Icon
name="material-symbols:task-outline"
size="16"
class="mt-0.5 flex-shrink-0 text-neutral-400"
/>
<div class="min-w-0 flex-1">
<p class="truncate font-medium text-neutral-800">
{{ task.title }}
</p>
<p
v-if="task.project"
class="truncate text-xs text-neutral-500"
>
{{ task.project.name }}
<span v-if="task.project.code && task.number">
{{ task.project.code }}-{{ task.number }}
</span>
</p>
</div>
<Icon
v-if="selectedTask?.id === task.id"
name="material-symbols:check-circle"
size="16"
class="flex-shrink-0 text-primary-500"
/>
</button>
</div>
</div>
<!-- Footer -->
<div class="flex justify-end gap-3 border-t border-neutral-100 px-6 py-4">
<MalioButton
variant="tertiary"
label="Annuler"
button-class="w-auto px-4"
@click="close"
/>
<MalioButton
:label="t('mail.linkTaskModal.submit')"
button-class="w-auto px-6"
:disabled="!selectedTask || isSubmitting"
@click="handleSubmit"
/>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.mail-modal-enter-active,
.mail-modal-leave-active {
transition: opacity 0.2s ease;
}
.mail-modal-enter-active > div:last-child,
.mail-modal-leave-active > div:last-child {
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
}
.mail-modal-enter-from,
.mail-modal-leave-to {
opacity: 0;
}
.mail-modal-enter-from > div:last-child {
transform: scale(0.95) translateY(8px);
opacity: 0;
}
</style>
@@ -1,151 +0,0 @@
<script setup lang="ts">
import type { MailMessageHeaderDto } from '~/services/dto/mail'
const props = defineProps<{
messages: readonly MailMessageHeaderDto[]
selectedId: number | null
loading: boolean
hasMore: boolean
}>()
const emit = defineEmits<{
select: [id: number]
loadMore: []
}>()
const { t } = useI18n()
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
})
/**
* Formate une date ISO en date relative (il y a X minutes/heures/jours).
* Utilise Intl.RelativeTimeFormat avec la locale fr.
*/
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')
return date.toLocaleDateString('fr', { day: '2-digit', month: 'short', year: 'numeric' })
}
function getSenderLabel(msg: MailMessageHeaderDto): string {
return msg.fromName ?? msg.fromEmail ?? ''
}
</script>
<template>
<div class="flex h-full flex-col overflow-hidden">
<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>
<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)"
>
<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>
<div class="min-w-0 flex-1">
<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>
<p
class="truncate text-sm"
:class="msg.isRead ? 'text-neutral-500' : 'text-neutral-800 font-medium'"
>
{{ msg.subject ?? t('mail.noSubject') }}
</p>
<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"
/>
<Icon
v-if="msg.linkedTaskIds.length > 0"
name="material-symbols:task-outline"
size="14"
class="text-primary-400 flex-shrink-0"
/>
</div>
</div>
</button>
<div ref="sentinelRef" class="h-px" />
<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>
<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>
@@ -1,278 +0,0 @@
<script setup lang="ts">
import type { MailMessageDetailDto, MailAddressDto, MailAttachmentDto } from '~/services/dto/mail'
import { sanitizeMailHtml } from '~/utils/sanitizeMailHtml'
import { useMailService } from '~/services/mail'
const props = defineProps<{
/** Détail complet du message. null = aucun message sélectionné. */
detail: MailMessageDetailDto | null
loading: boolean
}>()
const emit = defineEmits<{
createTask: [mailId: number]
linkTask: [mailId: number]
}>()
const { t } = useI18n()
const mailService = useMailService()
const showImages = ref(false)
const sanitizedBody = computed((): string => {
if (!props.detail?.bodyHtml) return ''
return sanitizeMailHtml(props.detail.bodyHtml, { allowImages: showImages.value })
})
// ─── Pièces jointes : aperçu / téléchargement ──────────────────────────────
function isImage(mime: string): boolean {
return mime.startsWith('image/')
}
function isPdf(mime: string): boolean {
return mime === 'application/pdf'
}
function isPreviewable(mime: string): boolean {
return isImage(mime) || isPdf(mime)
}
function attachmentIcon(mime: string): string {
if (isImage(mime)) return 'material-symbols:image-outline'
if (isPdf(mime)) return 'material-symbols:picture-as-pdf-outline'
return 'material-symbols:attach-file'
}
const previewOpen = ref(false)
const previewLoading = ref(false)
const previewAtt = ref<MailAttachmentDto | null>(null)
const previewUrl = ref<string | null>(null)
let previewBlob: Blob | null = null
function revokePreview(): void {
if (previewUrl.value) {
URL.revokeObjectURL(previewUrl.value)
previewUrl.value = null
}
previewBlob = null
}
watch(
() => props.detail?.header.id,
() => {
showImages.value = false
previewOpen.value = false
revokePreview()
},
)
watch(previewOpen, (open) => {
if (!open) revokePreview()
})
onBeforeUnmount(revokePreview)
async function handleAttachmentClick(att: MailAttachmentDto): Promise<void> {
if (!isPreviewable(att.mimeType)) {
await handleDownload(att.downloadId, att.filename)
return
}
previewAtt.value = att
previewUrl.value = null
previewLoading.value = true
previewOpen.value = true
try {
const { data } = await mailService.downloadAttachment(att.downloadId)
previewBlob = data
previewUrl.value = URL.createObjectURL(data)
} catch {
// useApi affiche déjà le toast — on referme la visionneuse.
previewOpen.value = false
} finally {
previewLoading.value = false
}
}
function downloadFromPreview(): void {
const att = previewAtt.value
if (!att) return
if (previewBlob) {
triggerBlobDownload(previewBlob, att.filename)
} else {
void handleDownload(att.downloadId, att.filename)
}
}
function triggerBlobDownload(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
async function handleDownload(downloadId: string, filename: string): Promise<void> {
try {
const { data } = await mailService.downloadAttachment(downloadId)
triggerBlobDownload(data, filename)
} catch {
// L'erreur est gérée par useApi (toast automatique)
}
}
function formatDate(iso: string | null): string {
if (!iso) return ''
return new Date(iso).toLocaleString('fr', {
dateStyle: 'long',
timeStyle: 'short',
})
}
function joinAddresses(addresses: MailAddressDto[]): 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">
<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>
<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>
<template v-else-if="detail">
<div class="flex-shrink-0 border-b border-neutral-200 px-4 py-3 space-y-1.5">
<h2 class="text-base font-semibold text-neutral-900 break-words">
{{ detail.header.subject ?? t('mail.noSubject') }}
</h2>
<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>
<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="13"
button-class="text-xs px-2.5 py-1"
@click="emit('createTask', detail.header.id)"
/>
<MalioButton
:label="t('mail.actions.linkTask')"
variant="secondary"
icon-name="material-symbols:link"
icon-position="left"
:icon-size="13"
button-class="text-xs px-2.5 py-1"
@click="emit('linkTask', detail.header.id)"
/>
</div>
</div>
<div class="flex-1 overflow-y-auto px-4 py-3">
<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">
{{ t('mail.remoteImagesBlocked') }}
</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>
<div
v-if="detail.bodyHtml"
class="prose prose-sm max-w-none text-neutral-800"
v-html="sanitizedBody"
/>
<pre
v-else-if="detail.bodyText"
class="whitespace-pre-wrap font-sans text-sm text-neutral-700"
>{{ detail.bodyText }}</pre>
</div>
<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="isPreviewable(att.mimeType) ? t('mail.preview.open') : t('mail.actions.download')"
@click="handleAttachmentClick(att)"
>
<Icon :name="attachmentIcon(att.mimeType)" 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>
<Icon
v-if="isPreviewable(att.mimeType)"
name="material-symbols:visibility-outline"
size="13"
class="flex-shrink-0 text-neutral-400"
/>
</button>
</div>
</div>
<MailAttachmentPreview
v-if="previewAtt"
v-model="previewOpen"
:filename="previewAtt.filename"
:mime-type="previewAtt.mimeType"
:url="previewUrl"
:loading="previewLoading"
@download="downloadFromPreview"
/>
</template>
</div>
</template>
@@ -1,24 +0,0 @@
<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>