# -*- 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 ' % settings.APP_ID if from_user: sender = '%s via Chromestatus ' % ( 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 ' % 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/', 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'}