mirror of
https://github.com/jwinterm/modoboa.git
synced 2026-11-15 09:23:58 +01:00
Merge pull request #1330 from fyfe/improved-email-parser
Improved email parser
This commit is contained in:
+193
-157
@@ -1,22 +1,29 @@
|
||||
# coding: utf-8
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from email.header import Header, decode_header
|
||||
import email
|
||||
from email.header import Header
|
||||
from email.mime.text import MIMEText
|
||||
from email.utils import make_msgid, formatdate, parseaddr
|
||||
import re
|
||||
import smtplib
|
||||
import time
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.encoding import smart_text
|
||||
from django.utils.html import conditional_escape
|
||||
from django.utils.encoding import smart_str, smart_text
|
||||
from django.utils.html import conditional_escape, escape
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils import six
|
||||
|
||||
from modoboa.lib import u2u_decode
|
||||
|
||||
import lxml.html
|
||||
from lxml.html.clean import Cleaner
|
||||
|
||||
|
||||
# used by Email()
|
||||
_RE_REMOVE_EXTRA_WHITESPACE = re.compile(r"\n\s*\n")
|
||||
_RE_CID = re.compile(r".*[ ]*cid=\"([^\"]*)\".*", re.I)
|
||||
|
||||
|
||||
class EmailAddress(object):
|
||||
|
||||
@@ -33,107 +40,100 @@ class EmailAddress(object):
|
||||
|
||||
class Email(object):
|
||||
|
||||
def __init__(self, mailid, mformat="plain", dformat="plain", links=0):
|
||||
self.attached_map = {}
|
||||
_basic_headers = (
|
||||
"From",
|
||||
"To",
|
||||
"Cc",
|
||||
"Date",
|
||||
"Subject",
|
||||
)
|
||||
|
||||
_image_mime_types = (
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/jpeg",
|
||||
)
|
||||
|
||||
def __init__(self, mailid, mformat="plain", dformat="plain", links=False):
|
||||
self.contents = {"html": "", "plain": ""}
|
||||
self.headers = []
|
||||
self.attachments = {}
|
||||
self.mailid = mailid
|
||||
self.mformat = mformat
|
||||
self.dformat = dformat
|
||||
self.links = links
|
||||
|
||||
if isinstance(links, bool):
|
||||
self.links = links
|
||||
elif isinstance(links, int):
|
||||
# TODO: modoboa-webmail currently sets links == 0 or 1, it needs
|
||||
# updated to use True or False.
|
||||
self.links = bool(links)
|
||||
elif links == "0":
|
||||
raise TypeError("links == \"0\" is not valid, did you mean True or "
|
||||
"False?")
|
||||
else:
|
||||
raise TypeError("links should be a boolean value")
|
||||
|
||||
self._msg = None
|
||||
self._headers = None
|
||||
self._body = None
|
||||
self._images = {}
|
||||
|
||||
# used by modoboa_webmail.lib.imapemail.ImapEmail
|
||||
self.attachments = {}
|
||||
|
||||
@property
|
||||
def msg(self):
|
||||
"""Return an email.message object.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
if self._msg is None:
|
||||
mail_text = self._fetch_message()
|
||||
assert isinstance(mail_text, bytes)
|
||||
# python 2 expects str (bytes), python 3 expects str (unicode)
|
||||
mail_text = smart_str(mail_text, errors="replace")
|
||||
self._msg = email.message_from_string(mail_text)
|
||||
|
||||
return self._msg
|
||||
|
||||
@property
|
||||
def headers(self):
|
||||
if self._headers is None:
|
||||
self._headers = []
|
||||
|
||||
# don't break modoboa_webmail.lib.imapemail.ImapEmail, it
|
||||
# doesn't have self._msg it loads e-mails (and their headers)
|
||||
# from the IMAP server.
|
||||
if self._msg is not None:
|
||||
for header in self._basic_headers:
|
||||
value = self.get_header(self.msg, header)
|
||||
self._headers.append({
|
||||
"name": header,
|
||||
"value": value
|
||||
})
|
||||
|
||||
return self._headers
|
||||
|
||||
@headers.setter
|
||||
def headers(self, value):
|
||||
self._headers = value
|
||||
|
||||
@property
|
||||
def body(self):
|
||||
"""Return email's body."""
|
||||
if self._body is None:
|
||||
self._body = (
|
||||
getattr(self, "viewmail_%s" % self.mformat)
|
||||
(self.contents[self.mformat], links=self.links)
|
||||
)
|
||||
self._parse(self.msg)
|
||||
self._body = getattr(self, "viewmail_%s" % self.dformat)()
|
||||
|
||||
return self._body
|
||||
|
||||
def get_header(self, msg, hdrname):
|
||||
"""Look for a particular header.
|
||||
|
||||
:param string hdrname: header name
|
||||
:return: header avalue
|
||||
"""
|
||||
for name in [hdrname, hdrname.upper()]:
|
||||
if name in msg:
|
||||
return msg[name]
|
||||
def get_header(self, msg, header):
|
||||
# msg parameter to maintain compatibility with
|
||||
# modoboa_webmail.lib.imapemail.ImapEmail
|
||||
if header in msg:
|
||||
return "".join([smart_text(v, encoding=(e or 'ascii'))
|
||||
for v, e in email.header.decode_header(msg[header])
|
||||
])
|
||||
return ""
|
||||
|
||||
def _parse_default(self, msg, level):
|
||||
"""Default parser
|
||||
def _fetch_message(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
All parts handled by this parser will be consireded as
|
||||
attachments.
|
||||
"""
|
||||
fname = msg.get_filename()
|
||||
if fname is not None:
|
||||
if isinstance(fname, six.text_type):
|
||||
fname = fname.encode("utf-8")
|
||||
decoded = decode_header(fname)
|
||||
value = (
|
||||
decoded[0][0] if decoded[0][1] is None
|
||||
else smart_text(decoded[0][0], decoded[0][1])
|
||||
)
|
||||
else:
|
||||
value = "part_%s" % level
|
||||
self.attachments[level] = value
|
||||
|
||||
def _parse_text(self, msg, level):
|
||||
"""Displayable content parser
|
||||
|
||||
text, html, calendar, etc. All those contents can be displayed
|
||||
inside a navigator.
|
||||
"""
|
||||
if msg.get_content_subtype() not in ["plain", "html"]:
|
||||
self._parse_default(msg, level)
|
||||
target = "plain"
|
||||
else:
|
||||
target = msg.get_content_subtype()
|
||||
self.contents[target] += decode(msg.get_payload(decode=True),
|
||||
charset=msg.get_content_charset())
|
||||
|
||||
def _parse_image(self, msg, level):
|
||||
"""image/* parser
|
||||
|
||||
The only reason to make a specific parser for images is that,
|
||||
sometimes, messages embark inline images, which means they
|
||||
must be displayed and not attached.
|
||||
"""
|
||||
if self.dformat == "html" and self.links \
|
||||
and "Content-Disposition" in msg:
|
||||
if msg["Content-Disposition"].startswith("inline"):
|
||||
cid = None
|
||||
if "Content-ID" in msg:
|
||||
m = re.match("<(.+)>", msg["Content-ID"])
|
||||
cid = m is not None and m.group(1) or msg["Content-ID"]
|
||||
fname = msg.get_filename()
|
||||
if fname is None:
|
||||
if "Content-Location" in msg:
|
||||
fname = msg["Content-Location"]
|
||||
elif cid is not None:
|
||||
fname = cid
|
||||
else:
|
||||
# I give up for now :p
|
||||
return
|
||||
self.attached_map[cid] = re.match("^http:", fname) and fname \
|
||||
or self.__save_image(fname, msg)
|
||||
return
|
||||
self._parse_default(msg, level)
|
||||
|
||||
def _parse(self, msg, level=None):
|
||||
def _parse(self, msg, level=0):
|
||||
"""Recursive email parser
|
||||
|
||||
A message structure can be complex. To correctly handle
|
||||
@@ -146,85 +146,121 @@ class Email(object):
|
||||
:param msg: message (or part) to parse
|
||||
:param level: current part number
|
||||
"""
|
||||
if msg.is_multipart() and msg.get_content_maintype() != "message":
|
||||
cpt = 1
|
||||
for part in msg.get_payload():
|
||||
nlevel = level is None and ("%d" % cpt) \
|
||||
or "%s.%d" % (level, cpt)
|
||||
self._parse(part, nlevel)
|
||||
cpt += 1
|
||||
content_type = msg.get_content_maintype()
|
||||
is_attachment = "attachment" in msg.get("Content-Disposition", "")
|
||||
|
||||
if content_type == "text" and not is_attachment:
|
||||
self._parse_text(msg, level)
|
||||
elif content_type == "multipart" and not is_attachment:
|
||||
self._parse_multipart(msg, level)
|
||||
else:
|
||||
self._parse_inline_image(msg, level)
|
||||
|
||||
if self.contents["plain"]:
|
||||
self._post_process_plain()
|
||||
if self.contents["html"]:
|
||||
self._post_process_html()
|
||||
|
||||
def _parse_text(self, msg, level=0):
|
||||
content_type = msg.get_content_subtype()
|
||||
|
||||
if content_type in ["plain", "html"]:
|
||||
encoding = msg.get_content_charset() or "utf-8"
|
||||
mail_text = smart_text(msg.get_payload(decode=True), encoding=encoding, errors="replace")
|
||||
self.contents[content_type] += mail_text
|
||||
|
||||
def _parse_multipart(self, msg, level=0):
|
||||
level += 1
|
||||
for part in msg.walk():
|
||||
content_type = part.get_content_maintype()
|
||||
if content_type == "text":
|
||||
self._parse_text(part, level=level)
|
||||
else:
|
||||
# I'm a dumb mail parser and treat all non-text parts as attachments
|
||||
self._parse_inline_image(part, level=level)
|
||||
|
||||
def _parse_inline_image(self, msg, level=0):
|
||||
content_type = msg.get_content_type()
|
||||
cid = None
|
||||
if "Content-ID" in msg:
|
||||
cid = msg["Content-ID"]
|
||||
if cid.startswith('<') and cid.endswith('>'):
|
||||
cid = cid[1:-1]
|
||||
else:
|
||||
matches = _RE_CID.match(msg["Content-Type"])
|
||||
if matches is not None:
|
||||
cid = matches.group(1)
|
||||
transfer_encoding = msg["Content-Transfer-Encoding"]
|
||||
if content_type not in self._image_mime_types or cid is None:
|
||||
# I'm a dumb mail parser and I don't care ;p
|
||||
return
|
||||
if transfer_encoding != "base64":
|
||||
# I don't deal with non base64 encoded images
|
||||
return
|
||||
if cid in self._images:
|
||||
# Duplicate Content-ID
|
||||
return
|
||||
|
||||
if level is None:
|
||||
level = "1"
|
||||
try:
|
||||
getattr(self, "_parse_%s" % msg.get_content_maintype())(msg, level)
|
||||
except AttributeError:
|
||||
self._parse_default(msg, level)
|
||||
self._images[cid] = "data:%s;base64,%s" % (
|
||||
content_type, "".join(msg.get_payload().splitlines(False)))
|
||||
|
||||
def __save_image(self, fname, part):
|
||||
"""Save an inline image on the filesystem.
|
||||
def _post_process_plain(self):
|
||||
mail_text = _RE_REMOVE_EXTRA_WHITESPACE \
|
||||
.sub("\n\n", self.contents["plain"]).strip()
|
||||
mail_text = escape(mail_text)
|
||||
self.contents["plain"] = smart_text(mail_text)
|
||||
|
||||
Some HTML messages are using inline images (attached images
|
||||
with links on them inside the body). In order to display them,
|
||||
images are saved on the filesystem and links contained in the
|
||||
message are modified.
|
||||
def _post_process_html(self):
|
||||
html = lxml.html.fromstring(self.contents["html"])
|
||||
|
||||
:param fname: the image associated filename
|
||||
:param part: the email part that contains the image payload
|
||||
"""
|
||||
if re.search(r"\.\.", fname):
|
||||
return None
|
||||
path = "/static/tmp/" + fname
|
||||
fp = open(settings.MODOBOA_DIR + path, "wb")
|
||||
fp.write(part.get_payload(decode=True))
|
||||
fp.close()
|
||||
return path
|
||||
if self.links:
|
||||
html.rewrite_links(self._map_cid)
|
||||
|
||||
def map_cid(self, url):
|
||||
"""Replace attachment links.
|
||||
for link in html.iterlinks():
|
||||
link[0].set("target", "_blank")
|
||||
else:
|
||||
html.rewrite_links(lambda x: None)
|
||||
|
||||
cleaner = Cleaner(
|
||||
scripts=True,
|
||||
javascript=True,
|
||||
links=True,
|
||||
page_structure=True,
|
||||
embedded=True,
|
||||
frames=True,
|
||||
add_nofollow=True)
|
||||
mail_text = lxml.html.tostring(cleaner.clean_html(html))
|
||||
self.contents["html"] = smart_text(mail_text)
|
||||
|
||||
def _map_cid(self, url):
|
||||
if url.startswith("cid:"):
|
||||
cid = url[4:]
|
||||
if cid in self._images:
|
||||
url = self._images[cid]
|
||||
|
||||
:param str url: original url
|
||||
:rtype: string
|
||||
:return: internal link
|
||||
"""
|
||||
match = re.match(".*cid:(.+)", url)
|
||||
if match is not None:
|
||||
if match.group(1) in self.attached_map:
|
||||
return self.attached_map[match.group(1)]
|
||||
return url
|
||||
|
||||
def viewmail_plain(self, contents=None, **kwargs):
|
||||
# contents and **kwargs parameters to maintain compatibility with
|
||||
# modoboa_webmail.lib.imapemail.ImapEmail
|
||||
if contents is None:
|
||||
contents = self.contents["plain"]
|
||||
else:
|
||||
contents = conditional_escape(contents)
|
||||
return "<pre>%s</pre>" % contents
|
||||
|
||||
def viewmail_html(self, contents=None, **kwargs):
|
||||
# contents and **kwargs parameters to maintain compatibility with
|
||||
# modoboa_webmail.lib.imapemail.ImapEmail
|
||||
if contents is None:
|
||||
contents = self.contents["html"]
|
||||
return contents
|
||||
|
||||
def render_headers(self, **kwargs):
|
||||
return render_to_string("common/mailheaders.html", {
|
||||
context = {
|
||||
"headers": self.headers,
|
||||
})
|
||||
|
||||
def viewmail_plain(self, content, **kwargs):
|
||||
content = conditional_escape(content)
|
||||
return "<pre>%s</pre>" % content
|
||||
|
||||
def viewmail_html(self, content, **kwargs):
|
||||
import lxml.html
|
||||
|
||||
if content is None or content == "":
|
||||
return ""
|
||||
links = kwargs.get("links", 0)
|
||||
html = lxml.html.fromstring(content)
|
||||
if not links:
|
||||
html.rewrite_links(lambda x: None)
|
||||
else:
|
||||
html.rewrite_links(self.map_cid)
|
||||
for link in html.iterlinks():
|
||||
link[0].set('target', '_blank')
|
||||
body = html.find("body")
|
||||
if body is None:
|
||||
body = lxml.html.tostring(html)
|
||||
else:
|
||||
body = lxml.html.tostring(body)
|
||||
body = re.sub(
|
||||
"<(/?)body", lambda m: "<%sdiv" % m.group(1),
|
||||
body.decode())
|
||||
return body
|
||||
}
|
||||
return render_to_string("common/mailheaders.html", context)
|
||||
|
||||
|
||||
def split_mailbox(mailbox, return_extension=False):
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# coding: utf-8
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""Testing utilities."""
|
||||
|
||||
from __future__ import unicode_literals
|
||||
@@ -13,8 +14,7 @@ from rest_framework.test import APITestCase
|
||||
|
||||
from modoboa.core import models as core_models
|
||||
|
||||
from . import sysutils
|
||||
from . import u2u_decode
|
||||
from .. import sysutils
|
||||
|
||||
try:
|
||||
s = socket.create_connection(('127.0.0.1', 25))
|
||||
@@ -24,7 +24,7 @@ except socket.error:
|
||||
NO_SMTP = True
|
||||
|
||||
try:
|
||||
import ldap # NOQA
|
||||
import ldap # noqa
|
||||
NO_LDAP = False
|
||||
except ImportError:
|
||||
NO_LDAP = True
|
||||
@@ -34,7 +34,7 @@ class ParametersMixin(object):
|
||||
"""Add tools to manage parameters."""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
def setUpTestData(cls): # noqa
|
||||
"""Set LocalConfig instance."""
|
||||
super(ParametersMixin, cls).setUpTestData()
|
||||
cls.localconfig = core_models.LocalConfig.objects.first()
|
||||
@@ -58,7 +58,7 @@ class ModoTestCase(ParametersMixin, TestCase):
|
||||
"""All test cases must inherit from this one."""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
def setUpTestData(cls): # noqa
|
||||
"""Create a default user."""
|
||||
super(ModoTestCase, cls).setUpTestData()
|
||||
management.call_command("load_initial_data")
|
||||
@@ -93,7 +93,7 @@ class ModoAPITestCase(ParametersMixin, APITestCase):
|
||||
"""All test cases must inherit from this one."""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
def setUpTestData(cls): # noqa
|
||||
"""Create a default user."""
|
||||
super(ModoAPITestCase, cls).setUpTestData()
|
||||
management.call_command("load_initial_data")
|
||||
@@ -104,54 +104,3 @@ class ModoAPITestCase(ParametersMixin, APITestCase):
|
||||
"""Setup."""
|
||||
super(ModoAPITestCase, self).setUp()
|
||||
self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token.key)
|
||||
|
||||
|
||||
class U2UTestCase(TestCase):
|
||||
"""Test RFC1342 decoding utilities."""
|
||||
|
||||
def test_header_decoding(self):
|
||||
"""Simple decoding."""
|
||||
samples = [
|
||||
("=?ISO-8859-15?Q?=20Profitez de tous les services en ligne sur "
|
||||
"impots.gouv.fr?=",
|
||||
"Profitez de tous les services en ligne sur impots.gouv.fr"),
|
||||
("=?ISO-8859-1?Q?Accus=E9?= de =?ISO-8859-1?Q?r=E9ception?= de "
|
||||
"votre annonce",
|
||||
"Accusé de réception de votre annonce"),
|
||||
("Sm=?ISO-8859-1?B?9g==?=rg=?ISO-8859-1?B?5Q==?=sbord",
|
||||
"Sm\xf6rg\xe5sbord"),
|
||||
# The following case currently fails because of the way we split
|
||||
# encoded words to parse them separately, which can lead to
|
||||
# unexpected unicode decode errors... I think it will work fine on
|
||||
# Python3
|
||||
# ("=?utf-8?B?VMOpbMOpcMOpYWdlIFZJTkNJIEF1dG9yb3V0ZXMgLSBFeHDD?=\n"
|
||||
# "=?utf-8?B?qWRpdGlvbiBkZSB2b3RyZSBjb21tYW5kZSBOwrAgMjAxNzEyMDcw"
|
||||
# "MDA1?=\n=?utf-8?B?MyBkdSAwNy8xMi8yMDE3IDE0OjQ5OjQx?=",
|
||||
# "")
|
||||
]
|
||||
for sample in samples:
|
||||
self.assertEqual(u2u_decode.u2u_decode(sample[0]), sample[1])
|
||||
|
||||
def test_address_header_decoding(self):
|
||||
"""Check address decoding."""
|
||||
mailsploit_sample = (
|
||||
"=?utf-8?b?cG90dXNAd2hpdGVob3VzZS5nb3Y=?==?utf-8?Q?=00?="
|
||||
"=?utf-8?b?cG90dXNAd2hpdGVob3VzZS5nb3Y=?=@mailsploit.com")
|
||||
expected_result = (
|
||||
"=?utf-8?b?cG90dXNAd2hpdGVob3VzZS5nb3Y=?==?utf-8?Q??="
|
||||
"=?utf-8?b?cG90dXNAd2hpdGVob3VzZS5nb3Y=?=@mailsploit.com")
|
||||
self.assertEqual(
|
||||
u2u_decode.decode_address(mailsploit_sample),
|
||||
("", expected_result)
|
||||
)
|
||||
mailsploit_sample = (
|
||||
'"=?utf-8?b?cG90dXNAd2hpdGVob3VzZS5nb3Y=?==?utf-8?Q?=0A=00?="\n'
|
||||
'<=?utf-8?b?cG90dXNAd2hpdGVob3VzZS5nb3Y=?==?utf-8?Q?=0A=00?='
|
||||
'@mailsploit.com>')
|
||||
expected_result = (
|
||||
'potus@whitehouse.gov',
|
||||
'=?utf-8?b?cG90dXNAd2hpdGVob3VzZS5nb3Y=?==?utf-8?Q??='
|
||||
'@mailsploit.com')
|
||||
self.assertEqual(
|
||||
u2u_decode.decode_address(mailsploit_sample),
|
||||
expected_result)
|
||||
@@ -0,0 +1,130 @@
|
||||
Return-Path: <me@example.net>
|
||||
X-Original-To: someone@example.net
|
||||
Delivered-To: me@example.net
|
||||
Received: from mail.example.net (localhost [127.0.0.1])
|
||||
by mail.example.net (Postfix) with ESMTP id 4BD531AF130
|
||||
for <someone@example.net>; Sun, 17 Dec 2017 03:11:30 +0000 (GMT)
|
||||
Authentication-Results: mail.example.net (amavisd-new);
|
||||
dkim=pass (1024-bit key) reason="pass (just generated, assumed good)"
|
||||
header.d=example.net
|
||||
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=example.net; h=
|
||||
content-language:content-type:content-type:mime-version
|
||||
:user-agent:date:date:message-id:subject:subject:from:from
|
||||
:received:received; s=mail; t=1513480289; bh=wAmt0wg2Yk04321B
|
||||
Q63uWL5f67PEBB2EEGONiFUrIvk=; b=UZUzPqH8TUk/bMZm1ofkjwatNCQftZKK
|
||||
C91uIuX3mmjvngNwbVjx4m7jFZItZscNqigyGJN5HCOl8tC/LJmmWzRdhIisFtLA
|
||||
uXRiBuWps82rbV4ciFU8N30i9UXvtWvMywwL6pYSESqixiwylzbddjMjKXXdyqaT
|
||||
DTyR16ZK8IE=
|
||||
X-Virus-Scanned: amavisd-new at example.net
|
||||
X-Spam-Flag: NO
|
||||
X-Spam-Score: -0.988
|
||||
X-Spam-Level:
|
||||
X-Spam-Status: No, score=-0.988 tagged_above=-999 required=3.9
|
||||
tests=[ALL_TRUSTED=-1, HTML_MESSAGE=0.001, T_HTML_ATTACH=0.01,
|
||||
URIBL_BLOCKED=0.001] autolearn=no autolearn_force=no
|
||||
Received: from mail.example.net ([127.0.0.1])
|
||||
by mail.example.net (mail.example.net [127.0.0.1]) (amavisd-new, port 10026)
|
||||
with LMTP id BSCmm59DxNvB for <someone@example.net>;
|
||||
Sun, 17 Dec 2017 03:11:29 +0000 (GMT)
|
||||
Received: from [10.0.0.1] (my-pc.example.net [10.0.0.1])
|
||||
by mail.example.net (Postfix) with ESMTPSA id 641171AF12D
|
||||
for <someone@example.net>; Sun, 17 Dec 2017 03:11:29 +0000 (GMT)
|
||||
To: Someone <someone@example.net>
|
||||
From: Me <me@example.net>
|
||||
X-Clacks-Overhead: GNU Terry Pratchett
|
||||
Subject: A test message
|
||||
Message-ID: <5294ad7a-e966-f1a0-3431-8c4b1b76f5a3@example.net>
|
||||
Date: Sun, 17 Dec 2017 03:11:33 +0000
|
||||
User-Agent: Mozilla/5.0 (Windows NT 10.0; WOW64; rv:52.0) Gecko/20100101
|
||||
Thunderbird/52.5.0
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/mixed;
|
||||
boundary="------------7EB9EDC10414C906CC3C2CEA"
|
||||
Content-Language: en-GB
|
||||
|
||||
This is a multi-part message in MIME format.
|
||||
--------------7EB9EDC10414C906CC3C2CEA
|
||||
Content-Type: multipart/alternative;
|
||||
boundary="------------87B289699A48D5D0D61BABCC"
|
||||
|
||||
|
||||
--------------87B289699A48D5D0D61BABCC
|
||||
Content-Type: text/plain; charset=utf-8; format=flowed
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
This is an *html* /test /_message._
|
||||
|
||||
:-)
|
||||
|
||||
|
||||
--
|
||||
Someone
|
||||
someone@example.net
|
||||
|
||||
|
||||
--------------87B289699A48D5D0D61BABCC
|
||||
Content-Type: multipart/related;
|
||||
boundary="------------D585B241EEF60F0F44EC20E5"
|
||||
|
||||
|
||||
--------------D585B241EEF60F0F44EC20E5
|
||||
Content-Type: text/html; charset=utf-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
<html>
|
||||
<head>
|
||||
|
||||
<meta http-equiv="content-type" content="text/html; charset=utf-8">
|
||||
</head>
|
||||
<body text="#000000" bgcolor="#FFFFFF">
|
||||
<p>This is an <b>html</b> <i>test </i><u>message.</u></p>
|
||||
<span class="moz-smiley-s1"><span>:-)</span></span><br>
|
||||
<br>
|
||||
<img moz-do-not-send="false"
|
||||
src="cid:part1.D56823CE.7959DBD4@example.net" alt="" height="10"
|
||||
width="10"><br>
|
||||
<pre class="moz-signature" cols="72">--
|
||||
someone
|
||||
<a class="moz-txt-link-abbreviated" href="mailto:someone@example.net">someone@example.net</a></pre>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
--------------D585B241EEF60F0F44EC20E5
|
||||
Content-Type: image/png;
|
||||
name="image.png"
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-ID: <part1.D56823CE.7959DBD4@example.net>
|
||||
Content-Disposition: inline;
|
||||
filename="image.png"
|
||||
|
||||
iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAIAAAACUFjqAAAACXBIWXMAAAsTAAALEwEAmpwY
|
||||
AAAAF0lEQVQY02NgYGD4//8/ThKfHASMRN0AfaqVa6MQxIQAAAAASUVORK5CYII=
|
||||
--------------D585B241EEF60F0F44EC20E5--
|
||||
|
||||
--------------87B289699A48D5D0D61BABCC--
|
||||
|
||||
--------------7EB9EDC10414C906CC3C2CEA
|
||||
Content-Type: text/html;
|
||||
name="test.html"
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-Disposition: attachment;
|
||||
filename="test.html"
|
||||
|
||||
//48AHMAcABhAG4APgA8AGIAPgBTAG8AbQBlACAAPABpAD4ASABUAE0ATAA8AC8AaQA+ACAA
|
||||
dABlAHgAdAA8AC8AYgA+ACwAIABhACAAPABhACAAaAByAGUAZgA9ACIAaAB0AHQAcAA6AC8A
|
||||
LwBlAHgAYQBtAHAAbABlAC4AbgBlAHQAIgAgAHQAYQByAGcAZQB0AD0AIgBfAGIAbABhAG4A
|
||||
awAiACAAcgBlAGwAPQAiAG4AbwBmAG8AbABsAG8AdwAiAD4AbABpAG4AawA8AC8AYQA+ACwA
|
||||
IABhAG4AJgAjADEAMwA7AA0ACgBlAG0AYgBlAGQAZABlAGQAIABpAG0AYQBnAGUAIABhAG4A
|
||||
ZAAgAGEAbgAgAGUAeAB0AGUAcgBuAGEAbAAgAGkAbQBhAGcAZQAuADwAYgByAD4AJgAjADEA
|
||||
MwA7AA0ACgA8AGkAbQBnACAAcwByAGMAPQAiAGQAYQB0AGEAOgBpAG0AYQBnAGUALwBwAG4A
|
||||
ZwA7AGIAYQBzAGUANgA0ACwAaQBWAEIATwBSAHcAMABLAEcAZwBvAEEAQQBBAEEATgBTAFUA
|
||||
aABFAFUAZwBBAEEAQQBBAG8AQQBBAEEAQQBLAEMAQQBJAEEAQQBBAEEAQwBVAEYAagBxAEEA
|
||||
QQBBAEEAQwBYAEIASQBXAFgATQBBAEEAQQBzAFQAQQBBAEEATABFAHcARQBBAG0AcAB3AFkA
|
||||
QQBBAEEAQQBGADAAbABFAFEAVgBRAFkAMAAyAE4AZwBZAEcARAA0AC8ALwA4AC8AVABoAEsA
|
||||
ZgBIAEEAUwBNAFIATgAwAEEAZgBhAHEAVgBhADYATQBRAHgASQBRAEEAQQBBAEEAQQBTAFUA
|
||||
VgBPAFIASwA1AEMAWQBJAEkAPQAiACAAdABhAHIAZwBlAHQAPQAiAF8AYgBsAGEAbgBrACIA
|
||||
PgA8AGIAcgA+ACYAIwAxADMAOwANAAoAPABpAG0AZwAgAHMAcgBjAD0AIgBoAHQAdABwADoA
|
||||
LwAvAGUAeABhAG0AcABsAGUALgBuAGUAdAAvAGkAbQBhAGcAZQAuAHAAbgBnACIAIAB0AGEA
|
||||
cgBnAGUAdAA9ACIAXwBiAGwAYQBuAGsAIgA+ADwAYgByAD4AJgAjADEAMwA7AA0ACgBOAGkA
|
||||
ZgB0AHkAIQA8AC8AcwBwAGEAbgA+AA0ACgA=
|
||||
--------------7EB9EDC10414C906CC3C2CEA--
|
||||
@@ -0,0 +1,16 @@
|
||||
<div>
|
||||
|
||||
|
||||
|
||||
|
||||
<body>
|
||||
<p>This is an <b>html</b> <i>test </i><u>message.</u></p>
|
||||
<span class="moz-smiley-s1"><span>:-)</span></span><br>
|
||||
<br>
|
||||
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAIAAAACUFjqAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAF0lEQVQY02NgYGD4//8/ThKfHASMRN0AfaqVa6MQxIQAAAAASUVORK5CYII=" alt="" height="10" width="10" target="_blank"><br>
|
||||
<pre class="moz-signature" cols="72">--
|
||||
someone
|
||||
<a class="moz-txt-link-abbreviated" href="mailto:someone@example.net" target="_blank" rel="nofollow">someone@example.net</a></pre>
|
||||
</body>
|
||||
<p>
|
||||
��</p></div>
|
||||
@@ -0,0 +1,16 @@
|
||||
<div>
|
||||
|
||||
|
||||
|
||||
|
||||
<body>
|
||||
<p>This is an <b>html</b> <i>test </i><u>message.</u></p>
|
||||
<span class="moz-smiley-s1"><span>:-)</span></span><br>
|
||||
<br>
|
||||
<img alt="" height="10" width="10"><br>
|
||||
<pre class="moz-signature" cols="72">--
|
||||
someone
|
||||
<a class="moz-txt-link-abbreviated">someone@example.net</a></pre>
|
||||
</body>
|
||||
<p>
|
||||
��</p></div>
|
||||
@@ -0,0 +1,7 @@
|
||||
<pre>This is an *html* /test /_message._
|
||||
|
||||
:-)
|
||||
|
||||
--
|
||||
Someone
|
||||
someone@example.net</pre>
|
||||
@@ -0,0 +1,51 @@
|
||||
Return-Path: <someone@example.net>
|
||||
X-Original-To: me@example.net Delivered-To: someone@example.net
|
||||
Received: from mail.example.net (localhost [127.0.0.1])
|
||||
by mail.example.net (Postfix) with ESMTP id A142E1AF12D
|
||||
for <me@example.net>; Sun, 17 Dec 2017 13:10:10 +0000 (GMT)
|
||||
Authentication-Results: mail.example.net (amavisd-new);
|
||||
dkim=pass (1024-bit key) reason="pass (just generated, assumed good)"
|
||||
header.d=example.net
|
||||
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=example.net; h=
|
||||
content-transfer-encoding:content-language:content-type
|
||||
:content-type:mime-version:user-agent:date:date:message-id
|
||||
:subject:subject:from:from:received:received; s=mail; t=
|
||||
1513516210; bh=yyFHETi2jtS7bFMEIArr53VnJMNNjltuXCFTOqs/cL0=; b=X
|
||||
nMo4pMsaH7EO9A55i0SOv5RGP4Sp4eukaXXQJgMWahcfUn4mM2+pSdc+8ZMaXX7y
|
||||
ITQdCzI0LL9WGfX7bbMA49hYtIELjQ/AochqABTUsMxnq0Sgf1FE37bA3NS1pBtp
|
||||
TBKehpGjr86y0V247Okmf2faEkO9dMvmwWz8QBI0N4=
|
||||
X-Virus-Scanned: amavisd-new at example.net
|
||||
X-Spam-Flag: NO
|
||||
X-Spam-Score: -0.999
|
||||
X-Spam-Level:
|
||||
X-Spam-Status: No, score=-0.999 tagged_above=-999 required=3.9
|
||||
tests=[ALL_TRUSTED=-1, URIBL_BLOCKED=0.001]
|
||||
autolearn=no autolearn_force=no
|
||||
Received: from mail.example.net ([127.0.0.1])
|
||||
by mail.example.net (mail.example.net [127.0.0.1]) (amavisd-new, port 10026)
|
||||
with LMTP id j0UYMNn7ZprP for <me@example.net>;
|
||||
Sun, 17 Dec 2017 13:10:10 +0000 (GMT)
|
||||
Received: from [10.0.0.1] (my-pc.example.net [10.0.0.1])
|
||||
by mail.example.net (Postfix) with ESMTPSA id E66201AF132
|
||||
for <me@example.net>; Sun, 17 Dec 2017 13:10:09 +0000 (GMT)
|
||||
To: Me <me@example.net>
|
||||
From: Someone <someone@example.net>
|
||||
X-Clacks-Overhead: GNU Terry Pratchett
|
||||
Subject: =?UTF-8?Q?T=c3=a9st_message?=
|
||||
Message-ID: <8626caea-4c05-8dc5-c687-bcfb7a329d18@example.net>
|
||||
Date: Sun, 17 Dec 2017 13:10:14 +0000
|
||||
User-Agent: Mozilla/5.0 (Windows NT 10.0; WOW64; rv:52.0) Gecko/20100101
|
||||
Thunderbird/52.5.0
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=utf-8; format=flowed
|
||||
Content-Language: en-GB
|
||||
Content-Transfer-Encoding: 7bit
|
||||
X-rAnDoM-cAsE-hEaDeR: fOoBaR
|
||||
X-UPPER-CASE-HEADER: FOOBAR
|
||||
x-lower-case-header: foobar
|
||||
|
||||
This is a plain text e-mail message.
|
||||
|
||||
--
|
||||
Someone
|
||||
someone@example.net
|
||||
@@ -0,0 +1,5 @@
|
||||
<pre>This is a plain text e-mail message.
|
||||
|
||||
--
|
||||
Someone
|
||||
someone@example.net</pre>
|
||||
@@ -0,0 +1,108 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""Tests for email_utils."""
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import os
|
||||
|
||||
from django.test import SimpleTestCase
|
||||
from django.utils.encoding import smart_bytes, smart_text
|
||||
|
||||
from ..email_utils import Email
|
||||
|
||||
SAMPLES_DIR = os.path.realpath(
|
||||
os.path.join(os.path.dirname(__file__), "sample_messages"))
|
||||
|
||||
|
||||
class EmailTestImplementation(Email):
|
||||
|
||||
def _fetch_message(self):
|
||||
message_path = os.path.join(SAMPLES_DIR, "%s-input.txt" % self.mailid)
|
||||
assert os.path.isfile(message_path), "%s does not exist." % message_path
|
||||
|
||||
with open(message_path, "rb") as fp:
|
||||
mail_text = smart_bytes(fp.read())
|
||||
|
||||
return mail_text
|
||||
|
||||
|
||||
class EmailTests(SimpleTestCase):
|
||||
"""Tests for modoboa.lib.email_utils.Email
|
||||
|
||||
When writing new sample messages use the following naming convention for
|
||||
the sample files stored in sample_messages:
|
||||
|
||||
input: {message_id}-input.txt
|
||||
output: {message_id}-output-{dformat}_{no,}links.txt
|
||||
"""
|
||||
|
||||
def _get_expected_output(self, message_id, **kwargs):
|
||||
ext = kwargs["dformat"] if "dformat" in kwargs else "plain"
|
||||
ext += "_links" if "links" in kwargs and kwargs["links"] else "_nolinks"
|
||||
message_path = os.path.join(SAMPLES_DIR,
|
||||
"%s-output-%s.txt" % (message_id, ext))
|
||||
assert os.path.isfile(message_path), "%s does not exist." % message_path
|
||||
|
||||
with open(message_path, "rb") as fp:
|
||||
# output should always be unicode (py2) or str (py3)
|
||||
mail_text = smart_text(fp.read())
|
||||
|
||||
return mail_text
|
||||
|
||||
def _test_email(self, message_id, **kwargs):
|
||||
"""Boiler plate code for testing e-mails."""
|
||||
expected_output = self._get_expected_output(message_id, **kwargs)
|
||||
output = EmailTestImplementation(message_id, **kwargs).body
|
||||
self.assertEqual(output, expected_output)
|
||||
|
||||
def test_invalid_links_value(self):
|
||||
"""modoboa-amavis set links = "0"; in python "0" == True, fixed in PR 79
|
||||
https://github.com/modoboa/modoboa-amavis/pull/79"""
|
||||
with self.assertRaises(TypeError) as cm:
|
||||
EmailTestImplementation("text_plain", links="0")
|
||||
|
||||
ex_message = cm.exception.messages
|
||||
self.assertEqual(ex_message,
|
||||
"links == \"0\" is not valid, did you mean True or "
|
||||
"False?")
|
||||
|
||||
def test_links_value_for_webmail(self):
|
||||
"""modoboa-webmail sets links = 0 or 1
|
||||
TODO: this should be fixed in modoboa-webmail to use True/False"""
|
||||
email = EmailTestImplementation("text_plain", links=0)
|
||||
self.assertFalse(email.links)
|
||||
|
||||
email = EmailTestImplementation("text_plain", links=1)
|
||||
self.assertTrue(email.links)
|
||||
|
||||
def test_headers(self):
|
||||
email = EmailTestImplementation("text_plain")
|
||||
# test header names are case insensative
|
||||
self.assertEqual(email.msg["x-upper-case-header"], "FOOBAR")
|
||||
self.assertEqual(email.msg["X-Lower-Case-Header"], "foobar")
|
||||
self.assertEqual(email.msg["x-random-case-header"], "fOoBaR")
|
||||
|
||||
expected_output = [
|
||||
{"name": "From", "value": "Someone <someone@example.net>"},
|
||||
{"name": "To", "value": "Me <me@example.net>"},
|
||||
{"name": "Cc", "value": ""},
|
||||
{"name": "Date", "value": "Sun, 17 Dec 2017 13:10:14 +0000"},
|
||||
{"name": "Subject", "value": "Tést message"},
|
||||
]
|
||||
self.assertEqual(email.headers, expected_output)
|
||||
|
||||
def test_email_text_plain(self):
|
||||
self._test_email("text_plain")
|
||||
|
||||
def test_email_multipart_as_text(self):
|
||||
"""display the text/plain part of a multipart message"""
|
||||
self._test_email("multipart")
|
||||
|
||||
def test_email_multipart_with_links(self):
|
||||
"""display the text/html part of a multipart message"""
|
||||
self._test_email("multipart", dformat="html", links=True)
|
||||
|
||||
def test_email_multipart_without_links(self):
|
||||
"""display the text/html part of a multipart message with links removed"""
|
||||
self._test_email("multipart", dformat="html", links=False)
|
||||
@@ -0,0 +1,60 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""Tests for u2u_decode."""
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from .. import u2u_decode
|
||||
|
||||
|
||||
class U2UTestCase(TestCase):
|
||||
"""Test RFC1342 decoding utilities."""
|
||||
|
||||
def test_header_decoding(self):
|
||||
"""Simple decoding."""
|
||||
samples = [
|
||||
("=?ISO-8859-15?Q?=20Profitez de tous les services en ligne sur "
|
||||
"impots.gouv.fr?=",
|
||||
"Profitez de tous les services en ligne sur impots.gouv.fr"),
|
||||
("=?ISO-8859-1?Q?Accus=E9?= de =?ISO-8859-1?Q?r=E9ception?= de "
|
||||
"votre annonce",
|
||||
"Accusé de réception de votre annonce"),
|
||||
("Sm=?ISO-8859-1?B?9g==?=rg=?ISO-8859-1?B?5Q==?=sbord",
|
||||
"Sm\xf6rg\xe5sbord"),
|
||||
# The following case currently fails because of the way we split
|
||||
# encoded words to parse them separately, which can lead to
|
||||
# unexpected unicode decode errors... I think it will work fine on
|
||||
# Python3
|
||||
# ("=?utf-8?B?VMOpbMOpcMOpYWdlIFZJTkNJIEF1dG9yb3V0ZXMgLSBFeHDD?=\n"
|
||||
# "=?utf-8?B?qWRpdGlvbiBkZSB2b3RyZSBjb21tYW5kZSBOwrAgMjAxNzEyMDcw"
|
||||
# "MDA1?=\n=?utf-8?B?MyBkdSAwNy8xMi8yMDE3IDE0OjQ5OjQx?=",
|
||||
# "")
|
||||
]
|
||||
for sample in samples:
|
||||
self.assertEqual(u2u_decode.u2u_decode(sample[0]), sample[1])
|
||||
|
||||
def test_address_header_decoding(self):
|
||||
"""Check address decoding."""
|
||||
mailsploit_sample = (
|
||||
"=?utf-8?b?cG90dXNAd2hpdGVob3VzZS5nb3Y=?==?utf-8?Q?=00?="
|
||||
"=?utf-8?b?cG90dXNAd2hpdGVob3VzZS5nb3Y=?=@mailsploit.com")
|
||||
expected_result = (
|
||||
"=?utf-8?b?cG90dXNAd2hpdGVob3VzZS5nb3Y=?==?utf-8?Q??="
|
||||
"=?utf-8?b?cG90dXNAd2hpdGVob3VzZS5nb3Y=?=@mailsploit.com")
|
||||
self.assertEqual(
|
||||
u2u_decode.decode_address(mailsploit_sample),
|
||||
("", expected_result)
|
||||
)
|
||||
mailsploit_sample = (
|
||||
'"=?utf-8?b?cG90dXNAd2hpdGVob3VzZS5nb3Y=?==?utf-8?Q?=0A=00?="\n'
|
||||
'<=?utf-8?b?cG90dXNAd2hpdGVob3VzZS5nb3Y=?==?utf-8?Q?=0A=00?='
|
||||
'@mailsploit.com>')
|
||||
expected_result = (
|
||||
'potus@whitehouse.gov',
|
||||
'=?utf-8?b?cG90dXNAd2hpdGVob3VzZS5nb3Y=?==?utf-8?Q??='
|
||||
'@mailsploit.com')
|
||||
self.assertEqual(
|
||||
u2u_decode.decode_address(mailsploit_sample),
|
||||
expected_result)
|
||||
@@ -27,3 +27,4 @@ cryptography
|
||||
pytz
|
||||
requests
|
||||
rfc6266
|
||||
lxml
|
||||
|
||||
Reference in New Issue
Block a user