Add build mailer capability to support gatekeeper_ng.

BUG=227883
R=iannucci@chromium.org

Review URL: https://chromiumcodereview.appspot.com/19878007

git-svn-id: svn://svn.chromium.org/chrome/trunk/tools/chromium-build@220739 0039d316-1c4b-4281-b951-d872f2087c98
diff --git a/app.yaml b/app.yaml
index 2ad63d3..d8a667d 100644
--- a/app.yaml
+++ b/app.yaml
@@ -29,6 +29,10 @@
 - url: /buildbot/.*
   script: buildlogparse.app
 
+- url: /mailer/.*
+  script: mailer.app
+  secure: always
+
 - url: /.*
   script: handler.application
 
diff --git a/app_test.py b/app_test.py
index 5aaaf4d..a24e144 100644
--- a/app_test.py
+++ b/app_test.py
@@ -5,10 +5,16 @@
 
 import ast
 import datetime
+import hashlib
+import hmac
+import json
 import os
+import random
+import time
 import unittest
 
 import app
+from third_party.BeautifulSoup.BeautifulSoup import BeautifulSoup
 
 
 TEST_DIR = os.path.join(os.path.dirname(__file__), 'tests')
@@ -473,3 +479,74 @@
         'http://build.chromium.org/p/chromium/console/../',
         page['offsite_base'])
     self.assertEquals('BuildBot: Chromium', page['title'])
