← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/more-pop-notifications into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/more-pop-notifications into lp:launchpad.

Commit message:
Use pop_notifications in more places to avoid fragile patterns with stub.test_emails, and introduce an assertEmailQueueLength helper.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/more-pop-notifications/+merge/270371

This is kind of yak-shaving.  I've been making some progress on https://bugs.launchpad.net/launchpad/+bug/29744, but in order to turn off immediate mail delivery I need to make sure that doing so isn't going to hide test problems.  A number of tests do something like "code_under_test(); self.assertEqual([], stub.test_emails);", and not all of them make sure to commit beforehand.  With immediate mail delivery disabled, this could hide bugs because test_emails could be empty until the transaction is committed.  The pop_notifications interface is in most situations better (it commits the transaction first by default, and it saves you having to clear test_emails yourself).

I've gone through the cases where test_emails was used directly and converted most of them to pop_notifications or something based on it.  This branch contains only changes to files that contained unsafe uses of test_emails; the rest can come later.

Checking the length of the mail queue is a common pattern in tests, so I introduced an assertEmailQueueLength helper for this.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/more-pop-notifications into lp:launchpad.
=== modified file 'lib/lp/archivepublisher/tests/test_generate_ppa_htaccess.py'
--- lib/lp/archivepublisher/tests/test_generate_ppa_htaccess.py	2014-10-29 06:04:09 +0000
+++ lib/lp/archivepublisher/tests/test_generate_ppa_htaccess.py	2015-09-08 09:46:54 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2010 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2015 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Test the generate_ppa_htaccess.py script. """
@@ -33,7 +33,6 @@
 from lp.registry.interfaces.teammembership import TeamMembershipStatus
 from lp.services.config import config
 from lp.services.log.logger import BufferLogger
-from lp.services.mail import stub
 from lp.services.osutils import (
     ensure_directory_exists,
     remove_if_exists,
@@ -281,9 +280,7 @@
             self.assertNotDeactivated(tokens[person])
 
         # Ensure that a cancellation email was sent.
-        num_emails = len(stub.test_emails)
-        self.assertEqual(
-            num_emails, 1, "Expected 1 email, got %s" % num_emails)
+        self.assertEmailQueueLength(1)
 
         # Promiscuous_person now leaves team1, but does not lose his
         # token because he's also in team2. No other tokens are
@@ -298,9 +295,7 @@
             self.assertNotDeactivated(tokens[person])
 
         # Ensure that a cancellation email was not sent.
-        num_emails = len(stub.test_emails)
-        self.assertEqual(
-            num_emails, 0, "Expected no emails, got %s" % num_emails)
+        self.assertEmailQueueLength(0)
 
         # Team 2 now leaves parent_team, and all its members lose their
         # tokens.
@@ -486,10 +481,6 @@
 
         script.sendCancellationEmail(tokens[0])
 
-        num_emails = len(stub.test_emails)
-        self.assertEqual(
-            num_emails, 1, "Expected 1 email, got %s" % num_emails)
-
         [email] = pop_notifications()
         self.assertEqual(
             email['Subject'],
@@ -537,9 +528,7 @@
 
         script.sendCancellationEmail(token)
 
-        num_emails = len(stub.test_emails)
-        self.assertEqual(
-            num_emails, 0, "Expected 0 emails, got %s" % num_emails)
+        self.assertEmailQueueLength(0)
 
     def test_getTimeToSyncFrom(self):
         # Sync from 1s before previous start to catch anything made during the

=== modified file 'lib/lp/archiveuploader/tests/test_ppauploadprocessor.py'
--- lib/lp/archiveuploader/tests/test_ppauploadprocessor.py	2015-08-26 13:41:21 +0000
+++ lib/lp/archiveuploader/tests/test_ppauploadprocessor.py	2015-09-08 09:46:54 +0000
@@ -9,7 +9,7 @@
 
 __metaclass__ = type
 
-from email import message_from_string
+from operator import itemgetter
 import os
 import shutil
 
@@ -27,7 +27,6 @@
 from lp.services.config import config
 from lp.services.database.constants import UTC_NOW
 from lp.services.librarian.interfaces import ILibraryFileAliasSet
-from lp.services.mail import stub
 from lp.soyuz.enums import (
     PackagePublishingStatus,
     PackageUploadStatus,
@@ -43,6 +42,7 @@
 from lp.soyuz.tests.fakepackager import FakePackager
 from lp.soyuz.tests.test_publishing import SoyuzTestPublisher
 from lp.testing.dbuser import switch_dbuser
+from lp.testing.mail_helpers import pop_notifications
 
 
 class TestPPAUploadProcessorBase(TestUploadProcessorBase):
@@ -87,20 +87,19 @@
             "X-Launchpad-PPA" header; it defaults to "name16" and should be
             explicitly set to None for non-PPA or rejection notifications.
         """
-        for item in expected:
+        notifications = self.assertEmailQueueLength(
+            len(expected), sort_key=itemgetter('X-Envelope-To'))
+
+        for item, msg in zip(expected, notifications):
             recipient = item.get("recipient", self.name16_recipient)
             contents = item.get("contents", [])
             ppa_header = item.get("ppa_header", "name16")
 
-            from_addr, to_addrs, raw_msg = stub.test_emails.pop()
-            msg = message_from_string(raw_msg)
-
             # This is now a non-multipart message.
             self.assertFalse(msg.is_multipart())
             body = msg.get_payload(decode=True)
 
-            clean_recipients = [r.strip() for r in to_addrs]
-            self.assertContentEqual([recipient], clean_recipients)
+            self.assertEqual(recipient, msg['X-Envelope-To'])
 
             subject = "Subject: %s\n" % msg['Subject']
             body = subject + body
