WIP: Webmail

This commit is contained in:
Antoine Nguyen
2025-05-01 18:00:00 +02:00
parent c56264e57d
commit af196f7333
37 changed files with 2910 additions and 14 deletions
+1
View File
@@ -37,3 +37,4 @@ modoboa.kdev4
.kateproject
test_project/cert.crt
test_project/cert.key
test_project/envfile
+1
View File
@@ -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",
+25
View File
@@ -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 })
},
}
+7 -2
View File
@@ -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>
+4
View File
@@ -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 })
}
},
}
+27 -1
View File
@@ -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
View File
@@ -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,
}
+50
View File
@@ -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>
+5
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
"""AppConfig for radicale."""
"""AppConfig for calendars."""
from django.apps import AppConfig
+7 -7
View File
@@ -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)
+2 -2
View File
@@ -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:
+1
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
default_app_config = "modoboa.webmail.apps.WebmailConfig"
+268
View File
@@ -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,
)
+15
View File
@@ -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()
+32
View File
@@ -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"),
]
+33
View File
@@ -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)
+32
View File
@@ -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",
]
+135
View File
@@ -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
+286
View File
@@ -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
+335
View File
@@ -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 ""
+139
View File
@@ -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)
+828
View File
@@ -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))
+108
View File
@@ -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")
+66
View File
@@ -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, {}
+28
View File
@@ -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
+22
View File
@@ -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
+94
View File
@@ -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()
+12
View File
@@ -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
+49
View File
@@ -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)
+5
View File
@@ -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", "")