Add "Failure Keyword" filtering for inbound emails (cc: #396)

This commit is contained in:
Pēteris Caune 2020-07-21 14:57:48 +03:00
parent 556e8c67c5
commit 0d03e3f00b
No known key found for this signature in database
GPG Key ID: E28D7679E9A9EDE2
11 changed files with 252 additions and 53 deletions

View File

@ -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

View File

@ -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()

View 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),
),
]

View File

@ -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)

View 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")

View File

@ -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)

View File

@ -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()

View File

@ -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)

View File

@ -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;
}

View File

@ -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);
});
});

View File

@ -8,11 +8,8 @@
method="post">
{% csrf_token %}
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal">&times;</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>