@@ -112,10 +111,6 @@
                 self.assertIn('X-Launchpad-PPA', msg.keys())
                 self.assertEqual(msg['X-Launchpad-PPA'], ppa_header)
 
-        self.assertEqual(
-            [], stub.test_emails,
-            "%d emails left over" % len(stub.test_emails))
-
     def checkFilesRestrictedInLibrarian(self, queue_item, condition):
         """Check the libraryfilealias restricted flag.
 
@@ -300,8 +295,8 @@
         self.assertEqual(
             self.uploadprocessor.last_processed_upload.queue_root.status,
             PackageUploadStatus.DONE)
-        # Consume the test email so the assertion futher down does not fail.
-        _from_addr, _to_addrs, _raw_msg = stub.test_emails.pop()
+        # Consume the test email so the assertion further down does not fail.
+        pop_notifications()
 
         # The SourcePackageRelease still has a component of universe:
         pub_foo = self.name16.archive.getPublishedSources(name=u"bar").one()
@@ -320,8 +315,7 @@
             self.build_uploadprocessor, upload_dir, build=build)
 
         # No mails are sent for successful binary uploads.
-        self.assertEqual(len(stub.test_emails), 0,
-                         "Unexpected email generated on binary upload.")
+        self.assertEmailQueueLength(0)
 
         # Publish the binary.
         [queue_item] = self.breezy.getPackageUploads(
@@ -477,7 +471,7 @@
             name12.preferredemail.email)
         self.assertEmails([
             {"ppa_header": "cprov", "recipient": expected_recipient}
-            for expected_recipient in reversed(sorted(expected_recipients))])
+            for expected_recipient in sorted(expected_recipients)])
 
     def testPPADistroSeriesOverrides(self):
         """It's possible to override target distroseries of PPA uploads.
@@ -850,9 +844,7 @@
         # Please note: this upload goes to the Ubuntu main archive.
         upload_dir = self.queueUpload("bar_1.0-10")
         self.processUpload(self.uploadprocessor, upload_dir)
-        # Discard the announcement email and check the acceptance message
-        # content.
-        stub.test_emails.pop()
+        pop_notifications()
 
         self.assertEqual(
             self.uploadprocessor.last_processed_upload.queue_root.status,
@@ -991,9 +983,9 @@
             u'Files specified in DSC are broken or missing, skipping package '
             u'unpack verification.')
 
-        # Also, the email generated should be sane.
-        from_addr, to_addrs, raw_msg = stub.test_emails.pop()
-        msg = message_from_string(raw_msg)
+        # Also, the email generated should be sane.  Any of the multiple
+        # notifications will do.
+        msg = pop_notifications()[-1]
         body = msg.get_payload(decode=True)
 
         self.assertTrue(
@@ -1015,8 +1007,7 @@
         self.processUpload(self.uploadprocessor, upload_dir)
 
         # The email generated should be sane.
-        from_addr, to_addrs, raw_msg = stub.test_emails.pop()
-        msg = message_from_string(raw_msg)
+        [msg] = pop_notifications()
         body = msg.get_payload(decode=True)
 
         self.assertTrue(
@@ -1148,7 +1139,7 @@
         queue_item.setAccepted()
         queue_item.realiseUpload()
         transaction.commit()
-        stub.test_emails.pop()
+        pop_notifications()
 
         # Now upload a 3.0 (quilt) source with missing orig*.tar.* to a
         # PPA. All of the missing files will be retrieved from the

=== modified file 'lib/lp/archiveuploader/tests/test_uploadprocessor.py'
--- lib/lp/archiveuploader/tests/test_uploadprocessor.py	2015-08-25 14:05:24 +0000
+++ lib/lp/archiveuploader/tests/test_uploadprocessor.py	2015-09-08 09:46:54 +0000
@@ -9,7 +9,6 @@
     "TestUploadProcessorBase",
     ]
 
-from email import message_from_string
 import os
 import shutil
 from StringIO import StringIO
@@ -65,7 +64,6 @@
     BufferLogger,
     DevNullLogger,
     )
-from lp.services.mail import stub
 from lp.soyuz.enums import (
     ArchivePermissionType,
     ArchivePurpose,
@@ -357,12 +355,16 @@
             over after checking the ones in expected.
         """
 
-        for item in expected:
+        if allow_leftover:
+            notifications = pop_notifications()
+            self.assertTrue(len(notifications) >= len(expected))
+        else:
+            notifications = self.assertEmailQueueLength(len(expected))
+
+        for item, msg in zip(expected, notifications):
             recipient = item.get("recipient", self.name16_recipient)
             contents = item.get("contents", [])
 
-            from_addr, to_addrs, raw_msg = stub.test_emails.pop()
-            msg = message_from_string(raw_msg)
             # This is now a MIMEMultipart message.
             body = msg.get_payload(0)
             body = body.get_payload(decode=True)
@@ -370,8 +372,7 @@
             # Only check the recipient if the caller didn't explicitly pass
             # "recipient": None.
             if recipient is not None:
-                clean_recipients = [r.strip() for r in to_addrs]
-                self.assertContentEqual([recipient], clean_recipients)
+                self.assertEqual(recipient, msg['X-Envelope-To'])
 
             subject = "Subject: %s\n" % msg['Subject']
             body = subject + body
@@ -381,11 +382,6 @@
                     content in body,
                     "Expect: '%s'\nGot:\n%s" % (content, body))
 
-        if not allow_leftover:
-            self.assertEqual(
-                [], stub.test_emails,
-                "%d emails left over" % len(stub.test_emails))
-
     def PGPSignatureNotPreserved(self, archive=None):
         """PGP signatures should be removed from .changes files.
 
@@ -423,13 +419,11 @@
 
     def _checkPartnerUploadEmailSuccess(self):
         """Ensure partner uploads generate the right email."""
-        from_addr, to_addrs, raw_msg = stub.test_emails.pop()
-        foo_bar = "foo.bar@xxxxxxxxxxxxx"
-        self.assertEqual([e.strip() for e in to_addrs], [foo_bar])
+        [msg] = pop_notifications()
+        self.assertEqual("foo.bar@xxxxxxxxxxxxx", msg["X-Envelope-To"])
         self.assertTrue(
-            "rejected" not in raw_msg,
-            "Expected acceptance email not rejection. Actually Got:\n%s"
-                % raw_msg)
+            "rejected" not in str(msg),
+            "Expected acceptance email not rejection. Actually Got:\n%s" % msg)
 
     def testInstantiate(self):
         """UploadProcessor should instantiate"""