+
+
+class MailTestCase(GaeTestCase):
+  def setUp(self):
+    self.test_dir = os.path.join(TEST_DIR, 'test_mailer')
+    with open(os.path.join(self.test_dir, 'input.json')) as f:
+      self.input_json = json.load(f)
+    self.build_data = json.loads(self.input_json['message'])
+
+  @staticmethod
+  def _hash_message(mytime, message, url, secret):
+    salt = random.getrandbits(32)
+    hasher = hmac.new(secret, message, hashlib.sha256)
+    hasher.update(str(mytime))
+    hasher.update(str(salt))
+    client_hash = hasher.hexdigest()
+
+    return {'message': message,
+            'time': mytime,
+            'salt': salt,
+            'url': url,
+            'hmac-sha256': client_hash,
+           }
+
+  def test_html_format(self):
+    import gatekeeper_mailer
+    template = gatekeeper_mailer.MailTemplate(self.build_data['waterfall_url'],
+                                              self.build_data['build_url'],
+                                              self.build_data['project_name'],
+                                              'test@chromium.org')
+
+    _, html_content, _ = template.genMessageContent(self.build_data)
+
+    with open(os.path.join(self.test_dir, 'expected.html')) as f:
+      expected_html = ' '.join(f.read().splitlines())
+
+    saw = str(BeautifulSoup(html_content)).split()
+    expected = str(BeautifulSoup(expected_html)).split()
+
+    self.assertEqual(saw, expected)
+
+  def test_hmac_validation(self):
+    from mailer import Email
+    message = self.input_json['message']
+    url = 'http://invalid.chromium.org'
+    secret = 'pajamas'
+
+    test_json = self._hash_message(time.time(), message, url, secret)
+    # pylint: disable=W0212
+    self.assertTrue(Email._validate_message(test_json, url, secret))
+
+    # Test that a trailing slash doesn't affect URL parsing.
+    test_json = self._hash_message(time.time(), message, url + '/', secret)
+    # pylint: disable=W0212
+    self.assertTrue(Email._validate_message(test_json, url, secret))
+
+    tests = [
+        self._hash_message(time.time() + 61, message, url, secret),
+        self._hash_message(time.time() - 61, message, url, secret),
+        self._hash_message(time.time(), message, url + 'hey', secret),
+        self._hash_message(time.time(), message, url, secret + 'hey'),
+    ]
+
+    for test_json in tests:
+      # pylint: disable=W0212
+      self.assertFalse(Email._validate_message(test_json, url, secret))
+
+    test_json = self._hash_message(time.time(), message, url, secret)
+    test_json['message'] = test_json['message'] + 'hey'
+    # pylint: disable=W0212
+    self.assertFalse(Email._validate_message(test_json, url, secret))
diff --git a/gatekeeper_mailer.py b/gatekeeper_mailer.py
new file mode 100644
index 0000000..d815346
--- /dev/null
+++ b/gatekeeper_mailer.py
@@ -0,0 +1,94 @@
+#!/usr/bin/env python
+# Copyright (c) 2013 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""Provides mailer templates for gatekeeper_ng.
+
+This module populates jinja  mail templates to notify tree watchers when the
+tree is closed.
+"""
+
+import jinja2
+import os
+import utils
+
+jinja_environment = jinja2.Environment(
+    loader=jinja2.FileSystemLoader(
+        os.path.join(os.path.dirname(__file__), 'templates')),
+    autoescape=True)
+jinja_environment.filters['urlquote'] = utils.urlquote
+
+# From buildbot's results.py.
+SUCCESS, WARNINGS, FAILURE, SKIPPED, EXCEPTION, RETRY = range(6)
+Results = ["success", "warnings", "failure", "skipped", "exception", "retry"]
+
+
+class MailTemplate(object):
+  """Encapsulates a buildbot status email."""
+
+  status_header = 'Automatically closing tree for "%(steps)s" on "%(builder)s"'
+
+  def __init__(self, waterfall_url, build_url,
+               project_name,
+               fromaddr,
+               reply_to=None,
+               subject='buildbot %(result)s in %(projectName)s on %(builder)s, '
+                       'revision %(revision)s'):
+
+    self.reply_to = reply_to
+    self.fromaddr = fromaddr
+    self.subject = subject
+    self.waterfall_url = waterfall_url.rstrip('/') + '/'
+    self.build_url = build_url
+    self.project_name = project_name
+
+  def genMessageContent(self, build_status):
+    builder_name = build_status['builderName']
+    us_steps = ','.join(build_status['unsatisfied'])
+    revisions_list = build_status['revisions']
+    status_text = self.status_header % {
+        'builder': builder_name,
+        'steps': us_steps,
+    }
+
+    # Use the first line as a title.
+    status_title = status_text.split('\n', 1)[0]
+    blame_list = ','.join(build_status['blamelist'])
+    revisions_string = ''
+    latest_revision = 0
+    if revisions_list:
+      revisions_string = ', '.join([str(rev) for rev in revisions_list])
+      latest_revision = max([rev for rev in revisions_list])
+    if build_status['result'] == FAILURE:
+      result = 'failure'
+    else:
+      result = 'warning'
+
+    context = {
+        'status_title': status_title,
+        'waterfall_url': self.waterfall_url,
+        'status': status_text.replace('\n', "<br>\n"),
+        'build_url': self.build_url,
+        'revisions': revisions_string,
+        'blame_list': blame_list,
+        'build_status': build_status,
+        'builder_name': builder_name,
+    }
+
+    html_content = jinja_environment.get_template('waterfall_mail.html').render(
+        context)
+
+    text_content = jinja_environment.get_template('mail_text.txt').render(
+        context)
+
+    subject = self.subject % {
+        'result': result,
+        'projectName': self.project_name,
+        'builder': builder_name,
+        'reason': build_status['reason'],
+        'revision': str(latest_revision),
+        'buildnumber': str(build_status['number']),
+    }
+
+    return text_content, html_content, subject
diff --git a/mailer.py b/mailer.py
new file mode 100644
index 0000000..5ff2dc6
--- /dev/null
+++ b/mailer.py
@@ -0,0 +1,195 @@
+import hashlib
+import hmac
+import json
+import logging
+import time
+
+from google.appengine.api import app_identity
+from google.appengine.api import mail
+from google.appengine.ext import ndb
+import webapp2
+from webapp2_extras import jinja2
+
+import gatekeeper_mailer
+
+
+class MailerSecret(ndb.Model):
+  """Model to represent the shared secret for the mail endpoint."""
+  secret = ndb.StringProperty()
+
+
+class BaseHandler(webapp2.RequestHandler):
+  """Provide a cached Jinja environment to each request."""
+  @webapp2.cached_property
+  def jinja2(self):
+    # Returns a Jinja2 renderer cached in the app registry.
+    return jinja2.get_jinja2(app=self.app)
+
+  def render_response(self, _template, **context):
+    # Renders a template and writes the result to the response.
+    rv = self.jinja2.render_template(_template, **context)
+    self.response.write(rv)
+
+
+class MainPage(BaseHandler):
+  def get(self):
+    context = {'title': 'Chromium Gatekeeper Mailer'}
+    self.render_response('main_mailer.html', **context)
+
+
+class Email(BaseHandler):
+  @staticmethod
+  def linear_compare(a, b):
+    """Scan through the entire string even if a mismatch is detected early.
+
+    This thwarts timing attacks attempting to guess the key one byte at a
+    time.
+    """
+    if len(a) != len(b):
+      return False
+    result = 0
+    for x, y in zip(a, b):
+      result |= ord(x) ^ ord(y)
+    return result == 0
+
+  @staticmethod
+  def _validate_message(message, url, secret):
+    """Cryptographically validates the message."""
+    mytime = time.time()
+
+    if abs(mytime - message['time']) > 60:
+      logging.error('message was rejected due to time')
+      return False
+
+    cleaned_url = url.rstrip('/') + '/'
+    cleaned_message_url = message['url'].rstrip('/') + '/'
+
+    if cleaned_message_url != cleaned_url:
+      logging.error('message URL did not match: %s vs %s', cleaned_message_url,
+          cleaned_url)
+      return False
+
+    hasher = hmac.new(str(secret), message['message'], hashlib.sha256)
+    hasher.update(str(message['time']))
+    hasher.update(str(message['salt']))
+
+    client_hash = hasher.hexdigest()
+
+    return Email.linear_compare(client_hash, message['hmac-sha256'])
+
+  @staticmethod
+  def _verify_json(build_data):
+    """Verifies that the submitted JSON contains all the proper fields."""
+    fields = ['waterfall_url',
+              'build_url',
+              'project_name',
+              'builderName',
+              'steps',
+              'unsatisfied',
+              'revisions',
+              'blamelist',
+              'result',
+              'number',
+              'changes',
+              'reason',
+              'recipients']
+
+    for field in fields:
+      if field not in build_data:
+        logging.error('build_data did not contain field %s' % field)
+        return False
+
+    step_fields = ['started',
+                   'text',
+                   'results',
+                   'name',
+                   'logs',
+                   'urls']
+
+    if not build_data['steps']:
+      logging.error('build_data did not contain any steps')
+      return False
+    for step in build_data['steps']:
+      for field in step_fields:
+        if field not in step:
+          logging.error('build_step did not contain field %s' % field)
+          return False
+
+    return True
+
+  def post(self):
+    blob = self.request.get('json')
+    if not blob:
+      self.response.out.write('no json data sent')
+      logging.error('error no json sent')
+      self.error(400)
+      return
+
+    message = {}
+    try:
+      message = json.loads(blob)
+    except ValueError as e:
+      self.response.out.write('couldn\'t decode json')
+      logging.error('error decoding incoming json: %s' % e)
+      self.error(400)
+      return
+
+    secret = MailerSecret.get_or_insert('mailer_secret').secret
+    if not secret:
+      self.response.out.write('unauthorized')
+      logging.critical('mailer shared secret has not been set!')
+      self.error(500)
+      return
+
+    if not self._validate_message(message, self.request.url, secret):
+      self.response.out.write('unauthorized')
+      logging.error('incoming message did not validate')
+      self.error(403)
+      return
+
+    try:
+      build_data = json.loads(message['message'])
+    except ValueError as e:
+      self.response.out.write('couldn\'t decode payload json')
+      logging.error('error decoding incoming json: %s' % e)
+      self.error(400)
+      return
+
+    if not self._verify_json(build_data):
+      logging.error('error verifying incoming json: %s' % build_data)
+      self.response.out.write('json build format is incorrect')
+      self.error(400)
+      return
+
+    # Emails can only come from the app ID, so we split on '@' here just in
+    # case the user specified a full email address.
+    from_addr_prefix = build_data.get('from_addr', 'buildbot').split('@')[0]
+    from_addr = from_addr_prefix + '@%s.appspotmail.com' % (
+        app_identity.get_application_id())
+
+    recipients = ', '.join(build_data['recipients'])
+
+    template = gatekeeper_mailer.MailTemplate(build_data['waterfall_url'],
+                                              build_data['build_url'],
+                                              build_data['project_name'],
+                                              from_addr)
+
+
+    text_content, html_content, subject = template.genMessageContent(build_data)
+
+    message = mail.EmailMessage(sender=from_addr,
+                                subject=subject,
+                                #to=recipients,
+                                to=['xusydoc@chromium.org'],
+                                body=text_content,
+                                html=html_content)
+    logging.info('sending email to %s', recipients)
+    logging.info('sending from %s', from_addr)
+    logging.info('subject is %s', subject)
+    message.send()
+    self.response.out.write('email sent')
+
+
+app = webapp2.WSGIApplication([('/mailer', MainPage),
+                               ('/mailer/email', Email)],
+                              debug=True)
diff --git a/templates/base_mail.html b/templates/base_mail.html
new file mode 100644
index 0000000..e5d373f
--- /dev/null
+++ b/templates/base_mail.html
@@ -0,0 +1,18 @@
+<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
+<html xmlns="http://www.w3.org/1999/xhtml">
+  <head>
+    <title>{{status_title}}</title>
+  </head>
+  <body>
+    <a href="{{waterfall_url}}">{{waterfall_url}}</a><p>
+    {{status}}</p>
+    <a href="{{build_url}}">{{build_url}}</a><p>
+    Revision: {{revisions}}<br>
+    Blame list: {{blame_list}}</p>
+    <table style="border-spacing: 1px 1px; font-weight: bold; padding: 3px 0px 3px 0px; text-align: center;">
+      {% block buildbox %}{% endblock %}</table>
+    <p>
+    {% block changes %}{%endblock %}
+    </p>
+  </body>
+</html>
diff --git a/templates/base_mailer.html b/templates/base_mailer.html
new file mode 100644
index 0000000..de3aac7
--- /dev/null
+++ b/templates/base_mailer.html
@@ -0,0 +1,27 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "DTD/xhtml1-transitional.dtd">
+<html>
+  <head>
+    <meta http-equiv="content-type" content="text/html; charset=utf-8"/>
+    <title>{{ title }}</title>
+    <link href="/style_mailer.css" type="text/css" rel="stylesheet"/>
+    {% block extra_head %}{% endblock %}
+  </head>
+  <body>
+    <center>
+      <table width="100%%">
+        <tr>
+          <td align="left">
+            <a href="/">
+              <img border="0" height="75" src="/static/logo.png"/>
+            </a>
+          </td>
+          <td valign="top" align="right">
+            <br/><p>Chromium Gatekeeper Mailer</p>
+          </td>
+        </tr>
+      </table>
+    </center>
+    <br/> <br/> <br/> <br/>
+    {% block content %}{% endblock %}
+  </body>
+</html>
diff --git a/templates/mail_text.txt b/templates/mail_text.txt
new file mode 100644
index 0000000..6cec9f4
--- /dev/null
+++ b/templates/mail_text.txt
@@ -0,0 +1,13 @@
+{{status_title}}
+
+{{build_url}}
+
+{{waterfall_url|urlquote(':/')}}waterfall?builder={{builder_name|urlquote}}
+
+--=>  {{status_text}}  <=--
+
+Revision: {{revisions}}
+Blame list: {{blame_list}}
+
+Buildbot waterfall: http://build.chromium.org/
+
diff --git a/templates/main_mailer.html b/templates/main_mailer.html
new file mode 100644
index 0000000..2761ee9
--- /dev/null
+++ b/templates/main_mailer.html
@@ -0,0 +1,7 @@
+{% extends "base_mailer.html" %}
+
+{% block content %}
+  <center>
+    <p>This is a mail relay for the Chromium waterfall gatekeeper.</p>
+  </center>
+{% endblock %}
diff --git a/templates/waterfall_mail.html b/templates/waterfall_mail.html
new file mode 100644
index 0000000..c951b3c
--- /dev/null
+++ b/templates/waterfall_mail.html
@@ -0,0 +1,87 @@
+{% extends "base_mail.html" %}
+
+{% block buildbox%}
+
+{% set results = ["success", "warnings", "failure", "skipped", "exception",
+                      "retry"] %}
+{% set styles = {
+    'exception': ('color: #FFFFFF; background-color: #e0b0ff; '
+                  'border-color: #ACA0B3;'),
+    'failure': ('color: #FFFFFF; background-color: #e98080; '
+                'border-color: #A77272;'),
+    'retry': ('color: #FFFFFF; background-color: #e0b0ff; '
+                  'border-color: #ACA0B3;'),
+    'offline': ('color: #FFFFFF; background-color: #e0b0ff; '
+                'border-color: #ACA0B3;'),
+    'skipped': '',
+    'start': ('color: #666666; background-color: #fffc6c;'
+              'border-color: #C5C56D;'),
+    'success': ('color: #FFFFFF; background-color: #8fdf5f; '
+                'border-color: #4F8530;'),
+    'warnings': ('color: #FFFFFF; background-color: #ffc343; '
+                 'border-color: #C29D46;'),
+   }
+%}
+
+{% set buildername = build_status['builderName'] %}
+<tr><td style="{{styles[results[(build_status['result'])]]}}"><a title="Reason: {{build_status['reason']}}" href="{{waterfall_url}}builders/{{buildername|urlquote}}/builds/{{build_status['number']}}">Build {{build_status['number']}}</a></td></tr>
+{% for step in build_status['steps'] if step['started'] and step['text'] %}
+  <tr><td style="{{styles[results[step['results']]]}}">
+      {% for line in step['text'] %}
+        {{line}}<br/>
+      {% endfor %}
+      {% for steplog in step['logs'] %}
+        <a href="{{steplog[1]}}">{{steplog[0]}}</a><br/>
+      {% endfor %}
+      {% for urlname, target in step['urls'].iteritems() %}
+        <a href="{{target}}">{{urlname}}</a><br/>
+      {% endfor %}
+  </td></tr>
+{% endfor %}
+{% endblock %}
+
+
+{% block changes %}
+  {% for change in build_status['changes'] %}
+    <p>Changed by: <b>{{change['who']}}</b><br />
+    Changed at: <b>{{change['at']}}</b><br />
+    {% if change['repository'] %}
+      Repository: <b>{{change['repository']}}</b><br />
+    {% endif %}
+    {% if change['branch'] %}
+      Branch: <b>{{change['branch']}}</b><br />
+    {% endif %}
+    {% if change['revision'] %}
+      {% if change.get('revlink') %}
+        Revision: <a href="{{change['revlink']}}"><b>{{change['revision']}}</b></a>
+      {% else %}
+        Revision: <b>{{change['revision']}}</b><br />
+      {% endif %}
+    {% endif %}
+    <br />
+
+    Changed files:
+    <ul>
+      {% for f in change['files'] %}
+        <li>
+          {% if f['url'] %}
+            <a href="{{f['url']}}"><b>{{f['name']}}</b></a>
+          {% else %}
+            <b>{{f['name']}}</b>{% endif %}</li>
+      {% endfor %}
+    </ul>
+
+    Comments:
+    <pre>{{change['comments']}}</pre>
+
+    Properties:
+    <ul>
+      {% for prop in change['properties'] %}
+        <li>
+          {{prop[0]}}: {{prop[1]}}<br/></li>
+      {% endfor %}
+    </ul>
+    </p>
+  {% endfor %}
+{% endblock %}
+
diff --git a/tests/test_mailer/expected.html b/tests/test_mailer/expected.html
new file mode 100644
index 0000000..67d8833
--- /dev/null
+++ b/tests/test_mailer/expected.html
@@ -0,0 +1,211 @@
+<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+  <title>Automatically closing tree for &#34;check_deps2git,sizes,update_scripts,update,compile,gclient_revert,archive_build,runhooks&#34; on &#34;Linux x64&#34;</title>
+</head>
+<body>
+  <a href="http://build.chromium.org/p/chromium/waterfall/">http://build.chromium.org/p/chromium/waterfall/</a><p>
+  Automatically closing tree for &#34;check_deps2git,sizes,update_scripts,update,compile,gclient_revert,archive_build,runhooks&#34; on &#34;Linux x64&#34;</p>
+  <a href="http://build.chromium.org/p/chromium/builders/Linux x64/builds/54475">http://build.chromium.org/p/chromium/builders/Linux x64/builds/54475</a><p>
+  Revision: 218091, 218092, 218093, 218094<br>
+  Blame list: akalin@chromium.org,lazyboy@chromium.org,noamsml@chromium.org,rmsousa@chromium.org</p>
+<table style="border-spacing: 1px 1px; font-weight: bold; padding: 3px 0px 3px 0px; text-align: center;">
+<tr><td style="color: #FFFFFF; background-color: #e0b0ff; border-color: #ACA0B3;"><a title="Reason: scheduler" href="http://build.chromium.org/p/chromium/waterfall/builders/Linux%20x64/builds/54475">Build 54475</a></td></tr> <tr><td style="color: #FFFFFF; background-color: #e0b0ff; border-color: #ACA0B3;"> update_scripts<br/> exception<br/> slave<br/> lost<br/> <a href="http://build.chromium.org/p/chromium/builders/Linux%20x64/builds/54475/steps/update_scripts/logs/stdio">stdio</a><br/> <a href="http://build.chromium.org/p/chromium/builders/Linux%20x64/builds/54475/steps/update_scripts/logs/interrupt">interrupt</a><br /> </td></tr> </table>
+<p>
+<p>Changed by: <b>noamsml@chromium.org</b><br />
+Changed at: <b>Fri 16 Aug 2013 14:20:12</b><br />
+Repository: <b>svn://svn-mirror.golo.chromium.org/chrome/trunk</b><br />
+
+Branch: <b>src</b><br />
+
+Revision: <a href="http://src.chromium.org/viewvc/chrome?view=rev&revision=218091"><b>218091</b></a>
+
+<br />
+
+Changed files:
+<ul>
+<li> <b>chrome/browser/local_discovery/service_discovery_host_client.cc</b></li>
+<li> <b>chrome/browser/local_discovery/service_discovery_host_client.h</b></li>
+<li> <b>chrome/browser/ui/webui/local_discovery/local_discovery_ui_handler.cc</b></li>
+</ul>
+
+
+Comments:
+<pre>Added singleton factory for ServiceDiscoveryHostClient
+
+Added singleton factory for ServiceDiscoveryHostClient so it can be shared
+between multiple unrelated users. The factory keeps track of references to
+service discovery and shuts down the client when not used.
+
+BUG=
+
+Review URL: https://chromiumcodereview.appspot.com/22449004</pre>
+
+Properties:
+<ul>
+</ul>
+
+</p>
+
+<p>Changed by: <b>lazyboy@chromium.org</b><br />
+Changed at: <b>Fri 16 Aug 2013 14:20:12</b><br />
+Repository: <b>svn://svn-mirror.golo.chromium.org/chrome/trunk</b><br />
+
+Branch: <b>src</b><br />
+
+Revision: <a href="http://src.chromium.org/viewvc/chrome?view=rev&revision=218092"><b>218092</b></a>
+
+<br />
+
+Changed files:
+<ul>
+<li> <b>chrome/browser/apps/web_view_browsertest.cc</b></li>
+<li> <b>chrome/test/data/extensions/platform_apps/web_view/interstitial_teardown</b></li>
+<li> <b>chrome/test/data/extensions/platform_apps/web_view/interstitial_teardown/embedder.js</b></li>
+<li> <b>chrome/test/data/extensions/platform_apps/web_view/interstitial_teardown/https_page.html</b></li>
+<li> <b>chrome/test/data/extensions/platform_apps/web_view/interstitial_teardown/main.html</b></li>
+<li> <b>chrome/test/data/extensions/platform_apps/web_view/interstitial_teardown/manifest.json</b></li>
+<li> <b>chrome/test/data/extensions/platform_apps/web_view/interstitial_teardown/test.js</b></li>
+<li> <b>content/browser/browser_plugin/browser_plugin_guest.cc</b></li>
+<li> <b>content/browser/browser_plugin/browser_plugin_guest.h</b></li>
+<li> <b>content/browser/renderer_host/render_widget_host_view_guest.cc</b></li>
+</ul>
+
+
+Comments:
+<pre>&lt;webview&gt;: Fix ptr access crash on browser plugin + interstitial teardown.
+
+If browser plugin loaded an interstitial page as view contents
+(e.g. loading plugin resulted in ssl-error), upon closing the
+containing app, the interstitial would try to show back
+(RenderWidgetHostView::WasShown) the original contents of the
+RWHViewGuest, whose render_view_host_ was already in the middle of
+destruction. We record the destruction phase and avoid calling methods
+on RenderViewHost in this case.
+
+BUG=270313
+TEST=chrome app &lt;webview&gt;, load a guest pointing a page that results in
+ssl-error page showing (Proceed to safety page, etc), close the app. The app
+would crash without this change.
+Added regression test.
+
+Review URL: https://chromiumcodereview.appspot.com/23004002</pre>
+
+Properties:
+<ul>
+</ul>
+
+</p>
+
+<p>Changed by: <b>akalin@chromium.org</b><br />
+Changed at: <b>Fri 16 Aug 2013 14:25:12</b><br />
+Repository: <b>svn://svn-mirror.golo.chromium.org/chrome/trunk</b><br />
+
+Branch: <b>src</b><br />
+
+Revision: <a href="http://src.chromium.org/viewvc/chrome?view=rev&revision=218093"><b>218093</b></a>
+
+<br />
+
+Changed files:
+<ul>
+<li> <b>chrome/browser/devtools/tethering_adb_filter.cc</b></li>
+<li> <b>chrome/browser/extensions/api/dns/dns_api.cc</b></li>
+<li> <b>chrome/browser/extensions/api/socket/socket_api.cc</b></li>
+<li> <b>chrome/browser/net/network_stats.cc</b></li>
+<li> <b>chrome/browser/net/predictor.cc</b></li>
+<li> <b>content/browser/renderer_host/p2p/socket_dispatcher_host.cc</b></li>
+<li> <b>content/browser/renderer_host/pepper/pepper_host_resolver_message_filter.cc</b></li>
+<li> <b>content/browser/renderer_host/pepper/pepper_tcp_socket.cc</b></li>
+<li> <b>net/dns/host_resolver.cc</b></li>
+<li> <b>net/dns/host_resolver.h</b></li>
+<li> <b>net/dns/host_resolver_impl_unittest.cc</b></li>
+<li> <b>net/dns/mapped_host_resolver_unittest.cc</b></li>
+<li> <b>net/dns/single_request_host_resolver_unittest.cc</b></li>
+<li> <b>net/ftp/ftp_network_transaction.cc</b></li>
+<li> <b>net/http/http_auth_handler_negotiate.cc</b></li>
+<li> <b>net/http/http_network_transaction_unittest.cc</b></li>
+<li> <b>net/proxy/proxy_resolver_v8_tracing.cc</b></li>
+<li> <b>net/quic/quic_network_transaction_unittest.cc</b></li>
+<li> <b>net/quic/quic_stream_factory.cc</b></li>
+<li> <b>net/socket/socks5_client_socket_unittest.cc</b></li>
+<li> <b>net/socket/socks_client_socket_pool.cc</b></li>
+<li> <b>net/socket/socks_client_socket_unittest.cc</b></li>
+<li> <b>net/socket/ssl_client_socket_pool_unittest.cc</b></li>
+<li> <b>net/socket/transport_client_socket_pool.cc</b></li>
+<li> <b>net/socket/transport_client_socket_pool.h</b></li>
+<li> <b>net/socket/transport_client_socket_unittest.cc</b></li>
+<li> <b>net/socket_stream/socket_stream.cc</b></li>
+<li> <b>net/spdy/spdy_session_pool.cc</b></li>
+<li> <b>net/spdy/spdy_session_pool_unittest.cc</b></li>
+<li> <b>net/spdy/spdy_session_unittest.cc</b></li>
+<li> <b>net/test/spawned_test_server/base_test_server.cc</b></li>
+<li> <b>net/tools/gdig/gdig.cc</b></li>
+</ul>
+
+
+Comments:
+<pre>Add a priority parameter for HostResolver::RequestInfo&#39;s constructor
+
+Use DEFAULT_PRIORITY when there isn&#39;t already a priority
+available.
+
+This has the net effect of changing the priority of
+host resolver requests that didn&#39;t specify one from
+MEDIUM to DEFAULT_PRIORITY (= LOWEST). This is okay,
+as those requests apparently didn&#39;t care what priority
+they wanted.
+
+BUG=166689
+TBR=mmenke@chromium.org
+
+Review URL: https://codereview.chromium.org/23146004</pre>
+
+Properties:
+<ul>
+</ul>
+
+</p>
+
+<p>Changed by: <b>rmsousa@chromium.org</b><br />
+Changed at: <b>Fri 16 Aug 2013 14:29:12</b><br />
+Repository: <b>svn://svn-mirror.golo.chromium.org/chrome/trunk</b><br />
+
+Branch: <b>src</b><br />
+
+Revision: <a href="http://src.chromium.org/viewvc/chrome?view=rev&revision=218094"><b>218094</b></a>
+
+<br />
+
+Changed files:
+<ul>
+<li> <b>remoting/host/setup/native_messaging_host.cc</b></li>
+<li> <b>remoting/host/setup/native_messaging_host.h</b></li>
+<li> <b>remoting/host/setup/native_messaging_host_unittest.cc</b></li>
+<li> <b>remoting/host/setup/oauth_client.cc</b></li>
+<li> <b>remoting/host/setup/oauth_client.h</b></li>
+<li> <b>remoting/remoting.gyp</b></li>
+<li> <b>remoting/webapp/host_controller.js</b></li>
+<li> <b>remoting/webapp/host_dispatcher.js</b></li>
+<li> <b>remoting/webapp/host_native_messaging.js</b></li>
+</ul>
+
+
+Comments:
+<pre>Native messaging support for service accounts.
+
+This is an alternative implementation of https://codereview.chromium.org/19670021/ . This version performs the OAuth token exchange steps from inside the NativeMessaging host. This means that the client secret and the service account access token never leave the native messaging host.
+
+Since the API keys are also needed to use the refresh token, this means that only the host binaries are able to get an access token for the service account.
+
+BUG=224742
+
+Review URL: https://chromiumcodereview.appspot.com/22769002</pre>
+
+Properties:
+<ul>
+</ul>
+
+</p>
+</body>
+</html>
diff --git a/tests/test_mailer/input.json b/tests/test_mailer/input.json
new file mode 100644
index 0000000..0c61545
--- /dev/null
+++ b/tests/test_mailer/input.json
@@ -0,0 +1 @@
+{"url": "https://chromium-gatekeeper-mailer.appspot.com/email", "message": "{\"blamelist\": [\"akalin@chromium.org\", \"lazyboy@chromium.org\", \"noamsml@chromium.org\", \"rmsousa@chromium.org\"], \"build_url\": \"http://build.chromium.org/p/chromium/builders/Linux x64/builds/54475\", \"builderName\": \"Linux x64\", \"changes\": [{\"at\": \"Fri 16 Aug 2013 14:20:12\", \"branch\": \"src\", \"category\": null, \"comments\": \"Added singleton factory for ServiceDiscoveryHostClient\\n\\nAdded singleton factory for ServiceDiscoveryHostClient so it can be shared\\nbetween multiple unrelated users. The factory keeps track of references to\\nservice discovery and shuts down the client when not used.\\n\\nBUG=\\n\\nReview URL: https://chromiumcodereview.appspot.com/22449004\", \"files\": [{\"name\": \"chrome/browser/local_discovery/service_discovery_host_client.cc\", \"url\": null}, {\"name\": \"chrome/browser/local_discovery/service_discovery_host_client.h\", \"url\": null}, {\"name\": \"chrome/browser/ui/webui/local_discovery/local_discovery_ui_handler.cc\", \"url\": null}], \"number\": 102628, \"project\": \"\", \"properties\": [], \"repository\": \"svn://svn-mirror.golo.chromium.org/chrome/trunk\", \"rev\": \"218091\", \"revision\": \"218091\", \"revlink\": \"http://src.chromium.org/viewvc/chrome?view=rev&revision=218091\", \"when\": 1376688012, \"who\": \"noamsml@chromium.org\"}, {\"at\": \"Fri 16 Aug 2013 14:20:12\", \"branch\": \"src\", \"category\": null, \"comments\": \"<webview>: Fix ptr access crash on browser plugin + interstitial teardown.\\n\\nIf browser plugin loaded an interstitial page as view contents\\n(e.g. loading plugin resulted in ssl-error), upon closing the\\ncontaining app, the interstitial would try to show back\\n(RenderWidgetHostView::WasShown) the original contents of the\\nRWHViewGuest, whose render_view_host_ was already in the middle of\\ndestruction. We record the destruction phase and avoid calling methods\\non RenderViewHost in this case.\\n\\nBUG=270313\\nTEST=chrome app <webview>, load a guest pointing a page that results in\\nssl-error page showing (Proceed to safety page, etc), close the app. The app\\nwould crash without this change.\\nAdded regression test.\\n\\nReview URL: https://chromiumcodereview.appspot.com/23004002\", \"files\": [{\"name\": \"chrome/browser/apps/web_view_browsertest.cc\", \"url\": null}, {\"name\": \"chrome/test/data/extensions/platform_apps/web_view/interstitial_teardown\", \"url\": null}, {\"name\": \"chrome/test/data/extensions/platform_apps/web_view/interstitial_teardown/embedder.js\", \"url\": null}, {\"name\": \"chrome/test/data/extensions/platform_apps/web_view/interstitial_teardown/https_page.html\", \"url\": null}, {\"name\": \"chrome/test/data/extensions/platform_apps/web_view/interstitial_teardown/main.html\", \"url\": null}, {\"name\": \"chrome/test/data/extensions/platform_apps/web_view/interstitial_teardown/manifest.json\", \"url\": null}, {\"name\": \"chrome/test/data/extensions/platform_apps/web_view/interstitial_teardown/test.js\", \"url\": null}, {\"name\": \"content/browser/browser_plugin/browser_plugin_guest.cc\", \"url\": null}, {\"name\": \"content/browser/browser_plugin/browser_plugin_guest.h\", \"url\": null}, {\"name\": \"content/browser/renderer_host/render_widget_host_view_guest.cc\", \"url\": null}], \"number\": 102629, \"project\": \"\", \"properties\": [], \"repository\": \"svn://svn-mirror.golo.chromium.org/chrome/trunk\", \"rev\": \"218092\", \"revision\": \"218092\", \"revlink\": \"http://src.chromium.org/viewvc/chrome?view=rev&revision=218092\", \"when\": 1376688012, \"who\": \"lazyboy@chromium.org\"}, {\"at\": \"Fri 16 Aug 2013 14:25:12\", \"branch\": \"src\", \"category\": null, \"comments\": \"Add a priority parameter for HostResolver::RequestInfo's constructor\\n\\nUse DEFAULT_PRIORITY when there isn't already a priority\\navailable.\\n\\nThis has the net effect of changing the priority of\\nhost resolver requests that didn't specify one from\\nMEDIUM to DEFAULT_PRIORITY (= LOWEST). This is okay,\\nas those requests apparently didn't care what priority\\nthey wanted.\\n\\nBUG=166689\\nTBR=mmenke@chromium.org\\n\\nReview URL: https://codereview.chromium.org/23146004\", \"files\": [{\"name\": \"chrome/browser/devtools/tethering_adb_filter.cc\", \"url\": null}, {\"name\": \"chrome/browser/extensions/api/dns/dns_api.cc\", \"url\": null}, {\"name\": \"chrome/browser/extensions/api/socket/socket_api.cc\", \"url\": null}, {\"name\": \"chrome/browser/net/network_stats.cc\", \"url\": null}, {\"name\": \"chrome/browser/net/predictor.cc\", \"url\": null}, {\"name\": \"content/browser/renderer_host/p2p/socket_dispatcher_host.cc\", \"url\": null}, {\"name\": \"content/browser/renderer_host/pepper/pepper_host_resolver_message_filter.cc\", \"url\": null}, {\"name\": \"content/browser/renderer_host/pepper/pepper_tcp_socket.cc\", \"url\": null}, {\"name\": \"net/dns/host_resolver.cc\", \"url\": null}, {\"name\": \"net/dns/host_resolver.h\", \"url\": null}, {\"name\": \"net/dns/host_resolver_impl_unittest.cc\", \"url\": null}, {\"name\": \"net/dns/mapped_host_resolver_unittest.cc\", \"url\": null}, {\"name\": \"net/dns/single_request_host_resolver_unittest.cc\", \"url\": null}, {\"name\": \"net/ftp/ftp_network_transaction.cc\", \"url\": null}, {\"name\": \"net/http/http_auth_handler_negotiate.cc\", \"url\": null}, {\"name\": \"net/http/http_network_transaction_unittest.cc\", \"url\": null}, {\"name\": \"net/proxy/proxy_resolver_v8_tracing.cc\", \"url\": null}, {\"name\": \"net/quic/quic_network_transaction_unittest.cc\", \"url\": null}, {\"name\": \"net/quic/quic_stream_factory.cc\", \"url\": null}, {\"name\": \"net/socket/socks5_client_socket_unittest.cc\", \"url\": null}, {\"name\": \"net/socket/socks_client_socket_pool.cc\", \"url\": null}, {\"name\": \"net/socket/socks_client_socket_unittest.cc\", \"url\": null}, {\"name\": \"net/socket/ssl_client_socket_pool_unittest.cc\", \"url\": null}, {\"name\": \"net/socket/transport_client_socket_pool.cc\", \"url\": null}, {\"name\": \"net/socket/transport_client_socket_pool.h\", \"url\": null}, {\"name\": \"net/socket/transport_client_socket_unittest.cc\", \"url\": null}, {\"name\": \"net/socket_stream/socket_stream.cc\", \"url\": null}, {\"name\": \"net/spdy/spdy_session_pool.cc\", \"url\": null}, {\"name\": \"net/spdy/spdy_session_pool_unittest.cc\", \"url\": null}, {\"name\": \"net/spdy/spdy_session_unittest.cc\", \"url\": null}, {\"name\": \"net/test/spawned_test_server/base_test_server.cc\", \"url\": null}, {\"name\": \"net/tools/gdig/gdig.cc\", \"url\": null}], \"number\": 102630, \"project\": \"\", \"properties\": [], \"repository\": \"svn://svn-mirror.golo.chromium.org/chrome/trunk\", \"rev\": \"218093\", \"revision\": \"218093\", \"revlink\": \"http://src.chromium.org/viewvc/chrome?view=rev&revision=218093\", \"when\": 1376688312, \"who\": \"akalin@chromium.org\"}, {\"at\": \"Fri 16 Aug 2013 14:29:12\", \"branch\": \"src\", \"category\": null, \"comments\": \"Native messaging support for service accounts.\\n\\nThis is an alternative implementation of https://codereview.chromium.org/19670021/ . This version performs the OAuth token exchange steps from inside the NativeMessaging host. This means that the client secret and the service account access token never leave the native messaging host.\\n\\nSince the API keys are also needed to use the refresh token, this means that only the host binaries are able to get an access token for the service account.\\n\\nBUG=224742\\n\\nReview URL: https://chromiumcodereview.appspot.com/22769002\", \"files\": [{\"name\": \"remoting/host/setup/native_messaging_host.cc\", \"url\": null}, {\"name\": \"remoting/host/setup/native_messaging_host.h\", \"url\": null}, {\"name\": \"remoting/host/setup/native_messaging_host_unittest.cc\", \"url\": null}, {\"name\": \"remoting/host/setup/oauth_client.cc\", \"url\": null}, {\"name\": \"remoting/host/setup/oauth_client.h\", \"url\": null}, {\"name\": \"remoting/remoting.gyp\", \"url\": null}, {\"name\": \"remoting/webapp/host_controller.js\", \"url\": null}, {\"name\": \"remoting/webapp/host_dispatcher.js\", \"url\": null}, {\"name\": \"remoting/webapp/host_native_messaging.js\", \"url\": null}], \"number\": 102631, \"project\": \"\", \"properties\": [], \"repository\": \"svn://svn-mirror.golo.chromium.org/chrome/trunk\", \"rev\": \"218094\", \"revision\": \"218094\", \"revlink\": \"http://src.chromium.org/viewvc/chrome?view=rev&revision=218094\", \"when\": 1376688552, \"who\": \"rmsousa@chromium.org\"}], \"from_addr\": \"buildbot@chromium.org\", \"number\": 54475, \"project_name\": \"Chromium\", \"reason\": \"scheduler\", \"recipients\": [\"vabr@google.com\", \"lazyboy@chromium.org\", \"akalin@chromium.org\", \"noamsml@chromium.org\", \"creis@google.com\", \"rmsousa@chromium.org\", \"brianderson@google.com\"], \"result\": 5, \"revisions\": [\"218091\", \"218092\", \"218093\", \"218094\"], \"steps\": [{\"logs\": [[\"stdio\", \"http://build.chromium.org/p/chromium/builders/Linux%20x64/builds/54475/steps/update_scripts/logs/stdio\"], [\"interrupt\", \"http://build.chromium.org/p/chromium/builders/Linux%20x64/builds/54475/steps/update_scripts/logs/interrupt\"]], \"name\": \"update_scripts\", \"results\": 5, \"started\": true, \"text\": [\"update_scripts\", \"exception\", \"slave\", \"lost\"], \"urls\": {}}, {\"logs\": [], \"name\": \"gclient_revert\", \"results\": null, \"started\": false, \"text\": [], \"urls\": {}}, {\"logs\": [], \"name\": \"update\", \"results\": null, \"started\": false, \"text\": [], \"urls\": {}}, {\"logs\": [], \"name\": \"runhooks\", \"results\": null, \"started\": false, \"text\": [], \"urls\": {}}, {\"logs\": [], \"name\": \"cleanup_temp\", \"results\": null, \"started\": false, \"text\": [], \"urls\": {}}, {\"logs\": [], \"name\": \"compile\", \"results\": null, \"started\": false, \"text\": [], \"urls\": {}}, {\"logs\": [], \"name\": \"archive_build\", \"results\": null, \"started\": false, \"text\": [], \"urls\": {}}, {\"logs\": [], \"name\": \"upload\", \"results\": null, \"started\": false, \"text\": [], \"urls\": {}}, {\"logs\": [], \"name\": \"check_deps2git\", \"results\": null, \"started\": false, \"text\": [], \"urls\": {}}, {\"logs\": [], \"name\": \"check_deps2submodules\", \"results\": null, \"started\": false, \"text\": [], \"urls\": {}}, {\"logs\": [], \"name\": \"sizes\", \"results\": null, \"started\": false, \"text\": [], \"urls\": {}}], \"unsatisfied\": [\"check_deps2git\", \"sizes\", \"update_scripts\", \"update\", \"compile\", \"gclient_revert\", \"archive_build\", \"runhooks\"], \"waterfall_url\": \"http://build.chromium.org/p/chromium/waterfall\"}", "hmac-sha256": "a65b569399932e8ce7bb0421cddbee285efeb8b05ee92b745f050a3835aad8f4", "salt": 2649136503, "time": 1377132926}
diff --git a/utils.py b/utils.py
index b689666..0488d94 100644
--- a/utils.py
+++ b/utils.py
@@ -12,6 +12,7 @@
 import sys
 import string
 import json
+import urllib
 
 from google.appengine.api import users
 
@@ -270,3 +271,6 @@
   # Obfuscure email addresses with rot13 encoding.
   value = re.sub(r'(\w+@[\w.]+)', lambda m: rot13_email(m.group(1)), value)
   return value
+
+def urlquote(value, safe=''):
+  return urllib.quote(value, safe=safe)