From f63a8c9470462a9bc5f54345b319de23211454e3 Mon Sep 17 00:00:00 2001 From: Chris Jackson Date: Wed, 9 Dec 2020 01:33:24 -1000 Subject: [PATCH 1/8] Add event publishing support for Send, Reject, Open, Click, RenderingFailure, and DeliveryDelay events --- django_bouncy/admin.py | 35 +- ...delay_open_reject_renderingfailure_send.py | 146 ++++++++ django_bouncy/models.py | 77 ++++ .../tests/examples/example_click.json | 91 +++++ .../examples/example_delivery_delay.json | 29 ++ .../tests/examples/example_open.json | 78 ++++ .../tests/examples/example_reject.json | 73 ++++ .../examples/example_rendering_failure.json | 23 ++ .../tests/examples/example_send.json | 71 ++++ django_bouncy/tests/views.py | 347 +++++++++++++++++- django_bouncy/views.py | 244 +++++++++++- 11 files changed, 1193 insertions(+), 21 deletions(-) create mode 100644 django_bouncy/migrations/0006_click_deliverydelay_open_reject_renderingfailure_send.py create mode 100644 django_bouncy/tests/examples/example_click.json create mode 100644 django_bouncy/tests/examples/example_delivery_delay.json create mode 100644 django_bouncy/tests/examples/example_open.json create mode 100644 django_bouncy/tests/examples/example_reject.json create mode 100644 django_bouncy/tests/examples/example_rendering_failure.json create mode 100644 django_bouncy/tests/examples/example_send.json diff --git a/django_bouncy/admin.py b/django_bouncy/admin.py index 7883d67..cdf7457 100644 --- a/django_bouncy/admin.py +++ b/django_bouncy/admin.py @@ -2,7 +2,7 @@ from django.contrib import admin -from django_bouncy.models import Bounce, Complaint, Delivery +from django_bouncy.models import Bounce, Complaint, Delivery, Send, Open, Click, RenderingFailure, DeliveryDelay class BounceAdmin(admin.ModelAdmin): @@ -30,6 +30,39 @@ class DeliveryAdmin(admin.ModelAdmin): search_fields = ('address',) +class SendAdmin(admin.ModelAdmin): + list_display = ('address', 'mail_from',) + search_fields = ('address',) + + +class OpenAdmin(admin.ModelAdmin): + list_display = ('opened_time', 'address', 'mail_from',) + list_filter = ('opened_time',) + search_fields = ('address',) + + +class ClickAdmin(admin.ModelAdmin): + list_display = ('clicked_time', 'address', 'mail_from', 'link',) + list_filter = ('clicked_time',) + search_fields = ('address',) + + +class RenderingFailureAdmin(admin.ModelAdmin): + list_display = ('address', 'mail_from', 'template_name', 'error_message',) + search_fields = ('address', 'template_name',) + + +class DeliveryDelayAdmin(admin.ModelAdmin): + list_display = ('delayed_time', 'address', 'mail_from', 'delay_type',) + list_filter = ('delayed_time',) + search_fields = ('address', 'delay_type',) + + admin.site.register(Bounce, BounceAdmin) admin.site.register(Complaint, ComplaintAdmin) admin.site.register(Delivery, DeliveryAdmin) +admin.site.register(Send, SendAdmin) +admin.site.register(Open, OpenAdmin) +admin.site.register(Click, ClickAdmin) +admin.site.register(RenderingFailure, RenderingFailureAdmin) +admin.site.register(DeliveryDelay, DeliveryDelayAdmin) diff --git a/django_bouncy/migrations/0006_click_deliverydelay_open_reject_renderingfailure_send.py b/django_bouncy/migrations/0006_click_deliverydelay_open_reject_renderingfailure_send.py new file mode 100644 index 0000000..4505185 --- /dev/null +++ b/django_bouncy/migrations/0006_click_deliverydelay_open_reject_renderingfailure_send.py @@ -0,0 +1,146 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.23 on 2020-12-09 02:03 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('django_bouncy', '0005_auto_20190731_0423'), + ] + + operations = [ + migrations.CreateModel( + name='Click', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('modified_at', models.DateTimeField(auto_now=True)), + ('sns_topic', models.CharField(max_length=350)), + ('sns_messageid', models.CharField(max_length=100)), + ('mail_timestamp', models.DateTimeField()), + ('mail_id', models.CharField(max_length=100)), + ('mail_from', models.EmailField(max_length=254)), + ('address', models.EmailField(max_length=254)), + ('feedback_id', models.CharField(blank=True, max_length=100, null=True)), + ('feedback_timestamp', models.DateTimeField(blank=True, null=True, verbose_name='Feedback Time')), + ('clicked_time', models.DateTimeField(blank=True, null=True)), + ('ip_address', models.GenericIPAddressField(blank=True, null=True)), + ('useragent', models.TextField(blank=True, null=True)), + ('link', models.TextField(blank=True, null=True)), + ('link_tags', models.TextField(blank=True, null=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='DeliveryDelay', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('modified_at', models.DateTimeField(auto_now=True)), + ('sns_topic', models.CharField(max_length=350)), + ('sns_messageid', models.CharField(max_length=100)), + ('mail_timestamp', models.DateTimeField()), + ('mail_id', models.CharField(max_length=100)), + ('mail_from', models.EmailField(max_length=254)), + ('address', models.EmailField(max_length=254)), + ('feedback_id', models.CharField(blank=True, max_length=100, null=True)), + ('feedback_timestamp', models.DateTimeField(blank=True, null=True, verbose_name='Feedback Time')), + ('delayed_time', models.DateTimeField(blank=True, null=True)), + ('delay_type', models.TextField(blank=True, null=True)), + ('expiration_time', models.DateTimeField(blank=True, null=True)), + ('reporting_mta', models.GenericIPAddressField(blank=True, null=True)), + ('status', models.TextField(blank=True, null=True)), + ('diagnostic_code', models.TextField(blank=True, null=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Open', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('modified_at', models.DateTimeField(auto_now=True)), + ('sns_topic', models.CharField(max_length=350)), + ('sns_messageid', models.CharField(max_length=100)), + ('mail_timestamp', models.DateTimeField()), + ('mail_id', models.CharField(max_length=100)), + ('mail_from', models.EmailField(max_length=254)), + ('address', models.EmailField(max_length=254)), + ('feedback_id', models.CharField(blank=True, max_length=100, null=True)), + ('feedback_timestamp', models.DateTimeField(blank=True, null=True, verbose_name='Feedback Time')), + ('opened_time', models.DateTimeField(blank=True, null=True)), + ('ip_address', models.GenericIPAddressField(blank=True, null=True)), + ('useragent', models.TextField(blank=True, null=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Reject', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('modified_at', models.DateTimeField(auto_now=True)), + ('sns_topic', models.CharField(max_length=350)), + ('sns_messageid', models.CharField(max_length=100)), + ('mail_timestamp', models.DateTimeField()), + ('mail_id', models.CharField(max_length=100)), + ('mail_from', models.EmailField(max_length=254)), + ('address', models.EmailField(max_length=254)), + ('feedback_id', models.CharField(blank=True, max_length=100, null=True)), + ('feedback_timestamp', models.DateTimeField(blank=True, null=True, verbose_name='Feedback Time')), + ('reason', models.TextField(blank=True, null=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='RenderingFailure', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('modified_at', models.DateTimeField(auto_now=True)), + ('sns_topic', models.CharField(max_length=350)), + ('sns_messageid', models.CharField(max_length=100)), + ('mail_timestamp', models.DateTimeField()), + ('mail_id', models.CharField(max_length=100)), + ('mail_from', models.EmailField(max_length=254)), + ('address', models.EmailField(max_length=254)), + ('feedback_id', models.CharField(blank=True, max_length=100, null=True)), + ('feedback_timestamp', models.DateTimeField(blank=True, null=True, verbose_name='Feedback Time')), + ('template_name', models.TextField(blank=True, null=True)), + ('error_message', models.TextField(blank=True, null=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Send', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('modified_at', models.DateTimeField(auto_now=True)), + ('sns_topic', models.CharField(max_length=350)), + ('sns_messageid', models.CharField(max_length=100)), + ('mail_timestamp', models.DateTimeField()), + ('mail_id', models.CharField(max_length=100)), + ('mail_from', models.EmailField(max_length=254)), + ('address', models.EmailField(max_length=254)), + ('feedback_id', models.CharField(blank=True, max_length=100, null=True)), + ('feedback_timestamp', models.DateTimeField(blank=True, null=True, verbose_name='Feedback Time')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/django_bouncy/models.py b/django_bouncy/models.py index d0ef22b..3b9ba60 100644 --- a/django_bouncy/models.py +++ b/django_bouncy/models.py @@ -79,3 +79,80 @@ def __str__(self): class Meta(object): """Meta info for the Delivery model""" verbose_name_plural = 'deliveries' + + +@python_2_unicode_compatible +class Send(Feedback): + """A send report for an individual email address""" + + def __str__(self): + """Unicode representation of Send""" + return "%s Send (email sender: %s)" % ( + self.address, self.mail_from) + + +@python_2_unicode_compatible +class Reject(Feedback): + """A reject report for an individual email address""" + reason = models.TextField(blank=True, null=True) + + def __str__(self): + """Unicode representation of Reject""" + return "%s %s Reject (email sender: %s)" % ( + self.address, self.reason, self.mail_from) + + +@python_2_unicode_compatible +class Open(Feedback): + """An open report for an individual email address""" + opened_time = models.DateTimeField(blank=True, null=True, db_index=True) + ip_address = models.GenericIPAddressField(blank=True, null=True) + useragent = models.TextField(blank=True, null=True) + + def __str__(self): + """Unicode representation of Open""" + return "%s Open (email sender: %s)" % ( + self.address, self.mail_from) + + +@python_2_unicode_compatible +class Click(Feedback): + """A click report for an individual email address""" + clicked_time = models.DateTimeField(blank=True, null=True, db_index=True) + ip_address = models.GenericIPAddressField(blank=True, null=True) + useragent = models.TextField(blank=True, null=True) + link = models.TextField(blank=True, null=True) + link_tags = models.TextField(blank=True, null=True) + + def __str__(self): + """Unicode representation of Click""" + return "%s %s Open (email sender: %s)" % ( + self.address, self.link, self.mail_from) + + +@python_2_unicode_compatible +class RenderingFailure(Feedback): + """A rendering failure report for an individual email address""" + template_name = models.TextField(blank=True, null=True, db_index=True) + error_message = models.TextField(blank=True, null=True) + + def __str__(self): + """Unicode representation of Rendering Failure""" + return "%s %s rendering failure %s" % ( + self.address, self.template_name, self.error_message) + + +@python_2_unicode_compatible +class DeliveryDelay(Feedback): + """A delivery delay report for an individual email address""" + delayed_time = models.DateTimeField(blank=True, null=True, db_index=True) + delay_type = models.TextField(blank=True, null=True, db_index=True) + expiration_time = models.DateTimeField(blank=True, null=True) + reporting_mta = models.GenericIPAddressField(blank=True, null=True) + status = models.TextField(blank=True, null=True) + diagnostic_code = models.TextField(blank=True, null=True) + + def __str__(self): + """Unicode representation of Delivery Delay""" + return "%s delivery delay %s %s" % ( + self.address, self.status, self.diagnostic_code) diff --git a/django_bouncy/tests/examples/example_click.json b/django_bouncy/tests/examples/example_click.json new file mode 100644 index 0000000..465ebaa --- /dev/null +++ b/django_bouncy/tests/examples/example_click.json @@ -0,0 +1,91 @@ +{ + "eventType": "Click", + "click": { + "ipAddress": "192.0.2.1", + "link": "http://docs.aws.amazon.com/ses/latest/DeveloperGuide/send-email-smtp.html", + "linkTags": { + "samplekey0": [ + "samplevalue0" + ], + "samplekey1": [ + "samplevalue1" + ] + }, + "timestamp": "2017-08-09T23:51:25.570Z", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36" + }, + "mail": { + "commonHeaders": { + "from": [ + "sender@example.com" + ], + "messageId": "EXAMPLE7c191be45-e9aedb9a-02f9-4d12-a87d-dd0099a07f8a-000000", + "subject": "Message sent from Amazon SES", + "to": [ + "recipient@example.com" + ] + }, + "destination": [ + "recipient@example.com" + ], + "headers": [ + { + "name": "X-SES-CONFIGURATION-SET", + "value": "ConfigSet" + }, + { + "name":"X-SES-MESSAGE-TAGS", + "value":"myCustomTag1=myCustomValue1, myCustomTag2=myCustomValue2" + }, + { + "name": "From", + "value": "sender@example.com" + }, + { + "name": "To", + "value": "recipient@example.com" + }, + { + "name": "Subject", + "value": "Message sent from Amazon SES" + }, + { + "name": "MIME-Version", + "value": "1.0" + }, + { + "name": "Content-Type", + "value": "multipart/alternative; boundary=\"XBoundary\"" + }, + { + "name": "Message-ID", + "value": "EXAMPLE7c191be45-e9aedb9a-02f9-4d12-a87d-dd0099a07f8a-000000" + } + ], + "headersTruncated": false, + "messageId": "EXAMPLE7c191be45-e9aedb9a-02f9-4d12-a87d-dd0099a07f8a-000000", + "sendingAccountId": "123456789012", + "source": "sender@example.com", + "tags": { + "myCustomTag1":[ + "myCustomValue1" + ], + "myCustomTag2":[ + "myCustomValue2" + ], + "ses:caller-identity": [ + "ses_user" + ], + "ses:configuration-set": [ + "ConfigSet" + ], + "ses:from-domain": [ + "example.com" + ], + "ses:source-ip": [ + "192.0.2.0" + ] + }, + "timestamp": "2017-08-09T23:50:05.795Z" + } +} diff --git a/django_bouncy/tests/examples/example_delivery_delay.json b/django_bouncy/tests/examples/example_delivery_delay.json new file mode 100644 index 0000000..b6a83ce --- /dev/null +++ b/django_bouncy/tests/examples/example_delivery_delay.json @@ -0,0 +1,29 @@ +{ + "eventType": "DeliveryDelay", + "mail":{ + "timestamp":"2020-06-16T00:15:40.641Z", + "source":"sender@example.com", + "sourceArn":"arn:aws:ses:us-east-1:123456789012:identity/sender@example.com", + "sendingAccountId":"123456789012", + "messageId":"EXAMPLE7c191be45-e9aedb9a-02f9-4d12-a87d-dd0099a07f8a-000000", + "destination":[ + "recipient@example.com" + ], + "headersTruncated":false, + "tags":{ + "ses:configuration-set":[ + "ConfigSet" + ] + } + }, + "deliveryDelay": { + "timestamp": "2020-06-16T00:25:40.095Z", + "delayType": "TransientCommunicationFailure", + "expirationTime": "2020-06-16T00:25:40.914Z", + "delayedRecipients": [{ + "emailAddress": "recipient@example.com", + "status": "4.4.1", + "diagnosticCode": "smtp; 421 4.4.1 Unable to connect to remote host" + }] + } +} diff --git a/django_bouncy/tests/examples/example_open.json b/django_bouncy/tests/examples/example_open.json new file mode 100644 index 0000000..9a746f8 --- /dev/null +++ b/django_bouncy/tests/examples/example_open.json @@ -0,0 +1,78 @@ +{ + "eventType": "Open", + "mail": { + "commonHeaders": { + "from": [ + "sender@example.com" + ], + "messageId": "EXAMPLE7c191be45-e9aedb9a-02f9-4d12-a87d-dd0099a07f8a-000000", + "subject": "Message sent from Amazon SES", + "to": [ + "recipient@example.com" + ] + }, + "destination": [ + "recipient@example.com" + ], + "headers": [ + { + "name": "X-SES-CONFIGURATION-SET", + "value": "ConfigSet" + }, + { + "name":"X-SES-MESSAGE-TAGS", + "value":"myCustomTag1=myCustomValue1, myCustomTag2=myCustomValue2" + }, + { + "name": "From", + "value": "sender@example.com" + }, + { + "name": "To", + "value": "recipient@example.com" + }, + { + "name": "Subject", + "value": "Message sent from Amazon SES" + }, + { + "name": "MIME-Version", + "value": "1.0" + }, + { + "name": "Content-Type", + "value": "multipart/alternative; boundary=\"XBoundary\"" + } + ], + "headersTruncated": false, + "messageId": "EXAMPLE7c191be45-e9aedb9a-02f9-4d12-a87d-dd0099a07f8a-000000", + "sendingAccountId": "123456789012", + "source": "sender@example.com", + "tags": { + "myCustomTag1":[ + "myCustomValue1" + ], + "myCustomTag2":[ + "myCustomValue2" + ], + "ses:caller-identity": [ + "ses-user" + ], + "ses:configuration-set": [ + "ConfigSet" + ], + "ses:from-domain": [ + "example.com" + ], + "ses:source-ip": [ + "192.0.2.0" + ] + }, + "timestamp": "2017-08-09T21:59:49.927Z" + }, + "open": { + "ipAddress": "192.0.2.1", + "timestamp": "2017-08-09T22:00:19.652Z", + "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_3 like Mac OS X) AppleWebKit/603.3.8 (KHTML, like Gecko) Mobile/14G60" + } +} diff --git a/django_bouncy/tests/examples/example_reject.json b/django_bouncy/tests/examples/example_reject.json new file mode 100644 index 0000000..46a643b --- /dev/null +++ b/django_bouncy/tests/examples/example_reject.json @@ -0,0 +1,73 @@ +{ + "eventType": "Reject", + "mail": { + "timestamp": "2016-10-14T17:38:15.211Z", + "source": "sender@example.com", + "sourceArn": "arn:aws:ses:us-east-1:123456789012:identity/sender@example.com", + "sendingAccountId": "123456789012", + "messageId": "EXAMPLE7c191be45-e9aedb9a-02f9-4d12-a87d-dd0099a07f8a-000000", + "destination": [ + "sender@example.com" + ], + "headersTruncated": false, + "headers": [ + { + "name": "From", + "value": "sender@example.com" + }, + { + "name": "To", + "value": "recipient@example.com" + }, + { + "name": "Subject", + "value": "Message sent from Amazon SES" + }, + { + "name": "MIME-Version", + "value": "1.0" + }, + { + "name": "Content-Type", + "value": "multipart/mixed; boundary=\"qMm9M+Fa2AknHoGS\"" + }, + { + "name": "X-SES-MESSAGE-TAGS", + "value": "myCustomTag1=myCustomTagValue1, myCustomTag2=myCustomTagValue2" + } + ], + "commonHeaders": { + "from": [ + "sender@example.com" + ], + "to": [ + "recipient@example.com" + ], + "messageId": "EXAMPLE7c191be45-e9aedb9a-02f9-4d12-a87d-dd0099a07f8a-000000", + "subject": "Message sent from Amazon SES" + }, + "tags": { + "ses:configuration-set": [ + "ConfigSet" + ], + "ses:source-ip": [ + "192.0.2.0" + ], + "ses:from-domain": [ + "example.com" + ], + "ses:caller-identity": [ + "ses_user" + ], + "myCustomTag1": [ + "myCustomTagValue1" + ], + "myCustomTag2": [ + "myCustomTagValue2" + ] + } + }, + "reject": { + "reason": "Bad content" + } +} diff --git a/django_bouncy/tests/examples/example_rendering_failure.json b/django_bouncy/tests/examples/example_rendering_failure.json new file mode 100644 index 0000000..7530545 --- /dev/null +++ b/django_bouncy/tests/examples/example_rendering_failure.json @@ -0,0 +1,23 @@ +{ + "eventType":"Rendering Failure", + "mail":{ + "timestamp":"2018-01-22T18:43:06.197Z", + "source":"sender@example.com", + "sourceArn":"arn:aws:ses:us-east-1:123456789012:identity/sender@example.com", + "sendingAccountId":"123456789012", + "messageId":"EXAMPLE7c191be45-e9aedb9a-02f9-4d12-a87d-dd0099a07f8a-000000", + "destination":[ + "recipient@example.com" + ], + "headersTruncated":false, + "tags":{ + "ses:configuration-set":[ + "ConfigSet" + ] + } + }, + "failure":{ + "errorMessage":"Attribute 'attributeName' is not present in the rendering data.", + "templateName":"MyTemplate" + } +} diff --git a/django_bouncy/tests/examples/example_send.json b/django_bouncy/tests/examples/example_send.json new file mode 100644 index 0000000..9e4774f --- /dev/null +++ b/django_bouncy/tests/examples/example_send.json @@ -0,0 +1,71 @@ +{ + "eventType": "Send", + "mail": { + "timestamp": "2016-10-14T05:02:16.645Z", + "source": "sender@example.com", + "sourceArn": "arn:aws:ses:us-east-1:123456789012:identity/sender@example.com", + "sendingAccountId": "123456789012", + "messageId": "EXAMPLE7c191be45-e9aedb9a-02f9-4d12-a87d-dd0099a07f8a-000000", + "destination": [ + "recipient@example.com" + ], + "headersTruncated": false, + "headers": [ + { + "name": "From", + "value": "sender@example.com" + }, + { + "name": "To", + "value": "recipient@example.com" + }, + { + "name": "Subject", + "value": "Message sent from Amazon SES" + }, + { + "name": "MIME-Version", + "value": "1.0" + }, + { + "name": "Content-Type", + "value": "multipart/mixed; boundary=\"----=_Part_0_716996660.1476421336341\"" + }, + { + "name": "X-SES-MESSAGE-TAGS", + "value": "myCustomTag1=myCustomTagValue1, myCustomTag2=myCustomTagValue2" + } + ], + "commonHeaders": { + "from": [ + "sender@example.com" + ], + "to": [ + "recipient@example.com" + ], + "messageId": "EXAMPLE7c191be45-e9aedb9a-02f9-4d12-a87d-dd0099a07f8a-000000", + "subject": "Message sent from Amazon SES" + }, + "tags": { + "ses:configuration-set": [ + "ConfigSet" + ], + "ses:source-ip": [ + "192.0.2.0" + ], + "ses:from-domain": [ + "example.com" + ], + "ses:caller-identity": [ + "ses_user" + ], + "myCustomTag1": [ + "myCustomTagValue1" + ], + "myCustomTag2": [ + "myCustomTagValue2" + ] + } + }, + "send": {} +} diff --git a/django_bouncy/tests/views.py b/django_bouncy/tests/views.py index aa27f17..9409e1c 100644 --- a/django_bouncy/tests/views.py +++ b/django_bouncy/tests/views.py @@ -18,7 +18,7 @@ from django_bouncy.tests.helpers import BouncyTestCase, loader from django_bouncy import views, signals from django_bouncy.utils import clean_time -from django_bouncy.models import Bounce, Complaint, Delivery +from django_bouncy.models import Bounce, Complaint, Delivery, Send, Open, Click, Reject, RenderingFailure, DeliveryDelay class BouncyEndpointViewTest(BouncyTestCase): @@ -409,3 +409,348 @@ def test_correct_delivery_created_long_response_time(self): smtp_response='250 ok: Message 64111812 accepted' ).exists()) + +class ProcessSendTest(BouncyTestCase): + def setUp(self): + self.send = loader('send') + + def test_send_created(self): + """Test that the Send object was created""" + original_count = Send.objects.count() + result = views.process_send( + self.send, self.notification) + new_count = Send.objects.count() + + self.assertEqual(new_count, original_count + 1) + self.assertEqual(result.status_code, 200) + self.assertEqual(result.content.decode('ascii'), 'Send Processed') + + def test_signals_sent(self): + """Test that the django send signal was sent""" + # pylint: disable=attribute-defined-outside-init, unused-variable + self.signal_count = 0 + + @receiver(signals.feedback) + def _signal_receiver(sender, **kwargs): + """Test signal receiver""" + # pylint: disable=unused-argument + self.signal_count += 1 + self.signal_notification = kwargs['notification'] + self.signal_message = kwargs['message'] + + result = views.process_send( + self.send, self.notification) + + self.assertEqual(result.status_code, 200) + self.assertEqual(result.content.decode('ascii'), 'Send Processed') + self.assertEqual(self.signal_count, 1) + self.assertEqual(self.signal_notification, self.notification) + + def test_correct_send_created(self): + """Test that the correct delivery was created""" + Send.objects.all().delete() + + result = views.process_send( + self.send, self.notification) + + self.assertEqual(result.status_code, 200) + self.assertEqual(result.content.decode('ascii'), 'Send Processed') + self.assertTrue(Send.objects.filter( + sns_topic='arn:aws:sns:us-east-1:250214102493:Demo_App_Unsubscribes', + sns_messageid='f34c6922-c3a1-54a1-bd88-23f998b43978', + mail_timestamp=clean_time('2016-10-14T05:02:16.645Z'), + mail_id='EXAMPLE7c191be45-e9aedb9a-02f9-4d12-a87d-dd0099a07f8a-000000', + mail_from='sender@example.com', + address='recipient@example.com', + ).exists()) + + +class ProcessOpenTest(BouncyTestCase): + def setUp(self): + self.open = loader('open') + + def test_open_created(self): + """Test that the Open object was created""" + original_count = Open.objects.count() + result = views.process_open( + self.open, self.notification) + new_count = Open.objects.count() + + self.assertEqual(new_count, original_count + 1) + self.assertEqual(result.status_code, 200) + self.assertEqual(result.content.decode('ascii'), 'Open Processed') + + def test_signals_sent(self): + """Test that the django open signal was sent""" + # pylint: disable=attribute-defined-outside-init, unused-variable + self.signal_count = 0 + + @receiver(signals.feedback) + def _signal_receiver(sender, **kwargs): + """Test signal receiver""" + # pylint: disable=unused-argument + self.signal_count += 1 + self.signal_notification = kwargs['notification'] + self.signal_message = kwargs['message'] + + result = views.process_open( + self.open, self.notification) + + self.assertEqual(result.status_code, 200) + self.assertEqual(result.content.decode('ascii'), 'Open Processed') + self.assertEqual(self.signal_count, 1) + self.assertEqual(self.signal_notification, self.notification) + + def test_correct_open_created(self): + """Test that the correct open was created""" + Open.objects.all().delete() + + result = views.process_open( + self.open, self.notification) + + self.assertEqual(result.status_code, 200) + self.assertEqual(result.content.decode('ascii'), 'Open Processed') + self.assertTrue(Open.objects.filter( + sns_topic='arn:aws:sns:us-east-1:250214102493:Demo_App_Unsubscribes', + sns_messageid='f34c6922-c3a1-54a1-bd88-23f998b43978', + mail_timestamp=clean_time('2017-08-09T21:59:49.927Z'), + mail_id='EXAMPLE7c191be45-e9aedb9a-02f9-4d12-a87d-dd0099a07f8a-000000', + mail_from='sender@example.com', + address='recipient@example.com', + opened_time=clean_time('2017-08-09T22:00:19.652Z'), + ip_address='192.0.2.1', + useragent='Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_3 like Mac OS X) AppleWebKit/603.3.8 (KHTML, like Gecko) Mobile/14G60' + ).exists()) + + +class ProcessClickTest(BouncyTestCase): + def setUp(self): + self.click = loader('click') + + def test_click_created(self): + """Test that the Click object was created""" + original_count = Click.objects.count() + result = views.process_click( + self.click, self.notification) + new_count = Click.objects.count() + + self.assertEqual(new_count, original_count + 1) + self.assertEqual(result.status_code, 200) + self.assertEqual(result.content.decode('ascii'), 'Click Processed') + + def test_signals_sent(self): + """Test that the django click signal was sent""" + # pylint: disable=attribute-defined-outside-init, unused-variable + self.signal_count = 0 + + @receiver(signals.feedback) + def _signal_receiver(sender, **kwargs): + """Test signal receiver""" + # pylint: disable=unused-argument + self.signal_count += 1 + self.signal_notification = kwargs['notification'] + self.signal_message = kwargs['message'] + + result = views.process_click( + self.click, self.notification) + + self.assertEqual(result.status_code, 200) + self.assertEqual(result.content.decode('ascii'), 'Click Processed') + self.assertEqual(self.signal_count, 1) + self.assertEqual(self.signal_notification, self.notification) + + def test_correct_click_created(self): + """Test that the correct click was created""" + Click.objects.all().delete() + + result = views.process_click( + self.click, self.notification) + + self.assertEqual(result.status_code, 200) + self.assertEqual(result.content.decode('ascii'), 'Click Processed') + self.assertTrue(Click.objects.filter( + sns_topic='arn:aws:sns:us-east-1:250214102493:Demo_App_Unsubscribes', + sns_messageid='f34c6922-c3a1-54a1-bd88-23f998b43978', + mail_timestamp=clean_time('2017-08-09T23:50:05.795Z'), + mail_id='EXAMPLE7c191be45-e9aedb9a-02f9-4d12-a87d-dd0099a07f8a-000000', + mail_from='sender@example.com', + address='recipient@example.com', + clicked_time=clean_time('2017-08-09T23:51:25.570Z'), + ip_address='192.0.2.1', + useragent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36', + link='http://docs.aws.amazon.com/ses/latest/DeveloperGuide/send-email-smtp.html', + link_tags="{'samplekey0': ['samplevalue0'], 'samplekey1': ['samplevalue1']}" + ).exists()) + + +class ProcessRejectTest(BouncyTestCase): + def setUp(self): + self.reject = loader('reject') + + def test_reject_created(self): + """Test that the Reject object was created""" + original_count = Reject.objects.count() + result = views.process_reject( + self.reject, self.notification) + new_count = Reject.objects.count() + + self.assertEqual(new_count, original_count + 1) + self.assertEqual(result.status_code, 200) + self.assertEqual(result.content.decode('ascii'), 'Reject Processed') + + def test_signals_sent(self): + """Test that the django reject signal was sent""" + # pylint: disable=attribute-defined-outside-init, unused-variable + self.signal_count = 0 + + @receiver(signals.feedback) + def _signal_receiver(sender, **kwargs): + """Test signal receiver""" + # pylint: disable=unused-argument + self.signal_count += 1 + self.signal_notification = kwargs['notification'] + self.signal_message = kwargs['message'] + + result = views.process_reject( + self.reject, self.notification) + + self.assertEqual(result.status_code, 200) + self.assertEqual(result.content.decode('ascii'), 'Reject Processed') + self.assertEqual(self.signal_count, 1) + self.assertEqual(self.signal_notification, self.notification) + + def test_correct_reject_created(self): + """Test that the correct reject was created""" + Reject.objects.all().delete() + + result = views.process_reject( + self.reject, self.notification) + + self.assertEqual(result.status_code, 200) + self.assertEqual(result.content.decode('ascii'), 'Reject Processed') + self.assertTrue(Reject.objects.filter( + sns_topic='arn:aws:sns:us-east-1:250214102493:Demo_App_Unsubscribes', + sns_messageid='f34c6922-c3a1-54a1-bd88-23f998b43978', + mail_timestamp=clean_time('2016-10-14T17:38:15.211Z'), + mail_id='EXAMPLE7c191be45-e9aedb9a-02f9-4d12-a87d-dd0099a07f8a-000000', + mail_from='sender@example.com', + address='sender@example.com', + reason='Bad content' + ).exists()) + + +class ProcessRenderingFailureTest(BouncyTestCase): + def setUp(self): + self.rendering_failure = loader('rendering_failure') + + def test_rendering_failure_created(self): + """Test that the RenderingFailure object was created""" + original_count = RenderingFailure.objects.count() + result = views.process_rendering_failure( + self.rendering_failure, self.notification) + new_count = RenderingFailure.objects.count() + + self.assertEqual(new_count, original_count + 1) + self.assertEqual(result.status_code, 200) + self.assertEqual(result.content.decode('ascii'), 'Rendering Failure Processed') + + def test_signals_sent(self): + """Test that the django rendering failure signal was sent""" + # pylint: disable=attribute-defined-outside-init, unused-variable + self.signal_count = 0 + + @receiver(signals.feedback) + def _signal_receiver(sender, **kwargs): + """Test signal receiver""" + # pylint: disable=unused-argument + self.signal_count += 1 + self.signal_notification = kwargs['notification'] + self.signal_message = kwargs['message'] + + result = views.process_rendering_failure( + self.rendering_failure, self.notification) + + self.assertEqual(result.status_code, 200) + self.assertEqual(result.content.decode('ascii'), 'Rendering Failure Processed') + self.assertEqual(self.signal_count, 1) + self.assertEqual(self.signal_notification, self.notification) + + def test_correct_rendering_failure_created(self): + """Test that the correct rendering failure was created""" + RenderingFailure.objects.all().delete() + + result = views.process_rendering_failure( + self.rendering_failure, self.notification) + + self.assertEqual(result.status_code, 200) + self.assertEqual(result.content.decode('ascii'), 'Rendering Failure Processed') + self.assertTrue(RenderingFailure.objects.filter( + sns_topic='arn:aws:sns:us-east-1:250214102493:Demo_App_Unsubscribes', + sns_messageid='f34c6922-c3a1-54a1-bd88-23f998b43978', + mail_timestamp=clean_time('2018-01-22T18:43:06.197Z'), + mail_id='EXAMPLE7c191be45-e9aedb9a-02f9-4d12-a87d-dd0099a07f8a-000000', + mail_from='sender@example.com', + address='recipient@example.com', + template_name='MyTemplate', + error_message="Attribute 'attributeName' is not present in the rendering data." + ).exists()) + + +class ProcessDeliveryDelayTest(BouncyTestCase): + def setUp(self): + self.delivery_delay = loader('delivery_delay') + + def test_delivery_delay_created(self): + """Test that the DeliveryDelay object was created""" + original_count = DeliveryDelay.objects.count() + result = views.process_delivery_delay( + self.delivery_delay, self.notification) + new_count = DeliveryDelay.objects.count() + + self.assertEqual(new_count, original_count + 1) + self.assertEqual(result.status_code, 200) + self.assertEqual(result.content.decode('ascii'), 'Delivery Delay Processed') + + def test_signals_sent(self): + """Test that the django delivery delay signal was sent""" + # pylint: disable=attribute-defined-outside-init, unused-variable + self.signal_count = 0 + + @receiver(signals.feedback) + def _signal_receiver(sender, **kwargs): + """Test signal receiver""" + # pylint: disable=unused-argument + self.signal_count += 1 + self.signal_notification = kwargs['notification'] + self.signal_message = kwargs['message'] + + result = views.process_delivery_delay( + self.delivery_delay, self.notification) + + self.assertEqual(result.status_code, 200) + self.assertEqual(result.content.decode('ascii'), 'Delivery Delay Processed') + self.assertEqual(self.signal_count, 1) + self.assertEqual(self.signal_notification, self.notification) + + def test_correct_delivery_delay_created(self): + """Test that the correct delivery delay was created""" + DeliveryDelay.objects.all().delete() + + result = views.process_delivery_delay( + self.delivery_delay, self.notification) + + self.assertEqual(result.status_code, 200) + self.assertEqual(result.content.decode('ascii'), 'Delivery Delay Processed') + self.assertTrue(DeliveryDelay.objects.filter( + sns_topic='arn:aws:sns:us-east-1:250214102493:Demo_App_Unsubscribes', + sns_messageid='f34c6922-c3a1-54a1-bd88-23f998b43978', + mail_timestamp=clean_time('2020-06-16T00:15:40.641Z'), + mail_id='EXAMPLE7c191be45-e9aedb9a-02f9-4d12-a87d-dd0099a07f8a-000000', + mail_from='sender@example.com', + address='recipient@example.com', + delayed_time=clean_time('2020-06-16T00:25:40.095Z'), + delay_type='TransientCommunicationFailure', + expiration_time=clean_time('2020-06-16T00:25:40.914Z'), + status='4.4.1', + diagnostic_code='smtp; 421 4.4.1 Unable to connect to remote host' + ).exists()) diff --git a/django_bouncy/views.py b/django_bouncy/views.py index e8fa63c..b083e32 100644 --- a/django_bouncy/views.py +++ b/django_bouncy/views.py @@ -15,7 +15,7 @@ from django_bouncy.utils import ( verify_notification, approve_subscription, clean_time ) -from django_bouncy.models import Bounce, Complaint, Delivery +from django_bouncy.models import Bounce, Complaint, Delivery, Open, Click, Send, Reject, RenderingFailure, DeliveryDelay from django_bouncy import signals VITAL_NOTIFICATION_FIELDS = [ @@ -24,10 +24,6 @@ 'SigningCertURL' ] -VITAL_MESSAGE_FIELDS = [ - 'notificationType', 'mail' -] - ALLOWED_TYPES = [ 'Notification', 'SubscriptionConfirmation', 'UnsubscribeConfirmation' ] @@ -128,29 +124,72 @@ def endpoint(request): return process_message(message, data) +def has_vital_fields(message): + return 'mail' in message and ('eventType' in message or 'notificationType' in message) + + def process_message(message, notification): """ Function to process a JSON message delivered from Amazon """ - # Confirm that there are 'notificationType' and 'mail' fields in our - # message - if not set(VITAL_MESSAGE_FIELDS) <= set(message): + # Confirm that there are 'mail' and either 'eventType' or 'notificationType' + # fields in our message + if not has_vital_fields(message): # At this point we're sure that it's Amazon sending the message # If we don't return a 200 status code, Amazon will attempt to send us # this same message a few seconds later. logger.info('JSON Message Missing Vital Fields') return HttpResponse('Missing Vital Fields') - if message['notificationType'] == 'Complaint': + message_type = message.get('eventType') or message.get('notificationType') + + if message_type == 'Complaint': return process_complaint(message, notification) - if message['notificationType'] == 'Bounce': + if message_type == 'Bounce': return process_bounce(message, notification) - if message['notificationType'] == 'Delivery': + if message_type == 'Delivery': return process_delivery(message, notification) + if message_type == 'Open': + return process_open(message, notification) + if message_type == 'Click': + return process_click(message, notification) + if message_type == 'Send': + return process_send(message, notification) + if message_type == 'Reject': + return process_reject(message, notification) + if message_type == 'Rendering Failure': + return process_rendering_failure(message, notification) + if message_type == 'DeliveryDelay': + return process_delivery_delay(message, notification) else: return HttpResponse('Unknown Notification Type') +def process_send(message, notification): + mail = message['mail'] + + sends = [] + for destination in mail['destination']: + sends += [Send.objects.create( + sns_topic=notification['TopicArn'], + sns_messageid=notification['MessageId'], + mail_timestamp=clean_time(mail['timestamp']), + mail_id=mail['messageId'], + mail_from=mail['source'], + address=destination + )] + + for send in sends: + signals.feedback.send( + sender=Send, + instance=send, + message=message, + notification=notification + ) + + return HttpResponse('Send Processed') + + def process_bounce(message, notification): """Function to process a bounce notification""" mail = message['mail'] @@ -178,10 +217,10 @@ def process_bounce(message, notification): )] # Send signals for each bounce. - for bounce in bounces: + for each_bounce in bounces: signals.feedback.send( sender=Bounce, - instance=bounce, + instance=each_bounce, message=message, notification=notification ) @@ -219,10 +258,10 @@ def process_complaint(message, notification): )] # Send signals for each complaint. - for complaint in complaints: + for each_complaint in complaints: signals.feedback.send( sender=Complaint, - instance=complaint, + instance=each_complaint, message=message, notification=notification ) @@ -243,7 +282,7 @@ def process_delivery(message, notification): delivered_datetime = None deliveries = [] - for eachrecipient in delivery['recipients']: + for each_recipient in delivery['recipients']: # Create each delivery deliveries += [Delivery.objects.create( sns_topic=notification['TopicArn'], @@ -251,7 +290,7 @@ def process_delivery(message, notification): mail_timestamp=clean_time(mail['timestamp']), mail_id=mail['messageId'], mail_from=mail['source'], - address=eachrecipient, + address=each_recipient, # delivery delivered_time=delivered_datetime, processing_time=int(delivery['processingTimeMillis']), @@ -259,10 +298,10 @@ def process_delivery(message, notification): )] # Send signals for each delivery. - for eachdelivery in deliveries: + for each_delivery in deliveries: signals.feedback.send( sender=Delivery, - instance=eachdelivery, + instance=each_delivery, message=message, notification=notification ) @@ -270,3 +309,170 @@ def process_delivery(message, notification): logger.info('Logged %s Deliveries(s)', str(len(deliveries))) return HttpResponse('Delivery Processed') + + +def process_open(message, notification): + """Function to process an open notification""" + mail = message['mail'] + each_open = message['open'] + + if 'timestamp' in each_open: + opened_datetime = clean_time(each_open['timestamp']) + else: + opened_datetime = None + + opens = [] + for destination in mail['destination']: + opens += [Open.objects.create( + sns_topic=notification['TopicArn'], + sns_messageid=notification['MessageId'], + mail_timestamp=clean_time(mail['timestamp']), + mail_id=mail['messageId'], + mail_from=mail['source'], + # open + address=destination, + opened_time=opened_datetime, + ip_address=each_open['ipAddress'], + useragent=each_open['userAgent'] + )] + + for each_open in opens: + signals.feedback.send( + sender=Open, + instance=each_open, + message=message, + notification=notification + ) + + return HttpResponse('Open Processed') + + +def process_click(message, notification): + """Function to process a click notification""" + mail = message['mail'] + click = message['click'] + + if 'timestamp' in click: + clicked_datetime = clean_time(click['timestamp']) + else: + clicked_datetime = None + + clicks = [] + for destination in mail['destination']: + clicks += [Click.objects.create( + sns_topic=notification['TopicArn'], + sns_messageid=notification['MessageId'], + mail_timestamp=clean_time(mail['timestamp']), + mail_id=mail['messageId'], + mail_from=mail['source'], + address=destination, + # click + clicked_time=clicked_datetime, + ip_address=click['ipAddress'], + useragent=click['userAgent'], + link=click['link'], + link_tags=click['linkTags'] + )] + + for each_click in clicks: + signals.feedback.send( + sender=Click, + instance=each_click, + message=message, + notification=notification + ) + + return HttpResponse('Click Processed') + + +def process_reject(message, notification): + """Function to process a reject notification""" + mail = message['mail'] + reject = message['reject'] + + rejects = [] + for destination in mail['destination']: + rejects += [Reject.objects.create( + sns_topic=notification['TopicArn'], + sns_messageid=notification['MessageId'], + mail_timestamp=clean_time(mail['timestamp']), + mail_id=mail['messageId'], + mail_from=mail['source'], + address=destination, + # reject + reason=reject['reason'] + )] + + for each_reject in rejects: + signals.feedback.send( + sender=Reject, + instance=each_reject, + message=message, + notification=notification + ) + + return HttpResponse('Reject Processed') + + +def process_rendering_failure(message, notification): + """Function to process a rendering failure notification""" + mail = message['mail'] + rendering_failure = message['failure'] + + rendering_failures = [] + for destination in mail['destination']: + rendering_failures += [RenderingFailure.objects.create( + sns_topic=notification['TopicArn'], + sns_messageid=notification['MessageId'], + mail_timestamp=clean_time(mail['timestamp']), + mail_id=mail['messageId'], + mail_from=mail['source'], + address=destination, + # rendering failure + template_name=rendering_failure['templateName'], + error_message=rendering_failure['errorMessage'] + )] + + for each_rendering_failure in rendering_failures: + signals.feedback.send( + sender=RenderingFailure, + instance=each_rendering_failure, + message=message, + notification=notification + ) + + return HttpResponse('Rendering Failure Processed') + + +def process_delivery_delay(message, notification): + """Function to process a delivery delay notification""" + mail = message['mail'] + delivery_delay = message['deliveryDelay'] + + delivery_delays = [] + for delayed_recipient in delivery_delay['delayedRecipients']: + delivery_delays += [DeliveryDelay.objects.create( + sns_topic=notification['TopicArn'], + sns_messageid=notification['MessageId'], + mail_timestamp=clean_time(mail['timestamp']), + mail_id=mail['messageId'], + mail_from=mail['source'], + address=delayed_recipient['emailAddress'], + # delivery delay + delayed_time=clean_time(delivery_delay['timestamp']), + delay_type=delivery_delay['delayType'], + expiration_time=clean_time(delivery_delay['expirationTime']), + reporting_mta=delivery_delay.get('reportingMTA'), + status=delayed_recipient['status'], + diagnostic_code=delayed_recipient['diagnosticCode'] + )] + + for each_delivery_delay in delivery_delays: + signals.feedback.send( + sender=DeliveryDelay, + instance=each_delivery_delay, + message=message, + notification=notification + ) + + return HttpResponse('Delivery Delay Processed') From bb9af88c182883d01af71559903066a03756bd9c Mon Sep 17 00:00:00 2001 From: Chris Jackson Date: Wed, 9 Dec 2020 01:50:10 -1000 Subject: [PATCH 2/8] Fix verification issue when notification contains Subject, which is the case for all event publishing events --- django_bouncy/utils.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/django_bouncy/utils.py b/django_bouncy/utils.py index ba4cbfd..81ed964 100644 --- a/django_bouncy/utils.py +++ b/django_bouncy/utils.py @@ -45,6 +45,21 @@ {Type} ''' +NOTIFICATION_WITH_SUBJECT_HASH_FORMAT = u'''Message +{Message} +MessageId +{MessageId} +Subject +{Subject} +Timestamp +{Timestamp} +TopicArn +{TopicArn} +Type +{Type} +''' + + SUBSCRIPTION_HASH_FORMAT = u'''Message {Message} MessageId @@ -102,7 +117,10 @@ def verify_notification(data): signature = base64.decodestring(six.b(data['Signature'])) if data['Type'] == "Notification": - hash_format = NOTIFICATION_HASH_FORMAT + if 'Subject' in data: + hash_format = NOTIFICATION_WITH_SUBJECT_HASH_FORMAT + else: + hash_format = NOTIFICATION_HASH_FORMAT else: hash_format = SUBSCRIPTION_HASH_FORMAT From 8ebd321bed44000a1ec2d8a8cc35f04c6e031feb Mon Sep 17 00:00:00 2001 From: Chris Jackson Date: Wed, 9 Dec 2020 11:44:43 -1000 Subject: [PATCH 3/8] Fix variable name conflict --- django_bouncy/views.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/django_bouncy/views.py b/django_bouncy/views.py index b083e32..ccf9b37 100644 --- a/django_bouncy/views.py +++ b/django_bouncy/views.py @@ -314,10 +314,10 @@ def process_delivery(message, notification): def process_open(message, notification): """Function to process an open notification""" mail = message['mail'] - each_open = message['open'] + open_ = message['open'] - if 'timestamp' in each_open: - opened_datetime = clean_time(each_open['timestamp']) + if 'timestamp' in open_: + opened_datetime = clean_time(open_['timestamp']) else: opened_datetime = None @@ -332,8 +332,8 @@ def process_open(message, notification): # open address=destination, opened_time=opened_datetime, - ip_address=each_open['ipAddress'], - useragent=each_open['userAgent'] + ip_address=open_['ipAddress'], + useragent=open_['userAgent'] )] for each_open in opens: From ff13a791bba6c46dcd245ce9a8f0febf6623d9cc Mon Sep 17 00:00:00 2001 From: Chris Jackson Date: Wed, 9 Dec 2020 12:14:05 -1000 Subject: [PATCH 4/8] Include times in admin lists, fix issue with DeliveryAdmin using feedback_timestamp (which isn't set) instead of delivered_time --- django_bouncy/admin.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/django_bouncy/admin.py b/django_bouncy/admin.py index cdf7457..56c59ca 100644 --- a/django_bouncy/admin.py +++ b/django_bouncy/admin.py @@ -8,7 +8,7 @@ class BounceAdmin(admin.ModelAdmin): """Admin model for 'Bounce' objects""" list_display = ( - 'address', 'mail_from', 'bounce_type', 'bounce_subtype', 'status') + 'feedback_timestamp', 'address', 'mail_from', 'bounce_type', 'bounce_subtype', 'status') list_filter = ( 'hard', 'action', 'bounce_type', 'bounce_subtype', 'feedback_timestamp' @@ -18,20 +18,21 @@ class BounceAdmin(admin.ModelAdmin): class ComplaintAdmin(admin.ModelAdmin): """Admin model for 'Complaint' objects""" - list_display = ('address', 'mail_from', 'feedback_type') + list_display = ('feedback_timestamp', 'address', 'mail_from', 'feedback_type') list_filter = ('feedback_type', 'feedback_timestamp') search_fields = ('address',) class DeliveryAdmin(admin.ModelAdmin): """Admin model for 'Delivery' objects""" - list_display = ('address', 'mail_from') - list_filter = ('feedback_timestamp',) + list_display = ('delivered_time', 'address', 'mail_from') + list_filter = ('delivered_time',) search_fields = ('address',) class SendAdmin(admin.ModelAdmin): - list_display = ('address', 'mail_from',) + list_display = ('mail_timestamp', 'address', 'mail_from',) + list_filter = ('mail_timestamp',) search_fields = ('address',) @@ -48,7 +49,8 @@ class ClickAdmin(admin.ModelAdmin): class RenderingFailureAdmin(admin.ModelAdmin): - list_display = ('address', 'mail_from', 'template_name', 'error_message',) + list_display = ('mail_timestamp', 'address', 'mail_from', 'template_name', 'error_message',) + list_filter = ('mail_timestamp',) search_fields = ('address', 'template_name',) From e37545e8d6fff9584021ec8bf1887dab61f979b8 Mon Sep 17 00:00:00 2001 From: Chris Jackson Date: Wed, 9 Dec 2020 15:01:30 -1000 Subject: [PATCH 5/8] Fix test in older Python versions --- django_bouncy/tests/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django_bouncy/tests/views.py b/django_bouncy/tests/views.py index 9409e1c..e34ac09 100644 --- a/django_bouncy/tests/views.py +++ b/django_bouncy/tests/views.py @@ -579,7 +579,7 @@ def test_correct_click_created(self): ip_address='192.0.2.1', useragent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36', link='http://docs.aws.amazon.com/ses/latest/DeveloperGuide/send-email-smtp.html', - link_tags="{'samplekey0': ['samplevalue0'], 'samplekey1': ['samplevalue1']}" + link_tags=json.loads('{"samplekey0": ["samplevalue0"], "samplekey1": ["samplevalue1"]}') ).exists()) From bbc4642b72cdc77ff6cbaf87f461559cda0b1556 Mon Sep 17 00:00:00 2001 From: Valentin Zberea Date: Thu, 14 Jan 2021 14:46:39 +0100 Subject: [PATCH 6/8] Encode the message data utf-8 --- django_bouncy/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django_bouncy/utils.py b/django_bouncy/utils.py index 81ed964..b0b0c01 100644 --- a/django_bouncy/utils.py +++ b/django_bouncy/utils.py @@ -126,7 +126,7 @@ def verify_notification(data): try: crypto.verify( - cert, signature, six.b(hash_format.format(**data)), 'sha1') + cert, signature, hash_format.format(**data).encode('utf-8'), 'sha1') except crypto.Error: return False return True From ecb8747b74ee8680cb34e3d08794d731b8e58b84 Mon Sep 17 00:00:00 2001 From: Valentin Zberea Date: Tue, 26 Jan 2021 15:44:51 +0100 Subject: [PATCH 7/8] Accept multiple ips reported from Amazon --- .../tests/examples/example_click.json | 2 +- .../tests/examples/example_open.json | 2 +- django_bouncy/tests/views.py | 4 ++-- django_bouncy/utils.py | 23 +++++++++++++++++++ django_bouncy/views.py | 8 +++---- 5 files changed, 31 insertions(+), 8 deletions(-) diff --git a/django_bouncy/tests/examples/example_click.json b/django_bouncy/tests/examples/example_click.json index 465ebaa..9f0ed3c 100644 --- a/django_bouncy/tests/examples/example_click.json +++ b/django_bouncy/tests/examples/example_click.json @@ -1,7 +1,7 @@ { "eventType": "Click", "click": { - "ipAddress": "192.0.2.1", + "ipAddress": "aaa, 62.251.97.95, 127.0.0.1, 194.109.157.34", "link": "http://docs.aws.amazon.com/ses/latest/DeveloperGuide/send-email-smtp.html", "linkTags": { "samplekey0": [ diff --git a/django_bouncy/tests/examples/example_open.json b/django_bouncy/tests/examples/example_open.json index 9a746f8..bd2264f 100644 --- a/django_bouncy/tests/examples/example_open.json +++ b/django_bouncy/tests/examples/example_open.json @@ -71,7 +71,7 @@ "timestamp": "2017-08-09T21:59:49.927Z" }, "open": { - "ipAddress": "192.0.2.1", + "ipAddress": "192.0.2.1, 127.0.0.1", "timestamp": "2017-08-09T22:00:19.652Z", "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_3 like Mac OS X) AppleWebKit/603.3.8 (KHTML, like Gecko) Mobile/14G60" } diff --git a/django_bouncy/tests/views.py b/django_bouncy/tests/views.py index e34ac09..104528d 100644 --- a/django_bouncy/tests/views.py +++ b/django_bouncy/tests/views.py @@ -518,7 +518,7 @@ def test_correct_open_created(self): mail_from='sender@example.com', address='recipient@example.com', opened_time=clean_time('2017-08-09T22:00:19.652Z'), - ip_address='192.0.2.1', + ip_address='127.0.0.1', useragent='Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_3 like Mac OS X) AppleWebKit/603.3.8 (KHTML, like Gecko) Mobile/14G60' ).exists()) @@ -576,7 +576,7 @@ def test_correct_click_created(self): mail_from='sender@example.com', address='recipient@example.com', clicked_time=clean_time('2017-08-09T23:51:25.570Z'), - ip_address='192.0.2.1', + ip_address='62.251.97.95', useragent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36', link='http://docs.aws.amazon.com/ses/latest/DeveloperGuide/send-email-smtp.html', link_tags=json.loads('{"samplekey0": ["samplevalue0"], "samplekey1": ["samplevalue1"]}') diff --git a/django_bouncy/utils.py b/django_bouncy/utils.py index b0b0c01..ecee924 100644 --- a/django_bouncy/utils.py +++ b/django_bouncy/utils.py @@ -23,6 +23,7 @@ import logging import six +from ipaddress import ip_address from OpenSSL import crypto from django.conf import settings from django.core.cache import caches @@ -177,3 +178,25 @@ def clean_time(time_string): # remove the timezone field time = time.astimezone(timezone.utc).replace(tzinfo=None) return time + + +def clean_ip(ip_string): + """Return a single ip address from the Amazon-provided ip address string""" + public_ip_address = None + private_ip_address = None + + if ip_string is None: + return public_ip_address + + for ip in ip_string.split(','): + ip = ip.strip() + try: + address = ip_address(ip) + # Return first public IP found + if address.is_global: + return ip + private_ip_address = ip + except ValueError: + continue + + return private_ip_address diff --git a/django_bouncy/views.py b/django_bouncy/views.py index ccf9b37..4a5396c 100644 --- a/django_bouncy/views.py +++ b/django_bouncy/views.py @@ -13,7 +13,7 @@ from django.conf import settings from django_bouncy.utils import ( - verify_notification, approve_subscription, clean_time + verify_notification, approve_subscription, clean_time, clean_ip ) from django_bouncy.models import Bounce, Complaint, Delivery, Open, Click, Send, Reject, RenderingFailure, DeliveryDelay from django_bouncy import signals @@ -332,7 +332,7 @@ def process_open(message, notification): # open address=destination, opened_time=opened_datetime, - ip_address=open_['ipAddress'], + ip_address=clean_ip(open_['ipAddress']), useragent=open_['userAgent'] )] @@ -368,7 +368,7 @@ def process_click(message, notification): address=destination, # click clicked_time=clicked_datetime, - ip_address=click['ipAddress'], + ip_address=clean_ip(click['ipAddress']), useragent=click['userAgent'], link=click['link'], link_tags=click['linkTags'] @@ -462,7 +462,7 @@ def process_delivery_delay(message, notification): delayed_time=clean_time(delivery_delay['timestamp']), delay_type=delivery_delay['delayType'], expiration_time=clean_time(delivery_delay['expirationTime']), - reporting_mta=delivery_delay.get('reportingMTA'), + reporting_mta=clean_ip(delivery_delay.get('reportingMTA')), status=delayed_recipient['status'], diagnostic_code=delayed_recipient['diagnosticCode'] )] From 5de0c961a20da03849002c1eedb1e1a01ecb7730 Mon Sep 17 00:00:00 2001 From: Valentin Zberea Date: Wed, 17 Mar 2021 10:55:12 +0100 Subject: [PATCH 8/8] Add index on mail_id --- .../migrations/0007_auto_20210317_0440.py | 85 +++++++++++++++++++ django_bouncy/models.py | 2 +- 2 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 django_bouncy/migrations/0007_auto_20210317_0440.py diff --git a/django_bouncy/migrations/0007_auto_20210317_0440.py b/django_bouncy/migrations/0007_auto_20210317_0440.py new file mode 100644 index 0000000..80f6ddd --- /dev/null +++ b/django_bouncy/migrations/0007_auto_20210317_0440.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.23 on 2021-03-17 04:40 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('django_bouncy', '0006_click_deliverydelay_open_reject_renderingfailure_send'), + ] + + operations = [ + migrations.AlterField( + model_name='bounce', + name='mail_id', + field=models.CharField(db_index=True, max_length=100), + ), + migrations.AlterField( + model_name='click', + name='clicked_time', + field=models.DateTimeField(blank=True, db_index=True, null=True), + ), + migrations.AlterField( + model_name='click', + name='mail_id', + field=models.CharField(db_index=True, max_length=100), + ), + migrations.AlterField( + model_name='complaint', + name='mail_id', + field=models.CharField(db_index=True, max_length=100), + ), + migrations.AlterField( + model_name='delivery', + name='mail_id', + field=models.CharField(db_index=True, max_length=100), + ), + migrations.AlterField( + model_name='deliverydelay', + name='delay_type', + field=models.TextField(blank=True, db_index=True, null=True), + ), + migrations.AlterField( + model_name='deliverydelay', + name='delayed_time', + field=models.DateTimeField(blank=True, db_index=True, null=True), + ), + migrations.AlterField( + model_name='deliverydelay', + name='mail_id', + field=models.CharField(db_index=True, max_length=100), + ), + migrations.AlterField( + model_name='open', + name='mail_id', + field=models.CharField(db_index=True, max_length=100), + ), + migrations.AlterField( + model_name='open', + name='opened_time', + field=models.DateTimeField(blank=True, db_index=True, null=True), + ), + migrations.AlterField( + model_name='reject', + name='mail_id', + field=models.CharField(db_index=True, max_length=100), + ), + migrations.AlterField( + model_name='renderingfailure', + name='mail_id', + field=models.CharField(db_index=True, max_length=100), + ), + migrations.AlterField( + model_name='renderingfailure', + name='template_name', + field=models.TextField(blank=True, db_index=True, null=True), + ), + migrations.AlterField( + model_name='send', + name='mail_id', + field=models.CharField(db_index=True, max_length=100), + ), + ] diff --git a/django_bouncy/models.py b/django_bouncy/models.py index 3b9ba60..44fc79e 100644 --- a/django_bouncy/models.py +++ b/django_bouncy/models.py @@ -12,7 +12,7 @@ class Feedback(models.Model): sns_topic = models.CharField(max_length=350) sns_messageid = models.CharField(max_length=100) mail_timestamp = models.DateTimeField() - mail_id = models.CharField(max_length=100) + mail_id = models.CharField(db_index=True, max_length=100) mail_from = models.EmailField() address = models.EmailField() # no feedback for delivery messages