changeset 5493:725266c03eab

updated mailgw to no longer use mimetools based on jerrykan's patch
author Christof Meerwald <cmeerw@cmeerw.org>
date Sun, 12 Aug 2018 16:15:10 +0100
parents 6b0c542642be
children b7fa56ced601
files roundup/cgi/client.py roundup/mailer.py roundup/mailgw.py roundup/roundupdb.py test/db_test_base.py test/test_mailgw.py test/test_mailgw_roundupmessage.py test/test_multipart.py
diffstat 8 files changed, 740 insertions(+), 400 deletions(-) [+]
line wrap: on
line diff
--- a/roundup/cgi/client.py	Sun Aug 12 16:05:42 2018 +0100
+++ b/roundup/cgi/client.py	Sun Aug 12 16:15:10 2018 +0100
@@ -32,7 +32,7 @@
     FormError, NotFound, NotModified, Redirect, SendFile, SendStaticFile,
     DetectorError, SeriousError)
 from roundup.cgi.form_parser import FormParser
-from roundup.mailer import Mailer, MessageSendError, encode_quopri
+from roundup.mailer import Mailer, MessageSendError
 from roundup.cgi import accept_language
 from roundup import xmlrpc
 
@@ -1538,12 +1538,11 @@
         to = [self.mailer.config.ADMIN_EMAIL]
         message = MIMEMultipart('alternative')
         self.mailer.set_message_attributes(message, to, subject)
-        part = MIMEBase('text', 'html')
-        part.set_charset('utf-8')
-        part.set_payload(html)
-        encode_quopri(part)
+        part = self.mail.get_text_message('utf-8', 'html')
+        part.set_payload(html, part.get_charset())
         message.attach(part)
-        part = MIMEText(txt)
+        part = self.mail.get_text_message()
+        part.set_payload(text, part.get_charset())
         message.attach(part)
         self.mailer.smtp_send(to, message.as_string())
 
--- a/roundup/mailer.py	Sun Aug 12 16:05:42 2018 +0100
+++ b/roundup/mailer.py	Sun Aug 12 16:15:10 2018 +0100
@@ -7,12 +7,15 @@
 from roundup import __version__
 from roundup.date import get_timezone, Date
 
+from email import charset
 from email.utils import formatdate, formataddr, specialsre, escapesre
+from email.charset import Charset
 from email.message import Message
 from email.header import Header
 from email.mime.base import MIMEBase
 from email.mime.text import MIMEText
 from email.mime.multipart import MIMEMultipart
+from email.mime.nonmultipart import MIMENonMultipart
 
 from roundup.anypy import email_
 from roundup.anypy.strings import b2s, s2b, s2u
@@ -26,13 +29,6 @@
 class MessageSendError(RuntimeError):
     pass
 
-def encode_quopri(msg):
-    orig = s2b(msg.get_payload())
-    encdata = quopri.encodestring(orig)
-    msg.set_payload(b2s(encdata))
-    del msg['Content-Transfer-Encoding']
-    msg['Content-Transfer-Encoding'] = 'quoted-printable'
-
 def nice_sender_header(name, address, charset):
     # construct an address header so it's as human-readable as possible
     # even in the presence of a non-ASCII name part
@@ -115,16 +111,23 @@
         # finally, an aid to debugging problems
         message['X-Roundup-Version'] = __version__
 
+    def get_text_message(self, _charset='utf-8', _subtype='plain'):
+        message = MIMENonMultipart('text', _subtype)
+        cs = Charset(_charset)
+        if cs.body_encoding == charset.BASE64:
+            cs.body_encoding = charset.QP
+        message.set_charset(cs)
+        del message['Content-Transfer-Encoding']
+        return message
+
     def get_standard_message(self, multipart=False):
         '''Form a standard email message from Roundup.
         Returns a Message object.
         '''
-        charset = getattr(self.config, 'EMAIL_CHARSET', 'utf-8')
         if multipart:
             message = MIMEMultipart()
         else:
-            message = MIMEText("")
-            message.set_charset(charset)
+            message = self.get_text_message(getattr(self.config, 'EMAIL_CHARSET', 'utf-8'))
 
         return message
 
@@ -132,7 +135,7 @@
         """Send a standard message.
 
         Arguments:
-        - to: a list of addresses usable by rfc822.parseaddr().
+        - to: a list of addresses usable by email.utils.parseaddr().
         - subject: the subject as a string.
         - content: the body of the message as a string.
         - author: the sender as a (name, address) tuple
@@ -141,8 +144,7 @@
         """
         message = self.get_standard_message()
         self.set_message_attributes(message, to, subject, author)
