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