← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/upload-mail into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/upload-mail into lp:launchpad.

Commit message:
Convert package upload notifications to BaseMailer.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #117155 in Launchpad itself: "soyuz emails are vague about why they are being sent to a given user"
  https://bugs.launchpad.net/launchpad/+bug/117155
  Bug #127917 in Launchpad itself: "soyuz emails lack X-LP-Message-Rationale"
  https://bugs.launchpad.net/launchpad/+bug/127917

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/upload-mail/+merge/269066

Convert package upload notifications to BaseMailer.

The main effect of this is that each recipient now receives a separate mail.  This allows us to set X-Launchpad-Message-Rationale headers and use a much less vague footer.

Other minor effects:
 * There's now an "X-Launchpad-Notification-Type: package-upload" header.
 * Rejection mails without any spr, bprs, or customfiles (e.g. rejections from archiveuploader) are a little more uniform: as well as the above header changes, the last word changes from "rejected" to "(Rejected)".
 * PPA upload notifications have a proper footer separator ("-- " rather than "--").
 * PPA upload notifications are no longer unnecessarily multipart.

The changes list required some special handling, because it doesn't correspond to a Person.  I added a StubPerson to the notification recipient set edifice to cope with this.

X-LP-M-R: Requester and "... because you made this upload" are intentionally a little vague, because at that point it could be either the signer of a direct upload or the requester of a sync.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/upload-mail into lp:launchpad.
=== modified file 'lib/lp/archiveuploader/tests/nascentupload-announcements.txt'
--- lib/lp/archiveuploader/tests/nascentupload-announcements.txt	2015-07-08 16:52:34 +0000
+++ lib/lp/archiveuploader/tests/nascentupload-announcements.txt	2015-08-25 14:09:28 +0000
@@ -101,9 +101,8 @@
     DEBUG above if files already exist in other distroseries.
     ...
     DEBUG --
-    DEBUG You are receiving this email because you are the uploader,
-    maintainer or
-    DEBUG signer of the above package.
+    DEBUG You are receiving this email because you are the most recent person
+    DEBUG listed in this package's changelog.
 
 There is only one email generated:
 
@@ -143,7 +142,7 @@
     DEBUG bar diff from 1.0-1 to 1.0-1 requested
     DEBUG Setting it to ACCEPTED
     ...
-    DEBUG Sending rejection email.
+    DEBUG Sent a mail:
     ...
     DEBUG Rejected:
     DEBUG The source bar - 1.0-1 is already accepted in ubuntu/hoary
@@ -206,8 +205,8 @@
     <BLANKLINE>
     -- =
     <BLANKLINE>
-    You are receiving this email because you are the uploader, maintainer or
-    signer of the above package.
+    You are receiving this email because you are the most recent person
+    listed in this package's changelog.
     <BLANKLINE>
 
 In order to facilitate automated processing of announcement emails, the
@@ -215,10 +214,10 @@
 
     >>> attachment = notification.get_payload()[1]
     >>> print attachment.as_string() # doctest: -NORMALIZE_WHITESPACE
+    Content-Disposition: attachment; filename="changesfile"
+    MIME-Version: 1.0
     Content-Type: text/plain; charset="utf-8"
-    MIME-Version: 1.0
     Content-Transfer-Encoding: quoted-printable
-    Content-Disposition: attachment; filename="changesfile"
     <BLANKLINE>
     -----BEGIN PGP SIGNED MESSAGE-----
     Hash: SHA1
@@ -296,10 +295,8 @@
 However, PPA upload notifications do not contain an attachment with the
 original changesfile.
 
-    >>> attachment = notification.get_payload()[1]
-    Traceback (most recent call last):
-    ...
-    IndexError: list index out of range
+    >>> notification.is_multipart()
+    False
 
 See further tests upon PPA upload notifications on
 archiveuploader/tests/test_ppauploadprocessor.
@@ -348,8 +345,7 @@
     >>> lang_pack.logger = FakeLogger()
     >>> result = lang_pack.do_accept()
     DEBUG Creating queue entry
-    DEBUG Skipping acceptance and announcement, it is a language-package
-    upload.
+    DEBUG Skipping acceptance and announcement for language packs.
 
     >>> lang_pack.queue_root.status.name
     'NEW'
@@ -381,8 +377,7 @@
     >>> result = lang_pack.do_accept()
     DEBUG Creating queue entry
     ...
-    DEBUG Skipping acceptance and announcement, it is a language-package
-    upload.
+    DEBUG Skipping acceptance and announcement for language packs.
 
     >>> lang_pack.queue_root.status.name
     'DONE'
@@ -412,22 +407,21 @@
     DEBUG Creating queue entry
     DEBUG language-pack-pt diff from 1.0-2 to 1.0-3 requested
     DEBUG Setting it to UNAPPROVED
-    DEBUG Skipping acceptance and announcement, it is a language-package
-    upload.
+    DEBUG Skipping acceptance and announcement for language packs.
 
     >>> lang_pack.queue_root.status.name
     'UNAPPROVED'
 
 UNAPPROVED message was also skipped for an upload targeted to
-'translation' section:
-
+'translation' section: 
     >>> transaction.commit()
     >>> len(stub.test_emails)
     0
 
 
