| # 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 |
| |
| def PostNotifyIssueChangeTask(self, **kwargs): |
| return self.handler(**kwargs) |
| |
| |
| 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 |
| |
| def PostNotifyBlockingChangeTask(self, **kwargs): |
| return self.handler(**kwargs) |
| |
| |
| 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 |
| |
| def PostNotifyBulkChangeTask(self, **kwargs): |
| return self.handler(**kwargs) |
| |
| |
| # 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)) |
| |
| def PostNotifyApprovalChangeTask(self, **kwargs): |
| return self.handler(**kwargs) |
| |
| |
| 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 |
| |
| def PostNotifyRulesDeletedTask(self, **kwargs): |
| return self.handler(**kwargs) |
| |
| |
| class OutboundEmailTask(jsonfeed.FlaskInternalTask): |
| """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.get_data(as_text=True)) |
| except ValueError: |
| logging.error(self.request.get_data(as_text=True)) |
| 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) |
| |
| def PostOutboundEmailTask(self, **kwargs): |
| return self.handler(**kwargs) |