blob: 101ba2b27c0f2a98cbe5934419996466eb5c43f4 [file] [log] [blame]
# Copyright 2016 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Helper functions for email notifications of issue changes."""
from __future__ import print_function
from __future__ import division
from __future__ import absolute_import
import json
import logging
import ezt
import six
from features import autolink
from features import autolink_constants
from features import features_constants
from features import filterrules_helpers
from features import savedqueries_helpers
from features import notify_reasons
from framework import cloud_tasks_helpers
from framework import emailfmt
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 mrproto import tracker_pb2
from search import query2ast
from search import searchpipeline
from tracker import tracker_bizobj
# Email tasks can get too large for AppEngine to handle. In order to prevent
# that, we set a maximum body size, and may truncate messages to that length.
# We set this value to 35k so that the total of 35k body + 35k html_body +
# metadata does not exceed AppEngine's limit of 100k.
MAX_EMAIL_BODY_SIZE = 35 * 1024
# 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": "%(url)s"
},
"description": ""
}
</script>
<div style="font-family: arial, sans-serif; white-space:pre">%(body)s</div>
</body>
</html>
"""
HTML_BODY_WITHOUT_GMAIL_ACTION_TEMPLATE = """
<html>
<body>
<div style="font-family: arial, sans-serif; white-space:pre">%(body)s</div>
</body>
</html>
"""
NOTIFY_RESTRICTED_ISSUES_PREF_NAME = 'notify_restricted_issues'
NOTIFY_WITH_DETAILS = 'notify with details'
NOTIFY_WITH_DETAILS_GOOGLE = 'notify with details: Google'
NOTIFY_WITH_LINK_ONLY = 'notify with link only'
def _EnqueueOutboundEmail(message_dict):
"""Create a task to send one email message, all fields are in the dict.
We use a separate task for each outbound email to isolate errors.
Args:
message_dict: dict with all needed info for the task.
"""
# We use a JSON-encoded payload because it ensures that the task size is
# effectively the same as the sum of the email bodies. Using params results
# in the dict being urlencoded, which can (worst case) triple the size of
# an email body containing many characters which need to be escaped.
payload = json.dumps(message_dict)
task = {
'app_engine_http_request':
{
'relative_uri': urls.OUTBOUND_EMAIL_TASK + '.do',
# Cloud Tasks expects body to be in bytes.
'body': payload.encode(),
# Cloud tasks default body content type is octet-stream.
'headers': {
'Content-type': 'application/json'
}
}
}
cloud_tasks_helpers.create_task(
task, queue=features_constants.QUEUE_OUTBOUND_EMAIL)
def AddAllEmailTasks(tasks):
"""Add one GAE task for each email to be sent."""
notified = []
for task in tasks:
_EnqueueOutboundEmail(task)
notified.append(task['to'])
return notified
class NotifyTaskBase(jsonfeed.InternalTask):
"""Abstract base class for notification task handler."""
_EMAIL_TEMPLATE = None # Subclasses must override this.
_LINK_ONLY_EMAIL_TEMPLATE = None # Subclasses may override this.
CHECK_SECURITY_TOKEN = False
def __init__(self, *args, **kwargs):
super(NotifyTaskBase, self).__init__(*args, **kwargs)
if not self._EMAIL_TEMPLATE:
raise Exception('Subclasses must override _EMAIL_TEMPLATE.'
' This class must not be called directly.')
# We use FORMAT_RAW for emails because they are plain text, not HTML.
# TODO(jrobbins): consider sending HTML formatted emails someday.
self.email_template = template_helpers.MonorailTemplate(
framework_constants.TEMPLATE_PATH + self._EMAIL_TEMPLATE,
compress_whitespace=False, base_format=ezt.FORMAT_RAW)
if self._LINK_ONLY_EMAIL_TEMPLATE:
self.link_only_email_template = template_helpers.MonorailTemplate(
framework_constants.TEMPLATE_PATH + self._LINK_ONLY_EMAIL_TEMPLATE,
compress_whitespace=False, base_format=ezt.FORMAT_RAW)
def _MergeLinkedAccountReasons(addr_to_addrperm, addr_to_reasons):
"""Return an addr_reasons_dict where parents omit child accounts."""
all_ids = set(addr_perm.user.user_id
for addr_perm in addr_to_addrperm.values()
if addr_perm.user)
merged_ids = set()
result = {}
for addr, reasons in addr_to_reasons.items():
addr_perm = addr_to_addrperm[addr]
parent_id = addr_perm.user.linked_parent_id if addr_perm.user else None
if parent_id and parent_id in all_ids:
# The current user is a child account and the parent would be notified,
# so only notify the parent.
merged_ids.add(parent_id)
else:
result[addr] = reasons
for addr, reasons in result.items():
addr_perm = addr_to_addrperm[addr]
if addr_perm.user and addr_perm.user.user_id in merged_ids:
reasons.append(notify_reasons.REASON_LINKED_ACCOUNT)
return result
def MakeBulletedEmailWorkItems(
group_reason_list, issue, body_link_only, body_for_non_members,
body_for_members, project, hostport, commenter_view, detail_url,
seq_num=None, subject_prefix=None, compact_subject_prefix=None):
"""Make a list of dicts describing email-sending tasks to notify users.
Args:
group_reason_list: list of (addr_perm_list, reason) tuples.
issue: Issue that was updated.
body_link_only: string body of email with minimal information.
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.
detail_url: str direct link to the issue.
seq_num: optional int sequence number of the comment.
subject_prefix: optional string to customize the email subject line.
compact_subject_prefix: optional string to customize the email subject line.
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_to_addrperm = {} # {email_address: AddrPerm object}
addr_to_reasons = {} # {email_address: [reason, ...]}
for group, reason in group_reason_list:
for memb_addr_perm in group:
addr = memb_addr_perm.address
addr_to_addrperm[addr] = memb_addr_perm
addr_to_reasons.setdefault(addr, []).append(reason)
addr_to_reasons = _MergeLinkedAccountReasons(
addr_to_addrperm, addr_to_reasons)
logging.info('addr_to_reasons is %r', addr_to_reasons)
email_tasks = []
for addr, reasons in addr_to_reasons.items():
memb_addr_perm = addr_to_addrperm[addr]
email_tasks.append(_MakeEmailWorkItem(
memb_addr_perm, reasons, issue, body_link_only, body_for_non_members,
body_for_members, project, hostport, commenter_view, detail_url,
seq_num=seq_num, subject_prefix=subject_prefix,
compact_subject_prefix=compact_subject_prefix))
return email_tasks
def _TruncateBody(body):
"""Truncate body string if it exceeds size limit."""
if len(body) > MAX_EMAIL_BODY_SIZE:
logging.info('Truncate body since its size %d exceeds limit', len(body))
return body[:MAX_EMAIL_BODY_SIZE] + '...'
return body
def _GetNotifyRestrictedIssues(user_prefs, email, user):
"""Return the notify_restricted_issues pref or a calculated default value."""
# If we explicitly set a pref for this address, use it.
if user_prefs:
for pref in user_prefs.prefs:
if pref.name == NOTIFY_RESTRICTED_ISSUES_PREF_NAME:
return pref.value
# Mailing lists cannot visit the site, so if it visited, it is a person.
if user and user.last_visit_timestamp:
return NOTIFY_WITH_DETAILS
# If it is a google.com mailing list, allow details for R-V-G issues.
if email.endswith('@google.com'):
return NOTIFY_WITH_DETAILS_GOOGLE
# It might be a public mailing list, so don't risk leaking any details.
return NOTIFY_WITH_LINK_ONLY
def ShouldUseLinkOnly(addr_perm, issue, always_detailed=False):
"""Return true when there is a risk of leaking a restricted issue.
We send notifications that contain only a link to the issue with no other
details about the change when:
- The issue is R-V-G and the address may be a non-google.com mailing list, or
- The issue is restricted with something other than R-V-G, and the user
may be a mailing list, or
- The user has a preference set.
"""
if always_detailed:
return False
restrictions = permissions.GetRestrictions(issue, perm=permissions.VIEW)
if not restrictions:
return False
pref = _GetNotifyRestrictedIssues(
addr_perm.user_prefs, addr_perm.address, addr_perm.user)
if pref == NOTIFY_WITH_DETAILS:
return False
if (pref == NOTIFY_WITH_DETAILS_GOOGLE and
restrictions == ['restrict-view-google']):
return False
# If NOTIFY_WITH_LINK_ONLY or any unexpected value:
return True
def _MakeEmailWorkItem(
addr_perm, reasons, issue, body_link_only,
body_for_non_members, body_for_members, project, hostport, commenter_view,
detail_url, seq_num=None, subject_prefix=None, compact_subject_prefix=None):
"""Make one email task dict for one user, includes a detailed reason."""
should_use_link_only = ShouldUseLinkOnly(
addr_perm, issue, always_detailed=project.issue_notify_always_detailed)
subject_format = (
(subject_prefix or 'Issue ') +
'%(local_id)d in %(project_name)s')
if addr_perm.user and addr_perm.user.email_compact_subject:
subject_format = (
(compact_subject_prefix or '') +
'%(project_name)s:%(local_id)d')
subject = subject_format % {
'local_id': issue.local_id,
'project_name': issue.project_name,
}
if not should_use_link_only:
subject += ': ' + issue.summary
footer = _MakeNotificationFooter(reasons, addr_perm.reply_perm, hostport)
footer = six.ensure_str(footer)
if should_use_link_only:
body = _TruncateBody(body_link_only) + footer
elif addr_perm.is_member:
logging.info('got member %r, sending body for members', addr_perm.address)
body = _TruncateBody(body_for_members) + footer
else:
logging.info(
'got non-member %r, sending body for non-members', addr_perm.address)
body = _TruncateBody(body_for_non_members) + footer
logging.info('sending message footer:\n%r', footer)
can_reply_to = (
addr_perm.reply_perm != notify_reasons.REPLY_NOT_ALLOWED and
project.process_inbound_email)
from_addr = emailfmt.FormatFromAddr(
project, commenter_view=commenter_view, reveal_addr=addr_perm.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(
addr_perm.address, subject, seq_num,
'%s@%s' % (project.project_name, emailfmt.MailDomain()))
# We use markup to display a convenient link that takes users directly to the
# issue without clicking on the email.
html_body = None
template = HTML_BODY_WITH_GMAIL_ACTION_TEMPLATE
if addr_perm.user and not addr_perm.user.email_view_widget:
template = HTML_BODY_WITHOUT_GMAIL_ACTION_TEMPLATE
body_with_tags = _AddHTMLTags(six.ensure_text(body))
# Escape single quotes which are occasionally used to contain HTML
# attributes and event handler definitions.
body_with_tags = body_with_tags.replace("'", '&#x27;')
html_body = template % {
'url': detail_url,
'body': body_with_tags,
}
return dict(
to=addr_perm.address, subject=subject, body=body, 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 = _AutolinkBody(body)
# Convert all "\n"s into "<br/>"s.
body = body.replace('\r\n', '<br/>')
body = body.replace('\n', '<br/>')
return body
def _AutolinkBody(body):
"""Convert text that looks like URLs into <a href=...>.
This uses autolink.py, but it does not register all the autolink components
because some of them depend on the current user's permissions which would
not make sense for an email body that will be sent to several different users.
"""
email_autolink = autolink.Autolink()
email_autolink.RegisterComponent(
'01-linkify-user-profiles-or-mailto',
lambda request, mr: None,
lambda _mr, match: [match.group(0)],
{autolink_constants.IS_IMPLIED_EMAIL_RE: autolink.LinkifyEmail})
email_autolink.RegisterComponent(
'02-linkify-full-urls',
lambda request, mr: None,
lambda mr, match: None,
{autolink_constants.IS_A_LINK_RE: autolink.Linkify})
email_autolink.RegisterComponent(
'03-linkify-shorthand',
lambda request, mr: None,
lambda mr, match: None,
{autolink_constants.IS_A_SHORT_LINK_RE: autolink.Linkify,
autolink_constants.IS_A_NUMERIC_SHORT_LINK_RE: autolink.Linkify,
autolink_constants.IS_IMPLIED_LINK_RE: autolink.Linkify,
})
input_run = template_helpers.TextRun(body)
output_runs = email_autolink.MarkupAutolinks(
None, [input_run], autolink.SKIP_LOOKUPS)
output_strings = [run.FormatForHTMLEmail() for run in output_runs]
return ''.join(output_strings)
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 == notify_reasons.REPLY_MAY_COMMENT:
lines.extend(['', 'Reply to this email to add a comment.'])
elif reply_perm == notify_reasons.REPLY_MAY_UPDATE:
lines.extend(['', 'Reply to this email to add a comment or make updates.'])
return '\n'.join(lines)