-        message.set_payload(content)
-        encode_quopri(message)
+        message.set_payload(s2u(content))
         self.smtp_send(to, message.as_string())
 
     def bounce_message(self, bounced_message, to, error,
@@ -150,8 +152,8 @@
         """Bounce a message, attaching the failed submission.
 
         Arguments:
-        - bounced_message: an RFC822 Message object.
-        - to: a list of addresses usable by rfc822.parseaddr(). Might be
+        - bounced_message: an mailgw.RoundupMessage object.
+        - to: a list of addresses usable by email.utils.parseaddr(). Might be
           extended or overridden according to the config
           ERROR_MESSAGES_TO setting.
         - error: the reason of failure as a string.
@@ -185,18 +187,7 @@
         message.attach(part)
 
         # attach the original message to the returned message
-        body = []
-        for header in bounced_message.headers:
-            body.append(header)
-        try:
-            bounced_message.rewindbody()
-        except IOError as errmessage:
-            body.append("*** couldn't include message body: %s ***" %
-                errmessage)
-        else:
-            body.append('\n')
-            body.append(bounced_message.fp.read())
-        part = MIMEText(''.join(body))
+        part = MIMEText(bounced_message.flatten())
         message.attach(part)
 
         self.logger.debug("bounce_message: to=%s, crypt_to=%s", to, crypt_to)
--- a/roundup/mailgw.py	Sun Aug 12 16:05:42 2018 +0100
+++ b/roundup/mailgw.py	Sun Aug 12 16:15:10 2018 +0100
@@ -27,7 +27,7 @@
    and given "file" class nodes that are linked to the "msg" node.
  . In a multipart/alternative message or part, we look for a text/plain
    subpart and ignore the other parts.
- . A message/rfc822 is treated similar tomultipart/mixed (except for
+ . A message/rfc822 is treated similar to multipart/mixed (except for
    special handling of the first text part) if unpack_rfc822 is set in
    the mailgw config section.
 
@@ -95,11 +95,13 @@
 from __future__ import print_function
 __docformat__ = 'restructuredtext'
 
-import base64, re, os, smtplib, socket, binascii, quopri
+import base64, re, os, smtplib, socket, binascii
 import time, sys, logging
 import codecs
 import traceback
+import email
 import email.utils
+from email.generator import Generator
 
 from .anypy.email_ import decode_header
 from roundup.anypy.my_input import my_input
@@ -108,7 +110,7 @@
 from roundup.mailer import Mailer, MessageSendError
 from roundup.i18n import _
 from roundup.hyperdb import iter_roles
-from roundup.anypy.strings import b2s, StringIO
+from roundup.anypy.strings import StringIO, b2s, u2s
 import roundup.anypy.random_ as random_
 
 try:
@@ -116,13 +118,6 @@
 except ImportError:
     pyme = None
 
-try:
-    import mimetools
-except ImportError:
-    class mimetools:
-        class Message:
-            pass
-
 SENDMAILDEBUG = os.environ.get('SENDMAILDEBUG', '')
 
 class MailGWError(ValueError):
@@ -143,11 +138,11 @@
     """ A general class of message that we should ignore. """
     pass
 class IgnoreBulk(IgnoreMessage):
-        """ This is email from a mailing list or from a vacation program. """
-        pass
+    """ This is email from a mailing list or from a vacation program. """
+    pass
 class IgnoreLoop(IgnoreMessage):
-        """ We've seen this message before... """
-        pass
+    """ We've seen this message before... """
+    pass
 
 def initialiseSecurity(security):
     ''' Create some Permissions and Roles on the security object
@@ -159,26 +154,6 @@
         description="User may use the email interface")
     security.addPermissionToRole('Admin', p)
 
-def getparam(str, param):
-    ''' From the rfc822 "header" string, extract "param" if it appears.
-    '''
-    if ';' not in str:
-        return None
-    str = str[str.index(';'):]
-    while str[:1] == ';':
-        str = str[1:]
-        if ';' in str:
-            # XXX Should parse quotes!
-            end = str.index(';')
-        else:
-            end = len(str)
-        f = str[:end]
-        if '=' in f:
-            i = f.index('=')
-            if f[:i].strip().lower() == param:
-                return email.utils.unquote(f[i+1:].strip())
-    return None
-
 def gpgh_key_getall(key, attr):
     ''' return list of given attribute for all uids in
         a key
@@ -222,57 +197,9 @@
     elif not may_be_unsigned:
         raise MailUsageError(_("Unsigned Message"))
 
-class Message(mimetools.Message):
-    ''' subclass mimetools.Message so we can retrieve the parts of the
-        message...
-    '''
-    def getpart(self):
-        ''' Get a single part of a multipart message and return it as a new
-            Message instance.
-        '''
-        boundary = self.getparam('boundary')
-        mid, end = '--'+boundary, '--'+boundary+'--'
-        s = StringIO()
-        while 1:
-            line = self.fp.readline()
-            if not line:
-                break
-            if line.strip() in (mid, end):
-                # according to rfc 1431 the preceding line ending is part of
-                # the boundary so we need to strip that
-                length = s.tell()
-                s.seek(-2, 1)
-                lineending = s.read(2)
-                if lineending == '\r\n':
-                    s.truncate(length - 2)
-                elif lineending[1] in ('\r', '\n'):
-                    s.truncate(length - 1)
-                else:
-                    raise ValueError('Unknown line ending in message.')
-                break
-            s.write(line)
-        if not s.getvalue().strip():
-            return None
-        s.seek(0)
-        return Message(s)
-
-    def getparts(self):
-        """Get all parts of this multipart message."""
-        # skip over the intro to the first boundary
-        self.fp.seek(0)
-        self.getpart()
-
-        # accumulate the other parts
+class RoundupMessage(email.message.Message):
+    def _decode_header(self, hdr):
         parts = []
-        while 1:
-            part = self.getpart()
-            if part is None:
-                break
-            parts.append(part)
-        return parts
-
-    def _decode_header_to_utf8(self, hdr):
-        l = []
         for part, encoding in decode_header(hdr):
             if encoding:
                 part = part.decode(encoding)
@@ -281,86 +208,41 @@
             # or vice-versa we must preserve a space. Multiple adjacent
             # non-encoded parts should not occur. This is now
             # implemented in our patched decode_header method in anypy
-            l.append(part)
-        return ''.join([s.encode('utf-8') for s in l])
+            parts.append(part)
+
+        return ''.join([u2s(p) for p in parts])
 
-    def getheader(self, name, default=None):
-        hdr = mimetools.Message.getheader(self, name, default)
-        # TODO are there any other False values possible?
-        # TODO if not hdr: return hdr
-        if hdr is None:
-            return None
-        if not hdr:
-            return ''
-        if hdr:
-            hdr = hdr.replace('\n','') # Inserted by rfc822.readheaders
-        return self._decode_header_to_utf8(hdr)
+    def flatten(self):
+        fp = StringIO()
+        generator = Generator(fp, mangle_from_=False)
+        generator.flatten(self)
+        return fp.getvalue()
 
-    def getaddrlist(self, name):
-        # overload to decode the name part of the address
-        l = []
-        for (name, addr) in mimetools.Message.getaddrlist(self, name):
-            name = self._decode_header_to_utf8(name)
-            l.append((name, addr))
-        return l
+    def get_header(self, header, default=None):
+        value = self.get(header, default)
+
+        if value:
+            return self._decode_header(value.replace('\n', ''))
+
+        return value
 
-    def getname(self):
-        """Find an appropriate name for this message."""
-        name = None
-        if self.gettype() == 'message/rfc822':
-            # handle message/rfc822 specially - the name should be
-            # the subject of the actual e-mail embedded here
-            # we add a '.eml' extension like other email software does it
-            self.fp.seek(0)
-            s = StringIO(self.getbody())
-            name = Message(s).getheader('subject')
-            if name:
-                name = name + '.eml'
-        if not name:
-            # try name on Content-Type
-            name = self.getparam('name')
-            if not name:
-                disp = self.getheader('content-disposition', None)
-                if disp:
-                    name = getparam(disp, 'filename')
+    def get_address_list(self, header):
+        addresses = []
 
-        if name:
-            return name.strip()
+        for name, addr in email.utils.getaddresses(self.get_all(header, [])):
+            addresses.append((self._decode_header(name), addr))
 
-    def getbody(self):
+        return addresses
+
+    def get_body(self):
         """Get the decoded message body."""
-        self.rewindbody()
-        encoding = self.getencoding()
-        data = None
-        if encoding == 'base64':
-            # BUG: is base64 really used for text encoding or
-            # are we inserting zip files here.
-            data = binascii.a2b_base64(self.fp.read())
-        elif encoding == 'quoted-printable':
-            # the quopri module wants to work with files
-            decoded = StringIO()
-            quopri.decode(self.fp, decoded)
-            data = decoded.getvalue()
-        elif encoding == 'uuencoded':
-            data = binascii.a2b_uu(self.fp.read())
-        else:
-            # take it as text
-            data = self.fp.read()
+        content = self.get_payload(decode=True)
 
-        # Encode message to unicode
-        charset = self.getparam("charset")
-        if charset:
-            charset = charset.lower().replace("windows-", 'cp')
-            # Do conversion only if charset specified - handle
-            # badly-specified charsets
-            edata = codecs.decode(data, charset, 'replace').encode('utf-8')
-            # Convert from dos eol to unix
-            edata = edata.replace('\r\n', '\n')
-        else:
-            # Leave message content as is
-            edata = data
+        if content is not None:
+            charset = self.get_content_charset()
+            content = u2s(content.decode(charset or 'iso8859-1', 'replace'))
 
-        return edata
+        return content
 
     # General multipart handling:
     #   Take the first text/plain part, anything else is considered an
@@ -392,32 +274,38 @@
     #   For web forms only.
     # message/rfc822:
     #   Only if configured in [mailgw] unpack_rfc822
-
     def extract_content(self, parent_type=None, ignore_alternatives=False,
-            unpack_rfc822=False, html2text=None):
-        """Extract the body and the attachments recursively.
+                        unpack_rfc822=False, html2text=None):
+        """
+        Extract the body and the attachments recursively.
 
-           If the content is hidden inside a multipart/alternative part,
-           we use the *last* text/plain part of the *first*
-           multipart/alternative in the whole message.
+        If the content is hidden inside a multipart/alternative part, we use
+        the *last* text/plain part of the *first* multipart/alternative in
+        the whole message.
+
+        If ignore_alteratives is True then only the alternative parts in the
+        same multipart/alternative part as where the content is found are
+        ignored.
         """
-        content_type = self.gettype()
+        content_type = self.get_content_type()
         content = None
         attachments = []
         html_part = False
 
         if content_type == 'text/plain':
-            content = self.getbody()
+            content = self.get_body()
         elif content_type == 'text/html' and html2text:
             # if user allows html conversion run this.
-            content = html2text(self.getbody())
+            content = html2text(self.get_body())
             attachments.append(self.as_attachment())
             html_part = True
-        elif content_type[:10] == 'multipart/':
+        elif content_type == 'message/rfc822' and not unpack_rfc822:
+            attachments.append(self.as_attachment())
+        elif self.is_multipart():
             content_found = False
             ig = ignore_alternatives
             html_part_found = False
-            for part in self.getparts():
+            for part in self.get_payload():
                 new_content, new_attach, html_part = part.extract_content(
                      content_type, not content and ig, unpack_rfc822,
                     html2text)
@@ -457,67 +345,78 @@
             if ig and content_type == 'multipart/alternative' and content:
                 attachments = []
             html_part = False
-        elif unpack_rfc822 and content_type == 'message/rfc822':
-            s = StringIO(self.getbody())
-            m = Message(s)
-            ig = ignore_alternatives and not content
-            new_content, attachments, html_part = m.extract_content(m.gettype(), ig,
-                    unpack_rfc822, html2text)
-            attachments.insert(0, m.text_as_attachment())
         elif (parent_type == 'multipart/signed' and
-              content_type == 'application/pgp-signature'):
-            # ignore it so it won't be saved as an attachment
+                content_type == 'application/pgp-signature'):
+            # Don't save signatures for signed messages as attachments
             pass
         else:
             attachments.append(self.as_attachment())
+
         return content, attachments, html_part
 
     def text_as_attachment(self):
         """Return first text/plain part as Message"""
-        if not self.gettype().startswith ('multipart/'):
+        if not self.is_multipart():
             return self.as_attachment()
-        for part in self.getparts():
-            content_type = part.gettype()
-            if content_type == 'text/plain':
-                return part.as_attachment()
-            elif content_type.startswith ('multipart/'):
+        for part in self.get_payload():
+            if part.is_multipart():
                 p = part.text_as_attachment()
                 if p:
                     return p
+            elif part.get_content_type() == 'text/plain':
+                return part.as_attachment()
         return None
 
     def as_attachment(self):
         """Return this message as an attachment."""
-        return (self.getname(), self.gettype(), self.getbody())
+        filename = self.get_filename()
+        content_type = self.get_content_type()
+        content = self.get_body()
+
+        if content is None and self.get_content_type() == 'message/rfc822':
+            # handle message/rfc822 specially - the name should be
+            # the subject of the actual e-mail embedded here
+            # we add a '.eml' extension like other email software does it
+            subject = self.get_payload(0).get('subject')
+            if subject:
+                filename = '{0}.eml'.format(subject)
+
+            content = self.get_payload(0).flatten()
+
+        return (filename, content_type, content)
 
     def pgp_signed(self):
-        ''' RFC 3156 requires OpenPGP MIME mail to have the protocol parameter
-        '''
-        return self.gettype() == 'multipart/signed' \
-            and self.typeheader.find('protocol="application/pgp-signature"') != -1
+        """
+        RFC 3156 requires OpenPGP MIME mail to have the protocol parameter
+        """
+        return (self.get_content_type() == 'multipart/signed' and
+                self.get_param('protocol') == 'application/pgp-signature')
 
     def pgp_encrypted(self):
-        ''' RFC 3156 requires OpenPGP MIME mail to have the protocol parameter
-        '''
-        return self.gettype() == 'multipart/encrypted' \
-            and self.typeheader.find('protocol="application/pgp-encrypted"') != -1
+        """
+        RFC 3156 requires OpenPGP MIME mail to have the protocol parameter
+        """
+        return (self.get_content_type() == 'multipart/encrypted' and
+                self.get_param('protocol') == 'application/pgp-encrypted')
 
     def decrypt(self, author, may_be_unsigned=False):
-        ''' decrypt an OpenPGP MIME message
-            This message must be signed as well as encrypted using the
-            "combined" method if incoming signatures are configured.
-            The decrypted contents are returned as a new message.
         '''
-        (hdr, msg) = self.getparts()
+        Decrypt an OpenPGP MIME message
+
+        This message must be signed as well as encrypted using the "combined"
+        method if incoming signatures are configured.  The decrypted contents
+        are returned as a new message.
+        '''
+        (hdr, msg) = self.get_payload()
         # According to the RFC 3156 encrypted mail must have exactly two parts.
         # The first part contains the control information. Let's verify that
         # the message meets the RFC before we try to decrypt it.
-        if hdr.getbody().strip() != 'Version: 1' \
-           or hdr.gettype() != 'application/pgp-encrypted':
+        if (hdr.get_payload().strip() != 'Version: 1' or
+                hdr.get_content_type() != 'application/pgp-encrypted'):
             raise MailUsageError(_("Unknown multipart/encrypted version."))
 
         context = pyme.core.Context()
-        ciphertext = pyme.core.Data(msg.getbody())
+        ciphertext = pyme.core.Data(msg.get_payload())
         plaintext = pyme.core.Data()
 
         result = context.op_decrypt_verify(ciphertext, plaintext)
@@ -530,43 +429,37 @@
         # was signed by someone we trust
         result = context.op_verify_result()
         check_pgp_sigs(result.signatures, context, author,
-            may_be_unsigned = may_be_unsigned)
+                       may_be_unsigned=may_be_unsigned)
 
-        plaintext.seek(0,0)
+        plaintext.seek(0, 0)
         # pyme.core.Data implements a seek method with a different signature
         # than roundup can handle. So we'll put the data in a container that
         # the Message class can work with.
-        c = StringIO()
-        c.write(plaintext.read())
-        c.seek(0)
-        return Message(c)
+        return email.message_from_string(plaintext.read(), RoundupMessage)
 
     def verify_signature(self, author):
-        ''' verify the signature of an OpenPGP MIME message
-            This only handles detached signatures. Old style
-            PGP mail (i.e. '-----BEGIN PGP SIGNED MESSAGE----')
-            is archaic and not supported :)
-        '''
+        """
+        Verify the signature of an OpenPGP MIME message
+
+        This only handles detached signatures. Old style PGP mail (i.e.
+        '-----BEGIN PGP SIGNED MESSAGE----') is archaic and not supported :)
+        """
         # we don't check the micalg parameter...gpgme seems to
         # figure things out on its own
-        (msg, sig) = self.getparts()
+        (msg, sig) = self.get_payload()
 
-        if sig.gettype() != 'application/pgp-signature':
+        if sig.get_content_type() != 'application/pgp-signature':
             raise MailUsageError(_("No PGP signature found in message."))
 
-        # msg.getbody() is skipping over some headers that are
-        # required to be present for verification to succeed so
-        # we'll do this by hand
-        msg.fp.seek(0)
         # according to rfc 3156 the data "MUST first be converted
         # to its content-type specific canonical form. For
         # text/plain this means conversion to an appropriate
         # character set and conversion of line endings to the
         # canonical <CR><LF> sequence."
         # TODO: what about character set conversion?
-        canonical_msg = re.sub('(?<!\r)\n', '\r\n', msg.fp.read())
+        canonical_msg = re.sub('(?<!\r)\n', '\r\n', msg.flatten())
         msg_data = pyme.core.Data(canonical_msg)
-        sig_data = pyme.core.Data(sig.getbody())
+        sig_data = pyme.core.Data(sig.get_payload())
 
         context = pyme.core.Context()
         context.op_verify(sig_data, msg_data, None)
@@ -582,16 +475,16 @@
         self.config = mailgw.instance.config
         self.db = mailgw.db
         self.message = message
-        self.subject = message.getheader('subject', '')
+        self.subject = message.get_header('subject', '')
         self.has_prefix = False
         self.matches = dict.fromkeys(['refwd', 'quote', 'classname',
                                  'nodeid', 'title', 'args', 'argswhole'])
         self.keep_real_from = self.config['EMAIL_KEEP_REAL_FROM']
         if self.keep_real_from:
-            self.from_list = message.getaddrlist('from')
+            self.from_list = message.get_address_list('from')
         else:
-            self.from_list = message.getaddrlist('resent-from') \
-                          or message.getaddrlist('from')
+            self.from_list = (message.get_address_list('resent-from') or
+                              message.get_address_list('from'))
         self.pfxmode = self.config['MAILGW_SUBJECT_PREFIX_PARSING']
         self.sfxmode = self.config['MAILGW_SUBJECT_SUFFIX_PARSING']
         # these are filled in by subsequent parsing steps
@@ -612,9 +505,9 @@
             detect loops and
             Precedence: Bulk, or Microsoft Outlook autoreplies
         '''
-        if self.message.getheader('x-roundup-loop', ''):
+        if self.message.get_header('x-roundup-loop', ''):
             raise IgnoreLoop
-        if (self.message.getheader('precedence', '') == 'bulk'
+        if (self.message.get_header('precedence', '') == 'bulk'
                 or self.subject.lower().find("autoreply") > 0):
             raise IgnoreBulk
 
@@ -812,7 +705,7 @@
             nodeid = self.matches['nodeid']
 
         # try in-reply-to to match the message if there's no nodeid
-        inreplyto = self.message.getheader('in-reply-to') or ''
+        inreplyto = self.message.get_header('in-reply-to') or ''
         if nodeid is None and inreplyto:
             l = self.db.getclass('msg').stringFind(messageid=inreplyto)
             if l:
@@ -958,8 +851,8 @@
         # now update the recipients list
         recipients = []
         tracker_email = self.config['TRACKER_EMAIL'].lower()
-        msg_to = self.message.getaddrlist('to')
-        msg_cc = self.message.getaddrlist('cc')
+        msg_to = self.message.get_address_list('to')
+        msg_cc = self.message.get_address_list('cc')
         for recipient in msg_to + msg_cc:
             r = recipient[1].strip().lower()
             if r == tracker_email or not r:
@@ -1110,7 +1003,6 @@
             ignore_alternatives=ig,
             unpack_rfc822=self.config.MAILGW_UNPACK_RFC822,
             html2text=html2text )
-        
 
     def create_files(self):
         ''' Create a file for each attachment in the message
@@ -1119,7 +1011,7 @@
             return
         files = []
         file_props = self.mailgw.get_class_arguments('file')
-        
+
         if self.attachments:
             for (name, mime_type, data) in self.attachments:
                 if not self.db.security.hasPermission('Create', self.author,
@@ -1160,8 +1052,8 @@
         self.msg_props.update (msg_props)
         
         # Get the message ids
-        inreplyto = self.message.getheader('in-reply-to') or ''
-        messageid = self.message.getheader('message-id')
+        inreplyto = self.message.get_header('in-reply-to') or ''
+        messageid = self.message.get_header('message-id')
         # generate a messageid if there isn't one
         if not messageid:
             messageid = "<%s.%s.%s%s@%s>"%(time.time(),
@@ -1247,7 +1139,7 @@
 #            issue_re = config['MAILGW_ISSUE_ADDRESS_RE']
 #            if issue_re:
 #                for header in ['to', 'cc', 'bcc']:
-#                    addresses = message.getheader(header, '')
+#                    addresses = message.get_header(header, '')
 #                if addresses:
 #                  # FIXME, this only finds the first match in the addresses.
 #                    issue = re.search(issue_re, addresses, 'i')
@@ -1348,33 +1240,29 @@
         """ Read a series of messages from the specified unix mailbox file and
             pass each to the mail handler.
         """
-        # open the spool file and lock it
-        import fcntl
-        # FCNTL is deprecated in py2.3 and fcntl takes over all the symbols
-        if hasattr(fcntl, 'LOCK_EX'):
-            FCNTL = fcntl
-        else:
-            import FCNTL
-        f = open(filename, 'r+')
-        fcntl.flock(f.fileno(), FCNTL.LOCK_EX)
+        import mailbox
 
-        # handle and clear the mailbox
+        class mboxRoundupMessage(mailbox.mboxMessage, RoundupMessage):
+            pass
+
         try:
-            from mailbox import UnixMailbox
-            mailbox = UnixMailbox(f, factory=Message)
-            # grab one message
-            message = next(mailbox)
-            while message:
-                # handle this message
-                self.handle_Message(message)
-                message = next(mailbox)
-            # nuke the file contents
-            os.ftruncate(f.fileno(), 0)
-        except:
-            import traceback
+            mbox = mailbox.mbox(filename, factory=mboxRoundupMessage,
+                                create=False)
+            mbox.lock()
+        except (mailbox.NoSuchMailboxError, mailbox.ExternalClashError) as e:
+            if isinstance(e, mailbox.ExternalClashError):
+                mbox.close()
             traceback.print_exc()
             return 1
-        fcntl.flock(f.fileno(), FCNTL.LOCK_UN)
+
+        try:
+            for key in mbox.keys():
+                self.handle_Message(mbox.get(key))
+                mbox.remove(key)
+        finally:
+            mbox.unlock()
+            mbox.close()
+
         return 0
 
     def do_imap(self, server, user='', password='', mailbox='', ssl=0,
@@ -1503,9 +1391,8 @@
             #   [ array of message lines ],
             #   number of octets ]
             lines = server.retr(i)[1]
-            s = StringIO('\n'.join(lines))
-            s.seek(0)
-            self.handle_Message(Message(s))
+            self.handle_Message(
+                email.message_from_string('\n'.join(lines), RoundupMessage))
             # delete the message
             server.dele(i)
 
@@ -1516,7 +1403,7 @@
     def main(self, fp):
         ''' fp - the file from which to read the Message.
         '''
-        return self.handle_Message(Message(fp))
+        return self.handle_Message(email.message_from_file(fp, RoundupMessage))
 
     def handle_Message(self, message):
         """Handle an RFC822 Message
