← Back to team overview

launchpad-reviewers team mailing list archive

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

 

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

Commit message:
Convert team membership notifications to BaseMailer.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #296889 in Launchpad itself: "Team membership notification doesn't explain why you're receiving it"
  https://bugs.launchpad.net/launchpad/+bug/296889
  Bug #508897 in Launchpad itself: "Add X-Launchpad-Membership-Rationale header to team emails"
  https://bugs.launchpad.net/launchpad/+bug/508897

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

Convert team membership notifications to BaseMailer.

The main subtlety here is that there may be multiple teams involved (for example, you may be receiving a notification about a change in one team due to your membership of another team), so I had to think quite carefully to make sure that the semantics of X-Launchpad-Message-Rationale construction were followed properly.  Also, I've tried to ensure that X-Launchpad-Message-Rationale describes your relation to the object that causes you to receive the message, while X-Launchpad-Notification-Type describes what action was performed.  The diff to lib/lp/registry/doc/teammembership-email-notification.txt serves as a fairly good summary.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/team-mail into lp:launchpad.
=== modified file 'lib/lp/code/mail/branchmergeproposal.py'
--- lib/lp/code/mail/branchmergeproposal.py	2015-08-23 22:53:55 +0000
+++ lib/lp/code/mail/branchmergeproposal.py	2015-08-27 14:49:13 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2011 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2015 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Email notifications related to branch merge proposals."""
@@ -93,7 +93,7 @@
             merge_proposal, from_address, message_id=get_msgid(),
             preview_diff=merge_proposal.preview_diff, direct_email=True)
 
-    def _getReplyToAddress(self):
+    def _getReplyToAddress(self, email, recipient):
         """Return the address to use for the reply-to header."""
         return self.merge_proposal.address
 

=== modified file 'lib/lp/code/mail/tests/test_codereviewcomment.py'
--- lib/lp/code/mail/tests/test_codereviewcomment.py	2015-08-23 22:53:55 +0000
+++ lib/lp/code/mail/tests/test_codereviewcomment.py	2015-08-27 14:49:13 +0000
@@ -131,7 +131,10 @@
         mailer, subscriber = self.makeMailer()
         merge_proposal = mailer.code_review_comment.branch_merge_proposal
         expected = 'mp+%d@xxxxxxxxxxxxxxxxxx' % merge_proposal.id
-        self.assertEqual(expected, mailer._getReplyToAddress())
+        self.assertEqual(
+            expected,
+            mailer._getReplyToAddress(
+                subscriber.preferredemail.email, subscriber))
 
     def test_generateEmail(self):
         """Ensure mailer's generateEmail method produces expected values."""
@@ -155,7 +158,8 @@
                     'X-Launchpad-Notification-Type': 'code-review',
                     'X-Launchpad-Project': source_branch.product.name,
                     'Message-Id': message.rfc822msgid,
-                    'Reply-To': mailer._getReplyToAddress(),
+                    'Reply-To': mailer._getReplyToAddress(
+                        subscriber.preferredemail.email, subscriber),
                     'In-Reply-To': message.parent.rfc822msgid}
         for header, value in expected.items():
             self.assertEqual(value, ctrl.headers[header], header)

=== modified file 'lib/lp/registry/doc/teammembership-email-notification.txt'
--- lib/lp/registry/doc/teammembership-email-notification.txt	2015-02-26 03:00:35 +0000
+++ lib/lp/registry/doc/teammembership-email-notification.txt	2015-08-27 14:49:13 +0000
@@ -84,10 +84,13 @@
 
     >>> print_distinct_emails(include_reply_to=True)
     From: Ubuntu Team <noreply@xxxxxxxxxxxxx>
-    To: colin.watson@xxxxxxxxxxxxxxx, foo.bar@xxxxxxxxxxxxx,
-        jeff.waugh@xxxxxxxxxxxxxxx, limi@xxxxxxxxx
+    To: Alexander Limi <limi@xxxxxxxxx>,
+        Colin Watson <colin.watson@xxxxxxxxxxxxxxx>,
+        Foo Bar <foo.bar@xxxxxxxxxxxxx>,
+        Jeff Waugh <jeff.waugh@xxxxxxxxxxxxxxx>
     Reply-To: robertc@xxxxxxxxxxxxxxxxx
     X-Launchpad-Message-Rationale: Admin (ubuntu-team)
