mirror of
https://github.com/jwinterm/modoboa.git
synced 2026-10-17 05:41:25 +02:00
New webmail features.
This commit is contained in:
@@ -38,3 +38,6 @@ modoboa.kdev4
|
||||
test_project/cert.crt
|
||||
test_project/cert.key
|
||||
test_project/envfile
|
||||
|
||||
# Docker
|
||||
docker/collections
|
||||
|
||||
@@ -108,6 +108,7 @@ services:
|
||||
dockerfile: docker/Dockerfile.radicale.dev
|
||||
volumes:
|
||||
- ./docker/radicale_config:/etc/radicale
|
||||
- ./docker/collections:/collections
|
||||
|
||||
policyd:
|
||||
container_name: modoboa_policyd
|
||||
|
||||
@@ -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}/`
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -355,6 +355,14 @@ const routes = [
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'forward',
|
||||
name: 'ForwardEmailView',
|
||||
component: () => import('@/views/webmail/ForwardEmailView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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">
|
||||
<{{ email.from_address.address }}>
|
||||
</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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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,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()
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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')} -----"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user