@@ -1532,20 +1419,20 @@
 
         self.parsed_message = None
         crypt = False
-        sendto = message.getaddrlist('resent-from')
+        sendto = message.get_address_list('resent-from')
         if not sendto or self.instance.config['EMAIL_KEEP_REAL_FROM']:
-            sendto = message.getaddrlist('from')
+            sendto = message.get_address_list('from')
         if not sendto:
             # very bad-looking message - we don't even know who sent it
             msg = ['Badly formed message from mail gateway. Headers:']
-            msg.extend(message.headers)
+            msg.extend([': '.join(args) for args in message.items()])
             msg = '\n'.join(map(str, msg))
             self.logger.error(msg)
             return
 
         msg = 'Handling message'
-        if message.getheader('message-id'):
-            msg += ' (Message-id=%r)'%message.getheader('message-id')
+        if message.get_header('message-id'):
+            msg += ' (Message-id=%r)'%message.get_header('message-id')
         self.logger.info(msg)
 
         # try normal message-handling
@@ -1595,14 +1482,14 @@
             # do not take any action
             # this exception is thrown when email should be ignored
             msg = 'IgnoreMessage raised'
-            if message.getheader('message-id'):
-                msg += ' (Message-id=%r)'%message.getheader('message-id')
+            if message.get_header('message-id'):
+                msg += ' (Message-id=%r)'%message.get_header('message-id')
             self.logger.info(msg)
             return
         except:
             msg = 'Exception handling message'
