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 | """Task handlers for email notifications of issue changes. |
| 6 | |
| 7 | Email notificatons are sent when an issue changes, an issue that is blocking |
| 8 | another issue changes, or a bulk edit is done. The users notified include |
| 9 | the project-wide mailing list, issue owners, cc'd users, starrers, |
| 10 | also-notify addresses, and users who have saved queries with email notification |
| 11 | set. |
| 12 | """ |
| 13 | from __future__ import print_function |
| 14 | from __future__ import division |
| 15 | from __future__ import absolute_import |
| 16 | |
| 17 | import collections |
| 18 | import json |
| 19 | import logging |
| 20 | import os |
| 21 | |
| 22 | import ezt |
| 23 | |
| 24 | from google.appengine.api import mail |
| 25 | from google.appengine.runtime import apiproxy_errors |
| 26 | |
| 27 | import settings |
| 28 | from features import autolink |
| 29 | from features import notify_helpers |
| 30 | from features import notify_reasons |
| 31 | from framework import authdata |
| 32 | from framework import emailfmt |
| 33 | from framework import exceptions |
| 34 | from framework import framework_bizobj |
| 35 | from framework import framework_constants |
| 36 | from framework import framework_helpers |
| 37 | from framework import framework_views |
| 38 | from framework import jsonfeed |
| 39 | from framework import monorailrequest |
| 40 | from framework import permissions |
| 41 | from framework import template_helpers |
| 42 | from framework import urls |
| 43 | from tracker import tracker_bizobj |
| 44 | from tracker import tracker_helpers |
| 45 | from tracker import tracker_views |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame^] | 46 | from mrproto import tracker_pb2 |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 47 | |
| 48 | |
| 49 | class NotifyIssueChangeTask(notify_helpers.NotifyTaskBase): |
| 50 | """JSON servlet that notifies appropriate users after an issue change.""" |
| 51 | |
| 52 | _EMAIL_TEMPLATE = 'tracker/issue-change-notification-email.ezt' |
| 53 | _LINK_ONLY_EMAIL_TEMPLATE = ( |
| 54 | 'tracker/issue-change-notification-email-link-only.ezt') |
| 55 | |
| 56 | def HandleRequest(self, mr): |
| 57 | """Process the task to notify users after an issue change. |
| 58 | |
| 59 | Args: |
| 60 | mr: common information parsed from the HTTP request. |
| 61 | |
| 62 | Returns: |
| 63 | Results dictionary in JSON format which is useful just for debugging. |
| 64 | The main goal is the side-effect of sending emails. |
| 65 | """ |
| 66 | issue_id = mr.GetPositiveIntParam('issue_id') |
| 67 | if not issue_id: |
| 68 | return { |
| 69 | 'params': {}, |
| 70 | 'notified': [], |
| 71 | 'message': 'Cannot proceed without a valid issue ID.', |
| 72 | } |
| 73 | commenter_id = mr.GetPositiveIntParam('commenter_id') |
| 74 | seq_num = mr.seq |
| 75 | omit_ids = [commenter_id] |
| 76 | hostport = mr.GetParam('hostport') |
| 77 | try: |
| 78 | old_owner_id = mr.GetPositiveIntParam('old_owner_id') |
| 79 | except Exception: |
| 80 | old_owner_id = framework_constants.NO_USER_SPECIFIED |
| 81 | send_email = bool(mr.GetIntParam('send_email')) |
| 82 | comment_id = mr.GetPositiveIntParam('comment_id') |
| 83 | params = dict( |
| 84 | issue_id=issue_id, commenter_id=commenter_id, |
| 85 | seq_num=seq_num, hostport=hostport, old_owner_id=old_owner_id, |
| 86 | omit_ids=omit_ids, send_email=send_email, comment_id=comment_id) |
| 87 | |
| 88 | logging.info('issue change params are %r', params) |
| 89 | # TODO(jrobbins): Re-enable the issue cache for notifications after |
| 90 | # the stale issue defect (monorail:2514) is 100% resolved. |
| 91 | issue = self.services.issue.GetIssue(mr.cnxn, issue_id, use_cache=False) |
| 92 | project = self.services.project.GetProject(mr.cnxn, issue.project_id) |
| 93 | config = self.services.config.GetProjectConfig(mr.cnxn, issue.project_id) |
| 94 | |
| 95 | if issue.is_spam: |
| 96 | # Don't send email for spam issues. |
| 97 | return { |
| 98 | 'params': params, |
| 99 | 'notified': [], |
| 100 | } |
| 101 | |
| 102 | all_comments = self.services.issue.GetCommentsForIssue( |
| 103 | mr.cnxn, issue.issue_id) |
| 104 | if comment_id: |
| 105 | logging.info('Looking up comment by comment_id') |
| 106 | for c in all_comments: |
| 107 | if c.id == comment_id: |
| 108 | comment = c |
| 109 | logging.info('Comment was found by comment_id') |
| 110 | break |
| 111 | else: |
| 112 | raise ValueError('Comment %r was not found' % comment_id) |
| 113 | else: |
| 114 | logging.info('Looking up comment by seq_num') |
| 115 | comment = all_comments[seq_num] |
| 116 | |
| 117 | # Only issues that any contributor could view sent to mailing lists. |
| 118 | contributor_could_view = permissions.CanViewIssue( |
| 119 | set(), permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET, |
| 120 | project, issue) |
| 121 | starrer_ids = self.services.issue_star.LookupItemStarrers( |
| 122 | mr.cnxn, issue.issue_id) |
| 123 | users_by_id = framework_views.MakeAllUserViews( |
| 124 | mr.cnxn, self.services.user, |
| 125 | tracker_bizobj.UsersInvolvedInIssues([issue]), [old_owner_id], |
| 126 | tracker_bizobj.UsersInvolvedInComment(comment), |
| 127 | issue.cc_ids, issue.derived_cc_ids, starrer_ids, omit_ids) |
| 128 | |
| 129 | # Make followup tasks to send emails |
| 130 | tasks = [] |
| 131 | if send_email: |
| 132 | tasks = self._MakeEmailTasks( |
| 133 | mr.cnxn, project, issue, config, old_owner_id, users_by_id, |
| 134 | all_comments, comment, starrer_ids, contributor_could_view, |
| 135 | hostport, omit_ids, mr.perms) |
| 136 | |
| 137 | notified = notify_helpers.AddAllEmailTasks(tasks) |
| 138 | |
| 139 | return { |
| 140 | 'params': params, |
| 141 | 'notified': notified, |
| 142 | } |
| 143 | |
| 144 | def _MakeEmailTasks( |
| 145 | self, cnxn, project, issue, config, old_owner_id, |
| 146 | users_by_id, all_comments, comment, starrer_ids, |
| 147 | contributor_could_view, hostport, omit_ids, perms): |
| 148 | """Formulate emails to be sent.""" |
| 149 | detail_url = framework_helpers.IssueCommentURL( |
| 150 | hostport, project, issue.local_id, seq_num=comment.sequence) |
| 151 | |
| 152 | # TODO(jrobbins): avoid the need to make a MonorailRequest object. |
| 153 | mr = monorailrequest.MonorailRequest(self.services) |
| 154 | mr.project_name = project.project_name |
| 155 | mr.project = project |
| 156 | mr.perms = perms |
| 157 | |
| 158 | # We do not autolink in the emails, so just use an empty |
| 159 | # registry of autolink rules. |
| 160 | # TODO(jrobbins): offer users an HTML email option w/ autolinks. |
| 161 | autolinker = autolink.Autolink() |
| 162 | was_created = ezt.boolean(comment.sequence == 0) |
| 163 | |
| 164 | email_data = { |
| 165 | # Pass open_related and closed_related into this method and to |
| 166 | # the issue view so that we can show it on new issue email. |
| 167 | 'issue': tracker_views.IssueView(issue, users_by_id, config), |
| 168 | 'summary': issue.summary, |
| 169 | 'comment': tracker_views.IssueCommentView( |
| 170 | project.project_name, comment, users_by_id, |
| 171 | autolinker, {}, mr, issue), |
| 172 | 'comment_text': comment.content, |
| 173 | 'detail_url': detail_url, |
| 174 | 'was_created': was_created, |
| 175 | } |
| 176 | |
| 177 | # Generate three versions of email body: link-only is just the link, |
| 178 | # non-members see some obscured email addresses, and members version has |
| 179 | # all full email addresses exposed. |
| 180 | body_link_only = self.link_only_email_template.GetResponse( |
| 181 | {'detail_url': detail_url, 'was_created': was_created}) |
| 182 | body_for_non_members = self.email_template.GetResponse(email_data) |
| 183 | framework_views.RevealAllEmails(users_by_id) |
| 184 | email_data['comment'] = tracker_views.IssueCommentView( |
| 185 | project.project_name, comment, users_by_id, |
| 186 | autolinker, {}, mr, issue) |
| 187 | body_for_members = self.email_template.GetResponse(email_data) |
| 188 | |
| 189 | logging.info('link-only body is:\n%r' % body_link_only) |
| 190 | logging.info('body for non-members is:\n%r' % body_for_non_members) |
| 191 | logging.info('body for members is:\n%r' % body_for_members) |
| 192 | |
| 193 | commenter_email = users_by_id[comment.user_id].email |
| 194 | omit_addrs = set([commenter_email] + |
| 195 | [users_by_id[omit_id].email for omit_id in omit_ids]) |
| 196 | |
| 197 | auth = authdata.AuthData.FromUserID( |
| 198 | cnxn, comment.user_id, self.services) |
| 199 | commenter_in_project = framework_bizobj.UserIsInProject( |
| 200 | project, auth.effective_ids) |
| 201 | noisy = tracker_helpers.IsNoisy(len(all_comments) - 1, len(starrer_ids)) |
| 202 | |
| 203 | # Give each user a bullet-list of all the reasons that apply for that user. |
| 204 | group_reason_list = notify_reasons.ComputeGroupReasonList( |
| 205 | cnxn, self.services, project, issue, config, users_by_id, |
| 206 | omit_addrs, contributor_could_view, noisy=noisy, |
| 207 | starrer_ids=starrer_ids, old_owner_id=old_owner_id, |
| 208 | commenter_in_project=commenter_in_project) |
| 209 | |
| 210 | commenter_view = users_by_id[comment.user_id] |
| 211 | detail_url = framework_helpers.FormatAbsoluteURLForDomain( |
| 212 | hostport, issue.project_name, urls.ISSUE_DETAIL, |
| 213 | id=issue.local_id) |
| 214 | email_tasks = notify_helpers.MakeBulletedEmailWorkItems( |
| 215 | group_reason_list, issue, body_link_only, body_for_non_members, |
| 216 | body_for_members, project, hostport, commenter_view, detail_url, |
| 217 | seq_num=comment.sequence) |
| 218 | |
| 219 | return email_tasks |
| 220 | |
Adrià Vilanova Martínez | 9f9ade5 | 2022-10-10 23:20:11 +0200 | [diff] [blame] | 221 | def PostNotifyIssueChangeTask(self, **kwargs): |
| 222 | return self.handler(**kwargs) |
Adrià Vilanova Martínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 223 | |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 224 | |
| 225 | class NotifyBlockingChangeTask(notify_helpers.NotifyTaskBase): |
| 226 | """JSON servlet that notifies appropriate users after a blocking change.""" |
| 227 | |
| 228 | _EMAIL_TEMPLATE = 'tracker/issue-blocking-change-notification-email.ezt' |
| 229 | _LINK_ONLY_EMAIL_TEMPLATE = ( |
| 230 | 'tracker/issue-change-notification-email-link-only.ezt') |
| 231 | |
| 232 | def HandleRequest(self, mr): |
| 233 | """Process the task to notify users after an issue blocking change. |
| 234 | |
| 235 | Args: |
| 236 | mr: common information parsed from the HTTP request. |
| 237 | |
| 238 | Returns: |
| 239 | Results dictionary in JSON format which is useful just for debugging. |
| 240 | The main goal is the side-effect of sending emails. |
| 241 | """ |
| 242 | issue_id = mr.GetPositiveIntParam('issue_id') |
| 243 | if not issue_id: |
| 244 | return { |
| 245 | 'params': {}, |
| 246 | 'notified': [], |
| 247 | 'message': 'Cannot proceed without a valid issue ID.', |
| 248 | } |
| 249 | commenter_id = mr.GetPositiveIntParam('commenter_id') |
| 250 | omit_ids = [commenter_id] |
| 251 | hostport = mr.GetParam('hostport') |
| 252 | delta_blocker_iids = mr.GetIntListParam('delta_blocker_iids') |
| 253 | send_email = bool(mr.GetIntParam('send_email')) |
| 254 | params = dict( |
| 255 | issue_id=issue_id, commenter_id=commenter_id, |
| 256 | hostport=hostport, delta_blocker_iids=delta_blocker_iids, |
| 257 | omit_ids=omit_ids, send_email=send_email) |
| 258 | |
| 259 | logging.info('blocking change params are %r', params) |
| 260 | issue = self.services.issue.GetIssue(mr.cnxn, issue_id) |
| 261 | if issue.is_spam: |
| 262 | return { |
| 263 | 'params': params, |
| 264 | 'notified': [], |
| 265 | } |
| 266 | |
| 267 | upstream_issues = self.services.issue.GetIssues( |
| 268 | mr.cnxn, delta_blocker_iids) |
| 269 | logging.info('updating ids %r', [up.local_id for up in upstream_issues]) |
| 270 | upstream_projects = tracker_helpers.GetAllIssueProjects( |
| 271 | mr.cnxn, upstream_issues, self.services.project) |
| 272 | upstream_configs = self.services.config.GetProjectConfigs( |
| 273 | mr.cnxn, list(upstream_projects.keys())) |
| 274 | |
| 275 | users_by_id = framework_views.MakeAllUserViews( |
| 276 | mr.cnxn, self.services.user, [commenter_id]) |
| 277 | commenter_view = users_by_id[commenter_id] |
| 278 | |
| 279 | tasks = [] |
| 280 | if send_email: |
| 281 | for upstream_issue in upstream_issues: |
| 282 | one_issue_email_tasks = self._ProcessUpstreamIssue( |
| 283 | mr.cnxn, upstream_issue, |
| 284 | upstream_projects[upstream_issue.project_id], |
| 285 | upstream_configs[upstream_issue.project_id], |
| 286 | issue, omit_ids, hostport, commenter_view) |
| 287 | tasks.extend(one_issue_email_tasks) |
| 288 | |
| 289 | notified = notify_helpers.AddAllEmailTasks(tasks) |
| 290 | |
| 291 | return { |
| 292 | 'params': params, |
| 293 | 'notified': notified, |
| 294 | } |
| 295 | |
| 296 | def _ProcessUpstreamIssue( |
| 297 | self, cnxn, upstream_issue, upstream_project, upstream_config, |
| 298 | issue, omit_ids, hostport, commenter_view): |
| 299 | """Compute notifications for one upstream issue that is now blocking.""" |
| 300 | upstream_detail_url = framework_helpers.FormatAbsoluteURLForDomain( |
| 301 | hostport, upstream_issue.project_name, urls.ISSUE_DETAIL, |
| 302 | id=upstream_issue.local_id) |
| 303 | logging.info('upstream_detail_url = %r', upstream_detail_url) |
| 304 | detail_url = framework_helpers.FormatAbsoluteURLForDomain( |
| 305 | hostport, issue.project_name, urls.ISSUE_DETAIL, |
| 306 | id=issue.local_id) |
| 307 | |
| 308 | # Only issues that any contributor could view are sent to mailing lists. |
| 309 | contributor_could_view = permissions.CanViewIssue( |
| 310 | set(), permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET, |
| 311 | upstream_project, upstream_issue) |
| 312 | |
| 313 | # Now construct the e-mail to send |
| 314 | |
| 315 | # Note: we purposely do not notify users who starred an issue |
| 316 | # about changes in blocking. |
| 317 | users_by_id = framework_views.MakeAllUserViews( |
| 318 | cnxn, self.services.user, |
| 319 | tracker_bizobj.UsersInvolvedInIssues([upstream_issue]), omit_ids) |
| 320 | |
| 321 | is_blocking = upstream_issue.issue_id in issue.blocked_on_iids |
| 322 | |
| 323 | email_data = { |
| 324 | 'issue': tracker_views.IssueView( |
| 325 | upstream_issue, users_by_id, upstream_config), |
| 326 | 'summary': upstream_issue.summary, |
| 327 | 'detail_url': upstream_detail_url, |
| 328 | 'is_blocking': ezt.boolean(is_blocking), |
| 329 | 'downstream_issue_ref': tracker_bizobj.FormatIssueRef( |
| 330 | (None, issue.local_id)), |
| 331 | 'downstream_issue_url': detail_url, |
| 332 | } |
| 333 | |
| 334 | # TODO(jrobbins): Generate two versions of email body: members |
| 335 | # vesion has other member full email addresses exposed. But, don't |
| 336 | # expose too many as we iterate through upstream projects. |
| 337 | body_link_only = self.link_only_email_template.GetResponse( |
| 338 | {'detail_url': upstream_detail_url, 'was_created': ezt.boolean(False)}) |
| 339 | body = self.email_template.GetResponse(email_data) |
| 340 | |
| 341 | omit_addrs = {users_by_id[omit_id].email for omit_id in omit_ids} |
| 342 | |
| 343 | # Get the transitive set of owners and Cc'd users, and their UserView's. |
| 344 | # Give each user a bullet-list of all the reasons that apply for that user. |
| 345 | # Starrers are not notified of blocking changes to reduce noise. |
| 346 | group_reason_list = notify_reasons.ComputeGroupReasonList( |
| 347 | cnxn, self.services, upstream_project, upstream_issue, |
| 348 | upstream_config, users_by_id, omit_addrs, contributor_could_view) |
| 349 | one_issue_email_tasks = notify_helpers.MakeBulletedEmailWorkItems( |
| 350 | group_reason_list, upstream_issue, body_link_only, body, body, |
| 351 | upstream_project, hostport, commenter_view, detail_url) |
| 352 | |
| 353 | return one_issue_email_tasks |
| 354 | |
Adrià Vilanova Martínez | 9f9ade5 | 2022-10-10 23:20:11 +0200 | [diff] [blame] | 355 | def PostNotifyBlockingChangeTask(self, **kwargs): |
| 356 | return self.handler(**kwargs) |
Adrià Vilanova Martínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 357 | |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 358 | |
| 359 | class NotifyBulkChangeTask(notify_helpers.NotifyTaskBase): |
| 360 | """JSON servlet that notifies appropriate users after a bulk edit.""" |
| 361 | |
| 362 | _EMAIL_TEMPLATE = 'tracker/issue-bulk-change-notification-email.ezt' |
| 363 | |
| 364 | def HandleRequest(self, mr): |
| 365 | """Process the task to notify users after an issue blocking change. |
| 366 | |
| 367 | Args: |
| 368 | mr: common information parsed from the HTTP request. |
| 369 | |
| 370 | Returns: |
| 371 | Results dictionary in JSON format which is useful just for debugging. |
| 372 | The main goal is the side-effect of sending emails. |
| 373 | """ |
| 374 | issue_ids = mr.GetIntListParam('issue_ids') |
| 375 | hostport = mr.GetParam('hostport') |
| 376 | if not issue_ids: |
| 377 | return { |
| 378 | 'params': {}, |
| 379 | 'notified': [], |
| 380 | 'message': 'Cannot proceed without a valid issue IDs.', |
| 381 | } |
| 382 | |
| 383 | old_owner_ids = mr.GetIntListParam('old_owner_ids') |
| 384 | comment_text = mr.GetParam('comment_text') |
| 385 | commenter_id = mr.GetPositiveIntParam('commenter_id') |
| 386 | amendments = mr.GetParam('amendments') |
| 387 | send_email = bool(mr.GetIntParam('send_email')) |
| 388 | params = dict( |
| 389 | issue_ids=issue_ids, commenter_id=commenter_id, hostport=hostport, |
| 390 | old_owner_ids=old_owner_ids, comment_text=comment_text, |
| 391 | send_email=send_email, amendments=amendments) |
| 392 | |
| 393 | logging.info('bulk edit params are %r', params) |
| 394 | issues = self.services.issue.GetIssues(mr.cnxn, issue_ids) |
| 395 | # TODO(jrobbins): For cross-project bulk edits, prefetch all relevant |
| 396 | # projects and configs and pass a dict of them to subroutines. For |
| 397 | # now, all issue must be in the same project. |
| 398 | project_id = issues[0].project_id |
| 399 | project = self.services.project.GetProject(mr.cnxn, project_id) |
| 400 | config = self.services.config.GetProjectConfig(mr.cnxn, project_id) |
| 401 | issues = [issue for issue in issues if not issue.is_spam] |
| 402 | anon_perms = permissions.GetPermissions(None, set(), project) |
| 403 | |
| 404 | users_by_id = framework_views.MakeAllUserViews( |
| 405 | mr.cnxn, self.services.user, [commenter_id]) |
| 406 | ids_in_issues = {} |
| 407 | starrers = {} |
| 408 | |
| 409 | non_private_issues = [] |
| 410 | for issue, old_owner_id in zip(issues, old_owner_ids): |
| 411 | # TODO(jrobbins): use issue_id consistently rather than local_id. |
| 412 | starrers[issue.local_id] = self.services.issue_star.LookupItemStarrers( |
| 413 | mr.cnxn, issue.issue_id) |
| 414 | named_ids = set() # users named in user-value fields that notify. |
| 415 | for fd in config.field_defs: |
| 416 | named_ids.update(notify_reasons.ComputeNamedUserIDsToNotify( |
| 417 | issue.field_values, fd)) |
| 418 | direct, indirect = self.services.usergroup.ExpandAnyGroupEmailRecipients( |
| 419 | mr.cnxn, |
| 420 | list(issue.cc_ids) + list(issue.derived_cc_ids) + |
| 421 | [issue.owner_id, old_owner_id, issue.derived_owner_id] + |
| 422 | list(named_ids)) |
| 423 | ids_in_issues[issue.local_id] = set(starrers[issue.local_id]) |
| 424 | ids_in_issues[issue.local_id].update(direct) |
| 425 | ids_in_issues[issue.local_id].update(indirect) |
| 426 | ids_in_issue_needing_views = ( |
| 427 | ids_in_issues[issue.local_id] | |
| 428 | tracker_bizobj.UsersInvolvedInIssues([issue])) |
| 429 | new_ids_in_issue = [user_id for user_id in ids_in_issue_needing_views |
| 430 | if user_id not in users_by_id] |
| 431 | users_by_id.update( |
| 432 | framework_views.MakeAllUserViews( |
| 433 | mr.cnxn, self.services.user, new_ids_in_issue)) |
| 434 | |
| 435 | anon_can_view = permissions.CanViewIssue( |
| 436 | set(), anon_perms, project, issue) |
| 437 | if anon_can_view: |
| 438 | non_private_issues.append(issue) |
| 439 | |
| 440 | commenter_view = users_by_id[commenter_id] |
| 441 | omit_addrs = {commenter_view.email} |
| 442 | |
| 443 | tasks = [] |
| 444 | if send_email: |
| 445 | email_tasks = self._BulkEditEmailTasks( |
| 446 | mr.cnxn, issues, old_owner_ids, omit_addrs, project, |
| 447 | non_private_issues, users_by_id, ids_in_issues, starrers, |
| 448 | commenter_view, hostport, comment_text, amendments, config) |
| 449 | tasks = email_tasks |
| 450 | |
| 451 | notified = notify_helpers.AddAllEmailTasks(tasks) |
| 452 | return { |
| 453 | 'params': params, |
| 454 | 'notified': notified, |
| 455 | } |
| 456 | |
| 457 | def _BulkEditEmailTasks( |
| 458 | self, cnxn, issues, old_owner_ids, omit_addrs, project, |
| 459 | non_private_issues, users_by_id, ids_in_issues, starrers, |
| 460 | commenter_view, hostport, comment_text, amendments, config): |
| 461 | """Generate Email PBs to notify interested users after a bulk edit.""" |
| 462 | # 1. Get the user IDs of everyone who could be notified, |
| 463 | # and make all their user proxies. Also, build a dictionary |
| 464 | # of all the users to notify and the issues that they are |
| 465 | # interested in. Also, build a dictionary of additional email |
| 466 | # addresses to notify and the issues to notify them of. |
| 467 | users_by_id = {} |
| 468 | ids_to_notify_of_issue = {} |
| 469 | additional_addrs_to_notify_of_issue = collections.defaultdict(list) |
| 470 | |
| 471 | users_to_queries = notify_reasons.GetNonOmittedSubscriptions( |
| 472 | cnxn, self.services, [project.project_id], {}) |
| 473 | config = self.services.config.GetProjectConfig( |
| 474 | cnxn, project.project_id) |
| 475 | for issue, old_owner_id in zip(issues, old_owner_ids): |
| 476 | issue_participants = set( |
| 477 | [tracker_bizobj.GetOwnerId(issue), old_owner_id] + |
| 478 | tracker_bizobj.GetCcIds(issue)) |
| 479 | # users named in user-value fields that notify. |
| 480 | for fd in config.field_defs: |
| 481 | issue_participants.update( |
| 482 | notify_reasons.ComputeNamedUserIDsToNotify(issue.field_values, fd)) |
| 483 | for user_id in ids_in_issues[issue.local_id]: |
| 484 | # TODO(jrobbins): implement batch GetUser() for speed. |
| 485 | if not user_id: |
| 486 | continue |
| 487 | auth = authdata.AuthData.FromUserID( |
| 488 | cnxn, user_id, self.services) |
| 489 | if (auth.user_pb.notify_issue_change and |
| 490 | not auth.effective_ids.isdisjoint(issue_participants)): |
| 491 | ids_to_notify_of_issue.setdefault(user_id, []).append(issue) |
| 492 | elif (auth.user_pb.notify_starred_issue_change and |
| 493 | user_id in starrers[issue.local_id]): |
| 494 | # Skip users who have starred issues that they can no longer view. |
| 495 | starrer_perms = permissions.GetPermissions( |
| 496 | auth.user_pb, auth.effective_ids, project) |
| 497 | granted_perms = tracker_bizobj.GetGrantedPerms( |
| 498 | issue, auth.effective_ids, config) |
| 499 | starrer_can_view = permissions.CanViewIssue( |
| 500 | auth.effective_ids, starrer_perms, project, issue, |
| 501 | granted_perms=granted_perms) |
| 502 | if starrer_can_view: |
| 503 | ids_to_notify_of_issue.setdefault(user_id, []).append(issue) |
| 504 | logging.info( |
| 505 | 'ids_to_notify_of_issue[%s] = %s', |
| 506 | user_id, |
| 507 | [i.local_id for i in ids_to_notify_of_issue.get(user_id, [])]) |
| 508 | |
| 509 | # Find all subscribers that should be notified. |
| 510 | subscribers_to_consider = notify_reasons.EvaluateSubscriptions( |
| 511 | cnxn, issue, users_to_queries, self.services, config) |
| 512 | for sub_id in subscribers_to_consider: |
| 513 | auth = authdata.AuthData.FromUserID(cnxn, sub_id, self.services) |
| 514 | sub_perms = permissions.GetPermissions( |
| 515 | auth.user_pb, auth.effective_ids, project) |
| 516 | granted_perms = tracker_bizobj.GetGrantedPerms( |
| 517 | issue, auth.effective_ids, config) |
| 518 | sub_can_view = permissions.CanViewIssue( |
| 519 | auth.effective_ids, sub_perms, project, issue, |
| 520 | granted_perms=granted_perms) |
| 521 | if sub_can_view: |
| 522 | ids_to_notify_of_issue.setdefault(sub_id, []) |
| 523 | if issue not in ids_to_notify_of_issue[sub_id]: |
| 524 | ids_to_notify_of_issue[sub_id].append(issue) |
| 525 | |
| 526 | if issue in non_private_issues: |
| 527 | for notify_addr in issue.derived_notify_addrs: |
| 528 | additional_addrs_to_notify_of_issue[notify_addr].append(issue) |
| 529 | |
| 530 | # 2. Compose an email specifically for each user, and one email to each |
| 531 | # notify_addr with all the issues that it. |
| 532 | # Start from non-members first, then members to reveal email addresses. |
| 533 | email_tasks = [] |
| 534 | needed_user_view_ids = [uid for uid in ids_to_notify_of_issue |
| 535 | if uid not in users_by_id] |
| 536 | users_by_id.update(framework_views.MakeAllUserViews( |
| 537 | cnxn, self.services.user, needed_user_view_ids)) |
| 538 | member_ids_to_notify_of_issue = {} |
| 539 | non_member_ids_to_notify_of_issue = {} |
| 540 | member_additional_addrs = {} |
| 541 | non_member_additional_addrs = {} |
| 542 | addr_to_addrperm = {} # {email_address: AddrPerm object} |
| 543 | all_user_prefs = self.services.user.GetUsersPrefs( |
| 544 | cnxn, ids_to_notify_of_issue) |
| 545 | |
| 546 | # TODO(jrobbins): Merge ids_to_notify_of_issue entries for linked accounts. |
| 547 | |
| 548 | for user_id in ids_to_notify_of_issue: |
| 549 | if not user_id: |
| 550 | continue # Don't try to notify NO_USER_SPECIFIED |
| 551 | if users_by_id[user_id].email in omit_addrs: |
| 552 | logging.info('Omitting %s', user_id) |
| 553 | continue |
| 554 | user_issues = ids_to_notify_of_issue[user_id] |
| 555 | if not user_issues: |
| 556 | continue # user's prefs indicate they don't want these notifications |
| 557 | auth = authdata.AuthData.FromUserID( |
| 558 | cnxn, user_id, self.services) |
| 559 | is_member = bool(framework_bizobj.UserIsInProject( |
| 560 | project, auth.effective_ids)) |
| 561 | if is_member: |
| 562 | member_ids_to_notify_of_issue[user_id] = user_issues |
| 563 | else: |
| 564 | non_member_ids_to_notify_of_issue[user_id] = user_issues |
| 565 | addr = users_by_id[user_id].email |
| 566 | omit_addrs.add(addr) |
| 567 | addr_to_addrperm[addr] = notify_reasons.AddrPerm( |
| 568 | is_member, addr, users_by_id[user_id].user, |
| 569 | notify_reasons.REPLY_NOT_ALLOWED, all_user_prefs[user_id]) |
| 570 | |
| 571 | for addr, addr_issues in additional_addrs_to_notify_of_issue.items(): |
| 572 | auth = None |
| 573 | try: |
| 574 | auth = authdata.AuthData.FromEmail(cnxn, addr, self.services) |
| 575 | except: # pylint: disable=bare-except |
| 576 | logging.warning('Cannot find user of email %s ', addr) |
| 577 | if auth: |
| 578 | is_member = bool(framework_bizobj.UserIsInProject( |
| 579 | project, auth.effective_ids)) |
| 580 | else: |
| 581 | is_member = False |
| 582 | if is_member: |
| 583 | member_additional_addrs[addr] = addr_issues |
| 584 | else: |
| 585 | non_member_additional_addrs[addr] = addr_issues |
| 586 | omit_addrs.add(addr) |
| 587 | addr_to_addrperm[addr] = notify_reasons.AddrPerm( |
| 588 | is_member, addr, None, notify_reasons.REPLY_NOT_ALLOWED, None) |
| 589 | |
| 590 | for user_id, user_issues in non_member_ids_to_notify_of_issue.items(): |
| 591 | addr = users_by_id[user_id].email |
| 592 | email = self._FormatBulkIssuesEmail( |
| 593 | addr_to_addrperm[addr], user_issues, users_by_id, |
| 594 | commenter_view, hostport, comment_text, amendments, config, project) |
| 595 | email_tasks.append(email) |
| 596 | logging.info('about to bulk notify non-member %s (%s) of %s', |
| 597 | users_by_id[user_id].email, user_id, |
| 598 | [issue.local_id for issue in user_issues]) |
| 599 | |
| 600 | for addr, addr_issues in non_member_additional_addrs.items(): |
| 601 | email = self._FormatBulkIssuesEmail( |
| 602 | addr_to_addrperm[addr], addr_issues, users_by_id, commenter_view, |
| 603 | hostport, comment_text, amendments, config, project) |
| 604 | email_tasks.append(email) |
| 605 | logging.info('about to bulk notify non-member additional addr %s of %s', |
| 606 | addr, [addr_issue.local_id for addr_issue in addr_issues]) |
| 607 | |
| 608 | framework_views.RevealAllEmails(users_by_id) |
| 609 | commenter_view.RevealEmail() |
| 610 | |
| 611 | for user_id, user_issues in member_ids_to_notify_of_issue.items(): |
| 612 | addr = users_by_id[user_id].email |
| 613 | email = self._FormatBulkIssuesEmail( |
| 614 | addr_to_addrperm[addr], user_issues, users_by_id, |
| 615 | commenter_view, hostport, comment_text, amendments, config, project) |
| 616 | email_tasks.append(email) |
| 617 | logging.info('about to bulk notify member %s (%s) of %s', |
| 618 | addr, user_id, [issue.local_id for issue in user_issues]) |
| 619 | |
| 620 | for addr, addr_issues in member_additional_addrs.items(): |
| 621 | email = self._FormatBulkIssuesEmail( |
| 622 | addr_to_addrperm[addr], addr_issues, users_by_id, commenter_view, |
| 623 | hostport, comment_text, amendments, config, project) |
| 624 | email_tasks.append(email) |
| 625 | logging.info('about to bulk notify member additional addr %s of %s', |
| 626 | addr, [addr_issue.local_id for addr_issue in addr_issues]) |
| 627 | |
| 628 | # 4. Add in the project's issue_notify_address. This happens even if it |
| 629 | # is the same as the commenter's email address (which would be an unusual |
| 630 | # but valid project configuration). Only issues that any contributor could |
| 631 | # view are included in emails to the all-issue-activity mailing lists. |
| 632 | if (project.issue_notify_address |
| 633 | and project.issue_notify_address not in omit_addrs): |
| 634 | non_private_issues_live = [] |
| 635 | for issue in issues: |
| 636 | contributor_could_view = permissions.CanViewIssue( |
| 637 | set(), permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET, |
| 638 | project, issue) |
| 639 | if contributor_could_view: |
| 640 | non_private_issues_live.append(issue) |
| 641 | |
| 642 | if non_private_issues_live: |
| 643 | project_notify_addrperm = notify_reasons.AddrPerm( |
| 644 | True, project.issue_notify_address, None, |
| 645 | notify_reasons.REPLY_NOT_ALLOWED, None) |
| 646 | email = self._FormatBulkIssuesEmail( |
| 647 | project_notify_addrperm, non_private_issues_live, |
| 648 | users_by_id, commenter_view, hostport, comment_text, amendments, |
| 649 | config, project) |
| 650 | email_tasks.append(email) |
| 651 | omit_addrs.add(project.issue_notify_address) |
| 652 | logging.info('about to bulk notify all-issues %s of %s', |
| 653 | project.issue_notify_address, |
| 654 | [issue.local_id for issue in non_private_issues]) |
| 655 | |
| 656 | return email_tasks |
| 657 | |
| 658 | def _FormatBulkIssuesEmail( |
| 659 | self, addr_perm, issues, users_by_id, commenter_view, |
| 660 | hostport, comment_text, amendments, config, project): |
| 661 | """Format an email to one user listing many issues.""" |
| 662 | |
| 663 | from_addr = emailfmt.FormatFromAddr( |
| 664 | project, commenter_view=commenter_view, reveal_addr=addr_perm.is_member, |
| 665 | can_reply_to=False) |
| 666 | |
| 667 | subject, body = self._FormatBulkIssues( |
| 668 | issues, users_by_id, commenter_view, hostport, comment_text, |
| 669 | amendments, config, addr_perm) |
| 670 | body = notify_helpers._TruncateBody(body) |
| 671 | |
| 672 | return dict(from_addr=from_addr, to=addr_perm.address, subject=subject, |
| 673 | body=body) |
| 674 | |
| 675 | def _FormatBulkIssues( |
| 676 | self, issues, users_by_id, commenter_view, hostport, comment_text, |
| 677 | amendments, config, addr_perm): |
| 678 | """Format a subject and body for a bulk issue edit.""" |
| 679 | project_name = issues[0].project_name |
| 680 | |
| 681 | any_link_only = False |
| 682 | issue_views = [] |
| 683 | for issue in issues: |
| 684 | # TODO(jrobbins): choose config from dict of prefetched configs. |
| 685 | issue_view = tracker_views.IssueView(issue, users_by_id, config) |
| 686 | issue_view.link_only = ezt.boolean(False) |
| 687 | if addr_perm and notify_helpers.ShouldUseLinkOnly(addr_perm, issue): |
| 688 | issue_view.link_only = ezt.boolean(True) |
| 689 | any_link_only = True |
| 690 | issue_views.append(issue_view) |
| 691 | |
| 692 | email_data = { |
| 693 | 'any_link_only': ezt.boolean(any_link_only), |
| 694 | 'hostport': hostport, |
| 695 | 'num_issues': len(issues), |
| 696 | 'issues': issue_views, |
| 697 | 'comment_text': comment_text, |
| 698 | 'commenter': commenter_view, |
| 699 | 'amendments': amendments, |
| 700 | } |
| 701 | |
| 702 | if len(issues) == 1: |
| 703 | # TODO(jrobbins): use compact email subject lines based on user pref. |
| 704 | if addr_perm and notify_helpers.ShouldUseLinkOnly(addr_perm, issues[0]): |
| 705 | subject = 'issue %s in %s' % (issues[0].local_id, project_name) |
| 706 | else: |
| 707 | subject = 'issue %s in %s: %s' % ( |
| 708 | issues[0].local_id, project_name, issues[0].summary) |
| 709 | # TODO(jrobbins): Look up the sequence number instead and treat this |
| 710 | # more like an individual change for email threading. For now, just |
| 711 | # add "Re:" because bulk edits are always replies. |
| 712 | subject = 'Re: ' + subject |
| 713 | else: |
| 714 | subject = '%d issues changed in %s' % (len(issues), project_name) |
| 715 | |
| 716 | body = self.email_template.GetResponse(email_data) |
| 717 | |
| 718 | return subject, body |
| 719 | |
Adrià Vilanova Martínez | 9f9ade5 | 2022-10-10 23:20:11 +0200 | [diff] [blame] | 720 | def PostNotifyBulkChangeTask(self, **kwargs): |
| 721 | return self.handler(**kwargs) |
Adrià Vilanova Martínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 722 | |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 723 | |
| 724 | # For now, this class will not be used to send approval comment notifications |
| 725 | # TODO(jojwang): monorail:3588, it might make sense for this class to handle |
| 726 | # sending comment notifications for approval custom_subfield changes. |
| 727 | class NotifyApprovalChangeTask(notify_helpers.NotifyTaskBase): |
| 728 | """JSON servlet that notifies appropriate users after an approval change.""" |
| 729 | |
| 730 | _EMAIL_TEMPLATE = 'tracker/approval-change-notification-email.ezt' |
| 731 | |
| 732 | def HandleRequest(self, mr): |
| 733 | """Process the task to notify users after an approval change. |
| 734 | |
| 735 | Args: |
| 736 | mr: common information parsed from the HTTP request. |
| 737 | |
| 738 | Returns: |
| 739 | Results dictionary in JSON format which is useful just for debugging. |
| 740 | The main goal is the side-effect of sending emails. |
| 741 | """ |
| 742 | |
| 743 | send_email = bool(mr.GetIntParam('send_email')) |
| 744 | issue_id = mr.GetPositiveIntParam('issue_id') |
| 745 | approval_id = mr.GetPositiveIntParam('approval_id') |
| 746 | comment_id = mr.GetPositiveIntParam('comment_id') |
| 747 | hostport = mr.GetParam('hostport') |
| 748 | |
| 749 | params = dict( |
| 750 | temporary='', |
| 751 | hostport=hostport, |
| 752 | issue_id=issue_id |
| 753 | ) |
| 754 | logging.info('approval change params are %r', params) |
| 755 | |
| 756 | issue, approval_value = self.services.issue.GetIssueApproval( |
| 757 | mr.cnxn, issue_id, approval_id, use_cache=False) |
| 758 | project = self.services.project.GetProject(mr.cnxn, issue.project_id) |
| 759 | config = self.services.config.GetProjectConfig(mr.cnxn, issue.project_id) |
| 760 | |
| 761 | approval_fd = tracker_bizobj.FindFieldDefByID(approval_id, config) |
| 762 | if approval_fd is None: |
| 763 | raise exceptions.NoSuchFieldDefException() |
| 764 | |
| 765 | # GetCommentsForIssue will fill the sequence for all comments, while |
| 766 | # other method for getting a single comment will not. |
| 767 | # The comment sequence is especially useful for Approval issues with |
| 768 | # many comment sections. |
| 769 | comment = None |
| 770 | all_comments = self.services.issue.GetCommentsForIssue(mr.cnxn, issue_id) |
| 771 | for c in all_comments: |
| 772 | if c.id == comment_id: |
| 773 | comment = c |
| 774 | break |
| 775 | if not comment: |
| 776 | raise exceptions.NoSuchCommentException() |
| 777 | |
| 778 | field_user_ids = set() |
| 779 | relevant_fds = [fd for fd in config.field_defs if |
| 780 | not fd.approval_id or |
| 781 | fd.approval_id is approval_value.approval_id] |
| 782 | for fd in relevant_fds: |
| 783 | field_user_ids.update( |
| 784 | notify_reasons.ComputeNamedUserIDsToNotify(issue.field_values, fd)) |
| 785 | users_by_id = framework_views.MakeAllUserViews( |
| 786 | mr.cnxn, self.services.user, [issue.owner_id], |
| 787 | approval_value.approver_ids, |
| 788 | tracker_bizobj.UsersInvolvedInComment(comment), |
| 789 | list(field_user_ids)) |
| 790 | |
| 791 | tasks = [] |
| 792 | if send_email: |
| 793 | tasks = self._MakeApprovalEmailTasks( |
| 794 | hostport, issue, project, approval_value, approval_fd.field_name, |
| 795 | comment, users_by_id, list(field_user_ids), mr.perms) |
| 796 | |
| 797 | notified = notify_helpers.AddAllEmailTasks(tasks) |
| 798 | |
| 799 | return { |
| 800 | 'params': params, |
| 801 | 'notified': notified, |
| 802 | 'tasks': tasks, |
| 803 | } |
| 804 | |
| 805 | def _MakeApprovalEmailTasks( |
| 806 | self, hostport, issue, project, approval_value, approval_name, |
| 807 | comment, users_by_id, user_ids_from_fields, perms): |
| 808 | """Formulate emails to be sent.""" |
| 809 | |
| 810 | # TODO(jojwang): avoid need to make MonorailRequest and autolinker |
| 811 | # for comment_view OR make make tracker_views._ParseTextRuns public |
| 812 | # and only pass text_runs to email_data. |
| 813 | mr = monorailrequest.MonorailRequest(self.services) |
| 814 | mr.project_name = project.project_name |
| 815 | mr.project = project |
| 816 | mr.perms = perms |
| 817 | autolinker = autolink.Autolink() |
| 818 | |
| 819 | approval_url = framework_helpers.IssueCommentURL( |
| 820 | hostport, project, issue.local_id, seq_num=comment.sequence) |
| 821 | |
| 822 | comment_view = tracker_views.IssueCommentView( |
| 823 | project.project_name, comment, users_by_id, autolinker, {}, mr, issue) |
| 824 | domain_url = framework_helpers.FormatAbsoluteURLForDomain( |
| 825 | hostport, project.project_name, '/issues/') |
| 826 | |
| 827 | commenter_view = users_by_id[comment.user_id] |
| 828 | email_data = { |
| 829 | 'domain_url': domain_url, |
| 830 | 'approval_url': approval_url, |
| 831 | 'comment': comment_view, |
| 832 | 'issue_local_id': issue.local_id, |
| 833 | 'summary': issue.summary, |
| 834 | } |
| 835 | subject = '%s Approval: %s (Issue %s)' % ( |
| 836 | approval_name, issue.summary, issue.local_id) |
| 837 | email_body = self.email_template.GetResponse(email_data) |
| 838 | body = notify_helpers._TruncateBody(email_body) |
| 839 | |
| 840 | recipient_ids = self._GetApprovalEmailRecipients( |
| 841 | approval_value, comment, issue, user_ids_from_fields, |
| 842 | omit_ids=[comment.user_id]) |
| 843 | direct, indirect = self.services.usergroup.ExpandAnyGroupEmailRecipients( |
| 844 | mr.cnxn, recipient_ids) |
| 845 | # group ids were found in recipient_ids. |
| 846 | # Re-set recipient_ids to remove group_ids |
| 847 | if indirect: |
| 848 | recipient_ids = set(direct + indirect) |
| 849 | users_by_id.update(framework_views.MakeAllUserViews( |
| 850 | mr.cnxn, self.services.user, indirect)) # already contains direct |
| 851 | |
| 852 | # TODO(crbug.com/monorail/9104): Compute notify_reasons.AddrPerms based on |
| 853 | # project settings and recipient permissions so `reply_to` can be accurately |
| 854 | # set. |
| 855 | |
| 856 | email_tasks = [] |
| 857 | for rid in recipient_ids: |
| 858 | from_addr = emailfmt.FormatFromAddr( |
| 859 | project, commenter_view=commenter_view, can_reply_to=False) |
| 860 | dest_email = users_by_id[rid].email |
| 861 | |
| 862 | refs = emailfmt.GetReferences( |
| 863 | dest_email, subject, comment.sequence, |
| 864 | '%s@%s' % (project.project_name, emailfmt.MailDomain())) |
| 865 | reply_to = emailfmt.NoReplyAddress() |
| 866 | email_tasks.append( |
| 867 | dict( |
| 868 | from_addr=from_addr, |
| 869 | to=dest_email, |
| 870 | subject=subject, |
| 871 | body=body, |
| 872 | reply_to=reply_to, |
| 873 | references=refs)) |
| 874 | |
| 875 | return email_tasks |
| 876 | |
| 877 | def _GetApprovalEmailRecipients( |
| 878 | self, approval_value, comment, issue, user_ids_from_fields, |
| 879 | omit_ids=None): |
| 880 | # TODO(jojwang): monorail:3588, reorganize this, since now, comment_content |
| 881 | # and approval amendments happen in the same comment. |
| 882 | # NOTE: user_ids_from_fields are all the notify_on=ANY_COMMENT users. |
| 883 | # However, these users will still be excluded from notifications |
| 884 | # meant for approvers only eg. (status changed to REVIEW_REQUESTED). |
| 885 | recipient_ids = [] |
| 886 | if comment.amendments: |
| 887 | for amendment in comment.amendments: |
| 888 | if amendment.custom_field_name == 'Status': |
| 889 | if (approval_value.status is |
| 890 | tracker_pb2.ApprovalStatus.REVIEW_REQUESTED): |
| 891 | recipient_ids = approval_value.approver_ids |
| 892 | else: |
| 893 | recipient_ids.extend([issue.owner_id]) |
| 894 | recipient_ids.extend(user_ids_from_fields) |
| 895 | |
| 896 | elif amendment.custom_field_name == 'Approvers': |
| 897 | recipient_ids.extend(approval_value.approver_ids) |
| 898 | recipient_ids.append(issue.owner_id) |
| 899 | recipient_ids.extend(user_ids_from_fields) |
| 900 | recipient_ids.extend(amendment.removed_user_ids) |
| 901 | else: |
| 902 | # No amendments, just a comment. |
| 903 | recipient_ids.extend(approval_value.approver_ids) |
| 904 | recipient_ids.append(issue.owner_id) |
| 905 | recipient_ids.extend(user_ids_from_fields) |
| 906 | |
| 907 | if omit_ids: |
| 908 | recipient_ids = [rid for rid in recipient_ids if rid not in omit_ids] |
| 909 | |
| 910 | return list(set(recipient_ids)) |
| 911 | |
Adrià Vilanova Martínez | 9f9ade5 | 2022-10-10 23:20:11 +0200 | [diff] [blame] | 912 | def PostNotifyApprovalChangeTask(self, **kwargs): |
| 913 | return self.handler(**kwargs) |
Adrià Vilanova Martínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 914 | |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 915 | |
| 916 | class NotifyRulesDeletedTask(notify_helpers.NotifyTaskBase): |
| 917 | """JSON servlet that sends one email.""" |
| 918 | |
| 919 | _EMAIL_TEMPLATE = 'project/rules-deleted-notification-email.ezt' |
| 920 | |
| 921 | def HandleRequest(self, mr): |
| 922 | """Process the task to notify project owners after a filter rule |
| 923 | has been deleted. |
| 924 | |
| 925 | Args: |
| 926 | mr: common information parsed from the HTTP request. |
| 927 | |
| 928 | Returns: |
| 929 | Results dictionary in JSON format which is useful for debugging. |
| 930 | """ |
| 931 | project_id = mr.GetPositiveIntParam('project_id') |
| 932 | rules = mr.GetListParam('filter_rules') |
| 933 | hostport = mr.GetParam('hostport') |
| 934 | |
| 935 | params = dict( |
| 936 | project_id=project_id, |
| 937 | rules=rules, |
| 938 | hostport=hostport, |
| 939 | ) |
| 940 | logging.info('deleted filter rules params are %r', params) |
| 941 | |
| 942 | project = self.services.project.GetProject(mr.cnxn, project_id) |
| 943 | emails_by_id = self.services.user.LookupUserEmails( |
| 944 | mr.cnxn, project.owner_ids, ignore_missed=True) |
| 945 | |
| 946 | tasks = self._MakeRulesDeletedEmailTasks( |
| 947 | hostport, project, emails_by_id, rules) |
| 948 | notified = notify_helpers.AddAllEmailTasks(tasks) |
| 949 | |
| 950 | return { |
| 951 | 'params': params, |
| 952 | 'notified': notified, |
| 953 | 'tasks': tasks, |
| 954 | } |
| 955 | |
| 956 | def _MakeRulesDeletedEmailTasks(self, hostport, project, emails_by_id, rules): |
| 957 | |
| 958 | rules_url = framework_helpers.FormatAbsoluteURLForDomain( |
| 959 | hostport, project.project_name, urls.ADMIN_RULES) |
| 960 | |
| 961 | email_data = { |
| 962 | 'project_name': project.project_name, |
| 963 | 'rules': rules, |
| 964 | 'rules_url': rules_url, |
| 965 | } |
| 966 | logging.info(email_data) |
| 967 | subject = '%s Project: Deleted Filter Rules' % project.project_name |
| 968 | email_body = self.email_template.GetResponse(email_data) |
| 969 | body = notify_helpers._TruncateBody(email_body) |
| 970 | |
| 971 | email_tasks = [] |
| 972 | for rid in project.owner_ids: |
| 973 | from_addr = emailfmt.FormatFromAddr( |
| 974 | project, reveal_addr=True, can_reply_to=False) |
| 975 | dest_email = emails_by_id.get(rid) |
| 976 | email_tasks.append( |
| 977 | dict(from_addr=from_addr, to=dest_email, subject=subject, body=body)) |
| 978 | |
| 979 | return email_tasks |
| 980 | |
Adrià Vilanova Martínez | 9f9ade5 | 2022-10-10 23:20:11 +0200 | [diff] [blame] | 981 | def PostNotifyRulesDeletedTask(self, **kwargs): |
| 982 | return self.handler(**kwargs) |
Adrià Vilanova Martínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 983 | |
| 984 | |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame^] | 985 | class OutboundEmailTask(jsonfeed.InternalTask): |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 986 | """JSON servlet that sends one email. |
| 987 | |
| 988 | Handles tasks enqueued from notify_helpers._EnqueueOutboundEmail. |
| 989 | """ |
| 990 | |
| 991 | def HandleRequest(self, mr): |
| 992 | """Process the task to send one email message. |
| 993 | |
| 994 | Args: |
| 995 | mr: common information parsed from the HTTP request. |
| 996 | |
| 997 | Returns: |
| 998 | Results dictionary in JSON format which is useful just for debugging. |
| 999 | The main goal is the side-effect of sending emails. |
| 1000 | """ |
| 1001 | # To avoid urlencoding the email body, the most salient parameters to this |
| 1002 | # method are passed as a json-encoded POST body. |
| 1003 | try: |
Adrià Vilanova Martínez | 9f9ade5 | 2022-10-10 23:20:11 +0200 | [diff] [blame] | 1004 | email_params = json.loads(self.request.get_data(as_text=True)) |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 1005 | except ValueError: |
Adrià Vilanova Martínez | 9f9ade5 | 2022-10-10 23:20:11 +0200 | [diff] [blame] | 1006 | logging.error(self.request.get_data(as_text=True)) |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 1007 | raise |
| 1008 | # If running on a GAFYD domain, you must define an app alias on the |
| 1009 | # Application Settings admin web page. |
| 1010 | sender = email_params.get('from_addr') |
| 1011 | reply_to = email_params.get('reply_to') |
| 1012 | to = email_params.get('to') |
| 1013 | if not to: |
| 1014 | # Cannot proceed if we cannot create a valid EmailMessage. |
| 1015 | return {'note': 'Skipping because no "to" address found.'} |
| 1016 | |
| 1017 | # Don't send emails to any banned users. |
| 1018 | try: |
| 1019 | user_id = self.services.user.LookupUserID(mr.cnxn, to) |
| 1020 | user = self.services.user.GetUser(mr.cnxn, user_id) |
| 1021 | if user.banned: |
| 1022 | logging.info('Not notifying banned user %r', user.email) |
| 1023 | return {'note': 'Skipping because user is banned.'} |
| 1024 | except exceptions.NoSuchUserException: |
| 1025 | pass |
| 1026 | |
| 1027 | references = email_params.get('references') |
| 1028 | subject = email_params.get('subject') |
| 1029 | body = email_params.get('body') |
| 1030 | html_body = email_params.get('html_body') |
| 1031 | |
| 1032 | if settings.local_mode: |
| 1033 | to_format = settings.send_local_email_to |
| 1034 | else: |
| 1035 | to_format = settings.send_all_email_to |
| 1036 | |
| 1037 | if to_format: |
| 1038 | to_user, to_domain = to.split('@') |
| 1039 | to = to_format % {'user': to_user, 'domain': to_domain} |
| 1040 | |
| 1041 | logging.info( |
| 1042 | 'Email:\n sender: %s\n reply_to: %s\n to: %s\n references: %s\n ' |
| 1043 | 'subject: %s\n body: %s\n html body: %s', |
| 1044 | sender, reply_to, to, references, subject, body, html_body) |
| 1045 | if html_body: |
| 1046 | logging.info('Readable HTML:\n%s', html_body.replace('<br/>', '<br/>\n')) |
| 1047 | message = mail.EmailMessage( |
| 1048 | sender=sender, to=to, subject=subject, body=body) |
| 1049 | if html_body: |
| 1050 | message.html = html_body |
| 1051 | if reply_to: |
| 1052 | message.reply_to = reply_to |
| 1053 | if references: |
| 1054 | message.headers = {'References': references} |
| 1055 | if settings.unit_test_mode: |
| 1056 | logging.info('Sending message "%s" in test mode.', message.subject) |
| 1057 | else: |
| 1058 | retry_count = 3 |
| 1059 | for i in range(retry_count): |
| 1060 | try: |
| 1061 | message.send() |
| 1062 | break |
| 1063 | except apiproxy_errors.DeadlineExceededError as ex: |
| 1064 | logging.warning('Sending email timed out on try: %d', i) |
| 1065 | logging.warning(str(ex)) |
| 1066 | |
| 1067 | return dict( |
| 1068 | sender=sender, to=to, subject=subject, body=body, html_body=html_body, |
| 1069 | reply_to=reply_to, references=references) |
Adrià Vilanova Martínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 1070 | |
Adrià Vilanova Martínez | 9f9ade5 | 2022-10-10 23:20:11 +0200 | [diff] [blame] | 1071 | def PostOutboundEmailTask(self, **kwargs): |
| 1072 | return self.handler(**kwargs) |