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