-            if message.getheader('message-id'):
-                msg += ' (Message-id=%r)'%message.getheader('message-id')
+            if message.get_header('message-id'):
+                msg += ' (Message-id=%r)'%message.get_header('message-id')
             self.logger.exception(msg)
 
             # bounce the message back to the sender with the error message
--- a/roundup/roundupdb.py	Sun Aug 12 16:05:42 2018 +0100
+++ b/roundup/roundupdb.py	Sun Aug 12 16:15:10 2018 +0100
@@ -36,8 +36,7 @@
 from roundup.i18n import _
 from roundup.hyperdb import iter_roles
 
-from roundup.mailer import Mailer, MessageSendError, encode_quopri, \
-    nice_sender_header
+from roundup.mailer import Mailer, MessageSendError, nice_sender_header
 
 from roundup.anypy.strings import b2s, s2u
 import roundup.anypy.random_ as random_
@@ -498,8 +497,6 @@
 
         # construct the content and convert to unicode object
         body = s2u('\n'.join(m))
-        if type(body) != type(''):
-            body = body.encode(charset)
 
         # make sure the To line is always the same (for testing mostly)
         sendto.sort()
@@ -610,28 +607,31 @@
             # attach files
             if message_files:
                 # first up the text as a part
-                part = MIMEText(body)
-                part.set_charset(charset)
-                encode_quopri(part)
+                part = mailer.get_standard_message()
+                part.set_payload(body, part.get_charset())
                 message.attach(part)
 
                 for fileid in message_files:
                     name = files.get(fileid, 'name')
-                    mime_type = files.get(fileid, 'type')
-                    content = files.get(fileid, 'content')
+                    mime_type = (files.get(fileid, 'type') or
+                                 mimetypes.guess_type(name)[0] or
+                                 'application/octet-stream')
                     if mime_type == 'text/plain':
