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
+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
class="ml-2"
color="primary"
variant="tonal"
prepend-icon="mdi-send"
:loading="working"
:text="$gettext('Send')"
@click="submit"
>
</v-btn>
<v-btn-group color="primary" rounded="lg" density="compact" divided>
<v-btn
class="ml-2"
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>
+174 -31
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,28 +34,30 @@
@click="deleteSelection"
>
</v-btn>
<v-btn
v-if="$route.params.mailbox !== 'Junk'"
class="ml-2"
color="warning"
variant="tonal"
icon="mdi-fire"
size="small"
:loading="working"
@click="markSelectionAsJunk"
>
</v-btn>
<v-btn
v-else
class="ml-2"
color="success"
variant="tonal"
icon="mdi-thumb-up"
size="small"
:loading="working"
@click="markSelectionAsNotJunk"
>
</v-btn>
<template v-if="!inScheduledView">
<v-btn
v-if="$route.params.mailbox !== 'Junk'"
class="ml-2"
color="warning"
variant="tonal"
icon="mdi-fire"
size="small"
:loading="working"
@click="markSelectionAsJunk"
>
</v-btn>
<v-btn
v-else
class="ml-2"
color="success"
variant="tonal"
icon="mdi-thumb-up"
size="small"
:loading="working"
@click="markSelectionAsNotJunk"
>
</v-btn>
</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),
}
+74 -61
View File
@@ -3,63 +3,57 @@
<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()"
>
{{ $gettext('Reply') }}
<template #append>
<v-btn size="x-small" variant="plain">
<v-icon icon="mdi-chevron-down" />
<v-menu activator="parent">
<v-list>
<v-list-item
:title="$gettext('Reply all')"
@click="() => replyToEmail(true)"
/>
<v-list-item
:title="$gettext('Forward')"
@click="forwardEmail"
/>
</v-list>
</v-menu>
</v-btn>
</template>
</v-btn>
<v-btn
class="ml-2"
color="error"
variant="tonal"
icon="mdi-trash-can"
size="small"
:loading="working"
@click="deleteEmail"
>
</v-btn>
<v-btn
v-if="route.params.mailbox !== 'Junk'"
class="ml-2"
color="warning"
variant="tonal"
icon="mdi-fire"
size="small"
:loading="working"
@click="markEmailAsJunk"
>
</v-btn>
<v-btn
v-else
class="ml-2"
color="success"
variant="tonal"
icon="mdi-thumb-up"
size="small"
:loading="working"
@click="markEmailAsNotJunk"
>
</v-btn>
<v-btn-group color="primary" rounded="lg" density="compact" divided>
<v-btn prepend-icon="mdi-reply" @click="() => replyToEmail()">
{{ $gettext('Reply') }}
</v-btn>
<v-btn size="small" icon>
<v-icon icon="mdi-chevron-down" />
<v-menu activator="parent">
<v-list>
<v-list-item
:title="$gettext('Reply all')"
@click="() => replyToEmail(true)"
/>
<v-list-item :title="$gettext('Forward')" @click="forwardEmail" />
</v-list>
</v-menu>
</v-btn>
</v-btn-group>
<template v-if="$route.query.mailbox !== 'Scheduled'">
<v-btn
class="ml-2"
color="error"
variant="tonal"
icon="mdi-trash-can"
size="small"
:loading="working"
@click="deleteEmail"
>
</v-btn>
<v-btn
v-if="route.params.mailbox !== 'Junk'"
class="ml-2"
color="warning"
variant="tonal"
icon="mdi-fire"
size="small"
:loading="working"
@click="markEmailAsJunk"
>
</v-btn>
<v-btn
v-else
class="ml-2"
color="success"
variant="tonal"
icon="mdi-thumb-up"
size="small"
:loading="working"
@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)
const iframeDoc = iframe.contentDocument
iframeDoc.write(email.value.body)
iframeDoc.close()
if (email.value.body) {
const iframeDoc = iframe.contentDocument
iframeDoc.write(email.value.body)
iframeDoc.close()
}
loaded.value = true
nextTick(resizeEmailIframe)
})