-An UNAPPROVED binary upload via insecure will send one email saying that
-the upload is waiting for approval:
+An UNAPPROVED binary upload via insecure will send emails (in this case, one
+to the signer and one to the changer) saying that the upload is waiting for
+approval:
 
     >>> bar_src = NascentUpload.from_changesfile_path(
     ...     datadir('suite/bar_1.0-2/bar_1.0-2_source.changes'),
@@ -439,16 +433,21 @@
     DEBUG Creating queue entry
     ...
 
-    >>> [notification] = pop_notifications()
-
-    >>> notification['X-Katie']
-    'Launchpad actually'
-
-    >>> print_addrlist(notification['To'])
+    >>> changer_notification, signer_notification = pop_notifications()
+
+    >>> changer_notification['X-Katie']
+    'Launchpad actually'
+    >>> signer_notification['X-Katie']
+    'Launchpad actually'
+
+    >>> print_addrlist(changer_notification['To'])
     Daniel Silverstone <daniel.silverstone@xxxxxxxxxxxxx>
+    >>> print_addrlist(signer_notification['To'])
     Foo Bar <foo.bar@xxxxxxxxxxxxx>
 
-    >>> notification['Subject']
+    >>> changer_notification['Subject']
+    '[ubuntu/hoary-updates] bar 1.0-2 (Waiting for approval)'
+    >>> signer_notification['Subject']
     '[ubuntu/hoary-updates] bar 1.0-2 (Waiting for approval)'
 
 And clean up.
@@ -457,7 +456,7 @@
     >>> upload_data = datadir('suite/bar_1.0-2')
     >>> os.remove(os.path.join(upload_data, 'bar_1.0.orig.tar.gz'))
 
-UNAPPROVED upload to BACKPORTS via insecure policy will send a notification
+UNAPPROVED upload to BACKPORTS via insecure policy will send notifications
 saying they are waiting for approval:
 
     >>> unapproved_backports_policy = getPolicy(
@@ -475,16 +474,21 @@
     DEBUG Setting it to UNAPPROVED
     ...
 
-    >>> [notification] = pop_notifications()
-
-    >>> notification['X-Katie']
-    'Launchpad actually'
-
-    >>> print_addrlist(notification['To'])
+    >>> changer_notification, signer_notification = pop_notifications()
+
+    >>> changer_notification['X-Katie']
+    'Launchpad actually'
+    >>> signer_notification['X-Katie']
+    'Launchpad actually'
+
+    >>> print_addrlist(changer_notification['To'])
     Daniel Silverstone <daniel.silverstone@xxxxxxxxxxxxx>
+    >>> print_addrlist(signer_notification['To'])
     Foo Bar <foo.bar@xxxxxxxxxxxxx>
 
-    >>> notification['Subject']
+    >>> changer_notification['Subject']
+    '[ubuntu/hoary-backports] bar 1.0-3 (Waiting for approval)'
+    >>> signer_notification['Subject']
     '[ubuntu/hoary-backports] bar 1.0-3 (Waiting for approval)'
 
 AUTO-APPROVED upload to BACKPORTS pocket via 'sync' policy:
@@ -528,9 +532,8 @@
     DEBUG Thank you for your contribution to Ubuntu.
     DEBUG
     DEBUG --
-    DEBUG You are receiving this email because you are the uploader,
-    maintainer or
-    DEBUG signer of the above package.
+    DEBUG You are receiving this email because you are the most recent person
+    DEBUG listed in this package's changelog.
 
 There is one email generated:
 
@@ -662,9 +665,8 @@
     ...
     DEBUG Announcing to hoary-announce@xxxxxxxxxxxxxxxx
     ...
-    DEBUG You are receiving this email because you are the uploader, maintainer
-    or
-    DEBUG signer of the above package.
+    DEBUG You are receiving this email because you are the most recent person
+    DEBUG listed in this package's changelog.
     DEBUG Would have sent a mail:
     DEBUG   Subject: [ubuntu/hoary] bar 1.0-6 (Accepted)
     DEBUG   Sender: Celso Providelo <cprov@xxxxxxxxxx>
@@ -698,10 +700,11 @@
 
     >>> msgs = pop_notifications(sort_key=operator.itemgetter('To'))
     >>> len(msgs)
-    2
+    3
 
     >>> [message['From'].replace('\n ', ' ') for message in msgs]
-    ['Root <root@localhost>', '=?utf-8?q?Non-ascii_changed-by_=C4=8Ciha=C5=99?=
+    ['Root <root@localhost>', 'Root <root@localhost>',
+    '=?utf-8?q?Non-ascii_changed-by_=C4=8Ciha=C5=99?=
     <daniel.silverstone@xxxxxxxxxxxxx>']
 
 UTF-8 text in the changes file that is sent on the email is preserved
@@ -746,8 +749,8 @@
     <BLANKLINE>
     -- =
     <BLANKLINE>
-    You are receiving this email because you are the uploader, maintainer or
-    signer of the above package.
+    You are receiving this email because you are the most recent person
+    listed in this package's changelog.
     <BLANKLINE>
 
 In order to facilitate scripts that parse announcement emails, the changes
@@ -763,10 +766,10 @@
 And what follows is the content of the attachment.
 
     >>> print attachment.as_string() # doctest: -NORMALIZE_WHITESPACE
+    Content-Disposition: attachment; filename="changesfile"
+    MIME-Version: 1.0
     Content-Type: text/plain; charset="utf-8"
-    MIME-Version: 1.0
     Content-Transfer-Encoding: quoted-printable
-    Content-Disposition: attachment; filename="changesfile"
     <BLANKLINE>
     -----BEGIN PGP SIGNED MESSAGE-----
     Hash: SHA1
@@ -832,7 +835,6 @@
     >>> result = bar_src.do_accept()
     DEBUG Building recipients list.
     ...
-    DEBUG Sending rejection email.
     DEBUG Sent a mail:
     ...
     DEBUG Rejected:
@@ -844,9 +846,8 @@
     DEBUG http://answers.launchpad.net/soyuz
     DEBUG
     DEBUG --
-    DEBUG You are receiving this email because you are the uploader,
-    maintainer or
-    DEBUG signer of the above package.
+    DEBUG You are receiving this email because you are the most recent person
+    DEBUG listed in this package's changelog.
 
     >>> [notification] = pop_notifications()
 

=== modified file 'lib/lp/archiveuploader/tests/test_ppauploadprocessor.py'
--- lib/lp/archiveuploader/tests/test_ppauploadprocessor.py	2015-04-20 15:59:52 +0000
+++ lib/lp/archiveuploader/tests/test_ppauploadprocessor.py	2015-08-25 14:09:28 +0000
@@ -73,56 +73,48 @@
     def makeArchive(self, owner):
         return self.factory.makeArchive(owner=owner, name='ppa')
 
-    def assertEmail(self, contents=None, recipients=None, ppa_header='name16'):
-        """Check email last upload notification attributes.
+    def assertEmails(self, expected):
+        """Check recent email upload notification attributes.
 
-        :param: contents: can be a list of one or more lines, if passed
-            they will be checked against the lines in Subject + Body.
-        :param: recipients: can be a list of recipients lines, it defaults
-            to 'Foo Bar <foo.bar@xxxxxxxxxxxxx>' (name16 account) and
-            should match the email To: header content.
-        :param: ppa_header: is the content of the 'X-Launchpad-PPA' header,
-            it defaults to 'name16' and should be explicitly set to None for
-            non-PPA or rejection notifications.
+        :param expected: A list of dicts, each of which represents an
+            expected email and may have "contents", "recipient", and
+            "ppa_header" keys.  All the items in expected must match in the
+            correct order, with none left over.  "contents" is a list of
+            lines; assert that each is in Subject + Body.  "recipient" is
+            the To address the email must have, defaulting to
+            "foo.bar@xxxxxxxxxxxxx" which is the signer on most of the test
+            data uploads.  "ppa_header" is the content of the
+            "X-Launchpad-PPA" header; it defaults to "name16" and should be
+            explicitly set to None for non-PPA or rejection notifications.
         """
-        if not recipients:
-            recipients = [self.name16_recipient]
-
-        if not contents:
-            contents = []
-
-        queue_size = len(stub.test_emails)
-        messages = "\n".join(m for f, t, m in stub.test_emails)
-        self.assertEqual(
-            queue_size, 1, 'Unexpected number of emails sent: %s\n%s'
-            % (queue_size, messages))
-
-        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)
-
-        clean_recipients = [r.strip() for r in to_addrs]
-        for recipient in list(recipients):
-            self.assertTrue(
-                recipient in clean_recipients,
-                "%s not in %s" % (recipient, clean_recipients))
-        self.assertEqual(
-            len(recipients), len(clean_recipients),
-            "Email recipients do not match exactly. Expected %s, got %s" %
-                (recipients, clean_recipients))
-
-        subject = "Subject: %s\n" % msg['Subject']
-        body = subject + body
-
-        for content in list(contents):
-            self.assertIn(content, body)
-
-        if ppa_header is not None:
-            self.assertIn('X-Launchpad-PPA', msg.keys())
-            self.assertEqual(msg['X-Launchpad-PPA'], ppa_header)
+        for item in expected:
+            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)
+
+            subject = "Subject: %s\n" % msg['Subject']
+            body = subject + body
+
+            for content in list(contents):
+                self.assertIn(content, body)
+
+            if ppa_header is not None:
+                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.
@@ -255,7 +247,7 @@
         # it's the default PPA.
         contents = [
             "Subject: [~name16/ubuntu/ppa/breezy] bar 1.0-1 (Accepted)"]
-        self.assertEmail(contents, ppa_header='name16')
+        self.assertEmails([{"contents": contents, "ppa_header": "name16"}])
 
     def testNamedPPAUploadNonDefault(self):
         """Test PPA uploads to a named PPA."""
@@ -273,7 +265,8 @@
         # Subject and PPA email-header are specific for this named-ppa.
         contents = [
             "Subject: [~name16/ubuntu/testing/breezy] bar 1.0-1 (Accepted)"]
-        self.assertEmail(contents, ppa_header='name16-testing')
+        self.assertEmails(
+            [{"contents": contents, "ppa_header": "name16-testing"}])
 
     def testNamedPPAUploadWithSeries(self):
         """Test PPA uploads to a named PPA location and with a distroseries.
@@ -447,7 +440,7 @@
         # name16 is Foo Bar, who signed the upload.  The package that was
         # uploaded also contains two other valid (in sampledata) email
         # addresses for maintainer and changed-by which must be ignored.
-        self.assertEmail()
+        self.assertEmails([{}])
 
     def testUploadSendsEmailToPeopleInArchivePermissions(self):
         """PPA uploads result in notifications to ArchivePermission uploaders.
@@ -474,17 +467,17 @@
         upload_dir = self.queueUpload("bar_1.0-1", "~cprov/ppa/ubuntu")
         self.processUpload(self.uploadprocessor, upload_dir)
 
-        name12_email = "%s <%s>" % (
-            name12.displayname, name12.preferredemail.email)
-        team_email = "%s <%s>" % (team.displayname, team.preferredemail.email)
-
         # We expect the recipients to be:
-        #  - the package signer (name15),
+        #  - the package signer (name16),
         #  - the team in the extra permissions,
         #  - name12 who is in the extra permissions.
         expected_recipients = (
-            self.name16_recipient, name12_email, team_email)
-        self.assertEmail(ppa_header="cprov", recipients=expected_recipients)
+            self.name16_recipient,
+            team.preferredemail.email,
+            name12.preferredemail.email)
+        self.assertEmails([
+            {"ppa_header": "cprov", "recipient": expected_recipient}
+            for expected_recipient in reversed(sorted(expected_recipients))])
 
     def testPPADistroSeriesOverrides(self):
         """It's possible to override target distroseries of PPA uploads.
@@ -805,17 +798,15 @@
                  'previous error.'], rejection_message.splitlines())
 
         contents = [
-            "Subject: [~cprov/ubuntu/ppa] bar_1.0-1_source.changes rejected",
+            "Subject: [~cprov/ubuntu/ppa] bar_1.0-1_source.changes (Rejected)",
             "Could not find person or team named 'boing'",
             "https://help.launchpad.net/Packaging/PPA/Uploading";,
             "If you don't understand why your files were rejected please "
                 "send an email",
             ("to %s for help (requires membership)."
              % config.launchpad.users_address),
-            "You are receiving this email because you are the uploader "
-                "of the above",
-            "PPA package."]
-        self.assertEmail(contents, ppa_header=None)
+            "You are receiving this email because you made this upload."]
+        self.assertEmails([{"contents": contents, "ppa_header": None}])
 
 
 class TestPPAUploadProcessorFileLookups(TestPPAUploadProcessorBase):
@@ -1003,8 +994,7 @@
         # Also, the email generated should be sane.
         from_addr, to_addrs, raw_msg = stub.test_emails.pop()
         msg = message_from_string(raw_msg)
-        body = msg.get_payload(0)
-        body = body.get_payload(decode=True)
+        body = msg.get_payload(decode=True)
 
         self.assertTrue(
             "File bar_1.0.orig.tar.gz already exists in unicode PPA name: "
@@ -1027,8 +1017,7 @@
         # The email generated should be sane.
         from_addr, to_addrs, raw_msg = stub.test_emails.pop()
         msg = message_from_string(raw_msg)
-        body = msg.get_payload(0)
-        body = body.get_payload(decode=True)
+        body = msg.get_payload(decode=True)
 
         self.assertTrue(
             "Rejected:\n"
@@ -1229,12 +1218,13 @@
         # An email communicating the rejection and the reason why it was
         # rejected is sent to the uploaders.
         contents = [
-            "Subject: [~name16/ubuntu/ppa] bar_1.0-1_source.changes rejected",
+            "Subject: [~name16/ubuntu/ppa] bar_1.0-1_source.changes "
+            "(Rejected)",
             "Rejected:",
             "PPA exceeded its size limit (2048.00 of 2048.00 MiB). "
             "Ask a question in https://answers.launchpad.net/soyuz/ "
             "if you need more space."]
-        self.assertEmail(contents)
+        self.assertEmails([{"contents": contents}])
 
     def testPPASizeNoQuota(self):
         self.name16.archive.authorized_size = None
@@ -1242,7 +1232,7 @@
         self.processUpload(self.uploadprocessor, upload_dir)
         contents = [
             "Subject: [~name16/ubuntu/ppa/breezy] bar 1.0-1 (Accepted)"]
-        self.assertEmail(contents)
+        self.assertEmails([{"contents": contents}])
         self.assertEqual(
             self.uploadprocessor.last_processed_upload.queue_root.status,
             PackageUploadStatus.DONE)
@@ -1266,7 +1256,7 @@
             "PPA exceeded 95 % of its size limit (2000.00 of 2048.00 MiB). "
             "Ask a question in https://answers.launchpad.net/soyuz/ "
             "if you need more space."]
-        self.assertEmail(contents)
+        self.assertEmails([{"contents": contents}])
 
         # User was warned about quota limits but the source was accepted
         # as informed in the upload notification.

=== modified file 'lib/lp/archiveuploader/tests/test_sync_notification.py'
--- lib/lp/archiveuploader/tests/test_sync_notification.py	2012-11-15 01:41:14 +0000
+++ lib/lp/archiveuploader/tests/test_sync_notification.py	2015-08-25 14:09:28 +0000
@@ -149,10 +149,10 @@
         In a situation like that, we should not bother those people with the
         failure.  We notify the person who requested the sync instead.
 
-        (The logic in lp.soyuz.adapters.notification may still notify the
-        author of the last change, if that person is also an uploader for the
-        archive that the failure happened in.  For this particular situation
-        we consider that not so much an intended behaviour, as an emergent one
+        (The logic in lp.soyuz.mail.packageupload may still notify the author
+        of the last change, if that person is also an uploader for the archive
+        that the failure happened in.  For this particular situation we
+        consider that not so much an intended behaviour, as an emergent one
         that does not seem inappropriate.  It'd be hard to change if we wanted
         to.)
 

=== modified file 'lib/lp/archiveuploader/tests/test_uploadprocessor.py'
--- lib/lp/archiveuploader/tests/test_uploadprocessor.py	2015-02-17 11:20:15 +0000
+++ lib/lp/archiveuploader/tests/test_uploadprocessor.py	2015-08-25 14:09:28 +0000
@@ -159,10 +159,8 @@
         self.options.nomails = False
         self.options.context = 'insecure'
 
-        # common recipients
-        self.kinnison_recipient = (
-            "Daniel Silverstone <daniel.silverstone@xxxxxxxxxxxxx>")
-        self.name16_recipient = "Foo Bar <foo.bar@xxxxxxxxxxxxx>"
+        # common recipient
+        self.name16_recipient = "foo.bar@xxxxxxxxxxxxx"
 
         self.log = BufferLogger()
 
@@ -344,50 +342,49 @@
         self.switchToUploader()
         return upload_processor
 
-    def assertEmail(self, contents=None, recipients=None):
-        """Check last email content and recipients.
+    def assertEmails(self, expected, allow_leftover=False):
+        """Check recent email content and recipients.
 
-        :param contents: A list of lines; assert that each is in the email.
-        :param recipients: A list of recipients that must be on the email.
-                           Supply an empty list if you don't want them
-                           checked.  Default action is to check that the
-                           recipient is foo.bar@xxxxxxxxxxxxx, which is the
-                           signer on most of the test data uploads.
+        :param expected: A list of dicts, each of which represents an
+            expected email and may have "contents" and "recipient" keys.
+            All the items in expected must match in the correct order, with
+            none left over.  "contents" is a list of lines; assert that each
+            is in Subject + Body.  "recipient" is the To address the email
+            must have, defaulting to "foo.bar@xxxxxxxxxxxxx" which is the
+            signer on most of the test data uploads; supply None if you
+            don't want it checked.
+        :param allow_leftover: If True, allow additional emails to be left
+            over after checking the ones in expected.
         """
-        if recipients is None:
-            recipients = [self.name16_recipient]
-        if contents is None:
-            contents = []
-
-        self.assertEqual(
-            len(stub.test_emails), 1,
-            'Unexpected number of emails sent: %s' % len(stub.test_emails))
-
-        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)
-
-        # Only check recipients if callsite didn't provide an empty list.
-        if recipients != []:
-            clean_recipients = [r.strip() for r in to_addrs]
-            for recipient in list(recipients):
+
+        for item in expected:
+            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)
+
+            # 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)
+
+            subject = "Subject: %s\n" % msg['Subject']
+            body = subject + body
+
+            for content in list(contents):
                 self.assertTrue(
-                    recipient in clean_recipients,
-                    "%s not found in %s" % (recipients, clean_recipients))
+                    content in body,
+                    "Expect: '%s'\nGot:\n%s" % (content, body))
+
+        if not allow_leftover:
             self.assertEqual(
-                len(recipients), len(clean_recipients),
-                "Email recipients do not match exactly. Expected %s, got %s" %
-                    (recipients, clean_recipients))
-
-        subject = "Subject: %s\n" % msg['Subject']
-        body = subject + body
-
-        for content in list(contents):
-            self.assertTrue(
-                content in body,
-                "Expect: '%s'\nGot:\n%s" % (content, body))
+                [], stub.test_emails,
+                "%d emails left over" % len(stub.test_emails))
 
     def PGPSignatureNotPreserved(self, archive=None):
         """PGP signatures should be removed from .changes files.
@@ -427,7 +424,7 @@
     def _checkPartnerUploadEmailSuccess(self):
         """Ensure partner uploads generate the right email."""
         from_addr, to_addrs, raw_msg = stub.test_emails.pop()
-        foo_bar = "Foo Bar <foo.bar@xxxxxxxxxxxxx>"
+        foo_bar = "foo.bar@xxxxxxxxxxxxx"
         self.assertEqual([e.strip() for e in to_addrs], [foo_bar])
         self.assertTrue(
             "rejected" not in raw_msg,
@@ -572,8 +569,7 @@
         body = msg.get_payload(0)
         body = body.get_payload(decode=True)
 
-        daniel = "Daniel Silverstone <daniel.silverstone@xxxxxxxxxxxxx>"
-        self.assertEqual(to_addrs, [daniel])
+        self.assertEqual(["daniel.silverstone@xxxxxxxxxxxxx"], to_addrs)
         self.assertTrue("Unhandled exception processing upload: Exception "
                         "raised by BrokenUploadPolicy for testing."
                         in body)
@@ -603,14 +599,15 @@
         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()
-        to_addrs = [e.strip() for e in to_addrs]
-        foo_bar = "Foo Bar <foo.bar@xxxxxxxxxxxxx>"
-        daniel = "Daniel Silverstone <daniel.silverstone@xxxxxxxxxxxxx>"
-        self.assertContentEqual(to_addrs, [foo_bar, daniel])
-        self.assertTrue(
-            "NEW" in raw_msg, "Expected email containing 'NEW', got:\n%s"
-            % raw_msg)
+        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])
+            self.assertTrue(
+                "NEW" in raw_msg, "Expected email containing 'NEW', got:\n%s"
+                % raw_msg)
 
         # Accept and publish the upload.
         # This is required so that the next upload of a later version of
@@ -638,14 +635,13 @@
         self.processUpload(uploadprocessor, upload_dir)
 
         # Verify we get an email talking about awaiting approval.
-        from_addr, to_addrs, raw_msg = stub.test_emails.pop()
-        to_addrs = [e.strip() for e in to_addrs]
-        daniel = "Daniel Silverstone <daniel.silverstone@xxxxxxxxxxxxx>"
-        foo_bar = "Foo Bar <foo.bar@xxxxxxxxxxxxx>"
-        self.assertContentEqual(to_addrs, [foo_bar, daniel])
-        self.assertTrue("Waiting for approval" in raw_msg,
-                        "Expected an 'upload awaits approval' email.\n"
-                        "Got:\n%s" % raw_msg)
+        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)
 
         # And verify that the queue item is in the unapproved state.
         queue_items = self.breezy.getPackageUploads(
@@ -942,7 +938,7 @@
 
         # Check that it was rejected.
         from_addr, to_addrs, raw_msg = stub.test_emails.pop()
-        foo_bar = "Foo Bar <foo.bar@xxxxxxxxxxxxx>"
+        foo_bar = "foo.bar@xxxxxxxxxxxxx"
         self.assertEqual([e.strip() for e in to_addrs], [foo_bar])
         self.assertTrue(
             "Cannot mix partner files with non-partner." in raw_msg,
@@ -1049,10 +1045,10 @@
             build_uploadprocessor, upload_dir, build=foocomm_build)
 
         contents = [
-            "Subject: [ubuntu/partner] foocomm_1.0-1_i386.changes rejected",
+            "Subject: [ubuntu/partner] foocomm_1.0-1_i386.changes (Rejected)",
             "Attempt to upload binaries specifying build 31, "
             "where they don't fit."]
-        self.assertEmail(contents)
+        self.assertEmails([{"contents": contents}])
 
         # Reset upload queue directory for a new upload.
         shutil.rmtree(upload_dir)
@@ -1292,7 +1288,8 @@
         # Check that the sourceful upload to the copy archive is rejected.
         contents = [
             "Invalid upload path (1/ubuntu) for this policy (insecure)"]
-        self.assertEmail(contents=contents, recipients=[])
+        self.assertEmails(
+            [{"contents": contents, "recipient": None}], allow_leftover=True)
 
     # Uploads that are new should have the component overridden
     # such that:
@@ -1689,7 +1686,7 @@
         from_addr, to_addrs, raw_msg = stub.test_emails.pop()
         msg = message_from_string(raw_msg)
         self.assertEqual(
-            msg['Subject'], '[ubuntu] bar_1.0-2_source.changes rejected')
+            msg['Subject'], '[ubuntu] bar_1.0-2_source.changes (Rejected)')
 
         # Grant the permissions in the proper series.
         self.switchToAdmin()
@@ -1716,7 +1713,7 @@
     def testUploadPathErrorIntendedForHumans(self):
         # Distribution upload path errors are augmented with a hint
         # to fix the current dput/dupload configuration.
-        # This information gets included in the rejection email along
+        # This information gets included in the rejection emails along
         # with pointer to the Soyuz questions in Launchpad and the
         # reason why the message was sent to the current recipients.
         self.setupBreezy()
@@ -1741,20 +1738,26 @@
              ],
             rejection_message.splitlines())
 
-        contents = [
-            "Subject: [ubuntu] bar_1.0-1_source.changes rejected",
+        base_contents = [
+            "Subject: [ubuntu] bar_1.0-1_source.changes (Rejected)",
             "Could not find distribution 'boing'",
             "If you don't understand why your files were rejected",
             "http://answers.launchpad.net/soyuz";,
-            "You are receiving this email because you are the "
-               "uploader, maintainer or",
-            "signer of the above package.",
-            ]
-        recipients = [
-            'Foo Bar <foo.bar@xxxxxxxxxxxxx>',
-            'Daniel Silverstone <daniel.silverstone@xxxxxxxxxxxxx>',
-            ]
-        self.assertEmail(contents, recipients=recipients)
+            ]
+        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",
+            })
+        self.assertEmails(expected)
 
     def test30QuiltUploadToUnsupportingSeriesIsRejected(self):
         """Ensure that uploads to series without format support are rejected.
@@ -1909,21 +1912,27 @@
             "the 'CURRENT' state.",
             rejection_message)
 
-        contents = [
-            "Subject: [ubuntu] bar_1.0-1_source.changes rejected",
+        base_contents = [
+            "Subject: [ubuntu] bar_1.0-1_source.changes (Rejected)",
             "Not permitted to upload to the RELEASE pocket in a series "
             "in the 'CURRENT' state.",
             "If you don't understand why your files were rejected",
             "http://answers.launchpad.net/soyuz";,
-            "You are receiving this email because you are the "
-               "uploader, maintainer or",
-            "signer of the above package.",
-            ]
-        recipients = [
-            'Foo Bar <foo.bar@xxxxxxxxxxxxx>',
-            'Daniel Silverstone <daniel.silverstone@xxxxxxxxxxxxx>',
-            ]
-        self.assertEmail(contents, recipients=recipients)
+            ]
+        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",
+            })
+        self.assertEmails(expected)
 
     def testPGPSignatureNotPreserved(self):
         """PGP signatures should be removed from .changes files.
@@ -1998,9 +2007,11 @@
         self.switchToUploader()
         upload_dir = self.queueUpload("bar_1.0-1")
         self.processUpload(uploadprocessor, upload_dir)
-        self.assertEmail(
-            contents=["Redirecting ubuntu breezy to ubuntu breezy-proposed."],
-            recipients=[])
+        self.assertEmails([{
+            "contents":
+                ["Redirecting ubuntu breezy to ubuntu breezy-proposed."],
+            "recipient": None,
+            }], allow_leftover=True)
         [queue_item] = self.breezy.getPackageUploads(
             status=PackageUploadStatus.NEW, name=u"bar",
             version=u"1.0-1", exact_match=True)