+    X-Launchpad-Notification-Type: pending-membership-approval
     Subject: lifeless wants to join
     <BLANKLINE>
     Robert Collins (lifeless) wants to be a member of Ubuntu Team (ubuntu-
@@ -100,14 +103,17 @@
     -- =
     <BLANKLINE>
     You received this email because you are an admin of the Ubuntu Team team.
+    <BLANKLINE>
     ----------------------------------------
     From: Ubuntu Team <noreply@xxxxxxxxxxxxx>
-    To: mark@xxxxxxxxxxx
+    To: Mark Shuttleworth <mark@xxxxxxxxxxx>
     Reply-To: robertc@xxxxxxxxxxxxxxxxx
     X-Launchpad-Message-Rationale: Owner (ubuntu-team)
+    X-Launchpad-Notification-Type: pending-membership-approval
     Subject: lifeless wants to join
     ...
     You received this email because you are the owner of the Ubuntu Team team.
+    <BLANKLINE>
     ----------------------------------------
 
 Declining a proposed member should generate notifications for both the
@@ -128,22 +134,39 @@
 
     >>> print_distinct_emails()
     From: Ubuntu Team <noreply@xxxxxxxxxxxxx>
-    To: colin.watson@xxxxxxxxxxxxxxx, foo.bar@xxxxxxxxxxxxx,
-        jeff.waugh@xxxxxxxxxxxxxxx, limi@xxxxxxxxx, mark@xxxxxxxxxxx
+    To: Alexander Limi <limi@xxxxxxxxx>,
+        Colin Watson <colin.watson@xxxxxxxxxxxxxxx>,
+        Foo Bar <foo.bar@xxxxxxxxxxxxx>,
+        Jeff Waugh <jeff.waugh@xxxxxxxxxxxxxxx>,
+        Mark Shuttleworth <mark@xxxxxxxxxxx>
+    X-Launchpad-Message-Rationale: Admin (ubuntu-team)
+    X-Launchpad-Notification-Type: membership-statuschange
     Subject: lifeless declined by mark
     <BLANKLINE>
     The membership status of Robert Collins (lifeless) in the team Ubuntu
     Team (ubuntu-team) was changed by Mark Shuttleworth (mark) from
     Proposed to Declined.
     <http://launchpad.dev/~ubuntu-team>
+    <BLANKLINE>
+    -- =
+    <BLANKLINE>
+    You received this email because you are an admin of the Ubuntu Team team.
+    <BLANKLINE>
     ----------------------------------------
     From: Ubuntu Team <noreply@xxxxxxxxxxxxx>
-    To: robertc@xxxxxxxxxxxxxxxxx
+    To: Robert Collins <robertc@xxxxxxxxxxxxxxxxx>
+    X-Launchpad-Message-Rationale: Member (ubuntu-team)
+    X-Launchpad-Notification-Type: membership-statuschange
     Subject: lifeless declined by mark
     <BLANKLINE>
     The status of your membership in the team Ubuntu Team (ubuntu-team) was
     changed by Mark Shuttleworth (mark) from Proposed to Declined.
     <http://launchpad.dev/~ubuntu-team>
+    <BLANKLINE>
+    -- =
+    <BLANKLINE>
+    You received this email because you are the affected member.
+    <BLANKLINE>
     ----------------------------------------
 
 The same goes for approving a proposed member.
@@ -169,8 +192,13 @@
 
     >>> print_distinct_emails()
     From: Ubuntu Team <noreply@xxxxxxxxxxxxx>
-    To: colin.watson@xxxxxxxxxxxxxxx, foo.bar@xxxxxxxxxxxxx,
-        jeff.waugh@xxxxxxxxxxxxxxx, limi@xxxxxxxxx, mark@xxxxxxxxxxx
+    To: Alexander Limi <limi@xxxxxxxxx>,
+        Colin Watson <colin.watson@xxxxxxxxxxxxxxx>,
+        Foo Bar <foo.bar@xxxxxxxxxxxxx>,
+        Jeff Waugh <jeff.waugh@xxxxxxxxxxxxxxx>,
+        Mark Shuttleworth <mark@xxxxxxxxxxx>
+    X-Launchpad-Message-Rationale: Admin (ubuntu-team)
+    X-Launchpad-Notification-Type: membership-statuschange
     Subject: daf approved by mark
     <BLANKLINE>
     The membership status of Dafydd Harries (daf) in the team Ubuntu Team
@@ -180,9 +208,15 @@
     <BLANKLINE>
     Mark Shuttleworth said:
      This is a nice guy; I like him
+    -- =
+    <BLANKLINE>
+    You received this email because you are an admin of the Ubuntu Team team.
+    <BLANKLINE>
     ----------------------------------------
     From: Ubuntu Team <noreply@xxxxxxxxxxxxx>
-    To: daf@xxxxxxxxxxxxx
+    To: Dafydd Harries <daf@xxxxxxxxxxxxx>
+    X-Launchpad-Message-Rationale: Member (ubuntu-team)
+    X-Launchpad-Notification-Type: membership-statuschange
     Subject: daf approved by mark
     <BLANKLINE>
     The status of your membership in the team Ubuntu Team (ubuntu-team) was
@@ -191,6 +225,10 @@
     <BLANKLINE>
     Mark Shuttleworth said:
      This is a nice guy; I like him
+    -- =
+    <BLANKLINE>
+    You received this email because you are the affected member.
+    <BLANKLINE>
     ----------------------------------------
 
 The same for deactivating a membership.
@@ -204,22 +242,37 @@
 
     >>> print_distinct_emails()
     From: Ubuntu Team <noreply@xxxxxxxxxxxxx>
-    To: colin.watson@xxxxxxxxxxxxxxx, foo.bar@xxxxxxxxxxxxx,
-        jeff.waugh@xxxxxxxxxxxxxxx, limi@xxxxxxxxx, mark@xxxxxxxxxxx
+    To: Alexander Limi <limi@xxxxxxxxx>,
+        Colin Watson <colin.watson@xxxxxxxxxxxxxxx>,
+        Foo Bar <foo.bar@xxxxxxxxxxxxx>,
+        Jeff Waugh <jeff.waugh@xxxxxxxxxxxxxxx>,
+        Mark Shuttleworth <mark@xxxxxxxxxxx>
+    X-Launchpad-Message-Rationale: Admin (ubuntu-team)
+    X-Launchpad-Notification-Type: membership-statuschange
     Subject: daf deactivated by mark
     <BLANKLINE>
     The membership status of Dafydd Harries (daf) in the team Ubuntu Team
     (ubuntu-team) was changed by Mark Shuttleworth (mark) from Approved to
     Deactivated.
     <http://launchpad.dev/~ubuntu-team>
+    -- =
+    <BLANKLINE>
+    You received this email because you are an admin of the Ubuntu Team team.
+    <BLANKLINE>
     ----------------------------------------
     From: Ubuntu Team <noreply@xxxxxxxxxxxxx>
-    To: daf@xxxxxxxxxxxxx
+    To: Dafydd Harries <daf@xxxxxxxxxxxxx>
+    X-Launchpad-Message-Rationale: Member (ubuntu-team)
+    X-Launchpad-Notification-Type: membership-statuschange
     Subject: daf deactivated by mark
     <BLANKLINE>
     The status of your membership in the team Ubuntu Team (ubuntu-team) was
     changed by Mark Shuttleworth (mark) from Approved to Deactivated.
     <http://launchpad.dev/~ubuntu-team>
+    -- =
+    <BLANKLINE>
+    You received this email because you are the affected member.
+    <BLANKLINE>
     ----------------------------------------
 
 Team admins can propose their teams using the join() method as well, but
@@ -235,10 +288,13 @@
 
     >>> print_distinct_emails(include_reply_to=True)
     From: Ubuntu Team <noreply@xxxxxxxxxxxxx>
-    To: colin.watson@xxxxxxxxxxxxxxx, foo.bar@xxxxxxxxxxxxx,
-        jeff.waugh@xxxxxxxxxxxxxxx, limi@xxxxxxxxx
+    To: Alexander Limi <limi@xxxxxxxxx>,
+        Colin Watson <colin.watson@xxxxxxxxxxxxxxx>,
+        Foo Bar <foo.bar@xxxxxxxxxxxxx>,
+        Jeff Waugh <jeff.waugh@xxxxxxxxxxxxxxx>
     Reply-To: mark@xxxxxxxxxxx
     X-Launchpad-Message-Rationale: Admin (ubuntu-team)
+    X-Launchpad-Notification-Type: pending-membership-approval
     Subject: admins wants to join
     <BLANKLINE>
     Mark Shuttleworth (mark) wants to make Launchpad Administrators
@@ -251,14 +307,17 @@
     -- =
     <BLANKLINE>
     You received this email because you are an admin of the Ubuntu Team team.
+    <BLANKLINE>
     ----------------------------------------
     From: Ubuntu Team <noreply@xxxxxxxxxxxxx>
-    To: mark@xxxxxxxxxxx
+    To: Mark Shuttleworth <mark@xxxxxxxxxxx>
     Reply-To: mark@xxxxxxxxxxx
     X-Launchpad-Message-Rationale: Owner (ubuntu-team)
+    X-Launchpad-Notification-Type: pending-membership-approval
     Subject: admins wants to join
      ...
     You received this email because you are the owner of the Ubuntu Team team.
+    <BLANKLINE>
     ----------------------------------------
 
 
@@ -281,22 +340,27 @@
 
     >>> print_distinct_emails()
     From: Ubuntu Team <noreply@xxxxxxxxxxxxx>
-    To: marilize@xxxxxxx
+    To: Marilize Coetzee <marilize@xxxxxxx>
     X-Launchpad-Message-Rationale: Member (ubuntu-team)
+    X-Launchpad-Notification-Type: new-member
     Subject: You have been added to ubuntu-team
     <BLANKLINE>
     Celso Providelo (cprov) added you as a member of Ubuntu Team (ubuntu-
     team).
-    <http://launchpad.dev/~ubuntu-team>
+      <http://launchpad.dev/~ubuntu-team>
     <BLANKLINE>
     -- =
     <BLANKLINE>
     You received this email because you are the new member.
+    <BLANKLINE>
     ----------------------------------------
     From: Ubuntu Team <noreply@xxxxxxxxxxxxx>
-    To: colin.watson@xxxxxxxxxxxxxxx, foo.bar@xxxxxxxxxxxxx,
-        jeff.waugh@xxxxxxxxxxxxxxx, limi@xxxxxxxxx
+    To: Alexander Limi <limi@xxxxxxxxx>,
+        Colin Watson <colin.watson@xxxxxxxxxxxxxxx>,
+        Foo Bar <foo.bar@xxxxxxxxxxxxx>,
+        Jeff Waugh <jeff.waugh@xxxxxxxxxxxxxxx>
     X-Launchpad-Message-Rationale: Admin (ubuntu-team)
+    X-Launchpad-Notification-Type: new-member
     Subject: marilize joined ubuntu-team
     <BLANKLINE>
     Marilize Coetzee (marilize) has been added as a member of Ubuntu Team
@@ -308,13 +372,16 @@
     -- =
     <BLANKLINE>
     You received this email because you are an admin of the Ubuntu Team team.
+    <BLANKLINE>
     ----------------------------------------
     From: Ubuntu Team <noreply@xxxxxxxxxxxxx>
-    To: mark@xxxxxxxxxxx
+    To: Mark Shuttleworth <mark@xxxxxxxxxxx>
     X-Launchpad-Message-Rationale: Owner (ubuntu-team)
+    X-Launchpad-Notification-Type: new-member
     Subject: marilize joined ubuntu-team
       ...
     You received this email because you are the owner of the Ubuntu Team team.
+    <BLANKLINE>
     ----------------------------------------
 
 By default, if the newly added member is actually a team, we'll only
@@ -332,7 +399,9 @@
 
     >>> print_distinct_emails()
     From: Ubuntu Team <noreply@xxxxxxxxxxxxx>
-    To: mark@xxxxxxxxxxx
+    To: Mark Shuttleworth <mark@xxxxxxxxxxx>
+    X-Launchpad-Message-Rationale: Admin (ubuntu-mirror-admins)
+    X-Launchpad-Notification-Type: membership-invitation
     Subject: Invitation for ubuntu-mirror-admins to join
     <BLANKLINE>
     Celso Providelo (cprov) has invited Mirror Administrators (ubuntu-
@@ -346,6 +415,13 @@
     <BLANKLINE>
     Regards,
     The Launchpad team
+    <BLANKLINE>
+    -- =
+    <BLANKLINE>
+    You received this email because you are an admin of the Mirror
+    Administrato=
+    rs team.
+    <BLANKLINE>
     ----------------------------------------
 
 If one of the admins accept the invitation, then a notification is sent
@@ -362,18 +438,48 @@
 
     >>> print_distinct_emails()
     From: Ubuntu Team <noreply@xxxxxxxxxxxxx>
-    To: colin.watson@xxxxxxxxxxxxxxx, foo.bar@xxxxxxxxxxxxx,
-        jeff.waugh@xxxxxxxxxxxxxxx, karl@xxxxxxxxxxxxx, limi@xxxxxxxxx,
-        mark@xxxxxxxxxxx
-    Subject: Invitation to ubuntu-mirror-admins accepted by mark
-    <BLANKLINE>
-    Mark Shuttleworth (mark) has accepted the invitation to make Mirror
-    Administrators (ubuntu-mirror-admins) a member of Ubuntu Team (ubuntu-
-    team).
-    <http://launchpad.dev/~ubuntu-team>
-    <BLANKLINE>
-    Mark Shuttleworth said:
-     Of course I want to be part of ubuntu!
+    To: Alexander Limi <limi@xxxxxxxxx>,
+        Colin Watson <colin.watson@xxxxxxxxxxxxxxx>,
+        Foo Bar <foo.bar@xxxxxxxxxxxxx>,
+        Jeff Waugh <jeff.waugh@xxxxxxxxxxxxxxx>
+    X-Launchpad-Message-Rationale: Admin (ubuntu-team)
+    X-Launchpad-Notification-Type: membership-invitation-accepted
+    Subject: Invitation to ubuntu-mirror-admins accepted by mark
+    <BLANKLINE>
+    Mark Shuttleworth (mark) has accepted the invitation to make Mirror
+    Administrators (ubuntu-mirror-admins) a member of Ubuntu Team (ubuntu-
+    team).
+    <http://launchpad.dev/~ubuntu-team>
+    <BLANKLINE>
+    Mark Shuttleworth said:
+     Of course I want to be part of ubuntu!
+    <BLANKLINE>
+    -- =
+    <BLANKLINE>
+    You received this email because you are an admin of the Ubuntu Team team.
+    <BLANKLINE>
+    ----------------------------------------
+    From: Ubuntu Team <noreply@xxxxxxxxxxxxx>
+    To: Karl Tilbury <karl@xxxxxxxxxxxxx>,
+        Mark Shuttleworth <mark@xxxxxxxxxxx>
+    X-Launchpad-Message-Rationale: Member (ubuntu-team) @ubuntu-mirror-admins
+    X-Launchpad-Notification-Type: membership-invitation-accepted
+    Subject: Invitation to ubuntu-mirror-admins accepted by mark
+    <BLANKLINE>
+    Mark Shuttleworth (mark) has accepted the invitation to make Mirror
+    Administrators (ubuntu-mirror-admins) a member of Ubuntu Team (ubuntu-
+    team).
+    <http://launchpad.dev/~ubuntu-team>
+    <BLANKLINE>
+    Mark Shuttleworth said:
+     Of course I want to be part of ubuntu!
+    <BLANKLINE>
+    -- =
+    <BLANKLINE>
+    You received this email because your team Mirror Administrators is the
+    affe=
+    cted member.
+    <BLANKLINE>
     ----------------------------------------
 
 Similarly, a notification is sent if the invitation is declined.
@@ -397,17 +503,47 @@
 
     >>> print_distinct_emails()
     From: Ubuntu Team <noreply@xxxxxxxxxxxxx>
-    To: colin.watson@xxxxxxxxxxxxxxx, foo.bar@xxxxxxxxxxxxx,
-        guilherme.salgado@xxxxxxxxxxxxx, jeff.waugh@xxxxxxxxxxxxxxx,
-        limi@xxxxxxxxx, mark@xxxxxxxxxxx, test@xxxxxxxxxxxxx
-    Subject: Invitation to landscape-developers declined by mark
-    <BLANKLINE>
-    Mark Shuttleworth (mark) has declined the invitation to make Landscape
-    Developers (landscape-developers) a member of Ubuntu Team (ubuntu-team).
-    <http://launchpad.dev/~ubuntu-team>
-    <BLANKLINE>
-    Mark Shuttleworth said:
-     Landscape has nothing to do with ubuntu, unfortunately.
+    To: Alexander Limi <limi@xxxxxxxxx>,
+        Colin Watson <colin.watson@xxxxxxxxxxxxxxx>,
+        Foo Bar <foo.bar@xxxxxxxxxxxxx>,
+        Jeff Waugh <jeff.waugh@xxxxxxxxxxxxxxx>,
+        Mark Shuttleworth <mark@xxxxxxxxxxx>
+    X-Launchpad-Message-Rationale: Admin (ubuntu-team)
+    X-Launchpad-Notification-Type: membership-invitation-declined
+    Subject: Invitation to landscape-developers declined by mark
+    <BLANKLINE>
+    Mark Shuttleworth (mark) has declined the invitation to make Landscape
+    Developers (landscape-developers) a member of Ubuntu Team (ubuntu-team).
+    <http://launchpad.dev/~ubuntu-team>
+    <BLANKLINE>
+    Mark Shuttleworth said:
+     Landscape has nothing to do with ubuntu, unfortunately.
+    <BLANKLINE>
+    -- =
+    <BLANKLINE>
+    You received this email because you are an admin of the Ubuntu Team team.
+    <BLANKLINE>
+    ----------------------------------------
+    From: Ubuntu Team <noreply@xxxxxxxxxxxxx>
+    To: Guilherme Salgado <guilherme.salgado@xxxxxxxxxxxxx>,
+        Sample Person <test@xxxxxxxxxxxxx>
+    X-Launchpad-Message-Rationale: Member (ubuntu-team) @landscape-developers
+    X-Launchpad-Notification-Type: membership-invitation-declined
+    Subject: Invitation to landscape-developers declined by mark
+    <BLANKLINE>
+    Mark Shuttleworth (mark) has declined the invitation to make Landscape
+    Developers (landscape-developers) a member of Ubuntu Team (ubuntu-team).
+    <http://launchpad.dev/~ubuntu-team>
+    <BLANKLINE>
+    Mark Shuttleworth said:
+     Landscape has nothing to do with ubuntu, unfortunately.
+    <BLANKLINE>
+    -- =
+    <BLANKLINE>
+    You received this email because your team Landscape Developers is the
+    affec=
+    ted member.
+    <BLANKLINE>
     ----------------------------------------
 
 It's also possible to forcibly add a team as a member of another one, by
@@ -423,16 +559,22 @@
 
     >>> print_distinct_emails()
     From: Ubuntu Team <noreply@xxxxxxxxxxxxx>
-    To: foo.bar@xxxxxxxxxxxxx
-    X-Launchpad-Message-Rationale: Indirect member (ubuntu-team)
+    To: Foo Bar <foo.bar@xxxxxxxxxxxxx>
+    X-Launchpad-Message-Rationale: Member (ubuntu-team) @launchpad
+    X-Launchpad-Notification-Type: new-member
     Subject: launchpad joined ubuntu-team
      ...
-    You received this email because launchpad is the new member.
+    You received this email because your team Launchpad Developers is the
+    new m=
+    ember.
+    <BLANKLINE>
     ----------------------------------------
     From: Ubuntu Team <noreply@xxxxxxxxxxxxx>
-    To: colin.watson@xxxxxxxxxxxxxxx, jeff.waugh@xxxxxxxxxxxxxxx,
-        limi@xxxxxxxxx
+    To: Alexander Limi <limi@xxxxxxxxx>,
+        Colin Watson <colin.watson@xxxxxxxxxxxxxxx>,
+        Jeff Waugh <jeff.waugh@xxxxxxxxxxxxxxx>
     X-Launchpad-Message-Rationale: Admin (ubuntu-team)
+    X-Launchpad-Notification-Type: new-member
     Subject: launchpad joined ubuntu-team
     <BLANKLINE>
     Launchpad Developers (launchpad) has been added as a member of Ubuntu
@@ -444,13 +586,16 @@
     -- =
     <BLANKLINE>
     You received this email because you are an admin of the Ubuntu Team team.
+    <BLANKLINE>
     ----------------------------------------
     From: Ubuntu Team <noreply@xxxxxxxxxxxxx>
-    To: mark@xxxxxxxxxxx
+    To: Mark Shuttleworth <mark@xxxxxxxxxxx>
     X-Launchpad-Message-Rationale: Owner (ubuntu-team)
+    X-Launchpad-Notification-Type: new-member
     Subject: launchpad joined ubuntu-team
      ...
     You received this email because you are the owner of the Ubuntu Team team.
+    <BLANKLINE>
     ----------------------------------------
 
 
@@ -483,13 +628,16 @@
     >>> transaction.commit()
     >>> print_distinct_emails()
     From: Ubuntu Team <noreply@xxxxxxxxxxxxx>
-    To: beta-admin@xxxxxxxxxxxxx
+    To: Launchpad Beta Testers Owner <beta-admin@xxxxxxxxxxxxx>
+    X-Launchpad-Message-Rationale: Member (ubuntu-team)
+                                   @launchpad-beta-testers
+    X-Launchpad-Notification-Type: membership-expiration-warning
     Subject: launchpad-beta-testers will expire soon from ubuntu-team
     <BLANKLINE>
     On ..., 9 days from now, the membership
-    of Launchpad Beta Testers (launchpad-beta-testers) (which you are
-    the owner of) in the Ubuntu Team (ubuntu-team) Launchpad team
-    is due to expire.
+    of Launchpad Beta Testers (launchpad-beta-testers) (which you are the
+    owner=
+    of) in the Ubuntu Team (ubuntu-team) Launchpad team is due to expire.
     <http://launchpad.dev/~ubuntu-team>
     <BLANKLINE>
     To prevent this membership from expiring, you should get in touch
@@ -505,6 +653,12 @@
     <BLANKLINE>
     Thanks for using Launchpad!
     <BLANKLINE>
+    -- =
+    <BLANKLINE>
+    You received this email because your team Launchpad Beta Testers is the
+    aff=
+    ected member.
+    <BLANKLINE>
     ----------------------------------------
 
 If the team's renewal policy is ONDEMAND, though, the member is invited
@@ -521,7 +675,9 @@
     >>> transaction.commit()
     >>> print_distinct_emails()
     From: Ubuntu Team <noreply@xxxxxxxxxxxxx>
-    To: colin.watson@xxxxxxxxxxxxxxx
+    To: Colin Watson <colin.watson@xxxxxxxxxxxxxxx>
+    X-Launchpad-Message-Rationale: Member (ubuntu-team)
+    X-Launchpad-Notification-Type: membership-expiration-warning
     Subject: Your membership in ubuntu-team is about to expire
     <BLANKLINE>
     On ..., 9 days from now, your membership
@@ -537,19 +693,26 @@
     <BLANKLINE>
     Thanks for using Launchpad!
     <BLANKLINE>
+    -- =
+    <BLANKLINE>
+    You received this email because you are the affected member.
+    <BLANKLINE>
     ----------------------------------------
 
     >>> beta_testers_on_ubuntu_team.sendExpirationWarningEmail()
     >>> transaction.commit()
     >>> print_distinct_emails()
     From: Ubuntu Team <noreply@xxxxxxxxxxxxx>
-    To: beta-admin@xxxxxxxxxxxxx
+    To: Launchpad Beta Testers Owner <beta-admin@xxxxxxxxxxxxx>
+    X-Launchpad-Message-Rationale: Member (ubuntu-team)
+                                   @launchpad-beta-testers
+    X-Launchpad-Notification-Type: membership-expiration-warning
     Subject: launchpad-beta-testers will expire soon from ubuntu-team
     <BLANKLINE>
     On ..., 9 days from now, the membership
-    of Launchpad Beta Testers (launchpad-beta-testers) (which you are
-    the owner of) in the Ubuntu Team (ubuntu-team) Launchpad team
-    is due to expire.
+    of Launchpad Beta Testers (launchpad-beta-testers) (which you are the
+    owner=
+    of) in the Ubuntu Team (ubuntu-team) Launchpad team is due to expire.
     <http://launchpad.dev/~ubuntu-team>
     <BLANKLINE>
     If you want, you can renew this membership at
@@ -560,6 +723,12 @@
     <BLANKLINE>
     Thanks for using Launchpad!
     <BLANKLINE>
+    -- =
+    <BLANKLINE>
+    You received this email because your team Launchpad Beta Testers is the
+    aff=
+    ected member.
+    <BLANKLINE>
     ----------------------------------------
 
 If the team's renewal policy is NONE but the member has the necessary
@@ -580,12 +749,15 @@
     >>> transaction.commit()
     >>> print_distinct_emails()
     From: Landscape Developers <noreply@xxxxxxxxxxxxx>
-    To: test@xxxxxxxxxxxxx
+    To: Sample Person <test@xxxxxxxxxxxxx>
+    X-Launchpad-Message-Rationale: Member (landscape-developers)
+    X-Launchpad-Notification-Type: membership-expiration-warning
     Subject: Your membership in landscape-developers is about to expire
     <BLANKLINE>
     On ..., 9 days from now, your membership
-    in the Landscape Developers (landscape-developers) Launchpad team
-    is due to expire.
+    in the Landscape Developers (landscape-developers) Launchpad team is due
+    to=
+    expire.
     <http://launchpad.dev/~landscape-developers>
     <BLANKLINE>
     To stay a member of this team you should extend your membership at
@@ -596,6 +768,10 @@
     <BLANKLINE>
     Thanks for using Launchpad!
     <BLANKLINE>
+    -- =
+    <BLANKLINE>
+    You received this email because you are the affected member.
+    <BLANKLINE>
     ----------------------------------------
 
 
@@ -634,7 +810,9 @@
 
     >>> print_distinct_emails()
     From: Mirror Administrators <noreply@xxxxxxxxxxxxx>
-    To: mark@xxxxxxxxxxx
+    To: Mark Shuttleworth <mark@xxxxxxxxxxx>
+    X-Launchpad-Message-Rationale: Admin (ubuntu-mirror-admins)
+    X-Launchpad-Notification-Type: membership-member-renewed
     Subject: karl extended their membership
     <BLANKLINE>
     Karl Tilbury (karl) renewed their own membership in the Mirror
@@ -643,6 +821,13 @@
     <BLANKLINE>
     Regards,
     The Launchpad team
+    <BLANKLINE>
+    -- =
+    <BLANKLINE>
+    You received this email because you are an admin of the Mirror
+    Administrato=
+    rs team.
+    <BLANKLINE>
     ----------------------------------------
 
 
@@ -678,22 +863,37 @@
 
     >>> print_distinct_emails()
     From: Ubuntu Team <noreply@xxxxxxxxxxxxx>
-    To: colin.watson@xxxxxxxxxxxxxxx, foo.bar@xxxxxxxxxxxxx,
-        jeff.waugh@xxxxxxxxxxxxxxx, limi@xxxxxxxxx, mark@xxxxxxxxxxx
+    To: Alexander Limi <limi@xxxxxxxxx>,
+        Colin Watson <colin.watson@xxxxxxxxxxxxxxx>,
+        Foo Bar <foo.bar@xxxxxxxxxxxxx>,
+        Jeff Waugh <jeff.waugh@xxxxxxxxxxxxxxx>,
+        Mark Shuttleworth <mark@xxxxxxxxxxx>
+    X-Launchpad-Message-Rationale: Admin (ubuntu-team)
+    X-Launchpad-Notification-Type: membership-statuschange
     Subject: cprov made admin by mark
     <BLANKLINE>
     The membership status of Celso Providelo (cprov) in the team Ubuntu Team
     (ubuntu-team) was changed by Mark Shuttleworth (mark) from Approved to
     Administrator.
     <http://launchpad.dev/~ubuntu-team>
+    <BLANKLINE>
+    -- =
+    You received this email because you are an admin of the Ubuntu Team team.
+    <BLANKLINE>
     ----------------------------------------
     From: Ubuntu Team <noreply@xxxxxxxxxxxxx>
-    To: celso.providelo@xxxxxxxxxxxxx
+    To: Celso Providelo <celso.providelo@xxxxxxxxxxxxx>
+    X-Launchpad-Message-Rationale: Member (ubuntu-team)
+    X-Launchpad-Notification-Type: membership-statuschange
     Subject: cprov made admin by mark
     <BLANKLINE>
     The status of your membership in the team Ubuntu Team (ubuntu-team) was
     changed by Mark Shuttleworth (mark) from Approved to Administrator.
     <http://launchpad.dev/~ubuntu-team>
+    <BLANKLINE>
+    -- =
+    You received this email because you are the affected member.
+    <BLANKLINE>
     ----------------------------------------
 
 If a team admin changes his own membership, the notification sent will
@@ -710,14 +910,23 @@
 
     >>> print_distinct_emails()
     From: Ubuntu Team <noreply@xxxxxxxxxxxxx>
-    To: celso.providelo@xxxxxxxxxxxxx, colin.watson@xxxxxxxxxxxxxxx,
-        foo.bar@xxxxxxxxxxxxx, limi@xxxxxxxxx, mark@xxxxxxxxxxx
+    To: Alexander Limi <limi@xxxxxxxxx>,
+        Celso Providelo <celso.providelo@xxxxxxxxxxxxx>,
+        Colin Watson <colin.watson@xxxxxxxxxxxxxxx>,
+        Foo Bar <foo.bar@xxxxxxxxxxxxx>,
+        Mark Shuttleworth <mark@xxxxxxxxxxx>
+    X-Launchpad-Message-Rationale: Admin (ubuntu-team)
+    X-Launchpad-Notification-Type: membership-statuschange
     Subject: Membership change: jdub in ubuntu-team
     <BLANKLINE>
     The membership status of Jeff Waugh (jdub) in the team Ubuntu Team
     (ubuntu-team) was changed by the user from Administrator to
     Approved.
     <http://launchpad.dev/~ubuntu-team>
+    <BLANKLINE>
+    -- =
+    You received this email because you are an admin of the Ubuntu Team team.
+    <BLANKLINE>
     ----------------------------------------
 
 Deactivating the membership of a team also generates notifications for
@@ -736,15 +945,38 @@
 
     >>> print_distinct_emails()
     From: Ubuntu Team <noreply@xxxxxxxxxxxxx>
-    To: celso.providelo@xxxxxxxxxxxxx, colin.watson@xxxxxxxxxxxxxxx,
-        foo.bar@xxxxxxxxxxxxx, karl@xxxxxxxxxxxxx, limi@xxxxxxxxx,
-        mark@xxxxxxxxxxx
-    Subject: ubuntu-mirror-admins deactivated by mark
-    <BLANKLINE>
-    The membership status of Mirror Administrators (ubuntu-mirror-admins) in
-    the team Ubuntu Team (ubuntu-team) was changed by Mark Shuttleworth
-    (mark) from Approved to Deactivated.
-    <http://launchpad.dev/~ubuntu-team>
+    To: Alexander Limi <limi@xxxxxxxxx>,
+        Celso Providelo <celso.providelo@xxxxxxxxxxxxx>,
+        Colin Watson <colin.watson@xxxxxxxxxxxxxxx>,
+        Foo Bar <foo.bar@xxxxxxxxxxxxx>
+    X-Launchpad-Message-Rationale: Admin (ubuntu-team)
+    X-Launchpad-Notification-Type: membership-statuschange
+    Subject: ubuntu-mirror-admins deactivated by mark
+    <BLANKLINE>
+    The membership status of Mirror Administrators (ubuntu-mirror-admins) in
+    the team Ubuntu Team (ubuntu-team) was changed by Mark Shuttleworth
+    (mark) from Approved to Deactivated.
+    <http://launchpad.dev/~ubuntu-team>
+    <BLANKLINE>
+    -- =
+    You received this email because you are an admin of the Ubuntu Team team.
+    ----------------------------------------
+    From: Ubuntu Team <noreply@xxxxxxxxxxxxx>
+    To: Karl Tilbury <karl@xxxxxxxxxxxxx>,
+        Mark Shuttleworth <mark@xxxxxxxxxxx>
+    X-Launchpad-Message-Rationale: Member (ubuntu-team) @ubuntu-mirror-admins
+    X-Launchpad-Notification-Type: membership-statuschange
+    Subject: ubuntu-mirror-admins deactivated by mark
+    <BLANKLINE>
+    The membership status of Mirror Administrators (ubuntu-mirror-admins) in
+    the team Ubuntu Team (ubuntu-team) was changed by Mark Shuttleworth
+    (mark) from Approved to Deactivated.
+    <http://launchpad.dev/~ubuntu-team>
+    <BLANKLINE>
+    -- =
+    You received this email because your team Mirror Administrators is the
+    affe=
+    cted member.
     ----------------------------------------
 
 Deactivating memberships can also be done silently (no email
@@ -809,8 +1041,9 @@
     >>> ignored = team_one.addMember(member, owner)
     >>> print_distinct_emails()
     From: Team One ...
-    To: team-member...
+    To: Team-member <team-member...>
     X-Launchpad-Message-Rationale: Member (team-one)
+    X-Launchpad-Notification-Type: new-member
     Subject: You have been added to team-one
     <BLANKLINE>
     Team-owner (team-owner) added you as a member of Team One (team-one).
@@ -818,11 +1051,12 @@
     <BLANKLINE>
     If you would like to subscribe to the team list, use the link below
     to update your Mailing List Subscription preferences.
-      <http://launchpad.dev/people/+me/+editmailinglists>
+      <http://launchpad.dev/~/+editmailinglists>
     <BLANKLINE>
     -- =
     <BLANKLINE>
     You received this email because you are the new member.
+    <BLANKLINE>
     ----------------------------------------
 
 When a team join a team with a mailing list, the new member notification
@@ -833,8 +1067,9 @@
     >>> ignored = team_one.addMember(team_two, owner, force_team_add=True)
     >>> print_distinct_emails()
     From: Team One ...
-    To: team-two...
-    X-Launchpad-Message-Rationale: Indirect member (team-one)
+    To: Team Two <team-two...>
+    X-Launchpad-Message-Rationale: Member (team-one) @team-two
+    X-Launchpad-Notification-Type: new-member
     Subject: team-two joined team-one
     <BLANKLINE>
     Team-owner (team-owner) added Team Two (team-two) (which you are a
@@ -843,11 +1078,12 @@
     <BLANKLINE>
     If you would like to subscribe to the team list, use the link below
     to update your Mailing List Subscription preferences.
-      <http://launchpad.dev/people/+me/+editmailinglists>
+      <http://launchpad.dev/~/+editmailinglists>
     <BLANKLINE>
     -- =
     <BLANKLINE>
-    You received this email because team-two is the new member.
+    You received this email because your team Team Two is the new member.
+    <BLANKLINE>
     ----------------------------------------
 
 

=== modified file 'lib/lp/registry/emailtemplates/membership-expiration-warning-bulk.txt'
--- lib/lp/registry/emailtemplates/membership-expiration-warning-bulk.txt	2011-03-09 18:18:02 +0000
+++ lib/lp/registry/emailtemplates/membership-expiration-warning-bulk.txt	2015-08-27 14:49:13 +0000
@@ -1,8 +1,7 @@
-Hello %(recipient_name)s,
+Hello %(recipient)s,
 
 On %(expiration_date)s, %(approximate_duration)s from now, the membership
-of %(member_name)s (which you are
-the owner of) in the %(team_name)s Launchpad team
+of %(member)s (which you are the owner of) in the %(team)s Launchpad team
 is due to expire.
 <%(team_url)s>
 

=== modified file 'lib/lp/registry/emailtemplates/membership-expiration-warning-personal.txt'
--- lib/lp/registry/emailtemplates/membership-expiration-warning-personal.txt	2011-03-09 18:18:02 +0000
+++ lib/lp/registry/emailtemplates/membership-expiration-warning-personal.txt	2015-08-27 14:49:13 +0000
@@ -1,8 +1,7 @@
-Hello %(recipient_name)s,
+Hello %(recipient)s,
 
 On %(expiration_date)s, %(approximate_duration)s from now, your membership
-in the %(team_name)s Launchpad team
-is due to expire.
+in the %(team)s Launchpad team is due to expire.
 <%(team_url)s>
 
 %(how_to_renew)s

=== modified file 'lib/lp/registry/emailtemplates/membership-expired-bulk.txt'
--- lib/lp/registry/emailtemplates/membership-expired-bulk.txt	2011-03-09 18:18:02 +0000
+++ lib/lp/registry/emailtemplates/membership-expired-bulk.txt	2015-08-27 14:49:13 +0000
@@ -1,6 +1,6 @@
-Hello %(recipient_name)s,
+Hello %(recipient)s,
 
-The membership of %(member_name)s in the %(team_name)s team has expired.
+The membership of %(member)s in the %(team)s team has expired.
 <%(team_url)s>
 
 Regards,

=== modified file 'lib/lp/registry/emailtemplates/membership-expired-personal.txt'
--- lib/lp/registry/emailtemplates/membership-expired-personal.txt	2011-03-09 18:18:02 +0000
+++ lib/lp/registry/emailtemplates/membership-expired-personal.txt	2015-08-27 14:49:13 +0000
@@ -1,6 +1,6 @@
-Hello %(recipient_name)s,
+Hello %(recipient)s,
 
-Your membership in the %(team_name)s team has expired.
+Your membership in the %(team)s team has expired.
 <%(team_url)s>
 
 Regards,

=== modified file 'lib/lp/registry/emailtemplates/membership-invitation-accepted-bulk.txt'
--- lib/lp/registry/emailtemplates/membership-invitation-accepted-bulk.txt	2011-03-09 18:18:02 +0000
+++ lib/lp/registry/emailtemplates/membership-invitation-accepted-bulk.txt	2015-08-27 14:49:13 +0000
@@ -1,5 +1,5 @@
-Hello %(recipient_name)s,
+Hello %(recipient)s,
 
-%(reviewer_name)s has accepted the invitation to make %(member_name)s a member of %(team_name)s.
+%(reviewer)s has accepted the invitation to make %(member)s a member of %(team)s.
 <%(team_url)s>
 %(comment)s

=== modified file 'lib/lp/registry/emailtemplates/membership-invitation-declined-bulk.txt'
--- lib/lp/registry/emailtemplates/membership-invitation-declined-bulk.txt	2011-03-09 18:18:02 +0000
+++ lib/lp/registry/emailtemplates/membership-invitation-declined-bulk.txt	2015-08-27 14:49:13 +0000
@@ -1,5 +1,5 @@
-Hello %(recipient_name)s,
+Hello %(recipient)s,
 
-%(reviewer_name)s has declined the invitation to make %(member_name)s a member of %(team_name)s.
+%(reviewer)s has declined the invitation to make %(member)s a member of %(team)s.
 <%(team_url)s>
 %(comment)s

=== modified file 'lib/lp/registry/emailtemplates/membership-invitation.txt'
--- lib/lp/registry/emailtemplates/membership-invitation.txt	2011-03-09 17:51:28 +0000
+++ lib/lp/registry/emailtemplates/membership-invitation.txt	2015-08-27 14:49:13 +0000
@@ -1,4 +1,4 @@
-Hello %(recipient_name)s,
+Hello %(recipient)s,
 
 %(reviewer)s has invited %(member)s (which you are an administrator of) to join %(team)s.
 <%(team_url)s>

=== modified file 'lib/lp/registry/emailtemplates/membership-member-renewed.txt'
--- lib/lp/registry/emailtemplates/membership-member-renewed.txt	2011-03-09 18:18:02 +0000
+++ lib/lp/registry/emailtemplates/membership-member-renewed.txt	2015-08-27 14:49:13 +0000
@@ -1,6 +1,6 @@
-Hello %(recipient_name)s,
+Hello %(recipient)s,
 
-%(member_name)s renewed their own membership in the %(team_name)s team until %(dateexpires)s.
+%(member)s renewed their own membership in the %(team)s team until %(dateexpires)s.
 <%(team_url)s>
 
 Regards,

=== modified file 'lib/lp/registry/emailtemplates/membership-statuschange-bulk.txt'
--- lib/lp/registry/emailtemplates/membership-statuschange-bulk.txt	2011-03-09 18:18:02 +0000
+++ lib/lp/registry/emailtemplates/membership-statuschange-bulk.txt	2015-08-27 14:49:13 +0000
@@ -1,5 +1,5 @@
-Hello %(recipient_name)s,
+Hello %(recipient)s,
 
-The membership status of %(member_name)s in the team %(team_name)s was changed by %(reviewer_name)s from %(old_status)s to %(new_status)s.
+The membership status of %(member)s in the team %(team)s was changed by %(reviewer)s from %(old_status)s to %(new_status)s.
 <%(team_url)s>
 %(comment)s

=== modified file 'lib/lp/registry/emailtemplates/membership-statuschange-personal.txt'
--- lib/lp/registry/emailtemplates/membership-statuschange-personal.txt	2011-03-09 18:18:02 +0000
+++ lib/lp/registry/emailtemplates/membership-statuschange-personal.txt	2015-08-27 14:49:13 +0000
@@ -1,5 +1,5 @@
-Hello %(recipient_name)s,
+Hello %(recipient)s,
 
-The status of your membership in the team %(team_name)s was changed by %(reviewer_name)s from %(old_status)s to %(new_status)s.
+The status of your membership in the team %(team)s was changed by %(reviewer)s from %(old_status)s to %(new_status)s.
 <%(team_url)s>
 %(comment)s

=== modified file 'lib/lp/registry/emailtemplates/new-member-notification-for-admins.txt'
--- lib/lp/registry/emailtemplates/new-member-notification-for-admins.txt	2011-03-09 17:51:28 +0000
+++ lib/lp/registry/emailtemplates/new-member-notification-for-admins.txt	2015-08-27 14:49:13 +0000
@@ -1,5 +1,5 @@
-Hello %(recipient_name)s,
-
-%(person_name)s has been added as a member of %(team_name)s by %(reviewer_name)s. Follow the link below for more details.
-
-    %(url)s
+Hello %(recipient)s,
+
+%(member)s has been added as a member of %(team)s by %(reviewer)s. Follow the link below for more details.
+
+    %(membership_url)s

=== modified file 'lib/lp/registry/emailtemplates/new-member-notification-for-teams.txt'
--- lib/lp/registry/emailtemplates/new-member-notification-for-teams.txt	2011-03-09 17:51:28 +0000
+++ lib/lp/registry/emailtemplates/new-member-notification-for-teams.txt	2015-08-27 14:49:13 +0000
@@ -1,4 +1,4 @@
-Hello %(recipient_name)s,
+Hello %(recipient)s,
 
 %(reviewer)s added %(member)s (which you are a member of) as a member of %(team)s.
   <%(team_url)s>

=== modified file 'lib/lp/registry/emailtemplates/new-member-notification.txt'
--- lib/lp/registry/emailtemplates/new-member-notification.txt	2011-03-09 17:51:28 +0000
+++ lib/lp/registry/emailtemplates/new-member-notification.txt	2015-08-27 14:49:13 +0000
@@ -1,4 +1,4 @@
-Hello %(recipient_name)s,
+Hello %(recipient)s,
 
 %(reviewer)s added you as a member of %(team)s.
   <%(team_url)s>

=== modified file 'lib/lp/registry/emailtemplates/pending-membership-approval-for-third-party.txt'
--- lib/lp/registry/emailtemplates/pending-membership-approval-for-third-party.txt	2011-03-09 17:51:28 +0000
+++ lib/lp/registry/emailtemplates/pending-membership-approval-for-third-party.txt	2015-08-27 14:49:13 +0000
@@ -1,5 +1,5 @@
-Hello %(recipient_name)s,
-
-%(reviewer_name)s wants to make %(person_name)s a member of %(team_name)s, but this is a moderated team, so that membership has to be approved.  You can approve, decline or leave it as proposed by following the link below.
-
-    %(url)s
+Hello %(recipient)s,
+
+%(reviewer)s wants to make %(member)s a member of %(team)s, but this is a moderated team, so that membership has to be approved.  You can approve, decline or leave it as proposed by following the link below.
+
+    %(membership_url)s

=== modified file 'lib/lp/registry/emailtemplates/pending-membership-approval.txt'
--- lib/lp/registry/emailtemplates/pending-membership-approval.txt	2011-03-09 17:51:28 +0000
+++ lib/lp/registry/emailtemplates/pending-membership-approval.txt	2015-08-27 14:49:13 +0000
@@ -1,5 +1,5 @@
-Hello %(recipient_name)s,
-
-%(person_name)s wants to be a member of %(team_name)s, but this is a moderated team, so that membership has to be approved.  You can approve, decline or leave it as proposed by following the link below.
-
-    %(url)s
+Hello %(recipient)s,
+
+%(member)s wants to be a member of %(team)s, but this is a moderated team, so that membership has to be approved.  You can approve, decline or leave it as proposed by following the link below.
+
+    %(membership_url)s

=== modified file 'lib/lp/registry/mail/notification.py'
--- lib/lp/registry/mail/notification.py	2015-03-13 19:05:50 +0000
+++ lib/lp/registry/mail/notification.py	2015-08-27 14:49:13 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2011 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2015 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Event handlers that send email notifications."""
@@ -17,19 +17,12 @@
     getUtility,
     )
 
-from lp.registry.enums import TeamMembershipPolicy
 from lp.registry.interfaces.mailinglist import IHeldMessageDetails
 from lp.registry.interfaces.person import IPersonSet
-from lp.registry.interfaces.teammembership import (
-    ITeamMembershipSet,
-    TeamMembershipStatus,
-    )
+from lp.registry.mail.team import TeamMailer
 from lp.services.config import config
 from lp.services.database.sqlbase import block_implicit_flushes
-from lp.services.mail.helpers import (
-    get_contact_email_addresses,
-    get_email_template,
-    )
+from lp.services.mail.helpers import get_email_template
 from lp.services.mail.mailwrapper import MailWrapper
 from lp.services.mail.notificationrecipientset import NotificationRecipientSet
 from lp.services.mail.sendmail import (
@@ -41,9 +34,7 @@
     IDirectEmailAuthorization,
     QuotaReachedError,
     )
-from lp.services.webapp.interfaces import ILaunchpadRoot
 from lp.services.webapp.publisher import canonical_url
-from lp.services.webapp.url import urlappend
 
 # Silence lint warnings.
 NotificationRecipientSet
@@ -57,48 +48,8 @@
 
     The notification will include a link to a page in which any team admin can
     accept the invitation.
-
-    XXX: Guilherme Salgado 2007-05-08:
-    At some point we may want to extend this functionality to allow invites
-    to be sent to users as well, but for now we only use it for teams.
     """
-    member = event.member
-    assert member.is_team
-    team = event.team
-    membership = getUtility(ITeamMembershipSet).getByPersonAndTeam(
-        member, team)
-    assert membership is not None
-
-    reviewer = membership.proposed_by
-    admin_addrs = member.getTeamAdminsEmailAddresses()
-    from_addr = format_address(
-        team.displayname, config.canonical.noreply_from_address)
-    subject = 'Invitation for %s to join' % member.name
-    templatename = 'membership-invitation.txt'
-    template = get_email_template(templatename, app='registry')
-    replacements = {
-        'reviewer': '%s (%s)' % (reviewer.displayname, reviewer.name),
-        'member': '%s (%s)' % (member.displayname, member.name),
-        'team': '%s (%s)' % (team.displayname, team.name),
-        'team_url': canonical_url(team),
-        'membership_invitations_url':
-            "%s/+invitation/%s" % (canonical_url(member), team.name)}
-    for address in admin_addrs:
-        recipient = getUtility(IPersonSet).getByEmail(address)
-        replacements['recipient_name'] = recipient.displayname
-        msg = MailWrapper().format(template % replacements, force_wrap=True)
-        simple_sendmail(from_addr, address, subject, msg)
-
-
-def send_team_email(from_addr, address, subject, template, replacements,
-                    rationale, headers=None):
-    """Send a team message with a rationale."""
-    if headers is None:
-        headers = {}
-    body = MailWrapper().format(template % replacements, force_wrap=True)
-    footer = "-- \n%s" % rationale
-    message = '%s\n\n%s' % (body, footer)
-    simple_sendmail(from_addr, address, subject, message, headers)
+    TeamMailer.forInvitationToJoinTeam(event.member, event.team).sendAll()
 
 
 @block_implicit_flushes
@@ -109,130 +60,7 @@
     is pending approval. Otherwise it'll say that the person has joined the
     team and who added that person to the team.
     """
-    person = event.person
-    team = event.team
-    membership = getUtility(ITeamMembershipSet).getByPersonAndTeam(
-        person, team)
-    assert membership is not None
-    approved, admin, proposed = [
-        TeamMembershipStatus.APPROVED, TeamMembershipStatus.ADMIN,
-        TeamMembershipStatus.PROPOSED]
-    admin_addrs = team.getTeamAdminsEmailAddresses()
-    from_addr = format_address(
-        team.displayname, config.canonical.noreply_from_address)
-
-    reviewer = membership.proposed_by
-    if reviewer != person and membership.status in [approved, admin]:
-        reviewer = membership.reviewed_by
-        # Somebody added this person as a member, we better send a
-        # notification to the person too.
-        member_addrs = get_contact_email_addresses(person)
-
-        headers = {}
-        if person.is_team:
-            templatename = 'new-member-notification-for-teams.txt'
-            subject = '%s joined %s' % (person.name, team.name)
-            header_rational = "Indirect member (%s)" % team.name
-            footer_rationale = (
-                "You received this email because "
-                "%s is the new member." % person.name)
-        else:
-            templatename = 'new-member-notification.txt'
-            subject = 'You have been added to %s' % team.name
-            header_rational = "Member (%s)" % team.name
-            footer_rationale = (
-                "You received this email because you are the new member.")
-
-        if team.mailing_list is not None:
-            template = get_email_template(
-                'team-list-subscribe-block.txt', app='registry')
-            editemails_url = urlappend(
-                canonical_url(getUtility(ILaunchpadRoot)),
-                'people/+me/+editmailinglists')
-            list_instructions = template % dict(editemails_url=editemails_url)
-        else:
-            list_instructions = ''
-
-        template = get_email_template(templatename, app='registry')
-        replacements = {
-            'reviewer': '%s (%s)' % (reviewer.displayname, reviewer.name),
-            'team_url': canonical_url(team),
-            'member': '%s (%s)' % (person.displayname, person.name),
-            'team': '%s (%s)' % (team.displayname, team.name),
-            'list_instructions': list_instructions,
-            }
-        headers = {'X-Launchpad-Message-Rationale': header_rational}
-        for address in member_addrs:
-            recipient = getUtility(IPersonSet).getByEmail(address)
-            replacements['recipient_name'] = recipient.displayname
-            send_team_email(
-                from_addr, address, subject, template, replacements,
-                footer_rationale, headers)
-
-        # The member's email address may be in admin_addrs too; let's remove
-        # it so the member don't get two notifications.
-        admin_addrs = set(admin_addrs).difference(set(member_addrs))
-
-    # Yes, we can have teams with no members; not even admins.
-    if not admin_addrs:
-        return
-
-    # Open teams do not notify admins about new members.
-    if team.membership_policy == TeamMembershipPolicy.OPEN:
-        return
-
-    replacements = {
-        'person_name': "%s (%s)" % (person.displayname, person.name),
-        'team_name': "%s (%s)" % (team.displayname, team.name),
-        'reviewer_name': "%s (%s)" % (reviewer.displayname, reviewer.name),
-        'url': canonical_url(membership)}
-
-    headers = {}
-    if membership.status in [approved, admin]:
-        template = get_email_template(
-            'new-member-notification-for-admins.txt', app='registry')
-        subject = '%s joined %s' % (person.name, team.name)
-    elif membership.status == proposed:
-        # In the UI, a user can only propose himself or a team he
-        # admins. Some users of the REST API have a workflow, where
-        # they propose users that are designated as mentees (Bug 498181).
-        if reviewer != person:
-            headers = {"Reply-To": reviewer.preferredemail.email}
-            template = get_email_template(
-                'pending-membership-approval-for-third-party.txt',
-                app='registry')
-        else:
-            headers = {"Reply-To": person.preferredemail.email}
-            template = get_email_template(
-                'pending-membership-approval.txt', app='registry')
-        subject = "%s wants to join" % person.name
-    else:
-        raise AssertionError(
-            "Unexpected membership status: %s" % membership.status)
-
-    for address in admin_addrs:
-        recipient = getUtility(IPersonSet).getByEmail(address)
-        replacements['recipient_name'] = recipient.displayname
-        if recipient.is_team:
-            header_rationale = 'Admin (%s via %s)' % (
-                team.name, recipient.name)
-            footer_rationale = (
-                "you are an admin of the %s team\n"
-                "via the %s team." % (
-                team.displayname, recipient.displayname))
-        elif recipient == team.teamowner:
-            header_rationale = 'Owner (%s)' % team.name
-            footer_rationale = (
-                "you are the owner of the %s team." % team.displayname)
-        else:
-            header_rationale = 'Admin (%s)' % team.name
-            footer_rationale = (
-                "you are an admin of the %s team." % team.displayname)
-        footer = 'You received this email because %s' % footer_rationale
-        headers['X-Launchpad-Message-Rationale'] = header_rationale
-        send_team_email(
-            from_addr, address, subject, template, replacements,
-            footer, headers)
+    TeamMailer.forTeamJoin(event.person, event.team).sendAll()
 
 
 def notify_mailinglist_activated(mailinglist, event):

=== added file 'lib/lp/registry/mail/team.py'
--- lib/lp/registry/mail/team.py	1970-01-01 00:00:00 +0000
+++ lib/lp/registry/mail/team.py	2015-08-27 14:49:13 +0000
@@ -0,0 +1,420 @@
+# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__metaclass__ = type
+__all__ = [
+    'TeamMailer',
+    ]
+
+from collections import OrderedDict
+from datetime import datetime
+
+import pytz
+from zope.component import getUtility
+
+from lp.app.browser.tales import DurationFormatterAPI
+from lp.registry.enums import (
+    TeamMembershipPolicy,
+    TeamMembershipRenewalPolicy,
+    )
+from lp.registry.interfaces.teammembership import (
+    ITeamMembershipSet,
+    TeamMembershipStatus,
+    )
+from lp.registry.model.person import get_recipients
+from lp.services.config import config
+from lp.services.mail.basemailer import (
+    BaseMailer,
+    RecipientReason,
+    )
+from lp.services.mail.helpers import get_email_template
+from lp.services.mail.sendmail import format_address
+from lp.services.webapp.interfaces import ILaunchpadRoot
+from lp.services.webapp.publisher import canonical_url
+from lp.services.webapp.url import urlappend
+
+
+class TeamRecipientReason(RecipientReason):
+
+    @classmethod
+    def forInvitation(cls, admin, team, recipient, proposed_member, **kwargs):
+        header = cls.makeRationale(
+            "Invitation (%s)" % team.name, proposed_member)
+        reason = (
+            "You received this email because %%(lc_entity_is)s an admin of "
+            "the %s team." % proposed_member.displayname)
+        return cls(admin, recipient, header, reason, **kwargs)
+
+    @classmethod
+    def forMember(cls, member, team, recipient, **kwargs):
+        header = cls.makeRationale("Member (%s)" % team.name, member)
+        reason = (
+            "You received this email because %(lc_entity_is)s the affected "
+            "member.")
+        return cls(member, recipient, header, reason, **kwargs)
+
+    @classmethod
+    def forNewMember(cls, new_member, team, recipient, **kwargs):
+        # From a filtering point of view, this is identical to forMember;
+        # filtering on X-Launchpad-Notification-Type is more useful for
+        # determining the type of notification sent to a particular member.
+        # It's worth having a footer that makes a little more sense, though.
+        header = cls.makeRationale("Member (%s)" % team.name, new_member)
+        reason = (
+            "You received this email because %(lc_entity_is)s the new member.")
+        return cls(new_member, recipient, header, reason, **kwargs)
+
+    @classmethod
+    def forAdmin(cls, admin, team, recipient, **kwargs):
+        header = cls.makeRationale("Admin (%s)" % team.name, admin)
+        reason = (
+            "You received this email because %%(lc_entity_is)s an admin of "
+            "the %s team." % team.displayname)
+        return cls(admin, recipient, header, reason, **kwargs)
+
+    @classmethod
+    def forOwner(cls, owner, team, recipient, **kwargs):
+        header = cls.makeRationale("Owner (%s)" % team.name, owner)
+        reason = (
+            "You received this email because %%(lc_entity_is)s the owner "
+            "of the %s team." % team.displayname)
+        return cls(owner, recipient, header, reason, **kwargs)
+
+    def __init__(self, subscriber, recipient, mail_header, reason_template,
+                 subject=None, template_name=None, reply_to=None,
+                 recipient_class=None):
+        super(TeamRecipientReason, self).__init__(
+            subscriber, recipient, mail_header, reason_template)
+        self.subject = subject
+        self.template_name = template_name
+        self.reply_to = reply_to
+        self.recipient_class = recipient_class
+
+
+class TeamMailer(BaseMailer):
+
+    app = 'registry'
+
+    @classmethod
+    def forInvitationToJoinTeam(cls, member, team):
+        """Create a mailer for notifying about team joining invitations.
+
+        XXX: Guilherme Salgado 2007-05-08:
+        At some point we may want to extend this functionality to allow
+        invites to be sent to users as well, but for now we only use it for
+        teams.
+        """
+        assert member.is_team
+        membership = getUtility(ITeamMembershipSet).getByPersonAndTeam(
+            member, team)
+        assert membership is not None
+        recipients = OrderedDict()
+        for admin in member.adminmembers:
+            for recipient in get_recipients(admin):
+                recipients[recipient] = TeamRecipientReason.forAdmin(
+                    admin, member, recipient)
+        from_addr = format_address(
+            team.displayname, config.canonical.noreply_from_address)
+        subject = "Invitation for %s to join" % member.name
+        return cls(
+            subject, "membership-invitation.txt", recipients, from_addr,
+            "membership-invitation", member, team, membership.proposed_by,
+            membership=membership)
+
+    @classmethod
+    def forTeamJoin(cls, member, team):
+        """Create a mailer for notifying about a new member joining a team."""
+        membership = getUtility(ITeamMembershipSet).getByPersonAndTeam(
+            member, team)
+        assert membership is not None
+        subject = None
+        template_name = None
+        notification_type = "new-member"
+        recipients = OrderedDict()
+        reviewer = membership.proposed_by
+        if reviewer != member and membership.status in [
+                TeamMembershipStatus.APPROVED, TeamMembershipStatus.ADMIN]:
+            reviewer = membership.reviewed_by
+            # Somebody added this person as a member, we better send a
+            # notification to the person too.
+            if member.is_team:
+                template_name = "new-member-notification-for-teams.txt"
+                subject = "%s joined %s" % (member.name, team.name)
+            else:
+                template_name = "new-member-notification.txt"
+                subject = "You have been added to %s" % team.name
+            for recipient in get_recipients(member):
+                recipients[recipient] = TeamRecipientReason.forNewMember(
+                    member, team, recipient, subject=subject,
+                    template_name=template_name)
+        # Open teams do not notify admins about new members.
+        if team.membership_policy != TeamMembershipPolicy.OPEN:
+            reply_to = None
+            if membership.status in [
+                    TeamMembershipStatus.APPROVED, TeamMembershipStatus.ADMIN]:
+                template_name = "new-member-notification-for-admins.txt"
+                subject = "%s joined %s" % (member.name, team.name)
+            elif membership.status == TeamMembershipStatus.PROPOSED:
+                # In the UI, a user can only propose themselves or a team
+                # they admin.  Some users of the REST API have a workflow
+                # where they propose users that are designated as undergoing
+                # mentorship (Bug 498181).
+                if reviewer != member:
+                    reply_to = reviewer.preferredemail.email
+                    template_name = (
+                        "pending-membership-approval-for-third-party.txt")
+                else:
+                    reply_to = member.preferredemail.email
+                    template_name = "pending-membership-approval.txt"
+                notification_type = "pending-membership-approval"
+                subject = "%s wants to join" % member.name
+            else:
+                raise AssertionError(
+                    "Unexpected membership status: %s" % membership.status)
+            for admin in team.adminmembers:
+                for recipient in get_recipients(admin):
+                    # The new member may also be a team admin; don't send
+                    # two notifications in that case.
+                    if recipient not in recipients:
+                        if recipient == team.teamowner:
+                            reason_factory = TeamRecipientReason.forOwner
+                        else:
+                            reason_factory = TeamRecipientReason.forAdmin
+                        recipients[recipient] = reason_factory(
+                            admin, team, recipient, subject=subject,
+                            template_name=template_name, reply_to=reply_to)
+        from_addr = format_address(
+            team.displayname, config.canonical.noreply_from_address)
+        return cls(
+            subject, template_name, recipients, from_addr, notification_type,
+            member, team, membership.proposed_by, membership=membership)
+
+    @classmethod
+    def forMembershipStatusChange(cls, member, team, reviewer,
+                                  old_status, new_status, last_change_comment):
+        """Create a mailer for a membership status change."""
+        notification_type = 'membership-statuschange'
+        subject = (
+            'Membership change: %(member)s in %(team)s' %
+            {'member': member.name, 'team': team.name})
+        if new_status == TeamMembershipStatus.EXPIRED:
+            notification_type = 'membership-expired'
+            subject = '%s expired from team' % member.name
+        elif (new_status == TeamMembershipStatus.APPROVED and
+            old_status != TeamMembershipStatus.ADMIN):
+            if old_status == TeamMembershipStatus.INVITED:
+                notification_type = 'membership-invitation-accepted'
+                subject = (
+                    'Invitation to %s accepted by %s' %
+                    (member.name, reviewer.name))
+            elif old_status == TeamMembershipStatus.PROPOSED:
+                subject = '%s approved by %s' % (member.name, reviewer.name)
+            else:
+                subject = '%s added by %s' % (member.name, reviewer.name)
+        elif new_status == TeamMembershipStatus.INVITATION_DECLINED:
+            notification_type = 'membership-invitation-declined'
+            subject = (
+                'Invitation to %s declined by %s' %
+                (member.name, reviewer.name))
+        elif new_status == TeamMembershipStatus.DEACTIVATED:
+            subject = '%s deactivated by %s' % (member.name, reviewer.name)
+        elif new_status == TeamMembershipStatus.ADMIN:
+            subject = '%s made admin by %s' % (member.name, reviewer.name)
+        elif new_status == TeamMembershipStatus.DECLINED:
+            subject = '%s declined by %s' % (member.name, reviewer.name)
+        else:
+            # Use the default template and subject.
+            pass
+        template_name = notification_type + "-%(recipient_class)s.txt"
+
+        if last_change_comment:
+            comment = "\n%s said:\n %s\n" % (
+                reviewer.displayname, last_change_comment.strip())
+        else:
+            comment = ""
+
+        recipients = OrderedDict()
+        if reviewer != member:
+            for recipient in get_recipients(member):
+                if member.is_team:
+                    recipient_class = "bulk"
+                else:
+                    recipient_class = "personal"
+                recipients[recipient] = TeamRecipientReason.forMember(
+                    member, team, recipient, recipient_class=recipient_class)
+        # Don't send admin notifications for open teams: they're
+        # unrestricted, so notifications on join/leave do not help the
+        # admins.
+        if team.membership_policy != TeamMembershipPolicy.OPEN:
+            for admin in team.adminmembers:
+                for recipient in get_recipients(admin):
+                    # The new member may also be a team admin; don't send
+                    # two notifications in that case.
+                    if recipient not in recipients:
+                        recipients[recipient] = TeamRecipientReason.forAdmin(
+                            admin, team, recipient, recipient_class="bulk")
+
+        extra_params = {
+            "old_status": old_status,
+            "new_status": new_status,
+            "comment": comment,
+            }
+        from_addr = format_address(
+            team.displayname, config.canonical.noreply_from_address)
+        return cls(
+            subject, template_name, recipients, from_addr, notification_type,
+            member, team, reviewer, extra_params=extra_params)
+
+    @classmethod
+    def forExpiringMembership(cls, member, team, membership, dateexpires):
+        """Create a mailer for warning about expiring membership."""
+        if member.is_team:
+            target = member.teamowner
+            template_name = "membership-expiration-warning-bulk.txt"
+            subject = "%s will expire soon from %s" % (member.name, team.name)
+        else:
+            target = member
+            template_name = "membership-expiration-warning-personal.txt"
+            subject = "Your membership in %s is about to expire" % team.name
+
+        if team.renewal_policy == TeamMembershipRenewalPolicy.ONDEMAND:
+            how_to_renew = (
+                "If you want, you can renew this membership at\n"
+                "<%s/+expiringmembership/%s>" %
+                (canonical_url(member), team.name))
+        elif not membership.canChangeExpirationDate(target):
+            admins_names = []
+            admins = team.getDirectAdministrators()
+            assert admins.count() >= 1
+            if admins.count() == 1:
+                admin = admins[0]
+                how_to_renew = (
+                    "To prevent this membership from expiring, you should "
+                    "contact the\nteam's administrator, %s.\n<%s>"
+                    % (admin.unique_displayname, canonical_url(admin)))
+            else:
+                for admin in admins:
+                    admins_names.append(
+                        "%s <%s>" % (admin.unique_displayname,
+                                        canonical_url(admin)))
+
+                how_to_renew = (
+                    "To prevent this membership from expiring, you should "
+                    "get in touch\nwith one of the team's administrators:\n")
+                how_to_renew += "\n".join(admins_names)
+        else:
+            how_to_renew = (
+                "To stay a member of this team you should extend your "
+                "membership at\n<%s/+member/%s>"
+                % (canonical_url(team), member.name))
+
+        recipients = OrderedDict()
+        for recipient in get_recipients(target):
+            recipients[recipient] = TeamRecipientReason.forMember(
+                member, team, recipient)
+
+        formatter = DurationFormatterAPI(dateexpires - datetime.now(pytz.UTC))
+        extra_params = {
+            "how_to_renew": how_to_renew,
+            "expiration_date": dateexpires.strftime("%Y-%m-%d"),
+            "approximate_duration": formatter.approximateduration(),
+            }
+
+        from_addr = format_address(
+            team.displayname, config.canonical.noreply_from_address)
+        return cls(
+            subject, template_name, recipients, from_addr,
+            "membership-expiration-warning", member, team,
+            membership.proposed_by, membership=membership,
+            extra_params=extra_params, wrap=False, force_wrap=False)
+
+    @classmethod
+    def forSelfRenewal(cls, member, team, dateexpires):
+        """Create a mailer for notifying about a self-renewal."""
+        assert team.renewal_policy == TeamMembershipRenewalPolicy.ONDEMAND
+        template_name = "membership-member-renewed.txt"
+        subject = "%s extended their membership" % member.name
+        recipients = OrderedDict()
+        for admin in team.adminmembers:
+            for recipient in get_recipients(admin):
+                recipients[recipient] = TeamRecipientReason.forAdmin(
+                    admin, team, recipient)
+        extra_params = {"dateexpires": dateexpires.strftime("%Y-%m-%d")}
+        from_addr = format_address(
+            team.displayname, config.canonical.noreply_from_address)
+        return cls(
+            subject, template_name, recipients, from_addr,
+            "membership-member-renewed", member, team, None,
+            extra_params=extra_params)
+
+    def __init__(self, subject, template_name, recipients, from_address,
+                 notification_type, member, team, reviewer, membership=None,
+                 extra_params={}, wrap=True, force_wrap=True):
+        """See `BaseMailer`."""
+        super(TeamMailer, self).__init__(
+            subject, template_name, recipients, from_address,
+            notification_type=notification_type, wrap=wrap,
+            force_wrap=force_wrap)
+        self.member = member
+        self.team = team
+        self.reviewer = reviewer
+        self.membership = membership
+        self.extra_params = extra_params
+
+    def _getSubject(self, email, recipient):
+        """See `BaseMailer`."""
+        reason, _ = self._recipients.getReason(email)
+        if reason.subject is not None:
+            subject_template = reason.subject
+        else:
+            subject_template = self._subject_template
+        return subject_template % self._getTemplateParams(email, recipient)
+
+    def _getReplyToAddress(self, email, recipient):
+        """See `BaseMailer`."""
+        reason, _ = self._recipients.getReason(email)
+        return reason.reply_to
+
+    def _getTemplateName(self, email, recipient):
+        """See `BaseMailer`."""
+        reason, _ = self._recipients.getReason(email)
+        if reason.template_name is not None:
+            template_name = reason.template_name
+        else:
+            template_name = self._template_name
+        return template_name % self._getTemplateParams(email, recipient)
+
+    def _getTemplateParams(self, email, recipient):
+        """See `BaseMailer`."""
+        params = super(TeamMailer, self)._getTemplateParams(email, recipient)
+        params["recipient"] = recipient.displayname
+        reason, _ = self._recipients.getReason(email)
+        if reason.recipient_class is not None:
+            params["recipient_class"] = reason.recipient_class
+        params["member"] = self.member.unique_displayname
+        params["membership_invitations_url"] = "%s/+invitation/%s" % (
+            canonical_url(self.member), self.team.name)
+        params["team"] = self.team.unique_displayname
+        params["team_url"] = canonical_url(self.team)
+        if self.membership is not None:
+            params["membership_url"] = canonical_url(self.membership)
+        if reason.recipient_class == "bulk" and self.reviewer == self.member:
+            params["reviewer"] = "the user"
+        elif self.reviewer is not None:
+            params["reviewer"] = self.reviewer.unique_displayname
+        if self.team.mailing_list is not None:
+            template = get_email_template(
+                "team-list-subscribe-block.txt", app="registry")
+            editemails_url = urlappend(
+                canonical_url(getUtility(ILaunchpadRoot)),
+                "~/+editmailinglists")
+            list_instructions = template % {"editemails_url": editemails_url}
+        else:
+            list_instructions = ""
+        params["list_instructions"] = list_instructions
+        params.update(self.extra_params)
+        return params
+
+    def _getFooter(self, email, recipient, params):
+        """See `BaseMailer`."""
+        return "%(reason)s\n" % params

=== modified file 'lib/lp/registry/model/persontransferjob.py'
--- lib/lp/registry/model/persontransferjob.py	2015-07-09 20:06:17 +0000
+++ lib/lp/registry/model/persontransferjob.py	2015-08-27 14:49:13 +0000
@@ -1,4 +1,4 @@
-# Copyright 2010-2013 Canonical Ltd.  This software is licensed under the
+# Copyright 2010-2015 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Job classes related to PersonTransferJob."""
@@ -27,10 +27,7 @@
     )
 
 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
-from lp.registry.enums import (
-    PersonTransferJobType,
-    TeamMembershipPolicy,
-    )
+from lp.registry.enums import PersonTransferJobType
 from lp.registry.interfaces.person import (
     IPerson,
     IPersonSet,
@@ -47,6 +44,7 @@
     IPersonTransferJobSource,
     )
 from lp.registry.interfaces.teammembership import TeamMembershipStatus
+from lp.registry.mail.team import TeamMailer
 from lp.registry.model.person import Person
 from lp.registry.personmerge import merge_people
 from lp.services.config import config
@@ -62,17 +60,7 @@
     Job,
     )
 from lp.services.job.runner import BaseRunnableJob
-from lp.services.mail.helpers import (
-    get_contact_email_addresses,
-    get_email_template,
-    )
-from lp.services.mail.mailwrapper import MailWrapper
-from lp.services.mail.sendmail import (
-    format_address,
-    format_address_for_person,
-    simple_sendmail,
-    )
-from lp.services.webapp import canonical_url
+from lp.services.mail.sendmail import format_address_for_person
 
 
 @implementer(IPersonTransferJob)
@@ -241,106 +229,9 @@
     def run(self):
         """See `IMembershipNotificationJob`."""
         from lp.services.scripts import log
-        from_addr = format_address(
-            self.team.displayname, config.canonical.noreply_from_address)
-        admin_emails = self.team.getTeamAdminsEmailAddresses()
-        # person might be a self.team, so we can't rely on its preferredemail.
-        self.member_email = get_contact_email_addresses(self.member)
-        # Make sure we don't send the same notification twice to anybody.
-        for email in self.member_email:
-            if email in admin_emails:
-                admin_emails.remove(email)
-
-        if self.reviewer != self.member:
-            self.reviewer_name = self.reviewer.unique_displayname
-        else:
-            self.reviewer_name = 'the user'
-
-        if self.last_change_comment:
-            comment = ("\n%s said:\n %s\n" % (
-                self.reviewer.displayname, self.last_change_comment.strip()))
-        else:
-            comment = ""
-
-        replacements = {
-            'member_name': self.member.unique_displayname,
-            'recipient_name': self.member.displayname,
-            'team_name': self.team.unique_displayname,
-            'team_url': canonical_url(self.team),
-            'old_status': self.old_status.title,
-            'new_status': self.new_status.title,
-            'reviewer_name': self.reviewer_name,
-            'comment': comment}
-
-        template_name = 'membership-statuschange'
-        subject = (
-            'Membership change: %(member)s in %(team)s'
-            % {
-                'member': self.member.name,
-                'team': self.team.name,
-              })
-        if self.new_status == TeamMembershipStatus.EXPIRED:
-            template_name = 'membership-expired'
-            subject = '%s expired from team' % self.member.name
-        elif (self.new_status == TeamMembershipStatus.APPROVED and
-            self.old_status != TeamMembershipStatus.ADMIN):
-            if self.old_status == TeamMembershipStatus.INVITED:
-                subject = ('Invitation to %s accepted by %s'
-                        % (self.member.name, self.reviewer.name))
-                template_name = 'membership-invitation-accepted'
-            elif self.old_status == TeamMembershipStatus.PROPOSED:
-                subject = '%s approved by %s' % (
-                    self.member.name, self.reviewer.name)
-            else:
-                subject = '%s added by %s' % (
-                    self.member.name, self.reviewer.name)
-        elif self.new_status == TeamMembershipStatus.INVITATION_DECLINED:
-            subject = ('Invitation to %s declined by %s'
-                    % (self.member.name, self.reviewer.name))
-            template_name = 'membership-invitation-declined'
-        elif self.new_status == TeamMembershipStatus.DEACTIVATED:
-            subject = '%s deactivated by %s' % (
-                self.member.name, self.reviewer.name)
-        elif self.new_status == TeamMembershipStatus.ADMIN:
-            subject = '%s made admin by %s' % (
-                self.member.name, self.reviewer.name)
-        elif self.new_status == TeamMembershipStatus.DECLINED:
-            subject = '%s declined by %s' % (
-                self.member.name, self.reviewer.name)
-        else:
-            # Use the default template and subject.
-            pass
-
-        # Must have someone to mail, and be a non-open team (because open
-        # teams are unrestricted, notifications on join/ leave do not help the
-        # admins.
-        if (len(admin_emails) != 0 and
-            self.team.membership_policy != TeamMembershipPolicy.OPEN):
-            admin_template = get_email_template(
-                "%s-bulk.txt" % template_name, app='registry')
-            for address in admin_emails:
-                recipient = getUtility(IPersonSet).getByEmail(address)
-                replacements['recipient_name'] = recipient.displayname
-                msg = MailWrapper().format(
-                    admin_template % replacements, force_wrap=True)
-                simple_sendmail(from_addr, address, subject, msg)
-
-        # The self.member can be a self.self.team without any
-        # self.members, and in this case we won't have a single email
-        # address to send this notification to.
-        if self.member_email and self.reviewer != self.member:
-            if self.member.is_team:
-                template = '%s-bulk.txt' % template_name
-            else:
-                template = '%s-personal.txt' % template_name
-            self.member_template = get_email_template(
-                template, app='registry')
-            for address in self.member_email:
-                recipient = getUtility(IPersonSet).getByEmail(address)
-                replacements['recipient_name'] = recipient.displayname
-                msg = MailWrapper().format(
-                    self.member_template % replacements, force_wrap=True)
-                simple_sendmail(from_addr, address, subject, msg)
+        TeamMailer.forMembershipStatusChange(
+            self.member, self.team, self.reviewer, self.old_status,
+            self.new_status, self.last_change_comment).sendAll()
         log.debug('MembershipNotificationJob sent email')
 
     def __repr__(self):

=== modified file 'lib/lp/registry/model/teammembership.py'
--- lib/lp/registry/model/teammembership.py	2015-07-08 16:05:11 +0000
+++ lib/lp/registry/model/teammembership.py	2015-08-27 14:49:13 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2012 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2015 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
@@ -24,7 +24,6 @@
 from zope.component import getUtility
 from zope.interface import implementer
 
-from lp.app.browser.tales import DurationFormatterAPI
 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
 from lp.registry.enums import TeamMembershipRenewalPolicy
 from lp.registry.errors import (
@@ -32,7 +31,6 @@
     UserCannotChangeMembershipSilently,
     )
 from lp.registry.interfaces.person import (
-    IPersonSet,
     validate_person,
     validate_public_person,
     )
@@ -52,7 +50,6 @@
     ITeamParticipation,
     TeamMembershipStatus,
     )
-from lp.services.config import config
 from lp.services.database.constants import UTC_NOW
 from lp.services.database.datetimecol import UtcDateTimeCol
 from lp.services.database.enumcol import EnumCol
@@ -63,16 +60,6 @@
     SQLBase,
     sqlvalues,
     )