+                        content = files.get(fileid, 'content')
+                        part = MIMEText('')
+                        del part['Content-Transfer-Encoding']
                         try:
-                            content.decode('ascii')
+                            enc = content.encode('ascii')
+                            part = mailer.get_text_message('us-ascii')
+                            part.set_payload(enc)
                         except UnicodeError:
                             # the content cannot be 7bit-encoded.
                             # use quoted printable
                             # XXX stuffed if we know the charset though :(
-                            part = MIMEText(content)
-                            encode_quopri(part)
-                        else:
-                            part = MIMEText(content)
-                            part['Content-Transfer-Encoding'] = '7bit'
+                            part = mailer.get_text_message('utf-8')
+                            part.set_payload(content, part.get_charset())
                     elif mime_type == 'message/rfc822':
+                        content = files.get(fileid, 'content')
                         main, sub = mime_type.split('/')
                         p = FeedParser()
                         p.feed(content)
@@ -639,11 +639,7 @@
                         part.set_payload([p.close()])
                     else:
                         # some other type, so encode it
-                        if not mime_type:
-                            # this should have been done when the file was saved
-                            mime_type = mimetypes.guess_type(name)[0]
-                        if mime_type is None:
-                            mime_type = 'application/octet-stream'
+                        content = files.get(fileid, 'binary_content')
                         main, sub = mime_type.split('/')
                         part = MIMEBase(main, sub)
                         part.set_payload(content)
@@ -653,8 +649,7 @@
                     message.attach(part)
 
             else:
-                message.set_payload(body)
-                encode_quopri(message)
+                message.set_payload(body, message.get_charset())
 
             if crypt:
                 send_msg = self.encrypt_to (message, sendto)
--- a/test/db_test_base.py	Sun Aug 12 16:05:42 2018 +0100
+++ b/test/db_test_base.py	Sun Aug 12 16:15:10 2018 +0100
@@ -2579,8 +2579,8 @@
             res["mail_to"], res["mail_msg"] = to, msg
         backup, Mailer.smtp_send = Mailer.smtp_send, dummy_snd
         try :
