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)
|
raise NotImplementedError("Unknown channel kind: %s" % self.kind)
|
||||||
|
|
||||||
def notify(self, check):
|
def notify(self, check):
|
||||||
# Make 3 attempts--
|
if self.transport.is_noop(check):
|
||||||
for x in range(0, 3):
|
return "no-op"
|
||||||
error = self.transport.notify(check) or ""
|
|
||||||
if error in ("", "no-op"):
|
|
||||||
break # Success!
|
|
||||||
|
|
||||||
if error != "no-op":
|
|
||||||
n = Notification(owner=check, channel=self)
|
n = Notification(owner=check, channel=self)
|
||||||
n.check_status = check.status
|
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.error = error
|
||||||
n.save()
|
n.save()
|
||||||
|
|
||||||
@ -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
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
|
# 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", "alice@example.org", email_verified=False)
|
self._setup_data("email", "alice@example.org", email_verified=False)
|
||||||
self.channel.notify(self.check)
|
self.channel.notify(self.check)
|
||||||
|
@ -27,37 +27,41 @@ class Transport(object):
|
|||||||
|
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
def test(self):
|
def is_noop(self, check):
|
||||||
""" Send test message.
|
""" Return True if transport will ignore check's current status.
|
||||||
|
|
||||||
This method returns None on success, and error message
|
This method is overriden in Webhook subclass where the user can
|
||||||
on error.
|
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:
|
assert url
|
||||||
# If the URL is empty then we do nothing
|
|
||||||
return "no-op"
|
|
||||||
|
|
||||||
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",
|
||||||
|
@ -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"),
|
||||||
]
|
]
|
||||||
|
@ -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()
|
||||||
|
@ -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):
|
def alert(to, ctx, headers={}):
|
||||||
send("alert", to, ctx)
|
send("alert", to, ctx, headers)
|
||||||
|
|
||||||
|
|
||||||
def verify_email(to, ctx):
|
def verify_email(to, ctx):
|
||||||
|
Loading…
x
Reference in New Issue
Block a user