@@ -563,13 +557,13 @@
         self.processUpload(uploadprocessor, upload_dir)
 
         # Check the mailer stub has a rejection email for Daniel
-        from_addr, to_addrs, raw_msg = stub.test_emails.pop()
+        [msg] = pop_notifications()
         # This is now a MIMEMultipart message.
-        msg = message_from_string(raw_msg)
         body = msg.get_payload(0)
         body = body.get_payload(decode=True)
 
-        self.assertEqual(["daniel.silverstone@xxxxxxxxxxxxx"], to_addrs)
+        self.assertEqual(
+            "daniel.silverstone@xxxxxxxxxxxxx", msg["X-Envelope-To"])
         self.assertTrue("Unhandled exception processing upload: Exception "
                         "raised by BrokenUploadPolicy for testing."
                         in body)
@@ -599,15 +593,14 @@
         self.processUpload(uploadprocessor, upload_dir)
 
         # Check it went ok to the NEW queue and all is going well so far.
-        foo_bar = "foo.bar@xxxxxxxxxxxxx"
         daniel = "daniel.silverstone@xxxxxxxxxxxxx"
-        for expected_to_addr in foo_bar, daniel:
-            from_addr, to_addrs, raw_msg = stub.test_emails.pop()
-            to_addrs = [e.strip() for e in to_addrs]
-            self.assertContentEqual(to_addrs, [expected_to_addr])
+        foo_bar = "foo.bar@xxxxxxxxxxxxx"
+        notifications = self.assertEmailQueueLength(2)
+        for expected_to_addr, msg in zip((daniel, foo_bar), notifications):
+            self.assertEqual(expected_to_addr, msg["X-Envelope-To"])
             self.assertTrue(
-                "NEW" in raw_msg, "Expected email containing 'NEW', got:\n%s"
-                % raw_msg)
+                "NEW" in str(msg),
+                "Expected email containing 'NEW', got:\n%s" % msg)
 
         # Accept and publish the upload.
         # This is required so that the next upload of a later version of
@@ -635,13 +628,12 @@
         self.processUpload(uploadprocessor, upload_dir)
 
         # Verify we get an email talking about awaiting approval.