-from lp.services.mail.helpers import (
-    get_contact_email_addresses,
-    get_email_template,
-    )
-from lp.services.mail.mailwrapper import MailWrapper
-from lp.services.mail.sendmail import (
-    format_address,
-    simple_sendmail,
-    )
-from lp.services.webapp import canonical_url
 
 
 @implementer(ITeamMembership)
@@ -132,26 +119,10 @@
 
     def sendSelfRenewalNotification(self):
         """See `ITeamMembership`."""
-        team = self.team
-        member = self.person
-        assert team.renewal_policy == TeamMembershipRenewalPolicy.ONDEMAND
-
-        from_addr = format_address(
-            team.displayname, config.canonical.noreply_from_address)
-        replacements = {'member_name': member.unique_displayname,
-                        'team_name': team.unique_displayname,
-                        'team_url': canonical_url(team),
-                        'dateexpires': self.dateexpires.strftime('%Y-%m-%d')}
-        subject = '%s extended their membership' % member.name
-        template = get_email_template(
-            'membership-member-renewed.txt', app='registry')
-        admins_addrs = self.team.getTeamAdminsEmailAddresses()
-        for address in admins_addrs:
-            recipient = getUtility(IPersonSet).getByEmail(address)
-            replacements['recipient_name'] = recipient.displayname
-            msg = MailWrapper().format(
-                template % replacements, force_wrap=True)
-            simple_sendmail(from_addr, address, subject, msg)
+        # Circular import.
+        from lp.registry.mail.team import TeamMailer
+        TeamMailer.forSelfRenewal(
+            self.person, self.team, self.dateexpires).sendAll()
 
     def canChangeStatusSilently(self, user):
         """Ensure that the user is in the Launchpad Administrators group.
@@ -185,6 +156,8 @@
 
     def sendExpirationWarningEmail(self):
         """See `ITeamMembership`."""
+        # Circular import.
+        from lp.registry.mail.team import TeamMailer
         if self.dateexpires is None:
             raise AssertionError(
                 '%s in team %s has no membership expiration date.' %
@@ -194,68 +167,8 @@
             # there is nothing to do. The member will have received emails
             # from previous calls by flag-expired-memberships.py
             return
-        member = self.person
-        team = self.team
-        if member.is_team:
-            recipient = member.teamowner
-            templatename = 'membership-expiration-warning-bulk.txt'
-            subject = '%s will expire soon from %s' % (member.name, team.name)
-        else:
-            recipient = member
-            templatename = 'membership-expiration-warning-personal.txt'
-            subject = 'Your membership in %s is about to expire' % team.name
-
-        if team.renewal_policy == TeamMembershipRenewalPolicy.ONDEMAND:
-            how_to_renew = (
-                "If you want, you can renew this membership at\n"
-                "<%s/+expiringmembership/%s>"
-                % (canonical_url(member), team.name))
-        elif not self.canChangeExpirationDate(recipient):
-            admins_names = []
-            admins = team.getDirectAdministrators()
-            assert admins.count() >= 1
-            if admins.count() == 1:
-                admin = admins[0]
-                how_to_renew = (
-                    "To prevent this membership from expiring, you should "
-                    "contact the\nteam's administrator, %s.\n<%s>"
-                    % (admin.unique_displayname, canonical_url(admin)))
-            else:
-                for admin in admins:
-                    admins_names.append(
-                        "%s <%s>" % (admin.unique_displayname,
-                                        canonical_url(admin)))
-
-                how_to_renew = (
-                    "To prevent this membership from expiring, you should "
-                    "get in touch\nwith one of the team's administrators:\n")
-                how_to_renew += "\n".join(admins_names)
-        else:
-            how_to_renew = (
-                "To stay a member of this team you should extend your "
-                "membership at\n<%s/+member/%s>"
-                % (canonical_url(team), member.name))
-
-        to_addrs = get_contact_email_addresses(recipient)
-        if len(to_addrs) == 0:
-            # The user does not have a preferred email address, he was
-            # probably suspended.
-            return
-        formatter = DurationFormatterAPI(
-            self.dateexpires - datetime.now(pytz.timezone('UTC')))
-        replacements = {
-            'recipient_name': recipient.displayname,
-            'member_name': member.unique_displayname,
-            'team_url': canonical_url(team),
-            'how_to_renew': how_to_renew,
-            'team_name': team.unique_displayname,
-            'expiration_date': self.dateexpires.strftime('%Y-%m-%d'),
-            'approximate_duration': formatter.approximateduration()}
-
-        msg = get_email_template(templatename, app='registry') % replacements
-        from_addr = format_address(
-            team.displayname, config.canonical.noreply_from_address)
-        simple_sendmail(from_addr, to_addrs, subject, msg)
+        TeamMailer.forExpiringMembership(
+            self.person, self.team, self, self.dateexpires).sendAll()
 
     def setStatus(self, status, user, comment=None, silent=False):
         """See `ITeamMembership`."""

=== modified file 'lib/lp/registry/tests/test_teammembership.py'
--- lib/lp/registry/tests/test_teammembership.py	2013-06-20 05:50:00 +0000
+++ lib/lp/registry/tests/test_teammembership.py	2015-08-27 14:49:13 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2013 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2015 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
@@ -61,6 +61,7 @@
 from lp.services.features.testing import FeatureFixture
 from lp.services.job.tests import block_on_job
 from lp.services.log.logger import BufferLogger
+from lp.services.mail.sendmail import format_address_for_person
 from lp.testing import (
     login,
     login_celebrity,
@@ -1085,8 +1086,7 @@
         message = notifications[0]
         self.assertEqual(
             'Your membership in red is about to expire', message['subject'])
-        self.assertEqual(
-            self.member.preferredemail.email, message['to'])
+        self.assertEqual(format_address_for_person(self.member), message['to'])
 
     def test_no_message_sent_for_expired_memberships(self):
         # Members whose membership has expired do not get a message.

=== modified file 'lib/lp/services/mail/basemailer.py'
--- lib/lp/services/mail/basemailer.py	2015-08-25 14:05:24 +0000
+++ lib/lp/services/mail/basemailer.py	2015-08-27 14:49:13 +0000
@@ -16,6 +16,7 @@
 from zope.error.interfaces import IErrorReportingUtility
 
 from lp.services.mail.helpers import get_email_template
+from lp.services.mail.mailwrapper import MailWrapper
 from lp.services.mail.notificationrecipientset import NotificationRecipientSet
 from lp.services.mail.sendmail import (
     append_footer,
@@ -39,7 +40,8 @@
 
     def __init__(self, subject, template_name, recipients, from_address,
                  delta=None, message_id=None, notification_type=None,
-                 mail_controller_class=None, request=None):
+                 mail_controller_class=None, request=None, wrap=False,
+                 force_wrap=False):
         """Constructor.
 
         :param subject: A Python dict-replacement template for the subject
