| 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]
|
|
|