← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~sinzui/launchpad/question-email-3 into lp:launchpad

 

Curtis Hovey has proposed merging lp:~sinzui/launchpad/question-email-3 into lp:launchpad.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #550954 in Launchpad itself: "Question:+index timeout (probably mail sending)"
  https://bugs.launchpad.net/launchpad/+bug/550954
  Bug #608037 in Launchpad itself: "timeouts in Question:+edit"
  https://bugs.launchpad.net/launchpad/+bug/608037
  Bug #618390 in Launchpad itself: "Question:+linkbug timeouts"
  https://bugs.launchpad.net/launchpad/+bug/618390

For more details, see:
https://code.launchpad.net/~sinzui/launchpad/question-email-3/+merge/59574

Send question email asynchronously.

    Launchpad bug:
        https://bugs.launchpad.net/bugs/550954
        https://bugs.launchpad.net/bugs/608037
        https://bugs.launchpad.net/bugs/618390
    Pre-implementation: jcsackett, lifeless

Change QuestionNotification to queue emails instead of sending them.
This addresses four kinds of timeouts that happen when a question needs
email sent to many users.

--------------------------------------------------------------------

RULES

    * Replace QuestionNotification's send() method with a enqueue() method.
    * Remove unneeded methods and tests that were used to send email.
    * Update the notification doctests to to use questionemailjobs instead
      of sent email.


QA

    * Create a question on qastaging.
    * Ask an admin to run cronscripts/process-job-source-groups.py on
      qastaging
    * Verify the email arrives in the staging inbox.


LINT

    database/schema/security.cfg
    lib/lp/answers/notification.py
    lib/lp/answers/doc/notifications.txt
    lib/lp/answers/tests/test_question_notifications.py
    lib/lp/coop/answersbugs/tests/notifications-linked-bug.txt
    lib/lp/coop/answersbugs/tests/notifications-linked-private-bug.txt


TEST

    ./bin/test -vv -t answers.*notification


IMPLEMENTATION

Gave all processes that could update a bug or question access to questionjob.
    database/schema/security.cfg

Added the enqueue() method and the recipient_set attribute.
Removes send(), getRecipients(), buildBody(), getFromAddress() methods.
    lib/lp/answers/notification.py
    lib/lp/answers/tests/test_question_notifications.py

/o\ More than 1000 lines of the diff is dedicated to updating the existing
doctests to check questionemailjob instead of sent emails. 900 lines are
just notifications.txt. For all three tests, I added a utility that behaves
like pop_notification, but questionemailjobs are not emails. I deleted all
tests for email addresses and reason footers, because those are provided by
recipientsets, which are already tested in question.txt and elsewhere. The
same is true the the two chunks about team email, I deleted them
since Question, not QuestionNotification, determines that. There were a few
cases where the counts of notifications changed. This is because notification
were implicitly created when the test question was created; the tests were
were somewhat wrong. Since the sort rules changed, email addresses versus
recipientset types, The order of question asker and subscriber changed.
    lib/lp/answers/doc/notifications.txt
    lib/lp/coop/answersbugs/tests/notifications-linked-bug.txt
    lib/lp/coop/answersbugs/tests/notifications-linked-private-bug.txt
-- 
https://code.launchpad.net/~sinzui/launchpad/question-email-3/+merge/59574
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~sinzui/launchpad/question-email-3 into lp:launchpad.
=== modified file 'database/schema/security.cfg'
--- database/schema/security.cfg	2011-04-29 13:59:32 +0000
+++ database/schema/security.cfg	2011-04-30 02:03:29 +0000
@@ -617,6 +617,7 @@
 public.project                          = SELECT, UPDATE
 public.questionbug                      = SELECT
 public.question                         = SELECT
+public.questionjob                      = SELECT, INSERT
 public.questionsubscription             = SELECT
 public.section                          = SELECT
 public.sourcepackagepublishinghistory   = SELECT
@@ -892,6 +893,7 @@
 public.karma                            = SELECT, INSERT
 public.questionbug                      = SELECT
 public.question                         = SELECT
+public.questionjob                      = SELECT, INSERT
 public.packagebugsupervisor             = SELECT
 public.milestone                        = SELECT
 public.bugwatch                         = SELECT, INSERT
@@ -1356,6 +1358,7 @@
 public.karma                            = SELECT, INSERT
 public.questionbug                      = SELECT
 public.question                         = SELECT
+public.questionjob                      = SELECT, INSERT
 public.packagebugsupervisor             = SELECT
 public.milestone                        = SELECT
 public.bugwatch                         = SELECT, INSERT
@@ -1466,6 +1469,7 @@
 public.karma                            = SELECT, INSERT
 public.questionbug                      = SELECT
 public.question                         = SELECT
+public.questionjob                      = SELECT, INSERT
 public.packagebugsupervisor             = SELECT
 public.milestone                        = SELECT
 public.bugwatch                         = SELECT, INSERT
@@ -1778,6 +1782,7 @@
 # Adding comment to question
 public.faq                              = SELECT
 public.question                         = SELECT, UPDATE
+public.questionjob                      = SELECT, INSERT
 public.questionmessage                  = SELECT, INSERT
 public.questionbug                      = SELECT
 
@@ -2163,6 +2168,7 @@
 public.project                          = SELECT, UPDATE
 public.pushmirroraccess                 = SELECT, UPDATE
 public.question                         = SELECT, UPDATE
+public.questionjob                      = SELECT, INSERT
 public.questionreopening                = SELECT, UPDATE
 public.questionsubscription             = SELECT, UPDATE, DELETE
 public.revisionauthor                   = SELECT, UPDATE

=== modified file 'lib/lp/answers/doc/notifications.txt'
--- lib/lp/answers/doc/notifications.txt	2011-04-27 13:59:57 +0000
+++ lib/lp/answers/doc/notifications.txt	2011-04-30 02:03:29 +0000
@@ -7,9 +7,9 @@
 notification looks like:
 
     >>> from zope.event import notify
-    >>> from lazr.lifecycle.event import ObjectCreatedEvent
+    >>> from lp.answers.tests.test_question_notifications import (
+    ...     pop_questionemailjobs)
     >>> from lp.registry.interfaces.distribution import IDistributionSet
-    >>> from lp.testing.mail_helpers import pop_notifications
     >>> login('test@xxxxxxxxxxxxx')
     >>> sample_person = getUtility(ILaunchBag).user
     >>> ubuntu = getUtility(IDistributionSet).getByName('ubuntu')
@@ -29,7 +29,7 @@
     >>> [sub.person.displayname for sub in ubuntu_question.subscriptions]
     [u'Sample Person']
 
-    >>> notifications = pop_notifications()
+    >>> notifications = pop_questionemailjobs()
     >>> len(notifications)
     1
 
@@ -41,44 +41,26 @@
 Danilo have a story worth telling.
 
     >>> add_notification = notifications[0]
