Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 1 | # 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. |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 4 | |
| 5 | """Helper functions for email notifications of issue changes.""" |
| 6 | from __future__ import print_function |
| 7 | from __future__ import division |
| 8 | from __future__ import absolute_import |
| 9 | |
| 10 | import json |
| 11 | import logging |
| 12 | |
| 13 | import ezt |
| 14 | import six |
| 15 | |
| 16 | from features import autolink |
| 17 | from features import autolink_constants |
| 18 | from features import features_constants |
| 19 | from features import filterrules_helpers |
| 20 | from features import savedqueries_helpers |
| 21 | from features import notify_reasons |
| 22 | from framework import cloud_tasks_helpers |
| 23 | from framework import emailfmt |
| 24 | from framework import framework_bizobj |
| 25 | from framework import framework_constants |
| 26 | from framework import framework_helpers |
| 27 | from framework import framework_views |
| 28 | from framework import jsonfeed |
| 29 | from framework import monorailrequest |
| 30 | from framework import permissions |
| 31 | from framework import template_helpers |
| 32 | from framework import urls |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 33 | from mrproto import tracker_pb2 |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 34 | from search import query2ast |
| 35 | from search import searchpipeline |
| 36 | from 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. |
| 43 | MAX_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 |
| 50 | HTML_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 | |
| 71 | HTML_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 | |
| 80 | NOTIFY_RESTRICTED_ISSUES_PREF_NAME = 'notify_restricted_issues' |
| 81 | NOTIFY_WITH_DETAILS = 'notify with details' |
| 82 | NOTIFY_WITH_DETAILS_GOOGLE = 'notify with details: Google' |
| 83 | NOTIFY_WITH_LINK_ONLY = 'notify with link only' |
| 84 | |
| 85 | |
| 86 | def _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 | |
| 115 | def 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ínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 125 | class NotifyTaskBase(jsonfeed.InternalTask): |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 126 | """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 | |
| 151 | def _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 | |
| 177 | def 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 | |
| 227 | def _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 | |
| 235 | def _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 | |
| 255 | def 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 | |
| 284 | def _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ínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 307 | footer = six.ensure_str(footer) |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 308 | 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ínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 338 | body_with_tags = _AddHTMLTags(six.ensure_text(body)) |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 339 | # Escape single quotes which are occasionally used to contain HTML |
| 340 | # attributes and event handler definitions. |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 341 | body_with_tags = body_with_tags.replace("'", ''') |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 342 | 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 | |
| 351 | def _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 | |
| 369 | def _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 | |
| 403 | def _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) |