Merge lp:~sinzui/launchpad/hold-message into lp:launchpad

Proposed by Curtis Hovey
Status: Merged
Merged at revision: 11685
Proposed branch: lp:~sinzui/launchpad/hold-message
Merge into: lp:launchpad
Diff against target: 334 lines (+175/-87)
5 files modified
lib/lp/services/mailman/doc/postings.txt (+0/-76)
lib/lp/services/mailman/monkeypatches/lpsize.py (+34/-3)
lib/lp/services/mailman/testing/__init__.py (+7/-3)
lib/lp/services/mailman/tests/test_lpmoderate.py (+5/-5)
lib/lp/services/mailman/tests/test_lpsize.py (+129/-0)
To merge this branch: bzr merge lp:~sinzui/launchpad/hold-message
Reviewer Review Type Date Requested Status
Edwin Grubbs (community) code Approve
Review via email: mp+37750@code.launchpad.net

Description of the change

This is my branch to forward small messages to the moderation queue.

    lp:~sinzui/launchpad/hold-message
    Diff size: 311
    Launchpad bug:
          https://bugs.launchpad.net/bugs/645702
    Test command: ./bin/test -vv \
          -t test_lpsize
    Pre-implementation: EdwinGrubbs, jcsacket
    Target release: 10.10

Forward small messages to the moderation queue
-----------------------------------------------

OOPS-1726XMLP285 shows that mailman forwarded a message for moderation that
exceeds the db storage limit.

There are two views on this issue. The first is that this is a copy of a
message mailman wants an admin to decide if the real message should be sent to
the list or discarded--the person only needs to see enough to make a decision.
the second issue is that mailman has rules for handling large messages and
they do not appear to agree with Lp. The soft limit enters moderation (which
is this case), and the hard limit is discarded immediately. I recall the soft
limit was raised at the request of statik.

Rules
-----

    * Update the lpsize tests to be unittests and delete the tests from
      postings.txt (often fails because of timeouts)
    * Add a rule to truncate the message so that it is small enough to
      be moderated.
      * Delete non text/plain parts because Lp only stores text/plain.
      * Truncate large text/parts. 10k will be enough for the moderator
        to make a decision to discard or forward. This message is shown
        in a list of messages; we want to limit the text to ensure the page
        is usable.

QA
--

    * Send an email with a large attachment to a list on staging.
      the attachment must be larger than 11m and not over 20m
    * Verify the message appears in the moderation queue

Lint
----

Linting changed files:
  lib/lp/services/mailman/doc/postings.txt
  lib/lp/services/mailman/monkeypatches/lpsize.py
  lib/lp/services/mailman/testing/__init__.py
  lib/lp/services/mailman/tests/test_lpsize.py

Test
----

    * lib/lp/services/mailman/doc/postings.txt
      * Removed redundant test that were prone to fail
    * lib/lp/services/mailman/testing/__init__.py
      * Fixed the test harness to make a proper email with an attachment.
        These tests were the first to really exercise it.
    * lib/lp/services/mailman/tests/test_lpsize.py
      * Converted the doctests to unittests
      * Added tests for the truncated_message function.

Implementation
--------------

    * lib/lp/services/mailman/monkeypatches/lpsize.py
      * Added truncated_message to create a small message and used it when
        calling hold().

To post a comment you must log in.
Revision history for this message
Edwin Grubbs (edwin-grubbs) wrote :
Download full text (6.1 KiB)

Hi Curtis,

This is a nice improvement. I have some comments below.

-Edwin

>=== modified file 'lib/lp/services/mailman/monkeypatches/lpsize.py'
>--- lib/lp/services/mailman/monkeypatches/lpsize.py 2010-08-20 20:31:18 +0000
>+++ lib/lp/services/mailman/monkeypatches/lpsize.py 2010-10-06 15:36:24 +0000
>@@ -3,15 +3,43 @@
>
> """A pipeline handler for checking message sizes."""
>
>-# pylint: disable-msg=F0401
>+import email
>+
> from Mailman import (
> Errors,
>+ Message,
> mm_cfg,
> )
> from Mailman.Handlers.LPModerate import hold
> from Mailman.Logging.Syslog import syslog
>
>
>+def truncated_message(original_message, limit=10000):
>+ """Create a smaller version of the message for moderation."""
>+ message = email.message_from_string(
>+ original_message.as_string(), Message.Message)
>+ for part in email.iterators.typed_subpart_iterator(message, 'multipart'):
>+ subparts = part.get_payload()
>+ removeable = []
>+ for subpart in subparts:
>+ if subpart.get_content_maintype() == 'multipart':
>+ # This part is handled in the outer loop.
>+ continue
>+ elif subpart.get_content_type() == 'text/plain':

