New webmail features.

This commit is contained in:
Antoine Nguyen
2025-05-01 18:02:43 +02:00
parent 6ee4ea1662
commit b30efe6a6c
28 changed files with 1321 additions and 252 deletions
+3
View File
@@ -38,3 +38,6 @@ modoboa.kdev4
test_project/cert.crt
test_project/cert.key
test_project/envfile
# Docker
docker/collections
+1
View File
@@ -108,6 +108,7 @@ services:
dockerfile: docker/Dockerfile.radicale.dev
volumes:
- ./docker/radicale_config:/etc/radicale
- ./docker/collections:/collections
policyd:
container_name: modoboa_policyd
+45
View File
@@ -8,6 +8,26 @@ export default {
}
return repository.get('/webmail/mailboxes/', { params })
},
getUserMailboxQuota(mailbox) {
const params = { mailbox }
return repository.get('/webmail/mailboxes/quota/', { params })
},
createUserMailbox(body) {
return repository.post('/webmail/mailboxes/', body)
},
renameUserMailbox(body) {
return repository.post('/webmail/mailboxes/rename/', body)
},
compressUserMailbox(body) {
return repository.post('/webmail/mailboxes/compress/', body)
},
deleteUserMailbox(body) {
return repository.post('/webmail/mailboxes/delete/', body)
},
emptyUserMailbox(mailbox) {
const body = { name: mailbox }
return repository.post('/webmail/mailboxes/empty/', body)
},
getMailboxEmails(mailbox, options) {
const params = {
mailbox,
@@ -73,4 +93,29 @@ export default {
sendEmail(body) {
return repository.post('/webmail/emails/send/', body)
},
createComposeSession() {
return repository.post('/webmail/compose-sessions/')
},
getAllowedSenders() {
return repository.get('/webmail/compose-sessions/allowed_senders/')
},
getUploadedAttachments(sessionUid) {
return repository.get(
`/webmail/compose-sessions/${sessionUid}/attachments/`
)
},
uploadAttachment(sessionUid, body) {
return repository.post(
`/webmail/compose-sessions/${sessionUid}/attachments/`,
body,
{
headers: { 'Content-Type': 'multipart/form-data' },
}
)
},
removeAttachment(sessionUid, name) {
return repository.delete(
`/webmail/compose-sessions/${sessionUid}/attachments/${name}/`
)
},
}
+3 -2
View File
@@ -1,6 +1,7 @@
<template>
<div>
<div ref="toolbar" class="mt-4">
<slot name="append-toolbar"></slot>
<v-btn-toggle
v-if="editor"
v-model="selection"
@@ -93,7 +94,7 @@
</div>
<EditorContent
:editor="editor"
class="border-sm d-flex flex-column flex-grow-1"
class="border-sm d-flex flex-column flex-grow-1 pa-4"
/>
</div>
</template>
@@ -143,6 +144,7 @@ watch(
<style lang="scss">
.tiptap {
flex-grow: 1;
:first-child {
margin-top: 0;
}
@@ -150,7 +152,6 @@ watch(
ol {
padding: 0 1rem;
margin: 1.25rem 1rem 1.25rem 0.4rem;
li p {
margin-top: 0.25em;
margin-bottom: 0.25em;
@@ -0,0 +1,96 @@
<template>
<v-card>
<v-card-title>{{ $gettext('Attachments') }}</v-card-title>
<v-card-text>
<v-form ref="formRef">
<v-file-input
v-model="file"
density="compact"
variant="outlined"
:rules="[rules.required]"
/>
<div class="w-100 d-flex justify-center">
<v-btn
color="primary"
variant="tonal"
:text="$gettext('Attach')"
@click="submit"
/>
</div>
</v-form>
<v-table class="mt-4 border-sm">
<tbody>
<tr v-for="attachment in attachments" :key="attachment.fname">
<td>{{ attachment.fname }}</td>
<td>
<v-btn
icon="mdi-trash-can"
color="error"
size="small"
variant="text"
@click="removeAttachment(attachment)"
/>
</td>
</tr>
</tbody>
</v-table>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn :text="$gettext('Close')" variant="flat" @click="close" />
</v-card-actions>
</v-card>
</template>
<script setup>
import { ref } from 'vue'
import api from '@/api/webmail'
import rules from '@/plugins/rules'
const props = defineProps({
sessionUid: {
type: String,
default: null,
},
})
const emit = defineEmits(['close'])
const attachments = ref([])
const file = ref()
const formRef = ref()
const loading = ref(false)
const close = () => {
emit('close')
}
const submit = async () => {
const { valid } = await formRef.value.validate()
if (!valid) {
return
}
const formData = new FormData()
formData.append('attachment', file.value)
loading.value = true
try {
const resp = await api.uploadAttachment(props.sessionUid, formData)
attachments.value.push(resp.data)
formRef.value.reset()
} finally {
loading.value = false
}
}
const removeAttachment = async (attachment) => {
await api.removeAttachment(props.sessionUid, attachment.tmpname)
const index = attachments.value.findIndex(
(item) => item.tmpname === attachment.tmpname
)
attachments.value.splice(index, 1)
}
api.getUploadedAttachments(props.sessionUid).then((resp) => {
attachments.value = resp.data
})
</script>
@@ -0,0 +1,47 @@
<template>
<HtmlEditor
v-if="htmlMode"
v-model="model"
class="d-flex flex-column flex-grow-1 mt-4"
>
<template #append-toolbar>
<v-btn-toggle
v-model="selection"
base-color="grey-lighten-3"
color="primary"
class="mr-2"
>
<v-btn text="HTML" size="small" @click="toggleHtmlMode" />
</v-btn-toggle>
</template>
</HtmlEditor>
<template v-else>
<div class="mt-8">
<v-btn-toggle
v-model="selection"
base-color="grey-lighten-3"
color="primary"
class="mr-2"
>
<v-btn v-btn text="HTML" size="small" @click="toggleHtmlMode"> </v-btn>
</v-btn-toggle>
</div>
<v-textarea v-model="model" class="border-sm" />
</template>
</template>
<script setup>
import { ref } from 'vue'
import HtmlEditor from '@/components/tools/HtmlEditor'
const model = defineModel()
const emit = defineEmits(['onToggleHtmlMode'])
const htmlMode = ref(false)
const selection = ref()
const toggleHtmlMode = () => {
htmlMode.value = !htmlMode.value
emit('onToggleHtmlMode', htmlMode.value)
}
</script>
@@ -14,6 +14,13 @@
@click="submit"
>
</v-btn>
<v-btn
class="ml-2"
variant="tonal"
prepend-icon="mdi-paperclip"
:text="$gettext('Attachments') + ` (${attachmentCount})`"
@click="openAttachmentsDialog"
/>
</v-toolbar>
<v-form ref="formRef" class="flex-grow-1 d-flex flex-column">
<div>
@@ -24,7 +31,9 @@
<v-col cols="8">
<v-select
v-model="form.sender"
:items="validSenderAddresses"
:items="allowedSenders"
item-title="address"
item-value="address"
variant="outlined"
density="compact"
hide-details="auto"
@@ -37,13 +46,17 @@
<span>{{ $gettext('To') }}</span>
</v-col>
<v-col cols="8">
<v-text-field
<v-combobox
v-model="form.to"
:items="contacts"
item-title="display_name"
return-object
:placeholder="$gettext('Provide one or more addresses')"
variant="outlined"
density="compact"
hide-details="auto"
:rules="[rules.required]"
@update:search="lookForContacts"
/>
</v-col>
<v-col cols="2">
@@ -76,12 +89,19 @@
/>
</v-col>
<v-col cols="8">
<v-text-field
<v-combobox
v-model="form.cc"
:items="contacts"
item-title="display_name"
return-object
:placeholder="$gettext('Provide one or more addresses')"
variant="outlined"
density="compact"
hide-details="auto"
chips
multiple
:hide-no-data="false"
@update:search="lookForContacts"
/>
</v-col>
</v-row>
@@ -96,12 +116,19 @@
/>
</v-col>
<v-col cols="8">
<v-text-field
<v-combobox
v-model="form.bcc"
:items="contacts"
item-title="display_name"
return-object
:placeholder="$gettext('Provide one or more addresses')"
variant="outlined"
density="compact"
hide-details="auto"
chips
multiple
:hide-no-data="false"
@update:search="lookForContacts"
/>
</v-col>
</v-row>
@@ -119,45 +146,60 @@
</v-col>
</v-row>
</div>
<HtmlEditor
<BodyEditor
v-model="form.body"
class="d-flex flex-column flex-grow-1 mt-4"
@on-toggle-html-mode="(value) => emit('onToggleHtmlMode', value)"
/>
</v-form>
</div>
<v-dialog v-model="showAttachmentsDialog" max-width="800">
<AttachmentsDialog
:session-uid="route.query.uid"
@close="closeAttachmentDialog"
/>
</v-dialog>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { useGettext } from 'vue3-gettext'
import { useAuthStore, useBusStore } from '@/stores'
import HtmlEditor from '@/components/tools/HtmlEditor'
import debounce from 'debounce'
import AttachmentsDialog from '@/components/webmail/AttachmentsDialog'
import BodyEditor from '@/components/webmail/BodyEditor'
import rules from '@/plugins/rules'
import api from '@/api/webmail'
import contactsApi from '@/api/contacts'
const props = defineProps({
originalEmail: {
type: Object,
default: null,
},
replyAll: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['onToggleHtmlMode'])
const route = useRoute()
const router = useRouter()
const { $gettext } = useGettext()
const { displayNotification } = useBusStore()
const authStore = useAuthStore()
const allowedSenders = ref([])
const attachmentCount = ref(0)
const contacts = ref([])
const form = ref({})
const formRef = ref()
const showAttachmentsDialog = ref(false)
const showCcField = ref(false)
const showBccField = ref(false)
const working = ref(false)
const validSenderAddresses = computed(() => {
return [authStore.authUser.username]
})
const close = () => {
router.push({
name: 'MailboxView',
@@ -168,11 +210,27 @@ const initForm = () => {
form.value = {
sender: authStore.authUser.username,
}
console.log(props.originalEmail)
if (props.originalEmail) {
form.value.to = props.originalEmail.from_address.address
form.value.to = props.originalEmail.reply_to
? props.originalEmail.reply_to
: props.originalEmail.from_address.address
if (props.replyAll) {
let addresses = props.originalEmail.to
.filter((rcpt) => rcpt.address !== authStore.authUser.username)
.map((rcpt) => rcpt.fulladdress)
if (props.originalEmail.cc && props.originalEmail.cc.length) {
addresses = addresses.concat(
props.originalEmail.cc.map((rcpt) => rcpt.fulladdress)
)
}
form.value.cc = addresses
showCcField.value = true
}
form.value.subject = props.originalEmail.subject
form.value.body = props.originalEmail.body
if (props.originalEmail.message_id) {
form.value.in_reply_to = props.originalEmail.message_id
}
}
}
@@ -183,7 +241,25 @@ const submit = async () => {
}
working.value = true
const body = { ...form.value }
body.to = [body.to]
if (typeof form.value.to !== 'string') {
body.to = [form.value.to.emails[0].address]
} else {
body.to = [form.value.to]
}
if (body.cc?.length) {
const cc = []
for (const rcpt of body.cc) {
cc.push(typeof rcpt === 'string' ? rcpt : rcpt.emails[0].address)
}
body.cc = cc
}
if (body.bcc?.length) {
const bcc = []
for (const rcpt of body.bcc) {
bcc.push(typeof rcpt === 'string' ? rcpt : rcpt.emails[0].address)
}
body.bcc = bcc
}
try {
await api.sendEmail(body)
router.push({ name: 'MailboxView' })
@@ -195,6 +271,28 @@ const submit = async () => {
}
}
const openAttachmentsDialog = () => {
showAttachmentsDialog.value = true
}
const closeAttachmentDialog = async () => {
showAttachmentsDialog.value = false
const resp = await api.getUploadedAttachments(route.query.uid)
attachmentCount.value = resp.data.length
}
const lookForContacts = debounce(async (search) => {
if (search) {
const params = { search }
const resp = await contactsApi.getContacts(params)
if (resp.data.length) {
contacts.value = resp.data
}
} else {
contacts.value = []
}
}, 500)
watch(
() => props.originalEmail,
() => {
@@ -202,4 +300,19 @@ watch(
},
{ immediate: true }
)
if (!route.query.uid) {
api.createComposeSession().then((resp) => {
const query = { ...route.query, uid: resp.data.uid }
router.push({ name: route.name, query })
})
} else {
api.getUploadedAttachments(route.query.uid).then((resp) => {
attachmentCount.value = resp.data.length
})
}
api.getAllowedSenders().then((resp) => {
allowedSenders.value = resp.data
})
</script>
@@ -0,0 +1,69 @@
<template>
<v-card min-width="300">
<v-list>
<v-list-item
:subtitle="model.name ? model.address : ''"
:title="model.name || model.address"
>
<template #append>
<v-btn
v-if="!model.contact_id"
color="secondary"
icon="mdi-card-account-mail-outline"
variant="text"
:loading="working"
:title="$gettext('Add to contacts')"
@click="addContact"
></v-btn>
<v-btn
v-else
icon="mdi-pencil"
variant="text"
:loading="working"
:title="$gettext('Update contact')"
@click="redirectToContacts"
></v-btn>
</template>
</v-list-item>
</v-list>
</v-card>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useGettext } from 'vue3-gettext'
import { useBusStore } from '@/stores'
import contactsApi from '@/api/contacts'
const emit = defineEmits(['contact-added'])
const model = defineModel()
const router = useRouter()
const { $gettext } = useGettext()
const { displayNotification } = useBusStore()
const working = ref(false)
const addContact = async () => {
const body = {
emails: [{ address: model.value.address, type: 'other' }],
}
if (model.value.name) {
body.display_name = model.value.name
}
working.value = true
let resp
try {
resp = await contactsApi.createContact(body)
} finally {
working.value = false
}
emit('contact-added', resp.data)
displayNotification({ msg: $gettext('Contact added to address book') })
}
const redirectToContacts = () => {
router.push({ name: 'ContactList' })
}
</script>
+80 -37
View File
@@ -11,13 +11,16 @@
<v-text-field
v-model="search"
prepend-inner-icon="mdi-magnify"
:placeholder="$gettext('Search')"
:placeholder="$gettext('Search in messages')"
variant="outlined"
single-line
flat
hide-details
density="compact"
class="flex-grow-0 w-33 mr-4"
clearable
@click:clear="fetchEmails"
@keyup.enter="submitSearch"
></v-text-field>
<v-btn
class="ml-2"
@@ -75,6 +78,12 @@
prepend-icon="mdi-star-outline"
@click="() => flagSelection('unflagged')"
/>
<v-list-item
v-if="$route.params.mailbox !== 'Trash'"
:title="$gettext('Empty mailbox')"
prepend-icon="mdi-trash-can"
@click="emptyMailbox"
/>
</v-list>
</v-menu>
</v-btn>
@@ -104,44 +113,58 @@
<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-card
v-for="email in emails.results"
:key="email.imapid"
density="compact"
class="mb-2 mx-1"
>
<v-card-text
class="d-flex align-center"
:class="{ 'font-weight-bold': email.style === 'unseen' }"
<template v-if="emails.results?.length">
<v-card
v-for="email in emails.results"
:key="email.imapid"
density="compact"
class="mb-2 mx-1"
>
<v-checkbox v-model="selection" :value="email.imapid" hide-details />
<v-btn
:icon="email.flagged ? 'mdiu-star' : 'mdi-star-outline'"
variant="flat"
/>
<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>
<v-card-text
class="d-flex align-center"
:class="{ 'font-weight-bold': email.style === 'unseen' }"
>
<v-checkbox
v-model="selection"
:value="email.imapid"
hide-details
/>
<v-btn
:icon="email.flagged ? 'mdi-star' : 'mdi-star-outline'"
variant="flat"
@click="toggleFollowState(email)"
/>
<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>
</div>
</div>
</div>
<v-spacer />
<div class="text-right">
<div>{{ email.date }}</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" />
<v-icon v-if="email.attachments" icon="mdi-paperclip" />
<span class="text-grey">{{ $filesize(email.size) }}</span>
<v-spacer />
<div class="text-right">
<div>{{ email.date }}</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" />
<v-icon v-if="email.attachments" icon="mdi-paperclip" />
<span class="text-grey">{{ $filesize(email.size) }}</span>
</div>
</div>
</div>
</v-card-text>
</v-card>
</v-card-text>
</v-card>
</template>
<v-alert
v-else
class="mt-4"
type="info"
:text="$gettext('No message yet in this mailbox')"
variant="tonal"
/>
</div>
</template>
</template>
@@ -184,7 +207,7 @@ const fetchEmails = () => {
emails.value = {}
loading.value = true
api
.getMailboxEmails(props.mailbox, { page: page.value })
.getMailboxEmails(props.mailbox, { page: page.value, search: search.value })
.then((resp) => {
emails.value = resp.data
loading.value = false
@@ -194,6 +217,12 @@ const fetchEmails = () => {
})
}
const submitSearch = () => {
if (search.value) {
fetchEmails()
}
}
const toggleAllSelection = (value) => {
if (!value) {
selection.value = []
@@ -251,6 +280,20 @@ const flagSelection = (status) => {
})
}
const emptyMailbox = () => {
loading.value = true
api.emptyUserMailbox(route.query.mailbox).then(() => {
emails.value = {}
loading.value = false
})
}
const toggleFollowState = async (email) => {
const flag = email.flagged ? 'unflagged' : 'flagged'
await api.flagSelection(route.query.mailbox, [email.imapid], flag)
email.flagged = flag === 'flagged'
}
watch(
() => props.mailbox,
() => {
@@ -0,0 +1,126 @@
<template>
<v-card :title="title">
<v-card-text>
<v-form ref="formRef">
<v-text-field
v-model="form.name"
:label="$gettext('Name')"
variant="outlined"
density="compact"
:rules="[rules.required]"
/>
<div class="mb-2 rounded-lg">
<h4>{{ $gettext('Parent mailbox') }}</h4>
<MailboxList
v-model="form.parent_mailbox"
:active-mailbox="props.parent"
:mailboxes="props.userMailboxes"
light-mode
compact
:unseen-counters="false"
allow-unselect
/>
</div>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
:text="$gettext('Close')"
variant="flat"
:loading="loading"
@click="close"
/>
<v-btn
:text="submitLabel"
variant="tonal"
color="primary"
:loading="loading"
@click="submit"
/>
</v-card-actions>
</v-card>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { useGettext } from 'vue3-gettext'
import { useBusStore } from '@/stores'
import MailboxList from '@/components/webmail/MailboxList.vue'
import api from '@/api/webmail'
import rules from '@/plugins/rules'
const props = defineProps({
userMailboxes: Array,
mailbox: {
type: String,
default: null,
},
hdelimiter: {
type: String,
default: null,
},
})
const emit = defineEmits(['close', 'mailboxRenamed'])
const { $gettext } = useGettext()
const { displayNotification } = useBusStore()
const form = ref({})
const formRef = ref()
const loading = ref(false)
const title = computed(() => {
return !props.mailbox
? $gettext('Create new mailbox')
: $gettext('Edit mailbox')
})
const submitLabel = computed(() => {
return !props.mailbox ? $gettext('Create') : $gettext('Update')
})
const close = () => {
form.value = {}
emit('close')
}
const submit = async () => {
const { valid } = await formRef.value.validate()
if (!valid) {
return
}
loading.value = true
try {
const body = { ...form.value }
if (props.mailbox) {
body.oldname = props.mailbox
await api.renameUserMailbox(body)
displayNotification({ msg: $gettext('Mailbox renamed') })
emit(
'mailboxRenamed',
`${body.parent_mailbox}${props.hdelimiter}${body.name}`
)
} else {
await api.createUserMailbox(body)
displayNotification({ msg: $gettext('Mailbox created') })
}
close()
} finally {
loading.value = false
}
}
watch(
() => props.mailbox,
(value) => {
if (value) {
const parts = props.mailbox.split(props.hdelimiter)
form.value.name = parts.slice(-1)[0]
form.value.parent_mailbox = parts.slice(0, -1).join(props.hdelimiter)
} else {
form.value = {}
}
},
{ immediate: true }
)
</script>
+42 -12
View File
@@ -3,17 +3,18 @@
<div
v-for="mailbox in props.mailboxes"
:key="mailbox.name"
class="py-1 pl-3 text-body-2"
class="pl-3 text-body-2"
:class="{ 'py-1': !props.compact }"
>
<div
:class="getBackgroundColorClass(mailbox)"
class="d-flex mailbox pa-1 align-center"
@click="$emit('mailboxSelected', mailbox)"
@click="updateSelection(mailbox.name)"
@mouseover="setHover(mailbox, true)"
@mouseleave="setHover(mailbox, false)"
>
<v-icon :icon="iconByMailboxType[mailbox.type]" class="mr-4" />
<template v-if="mailbox.unseen > 0">
<template v-if="props.unseenCounters && mailbox.unseen > 0">
<span class="font-weight-bold">
{{ getMailboxLabel(mailbox) }} ({{ mailbox.unseen }})
</span>
@@ -33,9 +34,11 @@
</div>
<MailboxList
v-if="getMailboxState(mailbox) && mailbox.sub && mailbox.sub.length"
v-model="model"
:mailboxes="mailbox.sub"
class="mt-1"
@mailbox-selected="(mailbox) => $emit('mailboxSelected', mailbox)"
:light-mode="props.lightMode"
:compact="props.compact"
/>
</div>
</div>
@@ -43,18 +46,32 @@
<script setup>
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import api from '@/api/webmail'
const route = useRoute()
const props = defineProps({
mailboxes: {
type: Array,
default: null,
},
lightMode: {
type: Boolean,
default: false,
},
compact: {
type: Boolean,
default: false,
},
unseenCounters: {
type: Boolean,
default: true,
},
allowUnselect: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['mailboxSelected'])
const model = defineModel()
const emit = defineEmits(['update:modelValue'])
const iconByMailboxType = {
inbox: 'mdi-inbox',
@@ -77,10 +94,15 @@ function setHover(mailbox, value) {
}
function getBackgroundColorClass(mailbox) {
if (route.query.mailbox === mailbox.name) {
return 'bg-primary-lighten-1'
if (model.value === mailbox.name) {
return `bg-primary-lighten-1`
}
return hoverStates.value[mailbox.name] ? 'bg-primary-lighten-1' : 'bg-primary'
if (!props.lightMode) {
return hoverStates.value[mailbox.name]
? `bg-primary-lighten-1`
: `bg-primary`
}
return hoverStates.value[mailbox.name] ? 'bg-primary' : `bg-white`
}
function getMailboxState(mailbox) {
@@ -94,10 +116,18 @@ function toggleMailbox(mailbox) {
mailboxStates.value[mailbox.name] = !mailboxStates.value[mailbox.name]
if (mailboxStates.value[mailbox.name] && !mailbox.sub.length) {
api.getUserMailboxes(mailbox.name).then((resp) => {
mailbox.sub = resp.data
mailbox.sub = resp.data.mailboxes
})
}
}
function updateSelection(value) {
if (props.allowUnselect && value === model.value) {
emit('update:modelValue', null)
} else {
emit('update:modelValue', value)
}
}
</script>
<style scoped lang="scss">
+168 -28
View File
@@ -8,65 +8,205 @@
color="primary"
app
>
<div class="d-flex align-center">
<v-img
src="@/assets/Modoboa_RVB-BLANC-SANS.png"
max-width="190"
class="logo"
/>
<v-btn
:icon="rail ? 'mdi-chevron-right' : 'mdi-chevron-left'"
variant="text"
@click.stop="rail = !rail"
>
</v-btn>
</div>
<template #prepend>
<div class="d-flex align-center">
<v-img
src="@/assets/Modoboa_RVB-BLANC-SANS.png"
max-width="190"
class="logo"
/>
<v-btn
:icon="rail ? 'mdi-chevron-right' : 'mdi-chevron-left'"
variant="text"
@click.stop="rail = !rail"
>
</v-btn>
</div>
<div class="d-flex justify-center mb-4">
<v-btn
:text="$gettext('Compose')"
color="secondary"
variant="flat"
prepend-icon="mdi-pencil"
@click="openComposeForm"
/>
</div>
<div class="d-flex justify-center mb-4">
<v-btn
:text="$gettext('Compose')"
color="secondary"
variant="flat"
prepend-icon="mdi-pencil"
@click="openComposeForm"
/>
</div>
</template>
<MailboxList
v-model="selectedMailbox"
:mailboxes="userMailboxes"
class="mr-2"
@mailbox-selected="openMailbox"
@update:model-value="openMailbox"
/>
<template #append>
<div class="border-t-sm d-flex align-center bg-grey">
<v-btn class="ml-2" variant="text" icon size="small">
<v-icon icon="mdi-cog" />
<v-menu activator="parent">
<v-list density="compact">
<v-list-item
:title="$gettext('Create a new mailbox')"
prepend-icon="mdi-plus"
@click="openMailboxForm"
/>
<v-list-item
:title="$gettext('Edit this mailbox')"
prepend-icon="mdi-pencil"
:disabled="readOnlyMailbox"
@click="editMailbox"
/>
<v-list-item
:title="$gettext('Delete this mailbox')"
prepend-icon="mdi-trash-can"
:disabled="readOnlyMailbox"
@click="deleteMailbox"
/>
<v-list-item
:title="$gettext('Compress this mailbox')"
prepend-icon="mdi-folder-zip-outline"
@click="compressMailbox"
/>
</v-list>
</v-menu>
</v-btn>
<v-progress-linear
v-if="mailboxQuota"
:color="quotaColor"
:model-value="mailboxQuota.usage"
:title="mailboxQuotaTitle"
/>
</div>
</template>
</v-navigation-drawer>
</template>
</ConnectedLayout>
<v-dialog v-model="showMailboxForm" max-width="800">
<MailboxForm
:user-mailboxes="userMailboxes"
:mailbox="editedMailbox"
:hdelimiter="hdelimiter"
@mailbox-renamed="navigateToMailbox"
@close="closeMailboxForm()"
/>
</v-dialog>
<ConfirmDialog ref="confirm" />
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useGettext } from 'vue3-gettext'
import { useBusStore } from '@/stores'
import ConfirmDialog from '@/components/tools/ConfirmDialog.vue'
import ConnectedLayout from '@/layouts/connected/ConnectedLayout.vue'
import MailboxForm from '@/components/webmail/MailboxForm.vue'
import MailboxList from '@/components/webmail/MailboxList.vue'
import api from '@/api/webmail'
const { $gettext } = useGettext()
const route = useRoute()
const router = useRouter()
const { displayNotification } = useBusStore()
const confirm = ref()
const drawer = ref(true)
const rail = ref(false)
const editedMailbox = ref(null)
const hdelimiter = ref(null)
const mailboxQuota = ref(null)
const selectedMailbox = ref(route.query.mailbox || 'INBOX')
const showMailboxForm = ref(false)
const userMailboxes = ref([])
const readOnlyMailbox = computed(() => {
const mailboxes = ['INBOX']
return mailboxes.includes(route.query.mailbox || 'INBOX')
})
const quotaColor = computed(() => {
if (mailboxQuota.value.usage < 50) {
return 'info'
}
if (mailboxQuota.value.usage < 80) {
return 'warning'
}
return 'error'
})
const mailboxQuotaTitle = computed(() => {
return `${mailboxQuota.value.current} / ${mailboxQuota.value.limit} (${mailboxQuota.value.usage}%)`
})
function openMailbox(mailbox) {
console.log(mailbox)
router.push({
name: 'MailboxView',
query: { mailbox: mailbox.name },
query: { mailbox },
})
api.getUserMailboxQuota(mailbox).then((resp) => {
mailboxQuota.value = resp.data
})
}
api.getUserMailboxes().then((resp) => {
userMailboxes.value = resp.data
userMailboxes.value = resp.data.mailboxes
hdelimiter.value = resp.data.hdelimiter
})
const openComposeForm = () => {
router.push({ name: 'ComposeEmailView' })
}
const closeMailboxForm = () => {
showMailboxForm.value = false
editedMailbox.value = null
api.getUserMailboxes().then((resp) => {
userMailboxes.value = resp.data.mailboxes
})
}
const openMailboxForm = () => {
showMailboxForm.value = true
}
const editMailbox = () => {
editedMailbox.value = selectedMailbox.value
showMailboxForm.value = true
}
const navigateToMailbox = (mailbox) => {
selectedMailbox.value = mailbox
router.push({
name: 'MailboxView',
query: { mailbox },
})
}
const compressMailbox = async () => {
await api.compressUserMailbox({ name: selectedMailbox.value })
displayNotification({ msg: $gettext('Mailbox compressed') })
}
const deleteMailbox = async () => {
const confirmed = await confirm.value.open(
$gettext('Warning'),
$gettext('Delete this mailbox?'),
{
color: 'warning',
agreeLabel: $gettext('Yes'),
cancelLabel: $gettext('No'),
}
)
if (!confirmed) {
return
}
await api.deleteUserMailbox({ name: selectedMailbox.value })
displayNotification({ msg: $gettext('Mailbox deleted') })
navigateToMailbox('INBOX')
}
api.getUserMailboxQuota(route.query.mailbox || 'INBOX').then((resp) => {
mailboxQuota.value = resp.data
})
</script>
+8
View File
@@ -355,6 +355,14 @@ const routes = [
requiresAuth: true,
},
},
{
path: 'forward',
name: 'ForwardEmailView',
component: () => import('@/views/webmail/ForwardEmailView.vue'),
meta: {
requiresAuth: true,
},
},
],
},
],
+79 -15
View File
@@ -7,7 +7,7 @@
color="primary"
variant="tonal"
prepend-icon="mdi-reply"
@click="replyToEmail"
@click="() => replyToEmail()"
>
{{ $gettext('Reply') }}
<template #append>
@@ -15,8 +15,14 @@
<v-icon icon="mdi-chevron-down" />
<v-menu activator="parent">
<v-list>
<v-list-item :title="$gettext('Reply all')" />
<v-list-item :title="$gettext('Forward')" />
<v-list-item
:title="$gettext('Reply all')"
@click="() => replyToEmail(true)"
/>
<v-list-item
:title="$gettext('Forward')"
@click="forwardEmail"
/>
</v-list>
</v-menu>
</v-btn>
@@ -80,16 +86,46 @@
<div v-if="email" ref="headers" class="bg-white pa-4">
<h2>{{ email.subject }}</h2>
<div class="d-flex mt-2">
<h3 v-if="email.from_address.fulladdress">
{{ email.from_address.fulladdress }}
</h3>
<h3 v-else>{{ email.from_address.address }}</h3>
<v-menu key="sender" open-on-hover>
<template #activator="{ props }">
<h3 v-bind="props">
<template v-if="email.from_address.name">
{{ email.from_address.name }}
<span class="text-grey text-body-2">
&lt;{{ email.from_address.address }}&gt;
</span>
</template>
<template v-else>
{{ email.from_address.address }}
</template>
</h3>
</template>
<ContactCard
v-model="email.from_address"
@contact-added="
(contact) => (email.from_address.contact_id = contact.id)
"
/>
</v-menu>
<v-spacer />
<span>{{ email.date }}</span>
<span class="text-grey">{{ email.date }}</span>
</div>
<div class="mt-2">
<div class="mt-2 text-grey">
{{ $gettext('To') }}
{{ recipients }}
<v-menu
v-for="(rcpt, index) in recipients"
:key="`rcpt-${index}`"
open-on-hover
>
<template #activator="{ props }">
<span v-if="index > 0">, </span>
<span v-bind="props">{{ rcpt.name || rcpt.address }}</span>
</template>
<ContactCard
:model-value="rcpt"
@contact-added="(contact) => storeContactId(rcpt, contact)"
/>
</v-menu>
</div>
<div v-if="email.attachments.length" class="mt-2">
<v-icon icon="mdi-paperclip" />
@@ -131,6 +167,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useGettext } from 'vue3-gettext'
import { useBusStore } from '@/stores'
import api from '@/api/webmail'
import ContactCard from '@/components/webmail/ContactCard.vue'
const { $gettext } = useGettext()
const { displayNotification } = useBusStore()
@@ -149,9 +186,11 @@ const recipients = computed(() => {
if (!email.value) {
return ''
}
return email.value.to
.map((rcpt) => (rcpt.name ? rcpt.name : rcpt.address))
.join(', ')
let result = email.value.to
if (email.value.cc && email.value.cc.length) {
result = result.concat(email.value.cc)
}
return result
})
onMounted(() => {
@@ -268,8 +307,33 @@ const openEmailSourceDialog = async () => {
showEmailSource.value = true
}
const replyToEmail = () => {
router.push({ name: 'ReplyEmailView', query: route.query })
const storeContactId = (address, contact) => {
console.log(address, contact)
for (const rcpt of email.value.to) {
if (rcpt.address === address.address) {
rcpt.contact_id = contact.id
return
}
}
if (!email.value.cc) return
for (const rcpt of email.value.cc) {
if (rcpt.address === address.address) {
rcpt.contact_id = contact.id
return
}
}
}
const replyToEmail = (all) => {
const query = { ...route.query }
if (all) {
query.all = all
}
router.push({ name: 'ReplyEmailView', query })
}
const forwardEmail = () => {
router.push({ name: 'ForwardEmailView', query: route.query })
}
</script>
@@ -0,0 +1,22 @@
<template>
<ComposeEmailForm :original-email="email" />
</template>
<script setup>
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import ComposeEmailForm from '@/components/webmail/ComposeEmailForm'
import api from '@/api/webmail'
const route = useRoute()
const email = ref()
api
.getEmailContent(route.query.mailbox, route.query.mailid, {
context: 'forward',
})
.then((resp) => {
email.value = resp.data
})
</script>
+18 -8
View File
@@ -1,5 +1,9 @@
<template>
<ComposeEmailForm :original-email="email" />
<ComposeEmailForm
:original-email="email"
:reply-all="route.query.all === 'true'"
@on-toggle-html-mode="getEmailContent"
/>
</template>
<script setup>
@@ -12,11 +16,17 @@ const route = useRoute()
const email = ref()
api
.getEmailContent(route.query.mailbox, route.query.mailid, {
context: 'reply',
})
.then((resp) => {
email.value = resp.data
})
const getEmailContent = (html) => {
email.value = ''
api
.getEmailContent(route.query.mailbox, route.query.mailid, {
context: 'reply',
dformat: html ? 'html' : 'plain',
})
.then((resp) => {
email.value = resp.data
})
}
getEmailContent()
</script>
+21
View File
@@ -0,0 +1,21 @@
import redis
from django.conf import settings
def get_redis_connection(hget_return_type=int) -> redis.Redis:
"""Return a client connection to Redis server."""
if not getattr(settings, "REDIS_SENTINEL", False):
rclient = redis.Redis(
host=settings.REDIS_HOST,
port=settings.REDIS_PORT,
db=settings.REDIS_QUOTA_DB,
)
else:
sentinel = redis.sentinel.Sentinel(
settings.REDIS_SENTINELS, socket_timeout=0.1, db=settings.REDIS_QUOTA_DB
)
rclient = sentinel.master_for(settings.REDIS_MASTER, socket_timeout=0.1)
if hget_return_type:
rclient.set_response_callback("HGET", hget_return_type)
return rclient
+2 -2
View File
@@ -4,9 +4,9 @@ from django.db.models import signals
from django.dispatch import receiver
from modoboa.admin import models as admin_models
from modoboa.lib.redis import get_redis_connection
from . import constants
from . import utils
def set_message_limit(instance, key):
@@ -14,7 +14,7 @@ def set_message_limit(instance, key):
old_message_limit = instance._loaded_values.get("message_limit")
if old_message_limit == instance.message_limit:
return
rclient = utils.get_redis_connection()
rclient = get_redis_connection()
if instance.message_limit is None:
# delete existing key
if rclient.hexists(constants.REDIS_HASHNAME, key):
+2 -2
View File
@@ -16,11 +16,11 @@ from django.urls import reverse
from modoboa.admin import factories as admin_factories
from modoboa.admin import models as admin_models
from modoboa.core import models as core_models
from modoboa.lib.redis import get_redis_connection
from modoboa.lib.tests import ModoTestCase, ParametersMixin
from modoboa.policyd import core as policyd_core
from . import constants
from . import utils
def start_policy_daemon():
@@ -32,7 +32,7 @@ class RedisTestCaseMixin:
def setUp(self):
super().setUp()
self.rclient = utils.get_redis_connection()
self.rclient = get_redis_connection()
self.rclient.delete(constants.REDIS_HASHNAME)
+1 -20
View File
@@ -1,29 +1,10 @@
"""Tooling."""
import redis
from django.conf import settings
from modoboa.lib.redis import get_redis_connection
from . import constants
def get_redis_connection():
"""Return a client connection to Redis server."""
if not getattr(settings, "REDIS_SENTINEL", False):
rclient = redis.Redis(
host=settings.REDIS_HOST,
port=settings.REDIS_PORT,
db=settings.REDIS_QUOTA_DB,
)
else:
sentinel = redis.sentinel.Sentinel(
settings.REDIS_SENTINELS, socket_timeout=0.1, db=settings.REDIS_QUOTA_DB
)
rclient = sentinel.master_for(settings.REDIS_MASTER, socket_timeout=0.1)
rclient.set_response_callback("HGET", int)
return rclient
def get_message_counter(key):
"""Return current counter for given key."""
rclient = get_redis_connection()
-4
View File
@@ -1,8 +1,6 @@
from .attachments import (
create_mail_attachment,
save_attachment,
clean_attachments,
set_compose_session,
AttachmentUploadHandler,
)
from .imapemail import ImapEmail, ReplyModifier, ForwardModifier
@@ -20,12 +18,10 @@ __all__ = [
"IMAPconnector",
"ImapEmail",
"ReplyModifier",
"clean_attachments",
"create_mail_attachment",
"decode_payload",
"get_imapconnector",
"save_attachment",
"send_mail",
"separate_mailbox",
"set_compose_session",
]
+77 -25
View File
@@ -1,21 +1,62 @@
from email import encoders
from email.mime.base import MIMEBase
import json
import os
from tempfile import NamedTemporaryFile
from typing import Optional
import uuid
import six
from django.conf import settings
from django.core.files.uploadhandler import FileUploadHandler, SkipFile
from django.http import Http404
from django.utils.encoding import smart_bytes
from django.utils.translation import gettext as _
from modoboa.lib.exceptions import InternalError
from modoboa.lib.redis import get_redis_connection
from modoboa.lib.web_utils import size2integer
from modoboa.parameters import tools as param_tools
from .rfc6266 import build_header
class ComposeSessionManager:
def __init__(self, request):
self.request = request
self.hash = f"webmail-{request.user.username}"
self.rclient = get_redis_connection(bytes)
def create(self) -> str:
"""
Initialize a new "compose" session.
It is used to keep track of attachments defined with a new
message. Each new message will be associated with a unique ID (in
order to avoid conflicts between users).
"""
randid = str(uuid.uuid4()).replace("-", "")
self.rclient.hset(self.hash, randid, json.dumps({"attachments": []}))
return randid
def delete(self, uid: str) -> None:
self.rclient.hdel(self.hash, uid)
def exists(self, uid: str) -> bool:
return self.rclient.hexists(self.hash, uid)
def get_content(self, uid: str) -> dict:
if not self.exists(uid):
raise Http404
content = self.rclient.hget(self.hash, uid)
return json.loads(content.decode())
def set_content(self, uid: str, content: dict):
return self.rclient.hset(self.hash, uid, json.dumps(content))
def get_storage_path(filename):
"""Return a path to store a file."""
storage_dir = os.path.join(settings.MEDIA_ROOT, "webmail")
@@ -24,22 +65,7 @@ def get_storage_path(filename):
return os.path.join(storage_dir, filename)
def set_compose_session(request):
"""Initialize a new "compose" session.
It is used to keep track of attachments defined with a new
message. Each new message will be associated with a unique ID (in
order to avoid conflicts between users).
:param request: a Request object.
:return: the new unique ID.
"""
randid = str(uuid.uuid4()).replace("-", "")
request.session["compose_mail"] = {"id": randid, "attachments": []}
return randid
def save_attachment(f):
def save_attachment(request, session_uid: str, f) -> dict:
"""Save a new attachment to the filesystem.
The attachment is not saved using its own name to the
@@ -49,8 +75,9 @@ def save_attachment(f):
:param f: an uploaded file object (see Django's documentation) or bytes
:return: the new random name
"""
from tempfile import NamedTemporaryFile
manager = ComposeSessionManager(request)
if not manager.exists(session_uid):
raise Http404
try:
fp = NamedTemporaryFile(dir=get_storage_path(""), delete=False)
except Exception as e:
@@ -61,21 +88,46 @@ def save_attachment(f):
for chunk in f.chunks():
fp.write(chunk)
fp.close()
return fp.name
session = manager.get_content(session_uid)
attachment = {
"fname": str(f),
"content-type": f.content_type,
"size": f.size,
"tmpname": os.path.basename(fp.name),
}
session["attachments"].append(attachment)
manager.set_content(session_uid, session)
return attachment
def clean_attachments(attlist):
"""Remove all attachments from the filesystem
def remove_attachment(request, session_uid: str, name: str) -> Optional[str]:
manager = ComposeSessionManager(request)
session = manager.get_content(session_uid)
for att in session["attachments"]:
if att["tmpname"] == name:
session["attachments"].remove(att)
fullpath = os.path.join(settings.MEDIA_ROOT, "webmail", att["tmpname"])
try:
os.remove(fullpath)
except OSError as e:
return _("Failed to remove attachment: ") + str(e)
manager.set_content(session_uid, session)
return None
raise Http404
:param attlist: a list of 2-uple. Each element must contain the
following information : (random name, real name).
"""
for att in attlist:
def remove_attachments_and_session(
manager: ComposeSessionManager, session_uid: str
) -> None:
content = manager.get_content(session_uid)
for att in content["attachments"]:
fullpath = get_storage_path(att["tmpname"])
try:
os.remove(fullpath)
except OSError:
pass
manager.delete(session_uid)
def create_mail_attachment(attdef, payload=None):
+45 -54
View File
@@ -7,16 +7,15 @@ import re
import email
import chardet
import six
from django.conf import settings
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.urls import reverse
from django.utils.encoding import smart_str
from django.utils.html import conditional_escape
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
@@ -46,20 +45,6 @@ class ImapEmail(Email):
self.mbox, self.mailid = self.mailid.split(":")
self.attachments: dict[str, str] = {}
def _insert_contact_links(self, addresses):
"""Insert 'add to address book' links."""
result = []
title = _("Add to contacts")
url = reverse("api:contact-list")
link_tpl = (
" <a class='addcontact' href='{}' title='{}'>"
"<span class='fa fa-vcard'></span></a>"
)
for address in addresses:
address += link_tpl.format(url, title)
result.append(address)
return result
def fetch_headers(self, raw_addresses: bool = False) -> None:
"""Fetch message headers from server."""
msg = self.imapc.fetchmail(
@@ -77,6 +62,18 @@ class ImapEmail(Email):
self.headers += [{"name": label, "value": hdrvalue}]
label = re.sub("-", "_", label)
setattr(self, label, hdrvalue)
addressbook = self.request.user.addressbook_set.first()
if not addressbook:
return
contacts = [self.From] + self.To
if hasattr(self, "Cc"):
contacts += self.Cc
for contact in contacts:
emailaddress = contacts_models.EmailAddress.objects.filter(
contact__addressbook=addressbook, address=contact["address"]
).first()
if emailaddress:
contact["contact_id"] = emailaddress.contact.id
def get_header(self, msg, header: str, **kwargs):
"""Look for a particular header.
@@ -117,6 +114,11 @@ class ImapEmail(Email):
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."""
return self.Subject if hasattr(self, "Subject") else ""
@property
def body(self):
"""Load email's body.
@@ -136,7 +138,7 @@ class ImapEmail(Email):
content = decode_payload(
part["encoding"], data[int(self.mailid)][f"BODY[{pnum}]"]
)
if not isinstance(content, six.text_type):
if not isinstance(content, str):
charset = self._find_content_charset(part)
if charset is not None:
try:
@@ -229,7 +231,6 @@ class Modifier(ImapEmail):
"""Message modifier."""
def __init__(self, request, *args, **kwargs):
kwargs["dformat"] = request.user.parameters.get_value("editor")
super().__init__(request, *args, **kwargs)
self.fetch_headers(raw_addresses=True)
self._inject_textheader()
@@ -243,11 +244,6 @@ class Modifier(ImapEmail):
self.body = re.sub("</?pre>", "", self.body)
self.body = re.sub("\n", "<br>", self.body)
@property
def subject(self):
"""Just a shortcut to return a subject in any case."""
return self.Subject if hasattr(self, "Subject") else ""
class ReplyModifier(Modifier):
"""Modify a message to reply to it."""
@@ -257,27 +253,15 @@ class ReplyModifier(Modifier):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# if hasattr(self, "Message_ID"):
# self.form.fields["origmsgid"].initial = self.Message_ID
# if not hasattr(self, "Reply_To"):
# self.form.fields["to"].initial = self.original_From
# else:
# self.form.fields["to"].initial = self.original_ReplyTo
# if self.request.GET.get("all", "0") == "1": # reply-all
# self.form.fields["cc"].initial = ""
# toparse = self.To.split(",")
# if hasattr(self, "Cc"):
# toparse += self.Cc.split(",")
# for addr in toparse:
# tmp = EmailAddress(addr)
# if tmp.address and tmp.address == self.request.user.username:
# continue
# if self.form.fields["cc"].initial != "":
# self.form.fields["cc"].initial += ", "
# self.form.fields["cc"].initial += tmp.fulladdress
m = re.match(r"re\s*:\s*.+", self.subject.lower())
@property
def subject(self) -> str:
result: str = getattr(self, "Subject", "")
if not result:
return result
m = re.match(r"re\s*:\s*.+", result.lower())
if not m:
self.subject = f"Re: {self.subject}"
return f"Re: {result}"
return result
def _inject_textheader(self):
sender = self.From.get("name", self.From["address"])
@@ -302,26 +286,33 @@ class ReplyModifier(Modifier):
class ForwardModifier(Modifier):
"""Modify a message so it can be forwarded."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._header()
self.form.fields["subject"].initial = f"Fwd: {self.subject}"
@property
def subject(self) -> str:
result: str = getattr(self, "Subject", "")
return f"Fwd: {result}"
def __getfunc(self, name):
return getattr(self, f"{name}_{self.dformat}")
def _header(self):
self.textheader = "{}\n".format(self.__getfunc("_header_begin")())
self.textheader += self.__getfunc("_header_line")(_("Subject"), self.subject)
self.textheader += self.__getfunc("_header_line")(_("Date"), self.Date)
for hdr in ["From", "To", "Reply-To"]:
def _inject_textheader(self):
textheader = "{}\n".format(self.__getfunc("_header_begin")())
textheader += self.__getfunc("_header_line")(
_("Subject"), getattr(self, "Subject", "")
)
textheader += self.__getfunc("_header_line")(_("Date"), self.Date)
for hdr in ["From", "To", "Reply-To", "Cc"]:
try:
key = re.sub("-", "_", hdr)
value = getattr(self, key)
self.textheader += self.__getfunc("_header_line")(_(hdr), value)
if isinstance(value, dict):
value = value["fulladdress"]
else:
value = ", ".join(item["fulladdress"] for item in value)
textheader += self.__getfunc("_header_line")(_(hdr), value)
except AttributeError:
pass
self.textheader += self.__getfunc("_header_end")()
textheader += self.__getfunc("_header_end")()
self.body = textheader + self.body
def _header_begin_plain(self):
return f"----- {_('Original message')} -----"
+3 -1
View File
@@ -19,7 +19,7 @@ from modoboa.parameters import tools as param_tools
from ..exceptions import ImapError, WebmailInternalError
from .fetch_parser import FetchResponseParser
imaplib.Debug = 4
# imaplib.Debug = 4
# workaround for the "got more than 10000 bytes" exception. MAXLINE
# value set to 1M, as on latest python versions.
@@ -175,6 +175,8 @@ class IMAPconnector:
self.__hdelimiter: Optional[str] = None
self.__ns_prefixes: dict = {}
self.quota_usage: int = -1
self.quota_limit: Optional[int] = None
self.quota_current: Optional[int] = None
self.criterions: list = []
self.conf = dict(param_tools.get_global_parameters("webmail"))
self.address = self.conf["imap_server"]
+15 -16
View File
@@ -14,6 +14,7 @@ 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 . import get_imapconnector
@@ -109,13 +110,13 @@ def format_sender_address(user: core_models.User, address: str) -> str:
return address
def create_message(user: core_models.User, attributes: dict):
def create_message(user: core_models.User, attributes: dict, attachments: list):
headers = {
"User-Agent": "Modoboa {}".format(
pkg_resources.get_distribution("modoboa").version
)
}
origmsgid = attributes.get("origmsgid")
origmsgid = attributes.get("in_reply_to")
if origmsgid:
headers.update({"References": origmsgid, "In-Reply-To": origmsgid})
mode = user.parameters.get_value("editor")
@@ -130,21 +131,22 @@ def create_message(user: core_models.User, attributes: dict):
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 send_mail(request, attributes: dict) -> Tuple[bool, Optional[str]]:
"""Email verification and sending.
If the form does not present any error, a new MIME message is
constructed. Then, a connection is established with the defined
SMTP server and the message is finally sent.
:param request: a Request object
:param posturl: the url to post the message form to
:return: a 2-uple (True|False, HttpResponse)
def send_mail(
request, attributes: dict, attachments: list
) -> Tuple[bool, Optional[str]]:
"""
msg = create_message(request.user, attributes)
Send a new email.
A new MIME message is first constructed. Then, a connection is established with the defined
SMTP server and the message is finally sent.
"""
msg = create_message(request.user, attributes, attachments)
conf = dict(param_tools.get_global_parameters("webmail"))
options = {"host": conf["smtp_server"], "port": conf["smtp_port"]}
if conf["smtp_secured_mode"] == "ssl":
@@ -177,7 +179,4 @@ def send_mail(request, attributes: dict) -> Tuple[bool, Optional[str]]:
# Copy message to sent folder
sentfolder = request.user.parameters.get_value("sent_folder")
get_imapconnector(request).push_mail(sentfolder, msg.message())
# clean_attachments(request.session["compose_mail"]["attachments"])
# del request.session["compose_mail"]
return True, None
+51 -1
View File
@@ -59,10 +59,35 @@ class UserMailboxSerializer(serializers.Serializer):
return None
class UserMailboxQuotaSerializer(serializers.Serializer):
usage = serializers.IntegerField(source="quota_usage")
current = serializers.IntegerField(source="quota_current")
limit = serializers.IntegerField(source="quota_limit")
class UserMailboxesSerializer(serializers.Serializer):
mailboxes = UserMailboxSerializer(many=True)
hdelimiter = serializers.CharField()
class UserMailboxInputSerializer(serializers.Serializer):
name = serializers.CharField()
parent_mailbox = serializers.CharField(required=False)
class UserMailboxUpdateSerializer(UserMailboxInputSerializer):
oldname = serializers.CharField()
class EmailAddressSerializer(serializers.Serializer):
fulladdress = serializers.CharField()
address = serializers.CharField()
name = serializers.CharField(required=False)
contact_id = serializers.IntegerField(required=False)
class AttachmentSerializer(serializers.Serializer):
@@ -70,6 +95,16 @@ class AttachmentSerializer(serializers.Serializer):
partnum = serializers.CharField()
class AttachmentUploadSerializer(serializers.Serializer):
attachment = serializers.FileField()
class UploadedAttachmentSerializer(serializers.Serializer):
tmpname = serializers.CharField()
fname = serializers.CharField()
class EmailHeadersSerializer(serializers.Serializer):
imapid = serializers.CharField()
@@ -105,11 +140,14 @@ class PaginatedEmailListSerializer(serializers.Serializer):
class EmailSerializer(serializers.Serializer):
subject = serializers.CharField(source="Subject")
subject = serializers.CharField()
from_address = EmailAddressSerializer(source="From")
to = EmailAddressSerializer(source="To", many=True)
cc = EmailAddressSerializer(source="Cc", many=True, required=False)
body = serializers.CharField()
date = serializers.CharField(source="Date")
message_id = serializers.CharField(source="Message_ID", required=False)
reply_to = serializers.EmailField(source="Reply_To", required=False)
attachments = serializers.SerializerMethodField()
def get_attachments(self, email):
@@ -156,6 +194,8 @@ class SendEmailSerializer(serializers.Serializer):
subject = serializers.CharField(required=False)
body = serializers.CharField(required=False)
in_reply_to = serializers.CharField(required=False)
def validate_sender(self, value):
return value
@@ -167,3 +207,13 @@ class SendEmailSerializer(serializers.Serializer):
def validate_bcc(self, value):
return email_utils.prepare_addresses(value, "envelope")
class ComposeSessionSerializer(serializers.Serializer):
uid = serializers.CharField()
class AllowedSenderSerializer(serializers.Serializer):
address = serializers.EmailField()
+5
View File
@@ -8,5 +8,10 @@ from modoboa.webmail import viewsets
router = routers.SimpleRouter()
router.register(r"mailboxes", viewsets.UserMailboxViewSet, basename="webmail-mailbox")
router.register(r"emails", viewsets.UserEmailViewSet, basename="webmail-email")
router.register(
r"compose-sessions",
viewsets.ComposeSessionViewSet,
basename="webmail-compose-session",
)
urlpatterns = router.urls
+164 -10
View File
@@ -1,26 +1,103 @@
"""Webmail viewsets."""
from django import forms
from django.core.validators import validate_email
from django.http import Http404, HttpResponse
from rest_framework import response, viewsets
from rest_framework import parsers, response, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from modoboa.lib.paginator import Paginator
from modoboa.webmail import lib, serializers
from modoboa.webmail.lib import attachments
class UserMailboxViewSet(viewsets.GenericViewSet):
permission_classes = (IsAuthenticated,)
serializer_class = serializers.UserMailboxSerializer
def get_serializer_class(self):
if self.action == "list":
return serializers.UserMailboxesSerializer
if self.action == "quota":
return serializers.UserMailboxQuotaSerializer
if self.action in ["create", "compress", "empty", "delete"]:
return serializers.UserMailboxInputSerializer
if self.action == "rename":
return serializers.UserMailboxUpdateSerializer
return serializers.UserMailboxSerializer
def list(self, request):
parent_mailbox = request.GET.get("mailbox")
imapc = lib.get_imapconnector(request)
mboxes = imapc.getmboxes(request.user, parent_mailbox)
serializer = serializers.UserMailboxSerializer(mboxes, many=True)
serializer = serializers.UserMailboxesSerializer(
{"mailboxes": mboxes, "hdelimiter": imapc.hdelimiter}
)
return response.Response(serializer.data)
@action(methods=["get"], detail=False)
def quota(self, request):
mailbox = request.GET.get("mailbox")
if mailbox is None:
raise Http404
imapc = lib.get_imapconnector(request)
imapc.getquota(mailbox)
serializer = self.get_serializer(imapc)
return response.Response(serializer.data)
def create(self, request):
serializer = serializers.UserMailboxInputSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
imapc = lib.get_imapconnector(request)
imapc.create_folder(
serializer.validated_data["name"],
serializer.validated_data.get("parent_mailbox"),
)
return response.Response(serializer.validated_data, status=201)
@action(methods=["post"], detail=False)
def rename(self, request):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
imapc = lib.get_imapconnector(request)
oldname, oldparent = lib.separate_mailbox(
serializer.validated_data["oldname"], sep=imapc.hdelimiter
)
name = serializer.validated_data["name"]
parent = serializer.validated_data.get("parent_mailbox")
if name != oldname or parent != oldparent:
newname = name if parent is None else imapc.hdelimiter.join([parent, name])
imapc.rename_folder(oldname, newname)
return response.Response(serializer.validated_data)
@action(methods=["post"], detail=False)
def compress(self, request):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
imapc = lib.get_imapconnector(request)
imapc.compact(serializer.validated_data["name"])
return response.Response(status=204)
@action(methods=["post"], detail=False)
def empty(self, request):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
mailbox = serializer.validated_data["name"]
if mailbox != request.user.parameters.get_value("trash_folder"):
raise Http404
imapc = lib.get_imapconnector(request)
imapc.empty(mailbox)
return response.Response(status=204)
@action(methods=["post"], detail=False)
def delete(self, request):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
imapc = lib.get_imapconnector(request)
imapc.delete_folder(serializer.validated_data["name"])
return response.Response(status=204)
class UserEmailViewSet(viewsets.GenericViewSet):
@@ -31,21 +108,31 @@ class UserEmailViewSet(viewsets.GenericViewSet):
return serializers.MoveSelectionSerializer
if self.action == "flag":
return serializers.FlagSelectionSerializer
if self.action == "send":
return serializers.SendEmailSerializer
return serializers.EmailSerializer
def list(self, request):
mailbox = request.GET.get("mailbox", "INBOX")
imapc = lib.get_imapconnector(request)
search = request.GET.get("search")
if search:
imapc.parse_search_parameters("both", search)
total = imapc.messages_count(mbox=mailbox)
messages_per_page = request.user.parameters.get_value("messages_per_page")
paginator = Paginator(total, messages_per_page)
page_num = int(request.GET.get("page", 1))
page = paginator.getpage(int(request.GET.get("page", 1)))
if not page:
return response.Response(
{
"count": 0,
"first_index": 0,
"last_index": 0,
"results": [],
}
)
content = imapc.fetch(page.id_start, page.id_stop, mbox=mailbox)
content = [dict(msg) for msg in content]
serializer = serializers.EmailHeadersSerializer(content, many=True)
results = serializers.EmailHeadersSerializer(content, many=True).data
return response.Response(
{
"count": total,
@@ -53,7 +140,7 @@ class UserEmailViewSet(viewsets.GenericViewSet):
"last_index": (page_num * messages_per_page) + len(content),
"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": serializer.data,
"results": results,
}
)
@@ -121,6 +208,8 @@ class UserEmailViewSet(viewsets.GenericViewSet):
if not mailbox or not mailid:
raise Http404
dformat = request.user.parameters.get_value("displaymode")
if "dformat" in request.GET:
dformat = request.GET.get("dformat")
context = self.request.GET.get("context")
if context and context in ["reply", "forward"]:
modclass = getattr(lib, f"{context.capitalize()}Modifier")
@@ -170,11 +259,76 @@ class UserEmailViewSet(viewsets.GenericViewSet):
resp["Content-Length"] = partdef["size"]
return resp
@action(methods=["post"], detail=False)
def send(self, request):
class ComposeSessionViewSet(viewsets.GenericViewSet):
def get_serializer_class(self):
if self.action == "send":
return serializers.SendEmailSerializer
if self.action == "allowed_senders":
return serializers.AllowedSenderSerializer
return serializers.ComposeSessionSerializer
def create(self, request):
uid = attachments.ComposeSessionManager(request).create()
serializer = self.get_serializer({"uid": uid})
return response.Response(serializer.data, status=201)
@action(methods=["get"], detail=False)
def allowed_senders(self, request):
addresses = [{"address": request.user.email}]
for address in request.user.mailbox.alias_addresses:
try:
validate_email(address)
addresses.append({"address": address})
except forms.ValidationError:
pass
addresses += [
{"address": address}
for address in request.user.mailbox.senderaddress_set.values_list(
"address", flat=True
)
]
serializer = self.get_serializer(addresses, many=True)
return response.Response(serializer.data)
@action(
methods=["get", "post"], detail=True, parser_classes=(parsers.MultiPartParser,)
)
def attachments(self, request, pk):
if request.method == "POST":
uploader = attachments.AttachmentUploadHandler()
request.upload_handlers.insert(0, uploader)
serializer = serializers.AttachmentUploadSerializer(data=request.FILES)
serializer.is_valid(raise_exception=True)
result = attachments.save_attachment(
request, pk, serializer.validated_data["attachment"]
)
return response.Response(result)
manager = attachments.ComposeSessionManager(request)
session = manager.get_content(pk)
serializer = serializers.UploadedAttachmentSerializer(
session["attachments"], many=True
)
return response.Response(serializer.data)
@action(methods=["delete"], detail=True, url_path="attachments/(?P<name>[^/.]+)")
def delete_attachment(self, request, pk, name):
error = attachments.remove_attachment(request, pk, name)
if error:
return response.Response({"error": error}, status=400)
return response.Response(status=204)
@action(methods=["post"], detail=True)
def send(self, request, pk):
"""Send an email based on the given compose session."""
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
status, error = lib.send_mail(request, serializer.validated_data)
manager = attachments.ComposeSessionManager(request)
status, error = lib.send_mail(
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)