-    >>> add_notification['From']
-    'Sample Person <question...@xxxxxxxxxxxxxxxxxxxxx>'
-
-    >>> add_notification['Reply-To']
-    'question...@xxxxxxxxxxxxxxxxxxxxx'
-
-    >>> add_notification['To']
-    'test@xxxxxxxxxxxxx'
-
-    >>> add_notification['Subject']
-    "[Question #...]: Can't install Ubuntu"
+
+    >>> print add_notification.subject
+    [Question #...]: Can't install Ubuntu
 
 Like all Launchpad notifications should, the message contain in the
 footer the reason why the user is receiving the notification.
 
-    >>> notification_body = add_notification.get_payload(decode=True)
-    >>> print notification_body #doctest: -NORMALIZE_WHITESPACE
+    >>> print add_notification.body
     New question #... on Ubuntu:
     http://.../ubuntu/+question/...
     <BLANKLINE>
     I insert the install CD in the CD-ROM drive, but it won't boot.
-    <BLANKLINE>
-    --...
-    You received this question notification because you asked the question.
 
 The notification also includes a 'X-Launchpad-Question' header that
 contains information about the question.
 
-    >>> print add_notification['X-Launchpad-Question']
+    >>> print add_notification.headers['X-Launchpad-Question']
     distribution=ubuntu; sourcepackage=None; status=Open;
     assignee=None; priority=Normal; language=en
 
-As well as the standard 'X-Launchpad-Message-Rationale' header that
-contains in short format the reason for the user to be contacted.
-
-    >>> print add_notification['X-Launchpad-Message-Rationale']
-    Asker
-
 Register the Ubuntu Team as Ubuntu's answer contact, so that they get
 notified about the changes as well:
 
@@ -124,17 +106,12 @@
 Three copies of the notification got sent, one to Sample Person, one to
 Foo Bar, and one to Ubuntu Team:
 
-    >>> from operator import itemgetter
-    >>> notifications = sorted(pop_notifications(), key=itemgetter('To'))
-    >>> [notification['To'] for notification in notifications]
-    ['foo.bar@xxxxxxxxxxxxx', 'support@xxxxxxxxxx', 'test@xxxxxxxxxxxxx']
-
-    >>> edit_notification = notifications[0]
-    >>> notification_body = edit_notification.get_payload(decode=True)
-    >>> print edit_notification['Subject']
+    >>> notifications = pop_questionemailjobs()
+    >>> edit_notification = notifications[1]
+    >>> print edit_notification.subject
     Re: [Question #...]: Installer doesn't work on a Mac
 
-    >>> print notification_body #doctest: -NORMALIZE_WHITESPACE
+    >>> print edit_notification.body
     Question #... libstdc++ in Ubuntu changed:
     http://.../ubuntu/+source/libstdc++/+question/...
     <BLANKLINE>
@@ -148,10 +125,6 @@
     drive, but it won't boot.
     <BLANKLINE>
     It boots straight into MacOS 9.
-    <BLANKLINE>
-    --...
-    You received this question notification because you are the assignee for
-    this question.
 
 # XXX flacoste 2006-09-19: Add checks for notification of change to #
 status whiteboard, priority. For example, if a question is # transferred
@@ -163,21 +136,13 @@
     >>> ubuntu_question.target = ubuntu
     >>> notify(ObjectModifiedEvent(
     ...     ubuntu_question, unmodified_question, ['target']))
-    >>> notifications = sorted(pop_notifications(), key=itemgetter('To'))
-    >>> [notification['To'] for notification in notifications]
-    ['foo.bar@xxxxxxxxxxxxx', 'support@xxxxxxxxxx', 'test@xxxxxxxxxxxxx']
-
-    >>> edit_notification = notifications[0]
-    >>> notification_body = edit_notification.get_payload(decode=True)
-    >>> print notification_body #doctest: -NORMALIZE_WHITESPACE
+    >>> notifications = pop_questionemailjobs()
+    >>> edit_notification = notifications[1]
+    >>> print edit_notification.body
     Question #... Ubuntu changed:
     http://.../ubuntu/+question/...
     <BLANKLINE>
         Project: libstdc++ in Ubuntu => Ubuntu
-    <BLANKLINE>
-    --...
-    You received this question notification because you are the assignee for
-    this question.
 
 Changing the assignee will trigger a notification.
 
@@ -187,21 +152,13 @@
     >>> ubuntu_question.assignee = no_priv
     >>> notify(ObjectModifiedEvent(
     ...     ubuntu_question, unmodified_question, ['assignee']))
-    >>> notifications = sorted(pop_notifications(), key=itemgetter('To'))
-    >>> [notification['To'] for notification in notifications]
-    ['no-priv@xxxxxxxxxxxxx', 'support@xxxxxxxxxx', 'test@xxxxxxxxxxxxx']
-
-    >>> edit_notification = notifications[0]
-    >>> notification_body = edit_notification.get_payload(decode=True)
-    >>> print notification_body
+    >>> notifications = pop_questionemailjobs()
+    >>> edit_notification = notifications[1]
+    >>> print edit_notification.body
     Question #... Ubuntu changed:
     http://.../ubuntu/+question/...
     <BLANKLINE>
         Assignee: Foo Bar => No Privileges Person
-    <BLANKLINE>
-    --...
-    You received this question notification because you are the assignee for
-    this question.
 
 If we trigger a modification event when no changes worth notifying about
 was made, no notification is sent:
@@ -211,7 +168,7 @@
     >>> notify(ObjectModifiedEvent(
     ...     ubuntu_question, unmodified_question, ['status']))
 
-    >>> notifications = pop_notifications()
+    >>> notifications = pop_questionemailjobs()
     >>> len(notifications)
     0
 
@@ -245,23 +202,18 @@
     >>> notify(ObjectModifiedEvent(
     ...     ubuntu_question, unmodified_question, ['bugs']))
 
-    >>> notifications = pop_notifications()
+    >>> notifications = pop_questionemailjobs()
     >>> len(notifications)
     2
 
-    >>> edit_notification = notifications[0]
-    >>> notification_body = edit_notification.get_payload(decode=True)
-    >>> print notification_body #doctest: -NORMALIZE_WHITESPACE
+    >>> edit_notification = notifications[1]
+    >>> print edit_notification.body
     Question #... on Ubuntu changed:
     http://.../ubuntu/+question/...
     <BLANKLINE>
         Linked to bug: #...
         http://.../bugs/...
         "Installer fails on a Mac PPC"
-    <BLANKLINE>
-    --...
-    You received this question notification because you are a member of
-    Ubuntu Team, which is an answer contact for Ubuntu.
 
 
 Bug Unlinked Notification
@@ -277,23 +229,18 @@
     >>> notify(ObjectModifiedEvent(
     ...     ubuntu_question, unmodified_question, ['bugs']))
 
-    >>> notifications = pop_notifications()
+    >>> notifications = pop_questionemailjobs()
     >>> len(notifications)
     2
 
-    >>> edit_notification = notifications[0]
-    >>> notification_body = edit_notification.get_payload(decode=True)
-    >>> print notification_body #doctest: -NORMALIZE_WHITESPACE
+    >>> edit_notification = notifications[1]
+    >>> print edit_notification.body
     Question #... on Ubuntu changed:
     http://.../ubuntu/+question/...
     <BLANKLINE>
         Removed link to bug: #...
         http://.../bugs/...
         "Installer fails on a Mac PPC"
-    <BLANKLINE>
-    --...
-    You received this question notification because you are a member of
-    Ubuntu Team, which is an answer contact for Ubuntu.
 
 
 Linked Bug Status Changed Notification
@@ -314,13 +261,10 @@
     >>> request_message = ubuntu_question.requestInfo(
     ...     no_priv, "What is your Mac model?")
 
-    >>> notifications = pop_notifications()
-    >>> [email_msg['To'] for email_msg in notifications]
-    ['support@xxxxxxxxxx', 'test@xxxxxxxxxxxxx']
-
-    >>> support_notification = notifications[0]
-    >>> support_notification['Subject']
-    "Re: [Question #...]: Installer doesn't work on a Mac"
+    >>> notifications = pop_questionemailjobs()
+    >>> support_notification = notifications[1]
+    >>> print support_notification.subject
+    Re: [Question #...]: Installer doesn't work on a Mac
 
 For workflow notifications, the content of the notification is slightly
 different based on whether you are the question owner or somebody else.
@@ -328,8 +272,7 @@
 For example, the notification to the answer contacts and every other
 subscribers except the question owner will look like this:
 
-    >>> notification_body = support_notification.get_payload(decode=True)
-    >>> print notification_body #doctest: -NORMALIZE_WHITESPACE
+    >>> print support_notification.body
     Question #... on Ubuntu changed:
     http://.../ubuntu/+question/...
     <BLANKLINE>
@@ -337,16 +280,11 @@
     <BLANKLINE>
     No Privileges Person requested more information:
     What is your Mac model?
-    <BLANKLINE>
-    --...
-    You received this question notification because you are a member of
-    Ubuntu Team, which is an answer contact for Ubuntu.
 
 But the owner notification has a slightly different preamble and has an
 extra footer.
 
-    >>> notification_body = notifications[1].get_payload(decode=True)
-    >>> print notification_body #doctest: -NORMALIZE_WHITESPACE
+    >>> print notifications[0].body
     Your question #... on Ubuntu changed:
     http://.../ubuntu/+question/...
     <BLANKLINE>
@@ -359,8 +297,6 @@
     To answer this request for more information, you can either reply to
     this email or enter your reply at the following page:
     http://.../ubuntu/+question/...
-    <BLANKLINE>
-    You received this question notification because you asked the question.
 
 Of course, if the owner unsubscribe from the question, he won't receives
 a notification.
@@ -369,12 +305,8 @@
     >>> ubuntu_question.unsubscribe(sample_person)
     >>> message = ubuntu_question.giveInfo('A PowerMac 7200.')
 
-    >>> notifications = pop_notifications()
-    >>> [email_msg['To'] for email_msg in notifications]
-    ['support@xxxxxxxxxx']
-
-    >>> notification_body = notifications[0].get_payload(decode=True)
-    >>> print notification_body #doctest: -NORMALIZE_WHITESPACE
+    >>> notifications = pop_questionemailjobs()
+    >>> print notifications[1].body
     Question #... on Ubuntu changed:
     http://.../ubuntu/+question/...
     <BLANKLINE>
@@ -382,15 +314,11 @@
     <BLANKLINE>
     Sample Person gave more information on the question:
     A PowerMac 7200.
-    <BLANKLINE>
-    --...
-    You received this question notification because you are a member of
-    Ubuntu Team, which is an answer contact for Ubuntu.
 
 The notification for new messages on the question contain a 'References'
 header to the previous message for threading purpose.
 
-    >>> references = notifications[0]['References']
+    >>> references = notifications[0].headers['References']
     >>> print references
     <...>
 
@@ -413,15 +341,11 @@
     >>> login('no-priv@xxxxxxxxxxxxx')
     >>> message = ubuntu_question.expireQuestion(
     ...     no_priv, "Expired because of no recent activity.")
-
-    >>> notifications = pop_notifications()
-    >>> [email_msg['To'] for email_msg in notifications]
-    ['support@xxxxxxxxxx', 'test@xxxxxxxxxxxxx']
+    >>> notifications = pop_questionemailjobs()
 
 Default notification when the question is expired:
 
-    >>> notification_body = notifications[0].get_payload(decode=True)
-    >>> print notification_body #doctest: -NORMALIZE_WHITESPACE
+    >>> print notifications[1].body
     Question #... on Ubuntu changed:
     http://.../ubuntu/+question/...
     <BLANKLINE>
@@ -430,14 +354,10 @@
     No Privileges Person expired the question:
     Expired because of no recent activity.
     <BLANKLINE>
-    --...
-    You received this question notification because you are a member of
-    Ubuntu Team, which is an answer contact for Ubuntu.
 
 Notification received by the owner:
 
-    >>> notification_body = notifications[1].get_payload(decode=True)
-    >>> print notification_body #doctest: -NORMALIZE_WHITESPACE
+    >>> print notifications[0].body
     Your question #... on Ubuntu changed:
     http://.../ubuntu/+question/...
     <BLANKLINE>
@@ -451,8 +371,6 @@
     by replying to this email or by going to the following page and
     entering more information about your problem:
     http://.../ubuntu/+question/...
-    <BLANKLINE>
-    You received this question notification because you asked the question.
 
 
 Notifications for reopen()
@@ -473,20 +391,16 @@
     ...         "newbie."),
     ...     owner=sample_person)
     >>> message = ubuntu_question.reopen(email_msg)
-
-    >>> notifications = pop_notifications()
-    >>> [email_msg['To'] for email_msg in notifications]
-    ['support@xxxxxxxxxx', 'test@xxxxxxxxxxxxx']
+    >>> notifications = pop_questionemailjobs()
 
 Notice also how the 'Re' handling is handled nicely:
 
-    >>> print notifications[0]['Subject']
+    >>> print notifications[0].subject
     Re: [Question #...]: Installer doesn't work on a Mac
 
 Default notification when the owner reopens the question:
 
-    >>> notification_body = notifications[0].get_payload(decode=True)
-    >>> print notification_body #doctest: -NORMALIZE_WHITESPACE
+    >>> print notifications[1].body
     Question #... on Ubuntu changed:
     http://.../ubuntu/+question/...
     <BLANKLINE>
@@ -497,15 +411,10 @@
     useful.
     <BLANKLINE>
     Please provide some help to a newbie.
-    <BLANKLINE>
-    --...
-    You received this question notification because you are a member of
-    Ubuntu Team, which is an answer contact for Ubuntu.
 
 Notification received by the owner:
 
-    >>> notification_body = notifications[1].get_payload(decode=True)
-    >>> print notification_body #doctest: -NORMALIZE_WHITESPACE
+    >>> print notifications[0].body
     Your question #... on Ubuntu changed:
     http://.../ubuntu/+question/...
     <BLANKLINE>
@@ -516,9 +425,6 @@
     useful.
     <BLANKLINE>
     Please provide some help to a newbie.
-    <BLANKLINE>
-    --...
-    You received this question notification because you asked the question.
 
 
 Notifications for giveAnswer()
@@ -533,14 +439,11 @@
     ...     "https://help.ubuntu.com/community/Installation/OldWorldMacs "
     ...     "for all the details.")
 
-    >>> notifications = pop_notifications()
-    >>> [email_msg['To'] for email_msg in notifications]
-    ['support@xxxxxxxxxx', 'test@xxxxxxxxxxxxx']
+    >>> notifications = pop_questionemailjobs()
 
 Default notification when an answer is proposed:
 
-    >>> notification_body = notifications[0].get_payload(decode=True)
-    >>> print notification_body #doctest: -NORMALIZE_WHITESPACE
+    >>> print notifications[1].body
     Question #... on Ubuntu changed:
     http://.../ubuntu/+question/...
     <BLANKLINE>
@@ -553,15 +456,10 @@
     <BLANKLINE>
     Consult https://help.ubuntu.com/community/Installation/OldWorldMacs for
     all the details.
-    <BLANKLINE>
-    --...
-    You received this question notification because you are a member of
-    Ubuntu Team, which is an answer contact for Ubuntu.
 
 Notification received by the owner:
 
-    >>> notification_body = notifications[1].get_payload(decode=True)
-    >>> print notification_body #doctest: -NORMALIZE_WHITESPACE
+    >>> print notifications[0].body
     Your question #... on Ubuntu changed:
     http://.../ubuntu/+question/...
     <BLANKLINE>
@@ -583,8 +481,6 @@
     If you still need help, you can reply to this email or go to the
     following page to enter your feedback:
     http://.../ubuntu/+question/...
-    <BLANKLINE>
-    You received this question notification because you asked the question.
 
 
 Notifications for confirm()
@@ -595,14 +491,11 @@
     ...     "I've installed BootX and the installer CD is now booting. "
     ...     "Thanks!", answer=answer_message)
 