Shouldn't it truncate text/html or any other "text/" content-type,
since the user's mail client might create an empty text/plain section?

>+ # Truncate the message at 10kb so that there is enough
>+ # information for the moderator to make a decision.
>+ content = subpart.get_payload().strip()
>+ if len(content) > limit:
>+ subpart.set_payload(
>+ content[:limit] + ' [truncated for moderation]',

It seems like [truncated for moderation] will be easier to see if
it is on a new line.

>+ subpart.get_charset())
>+ else:
>+ removeable.append(subpart)
>+ for subpart in removeable:
>+ subparts.remove(subpart)
>+ return message
>+
>+
> def process(mlist, msg, msgdata):
> """Check the message size (approximately) against hard and soft limits.
>
>=== added file 'lib/lp/services/mailman/tests/test_lpsize.py'
>--- lib/lp/services/mailman/tests/test_lpsize.py 1970-01-01 00:00:00 +0000
>+++ lib/lp/services/mailman/tests/test_lpsize.py 2010-10-06 15:36:24 +0000
>@@ -0,0 +1,129 @@
>+# Copyright 20010 Canonical Ltd. This software is licensed under the

I think the copyright will expire by the time Bender and Leela can use
it.

>+# GNU Affero General Public License version 3 (see the file LICENSE).
>+"""Test the lpsize monekypatches"""
>+
>+from __future__ import with_statement
>+
>+__metaclass__ = type
>+__all__ = []
>+
>+from email.mime.application import MIMEApplication
>+
>+from Mailman import Errors
>+from Mailman.Handlers import LPSize
>+
>+from canonical.config import config
>+from canonical.testing import (
>+ DatabaseFunctionalLayer,
>+ LaunchpadFunctionalLayer,
>+ )
>+from lp.services.mailman.testing import MailmanTestCase
>+
>+
>+class TestLPSizeTestCase(MailmanTestCase):
>+ """Test LPSize.
>+
>+ Mailman process() methods quietly return. They may...

Read more...

review: Approve (code)
Revision history for this message
Curtis Hovey (sinzui) wrote :
Download full text (5.1 KiB)

On Wed, 2010-10-06 at 17:23 +0000, Edwin Grubbs wrote:
> Review: Approve code
> Hi Curtis,
>
> This is a nice improvement. I have some comments below.
>
> -Edwin
>
>
> >=== mo>+# GNU Affero General Public License version 3 (see the file LICENSE).
> >+"""Test the lpsize monekypatches"""
> >+
> >+from __future__ import with_statement
> >+
> >+__metaclass__ = type
> >+__all__ = []
> >+
> >+from email.mime.application import MIMEApplication
> >+
> >+from Mailman import Errors
> >+from Mailman.Handlers import LPSize
> >+
> >+from canonical.config import config
> >+from canonical.testing import (
> >+ DatabaseFunctionalLayer,
> >+ LaunchpadFunctionalLayer,
> >+ )
> >+from lp.services.mailman.testing import MailmanTestCase
> >+
> >+
> dified file 'lib/lp/services/mailman/monkeypatches/lpsize.py'
> >--- lib/lp/services/mailman/monkeypatches/lpsize.py 2010-08-20 20:31:18 +0000
> >+++ lib/lp/services/mailman/monkeypatches/lpsize.py 2010-10-06 15:36:24 +0000
> >...
> >+def truncated_message(original_message, limit=10000):
> >+ """Create a smaller version of the message for moderation."""
> >+ message = email.message_from_string(
> >+ original_message.as_string(), Message.Message)
> >+ for part in email.iterators.typed_subpart_iterator(message, 'multipart'):
> >+ subparts = part.get_payload()
> >+ removeable = []
> >+ for subpart in subparts:
> >+ if subpart.get_content_maintype() == 'multipart':
> >+ # This part is handled in the outer loop.
> >+ continue
> >+ elif subpart.get_content_type() == 'text/plain':
>
>
>
> Shouldn't it truncate text/html or any other "text/" content-type,
> since the user's mail client might create an empty text/plain section?

