From a81ba43733a890e2c07ffd4f1ddd013451fb4177 Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Mon, 2 Feb 2026 10:51:41 +0100 Subject: [PATCH] Continuation of email scheduling feature --- docker-compose.yml | 1 + frontend/src/api/webmail.js | 9 + .../components/webmail/ComposeEmailForm.vue | 66 +++++- .../components/webmail/EmailAddressList.vue | 25 +++ frontend/src/components/webmail/EmailList.vue | 205 +++++++++++++++--- .../webmail/EmailSchedulingForm.vue | 118 ++++++++++ .../src/components/webmail/MailboxList.vue | 1 + frontend/src/plugins/rules.js | 8 + frontend/src/views/webmail/EmailView.vue | 135 ++++++------ .../commands/templates/cron_config.py.tpl | 2 + modoboa/lib/sysutils.py | 2 +- modoboa/webmail/app_settings.py | 37 ++++ modoboa/webmail/constants.py | 22 +- modoboa/webmail/jobs.py | 26 +++ modoboa/webmail/lib/__init__.py | 3 +- modoboa/webmail/lib/imapemail.py | 19 +- modoboa/webmail/lib/imapheader.py | 38 ++-- modoboa/webmail/lib/imaputils.py | 34 ++- modoboa/webmail/lib/sendmail.py | 197 ++++++++--------- modoboa/webmail/lib/utils.py | 109 ++++++++++ modoboa/webmail/migrations/0001_initial.py | 102 +++++++++ modoboa/webmail/migrations/__init__.py | 0 modoboa/webmail/models.py | 96 ++++++++ modoboa/webmail/serializers.py | 88 +++++++- modoboa/webmail/urls.py | 5 + modoboa/webmail/viewsets.py | 47 +++- test_project/test_project/cron_config.py | 3 + 27 files changed, 1149 insertions(+), 249 deletions(-) create mode 100644 frontend/src/components/webmail/EmailAddressList.vue create mode 100644 frontend/src/components/webmail/EmailSchedulingForm.vue create mode 100644 modoboa/webmail/jobs.py create mode 100644 modoboa/webmail/migrations/0001_initial.py create mode 100644 modoboa/webmail/migrations/__init__.py create mode 100644 modoboa/webmail/models.py diff --git a/docker-compose.yml b/docker-compose.yml index 4b07aa5bb..721a41ebe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,6 +41,7 @@ services: sh -c "cd test_project; python3 manage.py rqworker dkim modoboa dovecot" volumes: - .:/code + - ./docker/radicale_config:/etc/radicale rq-scheduler: build: diff --git a/frontend/src/api/webmail.js b/frontend/src/api/webmail.js index 6ee58d7e7..8f1191fa4 100644 --- a/frontend/src/api/webmail.js +++ b/frontend/src/api/webmail.js @@ -128,4 +128,13 @@ export default { `/webmail/compose-sessions/${sessionUid}/attachments/${name}/` ) }, + getScheduledMessage(messageId) { + return repository.get(`/webmail/scheduled-messages/${messageId}/`) + }, + updateScheduledMessage(messageId, data) { + return repository.patch(`/webmail/scheduled-messages/${messageId}/`, data) + }, + deleteScheduledMessage(messageId) { + return repository.delete(`/webmail/scheduled-messages/${messageId}/`) + }, } diff --git a/frontend/src/components/webmail/ComposeEmailForm.vue b/frontend/src/components/webmail/ComposeEmailForm.vue index efbab82b5..55a57d156 100644 --- a/frontend/src/components/webmail/ComposeEmailForm.vue +++ b/frontend/src/components/webmail/ComposeEmailForm.vue @@ -10,16 +10,29 @@ variant="flat" @click="close" /> - - + + + + + + + + + + + + + + + + @@ -178,6 +197,7 @@ import { useAuthStore, useBusStore } from '@/stores' import debounce from 'debounce' import AttachmentsDialog from '@/components/webmail/AttachmentsDialog' import BodyEditor from '@/components/webmail/BodyEditor' +import EmailSchedulingForm from '@/components/webmail/EmailSchedulingForm' import rules from '@/plugins/rules' import api from '@/api/webmail' import contactsApi from '@/api/contacts' @@ -209,6 +229,7 @@ const formRef = ref() const showAttachmentsDialog = ref(false) const showCcField = ref(false) const showBccField = ref(false) +const showSchedulingForm = ref(false) const working = ref(false) const close = () => { @@ -275,7 +296,10 @@ const submit = async () => { try { await api.sendEmailFromComposeSession(route.query.uid, body) router.push({ name: 'MailboxView' }) - displayNotification({ msg: $gettext('Email sent') }) + const msg = body.scheduled_datetime + ? $gettext('Email scheduled') + : $gettext('Email seng') + displayNotification({ msg }) } catch (error) { console.log(error) } finally { @@ -283,6 +307,11 @@ const submit = async () => { } } +const scheduleAndSubmit = async (datetime) => { + form.value.scheduled_datetime = datetime + await submit() +} + const openAttachmentsDialog = () => { showAttachmentsDialog.value = true } @@ -316,6 +345,21 @@ const initialize = (body) => { editorMode.value = body.editor_format } +const openSchedulingForm = () => { + if (!form.value.to?.length) { + displayNotification({ + msg: $gettext('You must provide one recipient at least'), + type: 'info', + }) + return + } + showSchedulingForm.value = true +} + +const closeSchedulingForm = () => { + showSchedulingForm.value = false +} + watch( () => props.originalEmail, () => { diff --git a/frontend/src/components/webmail/EmailAddressList.vue b/frontend/src/components/webmail/EmailAddressList.vue new file mode 100644 index 000000000..f9506e78b --- /dev/null +++ b/frontend/src/components/webmail/EmailAddressList.vue @@ -0,0 +1,25 @@ + + + diff --git a/frontend/src/components/webmail/EmailList.vue b/frontend/src/components/webmail/EmailList.vue index 4c7f2d8e3..e27a1ba39 100644 --- a/frontend/src/components/webmail/EmailList.vue +++ b/frontend/src/components/webmail/EmailList.vue @@ -24,6 +24,7 @@ @keyup.enter="submitSearch" > - - - - + @@ -80,7 +83,7 @@ @click="() => flagSelection('unflagged')" /> @@ -178,6 +237,10 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { useRouter, useRoute } from 'vue-router' import { useGettext } from 'vue3-gettext' import { useBusStore, useWebmailStore } from '@/stores' +import { DateTime } from 'luxon' +import EmailAddressList from './EmailAddressList.vue' +import EmailSchedulingForm from './EmailSchedulingForm.vue' +import MenuItems from '@/components/tools/MenuItems.vue' import api from '@/api/webmail' const props = defineProps({ @@ -193,11 +256,15 @@ const webmailStore = useWebmailStore() const router = useRouter() const route = useRoute() +const selectedScheduledEmail = ref(null) const loading = ref(false) const emails = ref({}) const page = ref(1) +const schedulingError = ref('') const search = ref('') const selectAll = ref(false) +const showSchedulingError = ref(false) +const showSchedulingForm = ref(false) const working = ref(false) let intervalId = null @@ -206,6 +273,79 @@ const currentMailbox = computed(() => { return route.query.mailbox || 'INBOX' }) +const inScheduledView = computed(() => props.mailbox === 'Scheduled') + +const getScheduledMessageActions = () => { + return [ + { + label: $gettext('Reschedule'), + icon: 'mdi-send-clock-outline', + onClick: reScheduleMessage, + }, + { + label: $gettext('Delete'), + icon: 'mdi-delete-outline', + onClick: deleteScheduledMessage, + color: 'red', + }, + ] +} + +const getEmailAddresses = (email) => { + if (inScheduledView.value) { + return email.recipients + } + return [email.from_address] +} + +const getEmailDate = (email) => { + if (inScheduledView.value) { + return email.scheduled_datetime + } + return email.date +} + +const isScheduledDateOver = (email) => { + if (!inScheduledView.value || !email.scheduled_datetime_raw) return false + const date = DateTime.fromISO(email.scheduled_datetime_raw) + return date < DateTime.now() +} + +const reScheduleMessage = (email) => { + selectedScheduledEmail.value = email + showSchedulingForm.value = true +} + +const updateScheduledEmail = async (datetime) => { + const data = { + scheduled_datetime: datetime, + } + await api.updateScheduledMessage( + selectedScheduledEmail.value.scheduled_id, + data + ) + fetchEmails() + displayNotification({ msg: $gettext('Scheduling updated') }) +} + +const closeSchedulingForm = () => { + showSchedulingForm.value = false +} + +const deleteScheduledMessage = async (email) => { + await api.deleteScheduledMessage(email.scheduled_id) + fetchEmails() + displayNotification({ + msg: $gettext('Scheduled canceled and message move to trash folder'), + }) +} + +const displaySchedulingError = async (email) => { + const resp = await api.getScheduledMessage(email.scheduled_id) + schedulingError.value = resp.data.error + showSchedulingError.value = true +} + const openEmail = (emailid) => { router.push({ name: 'EmailView', @@ -380,4 +520,7 @@ watch(page, () => { .clickable { cursor: pointer; } +.scheduling-top { + top: 45px; +} diff --git a/frontend/src/components/webmail/EmailSchedulingForm.vue b/frontend/src/components/webmail/EmailSchedulingForm.vue new file mode 100644 index 000000000..68b06da00 --- /dev/null +++ b/frontend/src/components/webmail/EmailSchedulingForm.vue @@ -0,0 +1,118 @@ + + + diff --git a/frontend/src/components/webmail/MailboxList.vue b/frontend/src/components/webmail/MailboxList.vue index 6fb7d58f1..bdc9a60a5 100644 --- a/frontend/src/components/webmail/MailboxList.vue +++ b/frontend/src/components/webmail/MailboxList.vue @@ -106,6 +106,7 @@ const iconByMailboxType = { draft: 'mdi-file', junk: 'mdi-fire', sent: 'mdi-email-fast-outline', + scheduled: 'mdi-send-clock-outline', trash: 'mdi-trash-can-outline', normal: 'mdi-folder-outline', } diff --git a/frontend/src/plugins/rules.js b/frontend/src/plugins/rules.js index b8231f5ee..c1bf084a7 100644 --- a/frontend/src/plugins/rules.js +++ b/frontend/src/plugins/rules.js @@ -22,6 +22,13 @@ function samePassword(value, confirmation) { return value === confirmation } +function validateTime(value) { + if (!/^([01]\d|2[0-3]):?([0-5]\d)$/.test(value)) { + return $gettext('Not a valid time') + } + return true +} + export default { required: (value) => (value !== '' && @@ -43,4 +50,5 @@ export default { samePassword(value, confirmation) || $gettext('Passwords mismatch'), portNumber: (value) => (value > 0 && value < 65536) || $gettext('Invalid port number'), + time: (value) => validateTime(value), } diff --git a/frontend/src/views/webmail/EmailView.vue b/frontend/src/views/webmail/EmailView.vue index 9261b70c3..4646ff3f2 100644 --- a/frontend/src/views/webmail/EmailView.vue +++ b/frontend/src/views/webmail/EmailView.vue @@ -3,63 +3,57 @@ - - {{ $gettext('Reply') }} - - - - - - - - + + + {{ $gettext('Reply') }} + + + + + + + + + + + + @@ -140,6 +134,17 @@ + + {{ $gettext('Message scheduled at:') }} + {{ $date(email.scheduled_datetime) }} +