Index: appengine/monorail/features/notify_helpers.py |
diff --git a/appengine/monorail/features/notify_helpers.py b/appengine/monorail/features/notify_helpers.py |
new file mode 100644 |
index 0000000000000000000000000000000000000000..ff37190419329899d56b3316ac67cbf5f6fefb42 |
--- /dev/null |
+++ b/appengine/monorail/features/notify_helpers.py |
@@ -0,0 +1,414 @@ |
+# Copyright 2016 The Chromium Authors. All rights reserved. |
+# Use of this source code is govered by a BSD-style |
+# license that can be found in the LICENSE file or at |
+# https://developers.google.com/open-source/licenses/bsd |
+ |
+"""Helper functions for email notifications of issue changes.""" |
+ |
+import cgi |
+import logging |
+import re |
+ |
+from django.utils.html import urlize |
+ |
+from features import filterrules_helpers |
+from features import savedqueries_helpers |
+from framework import emailfmt |
+from framework import framework_bizobj |
+from framework import framework_constants |
+from framework import framework_helpers |
+from framework import monorailrequest |
+from framework import permissions |
+from framework import urls |
+from proto import tracker_pb2 |
+from search import query2ast |
+from search import searchpipeline |
+from tracker import component_helpers |
+from tracker import tracker_bizobj |
+ |
+ |
+# When sending change notification emails, choose the reply-to header and |
+# footer message based on three levels of the the recipient's permissions |
+# for that issue. |
+REPLY_NOT_ALLOWED = 'REPLY_NOT_ALLOWED' |
+REPLY_MAY_COMMENT = 'REPLY_MAY_COMMENT' |
+REPLY_MAY_UPDATE = 'REPLY_MAY_UPDATE' |
+ |
+# This HTML template adds mark up which enables Gmail/Inbox to display a |
+# convenient link that takes users to the CL directly from the inbox without |
+# having to click on the email. |
+# Documentation for this schema.org markup is here: |
+# https://developers.google.com/gmail/markup/reference/go-to-action |
+HTML_BODY_WITH_GMAIL_ACTION_TEMPLATE = """ |
+<html> |
+<body> |
+<script type="application/ld+json"> |
+{ |
+ "@context": "http://schema.org", |
+ "@type": "EmailMessage", |
+ "potentialAction": { |
+ "@type": "ViewAction", |
+ "name": "View Issue", |
+ "url": "%s" |
+ }, |
+ "description": "" |
+} |
+</script> |
+ |
+<div style="font-family: arial, sans-serif">%s</div> |
+</body> |
+</html> |
+""" |
+ |
+ |
+def ComputeIssueChangeAddressPermList( |
+ cnxn, ids_to_consider, project, issue, services, omit_addrs, |
+ users_by_id, pref_check_function=lambda u: u.notify_issue_change): |
+ """Return a list of user email addresses to notify of an issue change. |
+ |
+ User email addresses are determined by looking up the given user IDs |
+ in the given users_by_id dict. |
+ |
+ Args: |
+ cnxn: connection to SQL database. |
+ ids_to_consider: list of user IDs for users interested in this issue. |
+ project: Project PB for the project contianing containing this issue. |
+ issue: Issue PB for the issue that was updated. |
+ services: Services. |
+ omit_addrs: set of strings for email addresses to not notify because |
+ they already know. |
+ users_by_id: dict {user_id: user_view} user info. |
+ pref_check_function: optional function to use to check if a certain |
+ User PB has a preference set to receive the email being sent. It |
+ defaults to "If I am in the issue's owner or cc field", but it |
+ can be set to check "If I starred the issue." |
+ |
+ Returns: |
+ A list of tuples: [(recipient_is_member, address, reply_perm), ...] where |
+ reply_perm is one of REPLY_NOT_ALLOWED, REPLY_MAY_COMMENT, |
+ REPLY_MAY_UPDATE. |
+ """ |
+ memb_addr_perm_list = [] |
+ for user_id in ids_to_consider: |
+ if user_id == framework_constants.NO_USER_SPECIFIED: |
+ continue |
+ user = services.user.GetUser(cnxn, user_id) |
+ # Notify people who have a pref set, or if they have no User PB |
+ # because the pref defaults to True. |
+ if user and not pref_check_function(user): |
+ continue |
+ # TODO(jrobbins): doing a bulk operation would reduce DB load. |
+ auth = monorailrequest.AuthData.FromUserID(cnxn, user_id, services) |
+ perms = permissions.GetPermissions(user, auth.effective_ids, project) |
+ config = services.config.GetProjectConfig(cnxn, project.project_id) |
+ granted_perms = tracker_bizobj.GetGrantedPerms( |
+ issue, auth.effective_ids, config) |
+ |
+ if not permissions.CanViewIssue( |
+ auth.effective_ids, perms, project, issue, |
+ granted_perms=granted_perms): |
+ continue |
+ |
+ addr = users_by_id[user_id].email |
+ if addr in omit_addrs: |
+ continue |
+ |
+ recipient_is_member = bool(framework_bizobj.UserIsInProject( |
+ project, auth.effective_ids)) |
+ |
+ reply_perm = REPLY_NOT_ALLOWED |
+ if project.process_inbound_email: |
+ if permissions.CanEditIssue(auth.effective_ids, perms, project, issue): |
+ reply_perm = REPLY_MAY_UPDATE |
+ elif permissions.CanCommentIssue( |
+ auth.effective_ids, perms, project, issue): |
+ reply_perm = REPLY_MAY_COMMENT |
+ |
+ memb_addr_perm_list.append((recipient_is_member, addr, reply_perm)) |
+ |
+ logging.info('For %s %s, will notify: %r', |
+ project.project_name, issue.local_id, memb_addr_perm_list) |
+ |
+ return memb_addr_perm_list |
+ |
+ |
+def ComputeProjectNotificationAddrList( |
+ project, contributor_could_view, omit_addrs): |
+ """Return a list of non-user addresses to notify of an issue change. |
+ |
+ The non-user addresses are specified by email address strings, not |
+ user IDs. One such address can be specified in the project PB. |
+ It is not assumed to have permission to see all issues. |
+ |
+ Args: |
+ project: Project PB containing the issue that was updated. |
+ contributor_could_view: True if any project contributor should be able to |
+ see the notification email, e.g., in a mailing list archive or feed. |
+ omit_addrs: set of strings for email addresses to not notify because |
+ they already know. |
+ |
+ Returns: |
+ A list of tuples: [(False, email_address, reply_permission_level), ...], |
+ where reply_permission_level is always REPLY_NOT_ALLOWED for now. |
+ """ |
+ memb_addr_perm_list = [] |
+ if contributor_could_view: |
+ ml_addr = project.issue_notify_address |
+ if ml_addr and ml_addr not in omit_addrs: |
+ memb_addr_perm_list.append((False, ml_addr, REPLY_NOT_ALLOWED)) |
+ |
+ return memb_addr_perm_list |
+ |
+ |
+def ComputeIssueNotificationAddrList(issue, omit_addrs): |
+ """Return a list of non-user addresses to notify of an issue change. |
+ |
+ The non-user addresses are specified by email address strings, not |
+ user IDs. They can be set by filter rules with the "Also notify" action. |
+ "Also notify" addresses are assumed to have permission to see any issue, |
+ even a restricted one. |
+ |
+ Args: |
+ issue: Issue PB for the issue that was updated. |
+ omit_addrs: set of strings for email addresses to not notify because |
+ they already know. |
+ |
+ Returns: |
+ A list of tuples: [(False, email_address, reply_permission_level), ...], |
+ where reply_permission_level is always REPLY_NOT_ALLOWED for now. |
+ """ |
+ addr_perm_list = [] |
+ for addr in issue.derived_notify_addrs: |
+ if addr not in omit_addrs: |
+ addr_perm_list.append((False, addr, REPLY_NOT_ALLOWED)) |
+ |
+ return addr_perm_list |
+ |
+ |
+def MakeBulletedEmailWorkItems( |
+ group_reason_list, subject, body_for_non_members, body_for_members, |
+ project, hostport, commenter_view, seq_num=None, detail_url=None): |
+ """Make a list of dicts describing email-sending tasks to notify users. |
+ |
+ Args: |
+ group_reason_list: list of (is_memb, addr_perm, reason) tuples. |
+ subject: string email subject line. |
+ body_for_non_members: string body of email to send to non-members. |
+ body_for_members: string body of email to send to members. |
+ project: Project that contains the issue. |
+ hostport: string hostname and port number for links to the site. |
+ commenter_view: UserView for the user who made the comment. |
+ seq_num: optional int sequence number of the comment. |
+ detail_url: optional str direct link to the issue. |
+ |
+ Returns: |
+ A list of dictionaries, each with all needed info to send an individual |
+ email to one user. Each email contains a footer that lists all the |
+ reasons why that user received the email. |
+ """ |
+ logging.info('group_reason_list is %r', group_reason_list) |
+ addr_reasons_dict = {} |
+ for group, reason in group_reason_list: |
+ for memb_addr_perm in group: |
+ addr_reasons_dict.setdefault(memb_addr_perm, []).append(reason) |
+ |
+ email_tasks = [] |
+ for memb_addr_perm, reasons in addr_reasons_dict.iteritems(): |
+ email_tasks.append(_MakeEmailWorkItem( |
+ memb_addr_perm, reasons, subject, body_for_non_members, |
+ body_for_members, project, hostport, commenter_view, seq_num=seq_num, |
+ detail_url=detail_url)) |
+ |
+ return email_tasks |
+ |
+ |
+def _MakeEmailWorkItem( |
+ (recipient_is_member, to_addr, reply_perm), reasons, subject, |
+ body_for_non_members, body_for_members, project, hostport, commenter_view, |
+ seq_num=None, detail_url=None): |
+ """Make one email task dict for one user, includes a detailed reason.""" |
+ footer = _MakeNotificationFooter(reasons, reply_perm, hostport) |
+ if isinstance(footer, unicode): |
+ footer = footer.encode('utf-8') |
+ if recipient_is_member: |
+ logging.info('got member %r', to_addr) |
+ body = body_for_members |
+ else: |
+ logging.info('got non-member %r', to_addr) |
+ body = body_for_non_members |
+ |
+ logging.info('sending body + footer: %r', body + footer) |
+ can_reply_to = ( |
+ reply_perm != REPLY_NOT_ALLOWED and project.process_inbound_email) |
+ from_addr = emailfmt.FormatFromAddr( |
+ project, commenter_view=commenter_view, reveal_addr=recipient_is_member, |
+ can_reply_to=can_reply_to) |
+ if can_reply_to: |
+ reply_to = '%s@%s' % (project.project_name, emailfmt.MailDomain()) |
+ else: |
+ reply_to = emailfmt.NoReplyAddress() |
+ refs = emailfmt.GetReferences( |
+ to_addr, subject, seq_num, |
+ '%s@%s' % (project.project_name, emailfmt.MailDomain())) |
+ # If detail_url is specified then we can use markup to display a convenient |
+ # link that takes users directly to the issue without clicking on the email. |
+ html_body = None |
+ if detail_url: |
+ # cgi.escape the body and additionally escape single quotes which are |
+ # occassionally used to contain HTML attributes and event handler |
+ # definitions. |
+ html_escaped_body = cgi.escape(body + footer, quote=1).replace("'", ''') |
+ html_body = HTML_BODY_WITH_GMAIL_ACTION_TEMPLATE % ( |
+ detail_url, |
+ _AddHTMLTags(html_escaped_body.decode('utf-8'))) |
+ return dict(to=to_addr, subject=subject, body=body + footer, |
+ html_body=html_body, from_addr=from_addr, reply_to=reply_to, |
+ references=refs) |
+ |
+ |
+def _AddHTMLTags(body): |
+ """Adds HMTL tags in the specified email body. |
+ |
+ Specifically does the following: |
+ * Detects links and adds <a href>s around the links. |
+ * Substitutes <br/> for all occurrences of "\n". |
+ |
+ See crbug.com/582463 for context. |
+ """ |
+ # Convert all URLs into clickable links. |
+ body = urlize(body) |
+ # The above step converts |
+ # '<link.com>' into '<<a href="link.com>">link.com></a>;' and |
+ # '<x@y.com>' into '<<a href="mailto:x@y.com>">x@y.com></a>;' |
+ # The below regex fixes this specific problem. See |
+ # https://bugs.chromium.org/p/monorail/issues/detail?id=1007 for more details. |
+ body = re.sub(r'<<a href="(|mailto:)(.*?)>">(.*?)></a>;', |
+ r'<a href="\1\2"><\3></a>', body) |
+ |
+ # Convert all "\n"s into "<br/>"s. |
+ body = body.replace("\n", "<br/>") |
+ return body |
+ |
+ |
+def _MakeNotificationFooter(reasons, reply_perm, hostport): |
+ """Make an informative footer for a notification email. |
+ |
+ Args: |
+ reasons: a list of strings to be used as the explanation. Empty if no |
+ reason is to be given. |
+ reply_perm: string which is one of REPLY_NOT_ALLOWED, REPLY_MAY_COMMENT, |
+ REPLY_MAY_UPDATE. |
+ hostport: string with domain_name:port_number to be used in linking to |
+ the user preferences page. |
+ |
+ Returns: |
+ A string to be used as the email footer. |
+ """ |
+ if not reasons: |
+ return '' |
+ |
+ domain_port = hostport.split(':') |
+ domain_port[0] = framework_helpers.GetPreferredDomain(domain_port[0]) |
+ hostport = ':'.join(domain_port) |
+ |
+ prefs_url = 'https://%s%s' % (hostport, urls.USER_SETTINGS) |
+ lines = ['-- '] |
+ lines.append('You received this message because:') |
+ lines.extend(' %d. %s' % (idx + 1, reason) |
+ for idx, reason in enumerate(reasons)) |
+ |
+ lines.extend(['', 'You may adjust your notification preferences at:', |
+ prefs_url]) |
+ |
+ if reply_perm == REPLY_MAY_COMMENT: |
+ lines.extend(['', 'Reply to this email to add a comment.']) |
+ elif reply_perm == REPLY_MAY_UPDATE: |
+ lines.extend(['', 'Reply to this email to add a comment or make updates.']) |
+ |
+ return '\n'.join(lines) |
+ |
+ |
+def GetNonOmittedSubscriptions(cnxn, services, project_ids, omit_addrs): |
+ """Get a dict of users w/ subscriptions in those projects.""" |
+ users_to_queries = services.features.GetSubscriptionsInProjects( |
+ cnxn, project_ids) |
+ user_emails = services.user.LookupUserEmails(cnxn, users_to_queries.keys()) |
+ for user_id, email in user_emails.iteritems(): |
+ if email in omit_addrs: |
+ del users_to_queries[user_id] |
+ |
+ return users_to_queries |
+ |
+ |
+def EvaluateSubscriptions( |
+ cnxn, issue, users_to_queries, services, config): |
+ """Determine subscribers who have subs that match the given issue.""" |
+ # Note: unlike filter rule, subscriptions see explicit & derived values. |
+ lower_labels = [lab.lower() for lab in tracker_bizobj.GetLabels(issue)] |
+ label_set = set(lower_labels) |
+ |
+ subscribers_to_notify = [] |
+ for uid, saved_queries in users_to_queries.iteritems(): |
+ for sq in saved_queries: |
+ if sq.subscription_mode != 'immediate': |
+ continue |
+ if issue.project_id not in sq.executes_in_project_ids: |
+ continue |
+ cond = savedqueries_helpers.SavedQueryToCond(sq) |
+ logging.info('evaluating query %s: %r', sq.name, cond) |
+ cond = searchpipeline.ReplaceKeywordsWithUserID(uid, cond) |
+ cond_ast = query2ast.ParseUserQuery( |
+ cond, '', query2ast.BUILTIN_ISSUE_FIELDS, config) |
+ |
+ if filterrules_helpers.EvalPredicate( |
+ cnxn, services, cond_ast, issue, label_set, config, |
+ tracker_bizobj.GetOwnerId(issue), tracker_bizobj.GetCcIds(issue), |
+ tracker_bizobj.GetStatus(issue)): |
+ subscribers_to_notify.append(uid) |
+ break # Don't bother looking at the user's other saved quereies. |
+ |
+ return subscribers_to_notify |
+ |
+ |
+def ComputeCustomFieldAddrPerms( |
+ cnxn, config, issue, project, services, omit_addrs, users_by_id): |
+ """Check the reasons to notify users named in custom fields.""" |
+ group_reason_list = [] |
+ for fd in config.field_defs: |
+ named_user_ids = ComputeNamedUserIDsToNotify(issue, fd) |
+ if named_user_ids: |
+ named_addr_perms = ComputeIssueChangeAddressPermList( |
+ cnxn, named_user_ids, project, issue, services, omit_addrs, |
+ users_by_id, pref_check_function=lambda u: True) |
+ group_reason_list.append( |
+ (named_addr_perms, 'You are named in the %s field' % fd.field_name)) |
+ |
+ return group_reason_list |
+ |
+ |
+def ComputeNamedUserIDsToNotify(issue, fd): |
+ """Give a list of user IDs to notify because they're in a field.""" |
+ if (fd.field_type == tracker_pb2.FieldTypes.USER_TYPE and |
+ fd.notify_on == tracker_pb2.NotifyTriggers.ANY_COMMENT): |
+ return [fv.user_id for fv in issue.field_values |
+ if fv.field_id == fd.field_id] |
+ |
+ return [] |
+ |
+ |
+def ComputeComponentFieldAddrPerms( |
+ cnxn, config, issue, project, services, omit_addrs, users_by_id): |
+ """Return [(addr_perm, reason), ...] for users auto-cc'd by components.""" |
+ component_ids = set(issue.component_ids) |
+ group_reason_list = [] |
+ for cd in config.component_defs: |
+ if cd.component_id in component_ids: |
+ cc_ids = component_helpers.GetCcIDsForComponentAndAncestors(config, cd) |
+ comp_addr_perms = ComputeIssueChangeAddressPermList( |
+ cnxn, cc_ids, project, issue, services, omit_addrs, |
+ users_by_id, pref_check_function=lambda u: True) |
+ group_reason_list.append( |
+ (comp_addr_perms, |
+ 'You are auto-CC\'d on all issues in component %s' % cd.path)) |
+ |
+ return group_reason_list |