Launchpad does not support text/html messages. Messages without
text/plain parts are discarded in the hold process. There is an old bug
about empty messages in the moderation queue. It was discovered that all
cases are from spammers. We did not see a need to support text/html
since list subscribers are not using it and getting messages in the
moderation queue.

> >+ # Truncate the message at 10kb so that there is enough
> >+ # information for the moderator to make a decision.
> >+ content = subpart.get_payload().strip()
> >+ if len(content) > limit:
> >+ subpart.set_payload(
> >+ content[:limit] + ' [truncated for moderation]',
>
>
>
> It seems like [truncated for moderation] will be easier to see if
> it is on a new line.

Agreed

> >=== added file 'lib/lp/services/mailman/tests/test_lpsize.py'
> >--- lib/lp/services/mailman/tests/test_lpsize.py 1970-01-01 00:00:00 +0000
> >+++ lib/lp/services/mailman/tests/test_lpsize.py 2010-10-06 15:36:24 +0000
> >@@ -0,0 +1,129 @@
> >+# Copyright 20010 Canonical Ltd. This software is licensed under the
>
>
>
> I think the copyright will expire by the time Bender and Leela can use
> it.

:)

...

> >+class TestLPSizeTestCase(MailmanTestCase):
> >+ """Test LPSize.
> >+
> >+ Mailman process() methods quietly return. They may set msg_data key-values
> ...

Read more...

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'lib/lp/services/mailman/doc/postings.txt'
--- lib/lp/services/mailman/doc/postings.txt 2010-10-03 15:30:06 +0000
+++ lib/lp/services/mailman/doc/postings.txt 2010-10-06 18:06:45 +0000
@@ -832,79 +832,3 @@
832 >>> vette_watcher.wait_for_discard('narwhale')832 >>> vette_watcher.wait_for_discard('narwhale')
833 >>> len(list(smtpd))833 >>> len(list(smtpd))
834 0834 0
835
836
837Large messages
838==============
839
840Only messages which are less than about 40k in size are allowed straight
841through on the mailing list. A message bigger than that will be held for
842moderator approval.
843
844 >>> from canonical.config import config
845 >>> config.mailman.soft_max_size
846 40000
847
848 >>> from email.mime.multipart import MIMEMultipart
849 >>> from email.mime.application import MIMEApplication
850 >>> from email.mime.text import MIMEText
851
852 >>> big_message = MIMEMultipart()
853 >>> big_message['From'] = 'anne.person@example.com'
854 >>> big_message['To'] = 'itest-one@lists.launchpad.dev'
855 >>> big_message['Subject'] = 'A huge message'
856 >>> big_message['Message-ID'] = '<puma>'
857 >>> big_message.attach(
858 ... MIMEText('look at this pdf.', 'plain'))
859 >>> big_message.attach(
860 ... MIMEApplication('\n'.join(['x' * 50] * 1000), 'octet-stream'))
861 >>> inject('itest-one', big_message.as_string())
862
863 >>> vette_watcher.wait_for_hold('itest-one', 'puma')
864 >>> print_message_summaries()
865 Number of messages: 1
866 bounces@canonical.com
867 ...
868 Itest One <noreply@launchpad.net>
869 New mailing list message requiring approval for Itest One
870
871Once this message is approved, it is posted through to the mailing list.
872
873 >>> browser.open(
874 ... 'http://launchpad.dev:8085/~itest-one/+mailinglist-moderate')
875 >>> browser.getControl(name='field.%3Cpuma%3E').value = ['approve']
876 >>> browser.getControl('Moderate').click()
877 >>> smtpd_watcher.wait_for_mbox_delivery('puma')
878 >>> smtpd_watcher.wait_for_mbox_delivery('puma')
879
880 >>> print_message_summaries()
881 Number of messages: 2
882 itest-one-bounces+anne.person=example.com@lists.launchpad.dev
883 <puma>
884 anne.person@example.com
885 [Itest-one] A huge message
886 itest-one-bounces+archive=mail-archive.dev@lists.launchpad.dev
887 <puma>
888 anne.person@example.com
889 [Itest-one] A huge message
890
891There is a hard limit of 1MB, over which the message is summarily logged and
892discarded.
893
894 >>> config.mailman.hard_max_size
895 1000000
896
897 >>> huge_message = MIMEMultipart()
898 >>> huge_message['From'] = 'anne.person@example.com'
899 >>> huge_message['To'] = 'itest-one@lists.launchpad.dev'
900 >>> huge_message['Subject'] = 'A huge message'
901 >>> huge_message['Message-ID'] = '<quahog>'
902 >>> huge_message.attach(
903 ... MIMEText('look at this huge pdf.', 'plain'))
904 >>> huge_message.attach(
905 ... MIMEApplication('\n'.join(['x' * 50] * 20000), 'octet-stream'))
906 >>> inject('itest-one', huge_message.as_string())
907
908 >>> vette_watcher.wait_for_discard('quahog')
909 >>> print_message_summaries()
910 Number of messages: 0
911835
=== modified file 'lib/lp/services/mailman/monkeypatches/lpsize.py'
--- lib/lp/services/mailman/monkeypatches/lpsize.py 2010-08-20 20:31:18 +0000
+++ lib/lp/services/mailman/monkeypatches/lpsize.py 2010-10-06 18:06:45 +0000
@@ -1,17 +1,45 @@
1# Copyright 2009 Canonical Ltd. This software is licensed under the1# Copyright 2009-2010 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""A pipeline handler for checking message sizes."""4"""A pipeline handler for checking message sizes."""
55
6# pylint: disable-msg=F04016import email
7
7from Mailman import (8from Mailman import (
8 Errors,9 Errors,
10 Message,
9 mm_cfg,11 mm_cfg,
10 )12 )
11from Mailman.Handlers.LPModerate import hold13from Mailman.Handlers.LPModerate import hold
12from Mailman.Logging.Syslog import syslog14from Mailman.Logging.Syslog import syslog
1315
1416
17def truncated_message(original_message, limit=10000):
18 """Create a smaller version of the message for moderation."""
19 message = email.message_from_string(
20 original_message.as_string(), Message.Message)
21 for part in email.iterators.typed_subpart_iterator(message, 'multipart'):
22 subparts = part.get_payload()
23 removeable = []
24 for subpart in subparts:
25 if subpart.get_content_maintype() == 'multipart':
26 # This part is handled in the outer loop.
27 continue
28 elif subpart.get_content_type() == 'text/plain':
29 # Truncate the message at 10kb so that there is enough
30 # information for the moderator to make a decision.
31 content = subpart.get_payload().strip()
32 if len(content) > limit:
33 subpart.set_payload(
34 content[:limit] + '\n[truncated for moderation]',
35 subpart.get_charset())
36 else:
37 removeable.append(subpart)
38 for subpart in removeable:
39 subparts.remove(subpart)
40 return message
41
42
15def process(mlist, msg, msgdata):43def process(mlist, msg, msgdata):
16 """Check the message size (approximately) against hard and soft limits.44 """Check the message size (approximately) against hard and soft limits.
1745
@@ -42,7 +70,10 @@
42 if message_size < mm_cfg.LAUNCHPAD_HARD_MAX_SIZE:70 if message_size < mm_cfg.LAUNCHPAD_HARD_MAX_SIZE:
43 # Hold the message in Mailman. See lpmoderate.py for similar71 # Hold the message in Mailman. See lpmoderate.py for similar
44 # algorithm.72 # algorithm.
45 hold(mlist, msg, msgdata, 'Too big')73 # There is a limit to the size that can be stored in Lp. Send
74 # a trucated copy of the message that has enough information for
75 # the moderator to make a decision.
76 hold(mlist, truncated_message(msg), msgdata, 'Too big')
46 # The message is larger than the hard limit, so log and discard.77 # The message is larger than the hard limit, so log and discard.
47 syslog('vette', 'Discarding message w/size > hard limit: %s',78 syslog('vette', 'Discarding message w/size > hard limit: %s',
48 msg.get('message-id', 'n/a'))79 msg.get('message-id', 'n/a'))
4980
=== modified file 'lib/lp/services/mailman/testing/__init__.py'
--- lib/lp/services/mailman/testing/__init__.py 2010-10-04 19:50:45 +0000
+++ lib/lp/services/mailman/testing/__init__.py 2010-10-06 18:06:45 +0000
@@ -7,6 +7,7 @@
77
8from contextlib import contextmanager8from contextlib import contextmanager
9import email9import email
10from email.mime.multipart import MIMEMultipart
10from email.mime.text import MIMEText11from email.mime.text import MIMEText
11import os12import os
12import shutil13import shutil
@@ -87,13 +88,16 @@
87 # Make a Mailman Message.Message.88 # Make a Mailman Message.Message.
88 if isinstance(sender, (list, tuple)):89 if isinstance(sender, (list, tuple)):
89 sender = ', '.join(sender)90 sender = ', '.join(sender)
90 message = MIMEText(content, mime_type)91 message = MIMEMultipart()
91 message['from'] = sender92 message['from'] = sender
92 message['to'] = mm_list.getListAddress()93 message['to'] = mm_list.getListAddress()
93 message['subject'] = subject94 message['subject'] = subject
94 message['message-id'] = self.getUniqueString()95 message['message-id'] = self.getUniqueString()
96 message.attach(MIMEText(content, mime_type))
97 if attachment is not None:
98 # Rewrap the text message in a multipart message and add the
99 # attachment.
100 message.attach(attachment)
95 mm_message = email.message_from_string(101 mm_message = email.message_from_string(
96 message.as_string(), Message.Message)102 message.as_string(), Message.Message)
97 if attachment is not None:
98 mm_message.attach(attachment, 'octet-stream')
99 return mm_message103 return mm_message
100104
=== modified file 'lib/lp/services/mailman/tests/test_lpmoderate.py'
--- lib/lp/services/mailman/tests/test_lpmoderate.py 2010-10-04 19:50:45 +0000
+++ lib/lp/services/mailman/tests/test_lpmoderate.py 2010-10-06 18:06:45 +0000
@@ -18,11 +18,11 @@
18 """Test lpmoderate.18 """Test lpmoderate.
1919
20 Mailman process() methods quietly return. They may set msg_data key-values20 Mailman process() methods quietly return. They may set msg_data key-values
21 or raise an error to end processing. This group of tests tests often check21 or raise an error to end processing. These tests often check for errors,
22 for errors, but that does not mean there is an error condition, it only22 but that does not mean there is an error condition, it only means message
23 means message processing has reached a final decision. Messages that do23 processing has reached a final decision. Messages that do not cause a
24 not cause a final decision pass-through and the process() methods ends24 final decision pass through, and the process() methods ends without a
25 without a return.25 return.
26 """26 """
2727
28 layer = LaunchpadFunctionalLayer28 layer = LaunchpadFunctionalLayer
2929
=== added file 'lib/lp/services/mailman/tests/test_lpsize.py'
--- lib/lp/services/mailman/tests/test_lpsize.py 1970-01-01 00:00:00 +0000
+++ lib/lp/services/mailman/tests/test_lpsize.py 2010-10-06 18:06:45 +0000
@@ -0,0 +1,129 @@
1# Copyright 2010 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3"""Test the lpsize monekypatches"""
4
5from __future__ import with_statement
6
7__metaclass__ = type
8__all__ = []
9
10from email.mime.application import MIMEApplication
11
12from Mailman import Errors
13from Mailman.Handlers import LPSize
14
15from canonical.config import config
16from canonical.testing import (
17 DatabaseFunctionalLayer,
18 LaunchpadFunctionalLayer,
19 )
20from lp.services.mailman.testing import MailmanTestCase
21
22
23class TestLPSizeTestCase(MailmanTestCase):
24 """Test LPSize.
25
26 Mailman process() methods quietly return. They may set msg_data key-values
27 or raise an error to end processing. These tests often check for errors,
28 but that does not mean there is an error condition, it only means message
29 processing has reached a final decision. Messages that do not cause a
30 final decision pass through, and the process() methods ends without a
31 return.
32 """
33
34 layer = LaunchpadFunctionalLayer
35
36 def setUp(self):
37 super(TestLPSizeTestCase, self).setUp()
38 self.team, self.mailing_list = self.factory.makeTeamAndMailingList(
39 'team-1', 'team-1-owner')
40 self.mm_list = self.makeMailmanList(self.mailing_list)
41 self.subscriber_email = self.team.teamowner.preferredemail.email
42
43 def tearDown(self):
44 super(TestLPSizeTestCase, self).tearDown()
45 self.cleanMailmanList(self.mm_list)
46
47 def test_process_size_under_soft_limit(self):
48 # Any message under 40kb is sent to the list.
49 attachment = MIMEApplication(
50 '\n'.join(['x' * 20] * 1000), 'octet-stream')
51 message = self.makeMailmanMessage(
52 self.mm_list, self.subscriber_email, 'subject', 'content',
53 attachment=attachment)
54 msg_data = {}
55 silence = LPSize.process(self.mm_list, message, msg_data)
56 self.assertEqual(None, silence)
57
58 def test_process_size_over_soft_limit_held(self):
59 # Messages over 40kb held for moderation.
60 self.assertEqual(40000, config.mailman.soft_max_size)
61 attachment = MIMEApplication(
62 '\n'.join(['x' * 40] * 1000), 'octet-stream')
63 message = self.makeMailmanMessage(
64 self.mm_list, self.subscriber_email, 'subject', 'content',
65 attachment=attachment)
66 msg_data = {}
67 args = (self.mm_list, message, msg_data)
68 self.assertRaises(
69 Errors.HoldMessage, LPSize.process, *args)
70 self.assertEqual(1, self.mailing_list.getReviewableMessages().count())
71
72 def test_process_size_over_hard_limit_discarded(self):
73 # Messages over 1MB are discarded.
74 self.assertEqual(1000000, config.mailman.hard_max_size)
75 attachment = MIMEApplication(
76 '\n'.join(['x' * 1000] * 1000), 'octet-stream')
77 message = self.makeMailmanMessage(
78 self.mm_list, self.subscriber_email, 'subject', 'content',
79 attachment=attachment)
80 msg_data = {}
81 args = (self.mm_list, message, msg_data)
82 self.assertRaises(
83 Errors.DiscardMessage, LPSize.process, *args)
84 self.assertEqual(0, self.mailing_list.getReviewableMessages().count())
85
86
87class TestTruncatedMessage(MailmanTestCase):
88 """Test truncated_message helper."""
89
90 layer = DatabaseFunctionalLayer
91
92 def setUp(self):
93 super(TestTruncatedMessage, self).setUp()
94 self.team, self.mailing_list = self.factory.makeTeamAndMailingList(
95 'team-1', 'team-1-owner')
96 self.mm_list = self.makeMailmanList(self.mailing_list)
97 self.subscriber_email = self.team.teamowner.preferredemail.email
98
99 def test_attchments_are_removed(self):
100 # Plain-text and multipart are preserved, everything else is removed.
101 attachment = MIMEApplication('binary gibberish', 'octet-stream')
102 message = self.makeMailmanMessage(
103 self.mm_list, self.subscriber_email, 'subject', 'content',
104 attachment=attachment)
105 moderated_message = LPSize.truncated_message(message)
106 parts = [part for part in moderated_message.walk()]
107 types = [part.get_content_type() for part in parts]
108 self.assertEqual(['multipart/mixed', 'text/plain'], types)
109
110 def test_small_text_is_preserved(self):
111 # Text parts below the limit are unchanged.
112 message = self.makeMailmanMessage(
113 self.mm_list, self.subscriber_email, 'subject', 'content')
114 moderated_message = LPSize.truncated_message(message, limit=1000)
115 parts = [part for part in moderated_message.walk()]
116 types = [part.get_content_type() for part in parts]
117 self.assertEqual(['multipart/mixed', 'text/plain'], types)
118 self.assertEqual('content', parts[1].get_payload())
119
120 def test_large_text_is_truncated(self):
121 # Text parts above the limit are truncated.
122 message = self.makeMailmanMessage(
123 self.mm_list, self.subscriber_email, 'subject', 'content excess')
124 moderated_message = LPSize.truncated_message(message, limit=7)
125 parts = [part for part in moderated_message.walk()]
126 types = [part.get_content_type() for part in parts]
127 self.assertEqual(['multipart/mixed', 'text/plain'], types)
128 self.assertEqual(
129 'content\n[truncated for moderation]', parts[1].get_payload())