forked from GithubBackups/healthchecks
"X-Bounce-Url" header in email messages. An API endpoint to handle bounce notifications. (#112)
This commit is contained in:
parent
f767cf59aa
commit
0d24d650f2
26
hc/api/migrations/0028_auto_20170305_1907.py
Normal file
26
hc/api/migrations/0028_auto_20170305_1907.py
Normal 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),
|
||||
),
|
||||
]
|
@ -262,15 +262,19 @@ 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):
|
||||
error = self.transport.notify(check) or ""
|
||||
if error in ("", "no-op"):
|
||||
break # Success!
|
||||
if self.transport.is_noop(check):
|
||||
return "no-op"
|
||||
|
||||
if error != "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 ""
|
||||
|
||||
n.error = error
|
||||
n.save()
|
||||
|
||||
@ -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
hc/api/tests/test_bounce.py
Normal file
36
hc/api/tests/test_bounce.py
Normal 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 = "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)
|
@ -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)
|
||||
|
@ -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",
|
||||
|
@ -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"),
|
||||
]
|
||||
|
@ -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()
|
||||
|
@ -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…
x
Reference in New Issue
Block a user