mirror of
https://github.com/jwinterm/modoboa.git
synced 2026-10-17 05:41:25 +02:00
WIP: Webmail
This commit is contained in:
@@ -37,3 +37,4 @@ modoboa.kdev4
|
||||
.kateproject
|
||||
test_project/cert.crt
|
||||
test_project/cert.key
|
||||
test_project/envfile
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"axios": "^1.8.2",
|
||||
"core-js": "^3.37.1",
|
||||
"debounce": "^2.1.0",
|
||||
"filesize": "^10.1.6",
|
||||
"js-cookie": "^3.0.5",
|
||||
"luxon": "^3.4.4",
|
||||
"oidc-client-ts": "^3.0.1",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import repository from './repository'
|
||||
|
||||
export default {
|
||||
getUserMailboxes(mailbox) {
|
||||
const params = {}
|
||||
if (mailbox) {
|
||||
params.mailbox = mailbox
|
||||
}
|
||||
return repository.get('/webmail/mailboxes/', { params })
|
||||
},
|
||||
getMailboxEmails(mailbox, options) {
|
||||
const params = {
|
||||
mailbox,
|
||||
...options,
|
||||
}
|
||||
return repository.get(`/webmail/emails/`, { params })
|
||||
},
|
||||
getEmailContent(mailbox, mailid) {
|
||||
const params = {
|
||||
mailbox,
|
||||
mailid,
|
||||
}
|
||||
return repository.get(`/webmail/emails/content/`, { params })
|
||||
},
|
||||
}
|
||||
@@ -21,7 +21,11 @@
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<v-list nav>
|
||||
<v-list
|
||||
:density="layoutStore.compactLeftMenu ? 'compact' : 'default'"
|
||||
:lines="layoutStore.compactLeftMenu ? false : true"
|
||||
nav
|
||||
>
|
||||
<template v-for="item in menuItems" :key="item.text">
|
||||
<template v-if="displayMenuItem(item)">
|
||||
<v-list-subheader v-if="item.subheader" class="text-white">
|
||||
@@ -89,7 +93,7 @@
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useGlobalStore, useAuthStore } from '@/stores'
|
||||
import { useGlobalStore, useAuthStore, useLayoutStore } from '@/stores'
|
||||
|
||||
const props = defineProps({
|
||||
color: {
|
||||
@@ -109,6 +113,7 @@ const props = defineProps({
|
||||
|
||||
const globalStore = useGlobalStore()
|
||||
const authStore = useAuthStore()
|
||||
const layoutStore = useLayoutStore()
|
||||
const router = useRouter()
|
||||
|
||||
const rail = ref(false)
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<v-card class="mt-6 mb-2">
|
||||
<v-toolbar color="white" flat>
|
||||
<v-checkbox class="mr-4" hide-details />
|
||||
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
:placeholder="$gettext('Search')"
|
||||
variant="outlined"
|
||||
single-line
|
||||
flat
|
||||
hide-details
|
||||
density="compact"
|
||||
class="flex-grow-0 w-33 mr-4"
|
||||
></v-text-field>
|
||||
</v-toolbar>
|
||||
</v-card>
|
||||
<v-skeleton-loader v-if="loading" type="card@2"></v-skeleton-loader>
|
||||
<template v-else>
|
||||
<v-card
|
||||
v-for="email in emails"
|
||||
:key="email.imapid"
|
||||
density="compact"
|
||||
class="mb-2"
|
||||
>
|
||||
<v-card-text
|
||||
class="d-flex align-center"
|
||||
:class="{ 'font-weight-bold': email.style === 'unseen' }"
|
||||
>
|
||||
<v-checkbox :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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import api from '@/api/webmail'
|
||||
|
||||
const props = defineProps({
|
||||
mailbox: {
|
||||
type: String,
|
||||
default: 'INBOX',
|
||||
},
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const emails = ref([])
|
||||
const search = ref('')
|
||||
|
||||
const openEmail = (emailid) => {
|
||||
router.push({
|
||||
name: 'EmailView',
|
||||
query: { mailbox: props.mailbox, mailid: emailid },
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.mailbox,
|
||||
(value) => {
|
||||
emails.value = []
|
||||
loading.value = true
|
||||
api
|
||||
.getMailboxEmails(value, {})
|
||||
.then((resp) => {
|
||||
emails.value = resp.data
|
||||
loading.value = false
|
||||
})
|
||||
.catch(() => {
|
||||
loading.value = false
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-card-text {
|
||||
padding: 0;
|
||||
}
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-for="mailbox in props.mailboxes"
|
||||
:key="mailbox.name"
|
||||
class="py-1 pl-3 text-body-2"
|
||||
>
|
||||
<div
|
||||
:class="getBackgroundColorClass(mailbox)"
|
||||
class="d-flex mailbox pa-1 align-center"
|
||||
@click="$emit('mailboxSelected', mailbox)"
|
||||
@mouseover="setHover(mailbox, true)"
|
||||
@mouseleave="setHover(mailbox, false)"
|
||||
>
|
||||
<v-icon :icon="iconByMailboxType[mailbox.type]" class="mr-4" />
|
||||
{{ getMailboxLabel(mailbox) }}
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
v-if="mailbox.sub"
|
||||
:icon="getMailboxState(mailbox) ? 'mdi-minus' : 'mdi-plus'"
|
||||
size="xsmall"
|
||||
variant="flat"
|
||||
color="transparent"
|
||||
@click.stop="toggleMailbox(mailbox)"
|
||||
/>
|
||||
</div>
|
||||
<MailboxList
|
||||
v-if="getMailboxState(mailbox) && mailbox.sub && mailbox.sub.length"
|
||||
:mailboxes="mailbox.sub"
|
||||
class="mt-1"
|
||||
@mailbox-selected="(mailbox) => $emit('mailboxSelected', mailbox)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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,
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['mailboxSelected'])
|
||||
|
||||
const iconByMailboxType = {
|
||||
inbox: 'mdi-inbox',
|
||||
draft: 'mdi-file',
|
||||
junk: 'mdi-fire',
|
||||
sent: 'mdi-email-fast-outline',
|
||||
trash: 'mdi-trash-can-outline',
|
||||
normal: 'mdi-folder-outline',
|
||||
}
|
||||
|
||||
const hoverStates = ref({})
|
||||
const mailboxStates = ref({})
|
||||
|
||||
function getMailboxLabel(mailbox) {
|
||||
return mailbox.label.split('/').pop()
|
||||
}
|
||||
|
||||
function setHover(mailbox, value) {
|
||||
hoverStates.value[mailbox.name] = value
|
||||
}
|
||||
|
||||
function getBackgroundColorClass(mailbox) {
|
||||
if (route.query.mailbox === mailbox.name) {
|
||||
return 'bg-primary-lighten-1'
|
||||
}
|
||||
return hoverStates.value[mailbox.name] ? 'bg-primary-lighten-1' : 'bg-primary'
|
||||
}
|
||||
|
||||
function getMailboxState(mailbox) {
|
||||
return mailboxStates.value[mailbox.name]
|
||||
}
|
||||
|
||||
function toggleMailbox(mailbox) {
|
||||
if (!mailboxStates.value[mailbox.name]) {
|
||||
mailboxStates.value[mailbox.name] = false
|
||||
}
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.mailbox {
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
border-radius: 5px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,8 @@
|
||||
<template>
|
||||
<v-app>
|
||||
<NavBar :color="color" :menu-items="menuItems" />
|
||||
<slot name="navbar">
|
||||
<NavBar :color="color" :menu-items="menuItems" />
|
||||
</slot>
|
||||
<TopMenu :user="authUser" />
|
||||
<ConnectedView />
|
||||
<v-snackbar
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<ConnectedLayout>
|
||||
<template #navbar>
|
||||
<v-navigation-drawer
|
||||
v-model="drawer"
|
||||
:rail="rail"
|
||||
permanent
|
||||
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>
|
||||
|
||||
<MailboxList
|
||||
:mailboxes="userMailboxes"
|
||||
class="mr-2"
|
||||
@mailbox-selected="openMailbox"
|
||||
/>
|
||||
</v-navigation-drawer>
|
||||
</template>
|
||||
</ConnectedLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ConnectedLayout from '@/layouts/connected/ConnectedLayout.vue'
|
||||
import MailboxList from '@/components/webmail/MailboxList.vue'
|
||||
import api from '@/api/webmail'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const drawer = ref(true)
|
||||
const rail = ref(false)
|
||||
const userMailboxes = ref([])
|
||||
|
||||
function openMailbox(mailbox) {
|
||||
console.log(mailbox)
|
||||
router.push({
|
||||
name: 'MailboxView',
|
||||
query: { mailbox: mailbox.name },
|
||||
})
|
||||
}
|
||||
|
||||
api.getUserMailboxes().then((resp) => {
|
||||
userMailboxes.value = resp.data
|
||||
})
|
||||
</script>
|
||||
@@ -1,5 +1,6 @@
|
||||
import gettext from './gettext'
|
||||
import { DateTime } from 'luxon'
|
||||
import { filesize } from 'filesize'
|
||||
|
||||
const { $gettext } = gettext
|
||||
|
||||
@@ -17,5 +18,8 @@ export default {
|
||||
clamp = clamp || '...'
|
||||
return value.length > length ? value.slice(0, length) + clamp : value
|
||||
}
|
||||
app.config.globalProperties.$filesize = (value) => {
|
||||
return filesize(value, { locale: gettext.current })
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -270,7 +270,6 @@ const routes = [
|
||||
},
|
||||
{
|
||||
path: '/user',
|
||||
component: () => import('@/layouts/user/UserLayout.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
},
|
||||
@@ -285,6 +284,7 @@ const routes = [
|
||||
},
|
||||
{
|
||||
path: 'contacts',
|
||||
component: () => import('@/layouts/user/UserLayout.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
},
|
||||
@@ -301,6 +301,7 @@ const routes = [
|
||||
},
|
||||
{
|
||||
path: 'calendars',
|
||||
component: () => import('@/layouts/user/UserLayout.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
},
|
||||
@@ -315,6 +316,31 @@ const routes = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'webmail',
|
||||
component: () => import('@/layouts/webmail/WebmailLayout.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'MailboxView',
|
||||
component: () => import('@/views/webmail/MailboxView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'view',
|
||||
name: 'EmailView',
|
||||
component: () => import('@/views/webmail/EmailView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@ import { defineStore } from 'pinia'
|
||||
|
||||
export const useLayoutStore = defineStore('layout', () => {
|
||||
const leftMenuItems = ref([])
|
||||
const compactLeftMenu = ref(false)
|
||||
|
||||
function setLeftMenuItems(items) {
|
||||
leftMenuItems.value = items
|
||||
@@ -10,10 +11,12 @@ export const useLayoutStore = defineStore('layout', () => {
|
||||
|
||||
async function $reset() {
|
||||
leftMenuItems.value = []
|
||||
compactLeftMenu.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
leftMenuItems,
|
||||
compactLeftMenu,
|
||||
setLeftMenuItems,
|
||||
$reset,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<v-toolbar color="white" class="mt-12">
|
||||
<v-btn variant="tonal" @click="close">
|
||||
<v-icon icon="mdi-arrow-left" />
|
||||
{{ $gettext('Back') }}
|
||||
</v-btn>
|
||||
<v-btn color="primary">
|
||||
{{ $gettext('Reply') }}
|
||||
</v-btn>
|
||||
</v-toolbar>
|
||||
|
||||
<div v-if="email" class="mt-4">
|
||||
<h3>{{ email.subject }}</h3>
|
||||
<div>{{ email.from_address.name }}</div>
|
||||
<iframe class="email-frame" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { nextTick, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import api from '@/api/webmail'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const email = ref(null)
|
||||
|
||||
onMounted(() => {
|
||||
api.getEmailContent(route.query.mailbox, route.query.mailid).then((resp) => {
|
||||
email.value = resp.data
|
||||
nextTick(() => {
|
||||
const iframe = document.createElement('iframe')
|
||||
iframe.classList.add('email-frame')
|
||||
document.querySelector('iframe').replaceWith(iframe)
|
||||
const iframeDoc = iframe.contentDocument
|
||||
iframeDoc.write(email.value.body)
|
||||
iframeDoc.close()
|
||||
})
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.email-frame {
|
||||
width: 100%;
|
||||
min-height: 1000px;
|
||||
border: none;
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<div class="text-h5 ml-4">
|
||||
{{ $gettext('Webmail') }}
|
||||
</div>
|
||||
<EmailList :mailbox="$route.query.mailbox || 'INBOX'" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import EmailList from '@/components/webmail/EmailList.vue'
|
||||
</script>
|
||||
@@ -1044,6 +1044,11 @@ file-entry-cache@^8.0.0:
|
||||
dependencies:
|
||||
flat-cache "^4.0.0"
|
||||
|
||||
filesize@^10.1.6:
|
||||
version "10.1.6"
|
||||
resolved "https://registry.yarnpkg.com/filesize/-/filesize-10.1.6.tgz#31194da825ac58689c0bce3948f33ce83aabd361"
|
||||
integrity sha512-sJslQKU2uM33qH5nqewAwVB2QgR6w1aMNsYUp3aN5rMRyXEwJGmZvaWzeJFNTOXWlHQyBFCWrdj3fV/fsTOX8w==
|
||||
|
||||
fill-range@^7.1.1:
|
||||
version "7.1.1"
|
||||
resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""AppConfig for radicale."""
|
||||
"""AppConfig for calendars."""
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
@@ -166,13 +166,13 @@ class AccountViewSet(core_v1_viewsets.AccountViewSet):
|
||||
"description": _("Address book"),
|
||||
"url": "/user/contacts",
|
||||
},
|
||||
# {
|
||||
# "name": "webmail",
|
||||
# "label": _("Webmail"),
|
||||
# "icon": "mdi-at",
|
||||
# "description": _("Webmail"),
|
||||
# "url": "/user/webmail",
|
||||
# }
|
||||
{
|
||||
"name": "webmail",
|
||||
"label": _("Webmail"),
|
||||
"icon": "mdi-at",
|
||||
"description": _("Webmail"),
|
||||
"url": "/user/webmail",
|
||||
},
|
||||
]
|
||||
apps += exts_pool.get_available_apps()
|
||||
serializer = serializers.ModoboaApplicationSerializer(apps, many=True)
|
||||
|
||||
@@ -35,7 +35,7 @@ class EmailAddress:
|
||||
else:
|
||||
self.fulladdress = self.address
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return self.fulladdress
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ class Email:
|
||||
|
||||
return self._body
|
||||
|
||||
def get_header(self, msg, header):
|
||||
def get_header(self, msg, header: str) -> str:
|
||||
# msg parameter to maintain compatibility with
|
||||
# modoboa_webmail.lib.imapemail.ImapEmail
|
||||
if header in msg:
|
||||
|
||||
@@ -22,6 +22,7 @@ urlpatterns = [
|
||||
path("", include("modoboa.sievefilters.api.v2.urls")),
|
||||
path("", include("modoboa.contacts.urls")),
|
||||
path("", include("modoboa.calendars.urls")),
|
||||
path("webmail/", include("modoboa.webmail.urls")),
|
||||
]
|
||||
|
||||
urlpatterns += exts_pool.get_urls(category="api")
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
default_app_config = "modoboa.webmail.apps.WebmailConfig"
|
||||
@@ -0,0 +1,268 @@
|
||||
import collections
|
||||
|
||||
from django.utils.translation import gettext_lazy
|
||||
|
||||
|
||||
GLOBAL_PARAMETERS_STRUCT = collections.OrderedDict(
|
||||
[
|
||||
(
|
||||
"general",
|
||||
{
|
||||
"label": gettext_lazy("General"),
|
||||
"params": collections.OrderedDict(
|
||||
[
|
||||
(
|
||||
"max_attachment_size",
|
||||
{
|
||||
"label": gettext_lazy("Maximum attachment size"),
|
||||
"help_text": gettext_lazy(
|
||||
"Maximum attachment size in bytes (or KB, MB, GB if specified)"
|
||||
),
|
||||
},
|
||||
)
|
||||
]
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"imap",
|
||||
{
|
||||
"label": gettext_lazy("IMAP settings"),
|
||||
"params": collections.OrderedDict(
|
||||
[
|
||||
(
|
||||
"imap_server",
|
||||
{
|
||||
"label": gettext_lazy("Server address"),
|
||||
"help_text": gettext_lazy(
|
||||
"Address of your IMAP server"
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"imap_secured",
|
||||
{
|
||||
"label": gettext_lazy("Use a secured connection"),
|
||||
"help_text": gettext_lazy(
|
||||
"Use a secured connection to access IMAP server"
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"imap_port",
|
||||
{
|
||||
"label": gettext_lazy("Server port"),
|
||||
"help_text": gettext_lazy(
|
||||
"Listening port of your IMAP server"
|
||||
),
|
||||
},
|
||||
),
|
||||
]
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"smtp",
|
||||
{
|
||||
"label": gettext_lazy("SMTP settings"),
|
||||
"params": collections.OrderedDict(
|
||||
[
|
||||
(
|
||||
"smtp_server",
|
||||
{
|
||||
"label": gettext_lazy("Server address"),
|
||||
"help_text": gettext_lazy(
|
||||
"Address of your SMTP server"
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"smtp_secured_mode",
|
||||
{
|
||||
"label": gettext_lazy("Secured connection mode"),
|
||||
"help_text": gettext_lazy(
|
||||
"Use a secured connection to access SMTP server"
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"smtp_port",
|
||||
{
|
||||
"label": gettext_lazy("Server port"),
|
||||
"help_text": gettext_lazy(
|
||||
"Listening port of your SMTP server"
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"smtp_authentication",
|
||||
{
|
||||
"label": gettext_lazy("Authentication required"),
|
||||
"help_text": gettext_lazy(
|
||||
"Server needs authentication"
|
||||
),
|
||||
},
|
||||
),
|
||||
]
|
||||
),
|
||||
},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
USER_PREFERENCES_STRUCT = collections.OrderedDict(
|
||||
[
|
||||
(
|
||||
"display",
|
||||
{
|
||||
"label": gettext_lazy("Display"),
|
||||
"params": collections.OrderedDict(
|
||||
[
|
||||
(
|
||||
"displaymode",
|
||||
{
|
||||
"label": gettext_lazy("Default message display mode"),
|
||||
"help_text": gettext_lazy(
|
||||
"The default mode used when displaying a message"
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"enable_links",
|
||||
{
|
||||
"label": gettext_lazy("Enable HTML links display"),
|
||||
"help_text": gettext_lazy(
|
||||
"Enable/Disable HTML links display"
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"messages_per_page",
|
||||
{
|
||||
"label": gettext_lazy(
|
||||
"Number of displayed emails per page"
|
||||
),
|
||||
"help_text": gettext_lazy(
|
||||
"Sets the maximum number of messages displayed in a page"
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"refresh_interval",
|
||||
{
|
||||
"label": gettext_lazy(
|
||||
"Number of displayed emails per page"
|
||||
),
|
||||
"help_text": gettext_lazy(
|
||||
"Sets the maximum number of messages displayed in a page"
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"mboxes_col_width",
|
||||
{
|
||||
"label": gettext_lazy("Folder container's width"),
|
||||
"help_text": gettext_lazy(
|
||||
"The width of the folder list container"
|
||||
),
|
||||
},
|
||||
),
|
||||
]
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"folders",
|
||||
{
|
||||
"label": gettext_lazy("Folders"),
|
||||
"params": collections.OrderedDict(
|
||||
[
|
||||
(
|
||||
"trash_folder",
|
||||
{
|
||||
"label": gettext_lazy("Trash folder"),
|
||||
"help_text": gettext_lazy(
|
||||
"Folder where deleted messages go"
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"sent_folder",
|
||||
{
|
||||
"label": gettext_lazy("Sent folder"),
|
||||
"help_text": gettext_lazy(
|
||||
"Folder where copies of sent messages go"
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"drafts_folder",
|
||||
{
|
||||
"label": gettext_lazy("Drafts folder"),
|
||||
"help_text": gettext_lazy("Folder where drafts go"),
|
||||
},
|
||||
),
|
||||
(
|
||||
"junk_folder",
|
||||
{
|
||||
"label": gettext_lazy("Junk folder"),
|
||||
"help_text": gettext_lazy(
|
||||
"Folder where junk messages should go"
|
||||
),
|
||||
},
|
||||
),
|
||||
]
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"composing",
|
||||
{
|
||||
"label": gettext_lazy("Composing messages"),
|
||||
"params": collections.OrderedDict(
|
||||
[
|
||||
(
|
||||
"editor",
|
||||
{
|
||||
"label": gettext_lazy("Default editor"),
|
||||
"help_text": gettext_lazy(
|
||||
"The default editor to use when composing a message"
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"signature",
|
||||
{
|
||||
"label": gettext_lazy("Signature text"),
|
||||
"help_text": gettext_lazy(
|
||||
"User defined email signature"
|
||||
),
|
||||
},
|
||||
),
|
||||
]
|
||||
),
|
||||
},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def load_settings():
|
||||
"""Load webmail settings."""
|
||||
from modoboa.parameters import tools as param_tools
|
||||
from modoboa.webmail import serializers
|
||||
|
||||
param_tools.registry.add2(
|
||||
"global",
|
||||
"webmail",
|
||||
gettext_lazy("Webmail"),
|
||||
GLOBAL_PARAMETERS_STRUCT,
|
||||
serializers.GlobalParametersSerializer,
|
||||
)
|
||||
param_tools.registry.add2(
|
||||
"user",
|
||||
"webmail",
|
||||
gettext_lazy("Webmail"),
|
||||
USER_PREFERENCES_STRUCT,
|
||||
serializers.UserPreferencesSerializer,
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
"""AppConfig for webmail."""
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class WebmailConfig(AppConfig):
|
||||
"""App configuration."""
|
||||
|
||||
name = "modoboa.webmail"
|
||||
verbose_name = "Modoboa webmail"
|
||||
|
||||
def ready(self):
|
||||
from modoboa.webmail.app_settings import load_settings
|
||||
|
||||
load_settings()
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Webmail constants."""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
MAILBOX_TYPES = ["inbox", "draft", "sent", "junk", "trash", "normal"]
|
||||
|
||||
|
||||
class SmtpConnectionMode(Enum):
|
||||
NONE = "none"
|
||||
STARTTLS = "starttls"
|
||||
TLS = "ssl"
|
||||
|
||||
|
||||
SMTP_CONNECTION_MODES = [
|
||||
(SmtpConnectionMode.NONE.value, _("None")),
|
||||
(SmtpConnectionMode.STARTTLS.value, "STARTTLS"),
|
||||
(SmtpConnectionMode.TLS.value, "SSL/TLS"),
|
||||
]
|
||||
|
||||
|
||||
class DisplayMode(Enum):
|
||||
PLAIN = "plain"
|
||||
HTML = "html"
|
||||
|
||||
|
||||
DISPLAY_MODES = [
|
||||
(DisplayMode.PLAIN.value, "text"),
|
||||
(DisplayMode.HTML.value, "html"),
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Webmail custom exceptions."""
|
||||
|
||||
import re
|
||||
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from modoboa.lib.exceptions import ModoboaException, InternalError
|
||||
|
||||
|
||||
class WebmailInternalError(InternalError):
|
||||
errorexpr = re.compile(r"\[([^\]]+)\]\s*([^\.]+)")
|
||||
|
||||
def __init__(self, reason, ajax=False):
|
||||
match = WebmailInternalError.errorexpr.match(reason)
|
||||
if not match:
|
||||
self.reason = reason
|
||||
else:
|
||||
self.reason = f"{_('Server response')}: {match.group(2)}"
|
||||
self.ajax = ajax
|
||||
|
||||
def __str__(self):
|
||||
return self.reason
|
||||
|
||||
|
||||
class ImapError(ModoboaException):
|
||||
|
||||
http_code = 500
|
||||
|
||||
def __init__(self, reason):
|
||||
self.reason = reason
|
||||
|
||||
def __str__(self):
|
||||
return str(self.reason)
|
||||
@@ -0,0 +1,32 @@
|
||||
from .attachments import (
|
||||
create_mail_attachment,
|
||||
save_attachment,
|
||||
clean_attachments,
|
||||
set_compose_session,
|
||||
AttachmentUploadHandler,
|
||||
)
|
||||
from .imapemail import ImapEmail, ReplyModifier, ForwardModifier
|
||||
from .imaputils import BodyStructure, IMAPconnector, get_imapconnector, separate_mailbox
|
||||
from .signature import EmailSignature
|
||||
from .utils import decode_payload, WebmailNavigationParameters
|
||||
from .sendmail import send_mail
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AttachmentUploadHandler",
|
||||
"BodyStructure",
|
||||
"EmailSignature",
|
||||
"ForwardModifier",
|
||||
"IMAPconnector",
|
||||
"ImapEmail",
|
||||
"ReplyModifier",
|
||||
"WebmailNavigationParameters",
|
||||
"clean_attachments",
|
||||
"create_mail_attachment",
|
||||
"decode_payload",
|
||||
"get_imapconnector",
|
||||
"save_attachment",
|
||||
"send_mail",
|
||||
"separate_mailbox",
|
||||
"set_compose_session",
|
||||
]
|
||||
@@ -0,0 +1,135 @@
|
||||
from email import encoders
|
||||
from email.mime.base import MIMEBase
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import six
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.uploadhandler import FileUploadHandler, SkipFile
|
||||
from django.utils.encoding import smart_bytes
|
||||
|
||||
from modoboa.lib.exceptions import InternalError
|
||||
from modoboa.lib.web_utils import size2integer
|
||||
from modoboa.parameters import tools as param_tools
|
||||
|
||||
from .rfc6266 import build_header
|
||||
|
||||
|
||||
def get_storage_path(filename):
|
||||
"""Return a path to store a file."""
|
||||
storage_dir = os.path.join(settings.MEDIA_ROOT, "webmail")
|
||||
if not filename:
|
||||
return storage_dir
|
||||
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):
|
||||
"""Save a new attachment to the filesystem.
|
||||
|
||||
The attachment is not saved using its own name to the
|
||||
filesystem. To avoid conflicts, a random name is generated and
|
||||
used instead.
|
||||
|
||||
:param f: an uploaded file object (see Django's documentation) or bytes
|
||||
:return: the new random name
|
||||
"""
|
||||
from tempfile import NamedTemporaryFile
|
||||
|
||||
try:
|
||||
fp = NamedTemporaryFile(dir=get_storage_path(""), delete=False)
|
||||
except Exception as e:
|
||||
raise InternalError(str(e)) from None
|
||||
if isinstance(f, (six.binary_type, six.text_type)):
|
||||
fp.write(smart_bytes(f))
|
||||
else:
|
||||
for chunk in f.chunks():
|
||||
fp.write(chunk)
|
||||
fp.close()
|
||||
return fp.name
|
||||
|
||||
|
||||
def clean_attachments(attlist):
|
||||
"""Remove all attachments from the filesystem
|
||||
|
||||
:param attlist: a list of 2-uple. Each element must contain the
|
||||
following information : (random name, real name).
|
||||
"""
|
||||
for att in attlist:
|
||||
fullpath = get_storage_path(att["tmpname"])
|
||||
try:
|
||||
os.remove(fullpath)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def create_mail_attachment(attdef, payload=None):
|
||||
"""Create the MIME part corresponding to the given attachment.
|
||||
|
||||
Mandatory keys: 'fname', 'tmpname', 'content-type'
|
||||
|
||||
:param attdef: a dictionary containing the attachment definition
|
||||
:return: a MIMEBase object
|
||||
"""
|
||||
if "content-type" in attdef:
|
||||
maintype, subtype = attdef["content-type"].split("/")
|
||||
elif "Content-Type" in attdef:
|
||||
maintype, subtype = attdef["Content-Type"].split("/")
|
||||
else:
|
||||
return None
|
||||
res = MIMEBase(maintype, subtype)
|
||||
if payload is None:
|
||||
path = get_storage_path(attdef["tmpname"])
|
||||
with open(path, "rb") as fp:
|
||||
res.set_payload(fp.read())
|
||||
else:
|
||||
res.set_payload(payload)
|
||||
encoders.encode_base64(res)
|
||||
if isinstance(attdef["fname"], six.binary_type):
|
||||
attdef["fname"] = attdef["fname"].decode("utf-8")
|
||||
content_disposition = build_header(attdef["fname"])
|
||||
if isinstance(content_disposition, six.binary_type):
|
||||
res["Content-Disposition"] = content_disposition.decode("utf-8")
|
||||
else:
|
||||
res["Content-Disposition"] = content_disposition
|
||||
return res
|
||||
|
||||
|
||||
class AttachmentUploadHandler(FileUploadHandler):
|
||||
"""
|
||||
Simple upload handler to limit the size of the attachments users
|
||||
can upload.
|
||||
"""
|
||||
|
||||
def __init__(self, request=None):
|
||||
super().__init__(request)
|
||||
self.total_upload = 0
|
||||
self.toobig = False
|
||||
self.maxsize = size2integer(
|
||||
param_tools.get_global_parameter("max_attachment_size")
|
||||
)
|
||||
|
||||
def receive_data_chunk(self, raw_data, start):
|
||||
self.total_upload += len(raw_data)
|
||||
if self.total_upload >= self.maxsize:
|
||||
self.toobig = True
|
||||
raise SkipFile()
|
||||
return raw_data
|
||||
|
||||
def file_complete(self, file_size):
|
||||
return None
|
||||
@@ -0,0 +1,286 @@
|
||||
"""Simple parser for FETCH responses.
|
||||
|
||||
The ``imaplib`` module doesn't parse IMAP responses, it returns raw
|
||||
values. Since Modoboa relies on BODYSTRUCTURE attributes to display
|
||||
messages (we don't want to overload the server), a parser is required.
|
||||
|
||||
Python 2/3 compatibility note: this parser excepts bytes objects when
|
||||
run with Python3 and str (not unicode) ones with Python2.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
import chardet
|
||||
import six
|
||||
|
||||
|
||||
class ParseError(Exception):
|
||||
"""Generic parsing error."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class Lexer:
|
||||
"""The lexical analysis part.
|
||||
|
||||
This class provides a simple way to define tokens (with patterns)
|
||||
to be detected. Patterns are provided using a list of 2-uple. Each
|
||||
2-uple consists of a token name and an associated pattern.
|
||||
|
||||
Example: [("left_bracket", r'\['),]
|
||||
"""
|
||||
|
||||
def __init__(self, definitions):
|
||||
self.definitions = definitions
|
||||
parts = []
|
||||
for name, part in definitions:
|
||||
parts.append(rf"(?P<{name}>{part})")
|
||||
self.regexpString = "|".join(parts)
|
||||
self.regexp = re.compile(self.regexpString, re.MULTILINE)
|
||||
self.wsregexp = re.compile(r"\s+", re.M)
|
||||
|
||||
def curlineno(self):
|
||||
"""Return the current line number"""
|
||||
return self.text[: self.pos].count("\n") + 1
|
||||
|
||||
def scan(self, text):
|
||||
"""Analyze some data.
|
||||
|
||||
Analyse the passed content. Each time a token is recognized, a
|
||||
2-uple containing its name and the parsed value is raised
|
||||
(using yield).
|
||||
|
||||
:param text: a string containing the data to parse
|
||||
:raises: ParseError
|
||||
"""
|
||||
self.pos = 0
|
||||
self.text = text
|
||||
while self.pos < len(text):
|
||||
m = self.wsregexp.match(text, self.pos)
|
||||
if m is not None:
|
||||
self.pos = m.end()
|
||||
continue
|
||||
|
||||
m = self.regexp.match(text, self.pos)
|
||||
if m is None:
|
||||
raise ParseError(f"unknown token {text[self.pos :]}")
|
||||
|
||||
self.pos = m.end()
|
||||
yield (m.lastgroup, m.group(m.lastgroup))
|
||||
|
||||
|
||||
class FetchResponseParser:
|
||||
"""A token generator.
|
||||
|
||||
By *token*, I mean: *literal*, *quoted* or anything else until the
|
||||
next ' ' or ')' character (number, NIL and others should fall into
|
||||
this last category).
|
||||
"""
|
||||
|
||||
rules = [
|
||||
("left_parenthesis", r"\("),
|
||||
("right_parenthesis", r"\)"),
|
||||
("string", r'"([^"\\]|\\.)*"'),
|
||||
("nil", r"NIL"),
|
||||
(
|
||||
"data_item",
|
||||
r"(?P<name>[A-Z][A-Z\.0-9]+)"
|
||||
r"(?P<section>\[.*\])?(?P<origin_octet>\<\d+\>)?",
|
||||
),
|
||||
("number", r"[0-9]+"),
|
||||
("literal_marker", r"{\d+}"),
|
||||
("flag", r"(\\|\$)?[a-zA-Z0-9\-_]+"),
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
"""Constructor."""
|
||||
self.lexer = Lexer(self.rules)
|
||||
self.__reset_parser()
|
||||
|
||||
def __reset_parser(self):
|
||||
"""Reset parser states."""
|
||||
self.result = {}
|
||||
self.__current_message = {}
|
||||
self.__next_literal_len = 0
|
||||
self.__cur_data_item = None
|
||||
self.__args_parsing_func = None
|
||||
self.__expected = None
|
||||
self.__depth = 0
|
||||
self.__bs_stack = []
|
||||
|
||||
def set_expected(self, *args):
|
||||
"""Indicate next expected token types."""
|
||||
self.__expected = args
|
||||
|
||||
def __default_args_parser(self, ttype, tvalue):
|
||||
"""Default arguments parser."""
|
||||
self.__current_message[self.__cur_data_item] = tvalue
|
||||
self.__args_parsing_func = None
|
||||
|
||||
def __flags_args_parser(self, ttype, tvalue):
|
||||
"""FLAGS arguments parser."""
|
||||
if ttype == "left_parenthesis":
|
||||
self.__current_message[self.__cur_data_item] = []
|
||||
self.__depth += 1
|
||||
elif ttype == "flag":
|
||||
self.__current_message[self.__cur_data_item].append(tvalue)
|
||||
self.set_expected("flag", "right_parenthesis")
|
||||
elif ttype == "right_parenthesis":
|
||||
self.__args_parsing_func = None
|
||||
self.__depth -= 1
|
||||
else:
|
||||
raise ParseError(f"Unexpected token found: {ttype}")
|
||||
|
||||
def __set_part_numbers(self, bs, prefix=""):
|
||||
"""Set part numbers."""
|
||||
cpt = 1
|
||||
for mp in bs:
|
||||
if isinstance(mp, list):
|
||||
self.__set_part_numbers(mp, prefix)
|
||||
elif isinstance(mp, dict):
|
||||
if isinstance(mp["struct"][0], list):
|
||||
nprefix = f"{prefix}{cpt}."
|
||||
self.__set_part_numbers(mp["struct"][0], nprefix)
|
||||
mp["partnum"] = f"{prefix}{cpt}"
|
||||
cpt += 1
|
||||
|
||||
def __bstruct_args_parser(self, ttype, tvalue):
|
||||
"""BODYSTRUCTURE arguments parser."""
|
||||
if ttype == "left_parenthesis":
|
||||
self.__bs_stack = [[]] + self.__bs_stack
|
||||
return
|
||||
if ttype == "right_parenthesis":
|
||||
if len(self.__bs_stack) > 1:
|
||||
part = self.__bs_stack.pop(0)
|
||||
# Check if we are parsing a list of mime part or a
|
||||
# list or arguments.
|
||||
condition = len(self.__bs_stack[0]) > 0 and not isinstance(
|
||||
self.__bs_stack[0][0], dict
|
||||
)
|
||||
if condition:
|
||||
self.__bs_stack[0].append(part)
|
||||
else:
|
||||
self.__bs_stack[0].append({"struct": part})
|
||||
else:
|
||||
# End of BODYSTRUCTURE
|
||||
if not isinstance(self.__bs_stack[0][0], list):
|
||||
# Special case for non multipart structures
|
||||
self.__bs_stack[0] = {"struct": self.__bs_stack[0]}
|
||||
self.__set_part_numbers(self.__bs_stack)
|
||||
self.__current_message[self.__cur_data_item] = self.__bs_stack
|
||||
self.__bs_stack = []
|
||||
self.__args_parsing_func = None
|
||||
return
|
||||
if ttype == "string":
|
||||
tvalue = tvalue.strip('"')
|
||||
# Check if previous element was a mime part. If so, we are
|
||||
# dealing with a 'multipart' mime part...
|
||||
condition = len(self.__bs_stack[0]) and isinstance(
|
||||
self.__bs_stack[0][-1], dict
|
||||
)
|
||||
if condition:
|
||||
self.__bs_stack[0] = [self.__bs_stack[0]] + [tvalue]
|
||||
return
|
||||
elif ttype == "number":
|
||||
tvalue = int(tvalue)
|
||||
self.__bs_stack[0].append(tvalue)
|
||||
|
||||
def __parse_data_item(self, ttype, tvalue):
|
||||
"""Find next data item."""
|
||||
if ttype == "data_item":
|
||||
self.__cur_data_item = tvalue
|
||||
if tvalue == "BODYSTRUCTURE":
|
||||
self.set_expected("left_parenthesis")
|
||||
self.__args_parsing_func = self.__bstruct_args_parser
|
||||
elif tvalue == "FLAGS":
|
||||
self.set_expected("left_parenthesis")
|
||||
self.__args_parsing_func = self.__flags_args_parser
|
||||
else:
|
||||
self.__args_parsing_func = self.__default_args_parser
|
||||
return
|
||||
elif ttype == "right_parenthesis":
|
||||
self.__depth -= 1
|
||||
assert self.__depth == 0
|
||||
# FIXME: sometimes, FLAGS are returned outside the UID
|
||||
# scope (see sample 1 in tests). For now, we just ignore
|
||||
# them but we need a better solution!
|
||||
if "UID" in self.__current_message:
|
||||
self.result[int(self.__current_message.pop("UID"))] = (
|
||||
self.__current_message
|
||||
)
|
||||
self.__current_message = {}
|
||||
return
|
||||
raise ParseError(
|
||||
f"unexpected {ttype} found while looking for data_item near {tvalue}"
|
||||
)
|
||||
|
||||
def __convert_to_str(self, chunk):
|
||||
"""Convert chunk to str and guess encoding."""
|
||||
condition = (
|
||||
six.PY2
|
||||
and isinstance(chunk, six.text_type)
|
||||
or six.PY3
|
||||
and isinstance(chunk, six.binary_type)
|
||||
)
|
||||
if not condition:
|
||||
return chunk
|
||||
try:
|
||||
chunk = chunk.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
else:
|
||||
return chunk
|
||||
try:
|
||||
result = chardet.detect(chunk)
|
||||
except UnicodeDecodeError:
|
||||
raise RuntimeError("Can't find string encoding") from None
|
||||
return chunk.decode(result["encoding"])
|
||||
|
||||
def parse_chunk(self, chunk):
|
||||
"""Parse chunk."""
|
||||
if not chunk:
|
||||
return
|
||||
chunk = self.__convert_to_str(chunk)
|
||||
if self.__next_literal_len:
|
||||
literal = chunk[: self.__next_literal_len]
|
||||
chunk = chunk[self.__next_literal_len :]
|
||||
self.__next_literal_len = 0
|
||||
if self.__cur_data_item != "BODYSTRUCTURE":
|
||||
self.__current_message[self.__cur_data_item] = literal
|
||||
self.__args_parsing_func = None
|
||||
else:
|
||||
self.__args_parsing_func("literal", literal)
|
||||
for ttype, tvalue in self.lexer.scan(chunk):
|
||||
if self.__expected is not None:
|
||||
if ttype not in self.__expected:
|
||||
raise ParseError(
|
||||
"unexpected {} found while looking for {}".format(
|
||||
ttype, "|".join(self.__expected)
|
||||
)
|
||||
)
|
||||
self.__expected = None
|
||||
if ttype == "literal_marker":
|
||||
self.__next_literal_len = int(tvalue[1:-1])
|
||||
continue
|
||||
elif self.__depth == 0:
|
||||
if ttype == "number":
|
||||
# We should start here with a message ID
|
||||
self.set_expected("left_parenthesis")
|
||||
if ttype == "left_parenthesis":
|
||||
self.__depth += 1
|
||||
continue
|
||||
elif self.__args_parsing_func is None:
|
||||
self.__parse_data_item(ttype, tvalue)
|
||||
continue
|
||||
self.__args_parsing_func(ttype, tvalue)
|
||||
|
||||
def parse(self, data):
|
||||
"""Parse received data."""
|
||||
self.__reset_parser()
|
||||
for chunk in data:
|
||||
if isinstance(chunk, tuple):
|
||||
for schunk in chunk:
|
||||
self.parse_chunk(schunk)
|
||||
else:
|
||||
self.parse_chunk(chunk)
|
||||
return self.result
|
||||
@@ -0,0 +1,335 @@
|
||||
"""
|
||||
Set of classes to manipulate/display emails inside the webmail.
|
||||
"""
|
||||
|
||||
import os
|
||||
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.lib import u2u_decode
|
||||
from modoboa.lib.email_utils import Email, EmailAddress
|
||||
|
||||
from . import imapheader
|
||||
from .attachments import get_storage_path
|
||||
from .imaputils import get_imapconnector, BodyStructure
|
||||
from .utils import decode_payload
|
||||
|
||||
|
||||
class ImapEmail(Email):
|
||||
"""
|
||||
A class to represent an email fetched from an IMAP server.
|
||||
"""
|
||||
|
||||
headernames = [
|
||||
("From", True),
|
||||
("To", True),
|
||||
("Cc", True),
|
||||
("Date", True),
|
||||
("Subject", True),
|
||||
]
|
||||
|
||||
def __init__(self, request, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.request = request
|
||||
self.imapc = get_imapconnector(request)
|
||||
self.mbox, self.mailid = self.mailid.split(":")
|
||||
|
||||
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(
|
||||
self.mbox, self.mailid, readonly=False, what=" ".join(self.headers_as_list)
|
||||
)
|
||||
headers = msg[f"BODY[HEADER.FIELDS ({self.headers_as_text})]"]
|
||||
self.fetch_body_structure(msg)
|
||||
msg = email.message_from_string(headers)
|
||||
for hdr in self.headernames:
|
||||
label = hdr[0]
|
||||
hdrvalue = self.get_header(msg, label, raw=raw_addresses)
|
||||
if not hdrvalue:
|
||||
continue
|
||||
if hdr[1]:
|
||||
self.headers += [{"name": label, "value": hdrvalue}]
|
||||
label = re.sub("-", "_", label)
|
||||
setattr(self, label, hdrvalue)
|
||||
|
||||
def get_header(self, msg, header: str, **kwargs):
|
||||
"""Look for a particular header.
|
||||
|
||||
We also try to decode the default value.
|
||||
"""
|
||||
hdrvalue = super().get_header(msg, header)
|
||||
if header in ["From", "Reply-To"]:
|
||||
# Store a raw copy for further use
|
||||
setattr(self, f"original_{header.replace('-', '')}", hdrvalue)
|
||||
if not hdrvalue:
|
||||
return ""
|
||||
try:
|
||||
key = re.sub("-", "_", header).lower()
|
||||
hdrvalue = getattr(imapheader, f"parse_{key}")(hdrvalue)
|
||||
except AttributeError:
|
||||
pass
|
||||
return hdrvalue
|
||||
|
||||
def fetch_body_structure(self, msg=None):
|
||||
"""Fetch BODYSTRUCTURE for email."""
|
||||
if msg is None:
|
||||
msg = self.imapc.fetchmail(self.mbox, self.mailid, readonly=False)
|
||||
self.bs = BodyStructure(msg["BODYSTRUCTURE"])
|
||||
self._find_attachments()
|
||||
if self.dformat not in ["plain", "html"]:
|
||||
self.dformat = self.request.user.parameters.get_value(self.dformat)
|
||||
fallback_fmt = "html" if self.dformat == "plain" else "plain"
|
||||
self.mformat = (
|
||||
self.dformat if self.dformat in self.bs.contents else fallback_fmt
|
||||
)
|
||||
|
||||
@property
|
||||
def headers_as_list(self):
|
||||
return [hdr[0].upper() for hdr in self.headernames]
|
||||
|
||||
@property
|
||||
def headers_as_text(self):
|
||||
return " ".join(self.headers_as_list)
|
||||
|
||||
@property
|
||||
def body(self):
|
||||
"""Load email's body.
|
||||
|
||||
This operation has to be made "on demand" because it requires
|
||||
a communication with the IMAP server.
|
||||
"""
|
||||
if self._body is None:
|
||||
self.fetch_body_structure()
|
||||
bodyc = ""
|
||||
parts = self.bs.contents.get(self.mformat, [])
|
||||
for part in parts:
|
||||
pnum = part["pnum"]
|
||||
data = self.imapc._cmd("FETCH", self.mailid, f"(BODY.PEEK[{pnum}])")
|
||||
if not data or int(self.mailid) not in data:
|
||||
continue
|
||||
content = decode_payload(
|
||||
part["encoding"], data[int(self.mailid)][f"BODY[{pnum}]"]
|
||||
)
|
||||
if not isinstance(content, six.text_type):
|
||||
charset = self._find_content_charset(part)
|
||||
if charset is not None:
|
||||
try:
|
||||
content = content.decode(charset)
|
||||
except (UnicodeDecodeError, LookupError):
|
||||
result = chardet.detect(content)
|
||||
content = content.decode(result["encoding"])
|
||||
bodyc += content
|
||||
self._fetch_inlines()
|
||||
if len(bodyc) != 0:
|
||||
bodyc = getattr(self, f"_post_process_{self.mformat}")(bodyc)
|
||||
self._body = getattr(self, f"viewmail_{self.mformat}")(
|
||||
bodyc, links=self.links
|
||||
)
|
||||
return self._body
|
||||
|
||||
@body.setter
|
||||
def body(self, value):
|
||||
self._body = value
|
||||
|
||||
@property
|
||||
def source(self):
|
||||
"""Retrieve email source."""
|
||||
return self.imapc.fetchmail(self.mbox, self.mailid, what="source")["BODY[]"]
|
||||
|
||||
def _find_content_charset(self, part):
|
||||
for pos, elem in enumerate(part["params"]):
|
||||
if elem == "charset":
|
||||
return part["params"][pos + 1]
|
||||
return None
|
||||
|
||||
def _find_attachments(self):
|
||||
"""Retrieve attachments from the parsed body structure.
|
||||
|
||||
We try to find and decode a file name for each attachment. If
|
||||
we failed, a generic name will be used (ie. part_1, part_2, ...).
|
||||
"""
|
||||
for att in self.bs.attachments:
|
||||
attname = "part_{}".format(att["pnum"])
|
||||
if "params" in att and att["params"] != "NIL":
|
||||
for pos, value in enumerate(att["params"]):
|
||||
if not value.startswith("name"):
|
||||
continue
|
||||
attname = u2u_decode.u2u_decode(att["params"][pos + 1]).strip(
|
||||
"\r\t\n"
|
||||
)
|
||||
break
|
||||
elif "disposition" in att and len(att["disposition"]) > 1:
|
||||
for pos, value in enumerate(att["disposition"][1]):
|
||||
if not value.startswith("filename"):
|
||||
continue
|
||||
attname = u2u_decode.u2u_decode(
|
||||
att["disposition"][1][pos + 1]
|
||||
).strip("\r\t\n")
|
||||
break
|
||||
self.attachments[att["pnum"]] = smart_str(attname)
|
||||
|
||||
def _fetch_inlines(self):
|
||||
"""Store inline images on filesystem to display them."""
|
||||
for cid, params in list(self.bs.inlines.items()):
|
||||
if re.search(r"\.\.", cid):
|
||||
continue
|
||||
fname = f"{self.mailid}_{cid}"
|
||||
path = get_storage_path(fname)
|
||||
params["fname"] = os.path.join(
|
||||
settings.MEDIA_URL, os.path.basename(get_storage_path("")), fname
|
||||
)
|
||||
if default_storage.exists(path):
|
||||
continue
|
||||
pdef, content = self.imapc.fetchpart(self.mailid, self.mbox, params["pnum"])
|
||||
default_storage.save(
|
||||
path, ContentFile(decode_payload(params["encoding"], content))
|
||||
)
|
||||
|
||||
def _map_cid(self, url):
|
||||
m = re.match(".*cid:(.+)", url)
|
||||
if m:
|
||||
if m.group(1) in self.bs.inlines:
|
||||
return self.bs.inlines[m.group(1)]["fname"]
|
||||
return url
|
||||
|
||||
def fetch_attachment(self, pnum):
|
||||
"""Fetch an attachment from the IMAP server."""
|
||||
return self.imapc.fetchpart(self.mailid, self.mbox, pnum)
|
||||
|
||||
|
||||
class Modifier(ImapEmail):
|
||||
"""Message modifier."""
|
||||
|
||||
def __init__(self, form, request, *args, **kwargs):
|
||||
kwargs["dformat"] = request.user.parameters.get_value("editor")
|
||||
super().__init__(request, *args, **kwargs)
|
||||
self.form = form
|
||||
self.fetch_headers(raw_addresses=True)
|
||||
getattr(self, f"_modify_{self.dformat}")()
|
||||
|
||||
def _modify_plain(self):
|
||||
self.body = re.sub("</?pre>", "", self.body)
|
||||
|
||||
def _modify_html(self):
|
||||
if self.dformat == "html" and self.mformat != self.dformat:
|
||||
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."""
|
||||
|
||||
headernames = ImapEmail.headernames + [("Reply-To", True), ("Message-ID", False)]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.textheader = f"{self.Form} {_('wrote:')}"
|
||||
if self.dformat == "html":
|
||||
self.textheader = f"<p>{self.textheader}</p>"
|
||||
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())
|
||||
if m:
|
||||
self.form.fields["subject"].initial = self.subject
|
||||
else:
|
||||
self.form.fields["subject"].initial = f"Re: {self.subject}"
|
||||
|
||||
def _modify_plain(self):
|
||||
super()._modify_plain()
|
||||
lines = self.body.split("\n")
|
||||
body = ""
|
||||
for line in lines:
|
||||
if body != "":
|
||||
body += "\n"
|
||||
body += f">{line}"
|
||||
self.body = body
|
||||
|
||||
|
||||
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}"
|
||||
|
||||
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"]:
|
||||
try:
|
||||
key = re.sub("-", "_", hdr)
|
||||
value = getattr(self, key)
|
||||
self.textheader += self.__getfunc("_header_line")(_(hdr), value)
|
||||
except AttributeError:
|
||||
pass
|
||||
self.textheader += self.__getfunc("_header_end")()
|
||||
|
||||
def _header_begin_plain(self):
|
||||
return f"----- {_('Original message')} -----"
|
||||
|
||||
def _header_begin_html(self):
|
||||
return f"----- {_('Original message')} -----"
|
||||
|
||||
def _header_line_plain(self, key, value):
|
||||
return f"{key}: {value}\n"
|
||||
|
||||
def _header_line_html(self, key, value):
|
||||
return f"<p>{key}: {conditional_escape(value)}</p>"
|
||||
|
||||
def _header_end_plain(self):
|
||||
return "\n"
|
||||
|
||||
def _header_end_html(self):
|
||||
return ""
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Set of functions used to parse and transform email headers."""
|
||||
|
||||
import datetime
|
||||
import email
|
||||
|
||||
import chardet
|
||||
import six
|
||||
|
||||
from django.utils import timezone
|
||||
from django.utils.formats import date_format
|
||||
|
||||
from modoboa.lib.email_utils import EmailAddress
|
||||
from modoboa.lib.signals import get_request
|
||||
|
||||
|
||||
__all__ = [
|
||||
"parse_from",
|
||||
"parse_to",
|
||||
"parse_message_id",
|
||||
"parse_date",
|
||||
"parse_reply_to",
|
||||
"parse_cc",
|
||||
"parse_subject",
|
||||
]
|
||||
|
||||
# date and time formats for email list
|
||||
# according to https://en.wikipedia.org/wiki/Date_format_by_country
|
||||
# and https://en.wikipedia.org/wiki/Date_and_time_representation_by_country
|
||||
DATETIME_FORMATS = {
|
||||
"cs": {"SHORT": "l, H:i", "LONG": "d. N Y H:i"},
|
||||
"de": {"SHORT": "l, H:i", "LONG": "d. N Y H:i"},
|
||||
"en": {"SHORT": "l, P", "LONG": "N j, Y P"},
|
||||
"es": {"SHORT": "l, H:i", "LONG": "d. N Y H:i"},
|
||||
"fr": {"SHORT": "l, H:i", "LONG": "d. N Y H:i"},
|
||||
"it": {"SHORT": "l, H:i", "LONG": "d. N Y H:i"},
|
||||
"ja_JP": {"SHORT": "l, P", "LONG": "N j, Y P"},
|
||||
"nl": {"SHORT": "l, H:i", "LONG": "d. N Y H:i"},
|
||||
"pl_PL": {"SHORT": "l, H:i", "LONG": "d. N Y H:i"},
|
||||
"pt_PT": {"SHORT": "l, H:i", "LONG": "d. N Y H:i"},
|
||||
"pt_BR": {"SHORT": "l, H:i", "LONG": "d. N Y H:i"},
|
||||
"ru": {"SHORT": "l, H:i", "LONG": "d. N Y H:i"},
|
||||
"sv": {"SHORT": "l, H:i", "LONG": "d. N Y H:i"},
|
||||
}
|
||||
|
||||
|
||||
def to_unicode(value):
|
||||
"""Try to convert a string to unicode."""
|
||||
condition = value is None or isinstance(value, six.text_type)
|
||||
if condition:
|
||||
return value
|
||||
try:
|
||||
value = value.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
else:
|
||||
return value
|
||||
try:
|
||||
res = chardet.detect(value)
|
||||
except UnicodeDecodeError:
|
||||
return value
|
||||
if res["encoding"] == "ascii":
|
||||
return value
|
||||
return value.decode(res["encoding"])
|
||||
|
||||
|
||||
def parse_address(value: str) -> dict:
|
||||
"""Parse an email address."""
|
||||
addr = EmailAddress(value)
|
||||
result = {
|
||||
"fulladdress": to_unicode(addr.fulladdress),
|
||||
"address": to_unicode(addr.address),
|
||||
}
|
||||
if addr.name:
|
||||
result["name"] = to_unicode(addr.name)
|
||||
return result
|
||||
|
||||
|
||||
def parse_address_list(values: str) -> list[dict]:
|
||||
"""Parse a list of email addresses."""
|
||||
lst = values.split(",")
|
||||
result = []
|
||||
for addr in lst:
|
||||
result.append(parse_address(addr))
|
||||
return result
|
||||
|
||||
|
||||
def parse_from(value):
|
||||
"""Parse a From: header."""
|
||||
return parse_address(value)
|
||||
|
||||
|
||||
def parse_to(value):
|
||||
"""Parse a To: header."""
|
||||
return parse_address_list(value)
|
||||
|
||||
|
||||
def parse_cc(value):
|
||||
"""Parse a Cc: header."""
|
||||
return parse_address_list(value)
|
||||
|
||||
|
||||
def parse_reply_to(value):
|
||||
"""Parse a Reply-To: header."""
|
||||
return parse_address_list(value)
|
||||
|
||||
|
||||
def parse_date(value, **kwargs):
|
||||
"""Parse a Date: header."""
|
||||
tmp = email.utils.parsedate_tz(value)
|
||||
if not tmp:
|
||||
return value
|
||||
ndate = datetime.datetime.fromtimestamp(email.utils.mktime_tz(tmp))
|
||||
if ndate.tzinfo is not None:
|
||||
tz = timezone.get_current_timezone()
|
||||
ndate = datetime.datetime.fromtimestamp(ndate).replace(tzinfo=tz)
|
||||
current_language = get_request().user.language
|
||||
if datetime.datetime.now() - ndate > datetime.timedelta(7):
|
||||
fmt = "LONG"
|
||||
else:
|
||||
fmt = "SHORT"
|
||||
return date_format(
|
||||
ndate, DATETIME_FORMATS.get(current_language, DATETIME_FORMATS.get("en"))[fmt]
|
||||
)
|
||||
|
||||
|
||||
def parse_message_id(value, **kwargs):
|
||||
"""Parse a Message-ID: header."""
|
||||
return value.strip("\n")
|
||||
|
||||
|
||||
def parse_subject(value, **kwargs):
|
||||
"""Parse a Subject: header."""
|
||||
from modoboa.lib import u2u_decode
|
||||
|
||||
try:
|
||||
subject = u2u_decode.u2u_decode(value)
|
||||
except UnicodeDecodeError:
|
||||
subject = value
|
||||
return to_unicode(subject)
|
||||
@@ -0,0 +1,828 @@
|
||||
"""Extra IMAPv4 utilities."""
|
||||
|
||||
import base64
|
||||
import email
|
||||
from functools import wraps
|
||||
import imaplib
|
||||
import re
|
||||
import ssl
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from modoboa.lib import imap_utf7 # noqa
|
||||
from modoboa.lib.exceptions import InternalError
|
||||
from modoboa.parameters import tools as param_tools
|
||||
|
||||
from ..exceptions import ImapError, WebmailInternalError
|
||||
from .fetch_parser import FetchResponseParser
|
||||
|
||||
imaplib.Debug = 4
|
||||
|
||||
# workaround for the "got more than 10000 bytes" exception. MAXLINE
|
||||
# value set to 1M, as on latest python versions.
|
||||
MAXLINE = 1000000
|
||||
if hasattr(imaplib, "_MAXLINE") and imaplib._MAXLINE < MAXLINE:
|
||||
imaplib._MAXLINE = MAXLINE
|
||||
|
||||
|
||||
class capability:
|
||||
"""
|
||||
Simple decorator to check if the server presents the required
|
||||
capability. If not, a fallback method is called instead.
|
||||
|
||||
:param name: the capability name (upper case)
|
||||
:param fallback_method: a method's name
|
||||
"""
|
||||
|
||||
def __init__(self, name: str, fallback_method: str):
|
||||
self.name = name
|
||||
self.fallback_method = fallback_method
|
||||
|
||||
def __call__(self, method):
|
||||
@wraps(method)
|
||||
def wrapped_func(cls, *args, **kwargs):
|
||||
if self.name in cls.capabilities:
|
||||
return method(cls, *args, **kwargs)
|
||||
return getattr(cls, self.fallback_method)(cls, **kwargs)
|
||||
|
||||
return wrapped_func
|
||||
|
||||
|
||||
class BodyStructure:
|
||||
"""
|
||||
BODYSTRUCTURE response parser.
|
||||
|
||||
Just a simple class that tries to distinguish content parts from
|
||||
attachments.
|
||||
"""
|
||||
|
||||
current_mailbox: str
|
||||
|
||||
def __init__(self, definition=None):
|
||||
self.is_multipart = False
|
||||
self.contents = {}
|
||||
self.attachments = []
|
||||
self.inlines = {}
|
||||
|
||||
if definition is not None:
|
||||
self.load_from_definition(definition)
|
||||
|
||||
def __store_part(self, definition: list, pnum: str, multisubtype: str) -> None:
|
||||
"""Store the given message part in the appropriate category.
|
||||
|
||||
This method sort parts in two categories:
|
||||
|
||||
* contents (what is going to be displayed)
|
||||
* attachments
|
||||
|
||||
As there is no official definition about what is a content and
|
||||
what is an attachment, the following rules are applied:
|
||||
|
||||
* If the MIME type is text/plain or text/html:
|
||||
|
||||
* If no previous part of this type has already been seen,
|
||||
it's a content
|
||||
* Otherwise it's an attachment
|
||||
|
||||
* Else, if the multipart subtype is related, we consider this
|
||||
part as content because it is certainly an embedded image
|
||||
|
||||
* Any other MIME type is considered as an attachment (for now)
|
||||
|
||||
:param definition: a part definition (list)
|
||||
:param pnum: the part's number
|
||||
:param multisubtype: the multipart subtype
|
||||
|
||||
"""
|
||||
pnum = "1" if pnum is None else pnum
|
||||
params = {
|
||||
"pnum": pnum,
|
||||
"params": definition[2],
|
||||
"cid": definition[3],
|
||||
"description": definition[4],
|
||||
"encoding": definition[5],
|
||||
"size": definition[6],
|
||||
}
|
||||
mtype = definition[0].lower()
|
||||
subtype = definition[1].lower()
|
||||
ftype = f"{definition[0].lower()}/{subtype}"
|
||||
if ftype in ("text/plain", "text/html"):
|
||||
if subtype not in self.contents:
|
||||
self.contents[subtype] = [params]
|
||||
else:
|
||||
self.contents[subtype].append(params)
|
||||
return
|
||||
elif multisubtype in ["related"]:
|
||||
self.inlines[params["cid"].strip("<>")] = params
|
||||
return
|
||||
|
||||
params["Content-Type"] = ftype
|
||||
if len(definition) > 7:
|
||||
extensions = ["md5", "disposition", "language", "location"]
|
||||
if mtype == "text":
|
||||
extensions = ["textlines"] + extensions
|
||||
elif ftype == "message/rfc822":
|
||||
extensions = ["envelopestruct", "bodystruct", "textlines"] + extensions
|
||||
for idx, value in enumerate(definition[7:]):
|
||||
params[extensions[idx]] = value
|
||||
|
||||
self.attachments += [params]
|
||||
|
||||
def load_from_definition(
|
||||
self, definition: list, multisubtype: Optional[str] = None
|
||||
) -> None:
|
||||
for mp in definition:
|
||||
if isinstance(mp, list):
|
||||
if isinstance(mp[0], list):
|
||||
self.load_from_definition(mp, mp[1])
|
||||
else:
|
||||
self.load_from_definition(mp)
|
||||
elif isinstance(mp, dict):
|
||||
if isinstance(mp["struct"][0], list):
|
||||
self.load_from_definition(mp["struct"][0], mp["struct"][1])
|
||||
continue
|
||||
self.__store_part(mp["struct"], mp["partnum"], multisubtype)
|
||||
|
||||
def has_attachments(self) -> int:
|
||||
return len(self.attachments)
|
||||
|
||||
def find_attachment(self, pnum):
|
||||
for att in self.attachments:
|
||||
if pnum == att["pnum"]:
|
||||
return att
|
||||
return None
|
||||
|
||||
|
||||
class IMAPconnector:
|
||||
"""The IMAPv4 connector."""
|
||||
|
||||
namespaces_pattern = re.compile(r"(\(\(.+?\)\)|NIL)")
|
||||
namespace_pattern = re.compile(r'\("(?P<prefix>.*?)" "(?P<delimiter>.+?)"\)')
|
||||
list_base_pattern = r'\((?P<flags>.*?)\) "(?P<delimiter>.*)" "?(?P<name>[^"]*)"?'
|
||||
list_response_pattern_literal = re.compile(
|
||||
r'\((?P<flags>.*?)\) "(?P<delimiter>.*)" \{(?P<namelen>\d+)\}'
|
||||
)
|
||||
list_response_pattern = re.compile(list_base_pattern)
|
||||
listextended_response_pattern = re.compile(
|
||||
list_base_pattern + r"\s*(?P<childinfo>.*)"
|
||||
)
|
||||
unseen_pattern = re.compile(r"[^\(]+\(UNSEEN (\d+)\)")
|
||||
|
||||
def __init__(self, user=None, password=None) -> None:
|
||||
self.__hdelimiter: Optional[str] = None
|
||||
self.__ns_prefixes: dict = {}
|
||||
self.quota_usage: int = -1
|
||||
self.criterions: list = []
|
||||
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)
|
||||
self.load_namespaces()
|
||||
|
||||
def _cmd(self, name: str, *args, **kwargs) -> Optional[list]:
|
||||
"""IMAP command wrapper.
|
||||
|
||||
To simplify errors handling, this wrapper calls the
|
||||
appropriate method (``uid`` or FIXME) and then check the
|
||||
return code. If an error has occured, an ``ImapError``
|
||||
exception is raised.
|
||||
|
||||
For specific commands commands (FETCH, ...), the result is
|
||||
parsed using the IMAPclient module before being returned.
|
||||
|
||||
:param name: the command's name
|
||||
:return: the command's result
|
||||
"""
|
||||
if name in ["FETCH", "SORT", "STORE", "COPY", "SEARCH"]:
|
||||
try:
|
||||
typ, data = self.m.uid(name, *args)
|
||||
except imaplib.IMAP4.error as e:
|
||||
raise ImapError(e) from None
|
||||
if typ == "NO":
|
||||
raise ImapError(data)
|
||||
if name == "FETCH":
|
||||
return FetchResponseParser().parse(data)
|
||||
return data
|
||||
|
||||
try:
|
||||
typ, data = self.m._simple_command(name, *args)
|
||||
except imaplib.IMAP4.error as e:
|
||||
raise ImapError(e) from None
|
||||
if typ == "NO":
|
||||
raise ImapError(data)
|
||||
if "responses" not in kwargs:
|
||||
if name not in self.m.untagged_responses:
|
||||
return None
|
||||
return self.m.untagged_responses.pop(name)
|
||||
res = []
|
||||
for r in kwargs["responses"]:
|
||||
if r not in self.m.untagged_responses:
|
||||
return None
|
||||
res.append(self.m.untagged_responses.pop(r))
|
||||
return res
|
||||
|
||||
@property
|
||||
def hdelimiter(self):
|
||||
"""Return the default hierachy delimiter.
|
||||
|
||||
:return: a string
|
||||
"""
|
||||
if self.__hdelimiter is None:
|
||||
raise InternalError(_("Failed to retrieve hierarchy delimiter"))
|
||||
return self.__hdelimiter
|
||||
|
||||
def login(self, user: str, rawtoken: str) -> None:
|
||||
"""Custom login method
|
||||
|
||||
We connect to the server, issue a LOGIN command. If
|
||||
successfull, we try to record a eventuel CAPABILITY untagged
|
||||
response. Otherwise, we issue the command.
|
||||
|
||||
:param user: username
|
||||
:param passwd: password
|
||||
"""
|
||||
try:
|
||||
if self.conf["imap_secured"]:
|
||||
self.m = imaplib.IMAP4_SSL(self.address, self.port)
|
||||
else:
|
||||
self.m = imaplib.IMAP4(self.address, self.port)
|
||||
except (OSError, imaplib.IMAP4.error, ssl.SSLError) as error:
|
||||
raise ImapError(_(f"Connection to IMAP server failed: {error}")) from None
|
||||
|
||||
if not settings.WEBMAIL_DEV_MODE:
|
||||
token = (
|
||||
b"n,a="
|
||||
+ user.encode("utf-8")
|
||||
+ b",\001auth=Bearer "
|
||||
+ rawtoken.encode("utf-8")
|
||||
+ b"\001\001"
|
||||
)
|
||||
token = base64.b64encode(token)
|
||||
data = self._cmd("AUTHENTICATE", b"OAUTHBEARER", token)
|
||||
else:
|
||||
user = bytes(settings.WEBMAIL_DEV_USERNAME, "utf-8")
|
||||
passwd = bytes(self.m._quote(settings.WEBMAIL_DEV_PASSWORD), "utf-8")
|
||||
data = self._cmd("LOGIN", user, passwd)
|
||||
self.m.state = "AUTH"
|
||||
if "CAPABILITY" in self.m.untagged_responses:
|
||||
self.capabilities = (
|
||||
self.m.untagged_responses.pop("CAPABILITY")[0].decode().split()
|
||||
)
|
||||
else:
|
||||
data = self._cmd("CAPABILITY")
|
||||
self.capabilities = data[0].decode().split()
|
||||
|
||||
def logout(self) -> None:
|
||||
"""Logout from server."""
|
||||
try:
|
||||
self._cmd("CHECK")
|
||||
except ImapError:
|
||||
pass
|
||||
self._cmd("LOGOUT")
|
||||
del self.m
|
||||
self.m = None
|
||||
if hasattr(self, "current_mailbox"):
|
||||
del self.current_mailbox
|
||||
|
||||
def load_namespaces(self) -> None:
|
||||
"""Load available namespaces."""
|
||||
data = self._cmd("NAMESPACE")
|
||||
nslist = self.namespaces_pattern.findall(data[0].decode())
|
||||
for pos, item in enumerate(["personal", "others", "public"]):
|
||||
if nslist[pos] == "NIL":
|
||||
continue
|
||||
ns = nslist[pos][1:-1]
|
||||
for m in self.namespace_pattern.finditer(ns):
|
||||
if self.__hdelimiter is None:
|
||||
self.__hdelimiter = m.group("delimiter")
|
||||
if item not in self.__ns_prefixes:
|
||||
self.__ns_prefixes[item] = []
|
||||
self.__ns_prefixes[item].append(m.group("prefix"))
|
||||
|
||||
def parse_search_parameters(self, criterion: str, pattern: str) -> None:
|
||||
"""Parse search information and apply them."""
|
||||
|
||||
def or_criterion(old, c):
|
||||
if old == "":
|
||||
return c
|
||||
return f"OR {old} {c}"
|
||||
|
||||
if criterion == "both":
|
||||
criterion = "from_addr, subject"
|
||||
criterions = ""
|
||||
for c in criterion.split(","):
|
||||
if c == "from_addr":
|
||||
key = "FROM"
|
||||
elif c == "subject":
|
||||
key = "SUBJECT"
|
||||
else:
|
||||
continue
|
||||
criterions = or_criterion(criterions, f"({key} {pattern})")
|
||||
self.criterions = [bytearray(criterions, "utf-8")]
|
||||
|
||||
def messages_count(self, **kwargs) -> int:
|
||||
"""An enhanced version of messages_count.
|
||||
|
||||
With IMAP, to know how many messages a mailbox contains, we
|
||||
have to make a request to the server. To avoid requests
|
||||
multiplications, we sort messages in the same time. This will
|
||||
be usefull for other methods.
|
||||
|
||||
:param order: sorting order
|
||||
:param folder: mailbox to scan
|
||||
"""
|
||||
if "order" in kwargs and kwargs["order"]:
|
||||
sign = kwargs["order"][:1]
|
||||
criterion = kwargs["order"][1:].upper()
|
||||
if sign == "-":
|
||||
criterion = f"REVERSE {criterion}"
|
||||
else:
|
||||
criterion = "REVERSE DATE"
|
||||
mbox = kwargs.get("mbox")
|
||||
|
||||
# FIXME: pourquoi suis je obligé de faire un SELECT ici? un
|
||||
# EXAMINE plante mais je pense que c'est du à une mauvaise
|
||||
# lecture des réponses de ma part...
|
||||
self.select_mailbox(mbox, readonly=False)
|
||||
cmdname = "SORT"
|
||||
data = self._cmd(
|
||||
cmdname,
|
||||
bytearray(f"({criterion})", "utf-8"),
|
||||
b"UTF-8",
|
||||
b"(NOT DELETED)",
|
||||
*self.criterions,
|
||||
)
|
||||
self.messages = data[0].decode().split()
|
||||
self.getquota(mbox)
|
||||
return len(self.messages)
|
||||
|
||||
def select_mailbox(
|
||||
self, name: str, readonly: bool = True, force: bool = False
|
||||
) -> None:
|
||||
"""Issue a SELECT/EXAMINE command to the server.
|
||||
|
||||
The given name is first 'imap-utf7' encoded.
|
||||
|
||||
:param name: mailbox's name
|
||||
:param readonly:
|
||||
"""
|
||||
if hasattr(self, "current_mailbox"):
|
||||
if self.current_mailbox == name and not force:
|
||||
return
|
||||
self.current_mailbox = name
|
||||
name = self._encode_mbox_name(name)
|
||||
if readonly:
|
||||
self._cmd("EXAMINE", name)
|
||||
else:
|
||||
self._cmd("SELECT", name)
|
||||
self.m.state = "SELECTED"
|
||||
|
||||
def unseen_messages(self, mailbox: str) -> int:
|
||||
"""Return the number of unseen messages
|
||||
|
||||
:param mailbox: the mailbox's name
|
||||
:return: an integer
|
||||
"""
|
||||
data = self._cmd("STATUS", self._encode_mbox_name(mailbox), "(UNSEEN)")
|
||||
m = self.unseen_pattern.match(data[-1].decode())
|
||||
if m is None:
|
||||
return 0
|
||||
return int(m.group(1))
|
||||
|
||||
def _encode_mbox_name(self, folder):
|
||||
"""Encode folder name (str) to imap4-utf-7 and quote it."""
|
||||
if not folder:
|
||||
return "INBOX"
|
||||
return b'"' + folder.encode("imap4-utf-7") + b'"'
|
||||
|
||||
def _parse_mailbox_name(self, descr, prefix, delimiter, parts):
|
||||
if not len(parts):
|
||||
return False
|
||||
path = f"{prefix}{delimiter}{parts[0]}"
|
||||
sdescr = None
|
||||
for d in descr:
|
||||
if d["path"] == path:
|
||||
sdescr = d
|
||||
break
|
||||
if sdescr is None:
|
||||
sdescr = {"name": parts[0], "path": path, "sub": []}
|
||||
descr += [sdescr]
|
||||
if self._parse_mailbox_name(sdescr["sub"], path, delimiter, parts[1:]):
|
||||
sdescr["class"] = "subfolders"
|
||||
return True
|
||||
|
||||
def _listmboxes_simple(
|
||||
self, topmailbox: str = "INBOX", mailboxes: Optional[list] = None, **kwargs
|
||||
) -> None:
|
||||
# data = self._cmd("LIST", "", "*")
|
||||
if not mailboxes:
|
||||
mailboxes = []
|
||||
(status, data) = self.m.list()
|
||||
newmboxes = []
|
||||
for mb in data:
|
||||
flags, delimiter, name = self.list_response_pattern.match(
|
||||
mb.decode()
|
||||
).groups()
|
||||
name = bytearray(name.strip('"'), "utf-8").decode("imap4-utf-7")
|
||||
mdm_found = False
|
||||
for idx, mdm in enumerate(mailboxes):
|
||||
if mdm["name"] == name:
|
||||
mdm_found = True
|
||||
descr = mailboxes[idx]
|
||||
break
|
||||
if not mdm_found:
|
||||
descr = {"name": name}
|
||||
newmboxes += [descr]
|
||||
|
||||
if re.search(rf"\{delimiter}", name):
|
||||
parts = name.split(delimiter)
|
||||
if "path" not in descr:
|
||||
descr["path"] = parts[0]
|
||||
descr["sub"] = []
|
||||
if self._parse_mailbox_name(
|
||||
descr["sub"], parts[0], delimiter, parts[1:]
|
||||
):
|
||||
descr["class"] = "subfolders"
|
||||
continue
|
||||
|
||||
from operator import itemgetter
|
||||
|
||||
mailboxes += sorted(newmboxes, key=itemgetter("name"))
|
||||
|
||||
@capability("LIST-EXTENDED", "_listmboxes_simple")
|
||||
def _listmboxes(self, topmailbox: str, mailboxes: list, until_mailbox=None) -> None:
|
||||
"""Retrieve mailboxes list."""
|
||||
pattern = (
|
||||
f'"{topmailbox.encode("imap4-utf-7").decode()}{self.hdelimiter}%"'
|
||||
if topmailbox
|
||||
else "%"
|
||||
)
|
||||
resp = self._cmd(
|
||||
"LIST", '""', pattern, "RETURN", "(CHILDREN STATUS (MESSAGES))"
|
||||
)
|
||||
newmboxes = []
|
||||
for mb in resp:
|
||||
if not mb:
|
||||
continue
|
||||
if type(mb) in [list, tuple]:
|
||||
flags, delimiter, namelen = self.list_response_pattern_literal.match(
|
||||
mb[0].decode()
|
||||
).groups()
|
||||
name = mb[1][0 : int(namelen)]
|
||||
else:
|
||||
flags, delimiter, name, childinfo = (
|
||||
self.listextended_response_pattern.match(mb.decode()).groups()
|
||||
)
|
||||
flags = flags.split(" ")
|
||||
name = bytearray(name, "utf-8")
|
||||
name = name.decode("imap4-utf-7")
|
||||
mdm_found = False
|
||||
for idx, mdm in enumerate(mailboxes):
|
||||
if mdm["name"] == name:
|
||||
mdm_found = True
|
||||
descr = mailboxes[idx]
|
||||
break
|
||||
if not mdm_found:
|
||||
descr = {"name": name, "label": name, "type": "normal"}
|
||||
newmboxes += [descr]
|
||||
|
||||
if "\\Marked" in flags or "\\UnMarked" not in flags:
|
||||
descr["send_status"] = True
|
||||
if r"\NonExistent" in flags:
|
||||
descr["removed"] = True
|
||||
if "\\HasChildren" in flags:
|
||||
descr["path"] = name
|
||||
descr["sub"] = []
|
||||
if until_mailbox and until_mailbox.startswith(name):
|
||||
self._listmboxes(name, descr["sub"], until_mailbox)
|
||||
|
||||
from operator import itemgetter
|
||||
|
||||
mailboxes += sorted(newmboxes, key=itemgetter("name"))
|
||||
|
||||
def getmboxes(
|
||||
self,
|
||||
user,
|
||||
topmailbox: str = "",
|
||||
until_mailbox=None,
|
||||
unseen_messages: bool = True,
|
||||
) -> list:
|
||||
"""Returns a list of mailboxes for a particular user.
|
||||
|
||||
By default, only the first level of mailboxes under
|
||||
``topmailbox`` is returned. If ``until_mailbox`` is specified,
|
||||
all levels needed to access this mailbox will be returned.
|
||||
|
||||
:param user: a ``User`` instance
|
||||
:param topmailbox: the mailbox where to start in the tree
|
||||
:param until_mailbox: the deepest needed mailbox
|
||||
:param unseen_messages: include unseen messages counters or not
|
||||
:return: a list
|
||||
"""
|
||||
if topmailbox:
|
||||
md_mailboxes = []
|
||||
else:
|
||||
md_mailboxes = [
|
||||
{"name": "INBOX", "type": "inbox", "label": _("Inbox")},
|
||||
{
|
||||
"name": user.parameters.get_value("drafts_folder"),
|
||||
"type": "draft",
|
||||
"label": _("Drafts"),
|
||||
},
|
||||
{
|
||||
"name": user.parameters.get_value("junk_folder"),
|
||||
"type": "junk",
|
||||
"label": _("Junk"),
|
||||
},
|
||||
{
|
||||
"name": user.parameters.get_value("sent_folder"),
|
||||
"type": "sent",
|
||||
"label": _("Sent"),
|
||||
},
|
||||
{
|
||||
"name": user.parameters.get_value("trash_folder"),
|
||||
"type": "trash",
|
||||
"label": _("Trash"),
|
||||
},
|
||||
]
|
||||
if until_mailbox:
|
||||
name, parent = separate_mailbox(until_mailbox, self.hdelimiter)
|
||||
if parent:
|
||||
until_mailbox = parent
|
||||
self._listmboxes(topmailbox, md_mailboxes, until_mailbox)
|
||||
|
||||
if unseen_messages:
|
||||
for mb in md_mailboxes:
|
||||
if "send_status" not in mb:
|
||||
continue
|
||||
del mb["send_status"]
|
||||
key = "path" if "path" in mb else "name"
|
||||
if mb.get("removed", False):
|
||||
continue
|
||||
count = self.unseen_messages(mb[key])
|
||||
if count == 0:
|
||||
continue
|
||||
mb["unseen"] = count
|
||||
return md_mailboxes
|
||||
|
||||
def _add_flag(self, mbox: str, msgset, flag: str) -> None:
|
||||
"""Add flag to a messages set.
|
||||
|
||||
:param mbox: the mailbox containing the messages
|
||||
:param msgset: messages set (uid)
|
||||
:param flag: the flag to add
|
||||
"""
|
||||
self.select_mailbox(mbox, False)
|
||||
self._cmd("STORE", msgset, "+FLAGS", flag)
|
||||
|
||||
def _remove_flag(self, mbox: str, msgset, flag: str) -> None:
|
||||
"""Remove flag from a message set.
|
||||
|
||||
:param mbox: the mailbox containing the messages
|
||||
:param msgset: messages set (uid)
|
||||
:param flag: the flag to remove
|
||||
"""
|
||||
self.select_mailbox(mbox, False)
|
||||
self._cmd("STORE", msgset, "-FLAGS", flag)
|
||||
|
||||
def mark_messages_unread(self, mbox: str, msgset) -> None:
|
||||
"""Mark a set of messages as unread.
|
||||
|
||||
:param mbox: the mailbox containing the messages
|
||||
:param msgset: messages set (uid)
|
||||
"""
|
||||
self._remove_flag(mbox, msgset, r"(\Seen)")
|
||||
|
||||
def mark_messages_read(self, mbox: str, msgset) -> None:
|
||||
"""Mark a set of messages as unread.
|
||||
|
||||
:param mbox: the mailbox containing the messages
|
||||
:param msgset: messages set (uid)
|
||||
"""
|
||||
self._add_flag(mbox, msgset, r"(\Seen)")
|
||||
|
||||
def mark_messages_flagged(self, mbox: str, msgset) -> None:
|
||||
"""Mark a set of messages as flagged.
|
||||
|
||||
:param mbox: the mailbox containing the messages
|
||||
:param msgset: messages set (uid)
|
||||
"""
|
||||
self._add_flag(mbox, msgset, r"(\Flagged)")
|
||||
|
||||
def mark_messages_unflagged(self, mbox: str, msgset) -> None:
|
||||
"""Mark a set of messages as unflagged.
|
||||
|
||||
:param mbox: the mailbox containing the messages
|
||||
:param msgset: messages set (uid)
|
||||
"""
|
||||
self._remove_flag(mbox, msgset, r"(\Flagged)")
|
||||
|
||||
def msg_forwarded(self, mailbox: str, mailid: str) -> None:
|
||||
self._add_flag(mailbox, mailid, "($Forwarded)")
|
||||
|
||||
def msg_answered(self, mailbox: str, mailid: str) -> None:
|
||||
"""Add the \Answered flag to this email."""
|
||||
self._add_flag(mailbox, mailid, r"(\Answered)")
|
||||
|
||||
def move(self, msgset, oldmailbox: str, newmailbox: str) -> None:
|
||||
"""Move messages between mailboxes."""
|
||||
self.select_mailbox(oldmailbox, False)
|
||||
self._cmd("COPY", msgset, self._encode_mbox_name(newmailbox))
|
||||
self._cmd("STORE", msgset, "+FLAGS", r"(\Deleted \Seen)")
|
||||
|
||||
def push_mail(self, mbox: str, msg):
|
||||
now = imaplib.Time2Internaldate(time.time())
|
||||
msg = bytes(msg)
|
||||
return self.m.append(self._encode_mbox_name(mbox), r"(\Seen)", now, msg)
|
||||
|
||||
def empty(self, mbox: str):
|
||||
self.select_mailbox(mbox, False)
|
||||
resp = self._cmd("SEARCH", "ALL")
|
||||
seq = b",".join(resp[0].split())
|
||||
if seq == b"":
|
||||
return
|
||||
self._cmd("STORE", seq, "+FLAGS", r"(\Deleted)")
|
||||
self._cmd("EXPUNGE")
|
||||
|
||||
def compact(self, mbox: str):
|
||||
"""Compact a specific mailbox.
|
||||
|
||||
Issue an EXPUNGE command for the specified mailbox.
|
||||
|
||||
:param mbox: the mailbox's name
|
||||
"""
|
||||
self.select_mailbox(mbox, False)
|
||||
self._cmd("EXPUNGE")
|
||||
|
||||
def create_folder(self, name: str, parent: Optional[str] = None) -> bool:
|
||||
if parent is not None:
|
||||
name = f"{parent}{self.hdelimiter}{name}"
|
||||
typ, data = self.m.create(self._encode_mbox_name(name))
|
||||
if typ == "NO":
|
||||
raise WebmailInternalError(data[0])
|
||||
return True
|
||||
|
||||
def rename_folder(self, oldname: str, newname: str) -> bool:
|
||||
typ, data = self.m.rename(
|
||||
self._encode_mbox_name(oldname), self._encode_mbox_name(newname)
|
||||
)
|
||||
if typ == "NO":
|
||||
raise WebmailInternalError(data[0], ajax=True)
|
||||
return True
|
||||
|
||||
def delete_folder(self, name: str) -> bool:
|
||||
typ, data = self.m.delete(self._encode_mbox_name(name))
|
||||
if typ == "NO":
|
||||
raise WebmailInternalError(data[0])
|
||||
return True
|
||||
|
||||
def getquota(self, mailbox: str) -> None:
|
||||
"""Retrieve quota information from the server.
|
||||
|
||||
We also compute the current usage.
|
||||
"""
|
||||
if "QUOTA" not in self.capabilities:
|
||||
self.quota_limit = self.quota_current = None
|
||||
return
|
||||
try:
|
||||
data = self._cmd(
|
||||
"GETQUOTAROOT",
|
||||
self._encode_mbox_name(mailbox),
|
||||
responses=["QUOTAROOT", "QUOTA"],
|
||||
)
|
||||
except ImapError:
|
||||
data = None
|
||||
|
||||
if data is None:
|
||||
self.quota_limit = self.quota_current = None
|
||||
return
|
||||
|
||||
quotadef = data[1][0].decode()
|
||||
m = re.search(r"\(STORAGE (\d+) (\d+)\)", quotadef)
|
||||
if not m:
|
||||
print("Problem while parsing quota def")
|
||||
return
|
||||
self.quota_limit = int(m.group(2))
|
||||
self.quota_current = int(m.group(1))
|
||||
try:
|
||||
self.quota_usage = int(
|
||||
float(self.quota_current) / float(self.quota_limit) * 100
|
||||
)
|
||||
except TypeError:
|
||||
self.quota_usage = -1
|
||||
|
||||
def fetchpart(self, uid: str, mbox: str, partnum):
|
||||
"""Retrieve a specific message part.
|
||||
|
||||
Useful to fetch attachments from the server. Part headers and
|
||||
the payload are returned separatly.
|
||||
|
||||
:param uid: a message UID
|
||||
:param mbox: the mailbox containing the message
|
||||
:param partnum: the part number
|
||||
:return: a 2uple (dict, string)
|
||||
"""
|
||||
self.select_mailbox(mbox, False)
|
||||
data = self._cmd("FETCH", uid, f"(BODYSTRUCTURE BODY[{partnum}])")
|
||||
bs = BodyStructure(data[int(uid)]["BODYSTRUCTURE"])
|
||||
attdef = bs.find_attachment(partnum)
|
||||
return attdef, data[int(uid)][f"BODY[{partnum}]"]
|
||||
|
||||
def fetch(
|
||||
self, start: int, stop: Optional[int] = None, mbox: Optional[str] = None
|
||||
) -> list:
|
||||
"""Retrieve information about messages from the server.
|
||||
|
||||
Issue a FETCH command to retrieve information about one or
|
||||
more messages (such as headers) from the server.
|
||||
|
||||
:param start: index of the first message
|
||||
:param stop: index of the last message (optionnal)
|
||||
:param mbox: the mailbox that contains the messages
|
||||
"""
|
||||
self.select_mailbox(mbox, False)
|
||||
if start and stop:
|
||||
submessages = self.messages[start - 1 : stop]
|
||||
mrange = ",".join(submessages)
|
||||
else:
|
||||
submessages = [start]
|
||||
mrange = str(start)
|
||||
headers = "DATE FROM TO CC SUBJECT"
|
||||
query = (
|
||||
f"(FLAGS BODYSTRUCTURE RFC822.SIZE BODY.PEEK[HEADER.FIELDS ({headers})])"
|
||||
)
|
||||
data = self._cmd("FETCH", mrange, query)
|
||||
result = []
|
||||
for uid in submessages:
|
||||
msg_data = data[int(uid)]
|
||||
msg = email.message_from_string(
|
||||
msg_data[f"BODY[HEADER.FIELDS ({headers})]"]
|
||||
)
|
||||
print(type(msg))
|
||||
msg["imapid"] = uid
|
||||
msg["size"] = msg_data["RFC822.SIZE"]
|
||||
if r"\Seen" not in msg_data["FLAGS"]:
|
||||
msg["style"] = "unseen"
|
||||
if r"\Answered" in msg_data["FLAGS"]:
|
||||
msg["answered"] = True
|
||||
if r"$Forwarded" in msg_data["FLAGS"]:
|
||||
msg["forwarded"] = True
|
||||
if r"\Flagged" in msg_data["FLAGS"]:
|
||||
msg["flagged"] = True
|
||||
bstruct = BodyStructure(msg_data["BODYSTRUCTURE"])
|
||||
if bstruct.has_attachments():
|
||||
msg["attachments"] = True
|
||||
result += [msg]
|
||||
return result
|
||||
|
||||
def fetchmail(
|
||||
self, mbox: str, mailid: str, readonly: bool = True, what: str = "bodystructure"
|
||||
):
|
||||
"""Retrieve information about a specific message.
|
||||
|
||||
Issue a FETCH command to retrieve a message's content from the
|
||||
server. In order to not overload the server, we first retrieve
|
||||
the BODYSTRUCTURE of the message. Then, according to the
|
||||
result and to the user's preferences, we retrieve the
|
||||
appropriate content (plain, html, etc.).
|
||||
|
||||
:param mbox: the mailbox containing the message
|
||||
:param mailid: the message's unique id
|
||||
:param readonly:
|
||||
:param headers:
|
||||
"""
|
||||
self.select_mailbox(mbox, readonly)
|
||||
if what == "bodystructure":
|
||||
to_fetch = "(BODYSTRUCTURE)"
|
||||
elif what == "source":
|
||||
to_fetch = "(BODY[])"
|
||||
else:
|
||||
bcmd = "BODY.PEEK" if readonly else "BODY"
|
||||
to_fetch = f"(BODYSTRUCTURE {bcmd}[HEADER.FIELDS ({what})])"
|
||||
data = self._cmd("FETCH", mailid, to_fetch)
|
||||
return data[int(mailid)]
|
||||
|
||||
|
||||
def separate_mailbox(fullname: str, sep: str = ".") -> tuple[str, Optional[str]]:
|
||||
"""Split a mailbox name.
|
||||
|
||||
If a separator is found in ``fullname``, this function returns the
|
||||
corresponding name and parent mailbox name.
|
||||
"""
|
||||
if sep in fullname:
|
||||
parts = fullname.split(sep)
|
||||
name = parts[-1]
|
||||
parent = sep.join(parts[0 : len(parts) - 1])
|
||||
return name, parent
|
||||
return fullname, None
|
||||
|
||||
|
||||
def get_imapconnector(request) -> IMAPconnector:
|
||||
"""Shortcut to create an IMAP connector.
|
||||
|
||||
:param request: a ``Request`` object
|
||||
"""
|
||||
return IMAPconnector(request.user.username, str(request.auth))
|
||||
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
Copy of the original rfc6266 package, just the generation part.
|
||||
|
||||
Orginal code is available at https://github.com/g2p/rfc6266.
|
||||
"""
|
||||
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
# RFC 2616
|
||||
separator_chars = '()<>@,;:\\"/[]?={} \t'
|
||||
# RFC 5987
|
||||
attr_chars_nonalnum = "!#$&+-.^_`|~"
|
||||
|
||||
|
||||
def percent_encode(string, safe, encoding):
|
||||
return quote(string, safe, encoding, errors="strict")
|
||||
|
||||
|
||||
def is_token_char(ch):
|
||||
# Must be ascii, and neither a control char nor a separator char
|
||||
asciicode = ord(ch)
|
||||
# < 128 means ascii, exclude control chars at 0-31 and 127,
|
||||
# exclude separator characters.
|
||||
return 31 < asciicode < 127 and ch not in separator_chars
|
||||
|
||||
|
||||
def is_token(candidate):
|
||||
return all(is_token_char(ch) for ch in candidate)
|
||||
|
||||
|
||||
def is_ascii(text):
|
||||
return all(ord(ch) < 128 for ch in text)
|
||||
|
||||
|
||||
def fits_inside_codec(text, codec):
|
||||
try:
|
||||
text.encode(codec)
|
||||
except UnicodeEncodeError:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
def is_lws_safe(text):
|
||||
return normalize_ws(text) == text
|
||||
|
||||
|
||||
def normalize_ws(text):
|
||||
return " ".join(text.split())
|
||||
|
||||
|
||||
def qd_quote(text):
|
||||
return text.replace("\\", "\\\\").replace('"', '\\"')
|
||||
|
||||
|
||||
def build_header(filename, disposition="attachment", filename_compat=None):
|
||||
"""Generate a Content-Disposition header for a given filename.
|
||||
For legacy clients that don't understand the filename* parameter,
|
||||
a filename_compat value may be given.
|
||||
It should either be ascii-only (recommended) or iso-8859-1 only.
|
||||
In the later case it should be a character string
|
||||
(unicode in Python 2).
|
||||
Options for generating filename_compat (only useful for legacy clients):
|
||||
- ignore (will only send filename*);
|
||||
- strip accents using unicode's decomposing normalisations,
|
||||
which can be done from unicode data (stdlib), and keep only ascii;
|
||||
- use the ascii transliteration tables from Unidecode (PyPI);
|
||||
- use iso-8859-1
|
||||
Ignore is the safest, and can be used to trigger a fallback
|
||||
to the document location (which can be percent-encoded utf-8
|
||||
if you control the URLs).
|
||||
See https://tools.ietf.org/html/rfc6266#appendix-D
|
||||
"""
|
||||
|
||||
# While this method exists, it could also sanitize the filename
|
||||
# by rejecting slashes or other weirdness that might upset a receiver.
|
||||
|
||||
if disposition != "attachment":
|
||||
assert is_token(disposition)
|
||||
|
||||
rv = disposition
|
||||
|
||||
if is_token(filename):
|
||||
rv += f"; filename={filename}"
|
||||
return rv
|
||||
elif is_ascii(filename) and is_lws_safe(filename):
|
||||
qd_filename = qd_quote(filename)
|
||||
rv += f'; filename="{qd_filename}"'
|
||||
if qd_filename == filename:
|
||||
# RFC 6266 claims some implementations are iffy on qdtext's
|
||||
# backslash-escaping, we'll include filename* in that case.
|
||||
return rv
|
||||
elif filename_compat:
|
||||
if is_token(filename_compat):
|
||||
rv += f"; filename={filename_compat}"
|
||||
else:
|
||||
assert is_lws_safe(filename_compat)
|
||||
rv += f'; filename="{qd_quote(filename_compat)}"'
|
||||
|
||||
# alnum are already considered always-safe, but the rest isn't.
|
||||
# Python encodes ~ when it shouldn't, for example.
|
||||
rv += "; filename*=utf-8''{}".format(
|
||||
percent_encode(filename, safe=attr_chars_nonalnum, encoding="utf-8"),
|
||||
)
|
||||
|
||||
# This will only encode filename_compat, if it used non-ascii iso-8859-1.
|
||||
return rv.encode("iso-8859-1")
|
||||
@@ -0,0 +1,66 @@
|
||||
import smtplib
|
||||
|
||||
from django.core import mail
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
from modoboa.lib.cryptutils import get_password
|
||||
from modoboa.parameters import tools as param_tools
|
||||
|
||||
from . import get_imapconnector, clean_attachments
|
||||
|
||||
|
||||
def send_mail(request, form, posturl=None):
|
||||
"""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)
|
||||
"""
|
||||
if not form.is_valid():
|
||||
editormode = request.user.parameters.get_value("editor")
|
||||
listing = render_to_string(
|
||||
"modoboa_webmail/compose.html",
|
||||
{
|
||||
"form": form,
|
||||
"noerrors": True,
|
||||
"body": form.cleaned_data.get("body", "").strip(),
|
||||
"posturl": posturl,
|
||||
},
|
||||
request,
|
||||
)
|
||||
return False, {"status": "ko", "listing": listing, "editor": editormode}
|
||||
|
||||
msg = form.to_msg(request)
|
||||
conf = dict(param_tools.get_global_parameters("modoboa_webmail"))
|
||||
options = {"host": conf["smtp_server"], "port": conf["smtp_port"]}
|
||||
if conf["smtp_secured_mode"] == "ssl":
|
||||
options.update({"use_ssl": True})
|
||||
elif conf["smtp_secured_mode"] == "starttls":
|
||||
options.update({"use_tls": True})
|
||||
if conf["smtp_authentication"]:
|
||||
options.update(
|
||||
{"username": request.user.username, "password": get_password(request)}
|
||||
)
|
||||
try:
|
||||
with mail.get_connection(**options) as connection:
|
||||
msg.connection = connection
|
||||
msg.send()
|
||||
except smtplib.SMTPResponseException as inst:
|
||||
return False, {"status": "ko", "error": inst.smtp_error}
|
||||
except smtplib.SMTPRecipientsRefused as inst:
|
||||
error = ", ".join(
|
||||
[f"{rcpt}: {error}" for rcpt, error in inst.recipients.items()]
|
||||
)
|
||||
return False, {"status": "ko", "error": error}
|
||||
|
||||
# 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, {}
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Tools to deal with message signatures."""
|
||||
|
||||
|
||||
class EmailSignature:
|
||||
"""User signature.
|
||||
|
||||
:param user: User object
|
||||
"""
|
||||
|
||||
def __init__(self, user):
|
||||
self._sig = ""
|
||||
dformat = user.parameters.get_value("editor")
|
||||
content = user.parameters.get_value("signature")
|
||||
if len(content):
|
||||
getattr(self, f"_format_sig_{dformat}")(content)
|
||||
|
||||
def _format_sig_plain(self, content):
|
||||
self._sig = f"""
|
||||
---
|
||||
{content}"""
|
||||
|
||||
def _format_sig_html(self, content):
|
||||
content = f"---<br>{content}"
|
||||
self._sig = content
|
||||
return
|
||||
|
||||
def __str__(self):
|
||||
return self._sig
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Misc. utilities."""
|
||||
|
||||
|
||||
def decode_payload(encoding, payload):
|
||||
"""Decode the payload according to the given encoding
|
||||
|
||||
Supported encodings: base64, quoted-printable.
|
||||
|
||||
:param encoding: the encoding's name
|
||||
:param payload: the value to decode
|
||||
:return: a string
|
||||
"""
|
||||
encoding = encoding.lower()
|
||||
if encoding == "base64":
|
||||
import base64
|
||||
|
||||
return base64.b64decode(payload)
|
||||
elif encoding == "quoted-printable":
|
||||
import quopri
|
||||
|
||||
return quopri.decodestring(payload)
|
||||
return payload
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Webmail serializers."""
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from modoboa.webmail import constants
|
||||
from modoboa.webmail.lib import imapheader
|
||||
|
||||
|
||||
class GlobalParametersSerializer(serializers.Serializer):
|
||||
max_attachment_size = serializers.CharField(default="2048")
|
||||
|
||||
imap_server = serializers.CharField(default="127.0.0.1")
|
||||
imap_secured = serializers.BooleanField(default=False)
|
||||
imap_port = serializers.IntegerField(default=143)
|
||||
|
||||
smtp_server = serializers.CharField(default="127.0.0.1")
|
||||
smtp_secured_mode = serializers.ChoiceField(
|
||||
default=constants.SmtpConnectionMode.NONE.value,
|
||||
choices=constants.SMTP_CONNECTION_MODES,
|
||||
)
|
||||
smtp_port = serializers.IntegerField(default=25)
|
||||
smtp_authentication = serializers.BooleanField(default=False)
|
||||
|
||||
|
||||
class UserPreferencesSerializer(serializers.Serializer):
|
||||
displaymode = serializers.ChoiceField(
|
||||
default=constants.DisplayMode.PLAIN.value, choices=constants.DISPLAY_MODES
|
||||
)
|
||||
enable_links = serializers.BooleanField(default=False)
|
||||
messages_per_page = serializers.IntegerField(default=40)
|
||||
refresh_interval = serializers.IntegerField(default=300)
|
||||
mboxes_col_width = serializers.IntegerField(default=200)
|
||||
|
||||
trash_folder = serializers.CharField(default="Trash")
|
||||
sent_folder = serializers.CharField(default="Sent")
|
||||
drafts_folder = serializers.CharField(default="Drafts")
|
||||
junk_folder = serializers.CharField(default="Junk")
|
||||
|
||||
editor = serializers.ChoiceField(
|
||||
default=constants.DisplayMode.PLAIN.value, choices=constants.DISPLAY_MODES
|
||||
)
|
||||
signature = serializers.CharField(required=False)
|
||||
|
||||
|
||||
class UserMailboxSerializer(serializers.Serializer):
|
||||
|
||||
name = serializers.CharField()
|
||||
path = serializers.CharField(required=False)
|
||||
label = serializers.CharField()
|
||||
type = serializers.ChoiceField(choices=constants.MAILBOX_TYPES)
|
||||
unseen = serializers.IntegerField(default=0)
|
||||
removed = serializers.BooleanField(default=False)
|
||||
sub = serializers.SerializerMethodField()
|
||||
|
||||
def get_sub(self, obj):
|
||||
if "sub" in obj:
|
||||
return UserMailboxSerializer(obj["sub"], many=True).data
|
||||
return None
|
||||
|
||||
|
||||
class EmailAddressSerializer(serializers.Serializer):
|
||||
fulladdress = serializers.CharField()
|
||||
address = serializers.CharField()
|
||||
name = serializers.CharField(required=False)
|
||||
|
||||
|
||||
class EmailHeadersSerializer(serializers.Serializer):
|
||||
|
||||
mailid = serializers.CharField()
|
||||
subject = serializers.SerializerMethodField()
|
||||
from_address = serializers.SerializerMethodField()
|
||||
date = serializers.SerializerMethodField()
|
||||
size = serializers.IntegerField()
|
||||
answered = serializers.BooleanField(default=False)
|
||||
attachments = serializers.BooleanField(default=False)
|
||||
forwarded = serializers.BooleanField(default=False)
|
||||
flagged = serializers.BooleanField(default=False)
|
||||
style = serializers.CharField(required=False)
|
||||
|
||||
def get_subject(self, obj) -> str:
|
||||
return imapheader.parse_subject(obj["Subject"])
|
||||
|
||||
def get_from_address(self, obj):
|
||||
return imapheader.parse_address(obj["From"])
|
||||
|
||||
def get_date(self, obj) -> str:
|
||||
return imapheader.parse_date(obj["Date"])
|
||||
|
||||
|
||||
class EmailSerializer(serializers.Serializer):
|
||||
|
||||
subject = serializers.CharField(source="Subject")
|
||||
from_address = EmailAddressSerializer(source="From")
|
||||
body = serializers.CharField()
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Webmail API urls."""
|
||||
|
||||
from rest_framework import routers
|
||||
|
||||
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")
|
||||
|
||||
urlpatterns = router.urls
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Webmail viewsets."""
|
||||
|
||||
from django.http import Http404
|
||||
|
||||
from rest_framework import 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
|
||||
|
||||
|
||||
class UserMailboxViewSet(viewsets.GenericViewSet):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
serializer_class = 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)
|
||||
return response.Response(serializer.data)
|
||||
|
||||
|
||||
class UserEmailViewSet(viewsets.GenericViewSet):
|
||||
|
||||
def list(self, request):
|
||||
mailbox = request.GET.get("mailbox", "INBOX")
|
||||
imapc = lib.get_imapconnector(request)
|
||||
paginator = Paginator(
|
||||
imapc.messages_count(mbox=mailbox),
|
||||
request.user.parameters.get_value("messages_per_page"),
|
||||
)
|
||||
page = paginator.getpage(1)
|
||||
content = imapc.fetch(page.id_start, page.id_stop, mbox=mailbox)
|
||||
content = [dict(msg) for msg in content]
|
||||
serializer = serializers.EmailHeadersSerializer(content, many=True)
|
||||
return response.Response(serializer.data)
|
||||
|
||||
@action(methods=["get"], detail=False)
|
||||
def content(self, request):
|
||||
mailbox = request.GET.get("mailbox", "INBOX")
|
||||
mailid = request.GET.get("mailid")
|
||||
if not mailbox or not mailid:
|
||||
raise Http404
|
||||
email = lib.ImapEmail(request, f"{mailbox}:{mailid}")
|
||||
email.fetch_headers()
|
||||
serializer = serializers.EmailSerializer(email)
|
||||
return response.Response(serializer.data)
|
||||
@@ -104,6 +104,7 @@ MODOBOA_APPS = (
|
||||
# Modoboa extensions here.
|
||||
"modoboa.contacts",
|
||||
"modoboa.calendars",
|
||||
"modoboa.webmail",
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -426,3 +427,7 @@ DISABLE_DASHBOARD_EXTERNAL_QUERIES = False
|
||||
# Load settings from extensions
|
||||
|
||||
LDAP_SERVER_PORT = os.environ.get("LDAP_SERVER_PORT", 3389)
|
||||
|
||||
WEBMAIL_DEV_MODE = True
|
||||
WEBMAIL_DEV_USERNAME = os.environ.get("WEBMAIL_DEV_USERNAME", "")
|
||||
WEBMAIL_DEV_PASSWORD = os.environ.get("WEBMAIL_DEV_PASSWORD", "")
|
||||
|
||||
Reference in New Issue
Block a user