chromium-dashboard/py2/sendemail.py

246 строки
8.1 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
import rfc822
from google.appengine.api import mail
from google.appengine.api import urlfetch
from google.appengine.ext.webapp.mail_handlers import BounceNotification
import settings
app = flask.Flask(__name__)
# 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, msg='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, msg='Missing parameter %r' % name)
return val
@app.route('/py2')
def py2_health_check():
"""Prove that this GAE module is responding."""
return {'message': 'OK py2'}
@app.route('/tasks/outbound-email', methods=['POST'])
def handle_outbound_mail_task():
"""Task to send a notification email to one recipient."""
require_task_header()
to = get_param(flask.request, 'to')
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)
if settings.SEND_ALL_EMAIL_TO and to != settings.REVIEW_COMMENT_MAILING_LIST:
to_user, to_domain = to.split('@')
to = settings.SEND_ALL_EMAIL_TO % {'user': to_user, 'domain': to_domain}
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)
message.check_initialized()
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)
logging.info('Subject: %s', message.subject)
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
@app.route('/_ah/bounce', methods=['POST'])
def handle_bounce():
"""Handler to notice when email to given user is bouncing."""
receive(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 = 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 = list(rfc822.AddressList(header_value))
return [addr for _friendly, addr in friendly_addr_pairs]
def call_py3_task_handler(handler_path, task_dict):
"""Request that our py3 code handle the rest of the work."""
handler_host = 'http://localhost:8080'
if settings.APP_ID == 'cr-status':
handler_host = 'https://cr-status.appspot.com'
if settings.APP_ID == 'cr-status-staging':
handler_host = 'https://cr-status-staging.appspot.com'
handler_url = handler_host + handler_path
request_body = json.dumps(task_dict).encode()
logging.info('task_dict is %r', task_dict)
# AppEngine automatically sets header X-Appengine-Inbound-Appid,
# and that header is stripped from external requests. So,
# require_task_header() can check for it to authenticate.
handler_response = urlfetch.fetch(
url=handler_url, payload=request_body, method=urlfetch.POST,
follow_redirects=False)
logging.info('request_response is %r:\n%r',
handler_response.status_code, handler_response.content)
return handler_response
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
@app.route('/_ah/mail/<string:addr>', methods=['POST'])
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('x-original-from', '')) or
_extract_addrs(msg.get('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, unicode):
body = body.decode('utf-8')
break # Only consider the first text part.
to_addr = urllib.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)
response = call_py3_task_handler('/tasks/detect-intent', task_dict)
if response.status_code and response.status_code != 200:
logging.warning('Handoff to py3 failed.')
flask.abort(400)
return {'message': 'Done'}