Project import generated by Copybara.
GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/features/notify.py b/features/notify.py
new file mode 100644
index 0000000..c285c76
--- /dev/null
+++ b/features/notify.py
@@ -0,0 +1,1055 @@
+# 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
+
+"""Task handlers for email notifications of issue changes.
+
+Email notificatons are sent when an issue changes, an issue that is blocking
+another issue changes, or a bulk edit is done. The users notified include
+the project-wide mailing list, issue owners, cc'd users, starrers,
+also-notify addresses, and users who have saved queries with email notification
+set.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import collections
+import json
+import logging
+import os
+
+import ezt
+
+from google.appengine.api import mail
+from google.appengine.runtime import apiproxy_errors
+
+import settings
+from features import autolink
+from features import notify_helpers
+from features import notify_reasons
+from framework import authdata
+from framework import emailfmt
+from framework import exceptions
+from framework import framework_bizobj
+from framework import framework_constants
+from framework import framework_helpers
+from framework import framework_views
+from framework import jsonfeed
+from framework import monorailrequest
+from framework import permissions
+from framework import template_helpers
+from framework import urls
+from tracker import tracker_bizobj
+from tracker import tracker_helpers
+from tracker import tracker_views
+from proto import tracker_pb2
+
+
+class NotifyIssueChangeTask(notify_helpers.NotifyTaskBase):
+ """JSON servlet that notifies appropriate users after an issue change."""
+
+ _EMAIL_TEMPLATE = 'tracker/issue-change-notification-email.ezt'
+ _LINK_ONLY_EMAIL_TEMPLATE = (
+ 'tracker/issue-change-notification-email-link-only.ezt')
+
+ def HandleRequest(self, mr):
+ """Process the task to notify users after an issue change.
+
+ Args:
+ mr: common information parsed from the HTTP request.
+
+ Returns:
+ Results dictionary in JSON format which is useful just for debugging.
+ The main goal is the side-effect of sending emails.
+ """
+ issue_id = mr.GetPositiveIntParam('issue_id')
+ if not issue_id:
+ return {
+ 'params': {},
+ 'notified': [],
+ 'message': 'Cannot proceed without a valid issue ID.',
+ }
+ commenter_id = mr.GetPositiveIntParam('commenter_id')
+ seq_num = mr.seq
+ omit_ids = [commenter_id]
+ hostport = mr.GetParam('hostport')
+ try:
+ old_owner_id = mr.GetPositiveIntParam('old_owner_id')
+ except Exception:
+ old_owner_id = framework_constants.NO_USER_SPECIFIED
+ send_email = bool(mr.GetIntParam('send_email'))
+ comment_id = mr.GetPositiveIntParam('comment_id')
+ params = dict(
+ issue_id=issue_id, commenter_id=commenter_id,
+ seq_num=seq_num, hostport=hostport, old_owner_id=old_owner_id,
+ omit_ids=omit_ids, send_email=send_email, comment_id=comment_id)
+
+ logging.info('issue change params are %r', params)
+ # TODO(jrobbins): Re-enable the issue cache for notifications after
+ # the stale issue defect (monorail:2514) is 100% resolved.
+ issue = self.services.issue.GetIssue(mr.cnxn, issue_id, use_cache=False)
+ project = self.services.project.GetProject(mr.cnxn, issue.project_id)
+ config = self.services.config.GetProjectConfig(mr.cnxn, issue.project_id)
+
+ if issue.is_spam:
+ # Don't send email for spam issues.
+ return {
+ 'params': params,
+ 'notified': [],
+ }
+
+ all_comments = self.services.issue.GetCommentsForIssue(
+ mr.cnxn, issue.issue_id)
+ if comment_id:
+ logging.info('Looking up comment by comment_id')
+ for c in all_comments:
+ if c.id == comment_id:
+ comment = c
+ logging.info('Comment was found by comment_id')
+ break
+ else:
+ raise ValueError('Comment %r was not found' % comment_id)
+ else:
+ logging.info('Looking up comment by seq_num')
+ comment = all_comments[seq_num]
+
+ # Only issues that any contributor could view sent to mailing lists.
+ contributor_could_view = permissions.CanViewIssue(
+ set(), permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET,
+ project, issue)
+ starrer_ids = self.services.issue_star.LookupItemStarrers(
+ mr.cnxn, issue.issue_id)
+ users_by_id = framework_views.MakeAllUserViews(
+ mr.cnxn, self.services.user,
+ tracker_bizobj.UsersInvolvedInIssues([issue]), [old_owner_id],
+ tracker_bizobj.UsersInvolvedInComment(comment),
+ issue.cc_ids, issue.derived_cc_ids, starrer_ids, omit_ids)
+
+ # Make followup tasks to send emails
+ tasks = []
+ if send_email:
+ tasks = self._MakeEmailTasks(
+ mr.cnxn, project, issue, config, old_owner_id, users_by_id,
+ all_comments, comment, starrer_ids, contributor_could_view,
+ hostport, omit_ids, mr.perms)
+
+ notified = notify_helpers.AddAllEmailTasks(tasks)
+
+ return {
+ 'params': params,
+ 'notified': notified,
+ }
+
+ def _MakeEmailTasks(
+ self, cnxn, project, issue, config, old_owner_id,
+ users_by_id, all_comments, comment, starrer_ids,
+ contributor_could_view, hostport, omit_ids, perms):
+ """Formulate emails to be sent."""
+ detail_url = framework_helpers.IssueCommentURL(
+ hostport, project, issue.local_id, seq_num=comment.sequence)
+
+ # TODO(jrobbins): avoid the need to make a MonorailRequest object.
+ mr = monorailrequest.MonorailRequest(self.services)
+ mr.project_name = project.project_name
+ mr.project = project
+ mr.perms = perms
+
+ # We do not autolink in the emails, so just use an empty
+ # registry of autolink rules.
+ # TODO(jrobbins): offer users an HTML email option w/ autolinks.
+ autolinker = autolink.Autolink()
+ was_created = ezt.boolean(comment.sequence == 0)
+
+ email_data = {
+ # Pass open_related and closed_related into this method and to
+ # the issue view so that we can show it on new issue email.
+ 'issue': tracker_views.IssueView(issue, users_by_id, config),
+ 'summary': issue.summary,
+ 'comment': tracker_views.IssueCommentView(
+ project.project_name, comment, users_by_id,
+ autolinker, {}, mr, issue),
+ 'comment_text': comment.content,
+ 'detail_url': detail_url,
+ 'was_created': was_created,
+ }
+
+ # Generate three versions of email body: link-only is just the link,
+ # non-members see some obscured email addresses, and members version has
+ # all full email addresses exposed.
+ body_link_only = self.link_only_email_template.GetResponse(
+ {'detail_url': detail_url, 'was_created': was_created})
+ body_for_non_members = self.email_template.GetResponse(email_data)
+ framework_views.RevealAllEmails(users_by_id)
+ email_data['comment'] = tracker_views.IssueCommentView(
+ project.project_name, comment, users_by_id,
+ autolinker, {}, mr, issue)
+ body_for_members = self.email_template.GetResponse(email_data)
+
+ logging.info('link-only body is:\n%r' % body_link_only)
+ logging.info('body for non-members is:\n%r' % body_for_non_members)
+ logging.info('body for members is:\n%r' % body_for_members)
+
+ commenter_email = users_by_id[comment.user_id].email
+ omit_addrs = set([commenter_email] +
+ [users_by_id[omit_id].email for omit_id in omit_ids])
+
+ auth = authdata.AuthData.FromUserID(
+ cnxn, comment.user_id, self.services)
+ commenter_in_project = framework_bizobj.UserIsInProject(
+ project, auth.effective_ids)
+ noisy = tracker_helpers.IsNoisy(len(all_comments) - 1, len(starrer_ids))
+
+ # Give each user a bullet-list of all the reasons that apply for that user.
+ group_reason_list = notify_reasons.ComputeGroupReasonList(
+ cnxn, self.services, project, issue, config, users_by_id,
+ omit_addrs, contributor_could_view, noisy=noisy,
+ starrer_ids=starrer_ids, old_owner_id=old_owner_id,
+ commenter_in_project=commenter_in_project)
+
+ commenter_view = users_by_id[comment.user_id]
+ detail_url = framework_helpers.FormatAbsoluteURLForDomain(
+ hostport, issue.project_name, urls.ISSUE_DETAIL,
+ id=issue.local_id)
+ email_tasks = notify_helpers.MakeBulletedEmailWorkItems(
+ group_reason_list, issue, body_link_only, body_for_non_members,
+ body_for_members, project, hostport, commenter_view, detail_url,
+ seq_num=comment.sequence)
+
+ return email_tasks
+
+
+class NotifyBlockingChangeTask(notify_helpers.NotifyTaskBase):
+ """JSON servlet that notifies appropriate users after a blocking change."""
+
+ _EMAIL_TEMPLATE = 'tracker/issue-blocking-change-notification-email.ezt'
+ _LINK_ONLY_EMAIL_TEMPLATE = (
+ 'tracker/issue-change-notification-email-link-only.ezt')
+
+ def HandleRequest(self, mr):
+ """Process the task to notify users after an issue blocking change.
+
+ Args:
+ mr: common information parsed from the HTTP request.
+
+ Returns:
+ Results dictionary in JSON format which is useful just for debugging.
+ The main goal is the side-effect of sending emails.
+ """
+ issue_id = mr.GetPositiveIntParam('issue_id')
+ if not issue_id:
+ return {
+ 'params': {},
+ 'notified': [],
+ 'message': 'Cannot proceed without a valid issue ID.',
+ }
+ commenter_id = mr.GetPositiveIntParam('commenter_id')
+ omit_ids = [commenter_id]
+ hostport = mr.GetParam('hostport')
+ delta_blocker_iids = mr.GetIntListParam('delta_blocker_iids')
+ send_email = bool(mr.GetIntParam('send_email'))
+ params = dict(
+ issue_id=issue_id, commenter_id=commenter_id,
+ hostport=hostport, delta_blocker_iids=delta_blocker_iids,
+ omit_ids=omit_ids, send_email=send_email)
+
+ logging.info('blocking change params are %r', params)
+ issue = self.services.issue.GetIssue(mr.cnxn, issue_id)
+ if issue.is_spam:
+ return {
+ 'params': params,
+ 'notified': [],
+ }
+
+ upstream_issues = self.services.issue.GetIssues(
+ mr.cnxn, delta_blocker_iids)
+ logging.info('updating ids %r', [up.local_id for up in upstream_issues])
+ upstream_projects = tracker_helpers.GetAllIssueProjects(
+ mr.cnxn, upstream_issues, self.services.project)
+ upstream_configs = self.services.config.GetProjectConfigs(
+ mr.cnxn, list(upstream_projects.keys()))
+
+ users_by_id = framework_views.MakeAllUserViews(
+ mr.cnxn, self.services.user, [commenter_id])
+ commenter_view = users_by_id[commenter_id]
+
+ tasks = []
+ if send_email:
+ for upstream_issue in upstream_issues:
+ one_issue_email_tasks = self._ProcessUpstreamIssue(
+ mr.cnxn, upstream_issue,
+ upstream_projects[upstream_issue.project_id],
+ upstream_configs[upstream_issue.project_id],
+ issue, omit_ids, hostport, commenter_view)
+ tasks.extend(one_issue_email_tasks)
+
+ notified = notify_helpers.AddAllEmailTasks(tasks)
+
+ return {
+ 'params': params,
+ 'notified': notified,
+ }
+
+ def _ProcessUpstreamIssue(
+ self, cnxn, upstream_issue, upstream_project, upstream_config,
+ issue, omit_ids, hostport, commenter_view):
+ """Compute notifications for one upstream issue that is now blocking."""
+ upstream_detail_url = framework_helpers.FormatAbsoluteURLForDomain(
+ hostport, upstream_issue.project_name, urls.ISSUE_DETAIL,
+ id=upstream_issue.local_id)
+ logging.info('upstream_detail_url = %r', upstream_detail_url)
+ detail_url = framework_helpers.FormatAbsoluteURLForDomain(
+ hostport, issue.project_name, urls.ISSUE_DETAIL,
+ id=issue.local_id)
+
+ # Only issues that any contributor could view are sent to mailing lists.
+ contributor_could_view = permissions.CanViewIssue(
+ set(), permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET,
+ upstream_project, upstream_issue)
+
+ # Now construct the e-mail to send
+
+ # Note: we purposely do not notify users who starred an issue
+ # about changes in blocking.
+ users_by_id = framework_views.MakeAllUserViews(
+ cnxn, self.services.user,
+ tracker_bizobj.UsersInvolvedInIssues([upstream_issue]), omit_ids)
+
+ is_blocking = upstream_issue.issue_id in issue.blocked_on_iids
+
+ email_data = {
+ 'issue': tracker_views.IssueView(
+ upstream_issue, users_by_id, upstream_config),
+ 'summary': upstream_issue.summary,
+ 'detail_url': upstream_detail_url,
+ 'is_blocking': ezt.boolean(is_blocking),
+ 'downstream_issue_ref': tracker_bizobj.FormatIssueRef(
+ (None, issue.local_id)),
+ 'downstream_issue_url': detail_url,
+ }
+
+ # TODO(jrobbins): Generate two versions of email body: members
+ # vesion has other member full email addresses exposed. But, don't
+ # expose too many as we iterate through upstream projects.
+ body_link_only = self.link_only_email_template.GetResponse(
+ {'detail_url': upstream_detail_url, 'was_created': ezt.boolean(False)})
+ body = self.email_template.GetResponse(email_data)
+
+ omit_addrs = {users_by_id[omit_id].email for omit_id in omit_ids}
+
+ # Get the transitive set of owners and Cc'd users, and their UserView's.
+ # Give each user a bullet-list of all the reasons that apply for that user.
+ # Starrers are not notified of blocking changes to reduce noise.
+ group_reason_list = notify_reasons.ComputeGroupReasonList(
+ cnxn, self.services, upstream_project, upstream_issue,
+ upstream_config, users_by_id, omit_addrs, contributor_could_view)
+ one_issue_email_tasks = notify_helpers.MakeBulletedEmailWorkItems(
+ group_reason_list, upstream_issue, body_link_only, body, body,
+ upstream_project, hostport, commenter_view, detail_url)
+
+ return one_issue_email_tasks
+
+
+class NotifyBulkChangeTask(notify_helpers.NotifyTaskBase):
+ """JSON servlet that notifies appropriate users after a bulk edit."""
+
+ _EMAIL_TEMPLATE = 'tracker/issue-bulk-change-notification-email.ezt'
+
+ def HandleRequest(self, mr):
+ """Process the task to notify users after an issue blocking change.
+
+ Args:
+ mr: common information parsed from the HTTP request.
+
+ Returns:
+ Results dictionary in JSON format which is useful just for debugging.
+ The main goal is the side-effect of sending emails.
+ """
+ issue_ids = mr.GetIntListParam('issue_ids')
+ hostport = mr.GetParam('hostport')
+ if not issue_ids:
+ return {
+ 'params': {},
+ 'notified': [],
+ 'message': 'Cannot proceed without a valid issue IDs.',
+ }
+
+ old_owner_ids = mr.GetIntListParam('old_owner_ids')
+ comment_text = mr.GetParam('comment_text')
+ commenter_id = mr.GetPositiveIntParam('commenter_id')
+ amendments = mr.GetParam('amendments')
+ send_email = bool(mr.GetIntParam('send_email'))
+ params = dict(
+ issue_ids=issue_ids, commenter_id=commenter_id, hostport=hostport,
+ old_owner_ids=old_owner_ids, comment_text=comment_text,
+ send_email=send_email, amendments=amendments)
+
+ logging.info('bulk edit params are %r', params)
+ issues = self.services.issue.GetIssues(mr.cnxn, issue_ids)
+ # TODO(jrobbins): For cross-project bulk edits, prefetch all relevant
+ # projects and configs and pass a dict of them to subroutines. For
+ # now, all issue must be in the same project.
+ project_id = issues[0].project_id
+ project = self.services.project.GetProject(mr.cnxn, project_id)
+ config = self.services.config.GetProjectConfig(mr.cnxn, project_id)
+ issues = [issue for issue in issues if not issue.is_spam]
+ anon_perms = permissions.GetPermissions(None, set(), project)
+
+ users_by_id = framework_views.MakeAllUserViews(
+ mr.cnxn, self.services.user, [commenter_id])
+ ids_in_issues = {}
+ starrers = {}
+
+ non_private_issues = []
+ for issue, old_owner_id in zip(issues, old_owner_ids):
+ # TODO(jrobbins): use issue_id consistently rather than local_id.
+ starrers[issue.local_id] = self.services.issue_star.LookupItemStarrers(
+ mr.cnxn, issue.issue_id)
+ named_ids = set() # users named in user-value fields that notify.
+ for fd in config.field_defs:
+ named_ids.update(notify_reasons.ComputeNamedUserIDsToNotify(
+ issue.field_values, fd))
+ direct, indirect = self.services.usergroup.ExpandAnyGroupEmailRecipients(
+ mr.cnxn,
+ list(issue.cc_ids) + list(issue.derived_cc_ids) +
+ [issue.owner_id, old_owner_id, issue.derived_owner_id] +
+ list(named_ids))
+ ids_in_issues[issue.local_id] = set(starrers[issue.local_id])
+ ids_in_issues[issue.local_id].update(direct)
+ ids_in_issues[issue.local_id].update(indirect)
+ ids_in_issue_needing_views = (
+ ids_in_issues[issue.local_id] |
+ tracker_bizobj.UsersInvolvedInIssues([issue]))
+ new_ids_in_issue = [user_id for user_id in ids_in_issue_needing_views
+ if user_id not in users_by_id]
+ users_by_id.update(
+ framework_views.MakeAllUserViews(
+ mr.cnxn, self.services.user, new_ids_in_issue))
+
+ anon_can_view = permissions.CanViewIssue(
+ set(), anon_perms, project, issue)
+ if anon_can_view:
+ non_private_issues.append(issue)
+
+ commenter_view = users_by_id[commenter_id]
+ omit_addrs = {commenter_view.email}
+
+ tasks = []
+ if send_email:
+ email_tasks = self._BulkEditEmailTasks(
+ mr.cnxn, issues, old_owner_ids, omit_addrs, project,
+ non_private_issues, users_by_id, ids_in_issues, starrers,
+ commenter_view, hostport, comment_text, amendments, config)
+ tasks = email_tasks
+
+ notified = notify_helpers.AddAllEmailTasks(tasks)
+ return {
+ 'params': params,
+ 'notified': notified,
+ }
+
+ def _BulkEditEmailTasks(
+ self, cnxn, issues, old_owner_ids, omit_addrs, project,
+ non_private_issues, users_by_id, ids_in_issues, starrers,
+ commenter_view, hostport, comment_text, amendments, config):
+ """Generate Email PBs to notify interested users after a bulk edit."""
+ # 1. Get the user IDs of everyone who could be notified,
+ # and make all their user proxies. Also, build a dictionary
+ # of all the users to notify and the issues that they are
+ # interested in. Also, build a dictionary of additional email
+ # addresses to notify and the issues to notify them of.
+ users_by_id = {}
+ ids_to_notify_of_issue = {}
+ additional_addrs_to_notify_of_issue = collections.defaultdict(list)
+
+ users_to_queries = notify_reasons.GetNonOmittedSubscriptions(
+ cnxn, self.services, [project.project_id], {})
+ config = self.services.config.GetProjectConfig(
+ cnxn, project.project_id)
+ for issue, old_owner_id in zip(issues, old_owner_ids):
+ issue_participants = set(
+ [tracker_bizobj.GetOwnerId(issue), old_owner_id] +
+ tracker_bizobj.GetCcIds(issue))
+ # users named in user-value fields that notify.
+ for fd in config.field_defs:
+ issue_participants.update(
+ notify_reasons.ComputeNamedUserIDsToNotify(issue.field_values, fd))
+ for user_id in ids_in_issues[issue.local_id]:
+ # TODO(jrobbins): implement batch GetUser() for speed.
+ if not user_id:
+ continue
+ auth = authdata.AuthData.FromUserID(
+ cnxn, user_id, self.services)
+ if (auth.user_pb.notify_issue_change and
+ not auth.effective_ids.isdisjoint(issue_participants)):
+ ids_to_notify_of_issue.setdefault(user_id, []).append(issue)
+ elif (auth.user_pb.notify_starred_issue_change and
+ user_id in starrers[issue.local_id]):
+ # Skip users who have starred issues that they can no longer view.
+ starrer_perms = permissions.GetPermissions(
+ auth.user_pb, auth.effective_ids, project)
+ granted_perms = tracker_bizobj.GetGrantedPerms(
+ issue, auth.effective_ids, config)
+ starrer_can_view = permissions.CanViewIssue(
+ auth.effective_ids, starrer_perms, project, issue,
+ granted_perms=granted_perms)
+ if starrer_can_view:
+ ids_to_notify_of_issue.setdefault(user_id, []).append(issue)
+ logging.info(
+ 'ids_to_notify_of_issue[%s] = %s',
+ user_id,
+ [i.local_id for i in ids_to_notify_of_issue.get(user_id, [])])
+
+ # Find all subscribers that should be notified.
+ subscribers_to_consider = notify_reasons.EvaluateSubscriptions(
+ cnxn, issue, users_to_queries, self.services, config)
+ for sub_id in subscribers_to_consider:
+ auth = authdata.AuthData.FromUserID(cnxn, sub_id, self.services)
+ sub_perms = permissions.GetPermissions(
+ auth.user_pb, auth.effective_ids, project)
+ granted_perms = tracker_bizobj.GetGrantedPerms(
+ issue, auth.effective_ids, config)
+ sub_can_view = permissions.CanViewIssue(
+ auth.effective_ids, sub_perms, project, issue,
+ granted_perms=granted_perms)
+ if sub_can_view:
+ ids_to_notify_of_issue.setdefault(sub_id, [])
+ if issue not in ids_to_notify_of_issue[sub_id]:
+ ids_to_notify_of_issue[sub_id].append(issue)
+
+ if issue in non_private_issues:
+ for notify_addr in issue.derived_notify_addrs:
+ additional_addrs_to_notify_of_issue[notify_addr].append(issue)
+
+ # 2. Compose an email specifically for each user, and one email to each
+ # notify_addr with all the issues that it.
+ # Start from non-members first, then members to reveal email addresses.
+ email_tasks = []
+ needed_user_view_ids = [uid for uid in ids_to_notify_of_issue
+ if uid not in users_by_id]
+ users_by_id.update(framework_views.MakeAllUserViews(
+ cnxn, self.services.user, needed_user_view_ids))
+ member_ids_to_notify_of_issue = {}
+ non_member_ids_to_notify_of_issue = {}
+ member_additional_addrs = {}
+ non_member_additional_addrs = {}
+ addr_to_addrperm = {} # {email_address: AddrPerm object}
+ all_user_prefs = self.services.user.GetUsersPrefs(
+ cnxn, ids_to_notify_of_issue)
+
+ # TODO(jrobbins): Merge ids_to_notify_of_issue entries for linked accounts.
+
+ for user_id in ids_to_notify_of_issue:
+ if not user_id:
+ continue # Don't try to notify NO_USER_SPECIFIED
+ if users_by_id[user_id].email in omit_addrs:
+ logging.info('Omitting %s', user_id)
+ continue
+ user_issues = ids_to_notify_of_issue[user_id]
+ if not user_issues:
+ continue # user's prefs indicate they don't want these notifications
+ auth = authdata.AuthData.FromUserID(
+ cnxn, user_id, self.services)
+ is_member = bool(framework_bizobj.UserIsInProject(
+ project, auth.effective_ids))
+ if is_member:
+ member_ids_to_notify_of_issue[user_id] = user_issues
+ else:
+ non_member_ids_to_notify_of_issue[user_id] = user_issues
+ addr = users_by_id[user_id].email
+ omit_addrs.add(addr)
+ addr_to_addrperm[addr] = notify_reasons.AddrPerm(
+ is_member, addr, users_by_id[user_id].user,
+ notify_reasons.REPLY_NOT_ALLOWED, all_user_prefs[user_id])
+
+ for addr, addr_issues in additional_addrs_to_notify_of_issue.items():
+ auth = None
+ try:
+ auth = authdata.AuthData.FromEmail(cnxn, addr, self.services)
+ except: # pylint: disable=bare-except
+ logging.warning('Cannot find user of email %s ', addr)
+ if auth:
+ is_member = bool(framework_bizobj.UserIsInProject(
+ project, auth.effective_ids))
+ else:
+ is_member = False
+ if is_member:
+ member_additional_addrs[addr] = addr_issues
+ else:
+ non_member_additional_addrs[addr] = addr_issues
+ omit_addrs.add(addr)
+ addr_to_addrperm[addr] = notify_reasons.AddrPerm(
+ is_member, addr, None, notify_reasons.REPLY_NOT_ALLOWED, None)
+
+ for user_id, user_issues in non_member_ids_to_notify_of_issue.items():
+ addr = users_by_id[user_id].email
+ email = self._FormatBulkIssuesEmail(
+ addr_to_addrperm[addr], user_issues, users_by_id,
+ commenter_view, hostport, comment_text, amendments, config, project)
+ email_tasks.append(email)
+ logging.info('about to bulk notify non-member %s (%s) of %s',
+ users_by_id[user_id].email, user_id,
+ [issue.local_id for issue in user_issues])
+
+ for addr, addr_issues in non_member_additional_addrs.items():
+ email = self._FormatBulkIssuesEmail(
+ addr_to_addrperm[addr], addr_issues, users_by_id, commenter_view,
+ hostport, comment_text, amendments, config, project)
+ email_tasks.append(email)
+ logging.info('about to bulk notify non-member additional addr %s of %s',
+ addr, [addr_issue.local_id for addr_issue in addr_issues])
+
+ framework_views.RevealAllEmails(users_by_id)
+ commenter_view.RevealEmail()
+
+ for user_id, user_issues in member_ids_to_notify_of_issue.items():
+ addr = users_by_id[user_id].email
+ email = self._FormatBulkIssuesEmail(
+ addr_to_addrperm[addr], user_issues, users_by_id,
+ commenter_view, hostport, comment_text, amendments, config, project)
+ email_tasks.append(email)
+ logging.info('about to bulk notify member %s (%s) of %s',
+ addr, user_id, [issue.local_id for issue in user_issues])
+
+ for addr, addr_issues in member_additional_addrs.items():
+ email = self._FormatBulkIssuesEmail(
+ addr_to_addrperm[addr], addr_issues, users_by_id, commenter_view,
+ hostport, comment_text, amendments, config, project)
+ email_tasks.append(email)
+ logging.info('about to bulk notify member additional addr %s of %s',
+ addr, [addr_issue.local_id for addr_issue in addr_issues])
+
+ # 4. Add in the project's issue_notify_address. This happens even if it
+ # is the same as the commenter's email address (which would be an unusual
+ # but valid project configuration). Only issues that any contributor could
+ # view are included in emails to the all-issue-activity mailing lists.
+ if (project.issue_notify_address
+ and project.issue_notify_address not in omit_addrs):
+ non_private_issues_live = []
+ for issue in issues:
+ contributor_could_view = permissions.CanViewIssue(
+ set(), permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET,
+ project, issue)
+ if contributor_could_view:
+ non_private_issues_live.append(issue)
+
+ if non_private_issues_live:
+ project_notify_addrperm = notify_reasons.AddrPerm(
+ True, project.issue_notify_address, None,
+ notify_reasons.REPLY_NOT_ALLOWED, None)
+ email = self._FormatBulkIssuesEmail(
+ project_notify_addrperm, non_private_issues_live,
+ users_by_id, commenter_view, hostport, comment_text, amendments,
+ config, project)
+ email_tasks.append(email)
+ omit_addrs.add(project.issue_notify_address)
+ logging.info('about to bulk notify all-issues %s of %s',
+ project.issue_notify_address,
+ [issue.local_id for issue in non_private_issues])
+
+ return email_tasks
+
+ def _FormatBulkIssuesEmail(
+ self, addr_perm, issues, users_by_id, commenter_view,
+ hostport, comment_text, amendments, config, project):
+ """Format an email to one user listing many issues."""
+
+ from_addr = emailfmt.FormatFromAddr(
+ project, commenter_view=commenter_view, reveal_addr=addr_perm.is_member,
+ can_reply_to=False)
+
+ subject, body = self._FormatBulkIssues(
+ issues, users_by_id, commenter_view, hostport, comment_text,
+ amendments, config, addr_perm)
+ body = notify_helpers._TruncateBody(body)
+
+ return dict(from_addr=from_addr, to=addr_perm.address, subject=subject,
+ body=body)
+
+ def _FormatBulkIssues(
+ self, issues, users_by_id, commenter_view, hostport, comment_text,
+ amendments, config, addr_perm):
+ """Format a subject and body for a bulk issue edit."""
+ project_name = issues[0].project_name
+
+ any_link_only = False
+ issue_views = []
+ for issue in issues:
+ # TODO(jrobbins): choose config from dict of prefetched configs.
+ issue_view = tracker_views.IssueView(issue, users_by_id, config)
+ issue_view.link_only = ezt.boolean(False)
+ if addr_perm and notify_helpers.ShouldUseLinkOnly(addr_perm, issue):
+ issue_view.link_only = ezt.boolean(True)
+ any_link_only = True
+ issue_views.append(issue_view)
+
+ email_data = {
+ 'any_link_only': ezt.boolean(any_link_only),
+ 'hostport': hostport,
+ 'num_issues': len(issues),
+ 'issues': issue_views,
+ 'comment_text': comment_text,
+ 'commenter': commenter_view,
+ 'amendments': amendments,
+ }
+
+ if len(issues) == 1:
+ # TODO(jrobbins): use compact email subject lines based on user pref.
+ if addr_perm and notify_helpers.ShouldUseLinkOnly(addr_perm, issues[0]):
+ subject = 'issue %s in %s' % (issues[0].local_id, project_name)
+ else:
+ subject = 'issue %s in %s: %s' % (
+ issues[0].local_id, project_name, issues[0].summary)
+ # TODO(jrobbins): Look up the sequence number instead and treat this
+ # more like an individual change for email threading. For now, just
+ # add "Re:" because bulk edits are always replies.
+ subject = 'Re: ' + subject
+ else:
+ subject = '%d issues changed in %s' % (len(issues), project_name)
+
+ body = self.email_template.GetResponse(email_data)
+
+ return subject, body
+
+
+# For now, this class will not be used to send approval comment notifications
+# TODO(jojwang): monorail:3588, it might make sense for this class to handle
+# sending comment notifications for approval custom_subfield changes.
+class NotifyApprovalChangeTask(notify_helpers.NotifyTaskBase):
+ """JSON servlet that notifies appropriate users after an approval change."""
+
+ _EMAIL_TEMPLATE = 'tracker/approval-change-notification-email.ezt'
+
+ def HandleRequest(self, mr):
+ """Process the task to notify users after an approval change.
+
+ Args:
+ mr: common information parsed from the HTTP request.
+
+ Returns:
+ Results dictionary in JSON format which is useful just for debugging.
+ The main goal is the side-effect of sending emails.
+ """
+
+ send_email = bool(mr.GetIntParam('send_email'))
+ issue_id = mr.GetPositiveIntParam('issue_id')
+ approval_id = mr.GetPositiveIntParam('approval_id')
+ comment_id = mr.GetPositiveIntParam('comment_id')
+ hostport = mr.GetParam('hostport')
+
+ params = dict(
+ temporary='',
+ hostport=hostport,
+ issue_id=issue_id
+ )
+ logging.info('approval change params are %r', params)
+
+ issue, approval_value = self.services.issue.GetIssueApproval(
+ mr.cnxn, issue_id, approval_id, use_cache=False)
+ project = self.services.project.GetProject(mr.cnxn, issue.project_id)
+ config = self.services.config.GetProjectConfig(mr.cnxn, issue.project_id)
+
+ approval_fd = tracker_bizobj.FindFieldDefByID(approval_id, config)
+ if approval_fd is None:
+ raise exceptions.NoSuchFieldDefException()
+
+ # GetCommentsForIssue will fill the sequence for all comments, while
+ # other method for getting a single comment will not.
+ # The comment sequence is especially useful for Approval issues with
+ # many comment sections.
+ comment = None
+ all_comments = self.services.issue.GetCommentsForIssue(mr.cnxn, issue_id)
+ for c in all_comments:
+ if c.id == comment_id:
+ comment = c
+ break
+ if not comment:
+ raise exceptions.NoSuchCommentException()
+
+ field_user_ids = set()
+ relevant_fds = [fd for fd in config.field_defs if
+ not fd.approval_id or
+ fd.approval_id is approval_value.approval_id]
+ for fd in relevant_fds:
+ field_user_ids.update(
+ notify_reasons.ComputeNamedUserIDsToNotify(issue.field_values, fd))
+ users_by_id = framework_views.MakeAllUserViews(
+ mr.cnxn, self.services.user, [issue.owner_id],
+ approval_value.approver_ids,
+ tracker_bizobj.UsersInvolvedInComment(comment),
+ list(field_user_ids))
+
+ tasks = []
+ if send_email:
+ tasks = self._MakeApprovalEmailTasks(
+ hostport, issue, project, approval_value, approval_fd.field_name,
+ comment, users_by_id, list(field_user_ids), mr.perms)
+
+ notified = notify_helpers.AddAllEmailTasks(tasks)
+
+ return {
+ 'params': params,
+ 'notified': notified,
+ 'tasks': tasks,
+ }
+
+ def _MakeApprovalEmailTasks(
+ self, hostport, issue, project, approval_value, approval_name,
+ comment, users_by_id, user_ids_from_fields, perms):
+ """Formulate emails to be sent."""
+
+ # TODO(jojwang): avoid need to make MonorailRequest and autolinker
+ # for comment_view OR make make tracker_views._ParseTextRuns public
+ # and only pass text_runs to email_data.
+ mr = monorailrequest.MonorailRequest(self.services)
+ mr.project_name = project.project_name
+ mr.project = project
+ mr.perms = perms
+ autolinker = autolink.Autolink()
+
+ approval_url = framework_helpers.IssueCommentURL(
+ hostport, project, issue.local_id, seq_num=comment.sequence)
+
+ comment_view = tracker_views.IssueCommentView(
+ project.project_name, comment, users_by_id, autolinker, {}, mr, issue)
+ domain_url = framework_helpers.FormatAbsoluteURLForDomain(
+ hostport, project.project_name, '/issues/')
+
+ commenter_view = users_by_id[comment.user_id]
+ email_data = {
+ 'domain_url': domain_url,
+ 'approval_url': approval_url,
+ 'comment': comment_view,
+ 'issue_local_id': issue.local_id,
+ 'summary': issue.summary,
+ }
+ subject = '%s Approval: %s (Issue %s)' % (
+ approval_name, issue.summary, issue.local_id)
+ email_body = self.email_template.GetResponse(email_data)
+ body = notify_helpers._TruncateBody(email_body)
+
+ recipient_ids = self._GetApprovalEmailRecipients(
+ approval_value, comment, issue, user_ids_from_fields,
+ omit_ids=[comment.user_id])
+ direct, indirect = self.services.usergroup.ExpandAnyGroupEmailRecipients(
+ mr.cnxn, recipient_ids)
+ # group ids were found in recipient_ids.
+ # Re-set recipient_ids to remove group_ids
+ if indirect:
+ recipient_ids = set(direct + indirect)
+ users_by_id.update(framework_views.MakeAllUserViews(
+ mr.cnxn, self.services.user, indirect)) # already contains direct
+
+ # TODO(crbug.com/monorail/9104): Compute notify_reasons.AddrPerms based on
+ # project settings and recipient permissions so `reply_to` can be accurately
+ # set.
+
+ email_tasks = []
+ for rid in recipient_ids:
+ from_addr = emailfmt.FormatFromAddr(
+ project, commenter_view=commenter_view, can_reply_to=False)
+ dest_email = users_by_id[rid].email
+
+ refs = emailfmt.GetReferences(
+ dest_email, subject, comment.sequence,
+ '%s@%s' % (project.project_name, emailfmt.MailDomain()))
+ reply_to = emailfmt.NoReplyAddress()
+ email_tasks.append(
+ dict(
+ from_addr=from_addr,
+ to=dest_email,
+ subject=subject,
+ body=body,
+ reply_to=reply_to,
+ references=refs))
+
+ return email_tasks
+
+ def _GetApprovalEmailRecipients(
+ self, approval_value, comment, issue, user_ids_from_fields,
+ omit_ids=None):
+ # TODO(jojwang): monorail:3588, reorganize this, since now, comment_content
+ # and approval amendments happen in the same comment.
+ # NOTE: user_ids_from_fields are all the notify_on=ANY_COMMENT users.
+ # However, these users will still be excluded from notifications
+ # meant for approvers only eg. (status changed to REVIEW_REQUESTED).
+ recipient_ids = []
+ if comment.amendments:
+ for amendment in comment.amendments:
+ if amendment.custom_field_name == 'Status':
+ if (approval_value.status is
+ tracker_pb2.ApprovalStatus.REVIEW_REQUESTED):
+ recipient_ids = approval_value.approver_ids
+ else:
+ recipient_ids.extend([issue.owner_id])
+ recipient_ids.extend(user_ids_from_fields)
+
+ elif amendment.custom_field_name == 'Approvers':
+ recipient_ids.extend(approval_value.approver_ids)
+ recipient_ids.append(issue.owner_id)
+ recipient_ids.extend(user_ids_from_fields)
+ recipient_ids.extend(amendment.removed_user_ids)
+ else:
+ # No amendments, just a comment.
+ recipient_ids.extend(approval_value.approver_ids)
+ recipient_ids.append(issue.owner_id)
+ recipient_ids.extend(user_ids_from_fields)
+
+ if omit_ids:
+ recipient_ids = [rid for rid in recipient_ids if rid not in omit_ids]
+
+ return list(set(recipient_ids))
+
+
+class NotifyRulesDeletedTask(notify_helpers.NotifyTaskBase):
+ """JSON servlet that sends one email."""
+
+ _EMAIL_TEMPLATE = 'project/rules-deleted-notification-email.ezt'
+
+ def HandleRequest(self, mr):
+ """Process the task to notify project owners after a filter rule
+ has been deleted.
+
+ Args:
+ mr: common information parsed from the HTTP request.
+
+ Returns:
+ Results dictionary in JSON format which is useful for debugging.
+ """
+ project_id = mr.GetPositiveIntParam('project_id')
+ rules = mr.GetListParam('filter_rules')
+ hostport = mr.GetParam('hostport')
+
+ params = dict(
+ project_id=project_id,
+ rules=rules,
+ hostport=hostport,
+ )
+ logging.info('deleted filter rules params are %r', params)
+
+ project = self.services.project.GetProject(mr.cnxn, project_id)
+ emails_by_id = self.services.user.LookupUserEmails(
+ mr.cnxn, project.owner_ids, ignore_missed=True)
+
+ tasks = self._MakeRulesDeletedEmailTasks(
+ hostport, project, emails_by_id, rules)
+ notified = notify_helpers.AddAllEmailTasks(tasks)
+
+ return {
+ 'params': params,
+ 'notified': notified,
+ 'tasks': tasks,
+ }
+
+ def _MakeRulesDeletedEmailTasks(self, hostport, project, emails_by_id, rules):
+
+ rules_url = framework_helpers.FormatAbsoluteURLForDomain(
+ hostport, project.project_name, urls.ADMIN_RULES)
+
+ email_data = {
+ 'project_name': project.project_name,
+ 'rules': rules,
+ 'rules_url': rules_url,
+ }
+ logging.info(email_data)
+ subject = '%s Project: Deleted Filter Rules' % project.project_name
+ email_body = self.email_template.GetResponse(email_data)
+ body = notify_helpers._TruncateBody(email_body)
+
+ email_tasks = []
+ for rid in project.owner_ids:
+ from_addr = emailfmt.FormatFromAddr(
+ project, reveal_addr=True, can_reply_to=False)
+ dest_email = emails_by_id.get(rid)
+ email_tasks.append(
+ dict(from_addr=from_addr, to=dest_email, subject=subject, body=body))
+
+ return email_tasks
+
+
+class OutboundEmailTask(jsonfeed.InternalTask):
+ """JSON servlet that sends one email.
+
+ Handles tasks enqueued from notify_helpers._EnqueueOutboundEmail.
+ """
+
+ def HandleRequest(self, mr):
+ """Process the task to send one email message.
+
+ Args:
+ mr: common information parsed from the HTTP request.
+
+ Returns:
+ Results dictionary in JSON format which is useful just for debugging.
+ The main goal is the side-effect of sending emails.
+ """
+ # To avoid urlencoding the email body, the most salient parameters to this
+ # method are passed as a json-encoded POST body.
+ try:
+ email_params = json.loads(self.request.body)
+ except ValueError:
+ logging.error(self.request.body)
+ raise
+ # If running on a GAFYD domain, you must define an app alias on the
+ # Application Settings admin web page.
+ sender = email_params.get('from_addr')
+ reply_to = email_params.get('reply_to')
+ to = email_params.get('to')
+ if not to:
+ # Cannot proceed if we cannot create a valid EmailMessage.
+ return {'note': 'Skipping because no "to" address found.'}
+
+ # Don't send emails to any banned users.
+ try:
+ user_id = self.services.user.LookupUserID(mr.cnxn, to)
+ user = self.services.user.GetUser(mr.cnxn, user_id)
+ if user.banned:
+ logging.info('Not notifying banned user %r', user.email)
+ return {'note': 'Skipping because user is banned.'}
+ except exceptions.NoSuchUserException:
+ pass
+
+ references = email_params.get('references')
+ subject = email_params.get('subject')
+ body = email_params.get('body')
+ html_body = email_params.get('html_body')
+
+ if settings.local_mode:
+ to_format = settings.send_local_email_to
+ else:
+ to_format = settings.send_all_email_to
+
+ if to_format:
+ to_user, to_domain = to.split('@')
+ to = to_format % {'user': to_user, 'domain': to_domain}
+
+ logging.info(
+ 'Email:\n sender: %s\n reply_to: %s\n to: %s\n references: %s\n '
+ 'subject: %s\n body: %s\n html body: %s',
+ sender, reply_to, to, references, subject, body, html_body)
+ if html_body:
+ logging.info('Readable HTML:\n%s', html_body.replace('<br/>', '<br/>\n'))
+ message = mail.EmailMessage(
+ sender=sender, to=to, subject=subject, body=body)
+ if html_body:
+ message.html = html_body
+ if reply_to:
+ message.reply_to = reply_to
+ if references:
+ message.headers = {'References': references}
+ if settings.unit_test_mode:
+ logging.info('Sending message "%s" in test mode.', message.subject)
+ else:
+ retry_count = 3
+ for i in range(retry_count):
+ try:
+ message.send()
+ break
+ except apiproxy_errors.DeadlineExceededError as ex:
+ logging.warning('Sending email timed out on try: %d', i)
+ logging.warning(str(ex))
+
+ return dict(
+ sender=sender, to=to, subject=subject, body=body, html_body=html_body,
+ reply_to=reply_to, references=references)