@@ -2010,9 +2021,11 @@
         pop_notifications()
         upload_dir = self.queueUpload("bar_1.0-2")
         self.processUpload(uploadprocessor, upload_dir)
-        self.assertEmail(
-            contents=["Redirecting ubuntu breezy to ubuntu breezy-proposed."],
-            recipients=[])
+        self.assertEmails([{
+            "contents":
+                ["Redirecting ubuntu breezy to ubuntu breezy-proposed."],
+            "recipient": None,
+            }], allow_leftover=True)
         [queue_item] = self.breezy.getPackageUploads(
             status=PackageUploadStatus.DONE, name=u"bar",
             version=u"1.0-2", exact_match=True)
@@ -2271,7 +2284,7 @@
             status=PackageUploadStatus.ACCEPTED,
             version=u"1.0-1", name=u"bar")
         queue_item.setDone()
-        stub.test_emails.pop()
+        stub.test_emails = []
 
         build.buildqueue_record.markAsBuilding(self.factory.makeBuilder())
         build.updateStatus(status)

=== modified file 'lib/lp/services/mail/basemailer.py'
--- lib/lp/services/mail/basemailer.py	2015-08-23 22:53:55 +0000
+++ lib/lp/services/mail/basemailer.py	2015-08-25 14:09:28 +0000
@@ -135,6 +135,10 @@
         """
         pass
 
+    def _getTemplateName(self, email, recipient):
+        """Return the name of the template to use for this email body."""
+        return self._template_name
+
     def _getTemplateParams(self, email, recipient):
         """Return a dict of values to use in the body and subject."""
         reason, rationale = self._recipients.getReason(email)
@@ -150,7 +154,8 @@
 
     def _getBody(self, email, recipient):
         """Return the complete body to use for this email."""
-        template = get_email_template(self._template_name, app=self.app)
+        template = get_email_template(
+            self._getTemplateName(email, recipient), app=self.app)
         params = self._getTemplateParams(email, recipient)
         body = template % params
         footer = self._getFooter(email, recipient, params)

=== modified file 'lib/lp/services/mail/notificationrecipientset.py'
--- lib/lp/services/mail/notificationrecipientset.py	2015-07-08 16:05:11 +0000
+++ lib/lp/services/mail/notificationrecipientset.py	2015-08-25 14:09:28 +0000
@@ -6,6 +6,7 @@
 __metaclass__ = type
 __all__ = [
     'NotificationRecipientSet',
+    'StubPerson',
 ]
 
 
@@ -21,6 +22,22 @@
     )
 
 
+class StubPerson:
+    """A stub recipient person.
+
+    This can be used when sending to special email addresses that do not
+    correspond to a real Person.
+    """
+
+    displayname = None
+    is_team = False
+    expanded_notification_footers = False
+
+    def __init__(self, email):
+        self.preferredemail = type(
+            "StubEmailAddress", (object,), {"email": email})
+
+
 @implementer(INotificationRecipientSet)
 class NotificationRecipientSet:
     """Set of recipients along the rationale for being in the set."""
@@ -87,17 +104,24 @@
         """See `INotificationRecipientSet`."""
         from zope.security.proxy import removeSecurityProxy
         from lp.registry.model.person import get_recipients
-        if IPerson.providedBy(persons):
+        if (IPerson.providedBy(persons) or
+                zope_isinstance(persons, StubPerson)):
             persons = [persons]
 
         for person in persons:
-            assert IPerson.providedBy(person), (
-                'You can only add() an IPerson: %r' % person)
+            assert (
+                IPerson.providedBy(person) or
+                zope_isinstance(person, StubPerson)), (
+                'You can only add() an IPerson or a StubPerson: %r' % person)
             # If the person already has a rationale, keep the first one.
             if person in self._personToRationale:
                 continue
             self._personToRationale[person] = reason, header
-            for receiving_person in get_recipients(person):
+            if IPerson.providedBy(person):
+                recipients = get_recipients(person)
+            else:
+                recipients = [person]
+            for receiving_person in recipients:
                 # Bypass zope's security because IEmailAddress.email is not
                 # public.
                 preferred_email = removeSecurityProxy(

=== modified file 'lib/lp/soyuz/doc/distroseriesqueue-notify.txt'
--- lib/lp/soyuz/doc/distroseriesqueue-notify.txt	2013-07-25 11:58:55 +0000
+++ lib/lp/soyuz/doc/distroseriesqueue-notify.txt	2015-08-25 14:09:28 +0000
@@ -59,7 +59,8 @@
     ...
     DEBUG above if files already exist in other distroseries.
     ...
-    DEBUG signer of the above package.
+    DEBUG You are receiving this email because you are the most recent person
+    DEBUG listed in this package's changelog.
 
 Helper functions to examine emails that were sent:
 
@@ -99,8 +100,8 @@
     <BLANKLINE>
     -- =
     <BLANKLINE>
-    You are receiving this email because you are the uploader, maintainer or
-    signer of the above package.
+    You are receiving this email because you are the most recent person
+    listed in this package's changelog.
     <BLANKLINE>
 
 Now we will process a signed package.  Signed packages will potentially
@@ -133,15 +134,17 @@
     DEBUG Sent a mail:
     ...
 
-There are two emails, the upload acknowledgement and the announcement,
-because this upload is already accepted.
+There are three emails, the upload acknowledgement to the changer, the
+upload acknowledgement to the signer, and the announcement, because this
+upload is already accepted.
 
     >>> msgs = pop_notifications()
     >>> len(msgs)
-    2
+    3
 
-The mail 'To:' addresses contain the signer and the changer's email.
-The announcement email contains the serieses changeslist.
+The two upload acknowledgements contain the changer's email and the signer's
+email in their respective 'To:' headers.
+The announcement email contains the series's changeslist.
 
     >>> def to_lower(address):
     ...     """Return lower-case version of email address."""
@@ -153,12 +156,10 @@
     ...         [addr.strip() for addr in header_field.split(',')],
     ...         key=to_lower)
 
-    >>> for addr in extract_addresses(msgs[0]['To']):
-    ...     print addr
+    >>> for msg in msgs:
+    ...     print msg['To']
     Daniel Silverstone <daniel.silverstone@xxxxxxxxxxxxx>
     Foo Bar <foo.bar@xxxxxxxxxxxxx>
-
-    >>> print msgs[1]['To']
     autotest_changes@xxxxxxxxxx
 
 The mail 'Bcc:' address is the uploader.  The announcement has the
@@ -167,35 +168,36 @@
     >>> for msg in msgs:
     ...     print extract_addresses(msg['Bcc'])
     ['Root <root@localhost>']
+    ['Root <root@localhost>']
     ['netapplet_derivatives@xxxxxxxxxxxxxxxxxxxxxx', 'Root <root@localhost>']
 
-The mail 'From:' addresses are the uploader and the changer.
+The mail 'From:' addresses are the uploader (for acknowledgements sent to
+the uploader and the changer) and the changer.
 
     >>> for msg in msgs:
     ...     print msg['From']
     Root <root@localhost>
+    Root <root@localhost>
     Daniel Silverstone <daniel.silverstone@xxxxxxxxxxxxx>
 
-    >>> print notification['Subject']
-    [ubuntu/breezy-autotest] netapplet 0.99.6-1 (New)
+    >>> print msgs[0]['Subject']
+    [ubuntu/breezy-autotest] netapplet 0.99.6-1 (Accepted)
 
 The mail body contains the same list of files again:
 
-    >>> print notification.get_payload(0) # doctest: -NORMALIZE_WHITESPACE
+    >>> print msgs[0].get_payload(0) # doctest: -NORMALIZE_WHITESPACE
     From nobody ...
     ...
-    NEW: netapplet_1.0-1.dsc
-    NEW: netapplet_1.0.orig.tar.gz
-    NEW: netapplet_1.0-1.diff.gz
+     OK: netapplet_1.0-1.dsc
+         -> Component: main Section: web
+     OK: netapplet_1.0.orig.tar.gz
+     OK: netapplet_1.0-1.diff.gz
     <BLANKLINE>
     ...
-    You may have gotten the distroseries wrong.  If so, you may get warnings
-    above if files already exist in other distroseries.
-    <BLANKLINE>
     -- =
     <BLANKLINE>
-    You are receiving this email because you are the uploader, maintainer or
-    signer of the above package.
+    You are receiving this email because you are the most recent person
+    listed in this package's changelog.
     <BLANKLINE>
 
 notify() will also work without passing the changes_file_object
@@ -216,46 +218,75 @@
     ...
     DEBUG Sent a mail:
     ...
-    DEBUG     Recipients: ... Silverstone ...
-    ...
-    DEBUG above if files already exist in other distroseries.
-    ...
-    DEBUG signer of the above package.
-
-Only one email is generated:
-
-    >>> [notification] = pop_notifications()
+    DEBUG   Recipients: ... Silverstone ...
+    ...
+    DEBUG above if files already exist in other distroseries.
+    ...
+    DEBUG You are receiving this email because you are the most recent person
+    DEBUG listed in this package's changelog.
+    DEBUG Sent a mail:
+    ...
+    DEBUG   Recipients: ... Bar ...
+    ...
+    DEBUG above if files already exist in other distroseries.
+    ...
+    DEBUG You are receiving this email because you made this upload.
+
+Two emails are generated, one to the changer and one to the signer:
+
+    >>> [changer_notification, signer_notification] = pop_notifications()
 
 The mail headers are the same as before:
 
-    >>> for addr in extract_addresses(notification['To']):
-    ...     print addr
+    >>> print changer_notification['To']
     Daniel Silverstone <daniel.silverstone@xxxxxxxxxxxxx>
+    >>> print signer_notification['To']
     Foo Bar <foo.bar@xxxxxxxxxxxxx>
 
-    >>> print notification['Bcc']
+    >>> print changer_notification['Bcc']
+    Root <root@localhost>
+    >>> print signer_notification['Bcc']
     Root <root@localhost>
 
-    >>> print notification['Subject']
+    >>> print changer_notification['Subject']
+    [ubuntu/breezy-autotest] netapplet 0.99.6-1 (New)
+    >>> print signer_notification['Subject']
     [ubuntu/breezy-autotest] netapplet 0.99.6-1 (New)
 
 The mail body contains the same list of files again:
 
-    >>> print notification.get_payload(0) # doctest: -NORMALIZE_WHITESPACE
-    From nobody ...
-    ...
-    NEW: netapplet_1.0-1.dsc
-    NEW: netapplet_1.0.orig.tar.gz
-    NEW: netapplet_1.0-1.diff.gz
-    <BLANKLINE>
-    ...
-    You may have gotten the distroseries wrong.  If so, you may get warnings
-    above if files already exist in other distroseries.
-    <BLANKLINE>
-    -- =
-    <BLANKLINE>
-    You are receiving this email because you are the uploader, maintainer or
-    signer of the above package.
+    >>> print changer_notification.get_payload(0)
+    ... # doctest: -NORMALIZE_WHITESPACE
+    From nobody ...
+    ...
+    NEW: netapplet_1.0-1.dsc
+    NEW: netapplet_1.0.orig.tar.gz
+    NEW: netapplet_1.0-1.diff.gz
+    <BLANKLINE>
+    ...
+    You may have gotten the distroseries wrong.  If so, you may get warnings
+    above if files already exist in other distroseries.
+    <BLANKLINE>
+    -- =
+    <BLANKLINE>
+    You are receiving this email because you are the most recent person
+    listed in this package's changelog.
+    <BLANKLINE>
+    >>> print signer_notification.get_payload(0)
+    ... # doctest: -NORMALIZE_WHITESPACE
+    From nobody ...
+    ...
+    NEW: netapplet_1.0-1.dsc
+    NEW: netapplet_1.0.orig.tar.gz
+    NEW: netapplet_1.0-1.diff.gz
+    <BLANKLINE>
+    ...
+    You may have gotten the distroseries wrong.  If so, you may get warnings
+    above if files already exist in other distroseries.
+    <BLANKLINE>
+    -- =
+    <BLANKLINE>
+    You are receiving this email because you made this upload.
     <BLANKLINE>
 
 notify() will also generate rejection notices if the upload failed.  The
@@ -268,7 +299,19 @@
     ...     summary_text="Testing rejection message", logger=FakeLogger())
     DEBUG Building recipients list.
     ...
-    DEBUG Sending rejection email.
+    DEBUG Sent a mail:
+    DEBUG   Subject: [ubuntu/breezy-autotest] netapplet 0.99.6-1 (Rejected)
+    DEBUG   Sender: Root <root@localhost>
+    DEBUG   Recipients: ... Silverstone ...
+    DEBUG   Bcc: Root <root@localhost>
+    DEBUG   Body:
+    DEBUG Rejected:
+    DEBUG Testing rejection message
+    ...
+    DEBUG If you don't understand why your files were rejected, or if the
+    ...
+    DEBUG You are receiving this email because you are the most recent person
+    DEBUG listed in this package's changelog.
     ...
     DEBUG   Subject: [ubuntu/breezy-autotest] netapplet 0.99.6-1 (Rejected)
     DEBUG   Sender: Root <root@localhost>
@@ -280,13 +323,13 @@
     ...
     DEBUG If you don't understand why your files were rejected, or if the
     ...
-    DEBUG signer of the above package.
+    DEBUG You are receiving this email because you made this upload.
 
-Only one email is generated:
+Two emails are generated:
 
     >>> transaction.commit()
     >>> len(stub.test_emails)
-    1
+    2
 
 Clean up, otherwise stuff is left lying around in /var/tmp.
 

=== modified file 'lib/lp/soyuz/doc/soyuz-set-of-uploads.txt'
--- lib/lp/soyuz/doc/soyuz-set-of-uploads.txt	2015-07-29 05:56:50 +0000
+++ lib/lp/soyuz/doc/soyuz-set-of-uploads.txt	2015-08-25 14:09:28 +0000
@@ -304,10 +304,11 @@
     Rejected uploads: ['bar_1.0-3']
 
     >>> read_email()
-    To:
-	Daniel Silverstone <daniel.silverstone@xxxxxxxxxxxxx>,
-	Foo Bar <foo.bar@xxxxxxxxxxxxx>
-    Subject: [ubuntutest] bar_1.0-3_source.changes rejected
+    To: Daniel Silverstone <daniel.silverstone@xxxxxxxxxxxxx>
+    Subject: [ubuntutest] bar_1.0-3_source.changes (Rejected)
+    ...
+    To: Foo Bar <foo.bar@xxxxxxxxxxxxx>
+    Subject: [ubuntutest] bar_1.0-3_source.changes (Rejected)
     ...
 
 Force weird behaviour with rfc2047 sentences containing '.' on
@@ -321,10 +322,8 @@
 '.', must be rfc2047 compliant:
 
     >>> simulate_upload('bar_1.0-4')
-    >>> uninteresting_email = stub.test_emails.pop()
     >>> read_email()
-    To: "Foo B. Bar" <foo.bar@xxxxxxxxxxxxx>,
-	  Celso Providelo <celso.providelo@xxxxxxxxxxxxx>
+    To: "Foo B. Bar" <foo.bar@xxxxxxxxxxxxx>
     Subject: [ubuntutest/breezy] bar 1.0-4 (Accepted)
     Content-Type: text/plain; charset="utf-8"
     MIME-Version: 1.0
@@ -353,10 +352,13 @@
     <BLANKLINE>
     -- =
     <BLANKLINE>
-    You are receiving this email because you are the uploader, maintainer or
-    signer of the above package.
-    <BLANKLINE>
-    <BLANKLINE>
+    You are receiving this email because you made this upload.
+    <BLANKLINE>
+    <BLANKLINE>
+    To: Celso Providelo <celso.providelo@xxxxxxxxxxxxx>
+    ...
+    To: breezy-changes@xxxxxxxxxx
+    ...
 
 Revert changes:
 
@@ -468,9 +470,8 @@
     DEBUG above if files already exist in other distroseries.
     DEBUG
     DEBUG --
-    DEBUG You are receiving this email because you are the uploader,
-    maintainer or
-    DEBUG signer of the above package.
+    DEBUG You are receiving this email because you are the most recent person
+    DEBUG listed in this package's changelog.
     INFO  Committing the transaction and any mails associated with this
     upload.
     ...

=== modified file 'lib/lp/soyuz/emailtemplates/ppa-upload-accepted.txt'
--- lib/lp/soyuz/emailtemplates/ppa-upload-accepted.txt	2012-02-10 09:31:39 +0000
+++ lib/lp/soyuz/emailtemplates/ppa-upload-accepted.txt	2015-08-25 14:09:28 +0000
@@ -2,7 +2,3 @@
 %(SUMMARY)s
 
 %(CHANGESFILE)s
-
---%(ARCHIVE_URL)s
-You are receiving this email because you are the uploader of the above
-PPA package.

=== modified file 'lib/lp/soyuz/emailtemplates/ppa-upload-rejection.txt'
--- lib/lp/soyuz/emailtemplates/ppa-upload-rejection.txt	2012-02-10 09:31:39 +0000
+++ lib/lp/soyuz/emailtemplates/ppa-upload-rejection.txt	2015-08-25 14:09:28 +0000
@@ -7,7 +7,3 @@
 
 If you don't understand why your files were rejected please send an email
 to %(USERS_ADDRESS)s for help (requires membership).
-
---%(ARCHIVE_URL)s
-You are receiving this email because you are the uploader of the above
-PPA package.

=== modified file 'lib/lp/soyuz/emailtemplates/upload-accepted.txt'
--- lib/lp/soyuz/emailtemplates/upload-accepted.txt	2012-10-24 09:43:58 +0000
+++ lib/lp/soyuz/emailtemplates/upload-accepted.txt	2015-08-25 14:09:28 +0000
@@ -10,7 +10,3 @@
 %(ANNOUNCE)s
 
 Thank you for your contribution to %(DISTRO)s.
-
--- 
-You are receiving this email because you are the uploader, maintainer or
-signer of the above package.

=== modified file 'lib/lp/soyuz/emailtemplates/upload-new.txt'
--- lib/lp/soyuz/emailtemplates/upload-new.txt	2011-12-18 23:30:56 +0000
+++ lib/lp/soyuz/emailtemplates/upload-new.txt	2015-08-25 14:09:28 +0000
@@ -9,7 +9,3 @@
 
 You may have gotten the distroseries wrong.  If so, you may get warnings
 above if files already exist in other distroseries.
-
--- 
-You are receiving this email because you are the uploader, maintainer or
-signer of the above package.

=== modified file 'lib/lp/soyuz/emailtemplates/upload-rejection.txt'
--- lib/lp/soyuz/emailtemplates/upload-rejection.txt	2011-12-18 23:30:56 +0000
+++ lib/lp/soyuz/emailtemplates/upload-rejection.txt	2015-08-25 14:09:28 +0000
@@ -10,7 +10,3 @@
 If you don't understand why your files were rejected, or if the
 override file requires editing, please go to:
 http://answers.launchpad.net/soyuz
-
--- 
-You are receiving this email because you are the uploader, maintainer or
-signer of the above package.

=== renamed file 'lib/lp/soyuz/adapters/notification.py' => 'lib/lp/soyuz/mail/packageupload.py'
--- lib/lp/soyuz/adapters/notification.py	2015-07-29 06:58:37 +0000
+++ lib/lp/soyuz/mail/packageupload.py	2015-08-25 14:09:28 +0000
@@ -1,20 +1,16 @@
 # Copyright 2011-2015 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
-"""Notification for uploads and copies."""
-
 __metaclass__ = type
-
 __all__ = [
-    'notify',
+    'PackageUploadMailer',
     ]
 
-
-from email.mime.multipart import MIMEMultipart
-from email.mime.text import MIMEText
-import os
+from collections import OrderedDict
+import os.path
 
 from zope.component import getUtility
+from zope.security.proxy import isinstance as zope_isinstance
 
 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
 from lp.archivepublisher.utils import get_ppa_reference
@@ -28,67 +24,175 @@
 from lp.registry.interfaces.pocket import PackagePublishingPocket
 from lp.services.config import config
 from lp.services.encoding import guess as guess_encoding
-from lp.services.mail.helpers import get_email_template
+from lp.services.mail.basemailer import (
+    BaseMailer,
+    RecipientReason,
+    )
+from lp.services.mail.mailwrapper import MailWrapper
+from lp.services.mail.notificationrecipientset import StubPerson
 from lp.services.mail.sendmail import (
     format_address,
     format_address_for_person,
-    sendmail,
     )
 from lp.services.webapp import canonical_url
 from lp.soyuz.interfaces.archivepermission import IArchivePermissionSet
 
 
-def reject_changes_file(blamer, changes_file_path, changes, archive,
-                        distroseries, reason, logger=None):
-    """Notify about a rejection where all of the details are not known.
-
-    :param blamer: The `IPerson` that is to blame for this notification.
-    :param changes_file_path: The path to the changes file.
-    :param changes: A dictionary of the parsed changes file.
-    :param archive: The `IArchive` the notification is regarding.
-    :param distroseries: The `IDistroSeries` the notification is regarding.
-    :param reason: The reason for the rejection.
+class PackageUploadRecipientReason(RecipientReason):
+
+    @classmethod
+    def forRequester(cls, requester, recipient):
+        header = cls.makeRationale("Requester", requester)
+        # This is a little vague - copies may end up here too - but it's
+        # close enough.
+        reason = "You are receiving this email because you made this upload."
+        return cls(requester, recipient, header, reason)
+
+    @classmethod
+    def forMaintainer(cls, maintainer, recipient):
+        header = cls.makeRationale("Maintainer", maintainer)
+        reason = (
+            "You are receiving this email because you are listed as this "
+            "package's maintainer.")
+        return cls(maintainer, recipient, header, reason)
+
+    @classmethod
+    def forChangedBy(cls, changed_by, recipient):
+        header = cls.makeRationale("Changed-By", changed_by)
+        reason = (
+            "You are receiving this email because you are the most recent "
+            "person listed in this package's changelog.")
+        return cls(changed_by, recipient, header, reason)
+
+    @classmethod
+    def forPPAUploader(cls, uploader, recipient):
+        header = cls.makeRationale("PPA-Uploader", uploader)
+        reason = (
+            "You are receiving this email because you have upload permissions "
+            "to this PPA.")
+        return cls(uploader, recipient, header, reason)
+
+    @classmethod
+    def forChangesList(cls, recipient):
+        return cls(recipient, recipient, "Changes-List", "")
+
+    def getReason(self):
+        """See `RecipientReason`."""
+        return MailWrapper(width=72).format(
+            super(PackageUploadRecipientReason, self).getReason())
+
+
+def debug(logger, msg, *args, **kwargs):
+    """Shorthand debug notation."""
+    if logger is not None:
+        logger.debug(msg, *args, **kwargs)
+
+
+def sanitize_string(s):
+    """Make sure string does not trigger 'ascii' codec errors.
+
+    Convert string to unicode if needed so that characters outside
+    the (7-bit) ASCII range do not cause errors like these:
+
+        'ascii' codec can't decode byte 0xc4 in position 21: ordinal
+        not in range(128)
     """
