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