-            f1 = db.file.create(name="test1.txt", content="x" * 20)
-            f2 = db.file.create(name="test2.txt", content="y" * 5000)
+            f1 = db.file.create(name="test1.txt", content="x" * 20, type="application/octet-stream")
+            f2 = db.file.create(name="test2.txt", content="y" * 5000, type="application/octet-stream")
             m  = db.msg.create(content="one two", author="admin",
                 files = [f1, f2])
             i  = db.issue.create(title='spam', files = [f1, f2],
@@ -2601,6 +2601,37 @@
             roundupdb._ = old_translate_
             Mailer.smtp_send = backup
 
+    def testNosyMailTextAndBinary(self) :
+        """Creates one issue with two attachments, one as text and one as binary.
+        """
+        old_translate_ = roundupdb._
+        roundupdb._ = i18n.get_translation(language='C').gettext
+        db = self.db
+        res = dict(mail_to = None, mail_msg = None)
+        def dummy_snd(s, to, msg, res=res) :
+            res["mail_to"], res["mail_msg"] = to, msg
+        backup, Mailer.smtp_send = Mailer.smtp_send, dummy_snd
+        try :
+            f1 = db.file.create(name="test1.txt", content="Hello world", type="text/plain")
+            f2 = db.file.create(name="test2.bin", content=b"\x01\x02\x03\xfe\xff", type="application/octet-stream")
+            m  = db.msg.create(content="one two", author="admin",
+                files = [f1, f2])
+            i  = db.issue.create(title='spam', files = [f1, f2],
+                messages = [m], nosy = [db.user.lookup("fred")])
+
+            db.issue.nosymessage(i, m, {})
+            mail_msg = str(res["mail_msg"])
+            self.assertEqual(res["mail_to"], ["fred@example.com"])
+            self.assert_("From: admin" in mail_msg)
+            self.assert_("Subject: [issue1] spam" in mail_msg)
+            self.assert_("New submission from admin" in mail_msg)
+            self.assert_("one two" in mail_msg)
+            self.assert_("Hello world" in mail_msg)
+            self.assert_(b2s(base64.encodestring(b"\x01\x02\x03\xfe\xff")).rstrip() in mail_msg)
+        finally :
+            roundupdb._ = old_translate_
+            Mailer.smtp_send = backup
+
     @pytest.mark.skipif(gpgmelib.pyme is None, reason='Skipping PGPNosy test')
     def testPGPNosyMail(self) :
         """Creates one issue with two attachments, one smaller and one larger
@@ -2623,8 +2654,8 @@
         try :
             john = db.user.create(username="john", roles='User,pgp',
                 address='john@test.test', realname='John Doe')
-            f1 = db.file.create(name="test1.txt", content="x" * 20)
-            f2 = db.file.create(name="test2.txt", content="y" * 5000)
+            f1 = db.file.create(name="test1.txt", content="x" * 20, type="application/octet-stream")
+            f2 = db.file.create(name="test2.txt", content="y" * 5000, type="application/octet-stream")
             m  = db.msg.create(content="one two", author="admin",
                 files = [f1, f2])
             i  = db.issue.create(title='spam', files = [f1, f2],
--- a/test/test_mailgw.py	Sun Aug 12 16:05:42 2018 +0100
+++ b/test/test_mailgw.py	Sun Aug 12 16:15:10 2018 +0100
@@ -28,7 +28,7 @@
         reason="Skipping PGP tests: 'pyme' not installed"))
 
 
-from roundup.anypy.strings import StringIO, u2s
+from roundup.anypy.strings import StringIO, b2s, u2s
 
 if 'SENDMAILDEBUG' not in os.environ:
     os.environ['SENDMAILDEBUG'] = 'mail-test.log'
@@ -894,7 +894,7 @@
         self.assertEqual(msg.content, 'test attachment second text/plain')
 
     def testMultipartCharsetUTF8NoAttach(self):
-        c = 'umlaut \xc3\xa4\xc3\xb6\xc3\xbc\xc3\x84\xc3\x96\xc3\x9c\xc3\x9f'
+        c = b2s(b'umlaut \xc3\xa4\xc3\xb6\xc3\xbc\xc3\x84\xc3\x96\xc3\x9c\xc3\x9f')
         self.doNewIssue()
         self.db.config.NOSY_MAX_ATTACHMENT_SIZE = 0
         self._handle_mail(self.multipart_msg_latin1)
@@ -944,7 +944,7 @@
 ''')
 
     def testMultipartCharsetLatin1NoAttach(self):
-        c = 'umlaut \xc3\xa4\xc3\xb6\xc3\xbc\xc3\x84\xc3\x96\xc3\x9c\xc3\x9f'
+        c = b2s(b'umlaut \xc3\xa4\xc3\xb6\xc3\xbc\xc3\x84\xc3\x96\xc3\x9c\xc3\x9f')
         self.doNewIssue()
         self.db.config.NOSY_MAX_ATTACHMENT_SIZE = 0
         self.db.config.MAIL_CHARSET = 'iso-8859-1'
@@ -995,7 +995,7 @@
 ''')
 
     def testMultipartCharsetUTF8AttachFile(self):
-        c = 'umlaut \xc3\xa4\xc3\xb6\xc3\xbc\xc3\x84\xc3\x96\xc3\x9c\xc3\x9f'
+        c = b2s(b'umlaut \xc3\xa4\xc3\xb6\xc3\xbc\xc3\x84\xc3\x96\xc3\x9c\xc3\x9f')
         self.doNewIssue()
         self._handle_mail(self.multipart_msg_latin1)
         messages = self.db.issue.get('1', 'messages')
@@ -1057,7 +1057,7 @@
 ''')
 
     def testMultipartCharsetLatin1AttachFile(self):
-        c = 'umlaut \xc3\xa4\xc3\xb6\xc3\xbc\xc3\x84\xc3\x96\xc3\x9c\xc3\x9f'
+        c = b2s(b'umlaut \xc3\xa4\xc3\xb6\xc3\xbc\xc3\x84\xc3\x96\xc3\x9c\xc3\x9f')
         self.doNewIssue()
         self.db.config.MAIL_CHARSET = 'iso-8859-1'
         self._handle_mail(self.multipart_msg_latin1)
@@ -1287,7 +1287,7 @@
         messages.sort()
         msg = self.db.msg.getnode(messages[-1])
         # html converted to utf-8 text
-        self.assertEqual(msg.content, mycontent+" \xc3\xa4\xc3\xb6\xc3\xbc\xc3\x84\xc3\x96\xc3\x9c\xc3\x9f")
+        self.assertEqual(msg.content, mycontent+b2s(b" \xc3\xa4\xc3\xb6\xc3\xbc\xc3\x84\xc3\x96\xc3\x9c\xc3\x9f"))
         self.assertEqual(msg.type, None)
         self.assertEqual(len(msg.files), 2)
         name = "unnamed" # no name for any files
@@ -1295,7 +1295,7 @@
         # replace quoted printable string at end of html document
         # with it's utf-8 encoded equivalent so comparison
         # works.
-        content = { 0: "75,23,16,18\n", 1: self.html_doc.replace(" =E4=F6=FC=C4=D6=DC=DF"," \xc3\xa4\xc3\xb6\xc3\xbc\xc3\x84\xc3\x96\xc3\x9c\xc3\x9f")}
+        content = { 0: "75,23,16,18\n", 1: self.html_doc.replace(" =E4=F6=FC=C4=D6=DC=DF",b2s(b" \xc3\xa4\xc3\xb6\xc3\xbc\xc3\x84\xc3\x96\xc3\x9c\xc3\x9f"))}
         for n, id in enumerate (msg.files):
             f = self.db.file.getnode (id)
             self.assertEqual(f.name, name)
@@ -2648,11 +2648,11 @@
         self._allowAnonymousSubmit()
         self._handle_mail(message)
         title = self.db.issue.get('1', 'title')
-        self.assertEquals(title, 'Test \xc3\x84\xc3\x96\xc3\x9c umlauts X1 X2')
+        self.assertEquals(title, b2s(b'Test \xc3\x84\xc3\x96\xc3\x9c umlauts X1 X2'))
         m = set(self.db.user.list())
         new = list(m - l)[0]
         name = self.db.user.get(new, 'realname')
-        self.assertEquals(name, 'Firstname \xc3\xa4\xc3\xb6\xc3\x9f Last')
+        self.assertEquals(name, b2s(b'Firstname \xc3\xa4\xc3\xb6\xc3\x9f Last'))
 
     def testNewUserAuthorMixedEncodedNameSpacing(self):
         l = set(self.db.user.list())
@@ -2670,12 +2670,12 @@
         self._allowAnonymousSubmit()
         self._handle_mail(message)
         title = self.db.issue.get('1', 'title')
-        self.assertEquals(title, 'Test (\xc3\x84\xc3\x96\xc3\x9c) umlauts X1')
+        self.assertEquals(title, b2s(b'Test (\xc3\x84\xc3\x96\xc3\x9c) umlauts X1'))
         m = set(self.db.user.list())
         new = list(m - l)[0]
         name = self.db.user.get(new, 'realname')
         self.assertEquals(name,
-            '(\xc3\xa4\xc3\xb6\xc3\x9f\xc3\xa4\xc3\xb6\xc3\x9f)')
+            b2s(b'(\xc3\xa4\xc3\xb6\xc3\x9f\xc3\xa4\xc3\xb6\xc3\x9f)'))
 
     def testUnknownUser(self):
         l = set(self.db.user.list())
@@ -2839,9 +2839,7 @@
 
     def testMultipartEnc01(self):
         self.doNewIssue()
-        self._handle_mail('''Content-Type: text/plain;
-  charset="iso-8859-1"
-From: mary <mary@test.test>
+        self._handle_mail('''From: mary <mary@test.test>
 To: issue_tracker@your.tracker.email.domain.example
 Message-Id: <followup_dummy_id>
 In-Reply-To: <dummy_test_message_id>
@@ -2893,9 +2891,7 @@
 
     def testContentDisposition(self):
         self.doNewIssue()
-        self._handle_mail('''Content-Type: text/plain;
-  charset="iso-8859-1"
-From: mary <mary@test.test>
+        self._handle_mail('''From: mary <mary@test.test>
 To: issue_tracker@your.tracker.email.domain.example
 Message-Id: <followup_dummy_id>
 In-Reply-To: <dummy_test_message_id>
@@ -4187,7 +4183,7 @@
     def testForwardedMessageAttachment(self):
         message = '''Return-Path: <rgg@test.test>
 Received: from localhost(127.0.0.1), claiming to be "[115.130.26.69]"
-via SMTP by localhost, id smtpdAAApLaWrq; Tue Apr 13 23:10:05 2010
+  via SMTP by localhost, id smtpdAAApLaWrq; Tue Apr 13 23:10:05 2010
 Message-ID: <4BC4F9C7.50409@test.test>
 Date: Wed, 14 Apr 2010 09:09:59 +1000
 From: Rupert Goldie <rgg@test.test>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/test/test_mailgw_roundupmessage.py	Sun Aug 12 16:15:10 2018 +0100
@@ -0,0 +1,442 @@
+# -*- coding: utf-8 -*-
+import email
+import textwrap
+from unittest import TestCase
+
+from roundup.mailgw import RoundupMessage
+
+PART_TYPES = {
+    'multipart/signed': '    boundary="boundary-{indent}";\n',
+    'multipart/mixed': '    boundary="boundary-{indent}";\n',
+    'multipart/alternative': '    boundary="boundary-{indent}";\n',
+    'text/plain': '    name="foo.txt"\n\nfoo\n',
+    'text/plain_2': '    name="foo2.txt"\n\nfoo2\n',
+    'text/plain_3': '    name="foo3.txt"\n\nfoo3\n',
+    'text/html': '    name="foo.html"\n\n<html>foo</html>\n',
+    'application/pgp-signature': '    name="foo.gpg"\nfoo\n',
+    'application/pdf': '    name="foo.pdf"\nfoo\n',
+    'application/pdf_2': '    name="foo2.pdf"\nfoo2\n',
+    'message/rfc822': '\nSubject: foo\n\nfoo\n',
+}
+
+
+def message_from_string(msg):
+    return email.message_from_string(
+        textwrap.dedent(msg).lstrip(),
+        RoundupMessage)
+
+
+def construct_message(spec, depth=0):
+    parts = []
+    for content_type in spec:
+        if isinstance(content_type, list):
+            parts.extend(construct_message(content_type, depth=(depth + 1)))
+            parts.append('\n--boundary-{0}--\n'.format(depth + 1))
+        else:
+            if depth > 0:
+                parts.append('\n--boundary-{0}\n'.format(depth))
+
+            parts.append(
+                'Content-Type: {0};\n'.format(content_type.split('_')[0]))
+            parts.append(PART_TYPES[content_type].format(indent=(depth + 1)))
+
+    if depth == 0:
+        return email.message_from_string(''.join(parts), RoundupMessage)
+    else:
+        return parts
+
+
+class FlattenRoundupMessageTests(TestCase):
+    def test_flatten_with_from(self):
+        msg_string = textwrap.dedent("""
+            From: Some User <some.user@example.com>
+            To: issue_tracker@example.com
+            Message-Id: <dummy_test_message_id>
+            Subject: Test line start with from
+
+            From here to there!
+        """).lstrip()
+
+        msg = email.message_from_string(msg_string, RoundupMessage)
+        self.assertEqual(msg.flatten(), msg_string)
+
+
+class HeaderRoundupMessageTests(TestCase):
+    msg = message_from_string("""
+        Content-Type: text/plain;
+            charset="iso-8859-1"
+        From: =?utf8?b?SOKCrGxsbw==?= <hello@example.com>
+        To: Issue Tracker <issue_tracker@example.com>
+        Cc: =?utf8?b?SOKCrGxsbw==?= <hello@example.com>,
+            Some User <some.user@example.com>
+        Message-Id: <dummy_test_message_id>
+        Subject: [issue] Testing...
+
+        This is a test submission of a new issue.
+    """)
+
+    def test_get_plain_header(self):
+        self.assertEqual(
+            self.msg.get_header('to'),
+            'Issue Tracker <issue_tracker@example.com>')
+
+    def test_get_encoded_header(self):
+        self.assertEqual(
+            self.msg.get_header('from'),
+            'H€llo <hello@example.com>')
+
+    def test_get_address_list(self):
+        self.assertEqual(self.msg.get_address_list('cc'), [
+            ('H€llo', 'hello@example.com'),
+            ('Some User', 'some.user@example.com'),
+        ])
+
+
+class BodyRoundupMessageTests(TestCase):
+    def test_get_body_iso_8859_1(self):
+        msg = message_from_string("""
+            Content-Type: text/plain; charset="iso-8859-1"
+            Content-Transfer-Encoding: quoted-printable
+
+            A message with encoding (encoded oe =F6)
+        """)
+
+        self.assertEqual(
+            msg.get_body(),
+            'A message with encoding (encoded oe ö)\n')
+
+    def test_get_body_utf_8(self):
+        msg = message_from_string("""
+            Content-Type: text/plain; charset="utf-8"
+            Content-Transfer-Encoding: quoted-printable
+
+            A message with encoding (encoded oe =C3=B6)
+        """)
+
+        self.assertEqual(
+            msg.get_body(),
+            'A message with encoding (encoded oe ö)\n')
+
+    def test_get_body_base64(self):
+        msg = message_from_string("""
+            Content-Type: application/octet-stream
+            Content-Disposition: attachment; filename="message.dat"
+            Content-Transfer-Encoding: base64
+
+            dGVzdCBlbmNvZGVkIG1lc3NhZ2U=
+        """)
+
+        self.assertEqual(msg.get_body(), 'test encoded message')
+
+
+class AsAttachmentRoundupMessageTests(TestCase):
+    def test_text_plain(self):
+        msg = message_from_string("""
+            Content-Type: text/plain; charset="iso-8859-1
+
+            Plain text message
+        """)
+
+        self.assertEqual(
+            msg.as_attachment(),
+            (None, 'text/plain', 'Plain text message\n'))
+
+    def test_octet_stream(self):
+        msg = message_from_string("""
+            Content-Type: application/octet-stream
+            Content-Disposition: attachment; filename="message.dat"
+            Content-Transfer-Encoding: base64
+
+            dGVzdCBlbmNvZGVkIG1lc3NhZ2U=
+        """)
+
+        self.assertEqual(
+            msg.as_attachment(),
+            ('message.dat', 'application/octet-stream',
+             'test encoded message'))
+
+    def test_rfc822(self):
+        msg = message_from_string("""
+            Content-Type: message/rfc822
+
+            Subject: foo
+
+            foo
+        """)
+
+        self.assertEqual(
+            msg.as_attachment(),
+            ('foo.eml', 'message/rfc822', 'Subject: foo\n\nfoo\n'))
+
+    def test_rfc822_no_subject(self):
+        msg = message_from_string("""
+            Content-Type: message/rfc822
+
+            X-No-Headers: nope
+
+            foo
+        """)
+
+        self.assertEqual(
+            msg.as_attachment(),
+            (None, 'message/rfc822', 'X-No-Headers: nope\n\nfoo\n'))
+
+    def test_rfc822_no_payload(self):
+        msg = message_from_string("""\
+            Content-Type: message/rfc822
+        """)
+
+        self.assertEqual(
+            msg.as_attachment(),
+            (None, 'message/rfc822', '\n'))
+
+
+class ExtractContentRoundupMessageTests(TestCase):
+    def test_text_plain(self):
+        msg = construct_message(['text/plain'])
+
+        self.assertEqual(msg.extract_content(), ('foo\n', [], False))
+
+    def test_attached_text_plain(self):
+        msg = construct_message([
+            'multipart/mixed', [
+                'text/plain',
+                'text/plain',
+            ],
+        ])
+
+        self.assertEqual(msg.extract_content(), (
+            'foo\n',
+            [('foo.txt', 'text/plain', 'foo\n')],
+            False
+        ))
+
+    def test_multipart_mixed(self):
+        msg = construct_message([
+            'multipart/mixed', [
+                'text/plain',
+                'application/pdf',
+            ],
+        ])
+
+        self.assertEqual(msg.extract_content(), (
+            'foo\n',
+            [('foo.pdf', 'application/pdf', 'foo\n')],
+            False
+        ))
+
+    def test_multipart_alternative(self):
+        msg = construct_message([
+            'multipart/alternative', [
+                'text/plain',
+                'text/html',
+            ],
+        ])
+
+        self.assertEqual(msg.extract_content(), (
+            'foo\n',
+            [('foo.html', 'text/html', '<html>foo</html>\n')],
+            False
+        ))
+
+    def test_deep_multipart_alternative(self):
+        msg = construct_message([
+            'multipart/mixed', [
+                'multipart/alternative', [
+                    'text/plain',
+                    'application/pdf',
+                    'text/plain_2',
+                    'text/html',
+                ],
+                'multipart/alternative', [
+                    'text/plain_3',
+                    'application/pdf_2',
+                ],
+            ],
+        ])
+
+        self.assertEqual(msg.extract_content(), (
+            'foo2\n', [
+                ('foo.pdf', 'application/pdf', 'foo\n'),
+                ('foo.txt', 'text/plain', 'foo\n'),
+                ('foo.html', 'text/html', '<html>foo</html>\n'),
+                ('foo3.txt', 'text/plain', 'foo3\n'),
+                ('foo2.pdf', 'application/pdf', 'foo2\n'),
+            ],
+            False
+        ))
+
+    def test_deep_multipart_alternative_ignore(self):
+        msg = construct_message([
+            'multipart/mixed', [
+                'multipart/alternative', [
+                    'text/plain',
+                    'application/pdf',
+                    'text/plain_2',
+                    'text/html',
+                ],
+                'multipart/alternative', [
+                    'text/plain_3',
+                    'application/pdf_2',
+                ],
+            ],
+        ])
+
+        msg.extract_content(ignore_alternatives=True)
+        self.assertEqual(msg.extract_content(ignore_alternatives=True), (
+            'foo2\n', [
+                ('foo3.txt', 'text/plain', 'foo3\n'),
+                ('foo2.pdf', 'application/pdf', 'foo2\n'),
+            ],
+            False
+        ))
+
+    def test_signed_text(self):
+        msg = construct_message([
+            'multipart/signed', [
+                'text/plain',
+                'application/pgp-signature',
+            ],
+        ])
+
+        self.assertEqual(msg.extract_content(), ('foo\n', [], False))
+
+    def test_signed_attachemts(self):
+        msg = construct_message([
+            'multipart/signed', [
+                'multipart/mixed', [
+                    'text/plain',
+                    'application/pdf',
+                ],
+                'application/pgp-signature',
+            ],
+        ])
+
+        self.assertEqual(msg.extract_content(), (
+            'foo\n',
+            [('foo.pdf', 'application/pdf', 'foo\n')],
+            False
+        ))
+
+    def test_attached_signature(self):
+        msg = construct_message([
+            'multipart/mixed', [
+                'text/plain',
+                'application/pgp-signature',
+            ],
+        ])
+
+        self.assertEqual(msg.extract_content(), (
+            'foo\n',
+            [('foo.gpg', 'application/pgp-signature', 'foo\n')],
+            False
+        ))
+
+    def test_rfc822_message(self):
+        msg = construct_message([
+            'multipart/mixed', [
+                'message/rfc822',
+            ],
+        ])
+
+        self.assertEqual(msg.extract_content(), (
+            None,
+            [('foo.eml', 'message/rfc822', 'Subject: foo\n\nfoo\n')],
+            False
+        ))
+
+    def test_rfc822_message_unpack(self):
+        msg = construct_message([
+            'multipart/mixed', [
+                'text/plain',
+                'message/rfc822',
+            ],
+        ])
+
+        self.assertEqual(msg.extract_content(unpack_rfc822=True), (
+            'foo\n',
+            [(None, 'text/plain', 'foo\n')],
+            False
+        ))
+
+
+class PgpDetectRoundupMessageTests(TestCase):
+    def test_pgp_message_signed(self):
+        msg = message_from_string("""
+            Content-Type: multipart/signed; micalg=pgp-sha1;
+                    protocol="application/pgp-signature"
+
+            Fake Body
+        """)
+
+        self.assertTrue(msg.pgp_signed())
+
+    def test_pgp_message_not_signed(self):
+        msg = message_from_string("""
+            Content-Type: text/plain
+
+            Fake Body
+        """)
+
+        self.assertFalse(msg.pgp_signed())
+
+    def test_pgp_message_signed_protocol_missing(self):
+        msg = message_from_string("""
+            Content-Type: multipart/signed; micalg=pgp-sha1
+
+            Fake Body
+        """)
+
+        self.assertFalse(msg.pgp_signed())
+
+    def test_pgp_message_signed_protocol_invalid(self):
+        msg = message_from_string("""
+            Content-Type: multipart/signed;
+                protocol="application/not-pgp-signature"
+
+            Fake Body
+        """)
+
+        self.assertFalse(msg.pgp_signed())
+
+    def test_pgp_message_encrypted(self):
+        msg = message_from_string("""
+            Content-Type: multipart/encrypted;
+                protocol="application/pgp-encrypted"
+
+            Fake Body
+        """)
+
+        self.assertTrue(msg.pgp_encrypted())
+
+    def test_pgp_message_not_encrypted(self):
+        msg = message_from_string("""
+            Content-Type: text/plain
+
+            Fake Body
+        """)
+
+        self.assertFalse(msg.pgp_encrypted())
+
+    def test_pgp_message_encrypted_protocol_missing(self):
+        msg = message_from_string("""
+            Content-Type: multipart/encrypted
+
+            Fake Body
+        """)
+
+        self.assertFalse(msg.pgp_encrypted())
+
+    def test_pgp_message_encrypted_protocol_invalid(self):
+        msg = message_from_string("""
+            Content-Type: multipart/encrypted;
+                protocol="application/not-pgp-encrypted"
+
+            Fake Body
+        """)
+
+        self.assertFalse(msg.pgp_encrypted())
+
+# TODO: testing of the verify_signature() and decrypt() RoundupMessage methods.
+#   The whole PGP testing stuff seems a bit messy, so we will rely on the tests
+#   in test_mailgw for the time being
--- a/test/test_multipart.py	Sun Aug 12 16:05:42 2018 +0100
+++ b/test/test_multipart.py	Sun Aug 12 16:15:10 2018 +0100
@@ -15,12 +15,30 @@
 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
 
+import email
 import unittest
 from roundup.anypy.strings import StringIO
 
-from roundup.mailgw import Message
+from roundup.mailgw import RoundupMessage
+
+def gen_message(spec):
+    """Create a basic MIME message according to 'spec'.
+
+    Each line of a spec has one content-type, which is optionally indented.
+    The indentation signifies how deep in the MIME hierarchy the
+    content-type is.
 
-class ExampleMessage(Message):
+    """
+
+    def getIndent(line):
+        """Get the current line's indentation, using four-space indents."""
+        count = 0
+        for char in line:
+            if char != ' ':
+                break
+            count += 1
+        return count // 4
+
     # A note on message/rfc822: The content of such an attachment is an
     # email with at least one header line. RFC2046 tells us: """   A
     # media type of "message/rfc822" indicates that the body contains an
@@ -42,36 +60,22 @@
              'application/pdf': '    name="foo.pdf"\nfoo\n',
              'message/rfc822': '\nSubject: foo\n\nfoo\n'}
 