-    >>> notifications = pop_notifications()
-    >>> [email_msg['To'] for email_msg in notifications]
-    ['support@xxxxxxxxxx', 'test@xxxxxxxxxxxxx']
+    >>> notifications = pop_questionemailjobs()
 
 Default notification when the owner confirms an answer:
 
-    >>> notification_body = notifications[0].get_payload(decode=True)
-    >>> print notification_body #doctest: -NORMALIZE_WHITESPACE
+    >>> print notifications[1].body
     Question #... on Ubuntu changed:
     http://.../ubuntu/+question/...
     <BLANKLINE>
@@ -610,15 +503,10 @@
     <BLANKLINE>
     Sample Person confirmed that the question is solved:
     I've installed BootX and the installer CD is now booting. Thanks!
-    <BLANKLINE>
-    --...
-    You received this question notification because you are a member of
-    Ubuntu Team, which is an answer contact for Ubuntu.
 
 Notification received by the owner:
 
-    >>> notification_body = notifications[1].get_payload(decode=True)
-    >>> print notification_body #doctest: -NORMALIZE_WHITESPACE
+    >>> print notifications[0].body
     Your question #... on Ubuntu changed:
     http://.../ubuntu/+question/...
     <BLANKLINE>
@@ -626,9 +514,6 @@
     <BLANKLINE>
     You confirmed that the question is solved:
     I've installed BootX and the installer CD is now booting. Thanks!
