blob: b25b3128a1b2b752b90513eee81a6193c68eedd2 [file] [log] [blame]
# 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]