-    def __init__(self, spec):
-        """Create a basic MIME message according to 'spec'.
-
-        Each line of a spec has one content-type, which is optionally indented.
-        The indentation signifies how deep in the MIME hierarchy the
-        content-type is.
-
-        """
-        parts = []
-        for line in spec.splitlines():
-            content_type = line.strip()
-            if not content_type:
-                continue
+    parts = []
+    for line in spec.splitlines():
+        content_type = line.strip()
+        if not content_type:
+            continue
 
-            indent = self.getIndent(line)
-            if indent:
-                parts.append('\n--boundary-%s\n' % indent)
-            parts.append('Content-type: %s;\n' % content_type)
-            parts.append(self.table[content_type] % {'indent': indent + 1})
-
-        Message.__init__(self, StringIO(''.join(parts)))
+        indent = getIndent(line)
+        if indent:
+            parts.append('\n--boundary-%s\n' % indent)
+        parts.append('Content-type: %s;\n' % content_type)
+        parts.append(table[content_type] % {'indent': indent + 1})
 
-    def getIndent(self, line):
-        """Get the current line's indentation, using four-space indents."""
-        count = 0
-        for char in line:
-            if char != ' ':
-                break
-            count += 1
-        return count // 4
+    for i in range(indent, 0, -1):
+        parts.append('\n--boundary-%s--\n' % i)
+
+    return email.message_from_file(StringIO(''.join(parts)), RoundupMessage)
 
 class MultipartTestCase(unittest.TestCase):
     def setUp(self):
