Browse Source

"X-Bounce-Url" header in email messages. An API endpoint to handle bounce notifications. (#112)

pull/117/head
Pēteris Caune 8 years ago
parent
commit
0d24d650f2
8 changed files with 152 additions and 31 deletions
  1. +26
    -0
      hc/api/migrations/0028_auto_20170305_1907.py
  2. +17
    -9
      hc/api/models.py
  3. +36
    -0
      hc/api/tests/test_bounce.py
  4. +3
    -0
      hc/api/tests/test_notify.py
  5. +38
    -14
      hc/api/transports.py
  6. +4
    -1
      hc/api/urls.py
  7. +19
    -1
      hc/api/views.py
  8. +9
    -6
      hc/lib/emails.py

+ 26
- 0
hc/api/migrations/0028_auto_20170305_1907.py View File

@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.5 on 2017-03-05 19:07
from __future__ import unicode_literals
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
dependencies = [
('api', '0027_auto_20161213_1059'),
]
operations = [
migrations.AddField(
model_name='notification',
name='code',
field=models.UUIDField(default=None, editable=False, null=True),
),
migrations.AlterField(
model_name='channel',
name='kind',
field=models.CharField(choices=[('email', 'Email'), ('webhook', 'Webhook'), ('hipchat', 'HipChat'), ('slack', 'Slack'), ('pd', 'PagerDuty'), ('po', 'Pushover'), ('pushbullet', 'Pushbullet'), ('opsgenie', 'OpsGenie'), ('victorops', 'VictorOps'), ('discord', 'Discord')], max_length=20),
),
]

+ 17
- 9
hc/api/models.py View File

@ -262,17 +262,21 @@ class Channel(models.Model):
raise NotImplementedError("Unknown channel kind: %s" % self.kind)
def notify(self, check):
# Make 3 attempts--
for x in range(0, 3):
if self.transport.is_noop(check):
return "no-op"
n = Notification(owner=check, channel=self)
n.check_status = check.status
n.error = "Sending"
n.save()
if self.kind == "email":
error = self.transport.notify(check, n.bounce_url()) or ""
else:
error = self.transport.notify(check) or ""
if error in ("", "no-op"):
break # Success!
if error != "no-op":
n = Notification(owner=check, channel=self)
n.check_status = check.status
n.error = error
n.save()
n.error = error
n.save()
return error
@ -348,8 +352,12 @@ class Notification(models.Model):
class Meta:
get_latest_by = "created"
code = models.UUIDField(default=uuid.uuid4, null=True, editable=False)
owner = models.ForeignKey(Check)
check_status = models.CharField(max_length=6)
channel = models.ForeignKey(Channel)
created = models.DateTimeField(auto_now_add=True)
error = models.CharField(max_length=200, blank=True)
def bounce_url(self):
return settings.SITE_ROOT + reverse("hc-api-bounce", args=[self.code])

+ 36
- 0
hc/api/tests/test_bounce.py View File

@ -0,0 +1,36 @@
from datetime import timedelta
from hc.api.models import Channel, Check, Notification
from hc.test import BaseTestCase
class BounceTestCase(BaseTestCase):
def setUp(self):
super(BounceTestCase, self).setUp()
self.check = Check(user=self.alice, status="up")
self.check.save()
self.channel = Channel(user=self.alice, kind="email")
self.channel.value = "[email protected]"
self.channel.save()
self.n = Notification(owner=self.check, channel=self.channel)
self.n.save()
def test_it_works(self):
url = "/api/v1/notifications/%s/bounce" % self.n.code
r = self.client.post(url, "foo", content_type="text/plain")
self.assertEqual(r.status_code, 200)
self.n.refresh_from_db()
self.assertEqual(self.n.error, "foo")
def test_it_checks_ttl(self):
self.n.created = self.n.created - timedelta(minutes=60)
self.n.save()
url = "/api/v1/notifications/%s/bounce" % self.n.code
r = self.client.post(url, "foo", content_type="text/plain")
self.assertEqual(r.status_code, 400)

+ 3
- 0
hc/api/tests/test_notify.py View File

@ -137,6 +137,9 @@ class NotifyTestCase(BaseTestCase):
# And email should have been sent
self.assertEqual(len(mail.outbox), 1)
email = mail.outbox[0]
self.assertTrue("X-Bounce-Url" in email.extra_headers)
def test_it_skips_unverified_email(self):
self._setup_data("email", "[email protected]", email_verified=False)
self.channel.notify(self.check)


+ 38
- 14
hc/api/transports.py View File

@ -27,37 +27,41 @@ class Transport(object):
raise NotImplementedError()
def test(self):
""" Send test message.
def is_noop(self, check):
""" Return True if transport will ignore check's current status.
This method returns None on success, and error message
on error.
This method is overriden in Webhook subclass where the user can
configure webhook urls for "up" and "down" events, and both are
optional.
"""
raise NotImplementedError()
return False
def checks(self):
return self.channel.user.check_set.order_by("created")
class Email(Transport):
def notify(self, check):
def notify(self, check, bounce_url):
if not self.channel.email_verified:
return "Email not verified"
headers = {"X-Bounce-Url": bounce_url}
ctx = {
"check": check,
"checks": self.checks(),
"now": timezone.now(),
"unsub_link": self.channel.get_unsub_link()
}
emails.alert(self.channel.value, ctx)
emails.alert(self.channel.value, ctx, headers)
class HttpTransport(Transport):
def request(self, method, url, **kwargs):
def _request(self, method, url, **kwargs):
try:
options = dict(kwargs)
if "headers" not in options:
@ -76,10 +80,22 @@ class HttpTransport(Transport):
return "Connection failed"
def get(self, url):
return self.request("get", url)
# Make 3 attempts--
for x in range(0, 3):
error = self._request("get", url)
if error is None:
break
return error
def post(self, url, **kwargs):
return self.request("post", url, **kwargs)
# Make 3 attempts--
for x in range(0, 3):
error = self._request("post", url, **kwargs)
if error is None:
break
return error
class Webhook(HttpTransport):
@ -115,14 +131,21 @@ class Webhook(HttpTransport):
return result
def is_noop(self, check):
if check.status == "down" and not self.channel.value_down:
return True
if check.status == "up" and not self.channel.value_up:
return True
return False
def notify(self, check):
url = self.channel.value_down
if check.status == "up":
url = self.channel.value_up
if not url:
# If the URL is empty then we do nothing
return "no-op"
assert url
url = self.prepare(url, check, urlencode=True)
if self.channel.post_data:
@ -236,9 +259,10 @@ class Pushover(HttpTransport):
class VictorOps(HttpTransport):
def notify(self, check):
description = tmpl("victorops_description.html", check=check)
mtype = "CRITICAL" if check.status == "down" else "RECOVERY"
payload = {
"entity_id": str(check.code),
"message_type": "CRITICAL" if check.status == "down" else "RECOVERY",
"message_type": mtype,
"entity_display_name": check.name_then_code(),
"state_message": description,
"monitoring_tool": "healthchecks.io",


+ 4
- 1
hc/api/urls.py View File

@ -8,5 +8,8 @@ urlpatterns = [
url(r'^api/v1/checks/$', views.checks),
url(r'^api/v1/checks/([\w-]+)$', views.update, name="hc-api-update"),
url(r'^api/v1/checks/([\w-]+)/pause$', views.pause, name="hc-api-pause"),
url(r'^badge/([\w-]+)/([\w-]{8})/([\w-]+).svg$', views.badge, name="hc-badge"),
url(r'^api/v1/notifications/([\w-]+)/bounce$', views.bounce,
name="hc-api-bounce"),
url(r'^badge/([\w-]+)/([\w-]{8})/([\w-]+).svg$', views.badge,
name="hc-badge"),
]

+ 19
- 1
hc/api/views.py View File

@ -8,7 +8,7 @@ from django.views.decorators.csrf import csrf_exempt
from hc.api import schemas
from hc.api.decorators import check_api_key, uuid_or_400, validate_json
from hc.api.models import Check, Ping
from hc.api.models import Check, Notification, Ping
from hc.lib.badges import check_signature, get_badge_svg
@ -175,3 +175,21 @@ def badge(request, username, signature, tag):
svg = get_badge_svg(tag, status)
return HttpResponse(svg, content_type="image/svg+xml")
@uuid_or_400
def bounce(request, code):
try:
notification = Notification.objects.get(code=code)
except Notification.DoesNotExist:
return HttpResponseBadRequest()
# If webhook is more than 10 minutes late, don't accept it:
td = timezone.now() - notification.created
if td.total_seconds() > 600:
return HttpResponseBadRequest()
notification.error = request.body
notification.save()
return HttpResponse()

+ 9
- 6
hc/lib/emails.py View File

@ -6,27 +6,30 @@ from django.template.loader import render_to_string as render
class EmailThread(Thread):
def __init__(self, subject, text, html, to):
def __init__(self, subject, text, html, to, headers):
Thread.__init__(self)
self.subject = subject
self.text = text
self.html = html
self.to = to
self.headers = headers
def run(self):
msg = EmailMultiAlternatives(self.subject, self.text, to=(self.to, ))
msg = EmailMultiAlternatives(self.subject, self.text, to=(self.to, ),
headers=self.headers)
msg.attach_alternative(self.html, "text/html")
msg.send()
def send(name, to, ctx):
def send(name, to, ctx, headers={}):
ctx["SITE_ROOT"] = settings.SITE_ROOT
subject = render('emails/%s-subject.html' % name, ctx).strip()
text = render('emails/%s-body-text.html' % name, ctx)
html = render('emails/%s-body-html.html' % name, ctx)
t = EmailThread(subject, text, html, to)
t = EmailThread(subject, text, html, to, headers)
if hasattr(settings, "BLOCKING_EMAILS"):
t.run()
else:
@ -41,8 +44,8 @@ def set_password(to, ctx):
send("set-password", to, ctx)
def alert(to, ctx):
send("alert", to, ctx)
def alert(to, ctx, headers={}):
send("alert", to, ctx, headers)
def verify_email(to, ctx):


Loading…
Cancel
Save