Browse Source

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

pull/406/head
Pēteris Caune 4 years ago
parent
commit
0d03e3f00b
No known key found for this signature in database GPG Key ID: E28D7679E9A9EDE2
11 changed files with 252 additions and 53 deletions
  1. +1
    -0
      CHANGELOG.md
  2. +36
    -32
      hc/api/management/commands/smtpd.py
  3. +23
    -0
      hc/api/migrations/0073_auto_20200721_1000.py
  4. +1
    -0
      hc/api/models.py
  5. +76
    -0
      hc/api/tests/test_smtpd.py
  6. +14
    -0
      hc/front/forms.py
  7. +24
    -7
      hc/front/tests/test_filtering_rules.py
  8. +1
    -2
      hc/front/views.py
  9. +19
    -0
      static/css/details.css
  10. +7
    -0
      static/js/details.js
  11. +50
    -12
      templates/front/filtering_rules_modal.html

+ 1
- 0
CHANGELOG.md 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


+ 36
- 32
hc/api/management/commands/smtpd.py View File

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

+ 23
- 0
hc/api/migrations/0073_auto_20200721_1000.py 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),
),
]

+ 1
- 0
hc/api/models.py 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)


+ 76
- 0
hc/api/tests/test_smtpd.py 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", "[email protected]", self.email, b"hello world")
ping = Ping.objects.latest("id")
self.assertEqual(ping.scheme, "email")
self.assertEqual(ping.ua, "Email from [email protected]")
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", "[email protected]", self.email, body.encode("utf8"))
ping = Ping.objects.latest("id")
self.assertEqual(ping.scheme, "email")
self.assertEqual(ping.ua, "Email from [email protected]")
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", "[email protected]", self.email, body.encode("utf8"))
ping = Ping.objects.latest("id")
self.assertEqual(ping.scheme, "email")
self.assertEqual(ping.ua, "Email from [email protected]")
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", "[email protected]", self.email, body.encode("utf8"))
ping = Ping.objects.latest("id")
self.assertEqual(ping.scheme, "email")
self.assertEqual(ping.ua, "Email from [email protected]")
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", "[email protected]", self.email, body.encode("utf8"))
ping = Ping.objects.latest("id")
self.assertEqual(ping.scheme, "email")
self.assertEqual(ping.ua, "Email from [email protected]")
self.assertEqual(ping.kind, "ign")

+ 14
- 0
hc/front/forms.py 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)


+ 24
- 7
hc/front/tests/test_filtering_rules.py 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="[email protected]", 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="[email protected]", 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="[email protected]", 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="[email protected]", 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()


+ 1
- 2
hc/front/views.py 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)

+ 19
- 0
static/css/details.css View File

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

+ 7
- 0
static/js/details.js 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);
});
});

+ 50
- 12
templates/front/filtering_rules_modal.html 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 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 set, {% site_name %} will ignore emails
without this value in the Subject line.
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…
Cancel
Save