Improved listing and detail pages

This commit is contained in:
Antoine Nguyen
2025-09-05 13:32:00 +02:00
parent 33005248c9
commit ac737baa4e
7 changed files with 434 additions and 22 deletions
+15
View File
@@ -4,4 +4,19 @@ export default {
getQuarantineContent(params) {
return repository.get('/amavis/quarantine/', { params })
},
releaseSelection(data) {
return repository.post('/amavis/quarantine/release_selection/', data)
},
deleteSelection(data) {
return repository.post('/amavis/quarantine/delete_selection/', data)
},
markMessageSelection(data) {
return repository.post('/amavis/quarantine/mark_selection/', data)
},
getMessageContent(mailid, rcpt) {
return repository.get(`/amavis/quarantine/${mailid}/?rcpt=${rcpt}`)
},
getMessageHeaders(mailid) {
return repository.get(`/amavis/quarantine/${mailid}/headers/`)
},
}
@@ -9,27 +9,98 @@
:items-length="totalMessages"
:page="currentPage"
:items-per-page="itemsPerPageR"
item-value="pk"
item-value="mailid"
elevation="0"
show-select
return-object
density="compact"
:sort-by="sortByR"
:row-props="getRowProps"
:cell-props="getCellProps"
@update:options="updatedOptions"
@click:row="openMessage"
>
<template #top>
<v-text-field
v-model="search"
prepend-inner-icon="mdi-magnify"
:placeholder="$gettext('Search in messages')"
variant="outlined"
single-line
flat
hide-details
density="compact"
class="flex-grow-0 w-33 mr-4"
clearable
@click:clear="fetchContent"
@keyup.enter="submitSearch"
></v-text-field>
<div class="d-flex my-4">
<v-text-field
v-model="search"
prepend-inner-icon="mdi-magnify"
:placeholder="$gettext('Search in messages')"
variant="outlined"
single-line
flat
hide-details
density="compact"
class="flex-grow-0 w-33 mr-4"
clearable
@click:clear="fetchContent"
@keyup.enter="submitSearch"
></v-text-field>
<v-btn
class="ml-2"
color="success"
variant="tonal"
icon="mdi-check"
size="small"
:disabled="selected.length === 0"
:title="$gettext('Release selection')"
:loading="loading"
@click="releaseSelection"
>
</v-btn>
<v-btn
class="ml-2"
color="error"
variant="tonal"
icon="mdi-trash-can"
size="small"
:disabled="selected.length === 0"
:title="$gettext('Delete selection')"
:loading="loading"
@click="deleteSelection"
>
</v-btn>
<v-btn
class="ml-2"
size="small"
variant="tonal"
icon
:disabled="selected.length === 0"
>
<v-icon icon="mdi-cog" />
<v-menu activator="parent">
<v-list>
<v-list-item
:title="$gettext('Mark as spam')"
@click="markSelectionAsSpam"
/>
<v-list-item
:title="$gettext('Mark as not spam')"
@click="markSelectionAsHam"
/>
</v-list>
</v-menu>
</v-btn>
</div>
</template>
<template #[`item.type`]="{ item }">
<v-chip :color="getTypeColor(item.type)" size="small" label>
{{ item.type }}
</v-chip>
</template>
<template #[`item.from_address`]="{ item }">
<span :title="item.from_address">
{{ $truncate(item.from_address, 30) }}
</span>
</template>
<template #[`item.subject`]="{ item }">
<span :title="item.subject">
{{ $truncate(item.subject, 30) }}
</span>
</template>
<template #[`item.datetime`]="{ item }">
{{ $date(item.datetime) }}
</template>
</v-data-table-server>
</v-card>
@@ -37,13 +108,161 @@
<script setup>
import { ref } from 'vue'
import { useGettext } from 'vue3-gettext'
import { useRouter } from 'vue-router'
import { useBusStore } from '@/stores'
import api from '@/api/amavis'
const { $gettext } = useGettext()
const router = useRouter()
const { displayNotification } = useBusStore()
const currentPage = ref(1)
const itemsPerPageR = ref(10)
const loading = ref(false)
const messages = ref([])
const search = ref('')
const selected = ref([])
const sortByR = ref([])
const totalMessages = ref(0)
const fetchContent = () => {}
const headers = [
{ title: '', key: 'type' },
{ title: $gettext('Score'), key: 'score' },
{ title: $gettext('To'), key: 'to_address' },
{ title: $gettext('From'), key: 'from_address' },
{ title: $gettext('Subject'), key: 'subject' },
{ title: $gettext('Date'), key: 'datetime' },
]
const getRowProps = ({ item }) => {
return {
class: item.style,
}
}
const getCellProps = () => {
return { class: 'clickable' }
}
const getTypeColor = (value) => {
if (['S', 'V'].includes(value)) {
return 'error'
}
if (['Y', 'H', 'M', 'B', 'O', 'T'].includes(value)) {
return 'warning'
}
if (value === 'C') {
return 'success'
}
return ''
}
const updatedOptions = async ({ page, itemsPerPage, sortBy }) => {
currentPage.value = page
itemsPerPageR.value = itemsPerPage
sortByR.value = sortBy
fetchContent()
}
const fetchContent = async () => {
loading.value = true
const params = {
page: currentPage.value || 1,
page_size: itemsPerPageR.value || 10,
}
if (sortByR.value && sortByR.value.length) {
params.ordering = sortByR.value
.map((item) => (item.order !== 'asc' ? `-${item.key}` : item.key))
.join(',')
}
if (search.value !== '') {
params.search = search.value
}
try {
const resp = await api.getQuarantineContent(params)
messages.value = resp.data.results
totalMessages.value = resp.data.count
selected.value = []
} finally {
loading.value = false
}
}
const openMessage = (event, { item }) => {
router.push({
name: 'QuarantineMessageView',
params: { mailid: item.mailid, rcpt: item.to_address },
})
}
const prepareSelectionPayload = () => {
const data = {
selection: [],
}
for (const row of selected.value) {
data.selection.push({ rcpt: row.to_address, mailid: row.mailid })
}
return data
}
const deleteSelection = async () => {
loading.value = true
const data = prepareSelectionPayload()
try {
await api.deleteSelection(data)
fetchContent()
displayNotification({ msg: $gettext('Selection deleted') })
} finally {
loading.value = false
}
}
const releaseSelection = async () => {
loading.value = true
const data = prepareSelectionPayload()
try {
const resp = await api.releaseSelection(data)
fetchContent()
if (resp.data.status === 'pending') {
displayNotification({ msg: $gettext('Release request sent') })
} else {
displayNotification({ msg: $gettext('Selection released') })
}
} finally {
loading.value = false
}
}
const markSelection = async (mtype) => {
loading.value = true
const data = prepareSelectionPayload()
data.type = mtype
try {
await api.markMessageSelection(data)
displayNotification({ msg: $gettext('Selection is being processed...') })
selected.value = []
} finally {
loading.value = false
}
}
const markSelectionAsSpam = async () => {
await markSelection('spam')
}
const markSelectionAsHam = async () => {
await markSelection('ham')
}
const submitSearch = () => {}
</script>
<style lang="scss">
.unseen {
font-weight: bold;
}
.clickable {
cursor: pointer;
}
</style>
+8
View File
@@ -441,6 +441,14 @@ const routes = [
requiresAuth: true,
},
},
{
path: ':mailid/:rcpt',
name: 'QuarantineMessageView',
component: () => import('@/views/quarantine/MessageView.vue'),
meta: {
requiresAuth: true,
},
},
],
},
],
@@ -0,0 +1,165 @@
<template>
<div class="bg-white rounded-lg pa-4 position-relative h-100">
<v-toolbar color="white">
<v-btn icon="mdi-arrow-left" size="small" variant="flat" @click="close" />
<v-btn
class="ml-2"
color="success"
variant="tonal"
icon="mdi-check"
size="small"
:title="$gettext('Release message')"
:loading="loading"
@click="releaseMessage"
>
</v-btn>
<v-btn
class="ml-2"
color="error"
variant="tonal"
icon="mdi-trash-can"
size="small"
:title="$gettext('Delete message')"
:loading="loading"
@click="deleteMessage"
>
</v-btn>
<v-btn class="ml-2" variant="tonal" icon size="small">
<v-icon icon="mdi-cog" />
<v-menu activator="parent">
<v-list density="compact">
<v-list-item
:title="$gettext('Mark as spam')"
@click="markAsSpam"
/>
<v-list-item
:title="$gettext('Mark as non-spam')"
@click="markAsHam"
/>
</v-list>
</v-menu>
</v-btn>
<v-btn
class="ml-2"
variant="tonal"
size="small"
:loading="loading"
@click="toggleFullHeaders"
>
<span v-if="!fullHeaders">
{{ $gettext('View full headers') }}
</span>
<span v-else>
{{ $gettext('Hide full headers') }}
</span>
</v-btn>
</v-toolbar>
<div class="pa-4 bg-grey-lighten-3 rounded">
<v-row v-for="header in headers" :key="header.name" no-gutters>
<v-col cols="2" class="font-weight-bold">
{{ header.name }}
</v-col>
<v-col cols="10">{{ header.value }}</v-col>
</v-row>
</div>
<div class="mt-4" v-html="message.body" />
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useGettext } from 'vue3-gettext'
import { useBusStore } from '@/stores'
import api from '@/api/amavis'
const route = useRoute()
const router = useRouter()
const { $gettext } = useGettext()
const { displayNotification } = useBusStore()
const fullHeaders = ref(null)
const loading = ref(false)
const message = ref(null)
const headers = computed(() => {
if (!fullHeaders.value) {
return message.value.headers
}
return fullHeaders.value
})
const close = () => {
router.go(-1)
}
const toggleFullHeaders = async () => {
if (fullHeaders.value) {
fullHeaders.value = null
return
}
loading.value = true
try {
const resp = await api.getMessageHeaders(route.params.mailid)
fullHeaders.value = resp.data.headers
} finally {
loading.value = false
}
}
const releaseMessage = async () => {
loading.value = true
const data = {
selection: [{ rcpt: route.rcpt, mailid: route.mailid }],
}
try {
const resp = await api.releaseSelection(data)
if (resp.data.status === 'pending') {
displayNotification({ msg: $gettext('Release request sent') })
} else {
displayNotification({ msg: $gettext('Message released') })
}
} finally {
loading.value = false
}
}
const deleteMessage = async () => {
loading.value = true
const data = {
selection: [{ rcpt: route.params.rcpt, mailid: route.params.mailid }],
}
try {
await api.deleteSelection(data)
displayNotification({ msg: $gettext('Message deleted') })
router.push({ name: 'QuarantineView' })
} finally {
loading.value = false
}
}
const mark = async (mtype, msg) => {
loading.value = true
const data = {
type: mtype,
selection: [{ rcpt: route.params.rcpt, mailid: route.params.mailid }],
}
try {
await api.markMessageSelection(data)
displayNotification({ msg })
} finally {
loading.value = false
}
}
const markAsSpam = async () => {
await mark('spam', $gettext('Message marked as spam'))
}
const markAsHam = async () => {
await mark('ham', $gettext('Message marked as non-spam'))
}
const resp = await api.getMessageContent(route.params.mailid)
message.value = resp.data
</script>
@@ -1,9 +1,11 @@
<template>
<div class="text-h5 ml-4">
{{ $gettext('Quarantine') }}
</div>
<div>
<div class="text-h5 ml-4">
{{ $gettext('Quarantine') }}
</div>
<QuarantineList />
<QuarantineList />
</div>
</template>
<script setup>
+1 -1
View File
@@ -96,7 +96,7 @@ class Email:
if self._headers is None:
self._headers = []
# don't break modoboa_webmail.lib.imapemail.ImapEmail, it
# don't break modoboa.webmail.lib.imapemail.ImapEmail, it
# doesn't have self._msg it loads e-mails (and their headers)
# from the IMAP server.
if self._msg is not None:
+4 -1
View File
@@ -77,7 +77,10 @@ dependencies = [
# User
"drf-nested-routers",
"vobject",
"caldav==2.0.1"
"caldav==2.0.1",
# Amavis
"html2text",
"idna",
]
[project.optional-dependencies]
dev = [