-    <BLANKLINE>
-    --...
-    You received this question notification because you asked the question.
 
 
 Notifications for addComment()
@@ -639,38 +524,27 @@
     ...     no_priv, "Unless you have lots of RAM... and even then, the "
     ...     "system will probably be very slow.")
 
-    >>> notifications = pop_notifications()
-    >>> [email_msg['To'] for email_msg in notifications]
-    ['support@xxxxxxxxxx', 'test@xxxxxxxxxxxxx']
+    >>> notifications = pop_questionemailjobs()
 
 Default notification when a comment is posted:
 
-    >>> notification_body = notifications[0].get_payload(decode=True)
-    >>> print notification_body #doctest: -NORMALIZE_WHITESPACE
+    >>> print notifications[1].body
     Question #... on Ubuntu changed:
     http://.../ubuntu/+question/...
     <BLANKLINE>
     No Privileges Person posted a new comment:
     Unless you have lots of RAM... and even then, the system will probably
     be very slow.
-    <BLANKLINE>
-    --...
-    You received this question notification because you are a member of
-    Ubuntu Team, which is an answer contact for Ubuntu.
 
 Notification received by the owner:
 
-    >>> notification_body = notifications[1].get_payload(decode=True)
-    >>> print notification_body #doctest: -NORMALIZE_WHITESPACE
+    >>> print notifications[0].body
     Your question #... on Ubuntu changed:
     http://.../ubuntu/+question/...
     <BLANKLINE>
     No Privileges Person posted a new comment:
     Unless you have lots of RAM... and even then, the system will probably
     be very slow.
-    <BLANKLINE>
-    --...
-    You received this question notification because you asked the question.
 
 
 Notifications for reject()
@@ -681,14 +555,11 @@
     >>> message = ubuntu_question.reject(
     ...     foo_bar, "Yeah! It will be awfully slow.")
 
-    >>> notifications = pop_notifications()
-    >>> [email_msg['To'] for email_msg in notifications]
-    ['support@xxxxxxxxxx', 'test@xxxxxxxxxxxxx']
+    >>> notifications = pop_questionemailjobs()
 
 Default notification when the question is rejected:
 
-    >>> notification_body = notifications[0].get_payload(decode=True)
-    >>> print notification_body #doctest: -NORMALIZE_WHITESPACE
+    >>> print notifications[1].body
     Question #... on Ubuntu changed:
     http://.../ubuntu/+question/...
     <BLANKLINE>
@@ -697,14 +568,10 @@
     Foo Bar rejected the question:
     Yeah! It will be awfully slow.
     <BLANKLINE>
-    --...
-    You received this question notification because you are a member of
-    Ubuntu Team, which is an answer contact for Ubuntu.
 
 Notification received by the owner:
 
-    >>> notification_body = notifications[1].get_payload(decode=True)
-    >>> print notification_body #doctest: -NORMALIZE_WHITESPACE
+    >>> print notifications[0].body
     Your question #... on Ubuntu changed:
     http://.../ubuntu/+question/...
     <BLANKLINE>
@@ -718,8 +585,6 @@
     explaining your point of view either by replying to this email or at
     the following page:
     http://.../ubuntu/+question/...
-    <BLANKLINE>
-    You received this question notification because you asked the question.
 
 
 Notifications for setStatus()
@@ -730,14 +595,11 @@
     >>> message = ubuntu_question.setStatus(
     ...     foo_bar, QuestionStatus.SOLVED, "The rejection was a mistake.")
 
-    >>> notifications = pop_notifications()
-    >>> [email_msg['To'] for email_msg in notifications]
-    ['support@xxxxxxxxxx', 'test@xxxxxxxxxxxxx']
+    >>> notifications = pop_questionemailjobs()
 
 Default notification when somebody changes the status:
 
-    >>> notification_body = notifications[0].get_payload(decode=True)
-    >>> print notification_body #doctest: -NORMALIZE_WHITESPACE
+    >>> print notifications[1].body
     Question #... on Ubuntu changed:
     http://.../ubuntu/+question/...
     <BLANKLINE>
@@ -745,15 +607,10 @@
     <BLANKLINE>
     Foo Bar changed the question status:
     The rejection was a mistake.
-    <BLANKLINE>
-    --...
-    You received this question notification because you are a member of
-    Ubuntu Team, which is an answer contact for Ubuntu.
 
 Notification received by the owner:
 
-    >>> notification_body = notifications[1].get_payload(decode=True)
-    >>> print notification_body #doctest: -NORMALIZE_WHITESPACE
+    >>> print notifications[0].body
     Your question #... on Ubuntu changed:
     http://.../ubuntu/+question/...
     <BLANKLINE>
@@ -761,9 +618,6 @@
     <BLANKLINE>
     Foo Bar changed the question status:
     The rejection was a mistake.
-    <BLANKLINE>
-    --...
-    You received this question notification because you asked the question.
 
 
 Notifications for linkFAQ()
@@ -777,10 +631,7 @@
     >>> firefox = getUtility(IProductSet).getByName('firefox')
     >>> firefox_question = firefox.newQuestion(
     ...     no_priv, 'How can I play Flash?', 'I want Flash!')
-
-    # Discard notifications.
-
-    >>> notifications = pop_notifications()
+    >>> ignore = pop_questionemailjobs()
 
     >>> login('test@xxxxxxxxxxxxx')
     >>> firefox_faq = firefox.getFAQ(10)
@@ -789,13 +640,9 @@
 
     >>> message = firefox_question.linkFAQ(
     ...     sample_person, firefox_faq, "Read the FAQ.")
-
-    >>> notifications = pop_notifications()
-    >>> [email_msg['To'] for email_msg in notifications]
-    ['no-priv@xxxxxxxxxxxxx']
-
-    >>> notification_body = notifications[0].get_payload(decode=True)
-    >>> print notification_body #doctest: -NORMALIZE_WHITESPACE
+    >>> notifications = pop_questionemailjobs()
+
+    >>> print notifications[0].body
     Your question #... on Mozilla Firefox changed:
     http://answers.launchpad.dev/firefox/+question/...
     <BLANKLINE>
