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) raise NotImplementedError("Unknown channel kind: %s" % self.kind)
def notify(self, check): 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 "" 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 return error
@ -348,8 +352,12 @@ class Notification(models.Model):
class Meta: class Meta:
get_latest_by = "created" get_latest_by = "created"
code = models.UUIDField(default=uuid.uuid4, null=True, editable=False)
owner = models.ForeignKey(Check) owner = models.ForeignKey(Check)
check_status = models.CharField(max_length=6) check_status = models.CharField(max_length=6)
channel = models.ForeignKey(Channel) channel = models.ForeignKey(Channel)
created = models.DateTimeField(auto_now_add=True) created = models.DateTimeField(auto_now_add=True)
error = models.CharField(max_length=200, blank=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 # And email should have been sent
self.assertEqual(len(mail.outbox), 1) 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): def test_it_skips_unverified_email(self):
self._setup_data("email", "[email protected]", email_verified=False) self._setup_data("email", "[email protected]", email_verified=False)
self.channel.notify(self.check) self.channel.notify(self.check)


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

@ -27,37 +27,41 @@ class Transport(object):
raise NotImplementedError() 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): def checks(self):
return self.channel.user.check_set.order_by("created") return self.channel.user.check_set.order_by("created")
class Email(Transport): class Email(Transport):
def notify(self, check):
def notify(self, check, bounce_url):
if not self.channel.email_verified: if not self.channel.email_verified:
return "Email not verified" return "Email not verified"
headers = {"X-Bounce-Url": bounce_url}
ctx = { ctx = {
"check": check, "check": check,
"checks": self.checks(), "checks": self.checks(),
"now": timezone.now(), "now": timezone.now(),
"unsub_link": self.channel.get_unsub_link() "unsub_link": self.channel.get_unsub_link()
} }
emails.alert(self.channel.value, ctx)
emails.alert(self.channel.value, ctx, headers)
class HttpTransport(Transport): class HttpTransport(Transport):
def request(self, method, url, **kwargs):
def _request(self, method, url, **kwargs):
try: try:
options = dict(kwargs) options = dict(kwargs)
if "headers" not in options: if "headers" not in options:
@ -76,10 +80,22 @@ class HttpTransport(Transport):
return "Connection failed" return "Connection failed"
def get(self, url): 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): 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): class Webhook(HttpTransport):
@ -115,14 +131,21 @@ class Webhook(HttpTransport):
return result 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): def notify(self, check):
url = self.channel.value_down url = self.channel.value_down
if check.status == "up": if check.status == "up":
url = self.channel.value_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) url = self.prepare(url, check, urlencode=True)
if self.channel.post_data: if self.channel.post_data:
@ -236,9 +259,10 @@ class Pushover(HttpTransport):
class VictorOps(HttpTransport): class VictorOps(HttpTransport):
def notify(self, check): def notify(self, check):
description = tmpl("victorops_description.html", check=check) description = tmpl("victorops_description.html", check=check)
mtype = "CRITICAL" if check.status == "down" else "RECOVERY"
payload = { payload = {
"entity_id": str(check.code), "entity_id": str(check.code),
"message_type": "CRITICAL" if check.status == "down" else "RECOVERY",
"message_type": mtype,
"entity_display_name": check.name_then_code(), "entity_display_name": check.name_then_code(),
"state_message": description, "state_message": description,
"monitoring_tool": "healthchecks.io", "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/$', views.checks),
url(r'^api/v1/checks/([\w-]+)$', views.update, name="hc-api-update"), 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'^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 import schemas
from hc.api.decorators import check_api_key, uuid_or_400, validate_json 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 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) svg = get_badge_svg(tag, status)
return HttpResponse(svg, content_type="image/svg+xml") 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): class EmailThread(Thread):
def __init__(self, subject, text, html, to):
def __init__(self, subject, text, html, to, headers):
Thread.__init__(self) Thread.__init__(self)
self.subject = subject self.subject = subject
self.text = text self.text = text
self.html = html self.html = html
self.to = to self.to = to
self.headers = headers
def run(self): 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.attach_alternative(self.html, "text/html")
msg.send() msg.send()
def send(name, to, ctx):
def send(name, to, ctx, headers={}):
ctx["SITE_ROOT"] = settings.SITE_ROOT ctx["SITE_ROOT"] = settings.SITE_ROOT
subject = render('emails/%s-subject.html' % name, ctx).strip() subject = render('emails/%s-subject.html' % name, ctx).strip()
text = render('emails/%s-body-text.html' % name, ctx) text = render('emails/%s-body-text.html' % name, ctx)
html = render('emails/%s-body-html.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"): if hasattr(settings, "BLOCKING_EMAILS"):
t.run() t.run()
else: else:
@ -41,8 +44,8 @@ def set_password(to, ctx):
send("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): def verify_email(to, ctx):


Loading…
Cancel
Save