Continuation of email scheduling feature

This commit is contained in:
Antoine Nguyen
2026-02-02 10:51:41 +01:00
parent 89c03bb26f
commit a81ba43733
27 changed files with 1149 additions and 249 deletions
+1
View File
@@ -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:
+9
View File
@@ -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}/`)
},
}
@@ -10,16 +10,29 @@
variant="flat"
@click="close"
/>
<v-btn-group color="primary" rounded="lg" density="compact" divided>
<v-btn
class="ml-2"
color="primary"
variant="tonal"
prepend-icon="mdi-send"
:loading="working"
:text="$gettext('Send')"
@click="submit"
>
</v-btn>
<v-btn size="small" icon>
<v-icon icon="mdi-chevron-down" />
<v-menu activator="parent">
<v-list>
<v-list-item
prepend-icon="mdi-send-clock-outline"
:title="$gettext('Schedule sending')"
@click="openSchedulingForm"
/>
</v-list>
</v-menu>
</v-btn>
</v-btn-group>
<v-btn
class="ml-2"
variant="tonal"
@@ -167,6 +180,12 @@
@close="closeAttachmentDialog"
/>
</v-dialog>
<v-dialog v-model="showSchedulingForm" max-width="800">
<EmailSchedulingForm
@schedule="scheduleAndSubmit"
@close="closeSchedulingForm"
/>
</v-dialog>
</div>
</template>
@@ -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,
() => {
@@ -0,0 +1,25 @@
<template>
<template v-for="(address, index) in addressesSample" :key="address">
<span v-if="address.name" :title="address.address">{{ address.name }}</span>
<span v-else>{{ address.address }}</span>
<span v-if="index < addressesSample.length - 1">, </span>
<span v-else-if="addressesSample.length < props.addresses.length"
>, ...</span
>
</template>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
addresses: {
type: Array,
default: null,
},
})
const addressesSample = computed(() =>
props.addresses.length > 2 ? props.addresses.slice(0, 2) : props.addresses
)
</script>
+152 -9
View File
@@ -24,6 +24,7 @@
@keyup.enter="submitSearch"
></v-text-field>
<v-btn
v-if="!inScheduledView"
class="ml-2"
color="error"
variant="tonal"
@@ -33,6 +34,7 @@
@click="deleteSelection"
>
</v-btn>
<template v-if="!inScheduledView">
<v-btn
v-if="$route.params.mailbox !== 'Junk'"
class="ml-2"
@@ -55,6 +57,7 @@
@click="markSelectionAsNotJunk"
>
</v-btn>
</template>
<v-btn class="ml-2" variant="tonal" icon size="small">
<v-icon icon="mdi-cog" />
<v-menu activator="parent">
@@ -80,7 +83,7 @@
@click="() => flagSelection('unflagged')"
/>
<v-list-item
v-if="$route.params.mailbox !== 'Trash'"
v-if="$route.query.mailbox === 'Trash'"
:title="$gettext('Empty mailbox')"
prepend-icon="mdi-trash-can"
@click="emptyMailbox"
@@ -113,7 +116,23 @@
</v-card>
<v-skeleton-loader v-if="loading" type="card@2"></v-skeleton-loader>
<template v-else>
<div class="emails position-absolute top-0 bottom-0 w-100 overflow-y-auto">
<v-alert
v-if="inScheduledView"
type="info"
variant="tonal"
density="compact"
class="mx-1"
>
{{
$gettext(
'Scheduled messages will be sent at the specified date and time. (visible on the right)'
)
}}
</v-alert>
<div
class="emails position-absolute bottom-0 w-100 overflow-y-auto"
:class="{ 'top-0': !inScheduledView, 'scheduling-top': inScheduledView }"
>
<template v-if="emails.results?.length">
<v-card
v-for="email in emails.results"
@@ -138,20 +157,38 @@
variant="flat"
@click="toggleFollowState(email)"
/>
<v-menu v-if="inScheduledView" location="bottom">
<template #activator="{ props }">
<v-btn
icon="mdi-dots-vertical"
v-bind="props"
size="small"
variant="text"
>
</v-btn>
</template>
<MenuItems :items="getScheduledMessageActions()" :obj="email" />
</v-menu>
<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>
<EmailAddressList :addresses="getEmailAddresses(email)" />
</div>
</div>
<v-spacer />
<div class="text-right">
<div>{{ email.date }}</div>
<div v-if="!isScheduledDateOver(email)">
{{ getEmailDate(email) }}
</div>
<div
v-else
class="text-error font-weight-bold"
style="cursor: pointer"
@click="displaySchedulingError(email)"
>
{{ getEmailDate(email) }}
</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" />
@@ -170,6 +207,28 @@
variant="tonal"
/>
</div>
<v-dialog v-model="showSchedulingForm" max-width="800">
<EmailSchedulingForm
:initial-date="selectedScheduledEmail.scheduled_datetime_raw"
@schedule="updateScheduledEmail"
@close="closeSchedulingForm"
/>
</v-dialog>
<v-dialog v-model="showSchedulingError" max-width="400">
<v-card
max-width="400"
:text="schedulingError"
:title="$gettext('Sending failure')"
>
<template #:actions>
<v-btn
class="ms-auto"
:text="$gettext('Close')"
@click="showSchedulingError = false"
></v-btn>
</template>
</v-card>
</v-dialog>
</template>
</template>
@@ -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;
}
</style>
@@ -0,0 +1,118 @@
<template>
<v-card :title="$gettext('Schedule your sending')">
<v-card-text>
<v-form ref="formRef">
<div class="d-flex">
<v-date-picker
v-model="date"
class="w-50"
:allowed-dates="allowedDates"
:rules="[rules.required]"
/>
<div
class="w-33 pt-2 text-uppercase text-overline font-weight-regular"
>
<label>{{ $gettext('Select time') }}</label>
<v-text-field
v-model="time"
variant="outlined"
class="mt-6"
:rules="[rules.required, rules.time]"
/>
</div>
</div>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn :text="$gettext('Close')" :loading="loading" @click="close" />
<v-btn
:text="$gettext('Schedule')"
color="primary"
:loading="loading"
@click="submit"
/>
</v-card-actions>
</v-card>
</template>
<script setup>
import { ref, watch } from 'vue'
import { DateTime } from 'luxon'
import { useGettext } from 'vue3-gettext'
import { useBusStore } from '@/stores'
import rules from '@/plugins/rules.js'
const props = defineProps({
initialDate: {
type: String,
default: null,
},
})
const emit = defineEmits(['close', 'schedule'])
const { $gettext } = useGettext()
const { displayNotification } = useBusStore()
const close = () => {
emit('close')
}
const allowedDates = (value) => {
const now = new Date()
return value > now
}
const validateDatetime = (value) => {
const now = new Date()
const msg = $gettext('Provided time must be one minute from now at least')
if (value <= now) {
displayNotification({ msg, type: 'error' })
return false
}
const delta = value - now
if (delta / 1000 < 60) {
displayNotification({ msg, type: 'error' })
return false
}
return true
}
const date = ref('')
const formRef = ref()
const loading = ref(false)
const time = ref('')
const now = DateTime.now()
date.value = now.toJSDate()
time.value = now.toFormat('HH:mm')
const submit = async () => {
const { valid } = await formRef.value.validate()
if (!valid) {
return
}
const datetime = date.value
const parts = time.value.split(':')
datetime.setHours(parseInt(parts[0]), parseInt(parts[1]))
if (!validateDatetime(datetime)) {
return
}
loading.value = true
emit('schedule', datetime.toISOString())
emit('close')
loading.value = false
}
watch(
() => props.initialDate,
(value) => {
if (value) {
const initialDate = DateTime.fromISO(value)
date.value = initialDate.toJSDate()
time.value = initialDate.toFormat('HH:mm')
}
},
{ immediate: true }
)
</script>
@@ -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',
}
+8
View File
@@ -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),
}
+28 -15
View File
@@ -3,15 +3,11 @@
<v-toolbar color="white">
<v-btn icon="mdi-arrow-left" size="small" variant="flat" @click="close" />
<v-btn
color="primary"
variant="tonal"
prepend-icon="mdi-reply"
@click="() => replyToEmail()"
>
<v-btn-group color="primary" rounded="lg" density="compact" divided>
<v-btn prepend-icon="mdi-reply" @click="() => replyToEmail()">
{{ $gettext('Reply') }}
<template #append>
<v-btn size="x-small" variant="plain">
</v-btn>
<v-btn size="small" icon>
<v-icon icon="mdi-chevron-down" />
<v-menu activator="parent">
<v-list>
@@ -19,15 +15,12 @@
:title="$gettext('Reply all')"
@click="() => replyToEmail(true)"
/>
<v-list-item
:title="$gettext('Forward')"
@click="forwardEmail"
/>
<v-list-item :title="$gettext('Forward')" @click="forwardEmail" />
</v-list>
</v-menu>
</v-btn>
</template>
</v-btn>
</v-btn-group>
<template v-if="$route.query.mailbox !== 'Scheduled'">
<v-btn
class="ml-2"
color="error"
@@ -60,6 +53,7 @@
@click="markEmailAsNotJunk"
>
</v-btn>
</template>
<v-btn class="ml-2" variant="tonal" icon size="small">
<v-icon icon="mdi-cog" />
<v-menu activator="parent">
@@ -140,6 +134,17 @@
</template>
</div>
</div>
<v-alert
v-if="email?.scheduled_datetime"
ref="schedulingInfo"
type="info"
variant="tonal"
density="compact"
class="mx-1"
>
{{ $gettext('Message scheduled at:') }}
{{ $date(email.scheduled_datetime) }}
</v-alert>
<iframe class="email-frame" />
</div>
<v-dialog v-model="showEmailSource" max-width="1200">
@@ -176,6 +181,7 @@ const email = ref(null)
const emailSource = ref(null)
const headers = ref(null)
const loaded = ref(false)
const schedulingInfo = ref()
const showEmailSource = ref(false)
const working = ref(false)
@@ -201,7 +207,12 @@ const close = () => {
const resizeEmailIframe = () => {
const iframe = document.querySelector('iframe')
const rect = headers.value.getBoundingClientRect()
let rect
if (schedulingInfo.value) {
rect = schedulingInfo.value.$el.getBoundingClientRect()
} else {
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`
@@ -221,9 +232,11 @@ const fetchMailContent = () => {
const iframe = document.createElement('iframe')
iframe.classList.add('email-frame')
document.querySelector('iframe').replaceWith(iframe)
if (email.value.body) {
const iframeDoc = iframe.contentDocument
iframeDoc.write(email.value.body)
iframeDoc.close()
}
loaded.value = true
nextTick(resizeEmailIframe)
})
@@ -25,6 +25,8 @@ register(maillog_jobs.update_statistics, queue_name="modoboa", cron="0 * * * *")
register(calendars_jobs.generate_rights, queue_name="privileged", cron="*/2 * * * *")
register(webmail_jobs.send_scheduled_messages, queue_name="modoboa", cron="* * * * *")
if "modoboa.amavis" in settings.MODOBOA_APPS:
register(amavis_jobs.qcleanup, queue_name="modoboa", cron="0 0 * * *")
register(amavis_jobs.amnotify, queue_name="modoboa", cron="0 12 * * *")
+1 -1
View File
@@ -43,7 +43,7 @@ def exec_cmd(cmd, sudo_user=None, pinput=None, capture_output=True, **kwargs):
return process.returncode, output
def doveadm_cmd(params, pinput=None, capture_output=True, **kwargs):
def doveadm_cmd(params: str, pinput=None, capture_output=True, **kwargs):
"""Execute doveadm command.
Run doveadm command using the current user. Set :keyword:`sudo_user` if
+37
View File
@@ -107,6 +107,43 @@ GLOBAL_PARAMETERS_STRUCT = collections.OrderedDict(
),
},
),
(
"scheduling",
{
"label": gettext_lazy("Scheduling settings"),
"params": collections.OrderedDict(
[
(
"scheduling_smtp_server",
{
"label": gettext_lazy("Server address"),
"help_text": gettext_lazy(
"Address of your SMTP server"
),
},
),
(
"scheduling_smtp_secured_mode",
{
"label": gettext_lazy("Secured connection mode"),
"help_text": gettext_lazy(
"Use a secured connection to access SMTP server"
),
},
),
(
"scheduling_smtp_port",
{
"label": gettext_lazy("Server port"),
"help_text": gettext_lazy(
"Listening port of your SMTP server"
),
},
),
]
),
},
),
]
)
+21 -1
View File
@@ -5,7 +5,27 @@ from enum import Enum
from django.utils.translation import gettext_lazy as _
MAILBOX_TYPES = ["inbox", "draft", "sent", "junk", "trash", "normal"]
MAILBOX_TYPES = ["inbox", "draft", "sent", "scheduled", "junk", "trash", "normal"]
MAILBOX_NAME_SCHEDULED = "Scheduled"
CUSTOM_HEADER_SCHEDULED_ID = "X-Scheduled-ID"
CUSTOM_HEADER_SCHEDULED_DATETIME = "X-Scheduled-Datetime"
class SchedulingState(Enum):
SCHEDULED = "scheduled"
SENDING = "sending"
SEND_ERROR = "send_error"
MOVE_ERROR = "move_error"
EMAIL_SCHEDULING_STATES = (
(SchedulingState.SCHEDULED, "scheduled"),
(SchedulingState.SENDING, "sending"),
(SchedulingState.SEND_ERROR, "send_error"),
(SchedulingState.MOVE_ERROR, "move_error"),
)
class SmtpConnectionMode(Enum):
+26
View File
@@ -0,0 +1,26 @@
"""Async (RQ) jobs definition."""
from django.utils import timezone
import django_rq
from modoboa.webmail import constants, models
from modoboa.webmail.lib import sendmail
def send_scheduled_message(message_id: int):
message = models.ScheduledMessage.objects.get(id=message_id)
if sendmail.send_scheduled_message(message):
message.delete()
def send_scheduled_messages():
messages = models.ScheduledMessage.objects.filter(
scheduled_datetime__lte=timezone.now(),
status=constants.SchedulingState.SCHEDULED,
)
queue = django_rq.get_queue("modoboa")
for message in messages:
message.state = constants.SchedulingState.SENDING
message.save()
queue.enqueue(send_scheduled_message, message_id=message.id)
+2 -1
View File
@@ -7,7 +7,7 @@ from .imapemail import ImapEmail, ReplyModifier, ForwardModifier
from .imaputils import BodyStructure, IMAPconnector, get_imapconnector, separate_mailbox
from .signature import EmailSignature
from .utils import decode_payload
from .sendmail import send_mail
from .sendmail import send_mail, schedule_email
__all__ = [
@@ -23,5 +23,6 @@ __all__ = [
"get_imapconnector",
"save_attachment",
"send_mail",
"schedule_email",
"separate_mailbox",
]
+8 -11
View File
@@ -18,6 +18,7 @@ from django.utils.translation import gettext as _
from modoboa.contacts import models as contacts_models
from modoboa.lib import u2u_decode
from modoboa.lib.email_utils import Email
from modoboa.webmail import constants
from . import imapheader
from .attachments import get_storage_path
@@ -51,13 +52,17 @@ class ImapEmail(Email):
def fetch_headers(self, raw_addresses: bool = False) -> None:
"""Fetch message headers from server."""
requested_headers = self.headernames
if self.mbox == constants.MAILBOX_NAME_SCHEDULED:
requested_headers += [(constants.CUSTOM_HEADER_SCHEDULED_DATETIME, True)]
header_names = [header[0].upper() for header in requested_headers]
msg = self.imapc.fetchmail(
self.mbox, self.mailid, readonly=False, what=" ".join(self.headers_as_list)
self.mbox, self.mailid, readonly=False, what=" ".join(header_names)
)
headers = msg[f"BODY[HEADER.FIELDS ({self.headers_as_text})]"]
headers = msg[f"BODY[HEADER.FIELDS ({' '.join(header_names)})]"]
self.fetch_body_structure(msg)
msg = email.message_from_string(headers)
for hdr in self.headernames:
for hdr in requested_headers:
label = hdr[0]
hdrvalue = self.get_header(msg, label, raw=raw_addresses)
if not hdrvalue:
@@ -110,14 +115,6 @@ class ImapEmail(Email):
self.dformat if self.dformat in self.bs.contents else fallback_fmt
)
@property
def headers_as_list(self):
return [hdr[0].upper() for hdr in self.headernames]
@property
def headers_as_text(self):
return " ".join(self.headers_as_list)
@property
def subject(self):
"""Just a shortcut to return a subject in any case."""
+25 -13
View File
@@ -27,19 +27,19 @@ __all__ = [
# according to https://en.wikipedia.org/wiki/Date_format_by_country
# and https://en.wikipedia.org/wiki/Date_and_time_representation_by_country
DATETIME_FORMATS = {
"cs": {"SHORT": "l, H:i", "LONG": "d. N Y H:i"},
"de": {"SHORT": "l, H:i", "LONG": "d. N Y H:i"},
"en": {"SHORT": "l, P", "LONG": "N j, Y P"},
"es": {"SHORT": "l, H:i", "LONG": "d. N Y H:i"},
"fr": {"SHORT": "l, H:i", "LONG": "d. N Y H:i"},
"it": {"SHORT": "l, H:i", "LONG": "d. N Y H:i"},
"ja_JP": {"SHORT": "l, P", "LONG": "N j, Y P"},
"nl": {"SHORT": "l, H:i", "LONG": "d. N Y H:i"},
"pl_PL": {"SHORT": "l, H:i", "LONG": "d. N Y H:i"},
"pt_PT": {"SHORT": "l, H:i", "LONG": "d. N Y H:i"},
"pt_BR": {"SHORT": "l, H:i", "LONG": "d. N Y H:i"},
"ru": {"SHORT": "l, H:i", "LONG": "d. N Y H:i"},
"sv": {"SHORT": "l, H:i", "LONG": "d. N Y H:i"},
"cs": {"SHORT": "l, H:i", "LONG": "d. N Y H:i", "TIME": "H:i"},
"de": {"SHORT": "l, H:i", "LONG": "d. N Y H:i", "TIME": "H:i"},
"en": {"SHORT": "l, P", "LONG": "N j, Y P", "TIME": "H:i"},
"es": {"SHORT": "l, H:i", "LONG": "d. N Y H:i", "TIME": "H:i"},
"fr": {"SHORT": "l, H:i", "LONG": "d. N Y H:i", "TIME": "H:i"},
"it": {"SHORT": "l, H:i", "LONG": "d. N Y H:i", "TIME": "H:i"},
"ja_JP": {"SHORT": "l, P", "LONG": "N j, Y P", "TIME": "H:i"},
"nl": {"SHORT": "l, H:i", "LONG": "d. N Y H:i", "TIME": "H:i"},
"pl_PL": {"SHORT": "l, H:i", "LONG": "d. N Y H:i", "TIME": "H:i"},
"pt_PT": {"SHORT": "l, H:i", "LONG": "d. N Y H:i", "TIME": "H:i"},
"pt_BR": {"SHORT": "l, H:i", "LONG": "d. N Y H:i", "TIME": "H:i"},
"ru": {"SHORT": "l, H:i", "LONG": "d. N Y H:i", "TIME": "H:i"},
"sv": {"SHORT": "l, H:i", "LONG": "d. N Y H:i", "TIME": "H:i"},
}
@@ -123,6 +123,18 @@ def parse_date(value, **kwargs):
)
def parse_scheduled_datetime(value: str, **kwargs) -> str:
result = datetime.datetime.fromisoformat(value)
current_language = get_request().user.language
if timezone.now().date().day != result.date().day:
fmt = "LONG"
else:
fmt = "TIME"
return date_format(
result, DATETIME_FORMATS.get(current_language, DATETIME_FORMATS.get("en"))[fmt]
)
def parse_message_id(value, **kwargs):
"""Parse a Message-ID: header."""
return value.strip("\n")
+31 -3
View File
@@ -13,6 +13,7 @@ from modoboa.lib import imap_utf7 # noqa
from modoboa.lib import oauth2
from modoboa.lib.exceptions import InternalError
from modoboa.parameters import tools as param_tools
from modoboa.webmail import constants
from ..exceptions import ImapError, WebmailInternalError
from .fetch_parser import FetchResponseParser
@@ -490,6 +491,11 @@ class IMAPconnector:
"type": "sent",
"label": _("Sent"),
},
{
"name": constants.MAILBOX_NAME_SCHEDULED,
"type": "scheduled",
"label": _("Scheduled"),
},
{
"name": user.parameters.get_value("trash_folder"),
"type": "trash",
@@ -581,10 +587,30 @@ class IMAPconnector:
self._cmd("COPY", msgset, self._encode_mbox_name(newmailbox))
self._cmd("STORE", msgset, "+FLAGS", r"(\Deleted \Seen)")
def push_mail(self, mbox: str, msg):
def push_mail(self, mbox: str, msg) -> int:
"""
Append a new message to a mailbox.
Return the UID of the created message.
"""
now = imaplib.Time2Internaldate(time.time())
msg = bytes(msg)
return self.m.append(self._encode_mbox_name(mbox), r"(\Seen)", now, msg)
typ, data = self.m.append(self._encode_mbox_name(mbox), r"(\Seen)", now, msg)
response = data[0].decode()
m = re.match(r"\[APPENDUID \d+ (\d+)\].+", response)
if m:
return int(m.group(1))
raise WebmailInternalError("Failed to retrieve message UID")
def delete_mail(self, mbox: str, uid: int) -> None:
"""
Delete given message (set \Deleted flag) and expunge mailbox.
Do not use directly.
"""
self.select_mailbox(mbox, False)
self._cmd("STORE", f"{uid}".encode(), "+FLAGS", r"(\Deleted)")
self._cmd("EXPUNGE")
def empty(self, mbox: str):
self.select_mailbox(mbox, False)
@@ -610,7 +636,7 @@ class IMAPconnector:
name = f"{parent}{self.hdelimiter}{name}"
typ, data = self.m.create(self._encode_mbox_name(name))
if typ == "NO":
raise WebmailInternalError(data[0])
raise WebmailInternalError(str(data[0]))
return True
def rename_folder(self, oldname: str, newname: str) -> bool:
@@ -699,6 +725,8 @@ class IMAPconnector:
submessages = [start]
mrange = str(start)
headers = "DATE FROM TO CC SUBJECT"
if mbox == constants.MAILBOX_NAME_SCHEDULED:
headers += " X-SCHEDULED-ID X-SCHEDULED-DATETIME"
query = (
f"(FLAGS BODYSTRUCTURE RFC822.SIZE BODY.PEEK[HEADER.FIELDS ({headers})])"
)
+94 -103
View File
@@ -1,116 +1,26 @@
from email.header import Header
from email.mime.image import MIMEImage
from importlib.metadata import version
import os
import smtplib
from urllib.parse import unquote, urlparse
import lxml
from django.conf import settings
from django.core import mail
from django.core.mail import EmailMessage, EmailMultiAlternatives
from modoboa.core import models as core_models
from modoboa.parameters import tools as param_tools
from modoboa.webmail.lib.attachments import create_mail_attachment
from modoboa.webmail.lib.utils import html2plaintext
from modoboa.webmail import constants, models
from modoboa.webmail.exceptions import WebmailInternalError
from modoboa.webmail.lib.utils import create_message
from . import get_imapconnector
def make_body_images_inline(body: str) -> tuple[str, list]:
"""Look for images inside the body and make them inline.
Before sending a message in HTML format, it is necessary to find
all img tags contained in the body in order to rewrite them. For
example, icons provided by CKeditor are stored on the server
filesystem and not accessible from the outside. We must embark
them as parts of the MIME message if we want recipients to
display them correctly.
:param body: the HTML body to parse
"""
html = lxml.html.fromstring(body)
parts = []
for tag in html.iter("img"):
src = tag.get("src")
if src is None:
continue
o = urlparse(src)
path = unquote(os.path.join(settings.BASE_DIR, o.path[1:]))
if not os.path.exists(path):
continue
fname = os.path.basename(path)
cid = f"{os.path.splitext(fname)[0]}@modoboa"
tag.set("src", f"cid:{cid}")
with open(path, "rb") as fp:
part = MIMEImage(fp.read())
part["Content-ID"] = f"<{cid}>"
part.replace_header("Content-Type", f'{part["Content-Type"]}; name="{fname}"')
part["Content-Disposition"] = "inline"
parts.append(part)
return lxml.html.tostring(html, encoding="unicode"), parts
def html_msg(attributes: dict) -> EmailMultiAlternatives:
"""Create a multipart message.
We attach two alternatives:
* text/html
* text/plain
"""
body = attributes.get("body")
if body:
tbody = html2plaintext(body)
body, images = make_body_images_inline(body)
else:
tbody = ""
images = []
msg = EmailMultiAlternatives()
msg.body = tbody
msg.attach_alternative(body, "text/html")
for img in images:
msg.attach(img)
return msg
def plain_msg(attributes: dict) -> EmailMessage:
"""Create a simple text message."""
msg = EmailMessage()
msg.body = attributes.get("body", "")
return msg
def format_sender_address(user: core_models.User, address: str) -> str:
"""Format address before message is sent."""
if user.first_name != "" or user.last_name != "":
return f'"{Header(user.fullname, "utf8")}" <{address}>'
return address
def create_message(user: core_models.User, attributes: dict, attachments: list):
headers = {"User-Agent": "Modoboa {}".format(version("modoboa"))}
origmsgid = attributes.get("in_reply_to")
if origmsgid:
headers.update({"References": origmsgid, "In-Reply-To": origmsgid})
mode = user.parameters.get_value("editor")
sender = format_sender_address(user, attributes["sender"])
if mode == "html":
msg = html_msg(attributes)
else:
msg = plain_msg(attributes)
msg.from_email = sender
msg.to = attributes["to"]
msg.headers = headers
for hdr in ["subject", "cc", "bcc"]:
if hdr in attributes:
setattr(msg, hdr, attributes[hdr])
for attachment in attachments:
msg.attach(create_mail_attachment(attachment))
return msg
def create_message_from_scheduled_message(sched_msg: models.ScheduledMessage):
attachments = [attachment.to_dict() for attachment in sched_msg.attachments.all()]
result = create_message(sched_msg.account, sched_msg.to_dict(), attachments)
result.extra_headers.update(
{
constants.CUSTOM_HEADER_SCHEDULED_ID: sched_msg.id,
constants.CUSTOM_HEADER_SCHEDULED_DATETIME: sched_msg.scheduled_datetime.isoformat(),
}
)
return result
def send_mail(request, attributes: dict, attachments: list) -> tuple[bool, str | None]:
@@ -161,3 +71,84 @@ def send_mail(request, attributes: dict, attachments: list) -> tuple[bool, str |
with get_imapconnector(request) as imapc:
imapc.push_mail(sentfolder, msg.message())
return True, None
def schedule_email(
request, attributes: dict, attachments: list
) -> models.ScheduledMessage:
"""Schedule a new email sending."""
message = models.ScheduledMessage(
account=request.user,
sender=attributes["sender"],
scheduled_datetime=attributes["scheduled_datetime"],
to=",".join(attributes["to"]),
subject=attributes.get("subject", ""),
body=attributes.get("body", ""),
in_reply_to=attributes.get("in_reply_to", ""),
)
for attr in ["cc", "bcc"]:
if attr in attributes:
setattr(message, attr, ",".join(attributes[attr]))
message.save()
# Save a copy of this message into an IMAP mailbox
msg = create_message_from_scheduled_message(message)
with get_imapconnector(request) as imapc:
try:
imapc.create_folder(constants.MAILBOX_NAME_SCHEDULED)
except WebmailInternalError:
pass
# TODO: deal with UID Validity
message.imap_uid = imapc.push_mail(
constants.MAILBOX_NAME_SCHEDULED, msg.message()
)
message.save()
for attachment in attachments:
mattachment = models.MessageAttachment(
message=message,
)
if "content-type" in attachment:
mattachment.content_type = attachment["content-type"]
elif "Content-Type" in attachment:
mattachment.content_type = attachment["Content-Type"]
else:
continue
mattachment.file.name = attachment["tmpname"]
if "fname" in attachment:
mattachment.filename = attachment["fname"]
mattachment.save()
return message
def send_scheduled_message(sched_msg: models.ScheduledMessage) -> bool:
"""Send a scheduled message using configured SMTP server."""
msg = create_message_from_scheduled_message(sched_msg)
conf = dict(param_tools.get_global_parameters("webmail"))
options = {
"host": conf["scheduling_smtp_server"],
"port": conf["scheduling_smtp_port"],
}
if conf["scheduling_smtp_secured_mode"] == "ssl":
options.update({"use_ssl": True})
elif conf["scheduling_smtp_secured_mode"] == "starttls":
options.update({"use_tls": True})
try:
with mail.get_connection(**options) as connection:
msg.connection = connection
msg.send()
except (smtplib.SMTPResponseException, smtplib.SMTPRecipientsRefused) as inst:
if isinstance(inst, smtplib.SMTPRecipientsRefused):
error = ", ".join(
[f"{rcpt}: {error}" for rcpt, error in inst.recipients.items()]
)
else:
error = str(inst.smtp_error)
sched_msg.status = constants.SchedulingState.SEND_ERROR
sched_msg.error = error
sched_msg.save()
return False
return sched_msg.delete_imap_copy()
+109
View File
@@ -1,7 +1,19 @@
"""Misc. utilities."""
from email.header import Header
from email.mime.image import MIMEImage
from importlib.metadata import version
import os
from urllib.parse import unquote, urlparse
import lxml
from django.conf import settings
from django.core.mail import EmailMessage, EmailMultiAlternatives
from modoboa.core import models as core_models
from modoboa.webmail.lib.attachments import create_mail_attachment
def html2plaintext(content: str) -> str:
"""HTML to plain text translation.
@@ -43,3 +55,100 @@ def decode_payload(encoding, payload):
return quopri.decodestring(payload)
return payload
def make_body_images_inline(body: str) -> tuple[str, list]:
"""Look for images inside the body and make them inline.
Before sending a message in HTML format, it is necessary to find
all img tags contained in the body in order to rewrite them. For
example, icons provided by CKeditor are stored on the server
filesystem and not accessible from the outside. We must embark
them as parts of the MIME message if we want recipients to
display them correctly.
:param body: the HTML body to parse
"""
html = lxml.html.fromstring(body)
parts = []
for tag in html.iter("img"):
src = tag.get("src")
if src is None:
continue
o = urlparse(src)
path = unquote(os.path.join(settings.BASE_DIR, o.path[1:]))
if not os.path.exists(path):
continue
fname = os.path.basename(path)
cid = f"{os.path.splitext(fname)[0]}@modoboa"
tag.set("src", f"cid:{cid}")
with open(path, "rb") as fp:
part = MIMEImage(fp.read())
part["Content-ID"] = f"<{cid}>"
part.replace_header("Content-Type", f'{part["Content-Type"]}; name="{fname}"')
part["Content-Disposition"] = "inline"
parts.append(part)
return lxml.html.tostring(html, encoding="unicode"), parts
def html_msg(content: str) -> EmailMultiAlternatives:
"""Create a multipart message.
We attach two alternatives:
* text/html
* text/plain
"""
if content:
body = html2plaintext(content)
body, images = make_body_images_inline(body)
else:
body = ""
images = []
msg = EmailMultiAlternatives()
msg.body = body
msg.attach_alternative(body, "text/html")
for img in images:
msg.attach(img)
return msg
def plain_msg(content: str) -> EmailMessage:
"""Create a simple text message."""
msg = EmailMessage()
msg.body = content
return msg
def format_sender_address(user: core_models.User, address: str) -> str:
"""Format address before message is sent."""
if user.first_name != "" or user.last_name != "":
return f'"{Header(user.fullname, "utf8")}" <{address}>'
return address
def create_message(
user: core_models.User, attributes: dict, attachments: list
) -> EmailMessage:
"""Create an EmailMessage instance ready to be sent."""
extra_headers = {"User-Agent": "Modoboa {}".format(version("modoboa"))}
headers = {}
origmsgid = attributes.get("in_reply_to")
if origmsgid:
headers.update({"References": origmsgid, "In-Reply-To": origmsgid})
mode = user.parameters.get_value("editor")
sender = format_sender_address(user, attributes["sender"])
if mode == "html":
msg = html_msg(attributes.get("body", ""))
else:
msg = plain_msg(attributes.get("body", ""))
msg.from_email = sender
msg.to = attributes["to"]
msg.headers = headers
msg.extra_headers = extra_headers
for hdr in ["subject", "cc", "bcc"]:
if hdr in attributes:
setattr(msg, hdr, attributes[hdr])
for attachment in attachments:
msg.attach(create_mail_attachment(attachment))
return msg
+102
View File
@@ -0,0 +1,102 @@
# Generated by Django 5.2.10 on 2026-01-31 10:44
import django.db.models.deletion
import modoboa.webmail.constants
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="ScheduledMessage",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("sender", models.CharField(max_length=255)),
("subject", models.CharField(max_length=255)),
("body", models.TextField()),
("to", models.TextField()),
("cc", models.TextField(blank=True, null=True)),
("bcc", models.TextField(blank=True, null=True)),
(
"in_reply_to",
models.CharField(blank=True, max_length=200, null=True),
),
("scheduled_datetime", models.DateTimeField()),
("imap_uid", models.IntegerField(blank=True, null=True)),
(
"status",
models.CharField(
choices=[
(
modoboa.webmail.constants.SchedulingState["SCHEDULED"],
"scheduled",
),
(
modoboa.webmail.constants.SchedulingState["SENDING"],
"sending",
),
(
modoboa.webmail.constants.SchedulingState["SEND_ERROR"],
"send_error",
),
(
modoboa.webmail.constants.SchedulingState["MOVE_ERROR"],
"move_error",
),
],
default=modoboa.webmail.constants.SchedulingState["SCHEDULED"],
max_length=15,
),
),
("error", models.CharField(blank=True, max_length=255, null=True)),
(
"account",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
),
migrations.CreateModel(
name="MessageAttachment",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("file", models.FileField(upload_to="")),
("content_type", models.CharField(max_length=150)),
("filename", models.CharField(max_length=255)),
(
"message",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="attachments",
to="webmail.scheduledmessage",
),
),
],
),
]
+96
View File
@@ -0,0 +1,96 @@
"""
Webmail related models.
"""
from django.core.mail import EmailMessage
from django.db import models
from modoboa.lib.sysutils import doveadm_cmd
from modoboa.webmail import constants
from modoboa.webmail.lib.utils import create_message
class ScheduledMessage(models.Model):
account = models.ForeignKey("core.User", on_delete=models.CASCADE)
sender = models.CharField(max_length=255)
subject = models.CharField(max_length=255)
body = models.TextField()
to = models.TextField()
cc = models.TextField(blank=True, null=True)
bcc = models.TextField(blank=True, null=True)
in_reply_to = models.CharField(max_length=200, blank=True, null=True)
scheduled_datetime = models.DateTimeField()
imap_uid = models.IntegerField(blank=True, null=True)
status = models.CharField(
choices=constants.EMAIL_SCHEDULING_STATES,
default=constants.SchedulingState.SCHEDULED,
max_length=15,
db_index=True,
)
error = models.CharField(max_length=255, null=True, blank=True)
def __str__(self):
return f"{self.subject} - {self.sender.username} - {self.scheduled_datetime}"
def to_dict(self) -> dict:
result = {
"in_reply_to": self.in_reply_to,
"sender": self.sender,
"to": self.to.split(","),
}
if self.subject:
result["subject"] = self.subject
if self.body:
result["body"] = self.body
for hdr in ["cc", "bcc"]:
if getattr(self, hdr):
result[hdr] = getattr(self, hdr).split(",")
return result
def delete_imap_copy(self) -> bool:
"""Move IMAP message to Sent folder using doveadm."""
# TODO: use doveadm HTTP API when dovecot is not local
sent_folder = self.account.parameters.get_value("sent_folder")
code, output = doveadm_cmd(
f"move -u {self.account.email} {sent_folder} "
f"mailbox {constants.MAILBOX_NAME_SCHEDULED} "
f"header {constants.CUSTOM_HEADER_SCHEDULED_ID} {self.id}"
)
if code:
self.status = constants.SchedulingState.MOVE_ERROR
self.error = output
self.save()
return False
return True
def to_email_message(self) -> EmailMessage:
"""Convert this scheduled message to an EmailMessage instance."""
attachments = [attachment.to_dict() for attachment in self.attachments.all()]
result = create_message(self.account, self.to_dict(), attachments)
result.extra_headers.update(
{
constants.CUSTOM_HEADER_SCHEDULED_ID: self.id,
constants.CUSTOM_HEADER_SCHEDULED_DATETIME: self.scheduled_datetime.isoformat(),
}
)
return result
class MessageAttachment(models.Model):
message = models.ForeignKey(
ScheduledMessage, on_delete=models.CASCADE, related_name="attachments"
)
file = models.FileField()
content_type = models.CharField(max_length=150)
filename = models.CharField(max_length=255)
def __str__(self):
return f"Attachment for {self.message.subject}"
def to_dict(self) -> dict:
return {
"content-type": self.content_type,
"tmpname": self.file.name,
"fname": self.filename,
}
+84 -4
View File
@@ -1,10 +1,14 @@
"""Webmail serializers."""
from django.utils.translation import gettext as _
from django.utils import timezone
from rest_framework import serializers
from modoboa.lib import email_utils
from modoboa.webmail import constants
from modoboa.webmail import constants, models
from modoboa.webmail.lib import imapheader, signature
from modoboa.webmail.lib.imaputils import get_imapconnector
class GlobalParametersSerializer(serializers.Serializer):
@@ -22,6 +26,13 @@ class GlobalParametersSerializer(serializers.Serializer):
smtp_port = serializers.IntegerField(default=25)
smtp_authentication = serializers.BooleanField(default=False)
scheduling_smtp_server = serializers.CharField(default="127.0.0.1")
scheduling_smtp_secured_mode = serializers.ChoiceField(
default=constants.SmtpConnectionMode.NONE.value,
choices=constants.SMTP_CONNECTION_MODES,
)
scheduling_smtp_port = serializers.IntegerField(default=25)
class UserPreferencesSerializer(serializers.Serializer):
displaymode = serializers.ChoiceField(
@@ -116,6 +127,7 @@ class EmailHeadersSerializer(serializers.Serializer):
imapid = serializers.CharField()
subject = serializers.SerializerMethodField()
from_address = serializers.SerializerMethodField()
recipients = serializers.SerializerMethodField()
date = serializers.SerializerMethodField()
size = serializers.IntegerField()
answered = serializers.BooleanField(default=False)
@@ -124,6 +136,14 @@ class EmailHeadersSerializer(serializers.Serializer):
flagged = serializers.BooleanField(default=False)
style = serializers.CharField(required=False)
scheduled_id = serializers.IntegerField(
source=constants.CUSTOM_HEADER_SCHEDULED_ID, required=False
)
scheduled_datetime = serializers.SerializerMethodField(required=False)
scheduled_datetime_raw = serializers.CharField(
source=constants.CUSTOM_HEADER_SCHEDULED_DATETIME, required=False
)
def get_subject(self, obj) -> str:
if "Subject" in obj:
return imapheader.parse_subject(obj["Subject"])
@@ -134,11 +154,23 @@ class EmailHeadersSerializer(serializers.Serializer):
return imapheader.parse_address(obj["From"])
return ""
def get_recipients(self, obj):
if "To" in obj:
return imapheader.parse_address_list(obj["To"])
return ""
def get_date(self, obj) -> str:
if "Date" in obj:
return imapheader.parse_date(obj["Date"])
return ""
def get_scheduled_datetime(self, obj) -> str:
if constants.CUSTOM_HEADER_SCHEDULED_DATETIME in obj:
return imapheader.parse_scheduled_datetime(
obj[constants.CUSTOM_HEADER_SCHEDULED_DATETIME]
)
return ""
class PaginatedEmailListSerializer(serializers.Serializer):
@@ -162,6 +194,11 @@ class EmailSerializer(serializers.Serializer):
reply_to = serializers.EmailField(source="Reply_To", required=False)
attachments = serializers.SerializerMethodField()
scheduled_datetime = serializers.DateTimeField(
source=constants.CUSTOM_HEADER_SCHEDULED_DATETIME.replace("-", "_"),
required=False,
)
def get_attachments(self, email):
result = []
if email.attachments:
@@ -177,6 +214,13 @@ class MoveSelectionSerializer(serializers.Serializer):
destination = serializers.CharField(required=False)
selection = serializers.ListField(child=serializers.CharField())
def validate_source(self, value):
if value == constants.MAILBOX_NAME_SCHEDULED:
raise serializers.ValidationError(
_("Moving scheduled messages is forbidden")
)
return value
def validate_selection(self, value):
return [item for item in value if item.isdigit()]
@@ -198,7 +242,25 @@ class FlagSelectionSerializer(serializers.Serializer):
return [item for item in value if item.isdigit()]
class SendEmailSerializer(serializers.Serializer):
class ScheduledDatetimeMixin:
"""Mixin to inject scheduled_datetime logic into a serializer."""
scheduled_datetime = serializers.DateTimeField(required=False)
def validate_scheduled_datetime(self, value):
if value <= timezone.now():
raise serializers.ValidationError(
_("Only datetime in the future is allowed")
)
delta = value - timezone.now()
if delta.total_seconds() < 60:
raise serializers.ValidationError(
_("Provived datetime must be one minute in the future at least")
)
return value
class SendEmailSerializer(ScheduledDatetimeMixin, serializers.Serializer):
sender = serializers.EmailField()
to = serializers.ListField(child=serializers.EmailField())
@@ -209,8 +271,7 @@ class SendEmailSerializer(serializers.Serializer):
in_reply_to = serializers.CharField(required=False)
def validate_sender(self, value):
return value
scheduled_datetime = serializers.DateTimeField(required=False)
def validate_to(self, value):
return email_utils.prepare_addresses(value, "envelope")
@@ -239,3 +300,22 @@ class ComposeSessionSerializer(serializers.Serializer):
class AllowedSenderSerializer(serializers.Serializer):
address = serializers.EmailField()
class ScheduledMessageSerializer(ScheduledDatetimeMixin, serializers.ModelSerializer):
class Meta:
model = models.ScheduledMessage
fields = ["error", "scheduled_datetime"]
read_only_fields = ["error"]
def update(self, instance, validated_data):
instance = super().update(instance, validated_data)
message = instance.to_email_message()
with get_imapconnector(self.context["request"]) as imapc:
imapc.delete_mail(constants.MAILBOX_NAME_SCHEDULED, instance.imap_uid)
instance.imap_uid = imapc.push_mail(
constants.MAILBOX_NAME_SCHEDULED, message.message()
)
instance.save()
return instance
+5
View File
@@ -13,5 +13,10 @@ router.register(
viewsets.ComposeSessionViewSet,
basename="webmail-compose-session",
)
router.register(
"scheduled-messages",
viewsets.ScheduledMessageViewSet,
basename="webmail-scheduled-message",
)
urlpatterns = router.urls
+34 -5
View File
@@ -5,14 +5,14 @@ from django.core.validators import validate_email
from django.http import Http404, HttpResponse
from django.utils.translation import gettext as _
from rest_framework import parsers, response, viewsets
from rest_framework import mixins, 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
from modoboa.webmail import lib, models, serializers
from modoboa.webmail.lib import attachments
@@ -156,11 +156,12 @@ class UserEmailViewSet(viewsets.GenericViewSet):
content = imapc.fetch(page.id_start, page.id_stop, mbox=mailbox)
content = [dict(msg) for msg in content]
first_index = (page_num - 1) * messages_per_page + 1
serializer = self.get_serializer(
{
"count": total,
"first_index": page_num * messages_per_page,
"last_index": (page_num * messages_per_page) + len(content),
"first_index": first_index,
"last_index": first_index + len(content) - 1,
"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": content,
@@ -376,10 +377,38 @@ class ComposeSessionViewSet(viewsets.GenericViewSet):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
manager = attachments.ComposeSessionManager(request.user.username)
if serializer.validated_data["scheduled_datetime"]:
lib.schedule_email(
request,
serializer.validated_data,
manager.get_content(pk)["attachments"],
)
return response.Response(status=204)
else:
status, error = lib.send_mail(
request, serializer.validated_data, manager.get_content(pk)["attachments"]
request,
serializer.validated_data,
manager.get_content(pk)["attachments"],
)
if status:
attachments.remove_attachments_and_session(manager, pk)
return response.Response(status=204)
return response.Response({"error": error}, status=400)
class ScheduledMessageViewSet(
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
permission_classes = (IsAuthenticated, HasMailbox)
serializer_class = serializers.ScheduledMessageSerializer
def get_queryset(self):
return models.ScheduledMessage.objects.filter(account=self.request.user)
def perform_destroy(self, instance):
if instance.delete_imap_copy():
instance.delete()
+3
View File
@@ -7,6 +7,7 @@ from modoboa.calendars import jobs as calendars_jobs
from modoboa.core import jobs as core_jobs
from modoboa.admin import jobs as admin_jobs
from modoboa.maillog import jobs as maillog_jobs
from modoboa.webmail import jobs as webmail_jobs
register(core_jobs.clean_logs, queue_name="modoboa", cron="0 0 * * *")
@@ -26,6 +27,8 @@ register(maillog_jobs.update_statistics, queue_name="modoboa", cron="0 * * * *")
register(calendars_jobs.generate_rights, queue_name="modoboa", cron="*/2 * * * *")
register(webmail_jobs.send_scheduled_messages, queue_name="modoboa", cron="* * * * *")
if "modoboa.amavis" in settings.MODOBOA_APPS:
register(amavis_jobs.qcleanup, queue_name="modoboa", cron="0 0 * * *")
register(amavis_jobs.amnotify, queue_name="modoboa", cron="0 12 * * *")