From 0d24d650f2308a91cb06056972515b4a62a8f3e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= Date: Sun, 5 Mar 2017 21:42:36 +0200 Subject: [PATCH] "X-Bounce-Url" header in email messages. An API endpoint to handle bounce notifications. (#112) --- hc/api/migrations/0028_auto_20170305_1907.py | 26 ++++++++++ hc/api/models.py | 26 ++++++---- hc/api/tests/test_bounce.py | 36 ++++++++++++++ hc/api/tests/test_notify.py | 3 ++ hc/api/transports.py | 52 ++++++++++++++------ hc/api/urls.py | 5 +- hc/api/views.py | 20 +++++++- hc/lib/emails.py | 15 +++--- 8 files changed, 152 insertions(+), 31 deletions(-) create mode 100644 hc/api/migrations/0028_auto_20170305_1907.py create mode 100644 hc/api/tests/test_bounce.py diff --git a/hc/api/migrations/0028_auto_20170305_1907.py b/hc/api/migrations/0028_auto_20170305_1907.py new file mode 100644 index 00000000..4505d428 --- /dev/null +++ b/hc/api/migrations/0028_auto_20170305_1907.py @@ -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), + ), + ] diff --git a/hc/api/models.py b/hc/api/models.py index 6aaf6b7f..78b0d2b8 100644 --- a/hc/api/models.py +++ b/hc/api/models.py @@ -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]) diff --git a/hc/api/tests/test_bounce.py b/hc/api/tests/test_bounce.py new file mode 100644 index 00000000..8b4b4d1d --- /dev/null +++ b/hc/api/tests/test_bounce.py @@ -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 = "alice@example.org" + 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) diff --git a/hc/api/tests/test_notify.py b/hc/api/tests/test_notify.py index a5b6acac..9509f46a 100644 --- a/hc/api/tests/test_notify.py +++ b/hc/api/tests/test_notify.py @@ -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", "alice@example.org", email_verified=False) self.channel.notify(self.check) diff --git a/hc/api/transports.py b/hc/api/transports.py index 910ce65c..0612b845 100644 --- a/hc/api/transports.py +++ b/hc/api/transports.py @@ -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", diff --git a/hc/api/urls.py b/hc/api/urls.py index 6cbf97e5..d726802f 100644 --- a/hc/api/urls.py +++ b/hc/api/urls.py @@ -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"), ] diff --git a/hc/api/views.py b/hc/api/views.py index d861dcda..2eb6a51c 100644 --- a/hc/api/views.py +++ b/hc/api/views.py @@ -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() diff --git a/hc/lib/emails.py b/hc/lib/emails.py index 58f78610..b2d453d0 100644 --- a/hc/lib/emails.py +++ b/hc/lib/emails.py @@ -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):