Improvements about mailbox listing and email display

This commit is contained in:
Antoine Nguyen
2025-05-01 18:00:00 +02:00
parent 927bc00194
commit ce2569f39d
8 changed files with 338 additions and 78 deletions
+8
View File
@@ -55,4 +55,12 @@ export default {
}
return repository.post('/webmail/emails/mark_as_not_junk/', body)
},
flagSelection(mailbox, selection, status) {
const body = {
mailbox,
selection,
status,
}
return repository.post('/webmail/emails/flag/', body)
},
}
+221 -51
View File
@@ -1,7 +1,12 @@
<template>
<v-card class="mt-6 mb-2">
<v-card class="mt-6 mb-2 mx-1">
<v-toolbar color="white" flat>
<v-checkbox class="mr-4" hide-details />
<v-checkbox
v-model="selectAll"
class="mr-4"
hide-details
@update:model-value="toggleAllSelection"
/>
<v-text-field
v-model="search"
@@ -14,54 +19,138 @@
density="compact"
class="flex-grow-0 w-33 mr-4"
></v-text-field>
<v-btn
class="ml-2"
color="error"
variant="tonal"
icon="mdi-trash-can"
size="small"
:loading="working"
@click="deleteSelection"
>
</v-btn>
<v-btn
v-if="$route.params.mailbox === 'Junk'"
class="ml-2"
color="warning"
variant="tonal"
icon="mdi-fire"
size="small"
:loading="working"
@click="markSelectionAsJunk"
>
</v-btn>
<v-btn
v-else
class="ml-2"
color="success"
variant="tonal"
icon="mdi-thumb-up"
size="small"
:loading="working"
@click="markSelectionAsNotJunk"
>
</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 read')"
prepend-icon="mdi-eye"
@click="() => flagSelection('read')"
/>
<v-list-item
:title="$gettext('Mark as unread')"
prepend-icon="mdi-eye-outline"
@click="() => flagSelection('unread')"
/>
<v-list-item
:title="$gettext('Mark as followed')"
prepend-icon="mdi-star"
@click="() => flagSelection('flagged')"
/>
<v-list-item
:title="$gettext('Mark as unfollowed')"
prepend-icon="mdi-star-outline"
@click="() => flagSelection('unflagged')"
/>
</v-list>
</v-menu>
</v-btn>
<v-spacer />
<div v-if="emails.results" class="d-flex align-center">
<div class="text-caption mr-2">
{{ emails.first_index }}-{{ emails.last_index }} {{ $gettext('on') }}
{{ emails.count }}
</div>
<div>
<v-btn
icon="mdi-chevron-left"
size="x-small"
:disabled="emails.prev_page === null"
@click="page = emails.prev_page"
/>
<v-btn
icon="mdi-chevron-right"
size="x-small"
:disabled="emails.next_page === null"
@click="page = emails.next_page"
/>
</div>
</div>
</v-toolbar>
</v-card>
<v-skeleton-loader v-if="loading" type="card@2"></v-skeleton-loader>
<template v-else>
<v-card
v-for="email in emails"
:key="email.imapid"
density="compact"
class="mb-2"
>
<v-card-text
class="d-flex align-center"
:class="{ 'font-weight-bold': email.style === 'unseen' }"
<div class="emails position-absolute top-0 bottom-0 w-100 overflow-y-auto">
<v-card
v-for="email in emails.results"
:key="email.imapid"
density="compact"
class="mb-2 mx-1"
>
<v-checkbox :value="email.imapid" hide-details />
<v-btn
:icon="email.flagged ? 'mdiu-star' : 'mdi-star-outline'"
variant="flat"
/>
<div class="ml-4 clickable" @click="openEmail(email.imapid)">
<div>{{ email.subject }}</div>
<div class="mt-1 text-grey">
<span
v-if="email.from_address.name"
:title="email.from_address.address"
>{{ email.from_address.name }}</span
>
<span v-else>{{ email.from_address.address }}</span>
<v-card-text
class="d-flex align-center"
:class="{ 'font-weight-bold': email.style === 'unseen' }"
>
<v-checkbox v-model="selection" :value="email.imapid" hide-details />
<v-btn
:icon="email.flagged ? 'mdiu-star' : 'mdi-star-outline'"
variant="flat"
/>
<div class="ml-4 clickable" @click="openEmail(email.imapid)">
<div>{{ email.subject }}</div>
<div class="mt-1 text-grey">
<span
v-if="email.from_address.name"
:title="email.from_address.address"
>{{ email.from_address.name }}</span
>
<span v-else>{{ email.from_address.address }}</span>
</div>
</div>
</div>
<v-spacer />
<div class="text-right">
<div>{{ email.date }}</div>
<div class="mt-1">
<v-icon v-if="email.answered" icon="mdi-reply-outline" />
<v-icon v-if="email.forwarded" icon="mdi-share-outline" />
<v-icon v-if="email.attachments" icon="mdi-paperclip" />
<span class="text-grey">{{ $filesize(email.size) }}</span>
<v-spacer />
<div class="text-right">
<div>{{ email.date }}</div>
<div class="mt-1">
<v-icon v-if="email.answered" icon="mdi-reply-outline" />
<v-icon v-if="email.forwarded" icon="mdi-share-outline" />
<v-icon v-if="email.attachments" icon="mdi-paperclip" />
<span class="text-grey">{{ $filesize(email.size) }}</span>
</div>
</div>
</div>
</v-card-text>
</v-card>
</v-card-text>
</v-card>
</div>
</template>
</template>
<script setup>
import { ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useRouter, useRoute } from 'vue-router'
import { useGettext } from 'vue3-gettext'
import { useBusStore } from '@/stores'
import api from '@/api/webmail'
const props = defineProps({
@@ -71,11 +160,18 @@ const props = defineProps({
},
})
const { $gettext } = useGettext()
const { displayNotification } = useBusStore()
const router = useRouter()
const route = useRoute()
const loading = ref(false)
const emails = ref([])
const emails = ref({})
const page = ref(1)
const search = ref('')
const selectAll = ref(false)
const selection = ref([])
const working = ref(false)
const openEmail = (emailid) => {
router.push({
@@ -84,29 +180,103 @@ const openEmail = (emailid) => {
})
}
const fetchEmails = () => {
emails.value = {}
loading.value = true
api
.getMailboxEmails(props.mailbox, { page: page.value })
.then((resp) => {
emails.value = resp.data
loading.value = false
})
.catch(() => {
loading.value = false
})
}
const toggleAllSelection = (value) => {
if (!value) {
selection.value = []
} else {
selection.value = emails.value.results.map((email) => email.imapid)
}
}
const deleteSelection = () => {
if (!selection.value.length) {
return
}
working.value = true
api.deleteSelection(route.query.mailbox, selection.value).then(() => {
working.value = false
displayNotification({ msg: $gettext('Message(s) deleted') })
fetchEmails()
})
}
const markSelectionAsJunk = () => {
if (!selection.value.length) {
return
}
working.value = true
api.markSelectionAsJunk(route.query.mailbox, selection.value).then(() => {
working.value = false
displayNotification({ msg: $gettext('Message(s) marked as junk') })
fetchEmails()
})
}
const markSelectionAsNotJunk = () => {
if (!selection.value.length) {
return
}
working.value = true
api.markSelectionAsNotJunk(route.query.mailbox, selection.value).then(() => {
working.value = false
displayNotification({ msg: $gettext('Message(s) marked as not junk') })
fetchEmails()
})
}
const flagSelection = (status) => {
if (!selection.value.length) {
return
}
working.value = true
api.flagSelection(route.query.mailbox, selection.value, status).then(() => {
working.value = false
selection.value = []
displayNotification({ msg: $gettext('Message(s) flagged') })
fetchEmails()
})
}
watch(
() => props.mailbox,
(value) => {
emails.value = []
loading.value = true
api
.getMailboxEmails(value, {})
.then((resp) => {
emails.value = resp.data
loading.value = false
})
.catch(() => {
loading.value = false
})
() => {
fetchEmails()
},
{ immediate: true }
)
watch(selection, () => {
if (!selection.value.length) {
selectAll.value = false
} else {
selectAll.value = true
}
})
watch(page, () => {
fetchEmails()
})
</script>
<style lang="scss" scoped>
.v-card-text {
padding: 0;
}
.emails {
margin-top: 150px;
}
.clickable {
cursor: pointer;
}
@@ -13,7 +13,14 @@
@mouseleave="setHover(mailbox, false)"
>
<v-icon :icon="iconByMailboxType[mailbox.type]" class="mr-4" />
{{ getMailboxLabel(mailbox) }}
<template v-if="mailbox.unseen > 0">
<span class="font-weight-bold">
{{ getMailboxLabel(mailbox) }} ({{ mailbox.unseen }})
</span>
</template>
<template v-else>
{{ getMailboxLabel(mailbox) }}
</template>
<v-spacer />
<v-btn
v-if="mailbox.sub"
+22 -7
View File
@@ -1,5 +1,5 @@
<template>
<div v-show="loaded" class="bg-white rounded-lg pa-4 mt-12">
<div v-show="loaded" 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" />
@@ -68,7 +68,7 @@
</v-btn>
</v-toolbar>
<div v-if="email" class="bg-white pa-4">
<div v-if="email" ref="headers" class="bg-white pa-4">
<h2>{{ email.subject }}</h2>
<div class="d-flex mt-2">
<h3 v-if="email.from_address.fulladdress">
@@ -97,13 +97,13 @@
</a>
</template>
</div>
<iframe class="email-frame" />
</div>
<iframe class="email-frame" />
</div>
</template>
<script setup>
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useGettext } from 'vue3-gettext'
import { useBusStore } from '@/stores'
@@ -116,6 +116,7 @@ const router = useRouter()
const enableLinks = ref(false)
const email = ref(null)
const headers = ref(null)
const loaded = ref(false)
const working = ref(false)
@@ -129,9 +130,14 @@ const recipients = computed(() => {
})
onMounted(() => {
window.addEventListener('resize', resizeEmailIframe)
fetchMailContent()
})
onUnmounted(() => {
window.removeEventListener('resize', resizeEmailIframe)
})
watch(enableLinks, () => {
fetchMailContent()
})
@@ -143,6 +149,14 @@ const close = () => {
})
}
const resizeEmailIframe = () => {
const iframe = document.querySelector('iframe')
const rect = headers.value.getBoundingClientRect()
iframe.style.top = `${rect.bottom}px`
iframe.style.width = `${rect.width - 24}px`
iframe.style.height = `${window.innerHeight - rect.bottom - 32}px`
}
const fetchMailContent = () => {
const options = {
dformat: 'html',
@@ -160,6 +174,7 @@ const fetchMailContent = () => {
iframeDoc.write(email.value.body)
iframeDoc.close()
loaded.value = true
nextTick(resizeEmailIframe)
})
})
}
@@ -220,10 +235,10 @@ const markEmailAsNotJunk = () => {
<style>
.email-frame {
width: 100%;
min-height: 1000px;
position: absolute !important;
left: 24px;
overflow-y: auto;
border: none;
margin-top: 10px;
background-color: #fff;
}
</style>
+5 -3
View File
@@ -1,8 +1,10 @@
<template>
<div class="text-h5 ml-4">
{{ $gettext('Webmail') }}
<div class="position-relative h-100">
<div class="text-h5 ml-4">
{{ $gettext('Webmail') }}
</div>
<EmailList :mailbox="$route.query.mailbox || 'INBOX'" />
</div>
<EmailList :mailbox="$route.query.mailbox || 'INBOX'" />
</template>
<script setup>
+8 -9
View File
@@ -568,7 +568,7 @@ class IMAPconnector:
mb["unseen"] = count
return md_mailboxes
def _add_flag(self, mbox: str, msgset, flag: str) -> None:
def _add_flag(self, mbox: str, msgset: list[str], flag: str) -> None:
"""Add flag to a messages set.
:param mbox: the mailbox containing the messages
@@ -576,9 +576,9 @@ class IMAPconnector:
:param flag: the flag to add
"""
self.select_mailbox(mbox, False)
self._cmd("STORE", msgset, "+FLAGS", flag)
self._cmd("STORE", ",".join(msgset), "+FLAGS", flag)
def _remove_flag(self, mbox: str, msgset, flag: str) -> None:
def _remove_flag(self, mbox: str, msgset: list[str], flag: str) -> None:
"""Remove flag from a message set.
:param mbox: the mailbox containing the messages
@@ -586,9 +586,9 @@ class IMAPconnector:
:param flag: the flag to remove
"""
self.select_mailbox(mbox, False)
self._cmd("STORE", msgset, "-FLAGS", flag)
self._cmd("STORE", ",".join(msgset), "-FLAGS", flag)
def mark_messages_unread(self, mbox: str, msgset) -> None:
def mark_messages_unread(self, mbox: str, msgset: list[str]) -> None:
"""Mark a set of messages as unread.
:param mbox: the mailbox containing the messages
@@ -596,7 +596,7 @@ class IMAPconnector:
"""
self._remove_flag(mbox, msgset, r"(\Seen)")
def mark_messages_read(self, mbox: str, msgset) -> None:
def mark_messages_read(self, mbox: str, msgset: list[str]) -> None:
"""Mark a set of messages as unread.
:param mbox: the mailbox containing the messages
@@ -604,7 +604,7 @@ class IMAPconnector:
"""
self._add_flag(mbox, msgset, r"(\Seen)")
def mark_messages_flagged(self, mbox: str, msgset) -> None:
def mark_messages_flagged(self, mbox: str, msgset: list[str]) -> None:
"""Mark a set of messages as flagged.
:param mbox: the mailbox containing the messages
@@ -612,7 +612,7 @@ class IMAPconnector:
"""
self._add_flag(mbox, msgset, r"(\Flagged)")
def mark_messages_unflagged(self, mbox: str, msgset) -> None:
def mark_messages_unflagged(self, mbox: str, msgset: list[str]) -> None:
"""Mark a set of messages as unflagged.
:param mbox: the mailbox containing the messages
@@ -761,7 +761,6 @@ class IMAPconnector:
msg = email.message_from_string(
msg_data[f"BODY[HEADER.FIELDS ({headers})]"]
)
print(type(msg))
msg["imapid"] = uid
msg["size"] = msg_data["RFC822.SIZE"]
if r"\Seen" not in msg_data["FLAGS"]:
+27
View File
@@ -92,6 +92,16 @@ class EmailHeadersSerializer(serializers.Serializer):
return imapheader.parse_date(obj["Date"])
class PaginatedEmailListSerializer(serializers.Serializer):
count = serializers.IntegerField()
first_index = serializers.IntegerField()
last_index = serializers.IntegerField()
prev_page = serializers.IntegerField()
next_page = serializers.IntegerField()
results = EmailHeadersSerializer(many=True)
class EmailSerializer(serializers.Serializer):
subject = serializers.CharField(source="Subject")
@@ -117,3 +127,20 @@ class MoveSelectionSerializer(serializers.Serializer):
def validate_selection(self, value):
return [item for item in value if item.isdigit()]
class FlagSelectionSerializer(serializers.Serializer):
mailbox = serializers.CharField()
selection = serializers.ListField(child=serializers.CharField())
status = serializers.ChoiceField(
choices=[
("read", "Read"),
("unread", "Unread"),
("flagged", "Flagged"),
("unflagged", "Unflagged"),
]
)
def validate_selection(self, value):
return [item for item in value if item.isdigit()]
+39 -7
View File
@@ -15,7 +15,7 @@ class UserMailboxViewSet(viewsets.GenericViewSet):
serializer_class = serializers.UserMailboxSerializer
def list(self, request):
parent_mailbox = request.GET.get("mailbox", "")
parent_mailbox = request.GET.get("mailbox")
imapc = lib.get_imapconnector(request)
mboxes = imapc.getmboxes(request.user, parent_mailbox)
serializer = serializers.UserMailboxSerializer(mboxes, many=True)
@@ -24,18 +24,36 @@ class UserMailboxViewSet(viewsets.GenericViewSet):
class UserEmailViewSet(viewsets.GenericViewSet):
def get_serializer_class(self):
if self.action == "list":
return serializers.PaginatedEmailListSerializer
if self.action in ["delete", "mark_as_junk", "mark_as_not_junk"]:
return serializers.MoveSelectionSerializer
if self.action == "flag":
return serializers.FlagSelectionSerializer
return serializers.EmailSerializer
def list(self, request):
mailbox = request.GET.get("mailbox", "INBOX")
imapc = lib.get_imapconnector(request)
paginator = Paginator(
imapc.messages_count(mbox=mailbox),
request.user.parameters.get_value("messages_per_page"),
)
page = paginator.getpage(1)
total = imapc.messages_count(mbox=mailbox)
messages_per_page = request.user.parameters.get_value("messages_per_page")
paginator = Paginator(total, messages_per_page)
page_num = int(request.GET.get("page", 1))
page = paginator.getpage(int(request.GET.get("page", 1)))
content = imapc.fetch(page.id_start, page.id_stop, mbox=mailbox)
content = [dict(msg) for msg in content]
serializer = serializers.EmailHeadersSerializer(content, many=True)
return response.Response(serializer.data)
return response.Response(
{
"count": total,
"first_index": page_num * messages_per_page,
"last_index": (page_num * messages_per_page) + len(content),
"prev_page": page.previous_page_number if page.has_previous else None,
"next_page": page.next_page_number if page.has_next else None,
"results": serializer.data,
}
)
def move_selection(self, request, destination: str) -> int:
"""Move selected messages to the given mailbox."""
@@ -80,6 +98,20 @@ class UserEmailViewSet(viewsets.GenericViewSet):
count = self.move_selection(request, "INBOX")
return response.Response({"count": count})
@action(
methods=["post"],
detail=False,
serializer_class=serializers.FlagSelectionSerializer,
)
def flag(self, request):
serializer = serializers.FlagSelectionSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
imapc = lib.get_imapconnector(request)
getattr(imapc, f"mark_messages_{serializer.validated_data['status']}")(
serializer.validated_data["mailbox"], serializer.validated_data["selection"]
)
return response.Response({"count": len(serializer.validated_data["selection"])})
@action(methods=["get"], detail=False)
def content(self, request):
mailbox = request.GET.get("mailbox", "INBOX")