Added possibility to Drag&Drop message selection between folders.

fix #3775
This commit is contained in:
Antoine Nguyen
2025-11-25 15:22:20 +01:00
parent ab9231cc8c
commit ae1ba7149d
11 changed files with 195 additions and 58 deletions
+14 -6
View File
@@ -65,23 +65,31 @@ export default {
responseType: 'blob',
})
},
deleteSelection(mailbox, selection) {
moveSelection(source, destination, selection) {
const body = {
mailbox,
source,
destination,
selection,
}
return repository.post('/webmail/emails/move/', body)
},
deleteSelection(source, selection) {
const body = {
source,
selection,
}
return repository.post('/webmail/emails/delete/', body)
},
markSelectionAsJunk(mailbox, selection) {
markSelectionAsJunk(source, selection) {
const body = {
mailbox,
source,
selection,
}
return repository.post('/webmail/emails/mark_as_junk/', body)
},
markSelectionAsNotJunk(mailbox, selection) {
markSelectionAsNotJunk(source, selection) {
const body = {
mailbox,
source,
selection,
}
return repository.post('/webmail/emails/mark_as_not_junk/', body)
+77 -37
View File
@@ -5,6 +5,7 @@
v-model="selectAll"
class="mr-4"
hide-details
color="primary"
@update:model-value="toggleAllSelection"
/>
@@ -88,7 +89,7 @@
</v-menu>
</v-btn>
<v-spacer />
<div v-if="emails.results" class="d-flex align-center">
<div v-if="emails" class="d-flex align-center">
<div class="text-caption mr-2">
{{ emails.first_index }}-{{ emails.last_index }} {{ $gettext('on') }}
{{ emails.count }}
@@ -119,14 +120,17 @@
:key="email.imapid"
density="compact"
class="mb-2 mx-1"
draggable="true"
@dragstart="onDragStart(email)"
>
<v-card-text
class="d-flex align-center"
:class="{ 'font-weight-bold': email.style === 'unseen' }"
>
<v-checkbox
v-model="selection"
v-model="webmailStore.selection"
:value="email.imapid"
color="primary"
hide-details
/>
<v-btn
@@ -170,10 +174,10 @@
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useGettext } from 'vue3-gettext'
import { useBusStore } from '@/stores'
import { useBusStore, useWebmailStore } from '@/stores'
import api from '@/api/webmail'
const props = defineProps({
@@ -183,18 +187,20 @@ const props = defineProps({
},
})
const { $gettext } = useGettext()
const { $gettext, $ngettext } = useGettext()
const { displayNotification, reloadMailboxCounters } = useBusStore()
const webmailStore = useWebmailStore()
const router = useRouter()
const route = useRoute()
const loading = ref(false)
const emails = ref({})
const emails = ref([])
const emailsPage = ref({})
const page = ref(1)
const search = ref('')
const selectAll = ref(false)
const selection = ref([])
const working = ref(false)
const emailList = ref(null)
let intervalId = null
@@ -233,18 +239,18 @@ const submitSearch = () => {
const toggleAllSelection = (value) => {
if (!value) {
selection.value = []
webmailStore.selection = []
} else {
selection.value = emails.value.results.map((email) => email.imapid)
webmailStore.selection = emails.value.results.map((email) => email.imapid)
}
}
const deleteSelection = () => {
if (!selection.value.length) {
if (!webmailStore.selection.length) {
return
}
working.value = true
api.deleteSelection(currentMailbox.value, selection.value).then(() => {
api.deleteSelection(currentMailbox.value, webmailStore.selection).then(() => {
working.value = false
displayNotification({ msg: $gettext('Message(s) deleted') })
fetchEmails()
@@ -253,41 +259,47 @@ const deleteSelection = () => {
}
const markSelectionAsJunk = () => {
if (!selection.value.length) {
if (!webmailStore.selection.length) {
return
}
working.value = true
api.markSelectionAsJunk(currentMailbox.value, selection.value).then(() => {
working.value = false
displayNotification({ msg: $gettext('Message(s) marked as junk') })
autoRefreshContent()
})
api
.markSelectionAsJunk(currentMailbox.value, webmailStore.selection)
.then(() => {
working.value = false
displayNotification({ msg: $gettext('Message(s) marked as junk') })
autoRefreshContent()
})
}
const markSelectionAsNotJunk = () => {
if (!selection.value.length) {
if (!webmailStore.selection.length) {
return
}
working.value = true
api.markSelectionAsNotJunk(currentMailbox.value, selection.value).then(() => {
working.value = false
displayNotification({ msg: $gettext('Message(s) marked as not junk') })
autoRefreshContent()
})
api
.markSelectionAsNotJunk(currentMailbox.value, webmailStore.selection)
.then(() => {
working.value = false
displayNotification({ msg: $gettext('Message(s) marked as not junk') })
autoRefreshContent()
})
}
const flagSelection = (status) => {
if (!selection.value.length) {
if (!webmailStore.selection.length) {
return
}
working.value = true
api.flagSelection(currentMailbox.value, selection.value, status).then(() => {
working.value = false
selection.value = []
displayNotification({ msg: $gettext('Message(s) flagged') })
fetchEmails()
reloadMailboxCounters()
})
api
.flagSelection(currentMailbox.value, webmailStore.selection, status)
.then(() => {
working.value = false
webmailStore.selection = []
displayNotification({ msg: $gettext('Message(s) flagged') })
fetchEmails()
reloadMailboxCounters()
})
}
const emptyMailbox = () => {
@@ -305,6 +317,25 @@ const toggleFollowState = async (email) => {
email.flagged = flag === 'flagged'
}
const onDragStart = (email) => {
if (!webmailStore.selection.includes(email.imapid)) {
webmailStore.selection = [email.imapid]
}
const ghost = document.createElement('div')
const count = webmailStore.selection.length || 0
const msg = $ngettext('%{count} message', '%{count} messages', count, {
count,
})
ghost.innerHTML = `
<div class="pa-4 rounded-lg" style="background: #a9a9a9; opacity: 0.8; width: 300px">
<div style="font-weight: 600">${msg}</div>
</div>
`
document.body.appendChild(ghost)
event.dataTransfer.setDragImage(ghost, -10, -10)
setTimeout(() => ghost.remove(), 0)
}
onMounted(() => {
intervalId = setInterval(autoRefreshContent, 300 * 1000)
})
@@ -320,13 +351,22 @@ watch(
},
{ immediate: true }
)
watch(selection, () => {
if (!selection.value.length) {
selectAll.value = false
} else {
selectAll.value = true
watch(
() => webmailStore.selection,
() => {
if (!webmailStore.selection.length) {
selectAll.value = false
} else {
selectAll.value = true
}
}
})
)
watch(
() => webmailStore.listingKey,
() => {
fetchEmails()
}
)
watch(page, () => {
fetchEmails()
})
@@ -12,6 +12,10 @@
@click="updateSelection(mailbox.name)"
@mouseover="setHover(mailbox, true)"
@mouseleave="setHover(mailbox, false)"
@dragover.prevent
@dragenter="setHover(mailbox, true)"
@dragleave="setHover(mailbox, false)"
@drop="onDrop(mailbox)"
>
<v-icon :icon="iconByMailboxType[mailbox.type]" class="mr-4" />
<template v-if="mailbox.name === route.query.mailbox">
@@ -57,7 +61,8 @@
<script setup>
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useBusStore } from '@/stores'
import { useGettext } from 'vue3-gettext'
import { useBusStore, useWebmailStore } from '@/stores'
import api from '@/api/webmail'
const props = defineProps({
@@ -86,7 +91,9 @@ const model = defineModel()
const emit = defineEmits(['update:modelValue'])
const route = useRoute()
const { $gettext } = useGettext()
const busStore = useBusStore()
const webmailStore = useWebmailStore()
const iconByMailboxType = {
inbox: 'mdi-inbox',
@@ -152,6 +159,25 @@ function updateSelection(value) {
}
}
async function onDrop(mailbox) {
try {
busStore.displayNotification({
msg: $gettext('Moving selection...'),
type: 'info',
timeout: 0,
})
const resp = await api.moveSelection(
route.query.mailbox,
mailbox.name,
webmailStore.selection
)
webmailStore.listingKey++
webmailStore.selection = []
} finally {
busStore.hideNotification()
}
}
watch(
() => busStore.mbCounterKey,
() => {
@@ -169,4 +195,8 @@ watch(
border-radius: 5px;
}
}
.mailbox * {
pointer-events: none;
}
</style>
@@ -54,17 +54,21 @@ const props = defineProps({
const authUser = computed(() => authStore.authUser)
const notificationColor = computed(() => busStore.notificationColor)
const notificationTimeout = computed(() => busStore.notificationTimeout)
const notification = computed(() => busStore.notification)
const snackbar = ref(false)
const notificationTimeout = 2000
busStore.$onAction(({ name, after }) => {
if (name === 'displayNotification') {
after(() => {
snackbar.value = true
})
}
if (name === 'hideNotification') {
after(() => {
snackbar.value = false
})
}
}, true)
if (props.loadTheme) {
+12 -2
View File
@@ -3,14 +3,16 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useBusStore = defineStore('bus', () => {
const notification = ref({})
const notification = ref('')
const notificationColor = ref('')
const notificationTimeout = ref(2000)
const dataKey = ref(1)
const mbCounterKey = ref(1)
async function $reset() {
notification.value = {}
notification.value = ''
notificationColor.value = ''
notificationTimeout.value = 2000
dataKey.value = 1
mbCounterKey.value = 1
}
@@ -18,6 +20,13 @@ export const useBusStore = defineStore('bus', () => {
function displayNotification(options) {
notification.value = options.msg
notificationColor.value = options.type ? options.type : 'success'
if (options.timeout !== undefined) {
notificationTimeout.value = options.timeout
}
}
function hideNotification() {
notification.value = ''
}
function reloadData() {
@@ -32,6 +41,7 @@ export const useBusStore = defineStore('bus', () => {
notification,
notificationColor,
displayNotification,
hideNotification,
reloadMailboxCounters,
dataKey,
mbCounterKey,
+1
View File
@@ -9,5 +9,6 @@ export * from './domains.store'
export * from './parameters.store'
export * from './providers.store'
export * from './contacts.store'
export * from './webmail.store'
export default createPinia()
+12
View File
@@ -0,0 +1,12 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useWebmailStore = defineStore('webmail', () => {
const selection = ref([])
const listingKey = ref(0)
return {
selection,
listingKey,
}
})
+5
View File
@@ -245,6 +245,11 @@
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.10.tgz#a2a1e3812d14525f725d011a73eceb41fef5bc1c"
integrity sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==
"@formkit/drag-and-drop@^0.5.3":
version "0.5.3"
resolved "https://registry.yarnpkg.com/@formkit/drag-and-drop/-/drag-and-drop-0.5.3.tgz#24408c0cf8749b26d3fba3b043503efba44b4c90"
integrity sha512-VGQsKYc350OOMbz7wYt69YqDbn49x7RJ32eqiJieNke54nGZYmx18XEqTw0lBiJmH3RaiH6V45gB1u+tvI/TNw==
"@fullcalendar/core@^6.1.15":
version "6.1.19"
resolved "https://registry.yarnpkg.com/@fullcalendar/core/-/core-6.1.19.tgz#60379111ba0cfb9fd9bc6594fee47cf855fc77b2"
+2 -1
View File
@@ -173,7 +173,8 @@ class EmailSerializer(serializers.Serializer):
class MoveSelectionSerializer(serializers.Serializer):
mailbox = serializers.CharField()
source = serializers.CharField()
destination = serializers.CharField(required=False)
selection = serializers.ListField(child=serializers.CharField())
def validate_selection(self, value):
+18 -6
View File
@@ -173,15 +173,27 @@ class UserEmailViewSetTestCase(WebmailTestCase):
self.assertEqual(response.status_code, 200)
self.assertIn("Message-ID", response.json()["source"])
def test_delete(self):
def test_move(self):
self.authenticate()
url = reverse("v2:webmail-email-delete")
body = {"mailbox": "INBOX", "selection": [1]}
url = reverse("v2:webmail-email-move")
body = {"source": "INBOX", "destination": "Junk", "selection": [1]}
response = self.client.post(url, body, format="json")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["count"], 1)
body = {"mailbox": "INBOX", "selection": ["truc"]}
body = {"source": "INBOX", "selection": [1]}
response = self.client.post(url, body, format="json")
self.assertEqual(response.status_code, 400)
def test_delete(self):
self.authenticate()
url = reverse("v2:webmail-email-delete")
body = {"source": "INBOX", "selection": [1]}
response = self.client.post(url, body, format="json")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["count"], 1)
body = {"source": "INBOX", "selection": ["truc"]}
response = self.client.post(url, body, format="json")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["count"], 0)
@@ -189,7 +201,7 @@ class UserEmailViewSetTestCase(WebmailTestCase):
def test_mark_as_junk(self):
self.authenticate()
url = reverse("v2:webmail-email-mark-as-junk")
body = {"mailbox": "INBOX", "selection": [1]}
body = {"source": "INBOX", "selection": [1]}
response = self.client.post(url, body, format="json")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["count"], 1)
@@ -197,7 +209,7 @@ class UserEmailViewSetTestCase(WebmailTestCase):
def test_mark_as_not_junk(self):
self.authenticate()
url = reverse("v2:webmail-email-mark-as-not-junk")
body = {"mailbox": "INBOX", "selection": [1]}
body = {"source": "INBOX", "selection": [1]}
response = self.client.post(url, body, format="json")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["count"], 1)
+17 -3
View File
@@ -9,6 +9,7 @@ from rest_framework import parsers, response, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from modoboa.lib import exceptions
from modoboa.lib.paginator import Paginator
from modoboa.lib.viewsets import HasMailbox
from modoboa.webmail import lib, serializers
@@ -123,7 +124,7 @@ 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"]:
if self.action in ["move", "delete", "mark_as_junk", "mark_as_not_junk"]:
return serializers.MoveSelectionSerializer
if self.action == "flag":
return serializers.FlagSelectionSerializer
@@ -167,18 +168,31 @@ class UserEmailViewSet(viewsets.GenericViewSet):
)
return response.Response(serializer.data)
def move_selection(self, request, destination: str) -> int:
def move_selection(self, request, destination: str | None = None) -> int:
"""Move selected messages to the given mailbox."""
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
if destination is None:
if "destination" not in serializer.validated_data:
raise exceptions.BadRequest("No destination provided")
destination = serializer.validated_data["destination"]
with lib.get_imapconnector(request) as mbc:
mbc.move(
",".join(serializer.validated_data["selection"]),
serializer.validated_data["mailbox"],
serializer.validated_data["source"],
destination,
)
return len(serializer.validated_data["selection"])
@action(
methods=["post"],
detail=False,
serializer_class=serializers.MoveSelectionSerializer,
)
def move(self, request):
count = self.move_selection(request)
return response.Response({"count": count})
@action(
methods=["post"],
detail=False,