forked from GithubBackups/healthchecks
Add "Failure Keyword" filtering for inbound emails (cc: #396)
This commit is contained in:
parent
556e8c67c5
commit
0d03e3f00b
@ -13,6 +13,7 @@ All notable changes to this project will be documented in this file.
|
||||
- Added "Docs > Reliability Tips" page
|
||||
- Spike.sh integration (#402)
|
||||
- Updated Discord integration to use discord.com instead of discordapp.com
|
||||
- Add "Failure Keyword" filtering for inbound emails (#396)
|
||||
|
||||
### Bug Fixes
|
||||
- Removing Pager Team integration, project appears to be discontinued
|
||||
|
@ -12,18 +12,8 @@ RE_UUID = re.compile(
|
||||
)
|
||||
|
||||
|
||||
class Listener(SMTPServer):
|
||||
def __init__(self, localaddr, stdout):
|
||||
self.stdout = stdout
|
||||
super(Listener, self).__init__(localaddr, None, decode_data=False)
|
||||
|
||||
def process_message(
|
||||
self, peer, mailfrom, rcpttos, data, mail_options=None, rcpt_options=None
|
||||
):
|
||||
# get a new db connection in case the old one has timed out:
|
||||
connections.close_all()
|
||||
|
||||
to_parts = rcpttos[0].split("@")
|
||||
def _process_message(remote_addr, mailfrom, mailto, data):
|
||||
to_parts = mailto.split("@")
|
||||
code = to_parts[0]
|
||||
|
||||
try:
|
||||
@ -32,25 +22,39 @@ class Listener(SMTPServer):
|
||||
data = "[binary data]"
|
||||
|
||||
if not RE_UUID.match(code):
|
||||
self.stdout.write("Not an UUID: %s" % code)
|
||||
return
|
||||
return f"Not an UUID: {code}"
|
||||
|
||||
try:
|
||||
check = Check.objects.get(code=code)
|
||||
except Check.DoesNotExist:
|
||||
self.stdout.write("Check not found: %s" % code)
|
||||
return
|
||||
return f"Check not found: {code}"
|
||||
|
||||
action = "success"
|
||||
if check.subject:
|
||||
parsed = email.message_from_string(data)
|
||||
received_subject = parsed.get("subject", "")
|
||||
if check.subject not in received_subject:
|
||||
if check.subject or check.subject_fail:
|
||||
action = "ign"
|
||||
subject = email.message_from_string(data).get("subject", "")
|
||||
if check.subject and check.subject in subject:
|
||||
action = "success"
|
||||
elif check.subject_fail and check.subject_fail in subject:
|
||||
action = "fail"
|
||||
|
||||
ua = "Email from %s" % mailfrom
|
||||
check.ping(peer[0], "email", "", ua, data, action)
|
||||
self.stdout.write("Processed ping for %s" % code)
|
||||
check.ping(remote_addr, "email", "", ua, data, action)
|
||||
|
||||
return f"Processed ping for {code}"
|
||||
|
||||
|
||||
class Listener(SMTPServer):
|
||||
def __init__(self, localaddr, stdout):
|
||||
self.stdout = stdout
|
||||
super(Listener, self).__init__(localaddr, None, decode_data=False)
|
||||
|
||||
def process_message(self, peer, mailfrom, rcpttos, data, **kwargs):
|
||||
# get a new db connection in case the old one has timed out:
|
||||
connections.close_all()
|
||||
|
||||
result = _process_message(peer[0], mailfrom, rcpttos[0], data)
|
||||
self.stdout.write(result)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@ -65,6 +69,6 @@ class Command(BaseCommand):
|
||||
)
|
||||
|
||||
def handle(self, host, port, *args, **options):
|
||||
listener = Listener((host, port), self.stdout)
|
||||
_ = Listener((host, port), self.stdout)
|
||||
print("Starting SMTP listener on %s:%d ..." % (host, port))
|
||||
asyncore.loop()
|
||||
|
23
hc/api/migrations/0073_auto_20200721_1000.py
Normal file
23
hc/api/migrations/0073_auto_20200721_1000.py
Normal file
@ -0,0 +1,23 @@
|
||||
# Generated by Django 3.0.8 on 2020-07-21 10:00
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('api', '0072_auto_20200701_1007'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='check',
|
||||
name='subject_fail',
|
||||
field=models.CharField(blank=True, max_length=100),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='channel',
|
||||
name='kind',
|
||||
field=models.CharField(choices=[('email', 'Email'), ('webhook', 'Webhook'), ('hipchat', 'HipChat'), ('slack', 'Slack'), ('pd', 'PagerDuty'), ('pagertree', 'PagerTree'), ('pagerteam', 'Pager Team'), ('po', 'Pushover'), ('pushbullet', 'Pushbullet'), ('opsgenie', 'OpsGenie'), ('victorops', 'VictorOps'), ('discord', 'Discord'), ('telegram', 'Telegram'), ('sms', 'SMS'), ('zendesk', 'Zendesk'), ('trello', 'Trello'), ('matrix', 'Matrix'), ('whatsapp', 'WhatsApp'), ('apprise', 'Apprise'), ('mattermost', 'Mattermost'), ('msteams', 'Microsoft Teams'), ('shell', 'Shell Command'), ('zulip', 'Zulip'), ('spike', 'Spike')], max_length=20),
|
||||
),
|
||||
]
|
@ -75,6 +75,7 @@ class Check(models.Model):
|
||||
schedule = models.CharField(max_length=100, default="* * * * *")
|
||||
tz = models.CharField(max_length=36, default="UTC")
|
||||
subject = models.CharField(max_length=100, blank=True)
|
||||
subject_fail = models.CharField(max_length=100, blank=True)
|
||||
methods = models.CharField(max_length=30, blank=True)
|
||||
manual_resume = models.NullBooleanField(default=False)
|
||||
|
||||
|
76
hc/api/tests/test_smtpd.py
Normal file
76
hc/api/tests/test_smtpd.py
Normal file
@ -0,0 +1,76 @@
|
||||
from hc.api.models import Check, Ping
|
||||
from hc.test import BaseTestCase
|
||||
from hc.api.management.commands.smtpd import _process_message
|
||||
|
||||
|
||||
PAYLOAD_TMPL = """
|
||||
From: "User Name" <username@gmail.com>
|
||||
To: "John Smith" <john@example.com>
|
||||
Subject: %s
|
||||
|
||||
...
|
||||
""".strip()
|
||||
|
||||
|
||||
class SmtpdTestCase(BaseTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.check = Check.objects.create(project=self.project)
|
||||
self.email = "%s@does.not.matter" % self.check.code
|
||||
|
||||
def test_it_works(self):
|
||||
_process_message("1.2.3.4", "foo@example.org", self.email, b"hello world")
|
||||
|
||||
ping = Ping.objects.latest("id")
|
||||
self.assertEqual(ping.scheme, "email")
|
||||
self.assertEqual(ping.ua, "Email from foo@example.org")
|
||||
self.assertEqual(ping.body, "hello world")
|
||||
self.assertEqual(ping.kind, None)
|
||||
|
||||
def test_it_handles_subject_filter_match(self):
|
||||
self.check.subject = "SUCCESS"
|
||||
self.check.save()
|
||||
|
||||
body = PAYLOAD_TMPL % "[SUCCESS] Backup completed"
|
||||
_process_message("1.2.3.4", "foo@example.org", self.email, body.encode("utf8"))
|
||||
|
||||
ping = Ping.objects.latest("id")
|
||||
self.assertEqual(ping.scheme, "email")
|
||||
self.assertEqual(ping.ua, "Email from foo@example.org")
|
||||
self.assertEqual(ping.kind, None)
|
||||
|
||||
def test_it_handles_subject_filter_miss(self):
|
||||
self.check.subject = "SUCCESS"
|
||||
self.check.save()
|
||||
|
||||
body = PAYLOAD_TMPL % "[FAIL] Backup did not complete"
|
||||
_process_message("1.2.3.4", "foo@example.org", self.email, body.encode("utf8"))
|
||||
|
||||
ping = Ping.objects.latest("id")
|
||||
self.assertEqual(ping.scheme, "email")
|
||||
self.assertEqual(ping.ua, "Email from foo@example.org")
|
||||
self.assertEqual(ping.kind, "ign")
|
||||
|
||||
def test_it_handles_subject_fail_filter_match(self):
|
||||
self.check.subject_fail = "FAIL"
|
||||
self.check.save()
|
||||
|
||||
body = PAYLOAD_TMPL % "[FAIL] Backup did not complete"
|
||||
_process_message("1.2.3.4", "foo@example.org", self.email, body.encode("utf8"))
|
||||
|
||||
ping = Ping.objects.latest("id")
|
||||
self.assertEqual(ping.scheme, "email")
|
||||
self.assertEqual(ping.ua, "Email from foo@example.org")
|
||||
self.assertEqual(ping.kind, "fail")
|
||||
|
||||
def test_it_handles_subject_fail_filter_miss(self):
|
||||
self.check.subject_fail = "FAIL"
|
||||
self.check.save()
|
||||
|
||||
body = PAYLOAD_TMPL % "[SUCCESS] Backup completed"
|
||||
_process_message("1.2.3.4", "foo@example.org", self.email, body.encode("utf8"))
|
||||
|
||||
ping = Ping.objects.latest("id")
|
||||
self.assertEqual(ping.scheme, "email")
|
||||
self.assertEqual(ping.ua, "Email from foo@example.org")
|
||||
self.assertEqual(ping.kind, "ign")
|
@ -63,10 +63,24 @@ class NameTagsForm(forms.Form):
|
||||
|
||||
|
||||
class FilteringRulesForm(forms.Form):
|
||||
filter_by_subject = forms.ChoiceField(choices=(("no", "no"), ("yes", "yes")))
|
||||
subject = forms.CharField(required=False, max_length=100)
|
||||
subject_fail = forms.CharField(required=False, max_length=100)
|
||||
methods = forms.ChoiceField(required=False, choices=(("", "Any"), ("POST", "POST")))
|
||||
manual_resume = forms.BooleanField(required=False)
|
||||
|
||||
def clean_subject(self):
|
||||
if self.cleaned_data["filter_by_subject"] == "yes":
|
||||
return self.cleaned_data["subject"]
|
||||
|
||||
return ""
|
||||
|
||||
def clean_subject_fail(self):
|
||||
if self.cleaned_data["filter_by_subject"] == "yes":
|
||||
return self.cleaned_data["subject_fail"]
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
class TimeoutForm(forms.Form):
|
||||
timeout = forms.IntegerField(min_value=60, max_value=2592000)
|
||||
|
@ -11,15 +11,21 @@ class FilteringRulesTestCase(BaseTestCase):
|
||||
self.redirect_url = "/checks/%s/details/" % self.check.code
|
||||
|
||||
def test_it_works(self):
|
||||
payload = {
|
||||
"subject": "SUCCESS",
|
||||
"subject_fail": "ERROR",
|
||||
"methods": "POST",
|
||||
"manual_resume": "1",
|
||||
"filter_by_subject": "yes",
|
||||
}
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
r = self.client.post(
|
||||
self.url,
|
||||
data={"subject": "SUCCESS", "methods": "POST", "manual_resume": "1"},
|
||||
)
|
||||
r = self.client.post(self.url, data=payload,)
|
||||
self.assertRedirects(r, self.redirect_url)
|
||||
|
||||
self.check.refresh_from_db()
|
||||
self.assertEqual(self.check.subject, "SUCCESS")
|
||||
self.assertEqual(self.check.subject_fail, "ERROR")
|
||||
self.assertEqual(self.check.methods, "POST")
|
||||
self.assertTrue(self.check.manual_resume)
|
||||
|
||||
@ -27,8 +33,10 @@ class FilteringRulesTestCase(BaseTestCase):
|
||||
self.check.method = "POST"
|
||||
self.check.save()
|
||||
|
||||
payload = {"subject": "SUCCESS", "methods": "", "filter_by_subject": "yes"}
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
r = self.client.post(self.url, data={"subject": "SUCCESS", "methods": ""})
|
||||
r = self.client.post(self.url, data=payload)
|
||||
self.assertRedirects(r, self.redirect_url)
|
||||
|
||||
self.check.refresh_from_db()
|
||||
@ -36,21 +44,30 @@ class FilteringRulesTestCase(BaseTestCase):
|
||||
|
||||
def test_it_clears_subject(self):
|
||||
self.check.subject = "SUCCESS"
|
||||
self.check.subject_fail = "ERROR"
|
||||
self.check.save()
|
||||
|
||||
payload = {
|
||||
"methods": "",
|
||||
"filter_by_subject": "no",
|
||||
"subject": "foo",
|
||||
"subject_fail": "bar",
|
||||
}
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
r = self.client.post(self.url, data={"methods": ""})
|
||||
r = self.client.post(self.url, data=payload)
|
||||
self.assertRedirects(r, self.redirect_url)
|
||||
|
||||
self.check.refresh_from_db()
|
||||
self.assertEqual(self.check.subject, "")
|
||||
self.assertEqual(self.check.subject_fail, "")
|
||||
|
||||
def test_it_clears_manual_resume_flag(self):
|
||||
self.check.manual_resume = True
|
||||
self.check.save()
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
r = self.client.post(self.url, data={})
|
||||
r = self.client.post(self.url, data={"filter_by_subject": "no"})
|
||||
self.assertRedirects(r, self.redirect_url)
|
||||
|
||||
self.check.refresh_from_db()
|
||||
|
@ -353,6 +353,7 @@ def filtering_rules(request, code):
|
||||
form = forms.FilteringRulesForm(request.POST)
|
||||
if form.is_valid():
|
||||
check.subject = form.cleaned_data["subject"]
|
||||
check.subject_fail = form.cleaned_data["subject_fail"]
|
||||
check.methods = form.cleaned_data["methods"]
|
||||
check.manual_resume = form.cleaned_data["manual_resume"]
|
||||
check.save()
|
||||
@ -1726,7 +1727,6 @@ def metrics(request, code, key):
|
||||
return HttpResponse(output(checks), content_type="text/plain")
|
||||
|
||||
|
||||
|
||||
@login_required
|
||||
def add_spike(request, code):
|
||||
project = _get_project_for_user(request, code)
|
||||
@ -1745,4 +1745,3 @@ def add_spike(request, code):
|
||||
|
||||
ctx = {"page": "channels", "project": project, "form": form}
|
||||
return render(request, "integrations/add_spike.html", ctx)
|
||||
|
||||
|
@ -128,3 +128,22 @@ ul.crosses {
|
||||
ul.crosses li:before {
|
||||
content: '✘ ';
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
#filtering-rules-modal .modal-dialog {
|
||||
width: 650px;
|
||||
}
|
||||
}
|
||||
|
||||
#filtering-rules-modal .modal-body {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
#filtering-rules-modal h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
|
||||
#filtering-rules-modal hr {
|
||||
margin: 0;
|
||||
}
|
@ -177,4 +177,11 @@ $(function () {
|
||||
$("#transfer-confirm").prop("disabled", !this.value);
|
||||
});
|
||||
|
||||
|
||||
// Enable/disable fields in the "Filtering Rules" modal
|
||||
$("input[type=radio][name=filter_by_subject]").on("change", function() {
|
||||
var enableInputs = this.value == "yes";
|
||||
$(".filter-by-subject").prop("disabled", !enableInputs);
|
||||
});
|
||||
|
||||
});
|
||||
|
@ -8,11 +8,8 @@
|
||||
method="post">
|
||||
{% csrf_token %}
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal">×</button>
|
||||
<h4>Filtering Rules</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<h2>HTTP Requests</h2>
|
||||
<p>Allowed request methods for HTTP requests:</p>
|
||||
<label class="radio-container">
|
||||
<input
|
||||
@ -38,29 +35,70 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="modal-body">
|
||||
<p>Filtering of Inbound Email Messages</p>
|
||||
<h2>Inbound Emails</h2>
|
||||
<label class="radio-container">
|
||||
<input
|
||||
type="radio"
|
||||
name="filter_by_subject"
|
||||
value="no"
|
||||
{% if not check.subject and not check.subject_fail %}checked{% endif %} />
|
||||
<span class="radiomark"></span>
|
||||
No filtering. Treat all emails as "success"
|
||||
</label>
|
||||
<label class="radio-container">
|
||||
<input
|
||||
type="radio"
|
||||
name="filter_by_subject"
|
||||
value="yes"
|
||||
{% if check.subject or check.subject_fail %}checked{% endif %} />
|
||||
<span class="radiomark"></span>
|
||||
Filter by keywords in the Subject line:
|
||||
</label>
|
||||
|
||||
|
||||
<div class="form-group">
|
||||
<label for="update-name-input" class="col-sm-4 control-label">
|
||||
Subject Must Contain
|
||||
Success Keyword
|
||||
</label>
|
||||
<div class="col-sm-7">
|
||||
<input
|
||||
name="subject"
|
||||
type="text"
|
||||
value="{{ check.subject }}"
|
||||
class="input-name form-control" />
|
||||
|
||||
{% if not check.subject and not check.subject_fail %}disabled{% endif %}
|
||||
class="input-name form-control filter-by-subject" />
|
||||
<span class="help-block">
|
||||
If set, {% site_name %} will ignore emails
|
||||
without this value in the Subject line.
|
||||
If Subject contains this keyword,
|
||||
treat it as "success".
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="update-name-input" class="col-sm-4 control-label">
|
||||
Failure Keyword
|
||||
</label>
|
||||
<div class="col-sm-7">
|
||||
<input
|
||||
name="subject_fail"
|
||||
type="text"
|
||||
value="{{ check.subject_fail }}"
|
||||
{% if not check.subject and not check.subject_fail %}disabled{% endif %}
|
||||
class="input-name form-control filter-by-subject" />
|
||||
<span class="help-block">
|
||||
If Subject contains this keyword,
|
||||
treat it as "failure".
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
|
||||
<div class="modal-body">
|
||||
<p>Handling of pings while paused:</p>
|
||||
<h2>Pinging a Paused Check</h2>
|
||||
<p>When a paused check receives a ping:</p>
|
||||
<label class="radio-container">
|
||||
<input
|
||||
type="radio"
|
||||
@ -77,7 +115,7 @@
|
||||
value="1"
|
||||
{% if check.manual_resume %}checked{% endif %}>
|
||||
<span class="radiomark"></span>
|
||||
Ignore pings, stay in the paused state
|
||||
Ignore the ping, stay in the paused state
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user