mirror of
https://github.com/jwinterm/modoboa.git
synced 2026-10-17 05:41:25 +02:00
Continuation of email scheduling feature
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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 * * *")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
},
|
||||
),
|
||||
]
|
||||
),
|
||||
},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 * * *")
|
||||
|
||||
Reference in New Issue
Block a user