@@ -110,54 +114,49 @@
         self.fp.seek(0)
 
     def testMultipart(self):
-        m = Message(self.fp)
+        m = email.message_from_file(self.fp, RoundupMessage)
         self.assert_(m is not None)
 
-        # skip the first bit
-        p = m.getpart()
-        self.assert_(p is not None)
-        self.assertEqual(p.fp.read(),
-            'This is a multipart message. Ignore this bit.\r\n')
+        it = iter(m.get_payload())
 
         # first text/plain
-        p = m.getpart()
+        p = next(it, None)
         self.assert_(p is not None)
-        self.assertEqual(p.gettype(), 'text/plain')
-        self.assertEqual(p.fp.read(),
+        self.assertEqual(p.get_content_type(), 'text/plain')
+        self.assertEqual(p.get_payload(),
             'Hello, world!\r\n\r\nBlah blah\r\nfoo\r\n-foo\r\n')
 
         # sub-multipart
-        p = m.getpart()
+        p = next(it, None)
         self.assert_(p is not None)
-        self.assertEqual(p.gettype(), 'multipart/alternative')
+        self.assertEqual(p.get_content_type(), 'multipart/alternative')
 
         # sub-multipart text/plain
-        q = p.getpart()
+        qit = iter(p.get_payload())
+        q = next(qit, None)
         self.assert_(q is not None)
-        q = p.getpart()
-        self.assert_(q is not None)
-        self.assertEqual(q.gettype(), 'text/plain')
-        self.assertEqual(q.fp.read(), 'Hello, world!\r\n\r\nBlah blah\r\n')
+        self.assertEqual(q.get_content_type(), 'text/plain')
+        self.assertEqual(q.get_payload(), 'Hello, world!\r\n\r\nBlah blah\r\n')
 
         # sub-multipart text/html
-        q = p.getpart()
+        q = next(qit, None)
         self.assert_(q is not None)
-        self.assertEqual(q.gettype(), 'text/html')
-        self.assertEqual(q.fp.read(), '<b>Hello, world!</b>\r\n')
+        self.assertEqual(q.get_content_type(), 'text/html')
+        self.assertEqual(q.get_payload(), '<b>Hello, world!</b>\r\n')
 
         # sub-multipart end
-        q = p.getpart()
+        q = next(qit, None)
         self.assert_(q is None)
 
         # final text/plain
-        p = m.getpart()
+        p = next(it, None)
         self.assert_(p is not None)
-        self.assertEqual(p.gettype(), 'text/plain')
-        self.assertEqual(p.fp.read(),
+        self.assertEqual(p.get_content_type(), 'text/plain')
+        self.assertEqual(p.get_payload(),
             'Last bit\n')
 
         # end
-        p = m.getpart()
+        p = next(it, None)
         self.assert_(p is None)
 
     def TestExtraction(self, spec, expected, convert_html_with=False):
@@ -167,7 +166,7 @@
         else:
             html2text=None
 
-        self.assertEqual(ExampleMessage(spec).extract_content(
+        self.assertEqual(gen_message(spec).extract_content(
             html2text=html2text), expected)
 
     def testTextPlain(self):

Roundup Issue Tracker: http://roundup-tracker.org/