-    ignored, filename = os.path.split(changes_file_path)
-    information = {
-        'SUMMARY': reason,
-        'CHANGESFILE': '',
-        'DATE': '',
-        'CHANGEDBY': '',
-        'MAINTAINER': '',
-        'SIGNER': '',
-        'ORIGIN': '',
-        'ARCHIVE_URL': '',
-        'USERS_ADDRESS': config.launchpad.users_address,
-    }
-    subject = '%s rejected' % filename
-    if archive:
-        subject = '[%s] %s' % (archive.reference, subject)
-        information['ARCHIVE_URL'] = '\n%s' % canonical_url(archive)
-    template = get_template(archive, 'rejected')
-    body = template % information
-    to_addrs = get_upload_notification_recipients(
-        blamer, archive, distroseries, logger, changes=changes)
-    debug(logger, "Sending rejection email.")
-    if not to_addrs:
-        debug(logger, "No recipients have a preferred email.")
+    if isinstance(s, unicode):
+        return s
+    else:
+        return guess_encoding(s)
+
+
+def add_recipient(recipients, person, reason_factory, logger=None):
+    # Circular import.
+    from lp.registry.model.person import get_recipients
+
+    if person is None:
         return
-    send_mail(None, archive, to_addrs, subject, body, False, logger=logger)
-
-
-def get_template(archive, action):
-    """Return the appropriate email template."""
-    template_name = 'upload-'
-    if action in ('new', 'accepted', 'announcement'):
-        template_name += action
-    elif action == 'unapproved':
-        template_name += 'accepted'
-    elif action == 'rejected':
-        template_name += 'rejection'
-    if archive.is_ppa:
-        template_name = 'ppa-%s' % template_name
-    template_name += '.txt'
-    return get_email_template(template_name, app='soyuz')
+    for recipient in get_recipients(person):
+        if recipient not in recipients:
+            debug(
+                logger, "Adding recipient: '%s'" % format_address_for_person(
+                    recipient))
+            reason = reason_factory(person, recipient)
+            recipients[recipient] = reason
+
+
+def fetch_information(spr, bprs, changes, previous_version=None):
+    changelog = date = changedby = maintainer = None
+
+    if changes:
+        changelog = ChangesFile.formatChangesComment(
+            sanitize_string(changes.get('Changes')))
+        date = changes.get('Date')
+        try:
+            changedby = parse_maintainer_bytes(
+                changes.get('Changed-By'), 'Changed-By')
+        except ParseMaintError:
+            pass
+        try:
+            maintainer = parse_maintainer_bytes(
+                changes.get('Maintainer'), 'Maintainer')
+        except ParseMaintError:
+            pass
+    elif spr or bprs:
+        if not spr and bprs:
+            spr = bprs[0].build.source_package_release
+        changelog = spr.aggregate_changelog(previous_version)
+        date = spr.dateuploaded
+        if spr.creator and spr.creator.preferredemail:
+            changedby = (
+                spr.creator.displayname, spr.creator.preferredemail.email)
+        if spr.maintainer and spr.maintainer.preferredemail:
+            maintainer = (
+                spr.maintainer.displayname,
+                spr.maintainer.preferredemail.email)
+
+    return {
+        'changelog': changelog,
+        'date': date,
+        'changedby': changedby,
+        'maintainer': maintainer,
+        }
+
+
+def addr_to_person(addr):
+    """Return an `IPerson` given a name and email address.
+
+    :param addr: (name, email) tuple. The name is ignored.
+    :return: `IPerson` with the given email address.  None if there
+        isn't one, or if `addr` is None.
+    """
+    if addr is None:
+        return None
+    return getUtility(IPersonSet).getByEmail(addr[1])
+
+
+def is_valid_uploader(person, distribution):
+    """Is `person` an uploader for `distribution`?
+
+    A `None` person is not an uploader.
+    """
+    if person is None:
+        return None
+    else:
+        return not getUtility(IArchivePermissionSet).componentsForUploader(
+            distribution.main_archive, person).is_empty()
+
+
+def is_auto_sync_upload(spr, bprs, pocket, changed_by):
+    """Return True if this is a (Debian) auto sync upload.
+
+    Sync uploads are source-only, unsigned and not targeted to
+    the security pocket. The Changed-By field is also the Katie
+    user (archive@xxxxxxxxxx).
+    """
+    changed_by = addr_to_person(changed_by)
+    return (
+        spr and
+        not bprs and
+        changed_by == getUtility(ILaunchpadCelebrities).katie and
+        pocket != PackagePublishingPocket.SECURITY)
 
 
 ACTION_DESCRIPTIONS = {
@@ -101,9 +205,8 @@
 
 
 def calculate_subject(spr, bprs, customfiles, archive, distroseries,
-                      pocket, action):
+                      pocket, action, changesfile_object=None):
     """Return the email subject for the notification."""
-    suite = distroseries.getSuite(pocket)
     names = set()
     version = '-'
     if spr:
@@ -114,431 +217,46 @@
         version = bprs[0].build.source_package_release.version
     for custom in customfiles:
         names.add(custom.libraryfilealias.filename)
-    name_str = ', '.join(names)
-    subject = '[%s/%s] %s %s (%s)' % (
-        archive.reference, suite, name_str, version,
-        ACTION_DESCRIPTIONS[action])
+    if names:
+        archive_and_suite = '%s/%s' % (
+            archive.reference, distroseries.getSuite(pocket))
+        name_and_version = '%s %s' % (', '.join(names), version)
+    else:
+        if changesfile_object is None:
+            return None
+        # The suite may not be meaningful if we have no
+        # spr/bprs/customfiles, since this must be a very early rejection.
+        # Don't introduce confusion by including it.
+        archive_and_suite = archive.reference
+        name_and_version = os.path.basename(changesfile_object.name)
+    subject = '[%s] %s (%s)' % (
+        archive_and_suite, name_and_version, ACTION_DESCRIPTIONS[action])
     return subject
 
 