-        for expected_to_addr in foo_bar, daniel:
-            from_addr, to_addrs, raw_msg = stub.test_emails.pop()
-            to_addrs = [e.strip() for e in to_addrs]
-            self.assertContentEqual(to_addrs, [expected_to_addr])
-            self.assertTrue("Waiting for approval" in raw_msg,
-                            "Expected an 'upload awaits approval' email.\n"
-                            "Got:\n%s" % raw_msg)
+        notifications = self.assertEmailQueueLength(2)
+        for expected_to_addr, msg in zip((daniel, foo_bar), notifications):
+            self.assertEqual(expected_to_addr, msg["X-Envelope-To"])
+            self.assertTrue(
+                "Waiting for approval" in str(msg),
+                "Expected an 'upload awaits approval' email.\nGot:\n%s" % msg)
 
         # And verify that the queue item is in the unapproved state.
         queue_items = self.breezy.getPackageUploads(
@@ -918,10 +910,10 @@
         self.processUpload(uploadprocessor, upload_dir)
 
         # Check that it was rejected appropriately.
-        from_addr, to_addrs, raw_msg = stub.test_emails.pop()
-        self.assertTrue(
-            "Partner archive for distro '%s' not found" % self.ubuntu.name
-                in raw_msg)
+        [msg] = pop_notifications()
+        self.assertIn(
+            "Partner archive for distro '%s' not found" % self.ubuntu.name,
+            str(msg))
 
     def testMixedPartnerUploadFails(self):
         """Uploads with partner and non-partner files are rejected.
@@ -937,13 +929,12 @@
         self.processUpload(uploadprocessor, upload_dir)
 
         # Check that it was rejected.
-        from_addr, to_addrs, raw_msg = stub.test_emails.pop()
-        foo_bar = "foo.bar@xxxxxxxxxxxxx"
-        self.assertEqual([e.strip() for e in to_addrs], [foo_bar])
+        [msg] = pop_notifications()
+        self.assertEqual("foo.bar@xxxxxxxxxxxxx", msg["X-Envelope-To"])
         self.assertTrue(
-            "Cannot mix partner files with non-partner." in raw_msg,
+            "Cannot mix partner files with non-partner." in str(msg),
             "Expected email containing 'Cannot mix partner files with "
-            "non-partner.', got:\n%s" % raw_msg)
+            "non-partner.', got:\n%s" % msg)
 
     def testPartnerReusingOrigFromPartner(self):
         """Partner uploads reuse 'orig.tar.gz' from the partner archive."""
@@ -977,8 +968,7 @@
         self.processUpload(uploadprocessor, upload_dir)
         # Discard the announcement email and check the acceptance message
         # content.
-        from_addr, to_addrs, raw_msg = stub.test_emails.pop()
-        msg = message_from_string(raw_msg)
+        msg = pop_notifications()[-1]
         # This is now a MIMEMultipart message.
         body = msg.get_payload(0)
         body = body.get_payload(decode=True)
@@ -1095,11 +1085,10 @@
         self.processUpload(uploadprocessor, upload_dir)
 
         # Check it went ok to the NEW queue and all is going well so far.
-        from_addr, to_addrs, raw_msg = stub.test_emails.pop()
+        [msg] = pop_notifications()
         self.assertTrue(
-            "NEW" in raw_msg,
-            "Expected email containing 'NEW', got:\n%s"
-            % raw_msg)
+            "NEW" in str(msg),
+            "Expected email containing 'NEW', got:\n%s" % msg)
 
         # Accept and publish the upload.
         partner_archive = getUtility(IArchiveSet).getByDistroPurpose(
@@ -1198,10 +1187,10 @@
         # Check it is rejected.
         expect_msg = ("Partner uploads must be for the RELEASE or "
                       "PROPOSED pocket.")
-        from_addr, to_addrs, raw_msg = stub.test_emails.pop()
+        [msg] = pop_notifications()
         self.assertTrue(
-            expect_msg in raw_msg,
-            "Expected email with %s, got:\n%s" % (expect_msg, raw_msg))
+            expect_msg in str(msg),
+            "Expected email with %s, got:\n%s" % (expect_msg, msg))
 
         # And an oops should be filed for the error.
         error_report = self.oopses[new_index]
@@ -1428,10 +1417,9 @@
         upload_dir = self.queueUpload("bar_1.0-1_lzma")
         self.processUpload(uploadprocessor, upload_dir)
         # Make sure it went ok:
-        from_addr, to_addrs, raw_msg = stub.test_emails.pop()
-        self.assertTrue(
-            "rejected" not in raw_msg,
-            "Failed to upload bar source:\n%s" % raw_msg)
+        [msg] = pop_notifications()
+        self.assertFalse(
+            "rejected" in str(msg), "Failed to upload bar source:\n%s" % msg)
         self.publishPackage("bar", "1.0-1")
         # Clear out emails generated during upload.
         pop_notifications()
@@ -1441,11 +1429,7 @@
         self.processUpload(uploadprocessor, upload_dir)
 
         # Successful binary uploads won't generate any email.
-        if len(stub.test_emails) != 0:
-            from_addr, to_addrs, raw_msg = stub.test_emails.pop()
-        self.assertEqual(
-            len(stub.test_emails), 0,
-            "Expected no emails!  Actually got:\n%s" % raw_msg)
+        self.assertEmailQueueLength(0)
 
         # Check in the queue to see if it really made it:
         queue_items = self.breezy.getPackageUploads(
@@ -1472,10 +1456,9 @@
         upload_dir = self.queueUpload("bar_1.0-1_xz")
         self.processUpload(uploadprocessor, upload_dir)
         # Make sure it went ok:
-        from_addr, to_addrs, raw_msg = stub.test_emails.pop()
-        self.assertTrue(
-            "rejected" not in raw_msg,
-            "Failed to upload bar source:\n%s" % raw_msg)
+        [msg] = pop_notifications()
+        self.assertFalse(
+            "rejected" in str(msg), "Failed to upload bar source:\n%s" % msg)
         self.publishPackage("bar", "1.0-1")
         # Clear out emails generated during upload.
         pop_notifications()
@@ -1485,11 +1468,7 @@
         self.processUpload(uploadprocessor, upload_dir)
 
         # Successful binary uploads won't generate any email.
-        if len(stub.test_emails) != 0:
-            from_addr, to_addrs, raw_msg = stub.test_emails.pop()
-        self.assertEqual(
-            len(stub.test_emails), 0,
-            "Expected no emails!  Actually got:\n%s" % raw_msg)
+        self.assertEmailQueueLength(0)
 
         # Check in the queue to see if it really made it:
         queue_items = self.breezy.getPackageUploads(
@@ -1683,8 +1662,8 @@
         self.processUpload(uploadprocessor, upload_dir)
 
         # Check that it failed (permissions were granted for wrong series).
-        from_addr, to_addrs, raw_msg = stub.test_emails.pop()
-        msg = message_from_string(raw_msg)
+        # Any of the multiple notifications will do.
+        msg = pop_notifications()[-1]
         self.assertEqual(
             msg['Subject'], '[ubuntu] bar_1.0-2_source.changes (Rejected)')
 
@@ -1747,16 +1726,16 @@
         expected = []
         expected.append({
             "contents": base_contents + [
-                "You are receiving this email because you made this upload."],
-            "recipient": "foo.bar@xxxxxxxxxxxxx",
-            })
-        expected.append({
-            "contents": base_contents + [
                 "You are receiving this email because you are the most "
                     "recent person",
                 "listed in this package's changelog."],
             "recipient": "daniel.silverstone@xxxxxxxxxxxxx",
             })
+        expected.append({
+            "contents": base_contents + [
+                "You are receiving this email because you made this upload."],
+            "recipient": "foo.bar@xxxxxxxxxxxxx",
+            })
         self.assertEmails(expected)
 
     def test30QuiltUploadToUnsupportingSeriesIsRejected(self):
@@ -1774,11 +1753,11 @@
         upload_dir = self.queueUpload("bar_1.0-1_3.0-quilt")
         self.processUpload(uploadprocessor, upload_dir)
         # Make sure it was rejected.
-        from_addr, to_addrs, raw_msg = stub.test_emails.pop()
+        [msg] = pop_notifications()
         self.assertTrue(
             "bar_1.0-1.dsc: format '3.0 (quilt)' is not permitted in "
-            "breezy." in raw_msg,
-            "Source was not rejected properly:\n%s" % raw_msg)
+            "breezy." in str(msg),
+            "Source was not rejected properly:\n%s" % msg)
 
     def test30QuiltUpload(self):
         """Ensure that 3.0 (quilt) uploads work properly. """
@@ -1792,10 +1771,9 @@
         upload_dir = self.queueUpload("bar_1.0-1_3.0-quilt")
         self.processUpload(uploadprocessor, upload_dir)
         # Make sure it went ok:
-        from_addr, to_addrs, raw_msg = stub.test_emails.pop()
-        self.assertTrue(
-            "rejected" not in raw_msg,
-            "Failed to upload bar source:\n%s" % raw_msg)
+        [msg] = pop_notifications()
+        self.assertFalse(
+            "rejected" in str(msg), "Failed to upload bar source:\n%s" % msg)
         spph = self.publishPackage("bar", "1.0-1")
 
         self.assertEquals(
@@ -1827,10 +1805,9 @@
         upload_dir = self.queueUpload("bar_1.0-1_3.0-quilt")
         self.processUpload(uploadprocessor, upload_dir)
         # Make sure it went ok:
-        from_addr, to_addrs, raw_msg = stub.test_emails.pop()
-        self.assertTrue(
-            "rejected" not in raw_msg,
-            "Failed to upload bar source:\n%s" % raw_msg)
+        [msg] = pop_notifications()
+        self.assertFalse(
+            "rejected" in str(msg), "Failed to upload bar source:\n%s" % msg)
         self.publishPackage("bar", "1.0-1")
 
         # Upload another source sharing the same (component) orig.
@@ -1865,10 +1842,9 @@
         upload_dir = self.queueUpload("bar_1.0_3.0-native")
         self.processUpload(uploadprocessor, upload_dir)
         # Make sure it went ok:
-        from_addr, to_addrs, raw_msg = stub.test_emails.pop()
-        self.assertTrue(
-            "rejected" not in raw_msg,
-            "Failed to upload bar source:\n%s" % raw_msg)
+        [msg] = pop_notifications()
+        self.assertFalse(
+            "rejected" in str(msg), "Failed to upload bar source:\n%s" % msg)
         spph = self.publishPackage("bar", "1.0")
 
         self.assertEquals(
@@ -1890,11 +1866,11 @@
         upload_dir = self.queueUpload("bar_1.0-1_1.0-bzip2")
         self.processUpload(uploadprocessor, upload_dir)
         # Make sure it was rejected.
-        from_addr, to_addrs, raw_msg = stub.test_emails.pop()
+        [msg] = pop_notifications()
         self.assertTrue(
             "bar_1.0-1.dsc: is format 1.0 but uses bzip2 compression."
-            in raw_msg,
-            "Source was not rejected properly:\n%s" % raw_msg)
+            in str(msg),
+            "Source was not rejected properly:\n%s" % msg)
 
     def testUploadToWrongPocketIsRejected(self):
         # Uploads to the wrong pocket are rejected.
@@ -1922,16 +1898,16 @@
         expected = []
         expected.append({
             "contents": base_contents + [
-                "You are receiving this email because you made this upload."],
-            "recipient": "foo.bar@xxxxxxxxxxxxx",
-            })
-        expected.append({
-            "contents": base_contents + [
                 "You are receiving this email because you are the most "
                     "recent person",
                 "listed in this package's changelog."],
             "recipient": "daniel.silverstone@xxxxxxxxxxxxx",
             })
+        expected.append({
+            "contents": base_contents + [
+                "You are receiving this email because you made this upload."],
+            "recipient": "foo.bar@xxxxxxxxxxxxx",
+            })
         self.assertEmails(expected)
 
     def testPGPSignatureNotPreserved(self):
@@ -1969,7 +1945,7 @@
         self.assertEqual(UploadStatusEnum.REJECTED, result)
         self.assertLogContains(
             "INFO Failed to parse changes file")
-        self.assertEqual(len(stub.test_emails), 0)
+        self.assertEmailQueueLength(0)
         self.assertEqual([], self.oopses)
 
     def test_ddeb_upload_overrides(self):
@@ -2137,13 +2113,13 @@
                 queue_entry=leaf_name)
         self.options.context = 'buildd'
         self.options.builds = True
-        last_stub_mail_count = len(stub.test_emails)
+        pop_notifications()
         BuildUploadHandler(self.uploadprocessor, self.incoming_folder,
             leaf_name).process()
         self.layer.txn.commit()
         # No emails are sent on success
-        self.assertEquals(len(stub.test_emails), last_stub_mail_count)
-        self.assertEquals(BuildStatus.FULLYBUILT, build.status)
+        self.assertEmailQueueLength(0)
+        self.assertEqual(BuildStatus.FULLYBUILT, build.status)
         # Upon full build the upload log is unset.
         self.assertIs(None, build.upload_log)
 
@@ -2192,7 +2168,7 @@
         See bug 778437.
         """
         self.doSuccessRecipeBuild()
-        self.assertEquals(len(pop_notifications()), 0, pop_notifications)
+        self.assertEmailQueueLength(0)
 
     def doFailureRecipeBuild(self):
         # A source package recipe build will fail if no files are present.
@@ -2284,7 +2260,7 @@
             status=PackageUploadStatus.ACCEPTED,
             version=u"1.0-1", name=u"bar")
         queue_item.setDone()