@@ -814,13 +661,9 @@
 
     >>> message = firefox_question.linkFAQ(
     ...     sample_person, None, "Sorry, this wasn't so useful.")
-
-    >>> notifications = pop_notifications()
-    >>> [email_msg['To'] for email_msg in notifications]
-    ['no-priv@xxxxxxxxxxxxx']
-
-    >>> notification_body = notifications[0].get_payload(decode=True)
-    >>> print notification_body #doctest: -NORMALIZE_WHITESPACE
+    >>> notifications = pop_questionemailjobs()
+
+    >>> print notifications[0].body
     Your question #... on Mozilla Firefox changed:
     http://answers.launchpad.dev/firefox/+question/...
     <BLANKLINE>
@@ -841,49 +684,9 @@
 from bugs just like when a question is normally created.
 
     >>> bug_question = ubuntu.createQuestionFromBug(bug)
-    >>> notifications = pop_notifications()
+    >>> notifications = pop_questionemailjobs()
     >>> len(notifications)
-    4
-
-    >>> [email_msg['To'] for email_msg in notifications]
-    ['no-priv@xxxxxxxxxxxxx', 'no-priv@xxxxxxxxxxxxx',
-     'support@xxxxxxxxxx', 'support@xxxxxxxxxx']
-
-
-Notifications and Teams
------------------------
-
-When a team is subscribed to a question, there are two cases two
-consider. The first one is if the team has an email address set, a
-notification will only be sent to that address. (That email address is
-assumed to be a mailing list reaching all the team members.) We already
-saw an example of that case with the Ubuntu Team in the examples above.
-
-The other case is when the team doesn't have an email address set. In
-that case, all the team members will be notified individually.
-
-    >>> launchpad_devs = getUtility(IPersonSet).getByName('launchpad')
-    >>> ubuntu_question.subscribe(launchpad_devs)
-    <QuestionSubscription...>
-
-    >>> login('test@xxxxxxxxxxxxx')
-    >>> message = ubuntu_question.addComment(sample_person, 'A comment.')
-
-    >>> notifications = pop_notifications()
-    >>> [email_msg['To'] for email_msg in notifications]
-    ['foo.bar@xxxxxxxxxxxxx', 'support@xxxxxxxxxx', 'test@xxxxxxxxxxxxx']
-
-Of course, if the user is also individually subscribed to the question,
-he will receives only one notification:
-
-    >>> ubuntu_question.subscribe(foo_bar)
-    <QuestionSubscription...>
-
-    >>> message = ubuntu_question.addComment(sample_person, 'A comment.')
-
-    >>> notifications = pop_notifications()
-    >>> [email_msg['To'] for email_msg in notifications]
-    ['foo.bar@xxxxxxxxxxxxx', 'support@xxxxxxxxxx', 'test@xxxxxxxxxxxxx']
+    3
 
 
 Notifications and Localized Questions
@@ -915,13 +718,10 @@
     ...         u'corretamente e mostra a minha versao do java. No entanto, '
     ...         u'mover o mouse na pagina faz com que o firefox quebre.'),
     ...     language=getUtility(ILanguageSet)['pt_BR'])
-    >>> notifications = pop_notifications()
-    >>> [email_msg['To'] for email_msg in notifications]
-    ['guilherme.salgado@xxxxxxxxxxxxx', 'test@xxxxxxxxxxxxx']
+    >>> notifications = pop_questionemailjobs()
 
-    >>> from email.Header import decode_header, make_header
-    >>> unicode(make_header(decode_header(notifications[0]['Subject'])))
-    u'[Question #...]: Abrir uma p\xe1gina que requer java quebra o firefox'
+    >>> print notifications[0].subject.encode('ASCII', 'backslashreplace')
+    [Question #...]: Abrir uma p\xe1gina que requer java quebra o firefox
 
 Similarly, when a question in a non-English language is modified or its
 status changed, only the subscribers speaking that language will receive
@@ -931,9 +731,7 @@
     ...     "Veja o screenshot: http://tinyurl.com/y8jq8z";)
     <QuestionMessage...>
 
-    >>> notifications = pop_notifications()
-    >>> [email_msg['To'] for email_msg in notifications]
-    ['guilherme.salgado@xxxxxxxxxxxxx', 'test@xxxxxxxxxxxxx']
+    >>> ignore = pop_questionemailjobs()
 
 The exception to these general rules is that when a question is created
 in language spoken by none of the answer contacts, each one will receive
@@ -949,43 +747,35 @@
     ...     sample_person, title="Impossible d'installer Ubuntu",
     ...     description=u"Le CD ne semble pas fonctionn\xe9.",
     ...     language=french)
-    >>> notifications = pop_notifications()
-    >>> [email_msg['To'] for email_msg in notifications]
-    ['guilherme.salgado@xxxxxxxxxxxxx', 'support@xxxxxxxxxx',
-     'test@xxxxxxxxxxxxx']
+    >>> notifications = pop_questionemailjobs()
 
