OLD | NEW |
(Empty) | |
| 1 # Copyright 2016 The Chromium Authors. All rights reserved. |
| 2 # Use of this source code is govered 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 """Helper functions for email notifications of issue changes.""" |
| 7 |
| 8 import cgi |
| 9 import logging |
| 10 import re |
| 11 |
| 12 from django.utils.html import urlize |
| 13 |
| 14 from features import filterrules_helpers |
| 15 from features import savedqueries_helpers |
| 16 from framework import emailfmt |
| 17 from framework import framework_bizobj |
| 18 from framework import framework_constants |
| 19 from framework import framework_helpers |
| 20 from framework import monorailrequest |
| 21 from framework import permissions |
| 22 from framework import urls |
| 23 from proto import tracker_pb2 |
| 24 from search import query2ast |
| 25 from search import searchpipeline |
| 26 from tracker import component_helpers |
| 27 from tracker import tracker_bizobj |
| 28 |
| 29 |
| 30 # When sending change notification emails, choose the reply-to header and |
| 31 # footer message based on three levels of the the recipient's permissions |
| 32 # for that issue. |
| 33 REPLY_NOT_ALLOWED = 'REPLY_NOT_ALLOWED' |
| 34 REPLY_MAY_COMMENT = 'REPLY_MAY_COMMENT' |
| 35 REPLY_MAY_UPDATE = 'REPLY_MAY_UPDATE' |
| 36 |
| 37 # This HTML template adds mark up which enables Gmail/Inbox to display a |
| 38 # convenient link that takes users to the CL directly from the inbox without |
| 39 # having to click on the email. |
| 40 # Documentation for this schema.org markup is here: |
| 41 # https://developers.google.com/gmail/markup/reference/go-to-action |
| 42 HTML_BODY_WITH_GMAIL_ACTION_TEMPLATE = """ |
| 43 <html> |
| 44 <body> |
| 45 <script type="application/ld+json"> |
| 46 { |
| 47 "@context": "http://schema.org", |
| 48 "@type": "EmailMessage", |
| 49 "potentialAction": { |
| 50 "@type": "ViewAction", |
| 51 "name": "View Issue", |
| 52 "url": "%s" |
| 53 }, |
| 54 "description": "" |
| 55 } |
| 56 </script> |
| 57 |
| 58 <div style="font-family: arial, sans-serif">%s</div> |
| 59 </body> |
| 60 </html> |
| 61 """ |
| 62 |
| 63 |
| 64 def ComputeIssueChangeAddressPermList( |
| 65 cnxn, ids_to_consider, project, issue, services, omit_addrs, |
| 66 users_by_id, pref_check_function=lambda u: u.notify_issue_change): |
| 67 """Return a list of user email addresses to notify of an issue change. |
| 68 |
| 69 User email addresses are determined by looking up the given user IDs |
| 70 in the given users_by_id dict. |
| 71 |
| 72 Args: |
| 73 cnxn: connection to SQL database. |
| 74 ids_to_consider: list of user IDs for users interested in this issue. |
| 75 project: Project PB for the project contianing containing this issue. |
| 76 issue: Issue PB for the issue that was updated. |
| 77 services: Services. |
| 78 omit_addrs: set of strings for email addresses to not notify because |
| 79 they already know. |
| 80 users_by_id: dict {user_id: user_view} user info. |
| 81 pref_check_function: optional function to use to check if a certain |
| 82 User PB has a preference set to receive the email being sent. It |
| 83 defaults to "If I am in the issue's owner or cc field", but it |
| 84 can be set to check "If I starred the issue." |
| 85 |
| 86 Returns: |
| 87 A list of tuples: [(recipient_is_member, address, reply_perm), ...] where |
| 88 reply_perm is one of REPLY_NOT_ALLOWED, REPLY_MAY_COMMENT, |
| 89 REPLY_MAY_UPDATE. |
| 90 """ |
| 91 memb_addr_perm_list = [] |
| 92 for user_id in ids_to_consider: |
| 93 if user_id == framework_constants.NO_USER_SPECIFIED: |
| 94 continue |
| 95 user = services.user.GetUser(cnxn, user_id) |
| 96 # Notify people who have a pref set, or if they have no User PB |
| 97 # because the pref defaults to True. |
| 98 if user and not pref_check_function(user): |
| 99 continue |
| 100 # TODO(jrobbins): doing a bulk operation would reduce DB load. |
| 101 auth = monorailrequest.AuthData.FromUserID(cnxn, user_id, services) |
| 102 perms = permissions.GetPermissions(user, auth.effective_ids, project) |
| 103 config = services.config.GetProjectConfig(cnxn, project.project_id) |
| 104 granted_perms = tracker_bizobj.GetGrantedPerms( |
| 105 issue, auth.effective_ids, config) |
| 106 |
| 107 if not permissions.CanViewIssue( |
| 108 auth.effective_ids, perms, project, issue, |
| 109 granted_perms=granted_perms): |
| 110 continue |
| 111 |
| 112 addr = users_by_id[user_id].email |
| 113 if addr in omit_addrs: |
| 114 continue |
| 115 |
| 116 recipient_is_member = bool(framework_bizobj.UserIsInProject( |
| 117 project, auth.effective_ids)) |
| 118 |
| 119 reply_perm = REPLY_NOT_ALLOWED |
| 120 if project.process_inbound_email: |
| 121 if permissions.CanEditIssue(auth.effective_ids, perms, project, issue): |
| 122 reply_perm = REPLY_MAY_UPDATE |
| 123 elif permissions.CanCommentIssue( |
| 124 auth.effective_ids, perms, project, issue): |
| 125 reply_perm = REPLY_MAY_COMMENT |
| 126 |
| 127 memb_addr_perm_list.append((recipient_is_member, addr, reply_perm)) |
| 128 |
| 129 logging.info('For %s %s, will notify: %r', |
| 130 project.project_name, issue.local_id, memb_addr_perm_list) |
| 131 |
| 132 return memb_addr_perm_list |
| 133 |
| 134 |
| 135 def ComputeProjectNotificationAddrList( |
| 136 project, contributor_could_view, omit_addrs): |
| 137 """Return a list of non-user addresses to notify of an issue change. |
| 138 |
| 139 The non-user addresses are specified by email address strings, not |
| 140 user IDs. One such address can be specified in the project PB. |
| 141 It is not assumed to have permission to see all issues. |
| 142 |
| 143 Args: |
| 144 project: Project PB containing the issue that was updated. |
| 145 contributor_could_view: True if any project contributor should be able to |
| 146 see the notification email, e.g., in a mailing list archive or feed. |
| 147 omit_addrs: set of strings for email addresses to not notify because |
| 148 they already know. |
| 149 |
| 150 Returns: |
| 151 A list of tuples: [(False, email_address, reply_permission_level), ...], |
| 152 where reply_permission_level is always REPLY_NOT_ALLOWED for now. |
| 153 """ |
| 154 memb_addr_perm_list = [] |
| 155 if contributor_could_view: |
| 156 ml_addr = project.issue_notify_address |
| 157 if ml_addr and ml_addr not in omit_addrs: |
| 158 memb_addr_perm_list.append((False, ml_addr, REPLY_NOT_ALLOWED)) |
| 159 |
| 160 return memb_addr_perm_list |
| 161 |
| 162 |
| 163 def ComputeIssueNotificationAddrList(issue, omit_addrs): |
| 164 """Return a list of non-user addresses to notify of an issue change. |
| 165 |
| 166 The non-user addresses are specified by email address strings, not |
| 167 user IDs. They can be set by filter rules with the "Also notify" action. |
| 168 "Also notify" addresses are assumed to have permission to see any issue, |
| 169 even a restricted one. |
| 170 |
| 171 Args: |
| 172 issue: Issue PB for the issue that was updated. |
| 173 omit_addrs: set of strings for email addresses to not notify because |
| 174 they already know. |
| 175 |
| 176 Returns: |
| 177 A list of tuples: [(False, email_address, reply_permission_level), ...], |
| 178 where reply_permission_level is always REPLY_NOT_ALLOWED for now. |
| 179 """ |
| 180 addr_perm_list = [] |
| 181 for addr in issue.derived_notify_addrs: |
| 182 if addr not in omit_addrs: |
| 183 addr_perm_list.append((False, addr, REPLY_NOT_ALLOWED)) |
| 184 |
| 185 return addr_perm_list |
| 186 |
| 187 |
| 188 def MakeBulletedEmailWorkItems( |
| 189 group_reason_list, subject, body_for_non_members, body_for_members, |
| 190 project, hostport, commenter_view, seq_num=None, detail_url=None): |
| 191 """Make a list of dicts describing email-sending tasks to notify users. |
| 192 |
| 193 Args: |
| 194 group_reason_list: list of (is_memb, addr_perm, reason) tuples. |
| 195 subject: string email subject line. |
| 196 body_for_non_members: string body of email to send to non-members. |
| 197 body_for_members: string body of email to send to members. |
| 198 project: Project that contains the issue. |
| 199 hostport: string hostname and port number for links to the site. |
| 200 commenter_view: UserView for the user who made the comment. |
| 201 seq_num: optional int sequence number of the comment. |
| 202 detail_url: optional str direct link to the issue. |
| 203 |
| 204 Returns: |
| 205 A list of dictionaries, each with all needed info to send an individual |
| 206 email to one user. Each email contains a footer that lists all the |
| 207 reasons why that user received the email. |
| 208 """ |
| 209 logging.info('group_reason_list is %r', group_reason_list) |
| 210 addr_reasons_dict = {} |
| 211 for group, reason in group_reason_list: |
| 212 for memb_addr_perm in group: |
| 213 addr_reasons_dict.setdefault(memb_addr_perm, []).append(reason) |
| 214 |
| 215 email_tasks = [] |
| 216 for memb_addr_perm, reasons in addr_reasons_dict.iteritems(): |
| 217 email_tasks.append(_MakeEmailWorkItem( |
| 218 memb_addr_perm, reasons, subject, body_for_non_members, |
| 219 body_for_members, project, hostport, commenter_view, seq_num=seq_num, |
| 220 detail_url=detail_url)) |
| 221 |
| 222 return email_tasks |
| 223 |
| 224 |
| 225 def _MakeEmailWorkItem( |
| 226 (recipient_is_member, to_addr, reply_perm), reasons, subject, |
| 227 body_for_non_members, body_for_members, project, hostport, commenter_view, |
| 228 seq_num=None, detail_url=None): |
| 229 """Make one email task dict for one user, includes a detailed reason.""" |
| 230 footer = _MakeNotificationFooter(reasons, reply_perm, hostport) |
| 231 if isinstance(footer, unicode): |
| 232 footer = footer.encode('utf-8') |
| 233 if recipient_is_member: |
| 234 logging.info('got member %r', to_addr) |
| 235 body = body_for_members |
| 236 else: |
| 237 logging.info('got non-member %r', to_addr) |
| 238 body = body_for_non_members |
| 239 |
| 240 logging.info('sending body + footer: %r', body + footer) |
| 241 can_reply_to = ( |
| 242 reply_perm != REPLY_NOT_ALLOWED and project.process_inbound_email) |
| 243 from_addr = emailfmt.FormatFromAddr( |
| 244 project, commenter_view=commenter_view, reveal_addr=recipient_is_member, |
| 245 can_reply_to=can_reply_to) |
| 246 if can_reply_to: |
| 247 reply_to = '%s@%s' % (project.project_name, emailfmt.MailDomain()) |
| 248 else: |
| 249 reply_to = emailfmt.NoReplyAddress() |
| 250 refs = emailfmt.GetReferences( |
| 251 to_addr, subject, seq_num, |
| 252 '%s@%s' % (project.project_name, emailfmt.MailDomain())) |
| 253 # If detail_url is specified then we can use markup to display a convenient |
| 254 # link that takes users directly to the issue without clicking on the email. |
| 255 html_body = None |
| 256 if detail_url: |
| 257 # cgi.escape the body and additionally escape single quotes which are |
| 258 # occassionally used to contain HTML attributes and event handler |
| 259 # definitions. |
| 260 html_escaped_body = cgi.escape(body + footer, quote=1).replace("'", ''') |
| 261 html_body = HTML_BODY_WITH_GMAIL_ACTION_TEMPLATE % ( |
| 262 detail_url, |
| 263 _AddHTMLTags(html_escaped_body.decode('utf-8'))) |
| 264 return dict(to=to_addr, subject=subject, body=body + footer, |
| 265 html_body=html_body, from_addr=from_addr, reply_to=reply_to, |
| 266 references=refs) |
| 267 |
| 268 |
| 269 def _AddHTMLTags(body): |
| 270 """Adds HMTL tags in the specified email body. |
| 271 |
| 272 Specifically does the following: |
| 273 * Detects links and adds <a href>s around the links. |
| 274 * Substitutes <br/> for all occurrences of "\n". |
| 275 |
| 276 See crbug.com/582463 for context. |
| 277 """ |
| 278 # Convert all URLs into clickable links. |
| 279 body = urlize(body) |
| 280 # The above step converts |
| 281 # '<link.com>' into '<<a href="link.com>">link.com></a>;' and |
| 282 # '<x@y.com>' into '<<a href="mailto:x@y.com>">x@y.com></a>;' |
| 283 # The below regex fixes this specific problem. See |
| 284 # https://bugs.chromium.org/p/monorail/issues/detail?id=1007 for more details. |
| 285 body = re.sub(r'<<a href="(|mailto:)(.*?)>">(.*?)></a>;', |
| 286 r'<a href="\1\2"><\3></a>', body) |
| 287 |
| 288 # Convert all "\n"s into "<br/>"s. |
| 289 body = body.replace("\n", "<br/>") |
| 290 return body |
| 291 |
| 292 |
| 293 def _MakeNotificationFooter(reasons, reply_perm, hostport): |
| 294 """Make an informative footer for a notification email. |
| 295 |
| 296 Args: |
| 297 reasons: a list of strings to be used as the explanation. Empty if no |
| 298 reason is to be given. |
| 299 reply_perm: string which is one of REPLY_NOT_ALLOWED, REPLY_MAY_COMMENT, |
| 300 REPLY_MAY_UPDATE. |
| 301 hostport: string with domain_name:port_number to be used in linking to |
| 302 the user preferences page. |
| 303 |
| 304 Returns: |
| 305 A string to be used as the email footer. |
| 306 """ |
| 307 if not reasons: |
| 308 return '' |
| 309 |
| 310 domain_port = hostport.split(':') |
| 311 domain_port[0] = framework_helpers.GetPreferredDomain(domain_port[0]) |
| 312 hostport = ':'.join(domain_port) |
| 313 |
| 314 prefs_url = 'https://%s%s' % (hostport, urls.USER_SETTINGS) |
| 315 lines = ['-- '] |
| 316 lines.append('You received this message because:') |
| 317 lines.extend(' %d. %s' % (idx + 1, reason) |
| 318 for idx, reason in enumerate(reasons)) |
| 319 |
| 320 lines.extend(['', 'You may adjust your notification preferences at:', |
| 321 prefs_url]) |
| 322 |
| 323 if reply_perm == REPLY_MAY_COMMENT: |
| 324 lines.extend(['', 'Reply to this email to add a comment.']) |
| 325 elif reply_perm == REPLY_MAY_UPDATE: |
| 326 lines.extend(['', 'Reply to this email to add a comment or make updates.']) |
| 327 |
| 328 return '\n'.join(lines) |
| 329 |
| 330 |
| 331 def GetNonOmittedSubscriptions(cnxn, services, project_ids, omit_addrs): |
| 332 """Get a dict of users w/ subscriptions in those projects.""" |
| 333 users_to_queries = services.features.GetSubscriptionsInProjects( |
| 334 cnxn, project_ids) |
| 335 user_emails = services.user.LookupUserEmails(cnxn, users_to_queries.keys()) |
| 336 for user_id, email in user_emails.iteritems(): |
| 337 if email in omit_addrs: |
| 338 del users_to_queries[user_id] |
| 339 |
| 340 return users_to_queries |
| 341 |
| 342 |
| 343 def EvaluateSubscriptions( |
| 344 cnxn, issue, users_to_queries, services, config): |
| 345 """Determine subscribers who have subs that match the given issue.""" |
| 346 # Note: unlike filter rule, subscriptions see explicit & derived values. |
| 347 lower_labels = [lab.lower() for lab in tracker_bizobj.GetLabels(issue)] |
| 348 label_set = set(lower_labels) |
| 349 |
| 350 subscribers_to_notify = [] |
| 351 for uid, saved_queries in users_to_queries.iteritems(): |
| 352 for sq in saved_queries: |
| 353 if sq.subscription_mode != 'immediate': |
| 354 continue |
| 355 if issue.project_id not in sq.executes_in_project_ids: |
| 356 continue |
| 357 cond = savedqueries_helpers.SavedQueryToCond(sq) |
| 358 logging.info('evaluating query %s: %r', sq.name, cond) |
| 359 cond = searchpipeline.ReplaceKeywordsWithUserID(uid, cond) |
| 360 cond_ast = query2ast.ParseUserQuery( |
| 361 cond, '', query2ast.BUILTIN_ISSUE_FIELDS, config) |
| 362 |
| 363 if filterrules_helpers.EvalPredicate( |
| 364 cnxn, services, cond_ast, issue, label_set, config, |
| 365 tracker_bizobj.GetOwnerId(issue), tracker_bizobj.GetCcIds(issue), |
| 366 tracker_bizobj.GetStatus(issue)): |
| 367 subscribers_to_notify.append(uid) |
| 368 break # Don't bother looking at the user's other saved quereies. |
| 369 |
| 370 return subscribers_to_notify |
| 371 |
| 372 |
| 373 def ComputeCustomFieldAddrPerms( |
| 374 cnxn, config, issue, project, services, omit_addrs, users_by_id): |
| 375 """Check the reasons to notify users named in custom fields.""" |
| 376 group_reason_list = [] |
| 377 for fd in config.field_defs: |
| 378 named_user_ids = ComputeNamedUserIDsToNotify(issue, fd) |
| 379 if named_user_ids: |
| 380 named_addr_perms = ComputeIssueChangeAddressPermList( |
| 381 cnxn, named_user_ids, project, issue, services, omit_addrs, |
| 382 users_by_id, pref_check_function=lambda u: True) |
| 383 group_reason_list.append( |
| 384 (named_addr_perms, 'You are named in the %s field' % fd.field_name)) |
| 385 |
| 386 return group_reason_list |
| 387 |
| 388 |
| 389 def ComputeNamedUserIDsToNotify(issue, fd): |
| 390 """Give a list of user IDs to notify because they're in a field.""" |
| 391 if (fd.field_type == tracker_pb2.FieldTypes.USER_TYPE and |
| 392 fd.notify_on == tracker_pb2.NotifyTriggers.ANY_COMMENT): |
| 393 return [fv.user_id for fv in issue.field_values |
| 394 if fv.field_id == fd.field_id] |
| 395 |
| 396 return [] |
| 397 |
| 398 |
| 399 def ComputeComponentFieldAddrPerms( |
| 400 cnxn, config, issue, project, services, omit_addrs, users_by_id): |
| 401 """Return [(addr_perm, reason), ...] for users auto-cc'd by components.""" |
| 402 component_ids = set(issue.component_ids) |
| 403 group_reason_list = [] |
| 404 for cd in config.component_defs: |
| 405 if cd.component_id in component_ids: |
| 406 cc_ids = component_helpers.GetCcIDsForComponentAndAncestors(config, cd) |
| 407 comp_addr_perms = ComputeIssueChangeAddressPermList( |
| 408 cnxn, cc_ids, project, issue, services, omit_addrs, |
| 409 users_by_id, pref_check_function=lambda u: True) |
| 410 group_reason_list.append( |
| 411 (comp_addr_perms, |
| 412 'You are auto-CC\'d on all issues in component %s' % cd.path)) |
| 413 |
| 414 return group_reason_list |
OLD | NEW |