Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/features/test/notify_test.py b/features/test/notify_test.py
new file mode 100644
index 0000000..00de106
--- /dev/null
+++ b/features/test/notify_test.py
@@ -0,0 +1,708 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Tests for notify.py."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import json
+import mock
+import unittest
+import webapp2
+
+from google.appengine.ext import testbed
+
+from features import notify
+from features import notify_reasons
+from framework import emailfmt
+from framework import urls
+from proto import tracker_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import attachment_helpers
+from tracker import tracker_bizobj
+
+from third_party import cloudstorage
+
+
+def MakeTestIssue(project_id, local_id, owner_id, reporter_id, is_spam=False):
+  issue = tracker_pb2.Issue()
+  issue.project_id = project_id
+  issue.local_id = local_id
+  issue.issue_id = 1000 * project_id + local_id
+  issue.owner_id = owner_id
+  issue.reporter_id = reporter_id
+  issue.is_spam = is_spam
+  return issue
+
+
+class NotifyTaskHandleRequestTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService(),
+        project=fake.ProjectService(),
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        issue_star=fake.IssueStarService(),
+        features=fake.FeaturesService())
+    self.requester = self.services.user.TestAddUser('requester@example.com', 1)
+    self.nonmember = self.services.user.TestAddUser('user@example.com', 2)
+    self.member = self.services.user.TestAddUser('member@example.com', 3)
+    self.project = self.services.project.TestAddProject(
+        'test-project', owner_ids=[1, 3], project_id=12345)
+    self.issue1 = MakeTestIssue(
+        project_id=12345, local_id=1, owner_id=2, reporter_id=1)
+    self.issue2 = MakeTestIssue(
+        project_id=12345, local_id=2, owner_id=2, reporter_id=1)
+    self.services.issue.TestAddIssue(self.issue1)
+
+    self._old_gcs_open = cloudstorage.open
+    cloudstorage.open = fake.gcs_open
+    self.orig_sign_attachment_id = attachment_helpers.SignAttachmentID
+    attachment_helpers.SignAttachmentID = (
+        lambda aid: 'signed_%d' % aid)
+
+    self.testbed = testbed.Testbed()
+    self.testbed.activate()
+    self.testbed.init_memcache_stub()
+    self.testbed.init_datastore_v3_stub()
+
+  def tearDown(self):
+    cloudstorage.open = self._old_gcs_open
+    attachment_helpers.SignAttachmentID = self.orig_sign_attachment_id
+
+  def get_filtered_task_call_args(self, create_task_mock, relative_uri):
+    return [
+        (args, kwargs)
+        for (args, kwargs) in create_task_mock.call_args_list
+        if args[0]['app_engine_http_request']['relative_uri'] == relative_uri
+    ]
+
+  def VerifyParams(self, result, params):
+    self.assertEqual(
+        bool(params['send_email']), result['params']['send_email'])
+    if 'issue_id' in params:
+      self.assertEqual(params['issue_id'], result['params']['issue_id'])
+    if 'issue_ids' in params:
+      self.assertEqual([int(p) for p in params['issue_ids'].split(',')],
+                       result['params']['issue_ids'])
+
+  def testNotifyIssueChangeTask_Normal(self):
+    task = notify.NotifyIssueChangeTask(
+        request=None, response=None, services=self.services)
+    params = {'send_email': 1, 'issue_id': 12345001, 'seq': 0,
+              'commenter_id': 2}
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        params=params,
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+    self.VerifyParams(result, params)
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testNotifyIssueChangeTask_Spam(self, _create_task_mock):
+    issue = MakeTestIssue(
+        project_id=12345, local_id=1, owner_id=1, reporter_id=1,
+        is_spam=True)
+    self.services.issue.TestAddIssue(issue)
+    task = notify.NotifyIssueChangeTask(
+        request=None, response=None, services=self.services)
+    params = {'send_email': 0, 'issue_id': issue.issue_id, 'seq': 0,
+              'commenter_id': 2}
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        params=params,
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+    self.assertEqual(0, len(result['notified']))
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testNotifyBlockingChangeTask_Normal(self, _create_task_mock):
+    issue2 = MakeTestIssue(
+        project_id=12345, local_id=2, owner_id=2, reporter_id=1)
+    self.services.issue.TestAddIssue(issue2)
+    task = notify.NotifyBlockingChangeTask(
+        request=None, response=None, services=self.services)
+    params = {
+        'send_email': 1, 'issue_id': issue2.issue_id, 'seq': 0,
+        'delta_blocker_iids': self.issue1.issue_id, 'commenter_id': 1,
+        'hostport': 'bugs.chromium.org'}
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        params=params,
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+    self.VerifyParams(result, params)
+
+  def testNotifyBlockingChangeTask_Spam(self):
+    issue2 = MakeTestIssue(
+        project_id=12345, local_id=2, owner_id=2, reporter_id=1,
+        is_spam=True)
+    self.services.issue.TestAddIssue(issue2)
+    task = notify.NotifyBlockingChangeTask(
+        request=None, response=None, services=self.services)
+    params = {
+        'send_email': 1, 'issue_id': issue2.issue_id, 'seq': 0,
+        'delta_blocker_iids': self.issue1.issue_id, 'commenter_id': 1}
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        params=params,
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+    self.assertEqual(0, len(result['notified']))
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testNotifyBulkChangeTask_Normal(self, create_task_mock):
+    """We generate email tasks for each user involved in the issues."""
+    issue2 = MakeTestIssue(
+        project_id=12345, local_id=2, owner_id=2, reporter_id=1)
+    issue2.cc_ids = [3]
+    self.services.issue.TestAddIssue(issue2)
+    task = notify.NotifyBulkChangeTask(
+        request=None, response=None, services=self.services)
+    params = {
+        'send_email': 1, 'seq': 0,
+        'issue_ids': '%d,%d' % (self.issue1.issue_id, issue2.issue_id),
+        'old_owner_ids': '1,1', 'commenter_id': 1}
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        params=params,
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+    self.VerifyParams(result, params)
+
+    call_args_list = self.get_filtered_task_call_args(
+        create_task_mock, urls.OUTBOUND_EMAIL_TASK + '.do')
+    self.assertEqual(2, len(call_args_list))
+
+    for (args, _kwargs) in call_args_list:
+      task = args[0]
+      body = json.loads(task['app_engine_http_request']['body'].decode())
+      if 'user' in body['to']:
+        self.assertIn(u'\u2026', body['from_addr'])
+      # Full email for members
+      if 'member' in body['to']:
+        self.assertNotIn(u'\u2026', body['from_addr'])
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testNotifyBulkChangeTask_AlsoNotify(self, create_task_mock):
+    """We generate email tasks for also-notify addresses."""
+    self.issue1.derived_notify_addrs = [
+        'mailing-list@example.com', 'member@example.com']
+    task = notify.NotifyBulkChangeTask(
+        request=None, response=None, services=self.services)
+    params = {
+        'send_email': 1, 'seq': 0,
+        'issue_ids': '%d' % (self.issue1.issue_id),
+        'old_owner_ids': '1', 'commenter_id': 1}
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        params=params,
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+    self.VerifyParams(result, params)
+
+    call_args_list = self.get_filtered_task_call_args(
+        create_task_mock, urls.OUTBOUND_EMAIL_TASK + '.do')
+    self.assertEqual(3, len(call_args_list))
+
+    self.assertItemsEqual(
+        ['user@example.com', 'mailing-list@example.com', 'member@example.com'],
+        result['notified'])
+    for (args, _kwargs) in call_args_list:
+      task = args[0]
+      body = json.loads(task['app_engine_http_request']['body'].decode())
+      # obfuscated email for non-members
+      if 'user' in body['to']:
+        self.assertIn(u'\u2026', body['from_addr'])
+      # Full email for members
+      if 'member' in body['to']:
+        self.assertNotIn(u'\u2026', body['from_addr'])
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testNotifyBulkChangeTask_ProjectNotify(self, create_task_mock):
+    """We generate email tasks for project.issue_notify_address."""
+    self.project.issue_notify_address = 'mailing-list@example.com'
+    task = notify.NotifyBulkChangeTask(
+        request=None, response=None, services=self.services)
+    params = {
+        'send_email': 1, 'seq': 0,
+        'issue_ids': '%d' % (self.issue1.issue_id),
+        'old_owner_ids': '1', 'commenter_id': 1}
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        params=params,
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+    self.VerifyParams(result, params)
+
+    call_args_list = self.get_filtered_task_call_args(
+        create_task_mock, urls.OUTBOUND_EMAIL_TASK + '.do')
+    self.assertEqual(2, len(call_args_list))
+
+    self.assertItemsEqual(
+        ['user@example.com', 'mailing-list@example.com'],
+        result['notified'])
+
+    for (args, _kwargs) in call_args_list:
+      task = args[0]
+      body = json.loads(task['app_engine_http_request']['body'].decode())
+      # obfuscated email for non-members
+      if 'user' in body['to']:
+        self.assertIn(u'\u2026', body['from_addr'])
+      # Full email for members
+      if 'member' in body['to']:
+        self.assertNotIn(u'\u2026', body['from_addr'])
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testNotifyBulkChangeTask_SubscriberGetsEmail(self, create_task_mock):
+    """If a user subscription matches the issue, notify that user."""
+    task = notify.NotifyBulkChangeTask(
+        request=None, response=None, services=self.services)
+    params = {
+        'send_email': 1,
+        'issue_ids': '%d' % (self.issue1.issue_id),
+        'seq': 0,
+        'old_owner_ids': '1', 'commenter_id': 1}
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        params=params,
+        method='POST',
+        services=self.services)
+    self.services.user.TestAddUser('subscriber@example.com', 4)
+    sq = tracker_bizobj.MakeSavedQuery(
+        1, 'all open issues', 2, '', subscription_mode='immediate',
+        executes_in_project_ids=[self.issue1.project_id])
+    self.services.features.UpdateUserSavedQueries('cnxn', 4, [sq])
+    result = task.HandleRequest(mr)
+    self.VerifyParams(result, params)
+
+    call_args_list = self.get_filtered_task_call_args(
+        create_task_mock, urls.OUTBOUND_EMAIL_TASK + '.do')
+    self.assertEqual(2, len(call_args_list))
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testNotifyBulkChangeTask_CCAndSubscriberListsIssueOnce(
+      self, create_task_mock):
+    """If a user both CCs and subscribes, include issue only once."""
+    task = notify.NotifyBulkChangeTask(
+        request=None, response=None, services=self.services)
+    params = {
+        'send_email': 1,
+        'issue_ids': '%d' % (self.issue1.issue_id),
+        'seq': 0,
+        'old_owner_ids': '1', 'commenter_id': 1}
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        params=params,
+        method='POST',
+        services=self.services)
+    self.services.user.TestAddUser('subscriber@example.com', 4)
+    self.issue1.cc_ids = [4]
+    sq = tracker_bizobj.MakeSavedQuery(
+        1, 'all open issues', 2, '', subscription_mode='immediate',
+        executes_in_project_ids=[self.issue1.project_id])
+    self.services.features.UpdateUserSavedQueries('cnxn', 4, [sq])
+    result = task.HandleRequest(mr)
+    self.VerifyParams(result, params)
+
+    call_args_list = self.get_filtered_task_call_args(
+        create_task_mock, urls.OUTBOUND_EMAIL_TASK + '.do')
+    self.assertEqual(2, len(call_args_list))
+
+    found = False
+    for (args, _kwargs) in call_args_list:
+      task = args[0]
+      body = json.loads(task['app_engine_http_request']['body'].decode())
+      if body['to'] == 'subscriber@example.com':
+        found = True
+        task_body = body['body']
+        self.assertEqual(1, task_body.count('Issue %d' % self.issue1.local_id))
+    self.assertTrue(found)
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testNotifyBulkChangeTask_Spam(self, _create_task_mock):
+    """A spam issue is excluded from notification emails."""
+    issue2 = MakeTestIssue(
+        project_id=12345, local_id=2, owner_id=2, reporter_id=1,
+        is_spam=True)
+    self.services.issue.TestAddIssue(issue2)
+    task = notify.NotifyBulkChangeTask(
+        request=None, response=None, services=self.services)
+    params = {
+        'send_email': 1,
+        'issue_ids': '%d,%d' % (self.issue1.issue_id, issue2.issue_id),
+        'seq': 0,
+        'old_owner_ids': '1,1', 'commenter_id': 1}
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        params=params,
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+    self.assertEqual(1, len(result['notified']))
+
+  def testFormatBulkIssues_Normal_Single(self):
+    """A user may see full notification details for all changed issues."""
+    self.issue1.summary = 'one summary'
+    task = notify.NotifyBulkChangeTask(
+        request=None, response=None, services=self.services)
+    users_by_id = {}
+    commenter_view = None
+    config = self.services.config.GetProjectConfig('cnxn', 12345)
+    addrperm = notify_reasons.AddrPerm(
+        False, 'nonmember@example.com', self.nonmember,
+        notify_reasons.REPLY_NOT_ALLOWED, None)
+
+    subject, body = task._FormatBulkIssues(
+      [self.issue1], users_by_id, commenter_view, 'localhost:8080',
+      'test comment', [], config, addrperm)
+
+    self.assertIn('one summary', subject)
+    self.assertIn('one summary', body)
+    self.assertIn('test comment', body)
+
+  def testFormatBulkIssues_Normal_Multiple(self):
+    """A user may see full notification details for all changed issues."""
+    self.issue1.summary = 'one summary'
+    self.issue2.summary = 'two summary'
+    task = notify.NotifyBulkChangeTask(
+        request=None, response=None, services=self.services)
+    users_by_id = {}
+    commenter_view = None
+    config = self.services.config.GetProjectConfig('cnxn', 12345)
+    addrperm = notify_reasons.AddrPerm(
+        False, 'nonmember@example.com', self.nonmember,
+        notify_reasons.REPLY_NOT_ALLOWED, None)
+
+    subject, body = task._FormatBulkIssues(
+      [self.issue1, self.issue2], users_by_id, commenter_view, 'localhost:8080',
+      'test comment', [], config, addrperm)
+
+    self.assertIn('2 issues changed', subject)
+    self.assertIn('one summary', body)
+    self.assertIn('two summary', body)
+    self.assertIn('test comment', body)
+
+  def testFormatBulkIssues_LinkOnly_Single(self):
+    """A user may not see full notification details for some changed issue."""
+    self.issue1.summary = 'one summary'
+    self.issue1.labels = ['Restrict-View-Google']
+    task = notify.NotifyBulkChangeTask(
+        request=None, response=None, services=self.services)
+    users_by_id = {}
+    commenter_view = None
+    config = self.services.config.GetProjectConfig('cnxn', 12345)
+    addrperm = notify_reasons.AddrPerm(
+        False, 'nonmember@example.com', self.nonmember,
+        notify_reasons.REPLY_NOT_ALLOWED, None)
+
+    subject, body = task._FormatBulkIssues(
+      [self.issue1], users_by_id, commenter_view, 'localhost:8080',
+      'test comment', [], config, addrperm)
+
+    self.assertIn('issue 1', subject)
+    self.assertNotIn('one summary', subject)
+    self.assertNotIn('one summary', body)
+    self.assertNotIn('test comment', body)
+
+  def testFormatBulkIssues_LinkOnly_Multiple(self):
+    """A user may not see full notification details for some changed issue."""
+    self.issue1.summary = 'one summary'
+    self.issue1.labels = ['Restrict-View-Google']
+    self.issue2.summary = 'two summary'
+    task = notify.NotifyBulkChangeTask(
+        request=None, response=None, services=self.services)
+    users_by_id = {}
+    commenter_view = None
+    config = self.services.config.GetProjectConfig('cnxn', 12345)
+    addrperm = notify_reasons.AddrPerm(
+        False, 'nonmember@example.com', self.nonmember,
+        notify_reasons.REPLY_NOT_ALLOWED, None)
+
+    subject, body = task._FormatBulkIssues(
+      [self.issue1, self.issue2], users_by_id, commenter_view, 'localhost:8080',
+      'test comment', [], config, addrperm)
+
+    self.assertIn('2 issues', subject)
+    self.assertNotIn('summary', subject)
+    self.assertNotIn('one summary', body)
+    self.assertIn('two summary', body)
+    self.assertNotIn('test comment', body)
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testNotifyApprovalChangeTask_Normal(self, _create_task_mock):
+    config = self.services.config.GetProjectConfig('cnxn', 12345)
+    config.field_defs = [
+        # issue's User field with any_comment is notified.
+        tracker_bizobj.MakeFieldDef(
+            121, 12345, 'TL', tracker_pb2.FieldTypes.USER_TYPE,
+            '', '', False, False, False, None, None, None, False, '',
+            None, tracker_pb2.NotifyTriggers.ANY_COMMENT, 'no_action',
+            'TL, notified on everything', False),
+        # issue's User field with never is not notified.
+        tracker_bizobj.MakeFieldDef(
+            122, 12345, 'silentTL', tracker_pb2.FieldTypes.USER_TYPE,
+            '', '', False, False, False, None, None, None, False, '',
+            None, tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+            'TL, notified on nothing', False),
+        # approval's User field with any_comment is notified.
+        tracker_bizobj.MakeFieldDef(
+            123, 12345, 'otherapprovalTL', tracker_pb2.FieldTypes.USER_TYPE,
+            '', '', False, False, False, None, None, None, False, '',
+            None, tracker_pb2.NotifyTriggers.ANY_COMMENT, 'no_action',
+            'TL on the approvers team', False, approval_id=3),
+        # another approval's User field with any_comment is not notified.
+        tracker_bizobj.MakeFieldDef(
+            124, 12345, 'otherapprovalTL', tracker_pb2.FieldTypes.USER_TYPE,
+            '', '', False, False, False, None, None, None, False, '',
+            None, tracker_pb2.NotifyTriggers.ANY_COMMENT, 'no_action',
+            'TL on another approvers team', False, approval_id=4),
+        tracker_bizobj.MakeFieldDef(
+            3, 12345, 'Goat-Approval', tracker_pb2.FieldTypes.APPROVAL_TYPE,
+            '', '', False, False, False, None, None, None, False, '',
+            None, tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+            'Get Approval from Goats', False)
+    ]
+    self.services.config.StoreConfig('cnxn', config)
+
+    # Custom user_type field TLs
+    self.services.user.TestAddUser('TL@example.com', 111)
+    self.services.user.TestAddUser('silentTL@example.com', 222)
+    self.services.user.TestAddUser('approvalTL@example.com', 333)
+    self.services.user.TestAddUser('otherapprovalTL@example.com', 444)
+
+    # Approvers
+    self.services.user.TestAddUser('approver_old@example.com', 777)
+    self.services.user.TestAddUser('approver_new@example.com', 888)
+    self.services.user.TestAddUser('approver_still@example.com', 999)
+    self.services.user.TestAddUser('approver_group@example.com', 666)
+    self.services.user.TestAddUser('group_mem1@example.com', 661)
+    self.services.user.TestAddUser('group_mem2@example.com', 662)
+    self.services.user.TestAddUser('group_mem3@example.com', 663)
+    self.services.usergroup.TestAddGroupSettings(
+        666, 'approver_group@example.com')
+    self.services.usergroup.TestAddMembers(666, [661, 662, 663])
+    canary_phase = tracker_pb2.Phase(
+        name='Canary', phase_id=1, rank=1)
+    approval_values = [
+        tracker_pb2.ApprovalValue(approval_id=3,
+                                  approver_ids=[888, 999, 666, 661])]
+    approval_issue = MakeTestIssue(
+        project_id=12345, local_id=2, owner_id=2, reporter_id=1,
+        is_spam=True)
+    approval_issue.phases = [canary_phase]
+    approval_issue.approval_values = approval_values
+    approval_issue.field_values = [
+        tracker_bizobj.MakeFieldValue(121, None, None, 111, None, None, False),
+        tracker_bizobj.MakeFieldValue(122, None, None, 222, None, None, False),
+        tracker_bizobj.MakeFieldValue(123, None, None, 333, None, None, False),
+        tracker_bizobj.MakeFieldValue(124, None, None, 444, None, None, False),
+    ]
+    self.services.issue.TestAddIssue(approval_issue)
+
+    amend = tracker_bizobj.MakeApprovalApproversAmendment([888], [777])
+
+    comment = tracker_pb2.IssueComment(
+        project_id=12345, user_id=999, issue_id=approval_issue.issue_id,
+        amendments=[amend], timestamp=1234567890, content='just a comment.')
+    attach = tracker_pb2.Attachment(
+        attachment_id=4567, filename='sploot.jpg', mimetype='image/png',
+        gcs_object_id='/pid/attachments/abcd', filesize=(1024 * 1023))
+    comment.attachments.append(attach)
+    self.services.issue.TestAddComment(comment, approval_issue.local_id)
+    self.services.issue.TestAddAttachment(
+        attach, comment.id, approval_issue.issue_id)
+
+    task = notify.NotifyApprovalChangeTask(
+        request=None, response=None, services=self.services)
+    params = {
+        'send_email': 1,
+        'issue_id': approval_issue.issue_id,
+        'approval_id': 3,
+        'comment_id': comment.id,
+    }
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        params=params,
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+    self.assertTrue('just a comment' in result['tasks'][0]['body'])
+    self.assertTrue('Approvers: -appro...' in result['tasks'][0]['body'])
+    self.assertTrue('sploot.jpg' in result['tasks'][0]['body'])
+    self.assertTrue(
+        '/issues/attachment?aid=4567' in result['tasks'][0]['body'])
+    self.assertItemsEqual(
+        ['user@example.com', 'approver_old@example.com',
+         'approver_new@example.com', 'TL@example.com',
+         'approvalTL@example.com', 'group_mem1@example.com',
+         'group_mem2@example.com', 'group_mem3@example.com'],
+        result['notified'])
+
+    # Test no approvers/groups notified
+    # Status change to NEED_INFO does not email approvers.
+    amend2 = tracker_bizobj.MakeApprovalStatusAmendment(
+        tracker_pb2.ApprovalStatus.NEED_INFO)
+    comment2 = tracker_pb2.IssueComment(
+        project_id=12345, user_id=999, issue_id=approval_issue.issue_id,
+        amendments=[amend2], timestamp=1234567891, content='')
+    self.services.issue.TestAddComment(comment2, approval_issue.local_id)
+    task = notify.NotifyApprovalChangeTask(
+        request=None, response=None, services=self.services)
+    params = {
+        'send_email': 1,
+        'issue_id': approval_issue.issue_id,
+        'approval_id': 3,
+        'comment_id': comment2.id,
+    }
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        params=params,
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+
+    self.assertIsNotNone(result['tasks'][0].get('references'))
+    self.assertEqual(result['tasks'][0]['reply_to'], emailfmt.NoReplyAddress())
+    self.assertTrue('Status: need_info' in result['tasks'][0]['body'])
+    self.assertItemsEqual(
+        ['user@example.com', 'TL@example.com', 'approvalTL@example.com'],
+        result['notified'])
+
+  def testNotifyApprovalChangeTask_GetApprovalEmailRecipients(self):
+    task = notify.NotifyApprovalChangeTask(
+        request=None, response=None, services=self.services)
+    issue = fake.MakeTestIssue(789, 1, 'summary', 'New', 111)
+    approval_value = tracker_pb2.ApprovalValue(
+        approver_ids=[222, 333],
+        status=tracker_pb2.ApprovalStatus.APPROVED)
+    comment = tracker_pb2.IssueComment(
+        project_id=789, user_id=1, issue_id=78901)
+
+    # Comment with not amendments notifies everyone.
+    rids = task._GetApprovalEmailRecipients(
+        approval_value, comment, issue, [777, 888])
+    self.assertItemsEqual(rids, [111, 222, 333, 777, 888])
+
+    # New APPROVED status notifies owners and any_comment users.
+    amendment = tracker_bizobj.MakeApprovalStatusAmendment(
+        tracker_pb2.ApprovalStatus.APPROVED)
+    comment.amendments = [amendment]
+    rids = task._GetApprovalEmailRecipients(
+        approval_value, comment, issue, [777, 888])
+    self.assertItemsEqual(rids, [111, 777, 888])
+
+    # New REVIEW_REQUESTED status notifies approvers.
+    approval_value.status = tracker_pb2.ApprovalStatus.REVIEW_REQUESTED
+    amendment = tracker_bizobj.MakeApprovalStatusAmendment(
+        tracker_pb2.ApprovalStatus.REVIEW_REQUESTED)
+    comment.amendments = [amendment]
+    rids = task._GetApprovalEmailRecipients(
+        approval_value, comment, issue, [777, 888])
+    self.assertItemsEqual(rids, [222, 333])
+
+    # Approvers change notifies everyone.
+    amendment = tracker_bizobj.MakeApprovalApproversAmendment(
+        [222], [555])
+    comment.amendments = [amendment]
+    approval_value.approver_ids = [222]
+    rids = task._GetApprovalEmailRecipients(
+        approval_value, comment, issue, [777], omit_ids=[444, 333])
+    self.assertItemsEqual(rids, [111, 222, 555, 777])
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testNotifyRulesDeletedTask(self, _create_task_mock):
+    self.services.project.TestAddProject(
+        'proj', owner_ids=[777, 888], project_id=789)
+    self.services.user.TestAddUser('owner1@test.com', 777)
+    self.services.user.TestAddUser('cow@test.com', 888)
+    task = notify.NotifyRulesDeletedTask(
+        request=None, response=None, services=self.services)
+    params = {'project_id': 789,
+              'filter_rules': 'if green make yellow,if orange make blue'}
+    mr = testing_helpers.MakeMonorailRequest(
+        params=params,
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+    self.assertEqual(len(result['tasks']), 2)
+    body = result['tasks'][0]['body']
+    self.assertTrue('if green make yellow' in body)
+    self.assertTrue('if green make yellow' in body)
+    self.assertTrue('/p/proj/adminRules' in body)
+    self.assertItemsEqual(
+        ['cow@test.com', 'owner1@test.com'], result['notified'])
+
+  def testOutboundEmailTask_Normal(self):
+    """We can send an email."""
+    params = {
+        'from_addr': 'requester@example.com',
+        'reply_to': 'user@example.com',
+        'to': 'user@example.com',
+        'subject': 'Test subject'}
+    body = json.dumps(params)
+    request = webapp2.Request.blank('/', body=body)
+    task = notify.OutboundEmailTask(
+        request=request, response=None, services=self.services)
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        payload=body,
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+    self.assertEqual(params['from_addr'], result['sender'])
+    self.assertEqual(params['subject'], result['subject'])
+
+  def testOutboundEmailTask_MissingTo(self):
+    """We skip emails that don't specify the To-line."""
+    params = {
+        'from_addr': 'requester@example.com',
+        'reply_to': 'user@example.com',
+        'subject': 'Test subject'}
+    body = json.dumps(params)
+    request = webapp2.Request.blank('/', body=body)
+    task = notify.OutboundEmailTask(
+        request=request, response=None, services=self.services)
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        payload=body,
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+    self.assertEqual('Skipping because no "to" address found.', result['note'])
+    self.assertNotIn('from_addr', result)
+
+  def testOutboundEmailTask_BannedUser(self):
+    """We don't send emails to banned users.."""
+    params = {
+        'from_addr': 'requester@example.com',
+        'reply_to': 'user@example.com',
+        'to': 'banned@example.com',
+        'subject': 'Test subject'}
+    body = json.dumps(params)
+    request = webapp2.Request.blank('/', body=body)
+    task = notify.OutboundEmailTask(
+        request=request, response=None, services=self.services)
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        payload=body,
+        method='POST',
+        services=self.services)
+    self.services.user.TestAddUser('banned@example.com', 404, banned=True)
+    result = task.HandleRequest(mr)
+    self.assertEqual('Skipping because user is banned.', result['note'])
+    self.assertNotIn('from_addr', result)