-        stub.test_emails = []
+        pop_notifications()
 
         build.buildqueue_record.markAsBuilding(self.factory.makeBuilder())
         build.updateStatus(status)
@@ -2300,7 +2276,7 @@
             "bar_1.0-1_binary", queue_entry=leaf_name)
         self.options.context = 'buildd'
         self.options.builds = True
-        self.assertEqual([], stub.test_emails)
+        self.assertEmailQueueLength(0)
         BuildUploadHandler(self.uploadprocessor, self.incoming_folder,
             leaf_name).process()
         self.layer.txn.commit()

=== modified file 'lib/lp/bugs/doc/externalbugtracker-debbugs.txt'
--- lib/lp/bugs/doc/externalbugtracker-debbugs.txt	2015-03-13 19:05:50 +0000
+++ lib/lp/bugs/doc/externalbugtracker-debbugs.txt	2015-09-08 09:46:54 +0000
@@ -596,15 +596,15 @@
     ...     '<123456@xxxxxxxxxxxxx>')
     '<123456@xxxxxxxxxxxxx>'
 
-We can look in the test_emails list for the mail that would have been
-sent.
-
-    >>> from lp.services.mail import stub
-    >>> recipent, to_addrs, comment_email = stub.test_emails.pop()
-    >>> print to_addrs
-    ['1234@xxxxxxxxxxx']
-
-    >>> print comment_email
+We can look for the mail that would have been sent.
+
+    >>> from lp.testing.mail_helpers import pop_notifications
+    >>> [msg] = pop_notifications()
+    >>> print msg['X-Envelope-To']
+    1234@xxxxxxxxxxx
+
+    >>> print str(msg)
+    From ...
     Content-Type...
     Message-Id: <123456@xxxxxxxxxxxxx>
     To: 1234@xxxxxxxxxxx

