251 строка
8.0 KiB
Python
251 строка
8.0 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright 2021 Google Inc.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License")
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
import flask
|
|
import json
|
|
import logging
|
|
import re
|
|
import urllib
|
|
from email import utils
|
|
|
|
from google.appengine.api import mail
|
|
|
|
import settings
|
|
from framework import cloud_tasks_helpers
|
|
|
|
|
|
# Parsing very large messages could cause out-of-memory errors.
|
|
MAX_BODY_SIZE = 20 * 1024 * 1024 # 20 MB
|
|
|
|
|
|
def require_task_header():
|
|
"""Abort if this is not a Google Cloud Tasks request."""
|
|
if settings.UNIT_TEST_MODE or settings.DEV_MODE:
|
|
return
|
|
if 'X-AppEngine-QueueName' not in flask.request.headers:
|
|
flask.abort(403, description='Lacking X-AppEngine-QueueName header')
|
|
|
|
|
|
def get_param(request, name, required=True):
|
|
"""Get the specified JSON parameter."""
|
|
json_body = request.get_json(force=True)
|
|
val = json_body.get(name)
|
|
if required and not val:
|
|
flask.abort(400, description='Missing parameter %r' % name)
|
|
return val
|
|
|
|
|
|
def format_staging_email(addr: str) -> str:
|
|
"""Format emails addresses to be redirected for staging logging."""
|
|
if not settings.SEND_ALL_EMAIL_TO:
|
|
return ''
|
|
to_user, to_domain = addr.split('@')
|
|
return settings.SEND_ALL_EMAIL_TO % {'user': to_user, 'domain': to_domain}
|
|
|
|
|
|
def handle_outbound_mail_task():
|
|
"""Task to send a notification email to one recipient."""
|
|
require_task_header()
|
|
json_body = flask.request.get_json(force=True)
|
|
logging.info('params: %r', json_body)
|
|
|
|
to = get_param(flask.request, 'to')
|
|
cc = get_param(flask.request, 'cc', required=False)
|
|
from_user = get_param(flask.request, 'from_user', required=False)
|
|
subject = get_param(flask.request, 'subject')
|
|
email_html = get_param(flask.request, 'html')
|
|
references = get_param(flask.request, 'references', required=False)
|
|
reply_to = get_param(flask.request, 'reply_to', required=False)
|
|
|
|
if isinstance(to, str):
|
|
to = [to]
|
|
if isinstance(cc, str):
|
|
cc = [cc]
|
|
|
|
if settings.SEND_ALL_EMAIL_TO and to != settings.REVIEW_COMMENT_MAILING_LIST:
|
|
to = [format_staging_email(addr) for addr in to]
|
|
|
|
if cc:
|
|
new_cc = []
|
|
for cc_addr in cc:
|
|
cc_user, cc_domain = cc_addr.split('@')
|
|
new_cc.append(
|
|
settings.CC_ALL_EMAIL_TO % {'user': cc_user, 'domain': cc_domain})
|
|
cc = new_cc
|
|
|
|
sender = 'Chromestatus <admin@%s.appspotmail.com>' % settings.APP_ID
|
|
if from_user:
|
|
sender = '%s via Chromestatus <admin+%s@%s.appspotmail.com>' % (
|
|
from_user, from_user, settings.APP_ID)
|
|
|
|
message = mail.EmailMessage(
|
|
sender=sender, to=to, subject=subject, html=email_html)
|
|
if reply_to:
|
|
message.reply_to = reply_to
|
|
message.check_initialized()
|
|
if cc:
|
|
message.cc = cc
|
|
|
|
if references:
|
|
message.headers = {
|
|
'References': references,
|
|
'In-Reply-To': references,
|
|
}
|
|
|
|
logging.info('Will send the following email:\n')
|
|
logging.info('Sender: %s', message.sender)
|
|
logging.info('To: %s', message.to)
|
|
if cc:
|
|
logging.info('Cc: %s', message.cc)
|
|
logging.info('Subject: %s', message.subject)
|
|
if reply_to:
|
|
logging.info('Reply-To: %s', message.reply_to)
|
|
logging.info('References: %s', references or '(not included)')
|
|
logging.info('In-Reply-To: %s', references or '(not included)')
|
|
logging.info('Body:\n%s', message.html[:settings.MAX_LOG_LINE])
|
|
if settings.SEND_EMAIL:
|
|
message.send()
|
|
logging.info('Email sent')
|
|
else:
|
|
logging.info('Email not sent because of settings.SEND_EMAIL')
|
|
|
|
return {'message': 'Done'}
|
|
|
|
|
|
BAD_WRAP_RE = re.compile('=\r\n')
|
|
BAD_EQ_RE = re.compile('=3D')
|
|
IS_INTERNAL_HANDLER = True
|
|
|
|
# For docs on AppEngine's bounce email handling, see:
|
|
# https://cloud.google.com/appengine/docs/python/mail/bounce
|
|
# Source code is in file:
|
|
# google_appengine/google/appengine/ext/webapp/mail_handlers.py
|
|
|
|
def handle_bounce():
|
|
"""Handler to notice when email to given user is bouncing."""
|
|
receive(mail.BounceNotification(flask.request.form))
|
|
return {'message': 'Done'}
|
|
|
|
|
|
def receive(bounce_message):
|
|
email_addr = bounce_message.original.get('to')
|
|
subject = 'Mail to %r bounced' % email_addr
|
|
logging.info(subject)
|
|
|
|
# TODO(jrobbins): Re-implement this without depending on models.
|
|
# Instead create a task and then have that processed in py3.
|
|
# pref_list = user_models.UserPref.get_prefs_for_emails([email_addr])
|
|
# user_pref = pref_list[0]
|
|
# user_pref.bounced = True
|
|
# user_pref.put()
|
|
|
|
# Escalate to someone who might do something about it, e.g.
|
|
# find a new owner for a component.
|
|
body = ('The following message bounced.\n'
|
|
'=================\n'
|
|
'From: {from}\n'
|
|
'To: {to}\n'
|
|
'Subject: {subject}\n\n'
|
|
'{text}\n'.format(**bounce_message.original))
|
|
logging.info(body)
|
|
message = mail.EmailMessage(
|
|
sender='Chromestatus <admin@%s.appspotmail.com>' % settings.APP_ID,
|
|
to=settings.BOUNCE_ESCALATION_ADDR, subject=subject, body=body)
|
|
message.check_initialized()
|
|
if settings.SEND_EMAIL:
|
|
message.send()
|
|
|
|
|
|
def _extract_addrs(header_value):
|
|
"""Given a message header value, return email address found there."""
|
|
friendly_addr_pairs = utils.getaddresses(header_value)
|
|
return [addr for _friendly, addr in friendly_addr_pairs]
|
|
|
|
|
|
def get_incoming_message():
|
|
"""Get an email message object from the request data."""
|
|
data = flask.request.get_data(as_text=True)
|
|
msg = mail.InboundEmailMessage(data).original
|
|
return msg
|
|
|
|
|
|
def handle_incoming_mail(addr=None):
|
|
"""Handle an incoming email by making a task to examine it.
|
|
|
|
This code checks some basic properties of the incoming message
|
|
to make sure that it is worth examining. Then it puts all the
|
|
relevent fields into a dict and makes a new Cloud Task which
|
|
is futher processed in python 3 code.
|
|
"""
|
|
logging.info('Request Headers: %r', flask.request.headers)
|
|
|
|
logging.info('\n\n\nPOST for InboundEmail and addr is %r', addr)
|
|
if addr != settings.INBOUND_EMAIL_ADDR:
|
|
logging.info('Message not sent directly to our address')
|
|
return {'message': 'Wrong address'}
|
|
|
|
if flask.request.content_length > MAX_BODY_SIZE:
|
|
logging.info('Message too big, ignoring')
|
|
return {'message': 'Too big'}
|
|
|
|
msg = get_incoming_message()
|
|
|
|
precedence = msg.get('precedence', '')
|
|
if precedence.lower() in ['bulk', 'junk']:
|
|
logging.info('Precedence: %r indicates an autoresponder', precedence)
|
|
return {'message': 'Wrong precedence'}
|
|
from_addrs = (_extract_addrs(msg.get_all('x-original-from', [])) or
|
|
_extract_addrs(msg.get_all('from', [])))
|
|
if from_addrs:
|
|
from_addr = from_addrs[0]
|
|
else:
|
|
logging.info('could not parse from addr')
|
|
return {'message': 'Missing From'}
|
|
in_reply_to = msg.get('in-reply-to', '')
|
|
|
|
body = u''
|
|
for part in msg.walk():
|
|
# We only process plain text emails.
|
|
if part.get_content_type() == 'text/plain':
|
|
body = part.get_payload(decode=True)
|
|
if not isinstance(body, str):
|
|
body = body.decode('utf-8')
|
|
break # Only consider the first text part.
|
|
|
|
to_addr = urllib.parse.unquote(addr)
|
|
subject = msg.get('subject', '')
|
|
task_dict = {
|
|
'to_addr': to_addr,
|
|
'from_addr': from_addr,
|
|
'subject': subject,
|
|
'in_reply_to': in_reply_to,
|
|
'body': body,
|
|
}
|
|
logging.info('task_dict is %r', task_dict)
|
|
cloud_tasks_helpers.enqueue_task(
|
|
'/tasks/detect-intent', task_dict)
|
|
|
|
return {'message': 'Done'}
|
|
|
|
|
|
def add_routes(app):
|
|
"""Add routes to the given flask app for email handlers."""
|
|
app.add_url_rule(
|
|
'/tasks/outbound-email', view_func=handle_outbound_mail_task,
|
|
methods=['POST'])
|
|
app.add_url_rule('/_ah/bounce', view_func=handle_bounce, methods=['POST'])
|
|
app.add_url_rule(
|
|
'/_ah/mail/<string:addr>', view_func=handle_incoming_mail, methods=['POST'])
|