blob: 101ba2b27c0f2a98cbe5934419996466eb5c43f4 [file] [log] [blame]
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01001# Copyright 2016 The Chromium Authors
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
Copybara854996b2021-09-07 19:36:02 +00004
5"""Helper functions for email notifications of issue changes."""
6from __future__ import print_function
7from __future__ import division
8from __future__ import absolute_import
9
10import json
11import logging
12
13import ezt
14import six
15
16from features import autolink
17from features import autolink_constants
18from features import features_constants
19from features import filterrules_helpers
20from features import savedqueries_helpers
21from features import notify_reasons
22from framework import cloud_tasks_helpers
23from framework import emailfmt
24from framework import framework_bizobj
25from framework import framework_constants
26from framework import framework_helpers
27from framework import framework_views
28from framework import jsonfeed
29from framework import monorailrequest
30from framework import permissions
31from framework import template_helpers
32from framework import urls
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +010033from mrproto import tracker_pb2
Copybara854996b2021-09-07 19:36:02 +000034from search import query2ast
35from search import searchpipeline
36from tracker import tracker_bizobj
37
38
39# Email tasks can get too large for AppEngine to handle. In order to prevent
40# that, we set a maximum body size, and may truncate messages to that length.
41# We set this value to 35k so that the total of 35k body + 35k html_body +
42# metadata does not exceed AppEngine's limit of 100k.
43MAX_EMAIL_BODY_SIZE = 35 * 1024
44
45# This HTML template adds mark up which enables Gmail/Inbox to display a
46# convenient link that takes users to the CL directly from the inbox without
47# having to click on the email.
48# Documentation for this schema.org markup is here:
49# https://developers.google.com/gmail/markup/reference/go-to-action
50HTML_BODY_WITH_GMAIL_ACTION_TEMPLATE = """
51<html>
52<body>
53<script type="application/ld+json">
54{
55 "@context": "http://schema.org",
56 "@type": "EmailMessage",
57 "potentialAction": {
58 "@type": "ViewAction",
59 "name": "View Issue",
60 "url": "%(url)s"
61 },
62 "description": ""
63}
64</script>
65
66<div style="font-family: arial, sans-serif; white-space:pre">%(body)s</div>
67</body>
68</html>
69"""
70
71HTML_BODY_WITHOUT_GMAIL_ACTION_TEMPLATE = """
72<html>
73<body>
74<div style="font-family: arial, sans-serif; white-space:pre">%(body)s</div>
75</body>
76</html>
77"""
78
79
80NOTIFY_RESTRICTED_ISSUES_PREF_NAME = 'notify_restricted_issues'
81NOTIFY_WITH_DETAILS = 'notify with details'
82NOTIFY_WITH_DETAILS_GOOGLE = 'notify with details: Google'
83NOTIFY_WITH_LINK_ONLY = 'notify with link only'
84
85
86def _EnqueueOutboundEmail(message_dict):
87 """Create a task to send one email message, all fields are in the dict.
88
89 We use a separate task for each outbound email to isolate errors.
90
91 Args:
92 message_dict: dict with all needed info for the task.
93 """
94 # We use a JSON-encoded payload because it ensures that the task size is
95 # effectively the same as the sum of the email bodies. Using params results
96 # in the dict being urlencoded, which can (worst case) triple the size of
97 # an email body containing many characters which need to be escaped.
98 payload = json.dumps(message_dict)
99 task = {
100 'app_engine_http_request':
101 {
102 'relative_uri': urls.OUTBOUND_EMAIL_TASK + '.do',
103 # Cloud Tasks expects body to be in bytes.
104 'body': payload.encode(),
105 # Cloud tasks default body content type is octet-stream.
106 'headers': {
107 'Content-type': 'application/json'
108 }
109 }
110 }
111 cloud_tasks_helpers.create_task(
112 task, queue=features_constants.QUEUE_OUTBOUND_EMAIL)
113
114
115def AddAllEmailTasks(tasks):
116 """Add one GAE task for each email to be sent."""
117 notified = []
118 for task in tasks:
119 _EnqueueOutboundEmail(task)
120 notified.append(task['to'])
121
122 return notified
123
124
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100125class NotifyTaskBase(jsonfeed.InternalTask):
Copybara854996b2021-09-07 19:36:02 +0000126 """Abstract base class for notification task handler."""
127
128 _EMAIL_TEMPLATE = None # Subclasses must override this.
129 _LINK_ONLY_EMAIL_TEMPLATE = None # Subclasses may override this.
130
131 CHECK_SECURITY_TOKEN = False
132
133 def __init__(self, *args, **kwargs):
134 super(NotifyTaskBase, self).__init__(*args, **kwargs)
135
136 if not self._EMAIL_TEMPLATE:
137 raise Exception('Subclasses must override _EMAIL_TEMPLATE.'
138 ' This class must not be called directly.')
139 # We use FORMAT_RAW for emails because they are plain text, not HTML.
140 # TODO(jrobbins): consider sending HTML formatted emails someday.
141 self.email_template = template_helpers.MonorailTemplate(
142 framework_constants.TEMPLATE_PATH + self._EMAIL_TEMPLATE,
143 compress_whitespace=False, base_format=ezt.FORMAT_RAW)
144
145 if self._LINK_ONLY_EMAIL_TEMPLATE:
146 self.link_only_email_template = template_helpers.MonorailTemplate(
147 framework_constants.TEMPLATE_PATH + self._LINK_ONLY_EMAIL_TEMPLATE,
148 compress_whitespace=False, base_format=ezt.FORMAT_RAW)
149
150
151def _MergeLinkedAccountReasons(addr_to_addrperm, addr_to_reasons):
152 """Return an addr_reasons_dict where parents omit child accounts."""
153 all_ids = set(addr_perm.user.user_id
154 for addr_perm in addr_to_addrperm.values()
155 if addr_perm.user)
156 merged_ids = set()
157
158 result = {}
159 for addr, reasons in addr_to_reasons.items():
160 addr_perm = addr_to_addrperm[addr]
161 parent_id = addr_perm.user.linked_parent_id if addr_perm.user else None
162 if parent_id and parent_id in all_ids:
163 # The current user is a child account and the parent would be notified,
164 # so only notify the parent.
165 merged_ids.add(parent_id)
166 else:
167 result[addr] = reasons
168
169 for addr, reasons in result.items():
170 addr_perm = addr_to_addrperm[addr]
171 if addr_perm.user and addr_perm.user.user_id in merged_ids:
172 reasons.append(notify_reasons.REASON_LINKED_ACCOUNT)
173
174 return result
175
176
177def MakeBulletedEmailWorkItems(
178 group_reason_list, issue, body_link_only, body_for_non_members,
179 body_for_members, project, hostport, commenter_view, detail_url,
180 seq_num=None, subject_prefix=None, compact_subject_prefix=None):
181 """Make a list of dicts describing email-sending tasks to notify users.
182
183 Args:
184 group_reason_list: list of (addr_perm_list, reason) tuples.
185 issue: Issue that was updated.
186 body_link_only: string body of email with minimal information.
187 body_for_non_members: string body of email to send to non-members.
188 body_for_members: string body of email to send to members.
189 project: Project that contains the issue.
190 hostport: string hostname and port number for links to the site.
191 commenter_view: UserView for the user who made the comment.
192 detail_url: str direct link to the issue.
193 seq_num: optional int sequence number of the comment.
194 subject_prefix: optional string to customize the email subject line.
195 compact_subject_prefix: optional string to customize the email subject line.
196
197 Returns:
198 A list of dictionaries, each with all needed info to send an individual
199 email to one user. Each email contains a footer that lists all the
200 reasons why that user received the email.
201 """
202 logging.info('group_reason_list is %r', group_reason_list)
203 addr_to_addrperm = {} # {email_address: AddrPerm object}
204 addr_to_reasons = {} # {email_address: [reason, ...]}
205 for group, reason in group_reason_list:
206 for memb_addr_perm in group:
207 addr = memb_addr_perm.address
208 addr_to_addrperm[addr] = memb_addr_perm
209 addr_to_reasons.setdefault(addr, []).append(reason)
210
211 addr_to_reasons = _MergeLinkedAccountReasons(
212 addr_to_addrperm, addr_to_reasons)
213 logging.info('addr_to_reasons is %r', addr_to_reasons)
214
215 email_tasks = []
216 for addr, reasons in addr_to_reasons.items():
217 memb_addr_perm = addr_to_addrperm[addr]
218 email_tasks.append(_MakeEmailWorkItem(
219 memb_addr_perm, reasons, issue, body_link_only, body_for_non_members,
220 body_for_members, project, hostport, commenter_view, detail_url,
221 seq_num=seq_num, subject_prefix=subject_prefix,
222 compact_subject_prefix=compact_subject_prefix))
223
224 return email_tasks
225
226
227def _TruncateBody(body):
228 """Truncate body string if it exceeds size limit."""
229 if len(body) > MAX_EMAIL_BODY_SIZE:
230 logging.info('Truncate body since its size %d exceeds limit', len(body))
231 return body[:MAX_EMAIL_BODY_SIZE] + '...'
232 return body
233
234
235def _GetNotifyRestrictedIssues(user_prefs, email, user):
236 """Return the notify_restricted_issues pref or a calculated default value."""
237 # If we explicitly set a pref for this address, use it.
238 if user_prefs:
239 for pref in user_prefs.prefs:
240 if pref.name == NOTIFY_RESTRICTED_ISSUES_PREF_NAME:
241 return pref.value
242
243 # Mailing lists cannot visit the site, so if it visited, it is a person.
244 if user and user.last_visit_timestamp:
245 return NOTIFY_WITH_DETAILS
246
247 # If it is a google.com mailing list, allow details for R-V-G issues.
248 if email.endswith('@google.com'):
249 return NOTIFY_WITH_DETAILS_GOOGLE
250
251 # It might be a public mailing list, so don't risk leaking any details.
252 return NOTIFY_WITH_LINK_ONLY
253
254
255def ShouldUseLinkOnly(addr_perm, issue, always_detailed=False):
256 """Return true when there is a risk of leaking a restricted issue.
257
258 We send notifications that contain only a link to the issue with no other
259 details about the change when:
260 - The issue is R-V-G and the address may be a non-google.com mailing list, or
261 - The issue is restricted with something other than R-V-G, and the user
262 may be a mailing list, or
263 - The user has a preference set.
264 """
265 if always_detailed:
266 return False
267
268 restrictions = permissions.GetRestrictions(issue, perm=permissions.VIEW)
269 if not restrictions:
270 return False
271
272 pref = _GetNotifyRestrictedIssues(
273 addr_perm.user_prefs, addr_perm.address, addr_perm.user)
274 if pref == NOTIFY_WITH_DETAILS:
275 return False
276 if (pref == NOTIFY_WITH_DETAILS_GOOGLE and
277 restrictions == ['restrict-view-google']):
278 return False
279
280 # If NOTIFY_WITH_LINK_ONLY or any unexpected value:
281 return True
282
283
284def _MakeEmailWorkItem(
285 addr_perm, reasons, issue, body_link_only,
286 body_for_non_members, body_for_members, project, hostport, commenter_view,
287 detail_url, seq_num=None, subject_prefix=None, compact_subject_prefix=None):
288 """Make one email task dict for one user, includes a detailed reason."""
289 should_use_link_only = ShouldUseLinkOnly(
290 addr_perm, issue, always_detailed=project.issue_notify_always_detailed)
291 subject_format = (
292 (subject_prefix or 'Issue ') +
293 '%(local_id)d in %(project_name)s')
294 if addr_perm.user and addr_perm.user.email_compact_subject:
295 subject_format = (
296 (compact_subject_prefix or '') +
297 '%(project_name)s:%(local_id)d')
298
299 subject = subject_format % {
300 'local_id': issue.local_id,
301 'project_name': issue.project_name,
302 }
303 if not should_use_link_only:
304 subject += ': ' + issue.summary
305
306 footer = _MakeNotificationFooter(reasons, addr_perm.reply_perm, hostport)
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100307 footer = six.ensure_str(footer)
Copybara854996b2021-09-07 19:36:02 +0000308 if should_use_link_only:
309 body = _TruncateBody(body_link_only) + footer
310 elif addr_perm.is_member:
311 logging.info('got member %r, sending body for members', addr_perm.address)
312 body = _TruncateBody(body_for_members) + footer
313 else:
314 logging.info(
315 'got non-member %r, sending body for non-members', addr_perm.address)
316 body = _TruncateBody(body_for_non_members) + footer
317 logging.info('sending message footer:\n%r', footer)
318
319 can_reply_to = (
320 addr_perm.reply_perm != notify_reasons.REPLY_NOT_ALLOWED and
321 project.process_inbound_email)
322 from_addr = emailfmt.FormatFromAddr(
323 project, commenter_view=commenter_view, reveal_addr=addr_perm.is_member,
324 can_reply_to=can_reply_to)
325 if can_reply_to:
326 reply_to = '%s@%s' % (project.project_name, emailfmt.MailDomain())
327 else:
328 reply_to = emailfmt.NoReplyAddress()
329 refs = emailfmt.GetReferences(
330 addr_perm.address, subject, seq_num,
331 '%s@%s' % (project.project_name, emailfmt.MailDomain()))
332 # We use markup to display a convenient link that takes users directly to the
333 # issue without clicking on the email.
334 html_body = None
335 template = HTML_BODY_WITH_GMAIL_ACTION_TEMPLATE
336 if addr_perm.user and not addr_perm.user.email_view_widget:
337 template = HTML_BODY_WITHOUT_GMAIL_ACTION_TEMPLATE
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100338 body_with_tags = _AddHTMLTags(six.ensure_text(body))
Copybara854996b2021-09-07 19:36:02 +0000339 # Escape single quotes which are occasionally used to contain HTML
340 # attributes and event handler definitions.
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100341 body_with_tags = body_with_tags.replace("'", '&#x27;')
Copybara854996b2021-09-07 19:36:02 +0000342 html_body = template % {
343 'url': detail_url,
344 'body': body_with_tags,
345 }
346 return dict(
347 to=addr_perm.address, subject=subject, body=body, html_body=html_body,
348 from_addr=from_addr, reply_to=reply_to, references=refs)
349
350
351def _AddHTMLTags(body):
352 """Adds HMTL tags in the specified email body.
353
354 Specifically does the following:
355 * Detects links and adds <a href>s around the links.
356 * Substitutes <br/> for all occurrences of "\n".
357
358 See crbug.com/582463 for context.
359 """
360 # Convert all URLs into clickable links.
361 body = _AutolinkBody(body)
362
363 # Convert all "\n"s into "<br/>"s.
364 body = body.replace('\r\n', '<br/>')
365 body = body.replace('\n', '<br/>')
366 return body
367
368
369def _AutolinkBody(body):
370 """Convert text that looks like URLs into <a href=...>.
371
372 This uses autolink.py, but it does not register all the autolink components
373 because some of them depend on the current user's permissions which would
374 not make sense for an email body that will be sent to several different users.
375 """
376 email_autolink = autolink.Autolink()
377 email_autolink.RegisterComponent(
378 '01-linkify-user-profiles-or-mailto',
379 lambda request, mr: None,
380 lambda _mr, match: [match.group(0)],
381 {autolink_constants.IS_IMPLIED_EMAIL_RE: autolink.LinkifyEmail})
382 email_autolink.RegisterComponent(
383 '02-linkify-full-urls',
384 lambda request, mr: None,
385 lambda mr, match: None,
386 {autolink_constants.IS_A_LINK_RE: autolink.Linkify})
387 email_autolink.RegisterComponent(
388 '03-linkify-shorthand',
389 lambda request, mr: None,
390 lambda mr, match: None,
391 {autolink_constants.IS_A_SHORT_LINK_RE: autolink.Linkify,
392 autolink_constants.IS_A_NUMERIC_SHORT_LINK_RE: autolink.Linkify,
393 autolink_constants.IS_IMPLIED_LINK_RE: autolink.Linkify,
394 })
395
396 input_run = template_helpers.TextRun(body)
397 output_runs = email_autolink.MarkupAutolinks(
398 None, [input_run], autolink.SKIP_LOOKUPS)
399 output_strings = [run.FormatForHTMLEmail() for run in output_runs]
400 return ''.join(output_strings)
401
402
403def _MakeNotificationFooter(reasons, reply_perm, hostport):
404 """Make an informative footer for a notification email.
405
406 Args:
407 reasons: a list of strings to be used as the explanation. Empty if no
408 reason is to be given.
409 reply_perm: string which is one of REPLY_NOT_ALLOWED, REPLY_MAY_COMMENT,
410 REPLY_MAY_UPDATE.
411 hostport: string with domain_name:port_number to be used in linking to
412 the user preferences page.
413
414 Returns:
415 A string to be used as the email footer.
416 """
417 if not reasons:
418 return ''
419
420 domain_port = hostport.split(':')
421 domain_port[0] = framework_helpers.GetPreferredDomain(domain_port[0])
422 hostport = ':'.join(domain_port)
423
424 prefs_url = 'https://%s%s' % (hostport, urls.USER_SETTINGS)
425 lines = ['-- ']
426 lines.append('You received this message because:')
427 lines.extend(' %d. %s' % (idx + 1, reason)
428 for idx, reason in enumerate(reasons))
429
430 lines.extend(['', 'You may adjust your notification preferences at:',
431 prefs_url])
432
433 if reply_perm == notify_reasons.REPLY_MAY_COMMENT:
434 lines.extend(['', 'Reply to this email to add a comment.'])
435 elif reply_perm == notify_reasons.REPLY_MAY_UPDATE:
436 lines.extend(['', 'Reply to this email to add a comment or make updates.'])
437
438 return '\n'.join(lines)