-def notify(blamer, spr, bprs, customfiles, archive, distroseries, pocket,
-           summary_text=None, changes=None, changesfile_content=None,
-           changesfile_object=None, action=None, dry_run=False,
-           logger=None, announce_from_person=None, previous_version=None):
-    """Notify about an upload or package copy.
-
-    :param blamer: The `IPerson` who is to blame for this notification.
-    :param spr: The `ISourcePackageRelease` that was created.
-    :param bprs: A list of `IBinaryPackageRelease` that were created.
-    :param customfiles: An `ILibraryFileAlias` that was created.
-    :param archive: The target `IArchive`.
-    :param distroseries: The target `IDistroSeries`.
-    :param pocket: The target `PackagePublishingPocket`.
-    :param summary_text: The summary of the notification.
-    :param changes: A dictionary of the parsed changes file.
-    :param changesfile_content: The raw content of the changes file, so it
-        can be attached to the mail if desired.
-    :param changesfile_object: The raw object of the changes file. Only used
-        to work out the filename for `reject_changes_file`.
-    :param action: A string of what action to notify for, such as 'new',
-        'accepted'.
-    :param dry_run: If True, only log the mail.
-    :param announce_from_person: If passed, use this `IPerson` as the From: in
-        announcement emails.  If the person has no preferred email address,
-        the person is ignored and the default From: is used instead.
-    :param previous_version: If specified, the change log on the email will
-        include all of the source package's change logs after that version
-        up to and including the passed spr's version.
-    """
-    # If this is a binary or mixed upload, we don't send *any* emails
-    # provided it's not a rejection or a security upload:
-    if (
-        bprs and action != 'rejected' and
-        pocket != PackagePublishingPocket.SECURITY):
-        debug(logger, "Not sending email; upload is from a build.")
-        return
-
-    if spr and spr.source_package_recipe_build and action == 'accepted':
-        debug(logger, "Not sending email; upload is from a recipe.")
-        return
-
-    if spr is None and not bprs and not customfiles:
-        # We do not have enough context to do a normal notification, so
-        # reject what we do have.
-        if changesfile_object is None:
-            return
-        reject_changes_file(
-            blamer, changesfile_object.name, changes, archive, distroseries,
-            summary_text, logger=logger)
-        return
-
-    # "files" will contain a list of tuples of filename,component,section.
-    # If files is empty, we don't need to send an email if this is not
-    # a rejection.
-    try:
-        files = build_uploaded_files_list(spr, bprs, customfiles, logger)
-    except LanguagePackEncountered:
-        # Don't send emails for language packs.
-        return
-
-    if not files and action != 'rejected':
-        return
-
-    recipients = get_upload_notification_recipients(
-        blamer, archive, distroseries, logger, changes=changes, spr=spr,
-        bprs=bprs)
-
-    # There can be no recipients if none of the emails are registered
-    # in LP.
-    if not recipients:
-        debug(logger, "No recipients on email, not sending.")
-        return
-
-    if action == 'rejected':
-        default_recipient = "%s <%s>" % (
-            config.uploader.default_recipient_name,
-            config.uploader.default_recipient_address)
-        if not recipients:
-            recipients = [default_recipient]
-        debug(logger, "Sending rejection email.")
-        summarystring = summary_text
-    else:
-        summary = build_summary(spr, files, action)
-        if summary_text:
-            summary.append(summary_text)
-        summarystring = "\n".join(summary)
-
-    attach_changes = not archive.is_ppa
-
-    def build_and_send_mail(action, recipients, from_addr=None, bcc=None,
-                            previous_version=None):
-        subject = calculate_subject(
-            spr, bprs, customfiles, archive, distroseries, pocket, action)
-        body = assemble_body(
-            blamer, spr, bprs, archive, distroseries, summarystring, changes,
-            action, previous_version=previous_version)
-        body = body.encode("utf8")
-        send_mail(
-            spr, archive, recipients, subject, body, dry_run,
-            changesfile_content=changesfile_content,
-            attach_changes=attach_changes, from_addr=from_addr, bcc=bcc,
-            logger=logger)
-
-    build_and_send_mail(
-        action, recipients, previous_version=previous_version)
-
-    info = fetch_information(spr, bprs, changes)
-    from_addr = info['changedby']
-    if (announce_from_person is not None
-            and announce_from_person.preferredemail is not None):
-        from_addr = (
-            announce_from_person.displayname,
-            announce_from_person.preferredemail.email)
-
-    # If we're sending an acceptance notification for a non-PPA upload,
-    # announce if possible. Avoid announcing backports, binary-only
-    # security uploads, or autosync uploads.
-    if (action == 'accepted' and distroseries.changeslist
-        and not archive.is_ppa
-        and pocket != PackagePublishingPocket.BACKPORTS
-        and not (pocket == PackagePublishingPocket.SECURITY and spr is None)
-        and not is_auto_sync_upload(spr, bprs, pocket, from_addr)):
-        name = None
-        bcc_addr = None
-        if spr:
-            name = spr.name
-        elif bprs:
-            name = bprs[0].build.source_package_release.name
-        if name:
-            email_base = distroseries.distribution.package_derivatives_email
-            if email_base:
-                bcc_addr = email_base.format(package_name=name)
-
-        build_and_send_mail(
-            'announcement', [str(distroseries.changeslist)],
-            format_address(*from_addr) if from_addr else None, bcc_addr,
-            previous_version=previous_version)
-
-
-def assemble_body(blamer, spr, bprs, archive, distroseries, summary, changes,
-                  action, previous_version=None):
-    """Assemble the email notification body."""
-    if changes is None:
-        changes = {}
-    info = fetch_information(
-        spr, bprs, changes, previous_version=previous_version)
-    information = {
-        'STATUS': ACTION_DESCRIPTIONS[action],
-        'SUMMARY': summary,
-        'DATE': 'Date: %s' % info['date'],
-        'CHANGESFILE': info['changelog'],
-        'DISTRO': distroseries.distribution.title,
-        'ANNOUNCE': 'No announcement sent',
-        'CHANGEDBY': '',
-        'MAINTAINER': '',
-        'ORIGIN': '',
-        'SIGNER': '',
-        'SPR_URL': '',
-        'ARCHIVE_URL': '\n%s' % canonical_url(archive),
-        'USERS_ADDRESS': config.launchpad.users_address,
-        }
-    if spr:
-        information['SPR_URL'] = canonical_url(
-            distroseries.distribution.getSourcePackageRelease(spr))
-
-    # Some syncs (e.g. from Debian) will involve packages whose
-    # changed-by person was auto-created in LP and hence does not have a
-    # preferred email address set.  We'll get a None here.
-    changedby_person = addr_to_person(info['changedby'])
-    if info['changedby']:
-        information['CHANGEDBY'] = (
-            '\nChanged-By: %s' % rfc822_encode_address(*info['changedby']))
-    if (blamer is not None and blamer != changedby_person
-            and blamer.preferredemail):
-        information['SIGNER'] = '\nSigned-By: %s' % rfc822_encode_address(
-            blamer.displayname, blamer.preferredemail.email)
-    if info['maintainer'] and info['maintainer'] != info['changedby']:
-        information['MAINTAINER'] = (
-            '\nMaintainer: %s' % rfc822_encode_address(*info['maintainer']))
-
-    origin = changes.get('Origin')
-    if origin:
-        information['ORIGIN'] = '\nOrigin: %s' % origin
-    if action == 'unapproved':
-        information['SUMMARY'] += (
-            "\nThis upload awaits approval by a distro manager\n")
-    if distroseries.changeslist:
-        information['ANNOUNCE'] = "Announcing to %s" % (
-            distroseries.changeslist)
-
-    return get_template(archive, action) % information
-
-
-def send_mail(
-    spr, archive, to_addrs, subject, mail_text, dry_run, from_addr=None,
-    bcc=None, changesfile_content=None, attach_changes=False, logger=None):
-    """Send an email to to_addrs with the given text and subject.
-
-    :param spr: The `ISourcePackageRelease` to be notified about.
-    :param archive: The target `IArchive`.
-    :param to_addrs: A list of email addresses to be used as recipients.
-        Each email must be a valid ASCII str instance or a unicode one.
-    :param subject: The email's subject.
-    :param mail_text: The text body of the email. Unicode is preserved in the
-        email.
-    :param dry_run: Whether or not an email should actually be sent. But
-        please note that this flag is (largely) ignored.
-    :param from_addr: The email address to be used as the sender. Must be a
-        valid ASCII str instance or a unicode one.  Defaults to the email
-        for config.uploader.
-    :param bcc: Optional email Blind Carbon Copy address(es).
-    :param param changesfile_content: The content of the actual changesfile.
-    :param attach_changes: A flag governing whether the original changesfile
-        content shall be attached to the email.
-    """
-    extra_headers = {
-        'X-Katie': 'Launchpad actually',
-        'X-Launchpad-Archive': archive.reference,
-        }
-
-    # The deprecated PPA reference header is included for Ubuntu PPAs to
-    # avoid breaking existing consumers.
-    if archive.is_ppa and archive.distribution.name == u'ubuntu':
-        extra_headers['X-Launchpad-PPA'] = get_ppa_reference(archive)
-
-    # Include a 'X-Launchpad-Component' header with the component and
-    # the section of the source package uploaded in order to facilitate
-    # filtering on the part of the email recipients.
-    if spr:
-        xlp_component_header = 'component=%s, section=%s' % (
-            spr.component.name, spr.section.name)
-        extra_headers['X-Launchpad-Component'] = xlp_component_header
-
-    if from_addr is None:
-        from_addr = format_address(
-            config.uploader.default_sender_name,
-            config.uploader.default_sender_address)
-
-    # All emails from here have a Bcc to the default recipient.
-    bcc_text = format_address(
-        config.uploader.default_recipient_name,
-        config.uploader.default_recipient_address)
-    if bcc:
-        bcc_text = "%s, %s" % (bcc_text, bcc)
-    extra_headers['Bcc'] = bcc_text
-
-    recipients = ", ".join(to_addrs)
-
-    if dry_run and logger is not None:
-        debug(logger, "Would have sent a mail:")
-    else:
-        debug(logger, "Sent a mail:")
-    debug(logger, "  Subject: %s" % subject)
-    debug(logger, "  Sender: %s" % from_addr)
-    debug(logger, "  Recipients: %s" % recipients)
-    if 'Bcc' in extra_headers:
-        debug(logger, "  Bcc: %s" % extra_headers['Bcc'])
-    debug(logger, "  Body:")
-    for line in mail_text.splitlines():
-        if isinstance(line, str):
-            line = line.decode('utf-8', 'replace')
-        debug(logger, line)
-
-    if not dry_run:
-        # Since we need to send the original changesfile as an
-        # attachment the sendmail() method will be used as opposed to
-        # simple_sendmail().
-        message = MIMEMultipart()
-        message['from'] = from_addr
-        message['subject'] = subject
-        message['to'] = recipients
-
-        # Set the extra headers if any are present.
-        for key, value in extra_headers.iteritems():
-            message.add_header(key, value)
-
-        # Add the email body.
-        message.attach(
-            MIMEText(sanitize_string(mail_text).encode('utf-8'),
-                'plain', 'utf-8'))
-
-        if attach_changes:
-            # Add the original changesfile as an attachment.
-            if changesfile_content is not None:
-                changesfile_text = sanitize_string(changesfile_content)
-            else:
-                changesfile_text = ("Sorry, changesfile not available.")
-
-            attachment = MIMEText(
-                changesfile_text.encode('utf-8'), 'plain', 'utf-8')
-            attachment.add_header(
-                'Content-Disposition',
-                'attachment; filename="changesfile"')
-            message.attach(attachment)
-
-        # And finally send the message.
-        sendmail(message)
-
-
-def sanitize_string(s):
-    """Make sure string does not trigger 'ascii' codec errors.
-
-    Convert string to unicode if needed so that characters outside
-    the (7-bit) ASCII range do not cause errors like these:
-
-        'ascii' codec can't decode byte 0xc4 in position 21: ordinal
-        not in range(128)
-    """
-    if isinstance(s, unicode):
-        return s
-    else:
-        return guess_encoding(s)
-
-
-def debug(logger, msg, *args, **kwargs):
-    """Shorthand debug notation for publish() methods."""
-    if logger is not None:
-        logger.debug(msg, *args, **kwargs)
-
-
-def is_valid_uploader(person, distribution):
-    """Is `person` an uploader for `distribution`?
-
-    A `None` person is not an uploader.
-    """
-    if person is None:
-        return None
-    else:
-        return not getUtility(IArchivePermissionSet).componentsForUploader(
-            distribution.main_archive, person).is_empty()
-
-
-def get_upload_notification_recipients(blamer, archive, distroseries,
-                                       logger=None, changes=None, spr=None,
-                                       bprs=None):
-    """Return a list of recipients for notification emails."""
-    debug(logger, "Building recipients list.")
-    candidate_recipients = [blamer]
-    info = fetch_information(spr, bprs, changes)
-
-    changer = addr_to_person(info['changedby'])
-    maintainer = addr_to_person(info['maintainer'])
-
-    if blamer is None and not archive.is_copy:
-        debug(logger, "Changes file is unsigned; adding changer as recipient.")
-        candidate_recipients.append(changer)
-
-    if archive.is_ppa:
-        # For PPAs, any person or team mentioned explicitly in the
-        # ArchivePermissions as uploaders for the archive will also
-        # get emailed.
-        candidate_recipients.extend([
-            permission.person
-            for permission in archive.getUploadersForComponent()])
-    elif archive.is_copy:
-        # For copy archives, notifying anyone else will probably only
-        # confuse them.
-        pass
-    else:
-        # If this is not a PPA, we also consider maintainer and changed-by.
-        if blamer is not None:
-            if is_valid_uploader(maintainer, distroseries.distribution):
-                debug(logger, "Adding maintainer to recipients")
-                candidate_recipients.append(maintainer)
-
-            if is_valid_uploader(changer, distroseries.distribution):
-                debug(logger, "Adding changed-by to recipients")
-                candidate_recipients.append(changer)
-
-    # Collect email addresses to notify.  Skip persons who do not have a
-    # preferredemail set, such as people who have not activated their
-    # Launchpad accounts (and are therefore not expecting this email).
-    recipients = [
-        format_address_for_person(person)
-        for person in filter(None, set(candidate_recipients))
-            if person.preferredemail is not None]
-
-    for recipient in recipients:
-        debug(logger, "Adding recipient: '%s'", recipient)
-
-    return recipients
-
-
 def build_uploaded_files_list(spr, builds, customfiles, logger):
     """Return a list of tuples of (filename, component, section).
 
     Component and section are only set where the file is a source upload.
     If an empty list is returned, it means there are no files.
-    Raises LanguagePackRejection if a language pack is detected.
-    No emails should be sent for language packs.
     """
-    files = []
-    # Bail out early if this is an upload for the translations
-    # section.
     if spr:
-        if spr.section.name == 'translations':
-            debug(logger,
-                "Skipping acceptance and announcement, it is a "
-                "language-package upload.")
-            raise LanguagePackEncountered
         for sprfile in spr.files:
-            files.append(
-                (sprfile.libraryfile.filename, spr.component.name,
-                spr.section.name))
+            yield (
+                sprfile.libraryfile.filename, spr.component.name,
+                spr.section.name)
 
     # Component and section don't get set for builds and custom, since
     # this information is only used in the summary string for source
     # uploads.
     for build in builds:
         for bpr in build.build.binarypackages:
-            files.extend([
-                (bpf.libraryfile.filename, '', '') for bpf in bpr.files])
+            for bpf in bpr.files:
+                yield bpf.libraryfile.filename, '', ''
 
     if customfiles:
-        files.extend(
-            [(file.libraryfilealias.filename, '', '') for file in customfiles])
-
-    return files
+        for customfile in customfiles:
+            yield customfile.libraryfilealias.filename, '', ''
 
 
 def build_summary(spr, files, action):
@@ -555,70 +273,336 @@
     return summary
 
 
