Index: appengine/monorail/features/inboundemail.py |
diff --git a/appengine/monorail/features/inboundemail.py b/appengine/monorail/features/inboundemail.py |
new file mode 100644 |
index 0000000000000000000000000000000000000000..b25b3128a1b2b752b90513eee81a6193c68eedd2 |
--- /dev/null |
+++ b/appengine/monorail/features/inboundemail.py |
@@ -0,0 +1,258 @@ |
+# Copyright 2016 The Chromium Authors. All rights reserved. |
+# Use of this source code is govered by a BSD-style |
+# license that can be found in the LICENSE file or at |
+# https://developers.google.com/open-source/licenses/bsd |
+ |
+"""Handler to process inbound email with issue comments and commands.""" |
+ |
+import logging |
+import os |
+import urllib |
+ |
+from third_party import ezt |
+ |
+from google.appengine.api import mail |
+ |
+import webapp2 |
+ |
+from features import commitlogcommands |
+from features import notify |
+from framework import emailfmt |
+from framework import framework_constants |
+from framework import monorailrequest |
+from framework import permissions |
+from framework import sql |
+from framework import template_helpers |
+from proto import project_pb2 |
+from services import issue_svc |
+from services import user_svc |
+ |
+ |
+TEMPLATE_PATH_BASE = framework_constants.TEMPLATE_PATH |
+ |
+MSG_TEMPLATES = { |
+ 'banned': 'features/inboundemail-banned.ezt', |
+ 'body_too_long': 'features/inboundemail-body-too-long.ezt', |
+ 'project_not_found': 'features/inboundemail-project-not-found.ezt', |
+ 'not_a_reply': 'features/inboundemail-not-a-reply.ezt', |
+ 'no_account': 'features/inboundemail-no-account.ezt', |
+ 'no_artifact': 'features/inboundemail-no-artifact.ezt', |
+ 'no_perms': 'features/inboundemail-no-perms.ezt', |
+ 'replies_disabled': 'features/inboundemail-replies-disabled.ezt', |
+ } |
+ |
+ |
+class InboundEmail(webapp2.RequestHandler): |
+ """Servlet to handle inbound email messages.""" |
+ |
+ def __init__(self, request, response, services=None, *args, **kwargs): |
+ super(InboundEmail, self).__init__(request, response, *args, **kwargs) |
+ self.services = services or self.app.config.get('services') |
+ self._templates = {} |
+ for name, template_path in MSG_TEMPLATES.iteritems(): |
+ self._templates[name] = template_helpers.MonorailTemplate( |
+ TEMPLATE_PATH_BASE + template_path, |
+ compress_whitespace=False, base_format=ezt.FORMAT_RAW) |
+ |
+ def get(self, project_addr=None): |
+ logging.info('\n\n\nGET for InboundEmail and project_addr is %r', |
+ project_addr) |
+ self.Handler(mail.InboundEmailMessage(self.request.body), |
+ urllib.unquote(project_addr)) |
+ |
+ def post(self, project_addr=None): |
+ logging.info('\n\n\nPOST for InboundEmail and project_addr is %r', |
+ project_addr) |
+ self.Handler(mail.InboundEmailMessage(self.request.body), |
+ urllib.unquote(project_addr)) |
+ |
+ def Handler(self, inbound_email_message, project_addr): |
+ """Process an inbound email message.""" |
+ msg = inbound_email_message.original |
+ email_tasks = self.ProcessMail(msg, project_addr) |
+ |
+ if email_tasks: |
+ notify.AddAllEmailTasks(email_tasks) |
+ |
+ def ProcessMail(self, msg, project_addr): |
+ """Process an inbound email message.""" |
+ # TODO(jrobbins): If the message is HUGE, don't even try to parse |
+ # it. Silently give up. |
+ |
+ (from_addr, to_addrs, cc_addrs, references, subject, |
+ body) = emailfmt.ParseEmailMessage(msg) |
+ |
+ logging.info('Proj addr: %r', project_addr) |
+ logging.info('From addr: %r', from_addr) |
+ logging.info('Subject: %r', subject) |
+ logging.info('To: %r', to_addrs) |
+ logging.info('Cc: %r', cc_addrs) |
+ logging.info('References: %r', references) |
+ logging.info('Body: %r', body) |
+ |
+ # If message body is very large, reject it and send an error email. |
+ if emailfmt.IsBodyTooBigToParse(body): |
+ return _MakeErrorMessageReplyTask( |
+ project_addr, from_addr, self._templates['body_too_long']) |
+ |
+ # Make sure that the project reply-to address is in the To: line. |
+ if not emailfmt.IsProjectAddressOnToLine(project_addr, to_addrs): |
+ return None |
+ |
+ # Identify the project and artifact to update. |
+ project_name, local_id = emailfmt.IdentifyProjectAndIssue( |
+ project_addr, subject) |
+ if not project_addr or not local_id: |
+ logging.info('Could not identify issue: %s %s', project_addr, subject) |
+ # No error message, because message was probably not intended for us. |
+ return None |
+ |
+ cnxn = sql.MonorailConnection() |
+ if self.services.cache_manager: |
+ self.services.cache_manager.DoDistributedInvalidation(cnxn) |
+ |
+ project = self.services.project.GetProjectByName(cnxn, project_name) |
+ |
+ if not project or project.state != project_pb2.ProjectState.LIVE: |
+ return _MakeErrorMessageReplyTask( |
+ project_addr, from_addr, self._templates['project_not_found']) |
+ |
+ if not project.process_inbound_email: |
+ return _MakeErrorMessageReplyTask( |
+ project_addr, from_addr, self._templates['replies_disabled'], |
+ project_name=project_name) |
+ |
+ # Verify that this is a reply to a notification that we could have sent. |
+ if not os.environ['SERVER_SOFTWARE'].startswith('Development'): |
+ for ref in references: |
+ if emailfmt.ValidateReferencesHeader(ref, project, from_addr, subject): |
+ break # Found a message ID that we could have sent. |
+ else: |
+ return _MakeErrorMessageReplyTask( |
+ project_addr, from_addr, self._templates['not_a_reply']) |
+ |
+ # Authenticate the from-addr and perm check. |
+ # Note: If the issue summary line is changed, a new thread is created, |
+ # and replies to the old thread will no longer work because the subject |
+ # line hash will not match, which seems reasonable. |
+ try: |
+ auth = monorailrequest.AuthData.FromEmail(cnxn, from_addr, self.services) |
+ from_user_id = auth.user_id |
+ except user_svc.NoSuchUserException: |
+ from_user_id = None |
+ if not from_user_id: |
+ return _MakeErrorMessageReplyTask( |
+ project_addr, from_addr, self._templates['no_account']) |
+ |
+ if auth.user_pb.banned: |
+ logging.info('Banned user %s tried to post to %s', |
+ from_addr, project_addr) |
+ return _MakeErrorMessageReplyTask( |
+ project_addr, from_addr, self._templates['banned']) |
+ |
+ perms = permissions.GetPermissions( |
+ auth.user_pb, auth.effective_ids, project) |
+ |
+ self.ProcessIssueReply( |
+ cnxn, project, local_id, project_addr, from_addr, from_user_id, |
+ auth.effective_ids, perms, body) |
+ |
+ return None |
+ |
+ def ProcessIssueReply( |
+ self, cnxn, project, local_id, project_addr, from_addr, from_user_id, |
+ effective_ids, perms, body): |
+ """Examine an issue reply email body and add a comment to the issue. |
+ |
+ Args: |
+ cnxn: connection to SQL database. |
+ project: Project PB for the project containing the issue. |
+ local_id: int ID of the issue being replied to. |
+ project_addr: string email address used for outbound emails from |
+ that project. |
+ from_addr: string email address of the user who sent the email |
+ reply to our server. |
+ from_user_id: int user ID of user who sent the reply email. |
+ effective_ids: set of int user IDs for the user (including any groups), |
+ or an empty set if user is not signed in. |
+ perms: PermissionSet for the user who sent the reply email. |
+ body: string email body text of the reply email. |
+ |
+ Returns: |
+ A list of follow-up work items, e.g., to notify other users of |
+ the new comment, or to notify the user that their reply was not |
+ processed. |
+ |
+ Side-effect: |
+ Adds a new comment to the issue, if no error is reported. |
+ """ |
+ try: |
+ issue = self.services.issue.GetIssueByLocalID( |
+ cnxn, project.project_id, local_id) |
+ except issue_svc.NoSuchIssueException: |
+ issue = None |
+ |
+ if not issue or issue.deleted: |
+ # The referenced issue was not found, e.g., it might have been |
+ # deleted, or someone messed with the subject line. Reject it. |
+ return _MakeErrorMessageReplyTask( |
+ project_addr, from_addr, self._templates['no_artifact'], |
+ artifact_phrase='issue %d' % local_id, |
+ project_name=project.project_name) |
+ |
+ if not perms.CanUsePerm( |
+ permissions.ADD_ISSUE_COMMENT, effective_ids, project, |
+ permissions.GetRestrictions(issue)): |
+ return _MakeErrorMessageReplyTask( |
+ project_addr, from_addr, self._templates['no_perms'], |
+ artifact_phrase='issue %d' % local_id, |
+ project_name=project.project_name) |
+ allow_edit = permissions.CanEditIssue( |
+ effective_ids, perms, project, issue) |
+ # TODO(jrobbins): if the user does not have EDIT_ISSUE and the inbound |
+ # email tries to make an edit, send back an error message. |
+ |
+ lines = body.strip().split('\n') |
+ uia = commitlogcommands.UpdateIssueAction(local_id) |
+ uia.Parse(cnxn, project.project_name, from_user_id, lines, self.services, |
+ strip_quoted_lines=True) |
+ uia.Run(cnxn, self.services, allow_edit=allow_edit) |
+ |
+ |
+def _MakeErrorMessageReplyTask( |
+ project_addr, sender_addr, template, **callers_page_data): |
+ """Return a new task to send an error message email. |
+ |
+ Args: |
+ project_addr: string email address that the inbound email was delivered to. |
+ sender_addr: string email address of user who sent the email that we could |
+ not process. |
+ template: EZT template used to generate the email error message. The |
+ first line of this generated text will be used as the subject line. |
+ callers_page_data: template data dict for body of the message. |
+ |
+ Returns: |
+ A list with a single Email task that can be enqueued to |
+ actually send the email. |
+ |
+ Raises: |
+ ValueError: if the template does begin with a "Subject:" line. |
+ """ |
+ email_data = { |
+ 'project_addr': project_addr, |
+ 'sender_addr': sender_addr |
+ } |
+ email_data.update(callers_page_data) |
+ |
+ generated_lines = template.GetResponse(email_data) |
+ subject, body = generated_lines.split('\n', 1) |
+ if subject.startswith('Subject: '): |
+ subject = subject[len('Subject: '):] |
+ else: |
+ raise ValueError('Email template does not begin with "Subject:" line.') |
+ |
+ email_task = dict(to=sender_addr, subject=subject, body=body, |
+ from_addr=emailfmt.NoReplyAddress()) |
+ logging.info('sending email error reply: %r', email_task) |
+ |
+ return [email_task] |