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