=== modified file 'lib/lp/bugs/mail/tests/test_handler.py'
--- lib/lp/bugs/mail/tests/test_handler.py	2012-09-17 16:13:40 +0000
+++ lib/lp/bugs/mail/tests/test_handler.py	2015-09-08 09:46:54 +0000
@@ -1,11 +1,10 @@
-# Copyright 2010-2012 Canonical Ltd.  This software is licensed under the
+# Copyright 2010-2015 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Test MaloneHandler."""
 
 __metaclass__ = type
 
-import email
 import time
 
 import transaction
@@ -32,7 +31,6 @@
 from lp.registry.enums import BugSharingPolicy
 from lp.services.config import config
 from lp.services.identity.interfaces.emailaddress import EmailAddressStatus
-from lp.services.mail import stub
 from lp.services.webapp.authorization import LaunchpadSecurityPolicy
 from lp.testing import (
     celebrity_logged_in,
@@ -124,12 +122,11 @@
         response = handler.extractAndAuthenticateCommands(
             message, 'help@xxxxxxxxxxxxxxxxxx')
         mail_handled, add_comment_to_bug, commands = response
-        self.assertEquals(mail_handled, True)
-        emails = self.getSentMail()
-        self.assertEquals(1, len(emails))
-        self.assertEquals(['non@xxxxxx'], emails[0][1])
-        self.assertTrue(
-            'Subject: Launchpad Bug Tracker Email Interface' in emails[0][2])
+        self.assertTrue(mail_handled)
+        emails = self.assertEmailQueueLength(1)
+        self.assertEqual('non@xxxxxx', emails[0]['X-Envelope-To'])
+        self.assertEqual(
+            'Launchpad Bug Tracker Email Interface Help', emails[0]['Subject'])
 
     def test_mailToHelpFromUnknownUser(self):
         """Mail from people of no account to help@ is simply dropped.
@@ -140,8 +137,8 @@
         mail_handled, add_comment_to_bug, commands = \
             handler.extractAndAuthenticateCommands(message,
                 'help@xxxxxxxxxxxxxxxxxx')
-        self.assertEquals(mail_handled, True)
-        self.assertEquals(self.getSentMail(), [])
+        self.assertTrue(mail_handled)
+        self.assertEmailQueueLength(0)
 
     def test_mailToHelp(self):
         """Mail to help@ generates a help command."""
@@ -152,19 +149,11 @@
             mail_handled, add_comment_to_bug, commands = \
                 handler.extractAndAuthenticateCommands(message,
                     'help@xxxxxxxxxxxxxxxxxx')
-        self.assertEquals(mail_handled, True)
-        emails = self.getSentMail()
-        self.assertEquals(1, len(emails))
-        self.assertEquals([message['From']], emails[0][1])
-        self.assertTrue(
-            'Subject: Launchpad Bug Tracker Email Interface' in emails[0][2])
-
-    def getSentMail(self):
-        # Sending mail is (unfortunately) a side effect of parsing the
-        # commands, and unfortunately you must commit the transaction to get
-        # them sent.
-        transaction.commit()
-        return stub.test_emails[:]
+        self.assertEqual(mail_handled, True)
+        emails = self.assertEmailQueueLength(1)
+        self.assertEqual(message['From'], emails[0]['X-Envelope-To'])
+        self.assertEqual(
+            'Launchpad Bug Tracker Email Interface Help', emails[0]['Subject'])
 
     def getFailureForMessage(self, to_address, from_address=None, body=None):
         mail = self.factory.makeSignedMessage(
@@ -683,22 +672,6 @@
         self.timestamp = timestamp
 
 
-def get_last_email():
-    from_addr, to_addrs, raw_message = stub.test_emails[-1]
-    sent_msg = email.message_from_string(raw_message)
-    error_mail, original_mail = sent_msg.get_payload()
-    # clear the emails so we don't accidentally get one from a previous test
-    return dict(
-        subject=sent_msg['Subject'],
-        body=error_mail.get_payload(decode=True))
-
-
-BAD_SIGNATURE_TIMESTAMP_MESSAGE = (
-    'The message you sent included commands to modify the bug '
-    'report, but the\nsignature was (apparently) generated too far '
-    'in the past or future.')
-
-
 class TestSignatureTimestampValidation(TestCaseWithFactory):
     """GPG signature timestamps are checked for emails containing commands."""
 
@@ -715,10 +688,9 @@
         handler = MaloneHandler()
         with person_logged_in(self.factory.makePerson()):
             handler.process(msg, msg['To'])
-        transaction.commit()
         # Since there were no commands in the poorly-timestamped message, no
         # error emails were generated.
-        self.assertEqual(stub.test_emails, [])
+        self.assertEmailQueueLength(0)
 
     def test_bad_timestamp_but_no_commands(self):
         # If an email message's GPG signature's timestamp is too far in the
@@ -732,10 +704,9 @@
         msg.signature = FakeSignature(timestamp=now + one_week)
         handler = MaloneHandler()
         # Clear old emails before potentially generating more.
-        del stub.test_emails[:]
+        pop_notifications()
         with person_logged_in(self.factory.makePerson()):
             handler.process(msg, msg['To'])
-        transaction.commit()
         # Since there were no commands in the poorly-timestamped message, no
         # error emails were generated.
-        self.assertEqual(stub.test_emails, [])
+        self.assertEmailQueueLength(0)

=== modified file 'lib/lp/services/mail/tests/incomingmail.txt'
--- lib/lp/services/mail/tests/incomingmail.txt	2015-07-08 16:05:11 +0000
+++ lib/lp/services/mail/tests/incomingmail.txt	2015-09-08 09:46:54 +0000
@@ -121,8 +121,9 @@
 wasn't a handler registered for that domain, an OOPS was recorded with
 a link to the original message.
 
-    >>> from lp.services.mail import stub
-    >>> print stub.test_emails[-1][2]
+    >>> from lp.testing.mail_helpers import pop_notifications
+    >>> print str(pop_notifications()[-1])
+    From ...
     Content-Type: multipart/mixed...
     ...
     To: Sample Person <test@xxxxxxxxxxxxx>
@@ -140,8 +141,6 @@
     Subject: Signed Email
     ...
 
-    >>> stub.test_emails = []
-
 ---------------------------------------------
 Mail from Persons not registered in Launchpad
 ---------------------------------------------
@@ -156,7 +155,7 @@
     >>> msgid not in foo_handler.handledMails
     True
 
-    >>> stub.test_emails = []
+    >>> _ = pop_notifications()
 
 However, bar_handler specifies that it can handle such emails:
 
@@ -171,7 +170,7 @@
     >>> msgid in bar_handler.handledMails
     True
 
-    >>> stub.test_emails = []
+    >>> _ = pop_notifications()
 
 ---------------------------------------------------------
 Mail from Persons with with an inactive Launchpad account
@@ -260,7 +259,8 @@
 An exception is raised, an OOPS is recorded, and an email is sent back
 to the user, citing the OOPS ID, with the original message attached.
 
-    >>> print stub.test_emails[-1][2]
+    >>> print str(pop_notifications()[-1])
+    From ...
     Content-Type: multipart/mixed...
     ...
     To: Foo Bar <foo.bar@xxxxxxxxxxxxx>
@@ -279,8 +279,6 @@
     Subject: doesn't matter
     ...
 
-    >>> stub.test_emails = []
-
 Unauthorized exceptions, which are ignored for the purpose of OOPS
 reporting in the web interface, are not ignored in the email interface.
 
@@ -305,7 +303,8 @@
     ...
     Unauthorized
 
-    >>> print stub.test_emails[-1][2]
+    >>> print str(pop_notifications()[-1])
+    From ...
     Content-Type: multipart/mixed...
     ...
     To: Foo Bar <foo.bar@xxxxxxxxxxxxx>
@@ -324,8 +323,6 @@
     Subject: doesn't matter
     ...
 
-    >>> stub.test_emails = []
-
 -------------
 DB exceptions
 -------------
@@ -382,6 +379,7 @@
 There is only one mail left in the mail box - the one sent back to
 the user reporting the error:
 
+    >>> from lp.services.mail import stub
     >>> len(stub.test_emails)
     1
 
@@ -412,7 +410,7 @@
     2
 
     >>> LibrarianLayer.reveal()
-    >>> stub.test_emails = []
+    >>> _ = pop_notifications()
 
 ----------------
 Handling bounces
@@ -425,7 +423,6 @@
 
 Emails with an empty Return-Path header should be dropped:
 
-    >>> stub.test_emails = []
     >>> msg = read_test_message('signed_detached.txt')
     >>> msg.replace_header('To', '123@xxxxxxx')
     >>> msg['Return-Path'] = '<>'
@@ -437,8 +434,8 @@
 Since this happens way too often, as we seem to get more spam than
 legitimate email, an email is not sent about it to the errors-list.
 
-    >>> len(stub.test_emails)
-    0
+    >>> pop_notifications()
+    []
 
 If the content type is multipart/report, it's most likely a DSN
 (RFC 3464), so those get dropped as well. Normally a DSN should have an
@@ -455,8 +452,8 @@
     >>> msgid in foo_handler.handledMails
     False
 
-    >>> len(stub.test_emails)
-    0
+    >>> pop_notifications()
+    []
 
 Email with the Precedence header are probably from an auto-responder or
 another robot. We also drop those.
@@ -470,8 +467,8 @@
     >>> msgid in foo_handler.handledMails
     False
 
-    >>> len(stub.test_emails)
-    0
+    >>> pop_notifications()
+    []
 
 
 .. Doctest cleanup

=== modified file 'lib/lp/soyuz/tests/test_packageupload.py'
--- lib/lp/soyuz/tests/test_packageupload.py	2015-09-04 12:19:07 +0000
+++ lib/lp/soyuz/tests/test_packageupload.py	2015-09-08 09:46:54 +0000
@@ -29,7 +29,6 @@
 from lp.services.job.interfaces.job import JobStatus
 from lp.services.job.runner import JobRunner
 from lp.services.librarian.browser import ProxiedLibraryFileAlias
-from lp.services.mail import stub
 from lp.soyuz.adapters.overrides import SourceOverride
 from lp.soyuz.enums import (
     PackagePublishingStatus,
@@ -192,10 +191,11 @@
         with dbuser(config.IPackageUploadNotificationJobSource.dbuser):
             JobRunner([job]).runAll()
 
-    def assertEmail(self, expected_to_addrs):
-        """Pop an email from the stub queue and check its recipients."""
-        _, to_addrs, _ = stub.test_emails.pop()
-        self.assertEqual(expected_to_addrs, to_addrs)
+    def assertEmails(self, expected_to_addrs):
+        """Pop emails from the stub queue and check their recipients."""
+        notifications = self.assertEmailQueueLength(len(expected_to_addrs))
+        for expected_to_addr, msg in zip(expected_to_addrs, notifications):
+            self.assertEqual(expected_to_addr, msg["X-Envelope-To"])
 
     def test_acceptFromQueue_source_sends_email(self):
         # Accepting a source package sends emails to the announcement list
@@ -204,10 +204,11 @@
         upload, uploader = self.makeSourcePackageUpload()
         upload.acceptFromQueue()
         self.runPackageUploadNotificationJob()
-        self.assertEqual(2, len(stub.test_emails))
         # Emails sent are the uploader's notification and the announcement:
-        self.assertEmail([uploader.preferredemail.email])
-        self.assertEmail(["autotest_changes@xxxxxxxxxx"])
+        self.assertEmails([
+            uploader.preferredemail.email,
+            "autotest_changes@xxxxxxxxxx",
+            ])
 
     def test_acceptFromQueue_source_backports_sends_no_announcement(self):
         # Accepting a source package into BACKPORTS does not send an
@@ -219,10 +220,9 @@
             pocket=PackagePublishingPocket.BACKPORTS)
         upload.acceptFromQueue()
         self.runPackageUploadNotificationJob()
-        self.assertEqual(1, len(stub.test_emails))
         # Only one email is sent, to the person in the changed-by field.  No
         # announcement email is sent.
-        self.assertEmail([uploader.preferredemail.email])
+        self.assertEmails([uploader.preferredemail.email])
 
     def test_acceptFromQueue_source_translations_sends_no_email(self):
         # Accepting source packages in the "translations" section (i.e.
@@ -235,7 +235,7 @@
         upload.acceptFromQueue()
         self.runPackageUploadNotificationJob()
         self.assertEqual("DONE", upload.status.name)
-        self.assertEqual(0, len(stub.test_emails))
+        self.assertEmailQueueLength(0)
 
     def test_acceptFromQueue_source_creates_builds(self):
         # Accepting a source package creates build records.
@@ -281,7 +281,7 @@
         upload, _ = self.makeBuildPackageUpload()
         upload.acceptFromQueue()
         self.runPackageUploadNotificationJob()
-        self.assertEqual(0, len(stub.test_emails))
+        self.assertEmailQueueLength(0)
 
     def test_acceptFromQueue_handles_duplicates(self):
         # Duplicate queue entries are handled sensibly.
@@ -329,8 +329,7 @@
         upload, uploader = self.makeSourcePackageUpload()
         upload.rejectFromQueue(self.factory.makePerson())
         self.runPackageUploadNotificationJob()
-        self.assertEqual(1, len(stub.test_emails))
-        self.assertEmail([uploader.preferredemail.email])
+        self.assertEmails([uploader.preferredemail.email])
 
     def test_rejectFromQueue_binary_sends_email(self):
         # Rejecting a binary package sends an email to the uploader.
@@ -338,8 +337,7 @@
         upload, uploader = self.makeBuildPackageUpload()
         upload.rejectFromQueue(self.factory.makePerson())
         self.runPackageUploadNotificationJob()
-        self.assertEqual(1, len(stub.test_emails))
-        self.assertEmail([uploader.preferredemail.email])
+        self.assertEmails([uploader.preferredemail.email])
 
     def test_rejectFromQueue_source_translations_sends_no_email(self):
         # Rejecting a language pack sends no email.
@@ -350,7 +348,7 @@
             section_name="translations")
         upload.rejectFromQueue(self.factory.makePerson())
         self.runPackageUploadNotificationJob()
-        self.assertEqual(0, len(stub.test_emails))
+        self.assertEmailQueueLength(0)
 
     def test_rejectFromQueue_source_with_reason(self):
         # Rejecting a source package with a reason includes it in the email to
@@ -360,10 +358,10 @@
         person = self.factory.makePerson()
         upload.rejectFromQueue(user=person, comment='Because.')
         self.runPackageUploadNotificationJob()
-        self.assertEqual(1, len(stub.test_emails))
+        [msg] = self.assertEmailQueueLength(1)
         self.assertIn(
             'Rejected:\nRejected by %s: Because.' % person.displayname,
-            stub.test_emails[0][-1])
+            str(msg))
 
 
 class TestPackageUploadSecurity(TestCaseWithFactory):

=== modified file 'lib/lp/testing/__init__.py'
--- lib/lp/testing/__init__.py	2015-08-03 12:59:18 +0000
+++ lib/lp/testing/__init__.py	2015-09-08 09:46:54 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2011 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2015 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 from __future__ import absolute_import
@@ -184,6 +184,7 @@
 from lp.testing.dbuser import switch_dbuser
 from lp.testing.fixture import CaptureOops
 from lp.testing.karma import KarmaRecorder
+from lp.testing.mail_helpers import pop_notifications
 
 # The following names have been imported for the purpose of being
 # exported. They are referred to here to silence lint warnings.
@@ -744,6 +745,17 @@
                     sorted(
                         expected_permissions[permission] - attribute_names)))
 
+    def assertEmailQueueLength(self, length, sort_key=None):
+        """Pop the email queue, assert its length, and return it.
+
+        This commits the transaction as part of pop_notifications.
+        """
+        notifications = pop_notifications(sort_key=sort_key)
+        self.assertEqual(
+            length, len(notifications),
+            "Expected %d emails, got %d" % (length, len(notifications)))
+        return notifications
+
 
 class TestCaseWithFactory(TestCase):
 


Follow ups