@@ -55,6 +57,8 @@
             use to send the mails.  Defaults to `MailController`.
         :param request: An optional `IErrorReportRequest` to use when
             logging OOPSes.
+        :param wrap: Wrap body text using `MailWrapper`.
+        :param force_wrap: See `MailWrapper.format`.
         """
         self._subject_template = subject
         self._template_name = template_name
@@ -70,6 +74,8 @@
             mail_controller_class = MailController
         self._mail_controller_class = mail_controller_class
         self.request = request
+        self._wrap = wrap
+        self._force_wrap = force_wrap
 
     def _getFromAddress(self, email, recipient):
         return self.from_address
@@ -108,7 +114,7 @@
         return (self._subject_template %
                     self._getTemplateParams(email, recipient))
 
-    def _getReplyToAddress(self):
+    def _getReplyToAddress(self, email, recipient):
         """Return the address to use for the reply-to header."""
         return None
 
@@ -119,7 +125,7 @@
         headers['X-Launchpad-Message-Rationale'] = reason.mail_header
         if self.notification_type is not None:
             headers['X-Launchpad-Notification-Type'] = self.notification_type
-        reply_to = self._getReplyToAddress()
+        reply_to = self._getReplyToAddress(email, recipient)
         if reply_to is not None:
             headers['Reply-To'] = reply_to
         if self.message_id is not None:
@@ -158,6 +164,9 @@
             self._getTemplateName(email, recipient), app=self.app)
         params = self._getTemplateParams(email, recipient)
         body = template % params
+        if self._wrap:
+            body = MailWrapper().format(
+                body, force_wrap=self._force_wrap) + "\n"
         footer = self._getFooter(email, recipient, params)
         if footer is not None:
             body = append_footer(body, footer)

=== modified file 'lib/lp/testing/mail_helpers.py'
--- lib/lp/testing/mail_helpers.py	2015-07-21 09:04:01 +0000
+++ lib/lp/testing/mail_helpers.py	2015-08-27 14:49:13 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2015 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Helper functions dealing with emails in tests.
@@ -54,7 +54,8 @@
 
 
 def print_emails(include_reply_to=False, group_similar=False,
-                 include_rationale=False, notifications=None):
+                 include_rationale=False, notifications=None,
+                 include_notification_type=False):
     """Pop all messages from stub.test_emails and print them with
      their recipients.
 
@@ -71,6 +72,8 @@
         header.
     :param notifications: Use the provided list of notifications instead of
         the stack.
+    :param include_notification_type: Include the
+        X-Launchpad-Notification-Type header.
     """
     distinct_bodies = {}
     if notifications is None:
@@ -99,16 +102,22 @@
         if include_rationale and rationale_header in message:
             print (
                 '%s: %s' % (rationale_header, message[rationale_header]))
+        notification_type_header = 'X-Launchpad-Notification-Type'
+        if include_notification_type and notification_type_header in message:
+            print '%s: %s' % (
+                notification_type_header, message[notification_type_header])
         print 'Subject:', message['Subject']
         print body
         print "-" * 40
 
 
-def print_distinct_emails(include_reply_to=False, include_rationale=True):
+def print_distinct_emails(include_reply_to=False, include_rationale=True,
+                          include_notification_type=True):
     """A convenient shortcut for `print_emails`(group_similar=True)."""
     return print_emails(group_similar=True,
                         include_reply_to=include_reply_to,
-                        include_rationale=include_rationale)
+                        include_rationale=include_rationale,
+                        include_notification_type=include_notification_type)
 
 
 def run_mail_jobs():


Follow ups