mirror of
https://github.com/jwinterm/modoboa.git
synced 2026-10-20 06:08:35 +02:00
Various improvements
This commit is contained in:
@@ -12,6 +12,10 @@ export default {
|
||||
const params = { mailbox }
|
||||
return repository.get('/webmail/mailboxes/quota/', { params })
|
||||
},
|
||||
getUserMailboxUnseen(mailbox) {
|
||||
const params = { mailbox }
|
||||
return repository.get('webmail/mailboxes/unseen/', { params })
|
||||
},
|
||||
createUserMailbox(body) {
|
||||
return repository.post('/webmail/mailboxes/', body)
|
||||
},
|
||||
@@ -93,17 +97,15 @@ export default {
|
||||
sendEmail(body) {
|
||||
return repository.post('/webmail/emails/send/', body)
|
||||
},
|
||||
getComposeSession(uid) {
|
||||
return repository.get(`/webmail/compose-sessions/${uid}/`)
|
||||
},
|
||||
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/`,
|
||||
|
||||
@@ -50,6 +50,24 @@
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
/>
|
||||
<v-textarea
|
||||
v-else-if="param.widget === 'TextareaField'"
|
||||
v-model="parameters[param.name]"
|
||||
:hint="param.help_text"
|
||||
persistent-hint
|
||||
:error="formErrors[param.name] !== undefined"
|
||||
:error-messages="formErrors[param.name]"
|
||||
:type="
|
||||
param.widget === 'PasswordField' ? 'password' : 'text'
|
||||
"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
/>
|
||||
<HtmlEditor
|
||||
v-else-if="param.widget === 'HTMLField'"
|
||||
v-model="parameters[param.name]"
|
||||
>
|
||||
</HtmlEditor>
|
||||
<v-text-field
|
||||
v-else
|
||||
v-model="parameters[param.name]"
|
||||
@@ -86,6 +104,7 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { useBusStore } from '@/stores'
|
||||
import HtmlEditor from '@/components/tools/HtmlEditor'
|
||||
|
||||
const props = defineProps({
|
||||
app: {
|
||||
|
||||
@@ -31,9 +31,16 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import HtmlEditor from '@/components/tools/HtmlEditor'
|
||||
|
||||
const props = defineProps({
|
||||
editorMode: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const model = defineModel()
|
||||
const emit = defineEmits(['onToggleHtmlMode'])
|
||||
|
||||
@@ -44,4 +51,11 @@ const toggleHtmlMode = () => {
|
||||
htmlMode.value = !htmlMode.value
|
||||
emit('onToggleHtmlMode', htmlMode.value)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.editorMode,
|
||||
(value) => {
|
||||
htmlMode.value = value === 'html'
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -148,6 +148,7 @@
|
||||
</div>
|
||||
<BodyEditor
|
||||
v-model="form.body"
|
||||
:editor-mode="editorMode"
|
||||
@on-toggle-html-mode="(value) => emit('onToggleHtmlMode', value)"
|
||||
/>
|
||||
</v-form>
|
||||
@@ -161,7 +162,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { useAuthStore, useBusStore } from '@/stores'
|
||||
@@ -193,6 +194,7 @@ const authStore = useAuthStore()
|
||||
const allowedSenders = ref([])
|
||||
const attachmentCount = ref(0)
|
||||
const contacts = ref([])
|
||||
const editorMode = ref('plain')
|
||||
const form = ref({})
|
||||
const formRef = ref()
|
||||
const showAttachmentsDialog = ref(false)
|
||||
@@ -293,6 +295,17 @@ const lookForContacts = debounce(async (search) => {
|
||||
}
|
||||
}, 500)
|
||||
|
||||
const initialize = (body) => {
|
||||
if (body.signature) {
|
||||
if (form.value.body) {
|
||||
form.value.body += body.signature
|
||||
} else {
|
||||
form.value.body = body.signature
|
||||
}
|
||||
}
|
||||
editorMode.value = body.editor_format
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.originalEmail,
|
||||
() => {
|
||||
@@ -305,10 +318,12 @@ if (!route.query.uid) {
|
||||
api.createComposeSession().then((resp) => {
|
||||
const query = { ...route.query, uid: resp.data.uid }
|
||||
router.push({ name: route.name, query })
|
||||
initialize(resp.data)
|
||||
})
|
||||
} else {
|
||||
api.getUploadedAttachments(route.query.uid).then((resp) => {
|
||||
attachmentCount.value = resp.data.length
|
||||
api.getComposeSession(route.query.uid).then((resp) => {
|
||||
attachmentCount.value = resp.data.attachments.length
|
||||
initialize(resp.data)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { useBusStore } from '@/stores'
|
||||
@@ -184,7 +184,7 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const { displayNotification } = useBusStore()
|
||||
const { displayNotification, reloadMailboxCounters } = useBusStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
@@ -196,12 +196,15 @@ const selectAll = ref(false)
|
||||
const selection = ref([])
|
||||
const working = ref(false)
|
||||
|
||||
let intervalId = null
|
||||
|
||||
const openEmail = (emailid) => {
|
||||
router.push({
|
||||
name: 'EmailView',
|
||||
query: { mailbox: props.mailbox, mailid: emailid },
|
||||
})
|
||||
}
|
||||
const emit = defineEmits(['reloadContent'])
|
||||
|
||||
const fetchEmails = () => {
|
||||
emails.value = {}
|
||||
@@ -217,6 +220,11 @@ const fetchEmails = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const autoRefreshContent = () => {
|
||||
fetchEmails()
|
||||
emit('refreshContent')
|
||||
}
|
||||
|
||||
const submitSearch = () => {
|
||||
if (search.value) {
|
||||
fetchEmails()
|
||||
@@ -240,6 +248,7 @@ const deleteSelection = () => {
|
||||
working.value = false
|
||||
displayNotification({ msg: $gettext('Message(s) deleted') })
|
||||
fetchEmails()
|
||||
reloadMailboxCounters()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -251,7 +260,7 @@ const markSelectionAsJunk = () => {
|
||||
api.markSelectionAsJunk(route.query.mailbox, selection.value).then(() => {
|
||||
working.value = false
|
||||
displayNotification({ msg: $gettext('Message(s) marked as junk') })
|
||||
fetchEmails()
|
||||
autoRefreshContent()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -263,7 +272,7 @@ const markSelectionAsNotJunk = () => {
|
||||
api.markSelectionAsNotJunk(route.query.mailbox, selection.value).then(() => {
|
||||
working.value = false
|
||||
displayNotification({ msg: $gettext('Message(s) marked as not junk') })
|
||||
fetchEmails()
|
||||
autoRefreshContent()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -277,6 +286,7 @@ const flagSelection = (status) => {
|
||||
selection.value = []
|
||||
displayNotification({ msg: $gettext('Message(s) flagged') })
|
||||
fetchEmails()
|
||||
reloadMailboxCounters()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -285,6 +295,7 @@ const emptyMailbox = () => {
|
||||
api.emptyUserMailbox(route.query.mailbox).then(() => {
|
||||
emails.value = {}
|
||||
loading.value = false
|
||||
reloadMailboxCounters()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -294,6 +305,14 @@ const toggleFollowState = async (email) => {
|
||||
email.flagged = flag === 'flagged'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
intervalId = setInterval(autoRefreshContent, 300 * 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(intervalId)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.mailbox,
|
||||
() => {
|
||||
|
||||
@@ -15,7 +15,15 @@
|
||||
>
|
||||
<v-icon :icon="iconByMailboxType[mailbox.type]" class="mr-4" />
|
||||
<template v-if="props.unseenCounters && mailbox.unseen > 0">
|
||||
<span class="font-weight-bold">
|
||||
<span
|
||||
v-if="mailbox.name === route.query.mailbox"
|
||||
class="font-weight-bold"
|
||||
>
|
||||
{{ getMailboxLabel(mailbox) }} ({{
|
||||
getCurrentMailboxUnseen(mailbox)
|
||||
}})
|
||||
</span>
|
||||
<span v-else class="font-weight-bold">
|
||||
{{ getMailboxLabel(mailbox) }} ({{ mailbox.unseen }})
|
||||
</span>
|
||||
</template>
|
||||
@@ -45,7 +53,9 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useBusStore } from '@/stores'
|
||||
import api from '@/api/webmail'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -73,6 +83,9 @@ const props = defineProps({
|
||||
const model = defineModel()
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const route = useRoute()
|
||||
const busStore = useBusStore()
|
||||
|
||||
const iconByMailboxType = {
|
||||
inbox: 'mdi-inbox',
|
||||
draft: 'mdi-file',
|
||||
@@ -82,6 +95,7 @@ const iconByMailboxType = {
|
||||
normal: 'mdi-folder-outline',
|
||||
}
|
||||
|
||||
const currentMailboxUnseen = ref(null)
|
||||
const hoverStates = ref({})
|
||||
const mailboxStates = ref({})
|
||||
|
||||
@@ -89,6 +103,13 @@ function getMailboxLabel(mailbox) {
|
||||
return mailbox.label.split('/').pop()
|
||||
}
|
||||
|
||||
function getCurrentMailboxUnseen(mailbox) {
|
||||
if (currentMailboxUnseen.value === null) {
|
||||
currentMailboxUnseen.value = mailbox.unseen
|
||||
}
|
||||
return currentMailboxUnseen.value
|
||||
}
|
||||
|
||||
function setHover(mailbox, value) {
|
||||
hoverStates.value[mailbox.name] = value
|
||||
}
|
||||
@@ -128,6 +149,15 @@ function updateSelection(value) {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => busStore.mbCounterKey,
|
||||
() => {
|
||||
api.getUserMailboxUnseen(route.query.mailbox).then((resp) => {
|
||||
currentMailboxUnseen.value = resp.data.counter
|
||||
})
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -20,6 +20,7 @@ const userSettingsMenuItems = computed(() => {
|
||||
// Temporary solution...
|
||||
const appIcons = {
|
||||
contacts: 'mdi-contacts-outline',
|
||||
webmail: 'mdi-at',
|
||||
}
|
||||
|
||||
result.push({
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { useBusStore } from '@/stores'
|
||||
@@ -108,7 +108,7 @@ import api from '@/api/webmail'
|
||||
const { $gettext } = useGettext()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { displayNotification } = useBusStore()
|
||||
const busStore = useBusStore()
|
||||
|
||||
const confirm = ref()
|
||||
const drawer = ref(true)
|
||||
@@ -149,10 +149,12 @@ function openMailbox(mailbox) {
|
||||
})
|
||||
}
|
||||
|
||||
api.getUserMailboxes().then((resp) => {
|
||||
userMailboxes.value = resp.data.mailboxes
|
||||
hdelimiter.value = resp.data.hdelimiter
|
||||
})
|
||||
const fetchUserMailboxes = () => {
|
||||
api.getUserMailboxes().then((resp) => {
|
||||
userMailboxes.value = resp.data.mailboxes
|
||||
hdelimiter.value = resp.data.hdelimiter
|
||||
})
|
||||
}
|
||||
|
||||
const openComposeForm = () => {
|
||||
router.push({ name: 'ComposeEmailView' })
|
||||
@@ -161,9 +163,7 @@ const openComposeForm = () => {
|
||||
const closeMailboxForm = () => {
|
||||
showMailboxForm.value = false
|
||||
editedMailbox.value = null
|
||||
api.getUserMailboxes().then((resp) => {
|
||||
userMailboxes.value = resp.data.mailboxes
|
||||
})
|
||||
fetchUserMailboxes()
|
||||
}
|
||||
|
||||
const openMailboxForm = () => {
|
||||
@@ -185,7 +185,7 @@ const navigateToMailbox = (mailbox) => {
|
||||
|
||||
const compressMailbox = async () => {
|
||||
await api.compressUserMailbox({ name: selectedMailbox.value })
|
||||
displayNotification({ msg: $gettext('Mailbox compressed') })
|
||||
busStore.displayNotification({ msg: $gettext('Mailbox compressed') })
|
||||
}
|
||||
|
||||
const deleteMailbox = async () => {
|
||||
@@ -202,10 +202,18 @@ const deleteMailbox = async () => {
|
||||
return
|
||||
}
|
||||
await api.deleteUserMailbox({ name: selectedMailbox.value })
|
||||
displayNotification({ msg: $gettext('Mailbox deleted') })
|
||||
busStore.displayNotification({ msg: $gettext('Mailbox deleted') })
|
||||
navigateToMailbox('INBOX')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => busStore.dataKey,
|
||||
() => {
|
||||
fetchUserMailboxes()
|
||||
}
|
||||
)
|
||||
|
||||
fetchUserMailboxes()
|
||||
api.getUserMailboxQuota(route.query.mailbox || 'INBOX').then((resp) => {
|
||||
mailboxQuota.value = resp.data
|
||||
})
|
||||
|
||||
@@ -5,20 +5,36 @@ import { ref } from 'vue'
|
||||
export const useBusStore = defineStore('bus', () => {
|
||||
const notification = ref({})
|
||||
const notificationColor = ref('')
|
||||
const dataKey = ref(1)
|
||||
const mbCounterKey = ref(1)
|
||||
|
||||
async function $reset() {
|
||||
notification.value = {}
|
||||
notificationColor.value = ''
|
||||
dataKey.value = 1
|
||||
mbCounterKey.value = 1
|
||||
}
|
||||
|
||||
function displayNotification(options) {
|
||||
notification.value = options.msg
|
||||
notificationColor.value = options.type ? options.type : 'success'
|
||||
}
|
||||
|
||||
function reloadData() {
|
||||
dataKey.value++
|
||||
}
|
||||
|
||||
function reloadMailboxCounters() {
|
||||
mbCounterKey.value++
|
||||
}
|
||||
|
||||
return {
|
||||
notification,
|
||||
notificationColor,
|
||||
displayNotification,
|
||||
reloadMailboxCounters,
|
||||
dataKey,
|
||||
mbCounterKey,
|
||||
$reset,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -167,7 +167,7 @@ import api from '@/api/webmail'
|
||||
import ContactCard from '@/components/webmail/ContactCard.vue'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const { displayNotification } = useBusStore()
|
||||
const { displayNotification, reloadMailboxCounters } = useBusStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
@@ -215,6 +215,7 @@ const fetchMailContent = () => {
|
||||
api
|
||||
.getEmailContent(route.query.mailbox, route.query.mailid, options)
|
||||
.then((resp) => {
|
||||
reloadMailboxCounters()
|
||||
email.value = resp.data
|
||||
nextTick(() => {
|
||||
const iframe = document.createElement('iframe')
|
||||
@@ -293,23 +294,6 @@ const openEmailSourceDialog = async () => {
|
||||
showEmailSource.value = true
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -71,6 +71,8 @@ class Registry:
|
||||
for name, field in list(serializer.fields.items()):
|
||||
if field.default is not empty:
|
||||
data["defaults"][name] = field.default
|
||||
else:
|
||||
data["defaults"][name] = None
|
||||
|
||||
def get_applications(self, level):
|
||||
"""Return all applications registered for level."""
|
||||
@@ -144,7 +146,9 @@ class Registry:
|
||||
if config.get("password"):
|
||||
data["widget"] = "PasswordField"
|
||||
else:
|
||||
data["widget"] = field.__class__.__name__
|
||||
data["widget"] = config.get(
|
||||
"widget", field.__class__.__name__
|
||||
)
|
||||
if data["widget"] == "ChoiceField":
|
||||
data["choices"] = [
|
||||
{"text": value, "value": key}
|
||||
|
||||
@@ -35,7 +35,7 @@ def __build_folders_list(
|
||||
|
||||
def user_mailboxes(request) -> List[UserMailbox]:
|
||||
"""Retrieve list of available mailboxes for given user."""
|
||||
mbc = imaputils.get_imapconnector(request, load_namespaces=False)
|
||||
ret = mbc.getmboxes(request.user)
|
||||
folders = __build_folders_list(ret, request.user, mbc)
|
||||
with imaputils.get_imapconnector(request, load_namespaces=False) as mbc:
|
||||
ret = mbc.getmboxes(request.user)
|
||||
folders = __build_folders_list(ret, request.user, mbc)
|
||||
return folders
|
||||
|
||||
@@ -131,6 +131,7 @@ USER_PREFERENCES_STRUCT = collections.OrderedDict(
|
||||
"enable_links",
|
||||
{
|
||||
"label": gettext_lazy("Enable HTML links display"),
|
||||
"display": "displaymode=html",
|
||||
"help_text": gettext_lazy(
|
||||
"Enable/Disable HTML links display"
|
||||
),
|
||||
@@ -150,11 +151,9 @@ USER_PREFERENCES_STRUCT = collections.OrderedDict(
|
||||
(
|
||||
"refresh_interval",
|
||||
{
|
||||
"label": gettext_lazy(
|
||||
"Number of displayed emails per page"
|
||||
),
|
||||
"label": gettext_lazy("Listing refresh rate"),
|
||||
"help_text": gettext_lazy(
|
||||
"Sets the maximum number of messages displayed in a page"
|
||||
"Automatic listing refresh rate (in secconds)"
|
||||
),
|
||||
},
|
||||
),
|
||||
@@ -237,6 +236,7 @@ USER_PREFERENCES_STRUCT = collections.OrderedDict(
|
||||
"help_text": gettext_lazy(
|
||||
"User defined email signature"
|
||||
),
|
||||
"widget": "HTMLField",
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -42,9 +42,13 @@ class ImapEmail(Email):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.request = request
|
||||
self.imapc = get_imapconnector(request)
|
||||
self.imapc = self.imapc.__enter__()
|
||||
self.mbox, self.mailid = self.mailid.split(":")
|
||||
self.attachments: dict[str, str] = {}
|
||||
|
||||
def __del__(self):
|
||||
self.imapc.__exit__()
|
||||
|
||||
def fetch_headers(self, raw_addresses: bool = False) -> None:
|
||||
"""Fetch message headers from server."""
|
||||
msg = self.imapc.fetchmail(
|
||||
|
||||
@@ -18,7 +18,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.
|
||||
@@ -147,7 +147,7 @@ class IMAPconnector:
|
||||
)
|
||||
unseen_pattern = re.compile(r"[^\(]+\(UNSEEN (\d+)\)")
|
||||
|
||||
def __init__(self, user=None, password=None, load_namespaces: bool = True) -> None:
|
||||
def __init__(self, user: str, password: str, with_namespaces: bool = True) -> None:
|
||||
self.__hdelimiter: Optional[str] = None
|
||||
self.__ns_prefixes: dict = {}
|
||||
self.quota_usage: int = -1
|
||||
@@ -157,9 +157,18 @@ class IMAPconnector:
|
||||
self.conf = dict(param_tools.get_global_parameters("webmail"))
|
||||
self.address = self.conf["imap_server"]
|
||||
self.port = self.conf["imap_port"]
|
||||
self.login(user, password)
|
||||
if load_namespaces:
|
||||
self.user = user
|
||||
self.password = password
|
||||
self.with_namespaces = with_namespaces
|
||||
|
||||
def __enter__(self):
|
||||
self.login(self.user, self.password)
|
||||
if self.load_namespaces:
|
||||
self.load_namespaces()
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
self.logout()
|
||||
|
||||
def _cmd(self, name: str, *args, **kwargs) -> Optional[list]:
|
||||
"""IMAP command wrapper.
|
||||
|
||||
@@ -15,31 +15,11 @@ from django.core.mail import EmailMessage, EmailMultiAlternatives
|
||||
from modoboa.core import models as core_models
|
||||
from modoboa.parameters import tools as param_tools
|
||||
from modoboa.webmail.lib.attachments import create_mail_attachment
|
||||
from modoboa.webmail.lib.utils import html2plaintext
|
||||
|
||||
from . import get_imapconnector
|
||||
|
||||
|
||||
def html2plaintext(content: str) -> str:
|
||||
"""HTML to plain text translation.
|
||||
|
||||
:param content: some HTML content
|
||||
"""
|
||||
if not content:
|
||||
return ""
|
||||
html = lxml.html.fromstring(content)
|
||||
plaintext = ""
|
||||
for ch in html.iter():
|
||||
p = None
|
||||
if ch.text is not None:
|
||||
p = ch.text.strip("\r\t\n")
|
||||
if ch.tag == "img":
|
||||
p = ch.get("alt")
|
||||
if p is None:
|
||||
continue
|
||||
plaintext += p + "\n"
|
||||
return plaintext
|
||||
|
||||
|
||||
def make_body_images_inline(body: str) -> Tuple[str, list]:
|
||||
"""Look for images inside the body and make them inline.
|
||||
|
||||
@@ -183,5 +163,6 @@ def send_mail(
|
||||
|
||||
# Copy message to sent folder
|
||||
sentfolder = request.user.parameters.get_value("sent_folder")
|
||||
get_imapconnector(request).push_mail(sentfolder, msg.message())
|
||||
with get_imapconnector(request) as imapc:
|
||||
imapc.push_mail(sentfolder, msg.message())
|
||||
return True, None
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tools to deal with message signatures."""
|
||||
|
||||
from modoboa.webmail.lib.utils import html2plaintext
|
||||
|
||||
|
||||
class EmailSignature:
|
||||
"""User signature.
|
||||
@@ -11,13 +13,13 @@ class EmailSignature:
|
||||
self._sig = ""
|
||||
dformat = user.parameters.get_value("editor")
|
||||
content = user.parameters.get_value("signature")
|
||||
if len(content):
|
||||
if content and len(content):
|
||||
getattr(self, f"_format_sig_{dformat}")(content)
|
||||
|
||||
def _format_sig_plain(self, content):
|
||||
self._sig = f"""
|
||||
---
|
||||
{content}"""
|
||||
{html2plaintext(content)}"""
|
||||
|
||||
def _format_sig_html(self, content):
|
||||
content = f"---<br>{content}"
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
"""Misc. utilities."""
|
||||
|
||||
import lxml
|
||||
|
||||
|
||||
def html2plaintext(content: str) -> str:
|
||||
"""HTML to plain text translation.
|
||||
|
||||
:param content: some HTML content
|
||||
"""
|
||||
if not content:
|
||||
return ""
|
||||
html = lxml.html.fromstring(content)
|
||||
plaintext = ""
|
||||
for ch in html.iter():
|
||||
p = None
|
||||
if ch.text is not None:
|
||||
p = ch.text.strip("\r\t\n")
|
||||
if ch.tag == "img":
|
||||
p = ch.get("alt")
|
||||
if p is None:
|
||||
continue
|
||||
plaintext += p + "\n"
|
||||
return plaintext
|
||||
|
||||
|
||||
def decode_payload(encoding, payload):
|
||||
"""Decode the payload according to the given encoding
|
||||
|
||||
@@ -14,7 +14,7 @@ class IMAP4Mock:
|
||||
|
||||
def _simple_command(self, name, *args, **kwargs):
|
||||
if name == "CAPABILITY":
|
||||
self.untagged_responses["CAPABILITY"] = [b""]
|
||||
self.untagged_responses["CAPABILITY"] = [b"QUOTA"]
|
||||
elif name == "LIST":
|
||||
self.untagged_responses["LIST"] = [b'() "." "INBOX"']
|
||||
elif name == "NAMESPACE":
|
||||
|
||||
@@ -4,7 +4,7 @@ from rest_framework import serializers
|
||||
|
||||
from modoboa.lib import email_utils
|
||||
from modoboa.webmail import constants
|
||||
from modoboa.webmail.lib import imapheader
|
||||
from modoboa.webmail.lib import imapheader, signature
|
||||
|
||||
|
||||
class GlobalParametersSerializer(serializers.Serializer):
|
||||
@@ -41,6 +41,7 @@ class UserPreferencesSerializer(serializers.Serializer):
|
||||
default=constants.DisplayMode.PLAIN.value, choices=constants.DISPLAY_MODES
|
||||
)
|
||||
signature = serializers.CharField(required=False)
|
||||
signature = serializers.CharField(required=False)
|
||||
|
||||
|
||||
class UserMailboxSerializer(serializers.Serializer):
|
||||
@@ -66,6 +67,11 @@ class UserMailboxQuotaSerializer(serializers.Serializer):
|
||||
limit = serializers.IntegerField(source="quota_limit")
|
||||
|
||||
|
||||
class UserMailboxUnseenSerializer(serializers.Serializer):
|
||||
|
||||
counter = serializers.IntegerField()
|
||||
|
||||
|
||||
class UserMailboxesSerializer(serializers.Serializer):
|
||||
|
||||
mailboxes = UserMailboxSerializer(many=True)
|
||||
@@ -211,7 +217,16 @@ class SendEmailSerializer(serializers.Serializer):
|
||||
|
||||
class ComposeSessionSerializer(serializers.Serializer):
|
||||
|
||||
attachments = UploadedAttachmentSerializer(many=True, required=False)
|
||||
uid = serializers.CharField()
|
||||
signature = serializers.SerializerMethodField()
|
||||
editor_format = serializers.SerializerMethodField()
|
||||
|
||||
def get_editor_format(self, obj):
|
||||
return self.context["request"].user.parameters.get_value("editor")
|
||||
|
||||
def get_signature(self, obj):
|
||||
return str(signature.EmailSignature(self.context["request"].user))
|
||||
|
||||
|
||||
class AllowedSenderSerializer(serializers.Serializer):
|
||||
|
||||
@@ -90,6 +90,15 @@ class UserMailboxViewSetTestCase(WebmailTestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["usage"], -1)
|
||||
|
||||
def test_unseen(self):
|
||||
self.authenticate()
|
||||
url = reverse("v2:webmail-mailbox-unseen")
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
response = self.client.get(f"{url}?mailbox=INBOX")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["counter"], 10)
|
||||
|
||||
def test_create(self):
|
||||
self.authenticate()
|
||||
url = reverse("v2:webmail-mailbox-list")
|
||||
@@ -247,6 +256,13 @@ class ComposeSessionViewSetTestCase(WebmailTestCase):
|
||||
self.authenticate()
|
||||
self._create_compose_session()
|
||||
|
||||
def test_get(self):
|
||||
self.authenticate()
|
||||
uid = self._create_compose_session()
|
||||
url = reverse("v2:webmail-compose-session-detail", args=[uid])
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_get_allowed_senders(self):
|
||||
self.authenticate()
|
||||
url = reverse("v2:webmail-compose-session-allowed-senders")
|
||||
@@ -257,12 +273,10 @@ class ComposeSessionViewSetTestCase(WebmailTestCase):
|
||||
def test_attachments(self):
|
||||
self.authenticate()
|
||||
uid = self._create_compose_session()
|
||||
url = reverse("v2:webmail-compose-session-attachments", args=[uid])
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
manager = ComposeSessionManager(self.user.username)
|
||||
self.set_global_parameters({"max_attachment_size": "10"})
|
||||
url = reverse("v2:webmail-compose-session-attachments", args=[uid])
|
||||
with self.settings(MEDIA_ROOT=self.workdir):
|
||||
response = self.client.post(url, {"attachment": get_gif()})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
+95
-80
@@ -22,6 +22,8 @@ class UserMailboxViewSet(viewsets.GenericViewSet):
|
||||
def get_serializer_class(self):
|
||||
if self.action == "quota":
|
||||
return serializers.UserMailboxQuotaSerializer
|
||||
if self.action == "unseen":
|
||||
return serializers.UserMailboxUnseenSerializer
|
||||
if self.action in ["create", "compress", "empty", "delete"]:
|
||||
return serializers.UserMailboxInputSerializer
|
||||
if self.action == "rename":
|
||||
@@ -30,8 +32,8 @@ class UserMailboxViewSet(viewsets.GenericViewSet):
|
||||
|
||||
def list(self, request):
|
||||
parent_mailbox = request.GET.get("mailbox")
|
||||
imapc = lib.get_imapconnector(request)
|
||||
mboxes = imapc.getmboxes(request.user, parent_mailbox)
|
||||
with lib.get_imapconnector(request) as imapc:
|
||||
mboxes = imapc.getmboxes(request.user, parent_mailbox)
|
||||
serializer = self.get_serializer(
|
||||
{"mailboxes": mboxes, "hdelimiter": imapc.hdelimiter}
|
||||
)
|
||||
@@ -42,42 +44,56 @@ class UserMailboxViewSet(viewsets.GenericViewSet):
|
||||
mailbox = request.GET.get("mailbox")
|
||||
if mailbox is None:
|
||||
raise Http404
|
||||
imapc = lib.get_imapconnector(request)
|
||||
imapc.getquota(mailbox)
|
||||
serializer = self.get_serializer(imapc)
|
||||
with lib.get_imapconnector(request) as imapc:
|
||||
imapc.getquota(mailbox)
|
||||
serializer = self.get_serializer(imapc)
|
||||
return response.Response(serializer.data)
|
||||
|
||||
@action(methods=["get"], detail=False)
|
||||
def unseen(self, request):
|
||||
"""Get unseen messages counter for given mailbox."""
|
||||
mailbox = request.GET.get("mailbox")
|
||||
if mailbox is None:
|
||||
raise Http404
|
||||
with lib.get_imapconnector(request) as imapc:
|
||||
serializer = self.get_serializer(
|
||||
{"counter": imapc.unseen_messages(mailbox)}
|
||||
)
|
||||
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"),
|
||||
)
|
||||
with lib.get_imapconnector(request) as imapc:
|
||||
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)
|
||||
with lib.get_imapconnector(request) as imapc:
|
||||
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"])
|
||||
with lib.get_imapconnector(request) as imapc:
|
||||
imapc.compact(serializer.validated_data["name"])
|
||||
return response.Response(status=204)
|
||||
|
||||
@action(methods=["post"], detail=False)
|
||||
@@ -87,16 +103,16 @@ class UserMailboxViewSet(viewsets.GenericViewSet):
|
||||
mailbox = serializer.validated_data["name"]
|
||||
if mailbox != request.user.parameters.get_value("trash_folder"):
|
||||
raise Http404
|
||||
imapc = lib.get_imapconnector(request)
|
||||
imapc.empty(mailbox)
|
||||
with lib.get_imapconnector(request) as imapc:
|
||||
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"])
|
||||
with lib.get_imapconnector(request) as imapc:
|
||||
imapc.delete_folder(serializer.validated_data["name"])
|
||||
return response.Response(status=204)
|
||||
|
||||
|
||||
@@ -115,29 +131,29 @@ class UserEmailViewSet(viewsets.GenericViewSet):
|
||||
|
||||
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:
|
||||
serializer = self.get_serializer(
|
||||
{
|
||||
"count": 0,
|
||||
"first_index": 0,
|
||||
"last_index": 0,
|
||||
"prev_page": None,
|
||||
"next_page": None,
|
||||
"results": [],
|
||||
}
|
||||
)
|
||||
return response.Response(serializer.data)
|
||||
with lib.get_imapconnector(request) as imapc:
|
||||
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:
|
||||
serializer = self.get_serializer(
|
||||
{
|
||||
"count": 0,
|
||||
"first_index": 0,
|
||||
"last_index": 0,
|
||||
"prev_page": None,
|
||||
"next_page": None,
|
||||
"results": [],
|
||||
}
|
||||
)
|
||||
return response.Response(serializer.data)
|
||||
|
||||
content = imapc.fetch(page.id_start, page.id_stop, mbox=mailbox)
|
||||
content = imapc.fetch(page.id_start, page.id_stop, mbox=mailbox)
|
||||
content = [dict(msg) for msg in content]
|
||||
serializer = self.get_serializer(
|
||||
{
|
||||
@@ -155,12 +171,12 @@ class UserEmailViewSet(viewsets.GenericViewSet):
|
||||
"""Move selected messages to the given mailbox."""
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
mbc = lib.get_imapconnector(request)
|
||||
mbc.move(
|
||||
",".join(serializer.validated_data["selection"]),
|
||||
serializer.validated_data["mailbox"],
|
||||
destination,
|
||||
)
|
||||
with lib.get_imapconnector(request) as mbc:
|
||||
mbc.move(
|
||||
",".join(serializer.validated_data["selection"]),
|
||||
serializer.validated_data["mailbox"],
|
||||
destination,
|
||||
)
|
||||
return len(serializer.validated_data["selection"])
|
||||
|
||||
@action(
|
||||
@@ -202,10 +218,11 @@ class UserEmailViewSet(viewsets.GenericViewSet):
|
||||
def flag(self, request):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
imapc = lib.get_imapconnector(request)
|
||||
getattr(imapc, f"mark_messages_{serializer.validated_data['status']}")(
|
||||
serializer.validated_data["mailbox"], serializer.validated_data["selection"]
|
||||
)
|
||||
with lib.get_imapconnector(request) as imapc:
|
||||
getattr(imapc, f"mark_messages_{serializer.validated_data['status']}")(
|
||||
serializer.validated_data["mailbox"],
|
||||
serializer.validated_data["selection"],
|
||||
)
|
||||
return response.Response({"count": len(serializer.validated_data["selection"])})
|
||||
|
||||
@action(methods=["get"], detail=False)
|
||||
@@ -256,8 +273,8 @@ class UserEmailViewSet(viewsets.GenericViewSet):
|
||||
partnum = request.GET.get("partnum")
|
||||
if not mailbox or not mailbox or not partnum:
|
||||
raise Http404
|
||||
imapc = lib.get_imapconnector(request)
|
||||
partdef, payload = imapc.fetchpart(mailid, mailbox, partnum)
|
||||
with lib.get_imapconnector(request) as imapc:
|
||||
partdef, payload = imapc.fetchpart(mailid, mailbox, partnum)
|
||||
resp = HttpResponse(lib.decode_payload(partdef["encoding"], payload))
|
||||
resp["Content-Type"] = partdef["Content-Type"]
|
||||
resp["Content-Transfer-Encoding"] = partdef["encoding"]
|
||||
@@ -285,6 +302,14 @@ class ComposeSessionViewSet(viewsets.GenericViewSet):
|
||||
return serializers.AllowedSenderSerializer
|
||||
return serializers.ComposeSessionSerializer
|
||||
|
||||
def retrieve(self, request, pk=None):
|
||||
manager = attachments.ComposeSessionManager(request.user.username)
|
||||
session = manager.get_content(pk)
|
||||
serializer = self.get_serializer(
|
||||
{"uid": pk, "attachments": session["attachments"]}
|
||||
)
|
||||
return response.Response(serializer.data)
|
||||
|
||||
def create(self, request):
|
||||
uid = attachments.ComposeSessionManager(request.user.username).create()
|
||||
serializer = self.get_serializer({"uid": uid})
|
||||
@@ -308,31 +333,21 @@ class ComposeSessionViewSet(viewsets.GenericViewSet):
|
||||
serializer = self.get_serializer(addresses, many=True)
|
||||
return response.Response(serializer.data)
|
||||
|
||||
@action(
|
||||
methods=["get", "post"], detail=True, parser_classes=(parsers.MultiPartParser,)
|
||||
)
|
||||
@action(methods=["post"], detail=True, parser_classes=(parsers.MultiPartParser,))
|
||||
def attachments(self, request, pk):
|
||||
if request.method == "POST":
|
||||
manager = attachments.ComposeSessionManager(request.user.username)
|
||||
serializer = serializers.AttachmentUploadSerializer(data=request.FILES)
|
||||
if not serializer.is_valid():
|
||||
errors = serializer.errors
|
||||
if request.upload_handlers[0].toobig:
|
||||
errors["attachment"] = [
|
||||
_("Attachment is too big (limit: %s)")
|
||||
% request.upload_handlers[0].maxsize
|
||||
]
|
||||
serializer = serializers.AttachmentUploadSerializer(data=request.FILES)
|
||||
if not serializer.is_valid():
|
||||
errors = serializer.errors
|
||||
if request.upload_handlers[0].toobig:
|
||||
errors["attachment"] = [
|
||||
_("Attachment is too big (limit: %s)")
|
||||
% request.upload_handlers[0].maxsize
|
||||
]
|
||||
return response.Response(errors, status=400)
|
||||
result = attachments.save_attachment(
|
||||
request, pk, serializer.validated_data["attachment"]
|
||||
)
|
||||
return response.Response(result)
|
||||
manager = attachments.ComposeSessionManager(request.user.username)
|
||||
session = manager.get_content(pk)
|
||||
serializer = serializers.UploadedAttachmentSerializer(
|
||||
session["attachments"], many=True
|
||||
result = attachments.save_attachment(
|
||||
request, pk, serializer.validated_data["attachment"]
|
||||
)
|
||||
return response.Response(serializer.data)
|
||||
return response.Response(result)
|
||||
|
||||
@action(methods=["delete"], detail=True, url_path="attachments/(?P<name>[^/.]+)")
|
||||
def delete_attachment(self, request, pk, name):
|
||||
|
||||
Reference in New Issue
Block a user