-def addr_to_person(addr):
-    """Return an `IPerson` given a name and email address.
-
-    :param addr: (name, email) tuple. The name is ignored.
-    :return: `IPerson` with the given email address.  None if there
-        isn't one, or if `addr` is None.
-    """
-    if addr is None:
-        return None
-    return getUtility(IPersonSet).getByEmail(addr[1])
-
-
-def is_auto_sync_upload(spr, bprs, pocket, changed_by):
-    """Return True if this is a (Debian) auto sync upload.
-
-    Sync uploads are source-only, unsigned and not targeted to
-    the security pocket. The Changed-By field is also the Katie
-    user (archive@xxxxxxxxxx).
-    """
-    changed_by = addr_to_person(changed_by)
-    return (
-        spr and
-        not bprs and
-        changed_by == getUtility(ILaunchpadCelebrities).katie and
-        pocket != PackagePublishingPocket.SECURITY)
-
-
-def fetch_information(spr, bprs, changes, previous_version=None):
-    changelog = date = changedby = maintainer = None
-
-    if changes:
-        changelog = ChangesFile.formatChangesComment(
-            sanitize_string(changes.get('Changes')))
-        date = changes.get('Date')
-        try:
-            changedby = parse_maintainer_bytes(
-                changes.get('Changed-By'), 'Changed-By')
-        except ParseMaintError:
-            pass
-        try:
-            maintainer = parse_maintainer_bytes(
-                changes.get('Maintainer'), 'Maintainer')
-        except ParseMaintError:
-            pass
-    elif spr or bprs:
-        if not spr and bprs:
-            spr = bprs[0].build.source_package_release
-        changelog = spr.aggregate_changelog(previous_version)
-        date = spr.dateuploaded
-        if spr.creator and spr.creator.preferredemail:
-            changedby = (
-                spr.creator.displayname, spr.creator.preferredemail.email)
-        if spr.maintainer and spr.maintainer.preferredemail:
-            maintainer = (
-                spr.maintainer.displayname,
-                spr.maintainer.preferredemail.email)
-
-    return {
-        'changelog': changelog,
-        'date': date,
-        'changedby': changedby,
-        'maintainer': maintainer,
-        }
-
-
-class LanguagePackEncountered(Exception):
-    """Thrown when not wanting to email notifications for language packs."""
+class PackageUploadMailer(BaseMailer):
+
+    app = 'soyuz'
+
+    @classmethod
+    def getRecipientsForAction(cls, action, info, blamee, spr, bprs, archive,
+                               distroseries, pocket, announce_from_person=None,
+                               logger=None):
+        # If this is a binary or mixed upload, we don't send *any* emails
+        # provided it's not a rejection or a security upload:
+        if (
+            bprs and action != 'rejected' and
+            pocket != PackagePublishingPocket.SECURITY):
+            debug(logger, "Not sending email; upload is from a build.")
+            return {}, ''
+
+        if spr and spr.source_package_recipe_build and action == 'accepted':
+            debug(logger, "Not sending email; upload is from a recipe.")
+            return {}, ''
+
+        if spr and spr.section.name == 'translations':
+            debug(
+                logger,
+                "Skipping acceptance and announcement for language packs.")
+            return {}, ''
+
+        debug(logger, "Building recipients list.")
+        recipients = OrderedDict()
+
+        add_recipient(
+            recipients, blamee, PackageUploadRecipientReason.forRequester,
+            logger=logger)
+
+        changer = addr_to_person(info['changedby'])
+        maintainer = addr_to_person(info['maintainer'])
+
+        if blamee is None and not archive.is_copy:
+            debug(
+                logger,
+                "Changes file is unsigned; adding changer as recipient.")
+            add_recipient(
+                recipients, changer, PackageUploadRecipientReason.forChangedBy,
+                logger=logger)
+
+        if archive.is_ppa:
+            # For PPAs, any person or team mentioned explicitly in the
+            # ArchivePermissions as uploaders for the archive will also get
+            # emailed.
+            for permission in archive.getUploadersForComponent():
+                add_recipient(
+                    recipients, permission.person,
+                    PackageUploadRecipientReason.forPPAUploader, logger=logger)
+        elif archive.is_copy:
+            # For copy archives, notifying anyone else will probably only
+            # confuse them.
+            pass
+        else:
+            # If this is not a PPA, we also consider maintainer and changed-by.
+            if blamee is not None:
+                if is_valid_uploader(maintainer, distroseries.distribution):
+                    debug(logger, "Adding maintainer to recipients")
+                    add_recipient(
+                        recipients, maintainer,
+                        PackageUploadRecipientReason.forMaintainer,
+                        logger=logger)
+
+                if is_valid_uploader(changer, distroseries.distribution):
+                    debug(logger, "Adding changed-by to recipients")
+                    add_recipient(
+                        recipients, changer,
+                        PackageUploadRecipientReason.forChangedBy,
+                        logger=logger)
+
+        if announce_from_person is not None:
+            announce_from_addr = (
+                announce_from_person.displayname,
+                announce_from_person.preferredemail.email)
+        else:
+            announce_from_addr = info['changedby']
+
+        # If we're sending an acceptance notification for a non-PPA upload,
+        # announce if possible. Avoid announcing backports, binary-only
+        # security uploads, or autosync uploads.
+        if (action == 'accepted' and distroseries.changeslist
+                and not archive.is_ppa
+                and pocket != PackagePublishingPocket.BACKPORTS
+                and not (
+                    pocket == PackagePublishingPocket.SECURITY and spr is None)
+                and not is_auto_sync_upload(
+                    spr, bprs, pocket, announce_from_addr)):
+            recipient = StubPerson(distroseries.changeslist)
+            recipients[recipient] = (
+                PackageUploadRecipientReason.forChangesList(recipient))
+
+        if announce_from_addr is not None:
+            announce_from_address = format_address(*announce_from_addr)
+        else:
+            announce_from_address = None
+        return recipients, announce_from_address
+
+    @classmethod
+    def forAction(cls, action, blamee, spr, bprs, customfiles, archive,
+                  distroseries, pocket, changes=None, changesfile_object=None,
+                  announce_from_person=None, previous_version=None,
+                  logger=None, **kwargs):
+        info = fetch_information(
+            spr, bprs, changes, previous_version=previous_version)
+        recipients, announce_from_address = cls.getRecipientsForAction(
+            action, info, blamee, spr, bprs, archive, distroseries, pocket,
+            announce_from_person=announce_from_person, logger=logger)
+        subject = calculate_subject(
+            spr, bprs, customfiles, archive, distroseries, pocket, action,
+            changesfile_object=changesfile_object)
+        if subject is None:
+            # We don't even have enough information to build a minimal
+            # subject, so do nothing.
+            recipients = {}
+        template_name = "upload-"
+        if action in ("new", "accepted", "announcement"):
+            template_name += action
+        elif action == "unapproved":
+            template_name += "accepted"
+        elif action == "rejected":
+            template_name += "rejection"
+        if archive.is_ppa:
+            template_name = "ppa-%s" % template_name
+        template_name += ".txt"
+        from_address = format_address(
+            config.uploader.default_sender_name,
+            config.uploader.default_sender_address)
+        return cls(
+            subject, template_name, recipients, from_address, action, info,
+            blamee, spr, bprs, customfiles, archive, distroseries, pocket,
+            changes=changes, announce_from_address=announce_from_address,
+            logger=logger, **kwargs)
+
+    def __init__(self, subject, template_name, recipients, from_address,
+                 action, info, blamee, spr, bprs, customfiles, archive,
+                 distroseries, pocket, summary_text=None, changes=None,
+                 changesfile_content=None, dry_run=False,
+                 announce_from_address=None, previous_version=None,
+                 logger=None):
+        super(PackageUploadMailer, self).__init__(
+            subject, template_name, recipients, from_address,
+            notification_type="package-upload")
+        self.action = action
+        self.info = info
+        self.blamee = blamee
+        self.spr = spr
+        self.bprs = bprs
+        self.customfiles = customfiles
+        self.archive = archive
+        self.distroseries = distroseries
+        self.pocket = pocket
+        self.changes = changes
+        self.changesfile_content = changesfile_content
+        self.dry_run = dry_run
+        self.logger = logger
+        self.announce_from_address = announce_from_address
+        self.previous_version = previous_version
+
+        if action == 'rejected':
+            self.summarystring = summary_text
+        else:
+            files = build_uploaded_files_list(spr, bprs, customfiles, logger)
+            summary = build_summary(spr, files, action)
+            if summary_text:
+                summary.append(summary_text)
+            self.summarystring = "\n".join(summary)
+
+    def _getFromAddress(self, email, recipient):
+        """See `BaseMailer`."""
+        if (zope_isinstance(recipient, StubPerson) and
+                self.announce_from_address is not None):
+            return self.announce_from_address
+        else:
+            return super(PackageUploadMailer, self)._getFromAddress(
+                email, recipient)
+
+    def _getHeaders(self, email, recipient):
+        """See `BaseMailer`."""
+        headers = super(PackageUploadMailer, self)._getHeaders(
+            email, recipient)
+        headers['X-Katie'] = 'Launchpad actually'
+        headers['X-Launchpad-Archive'] = self.archive.reference
+
+        # The deprecated PPA reference header is included for Ubuntu PPAs to
+        # avoid breaking existing consumers.
+        if self.archive.is_ppa and self.archive.distribution.name == u'ubuntu':
+            headers['X-Launchpad-PPA'] = get_ppa_reference(self.archive)
+
+        # Include a 'X-Launchpad-Component' header with the component and
+        # the section of the source package uploaded in order to facilitate
+        # filtering on the part of the email recipients.
+        if self.spr:
+            headers['X-Launchpad-Component'] = 'component=%s, section=%s' % (
+                self.spr.component.name, self.spr.section.name)
+
+        # All emails from here have a Bcc to the default recipient.
+        bcc_text = format_address(
+            config.uploader.default_recipient_name,
+            config.uploader.default_recipient_address)
+        if zope_isinstance(recipient, StubPerson):
+            name = None
+            if self.spr:
+                name = self.spr.name
+            elif self.bprs:
+                name = self.bprs[0].build.source_package_release.name
+            if name:
+                distribution = self.distroseries.distribution
+                email_base = distribution.package_derivatives_email
+                if email_base:
+                    bcc_text += ", " + email_base.format(package_name=name)
+        headers['Bcc'] = bcc_text
+
+        return headers
+
+    def _addAttachments(self, ctrl, email):
+        """See `BaseMailer`."""
+        if not self.archive.is_ppa:
+            if self.changesfile_content is not None:
+                changesfile_text = sanitize_string(self.changesfile_content)
+            else:
+                changesfile_text = "Sorry, changesfile not available."
+            ctrl.addAttachment(
+                changesfile_text, content_type='text/plain',
+                filename='changesfile', charset='utf-8')
+
+    def _getTemplateName(self, email, recipient):
+        """See `BaseMailer`."""
+        if zope_isinstance(recipient, StubPerson):
+            return "upload-announcement.txt"
+        else:
+            return self._template_name
+
+    def _getTemplateParams(self, email, recipient):
+        """See `BaseMailer`."""
+        params = super(PackageUploadMailer, self)._getTemplateParams(
+            email, recipient)
+        params.update({
+            'STATUS': ACTION_DESCRIPTIONS[self.action],
+            'SUMMARY': self.summarystring,
+            'DATE': '',
+            'CHANGESFILE': '',
+            'DISTRO': self.distroseries.distribution.title,
+            'ANNOUNCE': 'No announcement sent',
+            'CHANGEDBY': '',
+            'MAINTAINER': '',
+            'ORIGIN': '',
+            'SIGNER': '',
+            'SPR_URL': '',
+            'ARCHIVE_URL': canonical_url(self.archive),
+            'USERS_ADDRESS': config.launchpad.users_address,
+            })
+        changes = self.changes
+        if changes is None:
+            changes = {}
+
+        if self.info['date'] is not None:
+            params['DATE'] = 'Date: %s' % self.info['date']
+        if self.info['changelog'] is not None:
+            params['CHANGESFILE'] = self.info['changelog']
+        if self.spr:
+            params['SPR_URL'] = canonical_url(
+                self.distroseries.distribution.getSourcePackageRelease(
+                    self.spr))
+
+        # Some syncs (e.g. from Debian) will involve packages whose
+        # changed-by person was auto-created in LP and hence does not have a
+        # preferred email address set.  We'll get a None here.
+        changedby_person = addr_to_person(self.info['changedby'])
+        if self.info['changedby']:
+            params['CHANGEDBY'] = '\nChanged-By: %s' % rfc822_encode_address(
+                *self.info['changedby'])
+        if (self.blamee is not None and self.blamee != changedby_person
+                and self.blamee.preferredemail):
+            params['SIGNER'] = '\nSigned-By: %s' % rfc822_encode_address(
+                self.blamee.displayname, self.blamee.preferredemail.email)
+        if (self.info['maintainer']
+                and self.info['maintainer'] != self.info['changedby']):
+            params['MAINTAINER'] = '\nMaintainer: %s' % rfc822_encode_address(
+                *self.info['maintainer'])
+
+        origin = changes.get('Origin')
+        if origin:
+            params['ORIGIN'] = '\nOrigin: %s' % origin
+        if self.action == 'unapproved':
+            params['SUMMARY'] += (
+                "\nThis upload awaits approval by a distro manager\n")
+        if self.distroseries.changeslist:
+            params['ANNOUNCE'] = "Announcing to %s" % (
+                self.distroseries.changeslist)
+
+        return params
+
+    def _getFooter(self, email, recipient, params):
+        """See `BaseMailer`."""
+        if zope_isinstance(recipient, StubPerson):
+            return None
+        else:
+            footer_lines = []
+            if self.archive.is_ppa:
+                footer_lines.append("%(ARCHIVE_URL)s\n")
+            footer_lines.append("%(reason)s\n")
+            return "".join(footer_lines) % params
+
+    def generateEmail(self, email, recipient, force_no_attachments=False):
+        """See `BaseMailer`."""
+        ctrl = super(PackageUploadMailer, self).generateEmail(
+            email, recipient, force_no_attachments=force_no_attachments)
+        if self.dry_run:
+            debug(self.logger, "Would have sent a mail:")
+        else:
+            debug(self.logger, "Sent a mail:")
+        debug(self.logger, "  Subject: %s" % ctrl.subject)
+        debug(self.logger, "  Sender: %s" % ctrl.from_addr)
+        debug(self.logger, "  Recipients: %s" % ", ".join(ctrl.to_addrs))
+        if 'Bcc' in ctrl.headers:
+            debug(self.logger, "  Bcc: %s" % ctrl.headers['Bcc'])
+        debug(self.logger, "  Body:")
+        for line in ctrl.body.splitlines():
+            if isinstance(line, bytes):
+                line = line.decode('utf-8', 'replace')
+            debug(self.logger, line)
+        return ctrl
+
+    def sendOne(self, email, recipient):
+        """See `BaseMailer`."""
+        if self.dry_run:
+            # Just generate the email for the sake of debugging output.
+            self.generateEmail(email, recipient)
+        else:
+            super(PackageUploadMailer, self).sendOne(email, recipient)

=== added directory 'lib/lp/soyuz/mail/tests'
=== added file 'lib/lp/soyuz/mail/tests/__init__.py'
=== renamed file 'lib/lp/soyuz/adapters/tests/test_notification.py' => 'lib/lp/soyuz/mail/tests/test_packageupload.py'
--- lib/lp/soyuz/adapters/tests/test_notification.py	2015-07-29 07:01:04 +0000
+++ lib/lp/soyuz/mail/tests/test_packageupload.py	2015-08-25 14:09:28 +0000
@@ -2,35 +2,35 @@
 # NOTE: The first line above must stay first; do not move the copyright
 # notice to the top.  See http://www.python.org/dev/peps/pep-0263/.
 #
-# Copyright 2011-2014 Canonical Ltd.  This software is licensed under the
+# Copyright 2011-2015 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 from textwrap import dedent
 
-from storm.store import Store
+from testtools.matchers import (
+    Contains,
+    ContainsDict,
+    Equals,
+    KeysEqual,
+    )
 from zope.component import getUtility
 from zope.security.proxy import removeSecurityProxy
 
+from lp.archivepublisher.utils import get_ppa_reference
 from lp.registry.interfaces.pocket import PackagePublishingPocket
-from lp.services.log.logger import BufferLogger
-from lp.services.mail.sendmail import format_address_for_person
 from lp.services.propertycache import get_property_cache
 from lp.services.webapp.publisher import canonical_url
-from lp.soyuz.adapters.notification import (
-    assemble_body,
-    calculate_subject,
-    fetch_information,
-    get_upload_notification_recipients,
-    is_auto_sync_upload,
-    notify,
-    reject_changes_file,
-    )
 from lp.soyuz.enums import (
     ArchivePurpose,
     PackageUploadCustomFormat,
     )
 from lp.soyuz.interfaces.component import IComponentSet
-from lp.soyuz.model.component import ComponentSelection
+from lp.soyuz.mail.packageupload import (
+    calculate_subject,
+    fetch_information,
+    is_auto_sync_upload,
+    PackageUploadMailer,
+    )
 from lp.soyuz.model.distributionsourcepackagerelease import (
     DistributionSourcePackageRelease,
     )
@@ -49,7 +49,7 @@
 
     layer = LaunchpadZopelessLayer
 
-    def test_notify_from_unicode_names(self):
+    def test_mail_from_unicode_names(self):
         # People with unicode in their names should appear correctly in the
         # email and not get smashed to ASCII or otherwise transliterated.
         creator = self.factory.makePerson(displayname=u"Loïc")
@@ -60,9 +60,9 @@
         distroseries = self.factory.makeDistroSeries()
         distroseries.changeslist = "blah@xxxxxxxxxxx"
         blamer = self.factory.makePerson(displayname=u"Stéphane")
-        notify(
-            blamer, spr, [], [], archive, distroseries, pocket,
-            action='accepted')
+        mailer = PackageUploadMailer.forAction(
+            "accepted", blamer, spr, [], [], archive, distroseries, pocket)
+        mailer.sendAll()
         notifications = pop_notifications()
         self.assertEqual(2, len(notifications))
         msg = notifications[1].get_payload(0)
@@ -98,13 +98,15 @@
         blamer = self.factory.makePerson()
         if from_person is None:
             from_person = self.factory.makePerson()
-        notify(
-            blamer, spr, [], [], archive, distroseries, pocket,
-            action='accepted', announce_from_person=from_person)
+        mailer = PackageUploadMailer.forAction(
+            "accepted", blamer, spr, [], [], archive, distroseries, pocket,
+            announce_from_person=from_person)
+        mailer.sendAll()
 
