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