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 """Handler to process inbound email with issue comments and commands.""" |
| 7 |
| 8 import logging |
| 9 import os |
| 10 import urllib |
| 11 |
| 12 from third_party import ezt |
| 13 |
| 14 from google.appengine.api import mail |
| 15 |
| 16 import webapp2 |
| 17 |
| 18 from features import commitlogcommands |
| 19 from features import notify |
| 20 from framework import emailfmt |
| 21 from framework import framework_constants |
| 22 from framework import monorailrequest |
| 23 from framework import permissions |
| 24 from framework import sql |
| 25 from framework import template_helpers |
| 26 from proto import project_pb2 |
| 27 from services import issue_svc |
| 28 from services import user_svc |
| 29 |
| 30 |
| 31 TEMPLATE_PATH_BASE = framework_constants.TEMPLATE_PATH |
| 32 |
| 33 MSG_TEMPLATES = { |
| 34 'banned': 'features/inboundemail-banned.ezt', |
| 35 'body_too_long': 'features/inboundemail-body-too-long.ezt', |
| 36 'project_not_found': 'features/inboundemail-project-not-found.ezt', |
| 37 'not_a_reply': 'features/inboundemail-not-a-reply.ezt', |
| 38 'no_account': 'features/inboundemail-no-account.ezt', |
| 39 'no_artifact': 'features/inboundemail-no-artifact.ezt', |
| 40 'no_perms': 'features/inboundemail-no-perms.ezt', |
| 41 'replies_disabled': 'features/inboundemail-replies-disabled.ezt', |
| 42 } |
| 43 |
| 44 |
| 45 class InboundEmail(webapp2.RequestHandler): |
| 46 """Servlet to handle inbound email messages.""" |
| 47 |
| 48 def __init__(self, request, response, services=None, *args, **kwargs): |
| 49 super(InboundEmail, self).__init__(request, response, *args, **kwargs) |
| 50 self.services = services or self.app.config.get('services') |
| 51 self._templates = {} |
| 52 for name, template_path in MSG_TEMPLATES.iteritems(): |
| 53 self._templates[name] = template_helpers.MonorailTemplate( |
| 54 TEMPLATE_PATH_BASE + template_path, |
| 55 compress_whitespace=False, base_format=ezt.FORMAT_RAW) |
| 56 |
| 57 def get(self, project_addr=None): |
| 58 logging.info('\n\n\nGET for InboundEmail and project_addr is %r', |
| 59 project_addr) |
| 60 self.Handler(mail.InboundEmailMessage(self.request.body), |
| 61 urllib.unquote(project_addr)) |
| 62 |
| 63 def post(self, project_addr=None): |
| 64 logging.info('\n\n\nPOST for InboundEmail and project_addr is %r', |
| 65 project_addr) |
| 66 self.Handler(mail.InboundEmailMessage(self.request.body), |
| 67 urllib.unquote(project_addr)) |
| 68 |
| 69 def Handler(self, inbound_email_message, project_addr): |
| 70 """Process an inbound email message.""" |
| 71 msg = inbound_email_message.original |
| 72 email_tasks = self.ProcessMail(msg, project_addr) |
| 73 |
| 74 if email_tasks: |
| 75 notify.AddAllEmailTasks(email_tasks) |
| 76 |
| 77 def ProcessMail(self, msg, project_addr): |
| 78 """Process an inbound email message.""" |
| 79 # TODO(jrobbins): If the message is HUGE, don't even try to parse |
| 80 # it. Silently give up. |
| 81 |
| 82 (from_addr, to_addrs, cc_addrs, references, subject, |
| 83 body) = emailfmt.ParseEmailMessage(msg) |
| 84 |
| 85 logging.info('Proj addr: %r', project_addr) |
| 86 logging.info('From addr: %r', from_addr) |
| 87 logging.info('Subject: %r', subject) |
| 88 logging.info('To: %r', to_addrs) |
| 89 logging.info('Cc: %r', cc_addrs) |
| 90 logging.info('References: %r', references) |
| 91 logging.info('Body: %r', body) |
| 92 |
| 93 # If message body is very large, reject it and send an error email. |
| 94 if emailfmt.IsBodyTooBigToParse(body): |
| 95 return _MakeErrorMessageReplyTask( |
| 96 project_addr, from_addr, self._templates['body_too_long']) |
| 97 |
| 98 # Make sure that the project reply-to address is in the To: line. |
| 99 if not emailfmt.IsProjectAddressOnToLine(project_addr, to_addrs): |
| 100 return None |
| 101 |
| 102 # Identify the project and artifact to update. |
| 103 project_name, local_id = emailfmt.IdentifyProjectAndIssue( |
| 104 project_addr, subject) |
| 105 if not project_addr or not local_id: |
| 106 logging.info('Could not identify issue: %s %s', project_addr, subject) |
| 107 # No error message, because message was probably not intended for us. |
| 108 return None |
| 109 |
| 110 cnxn = sql.MonorailConnection() |
| 111 if self.services.cache_manager: |
| 112 self.services.cache_manager.DoDistributedInvalidation(cnxn) |
| 113 |
| 114 project = self.services.project.GetProjectByName(cnxn, project_name) |
| 115 |
| 116 if not project or project.state != project_pb2.ProjectState.LIVE: |
| 117 return _MakeErrorMessageReplyTask( |
| 118 project_addr, from_addr, self._templates['project_not_found']) |
| 119 |
| 120 if not project.process_inbound_email: |
| 121 return _MakeErrorMessageReplyTask( |
| 122 project_addr, from_addr, self._templates['replies_disabled'], |
| 123 project_name=project_name) |
| 124 |
| 125 # Verify that this is a reply to a notification that we could have sent. |
| 126 if not os.environ['SERVER_SOFTWARE'].startswith('Development'): |
| 127 for ref in references: |
| 128 if emailfmt.ValidateReferencesHeader(ref, project, from_addr, subject): |
| 129 break # Found a message ID that we could have sent. |
| 130 else: |
| 131 return _MakeErrorMessageReplyTask( |
| 132 project_addr, from_addr, self._templates['not_a_reply']) |
| 133 |
| 134 # Authenticate the from-addr and perm check. |
| 135 # Note: If the issue summary line is changed, a new thread is created, |
| 136 # and replies to the old thread will no longer work because the subject |
| 137 # line hash will not match, which seems reasonable. |
| 138 try: |
| 139 auth = monorailrequest.AuthData.FromEmail(cnxn, from_addr, self.services) |
| 140 from_user_id = auth.user_id |
| 141 except user_svc.NoSuchUserException: |
| 142 from_user_id = None |
| 143 if not from_user_id: |
| 144 return _MakeErrorMessageReplyTask( |
| 145 project_addr, from_addr, self._templates['no_account']) |
| 146 |
| 147 if auth.user_pb.banned: |
| 148 logging.info('Banned user %s tried to post to %s', |
| 149 from_addr, project_addr) |
| 150 return _MakeErrorMessageReplyTask( |
| 151 project_addr, from_addr, self._templates['banned']) |
| 152 |
| 153 perms = permissions.GetPermissions( |
| 154 auth.user_pb, auth.effective_ids, project) |
| 155 |
| 156 self.ProcessIssueReply( |
| 157 cnxn, project, local_id, project_addr, from_addr, from_user_id, |
| 158 auth.effective_ids, perms, body) |
| 159 |
| 160 return None |
| 161 |
| 162 def ProcessIssueReply( |
| 163 self, cnxn, project, local_id, project_addr, from_addr, from_user_id, |
| 164 effective_ids, perms, body): |
| 165 """Examine an issue reply email body and add a comment to the issue. |
| 166 |
| 167 Args: |
| 168 cnxn: connection to SQL database. |
| 169 project: Project PB for the project containing the issue. |
| 170 local_id: int ID of the issue being replied to. |
| 171 project_addr: string email address used for outbound emails from |
| 172 that project. |
| 173 from_addr: string email address of the user who sent the email |
| 174 reply to our server. |
| 175 from_user_id: int user ID of user who sent the reply email. |
| 176 effective_ids: set of int user IDs for the user (including any groups), |
| 177 or an empty set if user is not signed in. |
| 178 perms: PermissionSet for the user who sent the reply email. |
| 179 body: string email body text of the reply email. |
| 180 |
| 181 Returns: |
| 182 A list of follow-up work items, e.g., to notify other users of |
| 183 the new comment, or to notify the user that their reply was not |
| 184 processed. |
| 185 |
| 186 Side-effect: |
| 187 Adds a new comment to the issue, if no error is reported. |
| 188 """ |
| 189 try: |
| 190 issue = self.services.issue.GetIssueByLocalID( |
| 191 cnxn, project.project_id, local_id) |
| 192 except issue_svc.NoSuchIssueException: |
| 193 issue = None |
| 194 |
| 195 if not issue or issue.deleted: |
| 196 # The referenced issue was not found, e.g., it might have been |
| 197 # deleted, or someone messed with the subject line. Reject it. |
| 198 return _MakeErrorMessageReplyTask( |
| 199 project_addr, from_addr, self._templates['no_artifact'], |
| 200 artifact_phrase='issue %d' % local_id, |
| 201 project_name=project.project_name) |
| 202 |
| 203 if not perms.CanUsePerm( |
| 204 permissions.ADD_ISSUE_COMMENT, effective_ids, project, |
| 205 permissions.GetRestrictions(issue)): |
| 206 return _MakeErrorMessageReplyTask( |
| 207 project_addr, from_addr, self._templates['no_perms'], |
| 208 artifact_phrase='issue %d' % local_id, |
| 209 project_name=project.project_name) |
| 210 allow_edit = permissions.CanEditIssue( |
| 211 effective_ids, perms, project, issue) |
| 212 # TODO(jrobbins): if the user does not have EDIT_ISSUE and the inbound |
| 213 # email tries to make an edit, send back an error message. |
| 214 |
| 215 lines = body.strip().split('\n') |
| 216 uia = commitlogcommands.UpdateIssueAction(local_id) |
| 217 uia.Parse(cnxn, project.project_name, from_user_id, lines, self.services, |
| 218 strip_quoted_lines=True) |
| 219 uia.Run(cnxn, self.services, allow_edit=allow_edit) |
| 220 |
| 221 |
| 222 def _MakeErrorMessageReplyTask( |
| 223 project_addr, sender_addr, template, **callers_page_data): |
| 224 """Return a new task to send an error message email. |
| 225 |
| 226 Args: |
| 227 project_addr: string email address that the inbound email was delivered to. |
| 228 sender_addr: string email address of user who sent the email that we could |
| 229 not process. |
| 230 template: EZT template used to generate the email error message. The |
| 231 first line of this generated text will be used as the subject line. |
| 232 callers_page_data: template data dict for body of the message. |
| 233 |
| 234 Returns: |
| 235 A list with a single Email task that can be enqueued to |
| 236 actually send the email. |
| 237 |
| 238 Raises: |
| 239 ValueError: if the template does begin with a "Subject:" line. |
| 240 """ |
| 241 email_data = { |
| 242 'project_addr': project_addr, |
| 243 'sender_addr': sender_addr |
| 244 } |
| 245 email_data.update(callers_page_data) |
| 246 |
| 247 generated_lines = template.GetResponse(email_data) |
| 248 subject, body = generated_lines.split('\n', 1) |
| 249 if subject.startswith('Subject: '): |
| 250 subject = subject[len('Subject: '):] |
| 251 else: |
| 252 raise ValueError('Email template does not begin with "Subject:" line.') |
| 253 |
| 254 email_task = dict(to=sender_addr, subject=subject, body=body, |
| 255 from_addr=emailfmt.NoReplyAddress()) |
| 256 logging.info('sending email error reply: %r', email_task) |
| 257 |
| 258 return [email_task] |
OLD | NEW |