-    def test_notify_from_person_override(self):
-        # notify() takes an optional from_person to override the calculated
-        # From: address in announcement emails.
+    def test_forAction_announce_from_person_override(self):
+        # PackageUploadMailer.forAction() takes an optional
+        # announce_from_person to override the calculated From: address in
+        # announcement emails.
         spr = self.factory.makeSourcePackageRelease()
         self.factory.makeSourcePackageReleaseFile(sourcepackagerelease=spr)
         archive = self.factory.makeArchive(purpose=ArchivePurpose.PRIMARY)
@@ -114,21 +116,28 @@
         blamer = self.factory.makePerson()
         from_person = self.factory.makePerson(
             email="lemmy@xxxxxxxxxxx", displayname="Lemmy Kilmister")
-        notify(
-            blamer, spr, [], [], archive, distroseries, pocket,
-            action='accepted', announce_from_person=from_person)
+        mailer = PackageUploadMailer.forAction(
+            "accepted", blamer, spr, [], [], archive, distroseries, pocket,
+            announce_from_person=from_person)
+        mailer.sendAll()
         notifications = pop_notifications()
         self.assertEqual(2, len(notifications))
         # The first notification is to the blamer, the second notification is
         # to the announce list, which is the one that gets the overridden
         # From:
-        self.assertEqual(
-            "Lemmy Kilmister <lemmy@xxxxxxxxxxx>", notifications[1]["From"])
+        self.assertThat(
+            dict(notifications[1]),
+            ContainsDict({
+                "From": Equals("Lemmy Kilmister <lemmy@xxxxxxxxxxx>"),
+                "X-Launchpad-Message-Rationale": Equals("Changes-List"),
+                "X-Launchpad-Notification-Type": Equals("package-upload"),
+                }))
 
-    def test_notify_from_person_override_with_unicode_names(self):
-        # notify() takes an optional from_person to override the calculated
-        # From: address in announcement emails. Non-ASCII real names should be
-        # correctly encoded in the From heade.
+    def test_forAction_announce_from_person_override_with_unicode_names(self):
+        # PackageUploadMailer.forAction() takes an optional
+        # announce_from_person to override the calculated From: address in
+        # announcement emails.  Non-ASCII real names should be correctly
+        # encoded in the From header.
         spr = self.factory.makeSourcePackageRelease()
         self.factory.makeSourcePackageReleaseFile(sourcepackagerelease=spr)
         archive = self.factory.makeArchive(purpose=ArchivePurpose.PRIMARY)
@@ -138,21 +147,28 @@
         blamer = self.factory.makePerson()
         from_person = self.factory.makePerson(
             email="loic@xxxxxxxxxxx", displayname=u"Loïc Motörhead")
-        notify(
-            blamer, spr, [], [], archive, distroseries, pocket,
-            action='accepted', announce_from_person=from_person)
+        mailer = PackageUploadMailer.forAction(
+            "accepted", blamer, spr, [], [], archive, distroseries, pocket,
+            announce_from_person=from_person)
+        mailer.sendAll()
         notifications = pop_notifications()
         self.assertEqual(2, len(notifications))
         # The first notification is to the blamer, the second notification is
         # to the announce list, which is the one that gets the overridden
         # From:
-        self.assertEqual(
-            "=?utf-8?q?Lo=C3=AFc_Mot=C3=B6rhead?= <loic@xxxxxxxxxxx>",
-            notifications[1]["From"])
+        self.assertThat(
+            dict(notifications[1]),
+            ContainsDict({
+                "From": Equals(
+                    "=?utf-8?q?Lo=C3=AFc_Mot=C3=B6rhead?= <loic@xxxxxxxxxxx>"),
+                "X-Launchpad-Message-Rationale": Equals("Changes-List"),
+                "X-Launchpad-Notification-Type": Equals("package-upload"),
+                }))
 
-    def test_notify_bcc_to_derivatives_list(self):
-        # notify() will BCC the announcement email to the address defined in
-        # Distribution.package_derivatives_email if it's defined.
+    def test_forAction_bcc_to_derivatives_list(self):
+        # PackageUploadMailer.forAction() will BCC the announcement email to
+        # the address defined in Distribution.package_derivatives_email if
+        # it's defined.
         email = "{package_name}_thing@xxxxxxx"
         distroseries = self.factory.makeDistroSeries()
         with person_logged_in(distroseries.distribution.owner):
@@ -162,9 +178,14 @@
 
         notifications = pop_notifications()
         self.assertEqual(2, len(notifications))
-        bcc_address = notifications[1]["Bcc"]
         expected_email = email.format(package_name=spr.sourcepackagename.name)
-        self.assertIn(expected_email, bcc_address)
+        self.assertThat(
+            dict(notifications[1]),
+            ContainsDict({
+                "Bcc": Contains(expected_email),
+                "X-Launchpad-Message-Rationale": Equals("Changes-List"),
+                "X-Launchpad-Notification-Type": Equals("package-upload"),
+                }))
 
     def test_fetch_information_spr_multiple_changelogs(self):
         # If previous_version is passed the "changelog" entry in the
@@ -182,9 +203,9 @@
         self.assertIn("foo (1.1)", info['changelog'])
         self.assertIn("foo (1.2)", info['changelog'])
 