-    >>> notifications[0]['Subject']
-    "[Question #...]: (French) Impossible d'installer Ubuntu"
+    >>> print notifications[1].subject
+    [Question #...]: (French) Impossible d'installer Ubuntu
 
     # Define a function that will replace non-ascii character with
     # its unicoded encoded value.
     # Effectively replace u'\xe9' by '\\e9'.
 
-    >>> def escape_utf8_payload(message):
-    ...     charset = message.get_content_charset()
-    ...     content = unicode(message.get_payload(decode=True), charset)
-    ...     return content.encode('us-ascii', 'backslashreplace')
+    >>> def recode_text(notification):
+    ...     return notification.body.encode('ASCII', 'backslashreplace')
 
-    >>> notification_body = escape_utf8_payload(notifications[0])
-    >>> print notification_body #doctest: -NORMALIZE_WHITESPACE
+    >>> notification_body = recode_text(notifications[1])
+    >>> print notification_body
     A question was asked in a language (French) spoken by
     none of the registered Ubuntu answer contacts.
     <BLANKLINE>
     http://.../ubuntu/+question/...
     <BLANKLINE>
     Le CD ne semble pas fonctionn\xe9...
-    --...
-    You received this question notification because you are an answer
-    contact for Ubuntu.
 
 The notification received by the question owner contain a warning that
 the question is in a language spoken by none of the answer contacts:
 
-    >>> notifications[-1]['Subject']
-    "[Question #...]: Impossible d'installer Ubuntu"
+    >>> print notifications[0].subject
+    [Question #...]: Impossible d'installer Ubuntu
 
-    >>> notification_body = escape_utf8_payload(notifications[-1])
-    >>> print notification_body #doctest: -NORMALIZE_WHITESPACE
+    >>> notification_body = recode_text(notifications[0])
+    >>> print notification_body
     New question #... on Ubuntu:
     http://.../ubuntu/+question/...
     <BLANKLINE>
@@ -993,9 +783,6 @@
     <BLANKLINE>
     WARNING: This question is asked in a language (French)
     spoken by none of the registered Ubuntu answer contacts.
-    <BLANKLINE>
-    --...
-    You received this question notification because you asked the question.
 
 No notification will be sent to the answer contacts when this question
 is modified. Only the owner will receive a modification notification
@@ -1006,13 +793,10 @@
     >>> french_question.title = u"CD d'Ubuntu ne d\xe9marre pas"
     >>> notify(ObjectModifiedEvent(
     ...     french_question, unmodified_question, ['title']))
-
-    >>> notifications = pop_notifications()
-    >>> [email_msg['To'] for email_msg in notifications]
-    ['test@xxxxxxxxxxxxx']
-
-    >>> notification_body = escape_utf8_payload(notifications[0])
-    >>> print notification_body #doctest: -NORMALIZE_WHITESPACE
+    >>> notifications = pop_questionemailjobs()
+
+    >>> notification_body = recode_text(notifications[0])
+    >>> print notification_body
     Your question #... on Ubuntu changed:
     http://.../ubuntu/+question/...
     <BLANKLINE>
@@ -1021,89 +805,3 @@
     <BLANKLINE>
     WARNING: This question is asked in a language (French)
     spoken by none of the registered Ubuntu answer contacts.
-    <BLANKLINE>
-    --...
-    You received this question notification because you asked the question.
-
-
-Localized Questions and Teams
-.............................
-
-We will notify the team only if the question language is in one of the
-team's preferred languages. The languages spoken by the team members is
-unimportant.
-
-For example, the rosetta admins team becomes an Answer contact for
-English questions. Carlos speaks Spanish, and he is an answer contact
-for Ubuntu. He is also a member of the rosetta admins team. The team
-wont receive emails because of his membership when they become answer
-contacts too.
-
-    >>> rosetta_admins = getUtility(IPersonSet).getByName('rosetta-admins')
-    >>> [lang.code for lang in rosetta_admins.languages]
-    []
-
-    >>> rosetta_admins.addLanguage(getUtility(ILanguageSet)['en'])
-    >>> carlos = getUtility(IPersonSet).getByName('carlos')
-    >>> carlos.inTeam(rosetta_admins)
-    True
-
-    >>> spanish = getUtility(ILanguageSet)['es']
-    >>> spanish in carlos.languages
-    True
-
-    >>> ubuntu.addAnswerContact(carlos)
-    True
-
-    >>> ubuntu.addAnswerContact(rosetta_admins)
-    True
-
-    >>> spanish_question = ubuntu.newQuestion(
-    ...     sample_person, title="Necesidad ayuda con Firefox",
-    ...     description="No puedo acceso al Internet en Firefox.",
-    ...     language=spanish)
-    >>> notifications = pop_notifications()
-    >>> [email_msg['To'] for email_msg in notifications]
-    ['carlos@xxxxxxxxxxxxx', 'test@xxxxxxxxxxxxx']
-
-    >>> ubuntu.removeAnswerContact(carlos)
-    True
-
-But if the team languages attribute is set, this set of languages will
-be used. So, if the team only officially speaks French, it will only
-receive notifications about French (and English) questions.
-
-    >>> rosetta_admins.addLanguage(french)
-
-    # Resend the new message notification
-
-    >>> notify(ObjectCreatedEvent(french_question))
-    >>> notifications = pop_notifications()
-    >>> [email_msg['To'] for email_msg in notifications]
-    ['rosetta@xxxxxxxxxxxxx', 'test@xxxxxxxxxxxxx']
-
-When the team doesn't use an explicit address. All team members will be
-contacted if the question language is supported. For example, the
-Launchpad Developers team doesn't have any preferred email address set.
-Its only member, Foo Bar will receive a notification if the team
-supported languages includes the question language:
-
-    >>> launchpad_devs = getUtility(IPersonSet).getByName('launchpad')
-    >>> list(launchpad_devs.languages)
-    []
-
-    >>> [member.name for member in launchpad_devs.activemembers]
-    [u'name16']
-
-    >>> launchpad_devs.addLanguage(spanish)
-    >>> ubuntu.addAnswerContact(launchpad_devs)
-    True
-
-    # Resend the new message notification
-
-    >>> notify(ObjectCreatedEvent(spanish_question))
-    >>> notifications = pop_notifications()
-    >>> [email_msg['To'] for email_msg in notifications]
-    ['foo.bar@xxxxxxxxxxxxx', 'test@xxxxxxxxxxxxx']
-
-

=== modified file 'lib/lp/answers/notification.py'
--- lib/lp/answers/notification.py	2011-04-27 13:59:57 +0000
+++ lib/lp/answers/notification.py	2011-04-30 02:03:29 +0000
@@ -10,16 +10,17 @@
 
 import os
 
+from zope.component import getUtility
+
 from canonical.config import config
-from canonical.launchpad.mail import (
-    format_address,
-    simple_sendmail,
+from canonical.launchpad.webapp.publisher import canonical_url
+from lp.answers.enums import (
+    QuestionAction,
+    QuestionRecipientSet,
     )
-from canonical.launchpad.webapp.publisher import canonical_url
-from lp.answers.enums import QuestionAction
+from lp.answers.interfaces.questionjob import IQuestionEmailJobSource
 from lp.registry.interfaces.person import IPerson
 from lp.services.mail.mailwrapper import MailWrapper
-from lp.services.mail.notificationrecipientset import NotificationRecipientSet
 from lp.services.propertycache import cachedproperty
 
 
@@ -41,6 +42,8 @@
     QuestionNotification can be registered as event subscribers.
     """
 
+    recipient_set = QuestionRecipientSet.ASKER_SUBSCRIBER
+
     def __init__(self, question, event):
         """Base constructor.
 
@@ -51,25 +54,15 @@
         self.event = event
         self._user = IPerson(self.event.user)
         self.initialize()
+        self.job = None
         if self.shouldNotify():
-            self.send()
+            self.job = self.enqueue()
 
     @property
     def user(self):
         """Return the user from the event. """
         return self._user
 
-    def getFromAddress(self):
-        """Return a formatted email address suitable for user in the From
-        header of the question notification.
-
-        Default is Event Person Display Name <question#@answertracker_domain>
-        """
-        return format_address(
-            self.user.displayname,
-            'question%s@%s' % (
-                self.question.id, config.answertracker.email_domain))
-
     def getSubject(self):
         """Return the subject of the notification.
 
@@ -114,18 +107,6 @@
 
         return headers
 
-    def getRecipients(self):
-        """Return the recipient of the notification.
-
-        Default to the question's subscribers that speaks the request
-        languages. If the question owner is subscribed, he's always consider
-        to speak the language.
-
-        :return: A `INotificationRecipientSet` containing the recipients and
-                 rationale.
-        """
-        return self.question.getRecipients()
-
     def initialize(self):
         """Initialization hook for subclasses.
 
@@ -144,32 +125,16 @@
         """
         return True
 
-    def buildBody(self, body, rationale):
-        """Wrap the body and ensure the rationale is is separated."""
-        wrapper = MailWrapper()
-        body_parts = [body, wrapper.format(rationale)]
-        if '\n-- ' not in body:
-            body_parts.insert(1, '-- ')
-        return '\n'.join(body_parts)
-
-    def send(self):
-        """Sends the notification to all the notification recipients.
-
-        This method takes care of adding the rationale for contacting each
-        recipient and also sets the X-Launchpad-Message-Rationale header on
-        each message.
-        """
-        from_address = self.getFromAddress()
+    def enqueue(self):
+        """Create a job to send email about the event."""
         subject = self.getSubject()
         body = self.getBody()
         headers = self.getHeaders()
-        recipients = self.getRecipients()
-        for email in recipients.getEmails():
-            rationale, header = recipients.getReason(email)
-            headers['X-Launchpad-Message-Rationale'] = header
-            formatted_body = self.buildBody(body, rationale)
-            simple_sendmail(
-                from_address, email, subject, formatted_body, headers)
+        job_source = getUtility(IQuestionEmailJobSource)
+        job = job_source.create(
+            self.question, self.user, self.recipient_set,
+            subject, body, headers)
+        return job
 
     @property
     def unsupported_language(self):
@@ -215,6 +180,7 @@
 class QuestionModifiedDefaultNotification(QuestionNotification):
     """Base implementation of a notification when a question is modified."""
 
+    recipient_set = QuestionRecipientSet.SUBSCRIBER
     # Email template used to render the body.
     body_template = "question-modified-notification.txt"
 
@@ -347,18 +313,6 @@
 
         return get_email_template(self.body_template) % replacements
 
-    def getRecipients(self):
-        """The default notification goes to all question subscribers that
-        speak the request language, except the owner.
-        """
-        original_recipients = QuestionNotification.getRecipients(self)
-        recipients = NotificationRecipientSet()
-        for person in original_recipients:
-            if person != self.question.owner:
-                rationale, header = original_recipients.getReason(person)
-                recipients.add(person, rationale, header)
-        return recipients
-
     # Header template used when a new message is added to the question.
     action_header_template = {
         QuestionAction.REQUESTINFO:
@@ -397,6 +351,7 @@
 class QuestionModifiedOwnerNotification(QuestionModifiedDefaultNotification):
     """Notification sent to the owner when his question is modified."""
 
+    recipient_set = QuestionRecipientSet.ASKER
     # These actions will be done by the owner, so use the second person.
     action_header_template = dict(
         QuestionModifiedDefaultNotification.action_header_template)
@@ -426,16 +381,6 @@
             self.body_template = self.body_template_by_action.get(
                 self.new_message.action, self.body_template)
 
-    def getRecipients(self):
-        """Return the owner of the question if he's still subscribed."""
-        recipients = NotificationRecipientSet()
-        owner = self.question.owner
-        original_recipients = self.question.direct_recipients
-        if owner in self.question.direct_recipients:
-            rationale, header = original_recipients.getReason(owner)
-            recipients.add(owner, rationale, header)
-        return recipients
-
     def getBody(self):
         """See QuestionNotification."""
         body = QuestionModifiedDefaultNotification.getBody(self)
@@ -447,6 +392,8 @@
 class QuestionUnsupportedLanguageNotification(QuestionNotification):
     """Notification sent to answer contacts for unsupported languages."""
 
+    recipient_set = QuestionRecipientSet.CONTACT
+
     def getSubject(self):
         """See QuestionNotification."""
         return '[Question #%s]: (%s) %s' % (
@@ -457,10 +404,6 @@
         """Return True when the question is in an unsupported language."""
         return self.unsupported_language
 
-    def getRecipients(self):
-        """Notify only the answer contacts."""
-        return self.question.target.getAnswerContactRecipients(None)
-
     def getBody(self):
         """See QuestionNotification."""
         question = self.question

=== modified file 'lib/lp/answers/tests/test_question_notifications.py'
--- lib/lp/answers/tests/test_question_notifications.py	2011-04-23 01:31:22 +0000
+++ lib/lp/answers/tests/test_question_notifications.py	2011-04-30 02:03:29 +0000
@@ -5,15 +5,40 @@
 
 __metaclass__ = type
 
+__all__ = [
+    'pop_questionemailjobs',
+    ]
+
 from unittest import TestCase
 
+from zope.component import getUtility
 from zope.interface import implements
+from zope.security.proxy import removeSecurityProxy
 
+from canonical.testing import DatabaseFunctionalLayer
+from lp.answers.enums import QuestionRecipientSet
+from lp.answers.interfaces.questioncollection import IQuestionSet
+from lp.answers.model.questionjob import QuestionEmailJob
 from lp.answers.notification import (
     QuestionAddedNotification,
     QuestionModifiedDefaultNotification,
+    QuestionModifiedOwnerNotification,
+    QuestionNotification,
+    QuestionUnsupportedLanguageNotification,
     )
 from lp.registry.interfaces.person import IPerson
+from lp.services.worlddata.interfaces.language import ILanguageSet
+from lp.testing import TestCaseWithFactory
+
+
+def pop_questionemailjobs():
+    jobs = sorted(
+        QuestionEmailJob.iterReady(),
+        key=lambda job: job.metadata["recipient_set"])
+    for job in jobs:
+        job.start()
+        job.complete()
+    return jobs
 
 
 class TestQuestionModifiedNotification(QuestionModifiedDefaultNotification):
@@ -39,6 +64,7 @@
         self.id = id
         self.title = title
         self.owner = FakeUser()
+        self.messages = []
 
 
 class StubQuestionMessage:
@@ -56,6 +82,7 @@
 class FakeEvent:
     """A fake event."""
     user = FakeUser()
+    object_before_modification = StubQuestion()
 
 
 class QuestionModifiedDefaultNotificationTestCase(TestCase):
@@ -66,19 +93,10 @@
         self.notification = TestQuestionModifiedNotification(
             StubQuestion(), FakeEvent())
 
-    def test_buildBody_with_separator(self):
-        # A body with a separator is preserved.
-        formatted_body = self.notification.buildBody(
-            "body\n-- ", "rationale")
-        self.assertEqual(
-            "body\n-- \nrationale", formatted_body)
-
-    def test_buildBody_without_separator(self):
-        # A separator will added to body if one is not present.
-        formatted_body = self.notification.buildBody(
-            "body -- mdash", "rationale")
-        self.assertEqual(
-            "body -- mdash\n-- \nrationale", formatted_body)
+    def test_recipient_set(self):
+        self.assertEqual(
+            QuestionRecipientSet.SUBSCRIBER,
+            self.notification.recipient_set)
 
     def test_getSubject(self):
         """getSubject() when there is no message added to the question."""
@@ -95,6 +113,29 @@
         self.assertNotEqual(question.owner, notification.user)
 
 
+class TestQuestionModifiedOwnerNotification(
+                                           QuestionModifiedOwnerNotification):
+    """A subclass that does not send emails."""
+
+    def shouldNotify(self):
+        return False
+
+
+class QuestionModifiedOwnerNotificationTestCase(TestCase):
+    """Test cases for mail notifications about owner modified questions."""
+
+    def setUp(self):
+        self.question = StubQuestion()
+        self.event = FakeEvent()
+        self.notification = TestQuestionModifiedOwnerNotification(
+            self.question, self.event)
+
+    def test_recipient_set(self):
+        self.assertEqual(
+            QuestionRecipientSet.ASKER,
+            self.notification.recipient_set)
+
+
 class TestQuestionAddedNotification(QuestionAddedNotification):
     """A subclass that does not send emails."""
 
@@ -102,13 +143,85 @@
         return False
 
 
-class QuestionCreatedTestCase(TestCase):
+class QuestionAddedNotificationTestCase(TestCase):
     """Test cases for mail notifications about created questions."""
 
+    def setUp(self):
+        self.question = StubQuestion()
+        self.event = FakeEvent()
+        self.notification = TestQuestionAddedNotification(
+            self.question, self.event)
+
+    def test_recipient_set(self):
+        self.assertEqual(
+            QuestionRecipientSet.ASKER_SUBSCRIBER,
+            self.notification.recipient_set)
+
     def test_user_is_question_owner(self):
         """The notification user is always the question owner."""
-        question = StubQuestion()
+        self.assertEqual(self.question.owner, self.notification.user)
+        self.assertNotEqual(self.event.user, self.notification.user)
+
+
+class TestQuestionUnsupportedLanguageNotification(
+                                     QuestionUnsupportedLanguageNotification):
+    """A subclass that does not send emails."""
+
+    def shouldNotify(self):
+        return False
+
+
+class QuestionUnsupportedLanguageNotificationTestCase(TestCase):
+    """Test notifications about questions with unsupported languages."""
+
+    def setUp(self):
+        self.question = StubQuestion()
+        self.event = FakeEvent()
+        self.notification = TestQuestionUnsupportedLanguageNotification(
+            self.question, self.event)
+
+    def test_recipient_set(self):
+        self.assertEqual(
+            QuestionRecipientSet.CONTACT,
+            self.notification.recipient_set)
+
+
+class TestQuestionNotification(QuestionNotification):
+    """A subclass to exercise question notifcations."""
+
+    recipient_set = QuestionRecipientSet.ASKER_SUBSCRIBER
+
+    def getBody(self):
+        return 'body'
+
+
+class QuestionNotificationTestCase(TestCaseWithFactory):
+    """Test common question notification behavior."""
+
+    layer = DatabaseFunctionalLayer
+
+    def makeQuestion(self):
+        """Create question that does not trigger a notification."""
+        asker = self.factory.makePerson()
+        product = self.factory.makeProduct()
+        naked_question_set = removeSecurityProxy(getUtility(IQuestionSet))
+        question = naked_question_set.new(
+            title='title', description='description', owner=asker,
+            language=getUtility(ILanguageSet)['en'],
+            product=product, distribution=None, sourcepackagename=None)
+        return question
+
+    def test_init_enqueue(self):
+        # Creating a question notification creates a queation email job.
+        question = self.makeQuestion()
         event = FakeEvent()
-        notification = TestQuestionAddedNotification(question, event)
-        self.assertEqual(question.owner, notification.user)
-        self.assertNotEqual(event.user, notification.user)
+        event.user = self.factory.makePerson()
+        notification = TestQuestionNotification(question, event)
+        self.assertEqual(
+            notification.recipient_set.name,
+            notification.job.metadata['recipient_set'])
+        self.assertEqual(notification.question, notification.job.question)
+        self.assertEqual(notification.user, notification.job.user)
+        self.assertEqual(notification.getSubject(), notification.job.subject)
+        self.assertEqual(notification.getBody(), notification.job.body)
+        self.assertEqual(notification.getHeaders(), notification.job.headers)

=== modified file 'lib/lp/coop/answersbugs/tests/notifications-linked-bug.txt'
--- lib/lp/coop/answersbugs/tests/notifications-linked-bug.txt	2010-10-18 22:24:59 +0000
+++ lib/lp/coop/answersbugs/tests/notifications-linked-bug.txt	2011-04-30 02:03:29 +0000
@@ -1,4 +1,5 @@
-= Linked Bug Status Changed Notification =
+Linked Bug Status Changed Notification
+======================================
 
 While a bug is linked to a question , its subscribers will be notified
 of changes to the bug status:
@@ -7,6 +8,8 @@
     >>> from zope.interface import providedBy
     >>> from lazr.lifecycle.event import ObjectModifiedEvent
     >>> from lazr.lifecycle.snapshot import Snapshot
+    >>> from lp.answers.tests.test_question_notifications import (
+    ...     pop_questionemailjobs)
     >>> from lp.bugs.interfaces.bugtask import BugTaskStatus
     >>> from lp.registry.interfaces.person import IPersonSet
 
@@ -15,20 +18,22 @@
     >>> original_bugtask = Snapshot(bugtask, providing=providedBy(bugtask))
     >>> bugtask.transitionToStatus(BugTaskStatus.CONFIRMED, no_priv)
     >>> bugtask.statusexplanation = 'This bug really happened to me.'
+    >>> ignore = pop_questionemailjobs()
     >>> notify(ObjectModifiedEvent(
     ...     bugtask, original_bugtask, ['status', 'statusexplanation'],
     ...     user=no_priv))
 
-    >>> from lp.testing.mail_helpers import pop_notifications
-    >>> notifications = pop_notifications()
+    >>> notifications = pop_questionemailjobs()
     >>> len(notifications)
-    2
-    >>> [notification['To'] for notification in notifications]
-    ['support@xxxxxxxxxx', 'test@xxxxxxxxxxxxx']
-    >>> notification_body = notifications[0].get_payload(decode=True)
-    >>> print notifications[0]['Subject']
+    1
+
+    >>> print notifications[0].metadata['recipient_set']
+    ASKER_SUBSCRIBER
+
+    >>> print notifications[0].subject
     [Question #...]: Status of bug #... changed to 'Confirmed' in Ubuntu
-    >>> print notification_body #doctest: -NORMALIZE_WHITESPACE
+
+    >>> print notifications[0].body
     Bug #... status changed in Ubuntu:
     <BLANKLINE>
         New => Confirmed
@@ -43,15 +48,12 @@
     This bug is linked to #15.
     Can't install Ubuntu
     http://.../ubuntu/+question/...
-    <BLANKLINE>
-    --...
-    You received this question notification because you are a member of
-    Ubuntu Team, which is an answer contact for Ubuntu.
 
 Only a change in status triggers a notification.
 
     >>> from lp.testing import login_person
-    >>> sample_person = getUtility(IPersonSet).getByEmail('test@xxxxxxxxxxxxx')
+    >>> sample_person = getUtility(IPersonSet).getByEmail(
+    ...     'test@xxxxxxxxxxxxx')
     >>> login_person(sample_person)
     >>> original_bugtask = Snapshot(bugtask, providing=providedBy(bugtask))
     >>> bugtask.transitionToAssignee(sample_person)
@@ -59,6 +61,5 @@
     ...     bugtask, original_bugtask, ['assignee', 'dateassigned'],
     ...     user=sample_person))
 
-    >>> len(pop_notifications())
+    >>> len(pop_questionemailjobs())
     0
-

=== modified file 'lib/lp/coop/answersbugs/tests/notifications-linked-private-bug.txt'
--- lib/lp/coop/answersbugs/tests/notifications-linked-private-bug.txt	2010-10-10 15:39:28 +0000
+++ lib/lp/coop/answersbugs/tests/notifications-linked-private-bug.txt	2011-04-30 02:03:29 +0000
@@ -1,4 +1,5 @@
-= Linked Bug Status Changed Notification (Private) =
+Linked Bug Status Changed Notification (Private)
+================================================
 
 See `answer-tracker-notifications-linked-bug.txt` for public bug behavior.
 
@@ -9,9 +10,10 @@
     >>> from zope.interface import providedBy
     >>> from lazr.lifecycle.event import ObjectModifiedEvent
     >>> from lazr.lifecycle.snapshot import Snapshot
+    >>> from lp.answers.tests.test_question_notifications import (
+    ...     pop_questionemailjobs)
     >>> from lp.bugs.interfaces.bugtask import BugTaskStatus
     >>> from lp.registry.interfaces.person import IPersonSet
-    >>> from lp.testing.mail_helpers import pop_notifications
 
     >>> no_priv = getUtility(IPersonSet).getByName('no-priv')
     >>> bugtask = get_bugtask_linked_to_question()
@@ -20,8 +22,9 @@
     True
     >>> original_bugtask = Snapshot(bugtask, providing=providedBy(bugtask))
     >>> bugtask.transitionToStatus(BugTaskStatus.FIXCOMMITTED, no_priv)
+    >>> ignore = pop_questionemailjobs()
     >>> notify(ObjectModifiedEvent(
     ...     bugtask, original_bugtask, ['status'], user=no_priv))
-    >>> notifications = pop_notifications()
+    >>> notifications = pop_questionemailjobs()
     >>> len(notifications)
     0