Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 1 | # 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 | """Handler to process inbound email with issue comments and commands.""" |
| 7 | from __future__ import print_function |
| 8 | from __future__ import division |
| 9 | from __future__ import absolute_import |
| 10 | |
| 11 | import email |
| 12 | import logging |
| 13 | import os |
| 14 | import re |
| 15 | import time |
| 16 | import urllib |
| 17 | |
| 18 | import ezt |
| 19 | |
| 20 | from google.appengine.api import mail |
| 21 | from google.appengine.ext.webapp.mail_handlers import BounceNotificationHandler |
| 22 | |
| 23 | import webapp2 |
| 24 | |
| 25 | import settings |
| 26 | from businesslogic import work_env |
| 27 | from features import alert2issue |
| 28 | from features import commitlogcommands |
| 29 | from features import notify_helpers |
| 30 | from framework import authdata |
| 31 | from framework import emailfmt |
| 32 | from framework import exceptions |
| 33 | from framework import framework_constants |
| 34 | from framework import monorailcontext |
| 35 | from framework import permissions |
| 36 | from framework import sql |
| 37 | from framework import template_helpers |
| 38 | from proto import project_pb2 |
| 39 | from tracker import tracker_helpers |
| 40 | |
| 41 | |
| 42 | TEMPLATE_PATH_BASE = framework_constants.TEMPLATE_PATH |
| 43 | |
| 44 | MSG_TEMPLATES = { |
| 45 | 'banned': 'features/inboundemail-banned.ezt', |
| 46 | 'body_too_long': 'features/inboundemail-body-too-long.ezt', |
| 47 | 'project_not_found': 'features/inboundemail-project-not-found.ezt', |
| 48 | 'not_a_reply': 'features/inboundemail-not-a-reply.ezt', |
| 49 | 'no_account': 'features/inboundemail-no-account.ezt', |
| 50 | 'no_artifact': 'features/inboundemail-no-artifact.ezt', |
| 51 | 'no_perms': 'features/inboundemail-no-perms.ezt', |
| 52 | 'replies_disabled': 'features/inboundemail-replies-disabled.ezt', |
| 53 | } |
| 54 | |
| 55 | |
| 56 | class InboundEmail(webapp2.RequestHandler): |
| 57 | """Servlet to handle inbound email messages.""" |
| 58 | |
| 59 | def __init__(self, request, response, services=None, *args, **kwargs): |
| 60 | super(InboundEmail, self).__init__(request, response, *args, **kwargs) |
| 61 | self.services = services or self.app.config.get('services') |
| 62 | self._templates = {} |
| 63 | for name, template_path in MSG_TEMPLATES.items(): |
| 64 | self._templates[name] = template_helpers.MonorailTemplate( |
| 65 | TEMPLATE_PATH_BASE + template_path, |
| 66 | compress_whitespace=False, base_format=ezt.FORMAT_RAW) |
| 67 | |
| 68 | def get(self, project_addr=None): |
| 69 | logging.info('\n\n\nGET for InboundEmail and project_addr is %r', |
| 70 | project_addr) |
| 71 | self.Handler(mail.InboundEmailMessage(self.request.body), |
| 72 | urllib.unquote(project_addr)) |
| 73 | |
| 74 | def post(self, project_addr=None): |
| 75 | logging.info('\n\n\nPOST for InboundEmail and project_addr is %r', |
| 76 | project_addr) |
| 77 | self.Handler(mail.InboundEmailMessage(self.request.body), |
| 78 | urllib.unquote(project_addr)) |
| 79 | |
| 80 | def Handler(self, inbound_email_message, project_addr): |
| 81 | """Process an inbound email message.""" |
| 82 | msg = inbound_email_message.original |
| 83 | email_tasks = self.ProcessMail(msg, project_addr) |
| 84 | |
| 85 | if email_tasks: |
| 86 | notify_helpers.AddAllEmailTasks(email_tasks) |
| 87 | |
| 88 | def ProcessMail(self, msg, project_addr): |
| 89 | """Process an inbound email message.""" |
| 90 | # TODO(jrobbins): If the message is HUGE, don't even try to parse |
| 91 | # it. Silently give up. |
| 92 | |
| 93 | (from_addr, to_addrs, cc_addrs, references, incident_id, subject, |
| 94 | body) = emailfmt.ParseEmailMessage(msg) |
| 95 | |
| 96 | logging.info('Proj addr: %r', project_addr) |
| 97 | logging.info('From addr: %r', from_addr) |
| 98 | logging.info('Subject: %r', subject) |
| 99 | logging.info('To: %r', to_addrs) |
| 100 | logging.info('Cc: %r', cc_addrs) |
| 101 | logging.info('References: %r', references) |
| 102 | logging.info('Incident Id: %r', incident_id) |
| 103 | logging.info('Body: %r', body) |
| 104 | |
| 105 | # If message body is very large, reject it and send an error email. |
| 106 | if emailfmt.IsBodyTooBigToParse(body): |
| 107 | return _MakeErrorMessageReplyTask( |
| 108 | project_addr, from_addr, self._templates['body_too_long']) |
| 109 | |
| 110 | # Make sure that the project reply-to address is in the To: line. |
| 111 | if not emailfmt.IsProjectAddressOnToLine(project_addr, to_addrs): |
| 112 | return None |
| 113 | |
| 114 | project_name, verb, trooper_queue = emailfmt.IdentifyProjectVerbAndLabel( |
| 115 | project_addr) |
| 116 | |
| 117 | is_alert = bool(verb and verb.lower() == 'alert') |
| 118 | error_addr = from_addr |
| 119 | local_id = None |
| 120 | author_addr = from_addr |
| 121 | |
| 122 | if is_alert: |
| 123 | error_addr = settings.alert_escalation_email |
| 124 | author_addr = settings.alert_service_account |
| 125 | else: |
| 126 | local_id = emailfmt.IdentifyIssue(project_name, subject) |
| 127 | if not local_id: |
| 128 | logging.info('Could not identify issue: %s %s', project_addr, subject) |
| 129 | # No error message, because message was probably not intended for us. |
| 130 | return None |
| 131 | |
| 132 | cnxn = sql.MonorailConnection() |
| 133 | if self.services.cache_manager: |
| 134 | self.services.cache_manager.DoDistributedInvalidation(cnxn) |
| 135 | |
| 136 | project = self.services.project.GetProjectByName(cnxn, project_name) |
| 137 | # Authenticate the author_addr and perm check. |
| 138 | try: |
| 139 | mc = monorailcontext.MonorailContext( |
| 140 | self.services, cnxn=cnxn, requester=author_addr, autocreate=is_alert) |
| 141 | mc.LookupLoggedInUserPerms(project) |
| 142 | except exceptions.NoSuchUserException: |
| 143 | return _MakeErrorMessageReplyTask( |
| 144 | project_addr, error_addr, self._templates['no_account']) |
| 145 | |
| 146 | # TODO(zhangtiff): Add separate email templates for alert error cases. |
| 147 | if not project or project.state != project_pb2.ProjectState.LIVE: |
| 148 | return _MakeErrorMessageReplyTask( |
| 149 | project_addr, error_addr, self._templates['project_not_found']) |
| 150 | |
| 151 | if not project.process_inbound_email: |
| 152 | return _MakeErrorMessageReplyTask( |
| 153 | project_addr, error_addr, self._templates['replies_disabled'], |
| 154 | project_name=project_name) |
| 155 | |
| 156 | # Verify that this is a reply to a notification that we could have sent. |
| 157 | is_development = os.environ['SERVER_SOFTWARE'].startswith('Development') |
| 158 | if not (is_alert or is_development): |
| 159 | for ref in references: |
| 160 | if emailfmt.ValidateReferencesHeader(ref, project, from_addr, subject): |
| 161 | break # Found a message ID that we could have sent. |
| 162 | if emailfmt.ValidateReferencesHeader( |
| 163 | ref, project, from_addr.lower(), subject): |
| 164 | break # Also match all-lowercase from-address. |
| 165 | else: # for-else: if loop completes with no valid reference found. |
| 166 | return _MakeErrorMessageReplyTask( |
| 167 | project_addr, from_addr, self._templates['not_a_reply']) |
| 168 | |
| 169 | # Note: If the issue summary line is changed, a new thread is created, |
| 170 | # and replies to the old thread will no longer work because the subject |
| 171 | # line hash will not match, which seems reasonable. |
| 172 | |
| 173 | if mc.auth.user_pb.banned: |
| 174 | logging.info('Banned user %s tried to post to %s', |
| 175 | from_addr, project_addr) |
| 176 | return _MakeErrorMessageReplyTask( |
| 177 | project_addr, error_addr, self._templates['banned']) |
| 178 | |
| 179 | # If the email is an alert, switch to the alert handling path. |
| 180 | if is_alert: |
| 181 | alert2issue.ProcessEmailNotification( |
| 182 | self.services, cnxn, project, project_addr, from_addr, |
| 183 | mc.auth, subject, body, incident_id, msg, trooper_queue) |
| 184 | return None |
| 185 | |
| 186 | # This email is a response to an email about a comment. |
| 187 | self.ProcessIssueReply( |
| 188 | mc, project, local_id, project_addr, body) |
| 189 | |
| 190 | return None |
| 191 | |
| 192 | def ProcessIssueReply( |
| 193 | self, mc, project, local_id, project_addr, body): |
| 194 | """Examine an issue reply email body and add a comment to the issue. |
| 195 | |
| 196 | Args: |
| 197 | mc: MonorailContext with cnxn and the requester email, user_id, perms. |
| 198 | project: Project PB for the project containing the issue. |
| 199 | local_id: int ID of the issue being replied to. |
| 200 | project_addr: string email address used for outbound emails from |
| 201 | that project. |
| 202 | body: string email body text of the reply email. |
| 203 | |
| 204 | Returns: |
| 205 | A list of follow-up work items, e.g., to notify other users of |
| 206 | the new comment, or to notify the user that their reply was not |
| 207 | processed. |
| 208 | |
| 209 | Side-effect: |
| 210 | Adds a new comment to the issue, if no error is reported. |
| 211 | """ |
| 212 | try: |
| 213 | issue = self.services.issue.GetIssueByLocalID( |
| 214 | mc.cnxn, project.project_id, local_id) |
| 215 | except exceptions.NoSuchIssueException: |
| 216 | issue = None |
| 217 | |
| 218 | if not issue or issue.deleted: |
| 219 | # The referenced issue was not found, e.g., it might have been |
| 220 | # deleted, or someone messed with the subject line. Reject it. |
| 221 | return _MakeErrorMessageReplyTask( |
| 222 | project_addr, mc.auth.email, self._templates['no_artifact'], |
| 223 | artifact_phrase='issue %d' % local_id, |
| 224 | project_name=project.project_name) |
| 225 | |
| 226 | can_view = mc.perms.CanUsePerm( |
| 227 | permissions.VIEW, mc.auth.effective_ids, project, |
| 228 | permissions.GetRestrictions(issue)) |
| 229 | can_comment = mc.perms.CanUsePerm( |
| 230 | permissions.ADD_ISSUE_COMMENT, mc.auth.effective_ids, project, |
| 231 | permissions.GetRestrictions(issue)) |
| 232 | if not can_view or not can_comment: |
| 233 | return _MakeErrorMessageReplyTask( |
| 234 | project_addr, mc.auth.email, self._templates['no_perms'], |
| 235 | artifact_phrase='issue %d' % local_id, |
| 236 | project_name=project.project_name) |
| 237 | |
| 238 | # TODO(jrobbins): if the user does not have EDIT_ISSUE and the inbound |
| 239 | # email tries to make an edit, send back an error message. |
| 240 | |
| 241 | lines = body.strip().split('\n') |
| 242 | uia = commitlogcommands.UpdateIssueAction(local_id) |
| 243 | uia.Parse(mc.cnxn, project.project_name, mc.auth.user_id, lines, |
| 244 | self.services, strip_quoted_lines=True) |
| 245 | uia.Run(mc, self.services) |
| 246 | |
| 247 | |
| 248 | def _MakeErrorMessageReplyTask( |
| 249 | project_addr, sender_addr, template, **callers_page_data): |
| 250 | """Return a new task to send an error message email. |
| 251 | |
| 252 | Args: |
| 253 | project_addr: string email address that the inbound email was delivered to. |
| 254 | sender_addr: string email address of user who sent the email that we could |
| 255 | not process. |
| 256 | template: EZT template used to generate the email error message. The |
| 257 | first line of this generated text will be used as the subject line. |
| 258 | callers_page_data: template data dict for body of the message. |
| 259 | |
| 260 | Returns: |
| 261 | A list with a single Email task that can be enqueued to |
| 262 | actually send the email. |
| 263 | |
| 264 | Raises: |
| 265 | ValueError: if the template does begin with a "Subject:" line. |
| 266 | """ |
| 267 | email_data = { |
| 268 | 'project_addr': project_addr, |
| 269 | 'sender_addr': sender_addr |
| 270 | } |
| 271 | email_data.update(callers_page_data) |
| 272 | |
| 273 | generated_lines = template.GetResponse(email_data) |
| 274 | subject, body = generated_lines.split('\n', 1) |
| 275 | if subject.startswith('Subject: '): |
| 276 | subject = subject[len('Subject: '):] |
| 277 | else: |
| 278 | raise ValueError('Email template does not begin with "Subject:" line.') |
| 279 | |
| 280 | email_task = dict(to=sender_addr, subject=subject, body=body, |
| 281 | from_addr=emailfmt.NoReplyAddress()) |
| 282 | logging.info('sending email error reply: %r', email_task) |
| 283 | |
| 284 | return [email_task] |
| 285 | |
| 286 | |
| 287 | BAD_WRAP_RE = re.compile('=\r\n') |
| 288 | BAD_EQ_RE = re.compile('=3D') |
| 289 | |
| 290 | class BouncedEmail(BounceNotificationHandler): |
| 291 | """Handler to notice when email to given user is bouncing.""" |
| 292 | |
| 293 | # For docs on AppEngine's bounce email handling, see: |
| 294 | # https://cloud.google.com/appengine/docs/python/mail/bounce |
| 295 | # Source code is in file: |
| 296 | # google_appengine/google/appengine/ext/webapp/mail_handlers.py |
| 297 | |
| 298 | def post(self): |
| 299 | try: |
| 300 | super(BouncedEmail, self).post() |
| 301 | except AttributeError: |
| 302 | # Work-around for |
| 303 | # https://code.google.com/p/googleappengine/issues/detail?id=13512 |
| 304 | raw_message = self.request.POST.get('raw-message') |
| 305 | logging.info('raw_message %r', raw_message) |
| 306 | raw_message = BAD_WRAP_RE.sub('', raw_message) |
| 307 | raw_message = BAD_EQ_RE.sub('=', raw_message) |
| 308 | logging.info('fixed raw_message %r', raw_message) |
| 309 | mime_message = email.message_from_string(raw_message) |
| 310 | logging.info('get_payload gives %r', mime_message.get_payload()) |
| 311 | self.request.POST['raw-message'] = mime_message |
| 312 | super(BouncedEmail, self).post() # Retry with mime_message |
| 313 | |
| 314 | |
| 315 | def receive(self, bounce_message): |
| 316 | email_addr = bounce_message.original.get('to') |
| 317 | logging.info('Bounce was sent to: %r', email_addr) |
| 318 | |
| 319 | # TODO(crbug.com/monorail/8727): The problem is likely no longer happening. |
| 320 | # but we are adding permanent logging so we don't have to keep adding |
| 321 | # expriring logpoints. |
| 322 | if '@intel' in email_addr: # both intel.com and intel-partner. |
| 323 | logging.info( |
| 324 | 'bounce message: %s', bounce_message.notification.get('text')) |
| 325 | |
| 326 | app_config = webapp2.WSGIApplication.app.config |
| 327 | services = app_config['services'] |
| 328 | cnxn = sql.MonorailConnection() |
| 329 | |
| 330 | try: |
| 331 | user_id = services.user.LookupUserID(cnxn, email_addr) |
| 332 | user = services.user.GetUser(cnxn, user_id) |
| 333 | user.email_bounce_timestamp = int(time.time()) |
| 334 | services.user.UpdateUser(cnxn, user_id, user) |
| 335 | except exceptions.NoSuchUserException: |
| 336 | logging.info('User %r not found, ignoring', email_addr) |
| 337 | logging.info('Received bounce post ... [%s]', self.request) |
| 338 | logging.info('Bounce original: %s', bounce_message.original) |
| 339 | logging.info('Bounce notification: %s', bounce_message.notification) |