-    def test_notify_bpr_rejected(self):
-        # If we notify about a rejected bpr with no source, a notification is
-        # sent.
+    def test_forAction_bpr_rejected(self):
+        # If we try to send mail about a rejected bpr with no source, a
+        # notification is sent.
         bpr = self.factory.makeBinaryPackageRelease()
         changelog = self.factory.makeChangelog(spn="foo", versions=["1.1"])
         removeSecurityProxy(
@@ -196,31 +217,30 @@
         distroseries = self.factory.makeDistroSeries()
         person = self.factory.makePerson(
             displayname=u'Blamer', email='blamer@xxxxxxxxxxx')
-        notify(
-            person, None, [bpr], [], archive, distroseries, pocket,
-            summary_text="Rejected by archive administrator.",
-            action='rejected')
+        mailer = PackageUploadMailer.forAction(
+            "rejected", person, None, [bpr], [], archive, distroseries, pocket,
+            summary_text="Rejected by archive administrator.")
+        mailer.sendAll()
         [notification] = pop_notifications()
-        body = notification.get_payload()[0].get_payload()
+        body = notification.get_payload(decode=True)
         self.assertEqual('Blamer <blamer@xxxxxxxxxxx>', notification['To'])
         expected_body = dedent("""\
             Rejected:
             Rejected by archive administrator.
 
-            foo (1.1) unstable; urgency=3Dlow
+            foo (1.1) unstable; urgency=low
 
               * 1.1.
 
-            =3D=3D=3D
+            ===
 
             If you don't understand why your files were rejected please send an email
             to launchpad-users@xxxxxxxxxxxxxxxxxxx for help (requires membership).
 
-            --
+            %s
             http://launchpad.dev/~archiver/+archive/ubuntu/ppa
-            You are receiving this email because you are the uploader of the above
-            PPA package.
-            """)
+            You are receiving this email because you made this upload.
+            """ % "-- ")
         self.assertEqual(expected_body, body)
 
 
@@ -295,42 +315,41 @@
             None, [bpr], [], archive, distroseries, pocket, 'accepted')
         self.assertEqual(expected_subject, subject)
 
-    def test_notify_bpr(self):
-        # If we notify about an accepted bpr with no source, it is from a
-        # build, and no notification is sent.
+    def test_forAction_bpr(self):
+        # If we try to send mail about an accepted bpr with no source, it is
+        # from a build, and no notification is sent.
         bpr = self.factory.makeBinaryPackageRelease()
         archive = self.factory.makeArchive()
         pocket = self.factory.getAnyPocket()
         distroseries = self.factory.makeDistroSeries()
         person = self.factory.makePerson()
-        notify(
-            person, None, [bpr], [], archive, distroseries, pocket,
-            action='accepted')
+        mailer = PackageUploadMailer.forAction(
+            "accepted", person, None, [bpr], [], archive, distroseries, pocket)
+        mailer.sendAll()
         notifications = pop_notifications()
         self.assertEqual(0, len(notifications))
 
     def test_reject_changes_file_no_email(self):
-        # If we are rejecting a mail, and the person to notify has no
+        # If we are rejecting an upload, and the person to notify has no
         # preferred email, we should return early.
         archive = self.factory.makeArchive()
         distroseries = self.factory.makeDistroSeries()
         uploader = self.factory.makePerson()
         get_property_cache(uploader).preferredemail = None
-        email = '%s <foo@xxxxxxxxxxx>' % uploader.displayname
-        changes = {'Changed-By': email, 'Maintainer': email}
-        logger = BufferLogger()
-        reject_changes_file(
-            uploader, '/tmp/changes', changes, archive, distroseries, '',
-            logger=logger)
-        self.assertIn(
-            'No recipients have a preferred email.', logger.getLogBuffer())
+        info = fetch_information(None, None, None)
+        recipients, _ = PackageUploadMailer.getRecipientsForAction(
+            'rejected', info, uploader, None, [], archive, distroseries,
+            PackagePublishingPocket.RELEASE)
+        self.assertEqual({}, recipients)
 
     def test_reject_with_no_changes(self):
         # If we don't have any files and no changes content, nothing happens.
         archive = self.factory.makeArchive()
         distroseries = self.factory.makeDistroSeries()
         pocket = self.factory.getAnyPocket()
-        notify(None, None, (), (), archive, distroseries, pocket)
+        mailer = PackageUploadMailer.forAction(
+            "rejected", None, None, (), (), archive, distroseries, pocket)
+        mailer.sendAll()
         notifications = pop_notifications()
         self.assertEqual(0, len(notifications))
 
@@ -351,20 +370,18 @@
         # Now set the uploaders.
         component = getUtility(IComponentSet).ensure('main')
         if component not in distroseries.components:
-            store = Store.of(distroseries)
-            store.add(
-                ComponentSelection(
-                    distroseries=distroseries, component=component))
+            self.factory.makeComponentSelection(
+                distroseries=distroseries, component=component)
         distribution.main_archive.newComponentUploader(maintainer, component)
         distribution.main_archive.newComponentUploader(changer, component)
-        observed = get_upload_notification_recipients(
-            blamer, archive, distroseries, logger=None, changes=changes)
-        self.assertContentEqual(
-            [format_address_for_person(person) for person in expected],
-            observed)
+        info = fetch_information(None, None, changes)
+        observed, _ = PackageUploadMailer.getRecipientsForAction(
+            'accepted', info, blamer, None, [], archive, distroseries,
+            PackagePublishingPocket.RELEASE)
+        self.assertThat(observed, KeysEqual(*expected))
 
-    def test_get_upload_notification_recipients_good_emails(self):
-        # Test get_upload_notification_recipients with good email addresses..
+    def test_getRecipientsForAction_good_emails(self):
+        # Test getRecipientsForAction with good email addresses..
         blamer, maintainer, changer = self._setup_recipients()
         changes = {
             'Date': '2001-01-01',
@@ -376,7 +393,7 @@
             [blamer, maintainer, changer],
             changes, blamer, maintainer, changer)
 
-    def test_get_upload_notification_recipients_bad_maintainer_email(self):
+    def test_getRecipientsForAction_bad_maintainer_email(self):
         blamer, maintainer, changer = self._setup_recipients()
         changes = {
             'Date': '2001-01-01',
@@ -387,9 +404,8 @@
         self.assertRecipientsEqual(
             [blamer, changer], changes, blamer, maintainer, changer)
 
-    def test_get_upload_notification_recipients_bad_changedby_email(self):
-        # Test get_upload_notification_recipients with invalid changedby
-        # email address.
+    def test_getRecipientsForAction_bad_changedby_email(self):
+        # Test getRecipientsForAction with invalid changedby email address.
         blamer, maintainer, changer = self._setup_recipients()
         changes = {
             'Date': '2001-01-01',
@@ -400,7 +416,7 @@
         self.assertRecipientsEqual(
             [blamer, maintainer], changes, blamer, maintainer, changer)
 
-    def test_get_upload_notification_recipients_unsigned_copy_archive(self):
+    def test_getRecipientsForAction_unsigned_copy_archive(self):
         # Notifications for unsigned build uploads to copy archives only go
         # to the archive owner.
         _, maintainer, changer = self._setup_recipients()
@@ -414,9 +430,92 @@
             [], changes, None, maintainer, changer,
             purpose=ArchivePurpose.COPY)
 
-    def test_assemble_body_handles_no_preferred_email_for_changer(self):
+    def test__getHeaders_primary(self):
+        # _getHeaders returns useful values for headers used for filtering.
+        # For a primary archive, this includes the maintainer and changer.
+        blamer, maintainer, changer = self._setup_recipients()
+        distroseries = self.factory.makeDistroSeries()
+        archive = distroseries.distribution.main_archive
+        component = getUtility(IComponentSet).ensure("main")
+        if component not in distroseries.components:
+            self.factory.makeComponentSelection(
+                distroseries=distroseries, component=component)
+        archive.newComponentUploader(maintainer, component)
+        archive.newComponentUploader(changer, component)
+        spr = self.factory.makeSourcePackageRelease(
+            component=component, section_name="libs")
+        changes = {
+            'Date': '2001-01-01',
+            'Changed-By': 'Changer <changer@xxxxxxxxxxx>',
+            'Maintainer': 'Maintainer <maintainer@xxxxxxxxxxx>',
+            'Changes': ' * Foo!',
+            }
+        mailer = PackageUploadMailer.forAction(
+            "accepted", blamer, spr, [], [], archive, distroseries,
+            PackagePublishingPocket.RELEASE, changes=changes)
+        recipients = dict(mailer._recipients.getRecipientPersons())
+        for email, rationale in (
+                (blamer.preferredemail.email, "Requester"),
+                ("maintainer@xxxxxxxxxxx", "Maintainer"),
+                ("changer@xxxxxxxxxxx", "Changed-By")):
+            headers = mailer._getHeaders(email, recipients[email])
+            self.assertThat(
+                headers,
+                ContainsDict({
+                    "X-Launchpad-Message-Rationale": Equals(rationale),
+                    "X-Launchpad-Notification-Type": Equals("package-upload"),
+                    "X-Katie": Equals("Launchpad actually"),
+                    "X-Launchpad-Archive": Equals(archive.reference),
+                    "X-Launchpad-Component": Equals(
+                        "component=main, section=libs"),
+                    }))
+            self.assertNotIn("X-Launchpad-PPA", headers)
+
+    def test__getHeaders_ppa(self):
+        # _getHeaders returns useful values for headers used for filtering.
+        # For a PPA, this includes other people with component upload
+        # permissions.
+        blamer = self.factory.makePerson()
+        uploader = self.factory.makePerson()
+        distroseries = self.factory.makeUbuntuDistroSeries()
+        archive = self.factory.makeArchive(
+            distribution=distroseries.distribution, purpose=ArchivePurpose.PPA)
+        component = getUtility(IComponentSet).ensure("main")
+        if component not in distroseries.components:
+            self.factory.makeComponentSelection(
+                distroseries=distroseries, component=component)
+        archive.newComponentUploader(uploader, component)
+        spr = self.factory.makeSourcePackageRelease(
+            component=component, section_name="libs")
+        changes = {
+            'Date': '2001-01-01',
+            'Changed-By': 'Changer <changer@xxxxxxxxxxx>',
+            'Maintainer': 'Maintainer <maintainer@xxxxxxxxxxx>',
+            'Changes': ' * Foo!',
+            }
+        mailer = PackageUploadMailer.forAction(
+            "accepted", blamer, spr, [], [], archive, distroseries,
+            PackagePublishingPocket.RELEASE, changes=changes)
+        recipients = dict(mailer._recipients.getRecipientPersons())
+        for email, rationale in (
+                (blamer.preferredemail.email, "Requester"),
+                (uploader.preferredemail.email, "PPA-Uploader")):
+            headers = mailer._getHeaders(email, recipients[email])
+            self.assertThat(
+                headers,
+                ContainsDict({
+                    "X-Launchpad-Message-Rationale": Equals(rationale),
+                    "X-Launchpad-Notification-Type": Equals("package-upload"),
+                    "X-Katie": Equals("Launchpad actually"),
+                    "X-Launchpad-Archive": Equals(archive.reference),
+                    "X-Launchpad-PPA": Equals(get_ppa_reference(archive)),
+                    "X-Launchpad-Component": Equals(
+                        "component=main, section=libs"),
+                    }))
+
+    def test__getTemplateParams_handles_no_preferred_email_for_changer(self):
         # If changer has no preferred email address,
-        # assemble_body should still work.
+        # _getTemplateParams should still work.
         spr = self.factory.makeSourcePackageRelease()
         blamer = self.factory.makePerson()
         archive = self.factory.makeArchive()
@@ -424,11 +523,14 @@
 
         spr.creator.setPreferredEmail(None)
 
-        body = assemble_body(blamer, spr, [], archive, series, "",
-                             None, "unapproved")
-        self.assertIn("Waiting for approval", body)
+        mailer = PackageUploadMailer.forAction(
+            "unapproved", blamer, spr, [], [], archive, series,
+            PackagePublishingPocket.RELEASE)
+        email, recipient = list(mailer._recipients.getRecipientPersons())[0]
+        params = mailer._getTemplateParams(email, recipient)
+        self.assertEqual("Waiting for approval", params["STATUS"])
 
-    def test_assemble_body_inserts_package_url_for_distro_upload(self):
+    def test__getTemplateParams_inserts_package_url_for_distro_upload(self):
         # The email body should contain the canonical url to the package
         # page in the target distroseries.
         spr = self.factory.makeSourcePackageRelease()
@@ -436,13 +538,16 @@
         archive = self.factory.makeArchive(purpose=ArchivePurpose.PRIMARY)
         series = self.factory.makeDistroSeries()
 
-        body = assemble_body(blamer, spr, [], archive, series, "",
-                             None, "unapproved")
+        mailer = PackageUploadMailer.forAction(
+            "unapproved", blamer, spr, [], [], archive, series,
+            PackagePublishingPocket.RELEASE)
+        email, recipient = list(mailer._recipients.getRecipientPersons())[0]
+        params = mailer._getTemplateParams(email, recipient)
         dsspr = DistributionSourcePackageRelease(series.distribution, spr)
         url = canonical_url(dsspr)
-        self.assertIn(url, body)
+        self.assertEqual(url, params["SPR_URL"])
 
-    def test__is_auto_sync_upload__no_preferred_email_for_changer(self):
+    def test_is_auto_sync_upload__no_preferred_email_for_changer(self):
         # If changer has no preferred email address,
         # is_auto_sync_upload should still work.
         result = is_auto_sync_upload(

=== modified file 'lib/lp/soyuz/model/queue.py'
--- lib/lp/soyuz/model/queue.py	2015-07-08 16:05:11 +0000
+++ lib/lp/soyuz/model/queue.py	2015-08-25 14:09:28 +0000
@@ -82,7 +82,6 @@
     cachedproperty,
     get_property_cache,
     )
-from lp.soyuz.adapters.notification import notify
 from lp.soyuz.enums import (
     PackageUploadCustomFormat,
     PackageUploadStatus,
@@ -115,6 +114,7 @@
     QueueStateWriteProtectedError,
     )
 from lp.soyuz.interfaces.section import ISectionSet
+from lp.soyuz.mail.packageupload import PackageUploadMailer
 from lp.soyuz.model.binarypackagename import BinaryPackageName
 from lp.soyuz.model.binarypackagerelease import BinaryPackageRelease
 from lp.soyuz.model.component import Component
@@ -915,11 +915,14 @@
         else:
             changesfile_content = 'No changes file content available.'
         blamee = self.findPersonToNotify()
-        notify(
-            blamee, self.sourcepackagerelease, self.builds, self.customfiles,
-            self.archive, self.distroseries, self.pocket, summary_text,
-            changes, changesfile_content, changes_file_object,
-            status_action[self.status], dry_run=dry_run, logger=logger)
+        mailer = PackageUploadMailer.forAction(
+            status_action[self.status], blamee, self.sourcepackagerelease,
+            self.builds, self.customfiles, self.archive, self.distroseries,
+            self.pocket, summary_text=summary_text, changes=changes,
+            changesfile_content=changesfile_content,
+            changesfile_object=changes_file_object, dry_run=dry_run,
+            logger=logger)
+        mailer.sendAll()
 
     @property
     def components(self):

=== modified file 'lib/lp/soyuz/scripts/packagecopier.py'
--- lib/lp/soyuz/scripts/packagecopier.py	2015-07-09 20:06:17 +0000
+++ lib/lp/soyuz/scripts/packagecopier.py	2015-08-25 14:09:28 +0000
@@ -19,7 +19,6 @@
 from zope.security.proxy import removeSecurityProxy
 
 from lp.services.database.bulk import load_related
-from lp.soyuz.adapters.notification import notify
 from lp.soyuz.adapters.overrides import SourceOverride
 from lp.soyuz.enums import SourcePackageFormat
 from lp.soyuz.interfaces.archive import CannotCopy
@@ -31,6 +30,7 @@
     ISourcePackagePublishingHistory,
     )
 from lp.soyuz.interfaces.queue import IPackageUploadCustom
+from lp.soyuz.mail.packageupload import PackageUploadMailer
 from lp.soyuz.model.processacceptedbugsjob import (
     close_bugs_for_sourcepublication,
     )
@@ -572,9 +572,11 @@
             if series is None:
                 series = source.distroseries
             # In zopeless mode this email will be sent immediately.
-            notify(
-                person, source.sourcepackagerelease, [], [], archive,
-                series, pocket, summary_text=error_text, action='rejected')
+            mailer = PackageUploadMailer.forAction(
+                'rejected', person, source.sourcepackagerelease, [], [],
+                archive, series, pocket, summary_text=error_text,
+                logger=logger)
+            mailer.sendAll()
         raise CannotCopy(error_text)
 
     overrides_index = 0
@@ -610,14 +612,15 @@
             sponsor=sponsor, packageupload=packageupload,
             phased_update_percentage=phased_update_percentage, logger=logger)
         if send_email:
-            notify(
-                person, source.sourcepackagerelease, [], [], archive,
-                destination_series, pocket, action='accepted',
+            mailer = PackageUploadMailer.forAction(
+                'accepted', person, source.sourcepackagerelease, [], [],
+                archive, destination_series, pocket,
                 announce_from_person=announce_from_person,
-                previous_version=old_version)
+                previous_version=old_version, logger=logger)
+            mailer.sendAll()
         if not archive.private and has_restricted_files(source):
             # Fix copies by unrestricting files with privacy mismatch.
-            # We must do this *after* calling notify (which only
+            # We must do this *after* calling mailer.sendAll (which only
             # actually sends mail on commit), because otherwise the new
             # changelog LFA won't be visible without a commit, which may
             # not be safe here.

=== modified file 'lib/lp/soyuz/scripts/tests/test_copypackage.py'
--- lib/lp/soyuz/scripts/tests/test_copypackage.py	2015-05-19 02:24:48 +0000
+++ lib/lp/soyuz/scripts/tests/test_copypackage.py	2015-08-25 14:09:28 +0000
@@ -4,10 +4,7 @@
 __metaclass__ = type
 
 import datetime
-from textwrap import (
-    dedent,
-    fill,
-    )
+from textwrap import dedent
 
 import pytz
 from testtools.content import text_content
@@ -1426,24 +1423,20 @@
         [notification] = pop_notifications()
         self.assertEqual(
             target_archive.reference, notification['X-Launchpad-Archive'])
-        body = notification.get_payload()[0].get_payload()
-        expected = (dedent("""\
+        body = notification.get_payload(decode=True)
+        expected = dedent("""\
             Accepted:
              OK: foo_1.0-2.dsc
                  -> Component: main Section: base
 
-            foo (1.0-2) unstable; urgency=3Dlow
+            foo (1.0-2) unstable; urgency=low
 
               * 1.0-2.
 
-            --
+            %s
             http://launchpad.dev/~archiver/+archive/ubuntutest/ppa
-            """) +
-            # Slight contortion to avoid a long line.
-            fill(dedent("""\
-            You are receiving this email because you are the uploader of the
-            above PPA package.
-            """), 72) + "\n")
+            You are receiving this email because you made this upload.
+            """ % "-- ")
         self.assertEqual(expected, body)
 
     def test_copy_generates_notification(self):
@@ -1481,8 +1474,7 @@
         # Spurious newlines are a pain and don't really affect the end
         # results so stripping is the easiest route here.
         expected_text.strip()
-        body = mail.get_payload()[0].get_payload()
-        self.assertEqual(expected_text, body)
+        body = announcement.get_payload()[0].get_payload()
         self.assertEqual(expected_text, body)
 
     def test_sponsored_copy_notification(self):

=== modified file 'lib/lp/soyuz/stories/soyuz/xx-queue-pages.txt'
--- lib/lp/soyuz/stories/soyuz/xx-queue-pages.txt	2013-09-27 04:13:23 +0000
+++ lib/lp/soyuz/stories/soyuz/xx-queue-pages.txt	2015-08-25 14:09:28 +0000
@@ -347,9 +347,11 @@
 if it is someone other than the uploader) and (usually) an email to the
 distroseries' announcement list (see nascentupload-announcements.txt).
 
-    >>> [notification, announcement] = pop_notifications()
-    >>> print sort_addresses(notification['To'])
-    Daniel Silverstone <daniel.silverstone@xxxxxxxxxxxxx>,
+    >>> [changer_notification, signer_notification,
+    ...  announcement] = pop_notifications()
+    >>> print changer_notification['To']
+    Daniel Silverstone <daniel.silverstone@xxxxxxxxxxxxx>
+    >>> print signer_notification['To']
     Foo Bar <foo.bar@xxxxxxxxxxxxx>
     >>> print announcement['To']
     autotest_changes@xxxxxxxxxx
@@ -511,6 +513,8 @@
 
 Rejecting 'alsa-utils' source:
 
+    >>> stub.test_emails = []
+
     >>> upload_manager_browser.getControl(name="QUEUE_ID").value = ['4']
     >>> upload_manager_browser.getControl(name="Reject").disabled
     False
@@ -543,8 +547,8 @@
     Rejected:
     Rejected by Sample Person: Foo
     ...
-    You are receiving this email because you are the uploader, maintainer or
-    signer of the above package.
+    You are receiving this email because you are the most recent person
+    listed in this package's changelog.
     <BLANKLINE>
 
 The override controls are now available for rejected packages.

=== modified file 'lib/lp/soyuz/tests/test_distroseriesqueue_debian_installer.py'
--- lib/lp/soyuz/tests/test_distroseriesqueue_debian_installer.py	2015-07-21 09:04:01 +0000
+++ lib/lp/soyuz/tests/test_distroseriesqueue_debian_installer.py	2015-08-25 14:09:28 +0000
@@ -7,6 +7,7 @@
 of debian-installer custom upload extraction.
 """
 
+from itertools import chain
 import os
 
 import transaction
@@ -52,11 +53,16 @@
         self.assertEqual(1, len(upload.queue_root.customfiles))
 
     def test_generates_mail(self):
-        # Two email messages were generated (acceptance and announcement).
+        # Three email messages were generated (acceptance to signer,
+        # acceptance to changer, and announcement).
         self.anything_policy.setDistroSeriesAndPocket("hoary-test")
         self.anything_policy.distroseries.changeslist = "announce@xxxxxxxxxxx"
         self.uploadTestData()
-        self.assertEqual(2, len(stub.test_emails))
+        self.assertContentEqual(
+            ["announce@xxxxxxxxxxx", "celso.providelo@xxxxxxxxxxxxx",
+             "foo.bar@xxxxxxxxxxxxx"],
+            list(chain.from_iterable(
+                [to_addrs for _, to_addrs, _ in stub.test_emails])))
 
     def test_bad_upload_remains_in_accepted(self):
         # Bad debian-installer uploads remain in accepted.  Simulate an

=== modified file 'lib/lp/soyuz/tests/test_packagecopyjob.py'
--- lib/lp/soyuz/tests/test_packagecopyjob.py	2015-04-09 05:16:37 +0000
+++ lib/lp/soyuz/tests/test_packagecopyjob.py	2015-08-25 14:09:28 +0000
@@ -1230,12 +1230,14 @@
         # do it here.
         emails = pop_notifications(sort_key=operator.itemgetter('To'))
 
-        # We expect an uploader email and an announcement to the changeslist.
-        self.assertEqual(2, len(emails))
-        self.assertIn("requester@xxxxxxxxxxx", emails[0]['To'])
-        self.assertIn("changes@xxxxxxxxxxx", emails[1]['To'])
+        # We expect an email to the signer, an email to the uploader, and an
+        # announcement to the changeslist.
+        self.assertEqual(3, len(emails))
+        self.assertIn("foo.bar@xxxxxxxxxxxxx", emails[0]['To'])
+        self.assertIn("requester@xxxxxxxxxxx", emails[1]['To'])
+        self.assertIn("changes@xxxxxxxxxxx", emails[2]['To'])
         self.assertEqual(
-            "Nancy Requester <requester@xxxxxxxxxxx>", emails[1]['From'])
+            "Nancy Requester <requester@xxxxxxxxxxx>", emails[2]['From'])
 
     def test_silent(self):
         # Copies into a non-PPA archive normally send emails. They can

=== modified file 'lib/lp/soyuz/tests/test_packageupload.py'
--- lib/lp/soyuz/tests/test_packageupload.py	2015-08-03 12:59:18 +0000
+++ lib/lp/soyuz/tests/test_packageupload.py	2015-08-25 14:09:28 +0000
@@ -29,7 +29,6 @@
 from lp.services.job.interfaces.job import JobStatus
 from lp.services.librarian.browser import ProxiedLibraryFileAlias
 from lp.services.mail import stub
-from lp.services.mail.sendmail import format_address_for_person
 from lp.soyuz.adapters.overrides import SourceOverride
 from lp.soyuz.enums import (
     PackagePublishingStatus,
@@ -195,9 +194,9 @@
         upload, uploader = self.makeSourcePackageUpload()
         upload.acceptFromQueue()
         self.assertEqual(2, len(stub.test_emails))
-        # Emails sent are the announcement and the uploader's notification:
+        # Emails sent are the uploader's notification and the announcement:
+        self.assertEmail([uploader.preferredemail.email])
         self.assertEmail(["autotest_changes@xxxxxxxxxx"])
-        self.assertEmail([format_address_for_person(uploader)])
 
     def test_acceptFromQueue_source_backports_sends_no_announcement(self):
         # Accepting a source package into BACKPORTS does not send an
@@ -211,7 +210,7 @@
         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([format_address_for_person(uploader)])
+        self.assertEmail([uploader.preferredemail.email])
 
     def test_acceptFromQueue_source_translations_sends_no_email(self):
         # Accepting source packages in the "translations" section (i.e.
@@ -316,7 +315,7 @@
         upload, uploader = self.makeSourcePackageUpload()
         upload.rejectFromQueue(self.factory.makePerson())
         self.assertEqual(1, len(stub.test_emails))
-        self.assertEmail([format_address_for_person(uploader)])
+        self.assertEmail([uploader.preferredemail.email])
 
     def test_rejectFromQueue_binary_sends_email(self):
         # Rejecting a binary package sends an email to the uploader.
@@ -324,7 +323,7 @@
         upload, uploader = self.makeBuildPackageUpload()
         upload.rejectFromQueue(self.factory.makePerson())
         self.assertEqual(1, len(stub.test_emails))
-        self.assertEmail([format_address_for_person(uploader)])
+        self.assertEmail([uploader.preferredemail.email])
 
     def test_rejectFromQueue_source_translations_sends_no_email(self):
         # Rejecting a language pack sends no email.


Follow ups