diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d8ebac6..72679cdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/hc/api/management/commands/smtpd.py b/hc/api/management/commands/smtpd.py index 8b894053..5dfb758b 100644 --- a/hc/api/management/commands/smtpd.py +++ b/hc/api/management/commands/smtpd.py @@ -12,45 +12,49 @@ RE_UUID = re.compile( ) +def _process_message(remote_addr, mailfrom, mailto, data): + to_parts = mailto.split("@") + code = to_parts[0] + + try: + data = data.decode() + except UnicodeError: + data = "[binary data]" + + if not RE_UUID.match(code): + return f"Not an UUID: {code}" + + try: + check = Check.objects.get(code=code) + except Check.DoesNotExist: + return f"Check not found: {code}" + + action = "success" + 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(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, mail_options=None, rcpt_options=None - ): + 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() - to_parts = rcpttos[0].split("@") - code = to_parts[0] - - try: - data = data.decode() - except UnicodeError: - data = "[binary data]" - - if not RE_UUID.match(code): - self.stdout.write("Not an UUID: %s" % code) - return - - try: - check = Check.objects.get(code=code) - except Check.DoesNotExist: - self.stdout.write("Check not found: %s" % code) - return - - action = "success" - if check.subject: - parsed = email.message_from_string(data) - received_subject = parsed.get("subject", "") - if check.subject not in received_subject: - action = "ign" - - ua = "Email from %s" % mailfrom - check.ping(peer[0], "email", "", ua, data, action) - self.stdout.write("Processed ping for %s" % code) + 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() diff --git a/hc/api/migrations/0073_auto_20200721_1000.py b/hc/api/migrations/0073_auto_20200721_1000.py new file mode 100644 index 00000000..8db4cc1e --- /dev/null +++ b/hc/api/migrations/0073_auto_20200721_1000.py @@ -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), + ), + ] diff --git a/hc/api/models.py b/hc/api/models.py index 08bbde94..8e7b3040 100644 --- a/hc/api/models.py +++ b/hc/api/models.py @@ -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) diff --git a/hc/api/tests/test_smtpd.py b/hc/api/tests/test_smtpd.py new file mode 100644 index 00000000..ae8240d2 --- /dev/null +++ b/hc/api/tests/test_smtpd.py @@ -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" +To: "John Smith" +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") diff --git a/hc/front/forms.py b/hc/front/forms.py index 3aaabc96..fd9f0a32 100644 --- a/hc/front/forms.py +++ b/hc/front/forms.py @@ -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) diff --git a/hc/front/tests/test_filtering_rules.py b/hc/front/tests/test_filtering_rules.py index c0417c74..cdf8d71f 100644 --- a/hc/front/tests/test_filtering_rules.py +++ b/hc/front/tests/test_filtering_rules.py @@ -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() diff --git a/hc/front/views.py b/hc/front/views.py index b3604e56..2df1777a 100644 --- a/hc/front/views.py +++ b/hc/front/views.py @@ -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) - diff --git a/static/css/details.css b/static/css/details.css index e318d5a1..ec54dc24 100644 --- a/static/css/details.css +++ b/static/css/details.css @@ -127,4 +127,23 @@ 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; } \ No newline at end of file diff --git a/static/js/details.js b/static/js/details.js index 88c1d6af..3992ffc5 100644 --- a/static/js/details.js +++ b/static/js/details.js @@ -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); + }); + }); diff --git a/templates/front/filtering_rules_modal.html b/templates/front/filtering_rules_modal.html index 82263692..e3711232 100644 --- a/templates/front/filtering_rules_modal.html +++ b/templates/front/filtering_rules_modal.html @@ -8,11 +8,8 @@ method="post"> {% csrf_token %}