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 - Added "Docs > Reliability Tips" page
- Spike.sh integration (#402) - Spike.sh integration (#402)
- Updated Discord integration to use discord.com instead of discordapp.com - Updated Discord integration to use discord.com instead of discordapp.com
- Add "Failure Keyword" filtering for inbound emails (#396)
### Bug Fixes ### Bug Fixes
- Removing Pager Team integration, project appears to be discontinued - 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): class Listener(SMTPServer):
def __init__(self, localaddr, stdout): def __init__(self, localaddr, stdout):
self.stdout = stdout self.stdout = stdout
super(Listener, self).__init__(localaddr, None, decode_data=False) 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: # get a new db connection in case the old one has timed out:
connections.close_all() 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): class Command(BaseCommand):
@ -65,6 +69,6 @@ class Command(BaseCommand):
) )
def handle(self, host, port, *args, **options): 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)) print("Starting SMTP listener on %s:%d ..." % (host, port))
asyncore.loop() 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="* * * * *") schedule = models.CharField(max_length=100, default="* * * * *")
tz = models.CharField(max_length=36, default="UTC") tz = models.CharField(max_length=36, default="UTC")
subject = models.CharField(max_length=100, blank=True) 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) methods = models.CharField(max_length=30, blank=True)
manual_resume = models.NullBooleanField(default=False) 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): class FilteringRulesForm(forms.Form):
filter_by_subject = forms.ChoiceField(choices=(("no", "no"), ("yes", "yes")))
subject = forms.CharField(required=False, max_length=100) 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"))) methods = forms.ChoiceField(required=False, choices=(("", "Any"), ("POST", "POST")))
manual_resume = forms.BooleanField(required=False) 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): class TimeoutForm(forms.Form):
timeout = forms.IntegerField(min_value=60, max_value=2592000) 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 self.redirect_url = "/checks/%s/details/" % self.check.code
def test_it_works(self): 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") 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.assertRedirects(r, self.redirect_url)
self.check.refresh_from_db() self.check.refresh_from_db()
self.assertEqual(self.check.subject, "SUCCESS") self.assertEqual(self.check.subject, "SUCCESS")
self.assertEqual(self.check.subject_fail, "ERROR")
self.assertEqual(self.check.methods, "POST") self.assertEqual(self.check.methods, "POST")
self.assertTrue(self.check.manual_resume) self.assertTrue(self.check.manual_resume)
@ -27,8 +33,10 @@ class FilteringRulesTestCase(BaseTestCase):
self.check.method = "POST" self.check.method = "POST"
self.check.save() self.check.save()
payload = {"subject": "SUCCESS", "methods": "", "filter_by_subject": "yes"}
self.client.login(username="[email protected]", password="password") 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.assertRedirects(r, self.redirect_url)
self.check.refresh_from_db() self.check.refresh_from_db()
@ -36,21 +44,30 @@ class FilteringRulesTestCase(BaseTestCase):
def test_it_clears_subject(self): def test_it_clears_subject(self):
self.check.subject = "SUCCESS" self.check.subject = "SUCCESS"
self.check.subject_fail = "ERROR"
self.check.save() self.check.save()
payload = {
"methods": "",
"filter_by_subject": "no",
"subject": "foo",
"subject_fail": "bar",
}
self.client.login(username="[email protected]", password="password") 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.assertRedirects(r, self.redirect_url)
self.check.refresh_from_db() self.check.refresh_from_db()
self.assertEqual(self.check.subject, "") self.assertEqual(self.check.subject, "")
self.assertEqual(self.check.subject_fail, "")
def test_it_clears_manual_resume_flag(self): def test_it_clears_manual_resume_flag(self):
self.check.manual_resume = True self.check.manual_resume = True
self.check.save() self.check.save()
self.client.login(username="[email protected]", password="password") 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.assertRedirects(r, self.redirect_url)
self.check.refresh_from_db() 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) form = forms.FilteringRulesForm(request.POST)
if form.is_valid(): if form.is_valid():
check.subject = form.cleaned_data["subject"] check.subject = form.cleaned_data["subject"]
check.subject_fail = form.cleaned_data["subject_fail"]
check.methods = form.cleaned_data["methods"] check.methods = form.cleaned_data["methods"]
check.manual_resume = form.cleaned_data["manual_resume"] check.manual_resume = form.cleaned_data["manual_resume"]
check.save() check.save()
@ -1726,7 +1727,6 @@ def metrics(request, code, key):
return HttpResponse(output(checks), content_type="text/plain") return HttpResponse(output(checks), content_type="text/plain")
@login_required @login_required
def add_spike(request, code): def add_spike(request, code):
project = _get_project_for_user(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} ctx = {"page": "channels", "project": project, "form": form}
return render(request, "integrations/add_spike.html", ctx) 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 { ul.crosses li:before {
content: '✘ '; 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); $("#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"> method="post">
{% csrf_token %} {% csrf_token %}
<div class="modal-content"> <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"> <div class="modal-body">
<h2>HTTP Requests</h2>
<p>Allowed request methods for HTTP requests:</p> <p>Allowed request methods for HTTP requests:</p>
<label class="radio-container"> <label class="radio-container">
<input <input
@ -38,29 +35,70 @@
</label> </label>
</div> </div>
<hr>
<div class="modal-body"> <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"> <div class="form-group">
<label for="update-name-input" class="col-sm-4 control-label"> <label for="update-name-input" class="col-sm-4 control-label">
Subject Must Contain
Success Keyword
</label> </label>
<div class="col-sm-7"> <div class="col-sm-7">
<input <input
name="subject" name="subject"
type="text" type="text"
value="{{ check.subject }}" 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"> <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> </span>
</div> </div>
</div> </div>
</div> </div>
<hr>
<div class="modal-body"> <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"> <label class="radio-container">
<input <input
type="radio" type="radio"
@ -77,7 +115,7 @@
value="1" value="1"
{% if check.manual_resume %}checked{% endif %}> {% if check.manual_resume %}checked{% endif %}>
<span class="radiomark"></span> <span class="radiomark"></span>
Ignore pings, stay in the paused state
Ignore the ping, stay in the paused state
</label> </label>
</div> </div>


Loading…
Cancel
Save