diff --git a/.project b/.project
new file mode 100644
index 0000000..150a52d
--- /dev/null
+++ b/.project
@@ -0,0 +1,17 @@
+
+
+ trunk
+
+
+
+
+
+ org.python.pydev.PyDevBuilder
+
+
+
+
+
+ org.python.pydev.pythonNature
+
+
diff --git a/.pydevproject b/.pydevproject
new file mode 100644
index 0000000..baf7d0d
--- /dev/null
+++ b/.pydevproject
@@ -0,0 +1,5 @@
+
+
+Default
+python interpreter
+
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..f60de74
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,22 @@
+Copyright (c) 2011 Harry Marr
+
+Permission is hereby granted, free of charge, to any person
+obtaining a copy of this software and associated documentation
+files (the "Software"), to deal in the Software without
+restriction, including without limitation the rights to use,
+copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the
+Software is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+OTHER DEALINGS IN THE SOFTWARE.
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e69de29
diff --git a/django_aws_ses/__init__.py b/django_aws_ses/__init__.py
new file mode 100644
index 0000000..28ad96f
--- /dev/null
+++ b/django_aws_ses/__init__.py
@@ -0,0 +1,8 @@
+
+default_app_config = 'django_aws_ses.apps.DjangoAwsSesBackendConfig'
+
+# When changing this, remember to change it in setup.py
+VERSION = (0, 0, 1)
+__version__ = '.'.join([str(x) for x in VERSION])
+__author__ = 'Ray Jessop'
+__all__ = ('Django AWS SES Backend',)
diff --git a/django_aws_ses/admin.py b/django_aws_ses/admin.py
new file mode 100644
index 0000000..01a27d8
--- /dev/null
+++ b/django_aws_ses/admin.py
@@ -0,0 +1,76 @@
+from django.contrib import admin
+from .models import (
+ AwsSesSettings,
+ SESStat,
+ BounceRecord,
+ AwsSesUserAddon,
+ ComplaintRecord,
+ SendRecord,
+ UnknownRecord
+ )
+
+from . import settings
+
+logger = settings.logger
+
+class AwsSesSettingsAdmin(admin.ModelAdmin):
+ model = AwsSesSettings
+ list_display = ('get_site', 'region_name')
+
+ def get_site(self, obj):
+ return obj.site.domain
+
+ get_site.short_description = 'domain'
+ get_site.admin_order_field = 'site__domain'
+
+admin.site.register(AwsSesSettings, AwsSesSettingsAdmin)
+
+class AwsSesUserAddonAdmin(admin.ModelAdmin):
+ model = AwsSesUserAddon
+ list_display = ('get_email', 'unsubscribe')
+ def get_email(self, obj):
+ return obj.user.email
+
+ get_email.short_description = 'email'
+ get_email.admin_order_field = 'user__email'
+
+admin.site.register(AwsSesUserAddon, AwsSesUserAddonAdmin)
+
+class SESStatAdmin(admin.ModelAdmin):
+ model = SESStat
+ list_display = ('date', 'delivery_attempts', 'bounces', 'complaints', 'rejects')
+
+admin.site.register(SESStat, SESStatAdmin)
+
+class AdminEmailListFilter(admin.SimpleListFilter):
+ def queryset(self, request, queryset):
+ logger.info('self.value(): %s' % self.value())
+ return queryset.filter(email__contains=self.value())
+
+class BounceRecordAdmin(admin.ModelAdmin):
+ model = BounceRecord
+ list_display = ('email', 'bounce_type', 'bounce_sub_type', 'status', 'timestamp')
+ list_filter = ('email', 'bounce_type', 'bounce_sub_type', 'status', 'timestamp')
+
+admin.site.register(BounceRecord, BounceRecordAdmin)
+
+class ComplaintRecordAdmin(admin.ModelAdmin):
+ model = ComplaintRecord
+ list_display = ('email', 'sub_type', 'feedback_type', 'timestamp')
+ list_filter = ('email', 'sub_type', 'feedback_type', 'timestamp')
+
+admin.site.register(ComplaintRecord, ComplaintRecordAdmin)
+
+class SendRecordAdmin(admin.ModelAdmin):
+ model = ComplaintRecord
+ list_display = ('source', 'destination', 'subject', 'timestamp', 'status')
+ list_filter = ('source', 'destination', 'subject', 'timestamp', 'status')
+
+admin.site.register(SendRecord, SendRecordAdmin)
+
+class UnknownRecordAdmin(admin.ModelAdmin):
+ model = ComplaintRecord
+ list_display = ('event_type', 'aws_data')
+ list_filter = ('event_type', 'aws_data')
+
+admin.site.register(UnknownRecord, UnknownRecordAdmin)
\ No newline at end of file
diff --git a/django_aws_ses/apps.py b/django_aws_ses/apps.py
new file mode 100644
index 0000000..7129528
--- /dev/null
+++ b/django_aws_ses/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class DjangoAwsSesBackendConfig(AppConfig):
+ name = 'django_aws_ses'
+ verbose_name = 'Django AWS SES'
diff --git a/django_aws_ses/backends.py b/django_aws_ses/backends.py
new file mode 100644
index 0000000..8f66b23
--- /dev/null
+++ b/django_aws_ses/backends.py
@@ -0,0 +1,253 @@
+import logging
+
+import boto3
+from botocore.vendored.requests.packages.urllib3.exceptions import ResponseError
+from django.core.mail.backends.base import BaseEmailBackend
+from django.db.models import Count
+from django.dispatch import Signal
+
+from datetime import datetime, timedelta
+from time import sleep
+
+from . import settings
+from . import signals
+from . import utils
+from .models import BounceRecord
+
+logger = settings.logger
+
+cached_rate_limits = {}
+recent_send_times = []
+
+
+def dkim_sign(message, dkim_domain=None, dkim_key=None, dkim_selector=None, dkim_headers=None):
+ """Return signed email message if dkim package and settings are available."""
+ try:
+ import dkim
+ except ImportError:
+ pass
+ else:
+ if dkim_domain and dkim_key:
+ sig = dkim.sign(message,
+ dkim_selector,
+ dkim_domain,
+ dkim_key,
+ include_headers=dkim_headers)
+ message = sig + message
+ return message
+
+
+class SESBackend(BaseEmailBackend):
+ """A Django Email backend that uses Amazon's Simple Email Service.
+ """
+
+
+ def __init__(self, fail_silently=False, aws_access_key=None,
+ aws_secret_key=None, aws_region_name=None,
+ aws_region_endpoint=None, aws_auto_throttle=None,
+ dkim_domain=None, dkim_key=None, dkim_selector=None,
+ dkim_headers=None, **kwargs):
+
+ super(SESBackend, self).__init__(fail_silently=fail_silently, **kwargs)
+ self._access_key_id = aws_access_key or settings.ACCESS_KEY
+ self._access_key = aws_secret_key or settings.SECRET_KEY
+ self._region_name = aws_region_name if aws_region_name else settings.AWS_SES_REGION_NAME
+ self._endpoint_url = aws_region_endpoint if aws_region_endpoint else settings.AWS_SES_REGION_ENDPOINT_URL
+ self._throttle = aws_auto_throttle or settings.AWS_SES_AUTO_THROTTLE
+
+ self.dkim_domain = dkim_domain or settings.DKIM_DOMAIN
+ self.dkim_key = dkim_key or settings.DKIM_PRIVATE_KEY
+ self.dkim_selector = dkim_selector or settings.DKIM_SELECTOR
+ self.dkim_headers = dkim_headers or settings.DKIM_HEADERS
+
+ self.connection = None
+
+ def open(self):
+ """Create a connection to the AWS API server. This can be reused for
+ sending multiple emails.
+ """
+ if self.connection:
+ return False
+
+ try:
+ self.connection = boto3.client(
+ 'ses',
+ aws_access_key_id=self._access_key_id,
+ aws_secret_access_key=self._access_key,
+ region_name=self._region_name,
+ endpoint_url=self._endpoint_url,
+ )
+
+ except Exception:
+ if not self.fail_silently:
+ raise
+
+ def close(self):
+ """Close any open HTTP connections to the API server.
+ """
+ self.connection = None
+
+ def send_messages(self, email_messages):
+ """Sends one or more EmailMessage objects and returns the number of
+ email messages sent.
+ """
+ logger.info("send_messages")
+ if not email_messages:
+ return
+
+ new_conn_created = self.open()
+ if not self.connection:
+ # Failed silently
+ return
+
+ num_sent = 0
+ source = settings.AWS_SES_RETURN_PATH
+
+ logger.info("email_messages: %s" % email_messages)
+
+ for message in email_messages:
+ # SES Configuration sets. If the AWS_SES_CONFIGURATION_SET setting
+ # is not None, append the appropriate header to the message so that
+ # SES knows which configuration set it belongs to.
+ #
+ # If settings.AWS_SES_CONFIGURATION_SET is a callable, pass it the
+ # message object and dkim settings and expect it to return a string
+ # containing the SES Configuration Set name.
+ logger.info("Sending signal(email_pre_send)")
+ signals.email_pre_send.send_robust(self.__class__, message=message)
+
+ message.to = utils.filter_recipiants(message.recipients())
+
+ logger.info("message.recipients() after email_pre_send: %s" % message.recipients())
+
+ if not message.recipients():
+ logger.info("no recipients left after the filter")
+ return False
+
+ if (settings.AWS_SES_CONFIGURATION_SET
+ and 'X-SES-CONFIGURATION-SET' not in message.extra_headers):
+ if callable(settings.AWS_SES_CONFIGURATION_SET):
+ message.extra_headers[
+ 'X-SES-CONFIGURATION-SET'] = settings.AWS_SES_CONFIGURATION_SET(
+ message,
+ dkim_domain=self.dkim_domain,
+ dkim_key=self.dkim_key,
+ dkim_selector=self.dkim_selector,
+ dkim_headers=self.dkim_headers
+ )
+ else:
+ message.extra_headers[
+ 'X-SES-CONFIGURATION-SET'] = settings.AWS_SES_CONFIGURATION_SET
+
+ # Automatic throttling. Assumes that this is the only SES client
+ # currently operating. The AWS_SES_AUTO_THROTTLE setting is a
+ # factor to apply to the rate limit, with a default of 0.5 to stay
+ # well below the actual SES throttle.
+ # Set the setting to 0 or None to disable throttling.
+ if self._throttle:
+ global recent_send_times
+
+ now = datetime.now()
+
+ # Get and cache the current SES max-per-second rate limit
+ # returned by the SES API.
+ rate_limit = self.get_rate_limit()
+ logger.debug(u"send_messages.throttle rate_limit='{}'".format(rate_limit))
+
+ # Prune from recent_send_times anything more than a few seconds
+ # ago. Even though SES reports a maximum per-second, the way
+ # they enforce the limit may not be on a one-second window.
+ # To be safe, we use a two-second window (but allow 2 times the
+ # rate limit) and then also have a default rate limit factor of
+ # 0.5 so that we really limit the one-second amount in two
+ # seconds.
+ window = 2.0 # seconds
+ window_start = now - timedelta(seconds=window)
+ new_send_times = []
+ for time in recent_send_times:
+ if time > window_start:
+ new_send_times.append(time)
+ recent_send_times = new_send_times
+
+ # If the number of recent send times in the last 1/_throttle
+ # seconds exceeds the rate limit, add a delay.
+ # Since I'm not sure how Amazon determines at exactly what
+ # point to throttle, better be safe than sorry and let in, say,
+ # half of the allowed rate.
+ if len(new_send_times) > rate_limit * window * self._throttle:
+ # Sleep the remainder of the window period.
+ delta = now - new_send_times[0]
+ total_seconds = (delta.microseconds + (delta.seconds +
+ delta.days * 24 * 3600) * 10**6) / 10**6
+ delay = window - total_seconds
+ if delay > 0:
+ sleep(delay)
+
+ recent_send_times.append(now)
+ # end of throttling
+
+ try:
+ logger.info("Try to send raw email")
+ response = self.connection.send_raw_email(
+ Source=source or message.from_email,
+ Destinations=message.recipients(),
+ # todo attachments?
+ RawMessage={'Data': dkim_sign(message.message().as_string(),
+ dkim_key=self.dkim_key,
+ dkim_domain=self.dkim_domain,
+ dkim_selector=self.dkim_selector,
+ dkim_headers=self.dkim_headers)}
+ )
+ message.extra_headers['status'] = 200
+ message.extra_headers['message_id'] = response['MessageId']
+ message.extra_headers['request_id'] = response['ResponseMetadata']['RequestId']
+ num_sent += 1
+ if 'X-SES-CONFIGURATION-SET' in message.extra_headers:
+ logger.debug(
+ u"send_messages.sent from='{}' recipients='{}' message_id='{}' request_id='{}' "
+ u"ses-configuration-set='{}'".format(
+ message.from_email,
+ ", ".join(message.recipients()),
+ message.extra_headers['message_id'],
+ message.extra_headers['request_id'],
+ message.extra_headers['X-SES-CONFIGURATION-SET']
+ ))
+ else:
+ logger.debug(u"send_messages.sent from='{}' recipients='{}' message_id='{}' request_id='{}'".format(
+ message.from_email,
+ ", ".join(message.recipients()),
+ message.extra_headers['message_id'],
+ message.extra_headers['request_id']
+ ))
+
+ except ResponseError as err:
+ # Store failure information so to post process it if required
+ error_keys = ['status', 'reason', 'body', 'request_id',
+ 'error_code', 'error_message']
+ for key in error_keys:
+ message.extra_headers[key] = getattr(err, key, None)
+ if not self.fail_silently:
+ raise
+
+ if new_conn_created:
+ self.close()
+
+ return num_sent
+
+ def get_rate_limit(self):
+ if self._access_key_id in cached_rate_limits:
+ return cached_rate_limits[self._access_key_id]
+
+ new_conn_created = self.open()
+ if not self.connection:
+ raise Exception(
+ "No connection is available to check current SES rate limit.")
+ try:
+ quota_dict = self.connection.get_send_quota()
+ max_per_second = quota_dict['MaxSendRate']
+ ret = float(max_per_second)
+ cached_rate_limits[self._access_key_id] = ret
+ return ret
+ finally:
+ if new_conn_created:
+ self.close()
\ No newline at end of file
diff --git a/django_aws_ses/models.py b/django_aws_ses/models.py
new file mode 100644
index 0000000..b4300c8
--- /dev/null
+++ b/django_aws_ses/models.py
@@ -0,0 +1,136 @@
+import hashlib
+import logging
+
+from django.conf import settings
+from django.db import models
+from django.db.models.signals import post_save
+from django.dispatch import receiver
+from django.contrib.sites.models import Site
+from django.contrib.auth import get_user_model # If used custom user model
+from django.urls import reverse
+from django.utils.encoding import force_bytes, force_text
+from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode
+
+User = get_user_model()
+
+class AwsSesSettings(models.Model):
+ site = models.OneToOneField(Site, on_delete=models.CASCADE)
+ access_key = models.CharField(max_length=255, blank=True, null=True,)
+ secret_key = models.CharField(max_length=255, blank=True, null=True,)
+ region_name = models.CharField(max_length=255, blank=True, null=True,)
+ region_endpoint = models.CharField(max_length=255, blank=True, null=True,)
+
+ class Meta:
+ verbose_name = 'AWS SES Settings'
+
+@receiver(post_save, sender=Site)
+def update_awsses_settings(sender, instance, created, **kwargs):
+ if created:
+ AwsSesSettings.objects.create(Site=instance)
+ instance.AwsSesSettings.save()
+
+class AwsSesUserAddon(models.Model):
+ user = models.OneToOneField(User, related_name='aws_ses', on_delete=models.CASCADE)
+ unsubscribe = models.BooleanField(default=False)
+
+ class Meta:
+ verbose_name = 'User Data'
+
+ def get_email(self):
+ email_field = self.user.get_email_field_name()
+ email = getattr(self, email_field, '') or ''
+ return email
+
+ def unsubscribe_hash_generator(self):
+ email = self.get_email()
+ string_to_hash = "%s%s" % (str(self.user.pk), email)
+ return hashlib.md5(string_to_hash.encode()).hexdigest()
+
+ def check_unsubscribe_hash(self, hash):
+ test_hash = self.unsubscribe_hash_generator()
+ return hash == test_hash
+
+ def unsubscribe_url_generator(self):
+ uuid = urlsafe_base64_encode(force_bytes(self.user.pk))
+ hash = self.unsubscribe_hash_generator()
+ return reverse('django_aws_ses:aws_ses_unsubscribe', kwargs={"uuid":uuid, "hash":hash})
+
+@receiver(post_save, sender=User)
+def update_awsses_user(sender, instance, created, **kwargs):
+ if created:
+ AwsSesUserAddon.objects.create(user=instance)
+ try:
+ instance.AwsSesUserAddon.save()
+ except AwsSesUserAddon.DoesNotExist:
+ AwsSesUserAddon.objects.create(user=instance)
+
+class SESStat(models.Model):
+ date = models.DateField(unique=True, db_index=True)
+ delivery_attempts = models.PositiveIntegerField()
+ bounces = models.PositiveIntegerField()
+ complaints = models.PositiveIntegerField()
+ rejects = models.PositiveIntegerField()
+
+ class Meta:
+ verbose_name = 'SES Stat'
+ ordering = ['-date']
+
+ def __unicode__(self):
+ return self.date.strftime("%Y-%m-%d")
+
+class BounceRecord(models.Model):
+ timestamp = models.DateTimeField(auto_now_add=True)
+ email = models.EmailField()
+ bounce_type = models.CharField(max_length=255, blank=True, null=True,)
+ bounce_sub_type = models.CharField(max_length=255, blank=True, null=True,)
+ reporting_mta = models.CharField(max_length=255, blank=True, null=True,)
+ status = models.CharField(max_length=255, blank=True, null=True,)
+ action = models.CharField(max_length=255, blank=True, null=True,)
+ feedback_id = models.CharField(max_length=255, blank=True, null=True,)
+ diagnostic_code = models.CharField(max_length=255, blank=True, null=True,)
+ cleared = models.BooleanField(default=False)
+
+ def __str__(self):
+ return "email: %s, type: %s, sub_type: %s, status: %s, date: %s" % (self.email, self.bounce_type, self.bounce_sub_type, self.status, self.timestamp)
+
+class ComplaintRecord(models.Model):
+ timestamp = models.DateTimeField(auto_now_add=True)
+ email = models.EmailField()
+ sub_type = models.CharField(max_length=255, blank=True, null=True,)
+ feedback_id = models.CharField(max_length=255, blank=True, null=True,)
+ feedback_type = models.CharField(max_length=255, blank=True, null=True,)
+
+ def __str__(self):
+ return "email: %s, sub_type: %s, feedback_type: %s, date: %s" % (self.email, self.bounce_sub_type, self.feedback_type, self.timestamp)
+
+class SendRecord(models.Model):
+
+
+ SEND = 'Send'
+ DELIVERED = 'Delivery'
+ STATUS_CHOICE = (
+ (SEND, SEND),
+ (DELIVERED, DELIVERED),
+ )
+
+ timestamp = models.DateTimeField(auto_now_add=True)
+ source = models.EmailField()
+ destination = models.EmailField()
+ subject = models.CharField(max_length=255, blank=True, null=True,)
+ message_id = models.CharField(max_length=255, blank=True, null=True,)
+ aws_process_time = models.IntegerField()
+ smtp_response = models.CharField(max_length=255, blank=True, null=True,)
+ status = models.CharField(max_length=255, blank=True, null=True,)
+
+ def __str__(self):
+ return "source: %s, destination: %s, subject: %s, date: %s" % (self.source, self.destination, self.subject, self.timestamp)
+
+
+class UnknownRecord(models.Model):
+ timestamp = models.DateTimeField(auto_now_add=True)
+ event_type = models.CharField(max_length=255, blank=True, null=True,)
+ aws_data = models.TextField(blank=True, null=True,)
+
+ def __str__(self):
+ return "eventType: %s, timestamp: %s" % (self.eventType, self.timestamp)
+
\ No newline at end of file
diff --git a/django_aws_ses/settings.py b/django_aws_ses/settings.py
new file mode 100644
index 0000000..3886dfc
--- /dev/null
+++ b/django_aws_ses/settings.py
@@ -0,0 +1,82 @@
+from django.conf import settings
+
+import logging
+
+from .models import (
+ AwsSesSettings
+ )
+
+aws_ses_Settings, c = AwsSesSettings.objects.get_or_create(site_id=settings.SITE_ID)
+
+__all__ = ('ACCESS_KEY', 'SECRET_KEY', 'AWS_SES_REGION_NAME',
+ 'AWS_SES_REGION_ENDPOINT', 'AWS_SES_AUTO_THROTTLE',
+ 'AWS_SES_RETURN_PATH', 'DKIM_DOMAIN', 'DKIM_PRIVATE_KEY',
+ 'DKIM_SELECTOR', 'DKIM_HEADERS', 'TIME_ZONE', 'BASE_DIR',
+ 'BOUNCE_LIMIT','SES_BACKEND_DEBUG','SES_BACKEND_DEBUG_LOGFILE_PATH',
+ 'SES_BACKEND_DEBUG_LOGFILE_FORMATTER')
+
+BASE_DIR = getattr(settings, 'BASE_DIR', None)
+
+if not BASE_DIR:
+ raise RuntimeError('No BASE_DIR defined in project settings, django_aws_ses requires BASE_DIR to be defined and pointed at your root directory. i.e. BASE_DIR = os.path.dirname(os.path.abspath(__file__))')
+
+DEFAULT_FROM_EMAIL = getattr(settings, 'DEFAULT_FROM_EMAIL', 'no_reply@%s' % aws_ses_Settings.site.domain)
+
+HOME_URL = getattr(settings, 'HOME_URL', '')
+
+UNSUBSCRIBE_TEMPLET = getattr(settings, 'UNSUBSCRIBE_TEMPLET', 'django_aws_ses/unsebscribe.html')
+BASE_TEMPLET = getattr(settings, 'UNSUBSCRIBE_TEMPLET', 'django_aws_ses/base.html')
+
+ACCESS_KEY = aws_ses_Settings.access_key or getattr(settings, 'AWS_SES_ACCESS_KEY_ID',getattr(settings, 'AWS_ACCESS_KEY_ID', None))
+
+SECRET_KEY = aws_ses_Settings.secret_key or getattr(settings, 'AWS_SES_SECRET_ACCESS_KEY',getattr(settings, 'AWS_SECRET_ACCESS_KEY', None))
+
+AWS_SES_REGION_NAME = aws_ses_Settings.region_name or getattr(settings, 'AWS_SES_REGION_NAME',getattr(settings, 'AWS_DEFAULT_REGION', 'us-east-1'))
+
+AWS_SES_REGION_ENDPOINT = aws_ses_Settings.region_endpoint or getattr(settings, 'AWS_SES_REGION_ENDPOINT','email.us-east-1.amazonaws.com')
+
+AWS_SES_REGION_ENDPOINT_URL = getattr(settings, 'AWS_SES_REGION_ENDPOINT_URL','https://' + AWS_SES_REGION_ENDPOINT)
+
+AWS_SES_AUTO_THROTTLE = getattr(settings, 'AWS_SES_AUTO_THROTTLE', 0.5)
+AWS_SES_RETURN_PATH = getattr(settings, 'AWS_SES_RETURN_PATH', None)
+AWS_SES_CONFIGURATION_SET = getattr(settings, 'AWS_SES_CONFIGURATION_SET', None)
+
+DKIM_DOMAIN = getattr(settings, "DKIM_DOMAIN", None)
+DKIM_PRIVATE_KEY = getattr(settings, 'DKIM_PRIVATE_KEY', None)
+DKIM_SELECTOR = getattr(settings, 'DKIM_SELECTOR', 'ses')
+DKIM_HEADERS = getattr(settings, 'DKIM_HEADERS',
+ ('From', 'To', 'Cc', 'Subject'))
+
+TIME_ZONE = settings.TIME_ZONE
+
+VERIFY_BOUNCE_SIGNATURES = getattr(settings, 'AWS_SES_VERIFY_BOUNCE_SIGNATURES', True)
+
+# Domains that are trusted when retrieving the certificate
+# used to sign bounce messages.
+BOUNCE_CERT_DOMAINS = getattr(settings, 'AWS_SNS_BOUNCE_CERT_TRUSTED_DOMAINS', (
+ 'amazonaws.com',
+ 'amazon.com',
+))
+
+SES_BOUNCE_LIMIT = getattr(settings,'BOUNCE_LIMT', 1)
+
+SES_BACKEND_DEBUG = getattr(settings,'SES_BACKEND_DEBUG', False)
+
+SES_BACKEND_DEBUG_LOGFILE_PATH = getattr(settings,'SES_BACKEND_DEBUG_LOGFILE_PATH', '%s/aws_ses.log' % BASE_DIR)
+
+SES_BACKEND_DEBUG_LOGFILE_FORMATTER = getattr(settings,'SES_BACKEND_DEBUG_LOGFILE_FORMATTER', '%(asctime)s - %(name)s - %(levelname)s - %(message)s')
+
+logger = logging.getLogger('django_aws_ses')
+# logger.setLevel(logging.WARNING)
+if SES_BACKEND_DEBUG:
+ logger.setLevel(logging.INFO)
+ # create a file handler
+ if SES_BACKEND_DEBUG_LOGFILE_PATH:
+ handler = logging.FileHandler(SES_BACKEND_DEBUG_LOGFILE_PATH)
+ handler.setLevel(logging.INFO)
+ # create a logging format
+ formatter = logging.Formatter(SES_BACKEND_DEBUG_LOGFILE_FORMATTER)
+ handler.setFormatter(formatter)
+ # add the handlers to the logger
+ logger.addHandler(handler)
+ #logger.info('something we are logging')
diff --git a/django_aws_ses/signals.py b/django_aws_ses/signals.py
new file mode 100644
index 0000000..47ea1a1
--- /dev/null
+++ b/django_aws_ses/signals.py
@@ -0,0 +1,8 @@
+from django.dispatch import Signal
+
+# The following fields are used from the 3 signals below: mail_obj, bounce_obj, raw_message
+bounce_received = Signal()
+complaint_received = Signal()
+delivery_received = Signal()
+email_pre_send = Signal()
+email_post_send = Signal()
diff --git a/django_aws_ses/templates/django_aws_ses/base.html b/django_aws_ses/templates/django_aws_ses/base.html
new file mode 100644
index 0000000..30a1d01
--- /dev/null
+++ b/django_aws_ses/templates/django_aws_ses/base.html
@@ -0,0 +1,14 @@
+{% load static %}
+
+
+
+ {% block title1 %}N/A {% endblock title1 %} | {% block title2 %} {{ site.domain }}{% endblock title2 %}
+
+
+
+ {% block content %}
+ if you see this, something is wrong!
+ {% endblock content %}
+
+
+
\ No newline at end of file
diff --git a/django_aws_ses/templates/django_aws_ses/send_stats.html b/django_aws_ses/templates/django_aws_ses/send_stats.html
new file mode 100644
index 0000000..bba430f
--- /dev/null
+++ b/django_aws_ses/templates/django_aws_ses/send_stats.html
@@ -0,0 +1,144 @@
+{% extends "admin/base_site.html" %}
+
+{% block extrastyle %}
+ {{ block.super }}
+
+{% endblock %}
+
+{% block extrahead %}
+
+
+{% endblock %}
+
+{% block bodyclass %}dashboard{% endblock %}
+{% block content_title %}SES Stats
{% endblock %}
+
+{% block content %}
+Access Key: {{ access_key }}
+
+
+
+ Quotas
+
+
+ 24 Quota |
+ 24 Sent |
+ Quota Remaining |
+ Per/s Quota |
+
+
+
+
+ {{ 24hour_quota }} |
+ {{ 24hour_sent }} |
+ {{ 24hour_remaining }} |
+ {{ persecond_rate }} |
+
+
+
+
+
+
+
+ Sending Stats
+
+
+ Delivery Attempts |
+ Bounces |
+ Complaints |
+ Rejected |
+
+
+
+
+ {{ summary.DeliveryAttempts }} |
+ {{ summary.Bounces }} |
+ {{ summary.Complaints }} |
+ {{ summary.Rejects }} |
+
+
+
+
+
+
+
+ Sending Activity
+
+
+ Delivery Attempts |
+ Bounces |
+ Complaints |
+ Rejected |
+ {% if local_time %}Local Time{% else %}Timestamp{% endif %} |
+
+
+
+ {% for datapoint in datapoints %}
+
+ {{ datapoint.DeliveryAttempts }} |
+ {{ datapoint.Bounces }} |
+ {{ datapoint.Complaints }} |
+ {{ datapoint.Rejects }} |
+ {{ datapoint.Timestamp }} |
+
+ {% endfor %}
+
+
+
+
+{% endblock %}
+
+
+{% block sidebar %}
+
+
+
Verified Emails
+
+
+
+ Email Address |
+
+
+
+ {% for email_address in verified_emails %}
+
+ {{ email_address }} |
+
+ {% endfor %}
+
+
+ {{ verified_emails|length }} |
+
+
+
+
+{% endblock %}
diff --git a/django_aws_ses/templates/django_aws_ses/unsebscribe.html b/django_aws_ses/templates/django_aws_ses/unsebscribe.html
new file mode 100644
index 0000000..d3efa32
--- /dev/null
+++ b/django_aws_ses/templates/django_aws_ses/unsebscribe.html
@@ -0,0 +1,11 @@
+{% extends base_template_name %}
+
+{% block title1 %}
+Unsubscribe
+{% endblock title1 %}
+
+{% block content %}
+
+
{{ unsubscribe_message }}
+
+{% endblock content %}
\ No newline at end of file
diff --git a/django_aws_ses/urls.py b/django_aws_ses/urls.py
new file mode 100644
index 0000000..4ef9ef7
--- /dev/null
+++ b/django_aws_ses/urls.py
@@ -0,0 +1,17 @@
+from django.conf.urls import include, url
+from django.urls import path
+from django.views.decorators.csrf import csrf_exempt
+
+from .views import (
+ dashboard,
+ handle_bounce,
+ HandleUnsubscribe
+ )
+
+app_name = "django_aws_ses"
+
+urlpatterns = [
+ url(r'^status/$', dashboard, name='aws_ses_status'),
+ url(r'^bounce/$', csrf_exempt(handle_bounce),name='aws_ses_bounce'),
+ url(r'^unsubscribe/(?P[0-9a-zA-Z]+)/(?P[0-9a-zA-Z]+)/$', HandleUnsubscribe.as_view(), name='aws_ses_unsubscribe')
+]
diff --git a/django_aws_ses/utils.py b/django_aws_ses/utils.py
new file mode 100644
index 0000000..36b8d5e
--- /dev/null
+++ b/django_aws_ses/utils.py
@@ -0,0 +1,280 @@
+import base64
+import logging
+from builtins import str as text
+from builtins import bytes
+from io import StringIO
+try:
+ from urllib.parse import urlparse
+except ImportError:
+ from urlparse import urlparse
+from django.core.exceptions import ImproperlyConfigured
+from django.utils.encoding import smart_str
+from django.dispatch.dispatcher import receiver
+from django.db.models import Count
+
+from django.contrib.auth import get_user_model # If used custom user model
+User = get_user_model()
+
+from . import settings
+from . import signals
+from . import utils
+from .models import (
+ BounceRecord,
+ ComplaintRecord
+ )
+
+logger = settings.logger
+
+
+class BounceMessageVerifier(object):
+ """
+ A utility class for validating bounce messages
+
+ See: http://docs.amazonwebservices.com/sns/latest/gsg/SendMessageToHttp.verify.signature.html
+ """
+
+ def __init__(self, bounce_dict):
+ """
+ Creates a new bounce message from the given dict.
+ """
+ self._data = bounce_dict
+ self._verified = None
+
+ def is_verified(self):
+ """
+ Verifies an SES bounce message.
+
+ """
+ if self._verified is None:
+ signature = self._data.get('Signature')
+ if not signature:
+ self._verified = False
+ return self._verified
+
+ # Decode the signature from base64
+ signature = bytes(base64.b64decode(signature))
+
+ # Get the message to sign
+ sign_bytes = self._get_bytes_to_sign()
+ if not sign_bytes:
+ self._verified = False
+ return self._verified
+
+ if not self.certificate:
+ self._verified = False
+ return self._verified
+
+ # Extract the public key
+ pkey = self.certificate.get_pubkey()
+
+ # Use the public key to verify the signature.
+ pkey.verify_init()
+ pkey.verify_update(sign_bytes)
+ verify_result = pkey.verify_final(signature)
+
+ self._verified = verify_result == 1
+
+ return self._verified
+
+ @property
+ def certificate(self):
+ """
+ Retrieves the certificate used to sign the bounce message.
+
+ TODO: Cache the certificate based on the cert URL so we don't have to
+ retrieve it for each bounce message. *We would need to do it in a
+ secure way so that the cert couldn't be overwritten in the cache*
+ """
+ if not hasattr(self, '_certificate'):
+ cert_url = self._get_cert_url()
+ # Only load certificates from a certain domain?
+ # Without some kind of trusted domain check, any old joe could
+ # craft a bounce message and sign it using his own certificate
+ # and we would happily load and verify it.
+
+ if not cert_url:
+ self._certificate = None
+ return self._certificate
+
+ try:
+ import requests
+ except ImportError:
+ raise ImproperlyConfigured(
+ "`requests` is required for bounce message verification. "
+ "Please consider installing the `django-ses` with the "
+ "`bounce` extra - e.g. `pip install django-ses[bounce]`."
+ )
+
+ try:
+ import M2Crypto
+ except ImportError:
+ raise ImproperlyConfigured(
+ "`M2Crypto` is required for bounce message verification. "
+ "Please consider installing the `django-ses` with the "
+ "`bounce` extra - e.g. `pip install django-ses[bounce]`."
+ )
+
+ # We use requests because it verifies the https certificate
+ # when retrieving the signing certificate. If https was somehow
+ # hijacked then all bets are off.
+ response = requests.get(cert_url)
+ if response.status_code != 200:
+ logger.warning(u'Could not download certificate from %s: "%s"', cert_url, response.status_code)
+ self._certificate = None
+ return self._certificate
+
+ # Handle errors loading the certificate.
+ # If the certificate is invalid then return
+ # false as we couldn't verify the message.
+ try:
+ self._certificate = M2Crypto.X509.load_cert_string(response.content)
+ except M2Crypto.X509.X509Error as e:
+ logger.warning(u'Could not load certificate from %s: "%s"', cert_url, e)
+ self._certificate = None
+
+ return self._certificate
+
+ def _get_cert_url(self):
+ """
+ Get the signing certificate URL.
+ Only accept urls that match the domains set in the
+ AWS_SNS_BOUNCE_CERT_TRUSTED_DOMAINS setting. Sub-domains
+ are allowed. i.e. if amazonaws.com is in the trusted domains
+ then sns.us-east-1.amazonaws.com will match.
+ """
+ cert_url = self._data.get('SigningCertURL')
+ if cert_url:
+ if cert_url.startswith('https://'):
+ url_obj = urlparse(cert_url)
+ for trusted_domain in settings.BOUNCE_CERT_DOMAINS:
+ parts = trusted_domain.split('.')
+ if url_obj.netloc.split('.')[-len(parts):] == parts:
+ return cert_url
+ logger.warning(u'Untrusted certificate URL: "%s"', cert_url)
+ else:
+ logger.warning(u'No signing certificate URL: "%s"', cert_url)
+ return None
+
+ def _get_bytes_to_sign(self):
+ """
+ Creates the message used for signing SNS notifications.
+ This is used to verify the bounce message when it is received.
+ """
+
+ # Depending on the message type the fields to add to the message
+ # differ so we handle that here.
+ msg_type = self._data.get('Type')
+ if msg_type == 'Notification':
+ fields_to_sign = [
+ 'Message',
+ 'MessageId',
+ 'Subject',
+ 'Timestamp',
+ 'TopicArn',
+ 'Type',
+ ]
+ elif (msg_type == 'SubscriptionConfirmation' or
+ msg_type == 'UnsubscribeConfirmation'):
+ fields_to_sign = [
+ 'Message',
+ 'MessageId',
+ 'SubscribeURL',
+ 'Timestamp',
+ 'Token',
+ 'TopicArn',
+ 'Type',
+ ]
+ else:
+ # Unrecognized type
+ logger.warning(u'Unrecognized SNS message Type: "%s"', msg_type)
+ return None
+
+ outbytes = StringIO()
+ for field_name in fields_to_sign:
+ field_value = smart_str(self._data.get(field_name, ''),
+ errors="replace")
+ if field_value:
+ outbytes.write(text(field_name))
+ outbytes.write(text("\n"))
+ outbytes.write(text(field_value))
+ outbytes.write(text("\n"))
+
+ response = outbytes.getvalue()
+ return bytes(response, 'utf-8')
+
+
+def verify_bounce_message(msg):
+ """
+ Verify an SES/SNS bounce notification message.
+ """
+ verifier = BounceMessageVerifier(msg)
+ return verifier.is_verified()
+
+@receiver(signals.email_pre_send)
+def receiver_email_pre_send(sender, message=None, **kwargs):
+ logger.info("receiver_email_pre_send received signal")
+
+def filter_recipiants(recipiant_list):
+
+ if len(recipiant_list) > 0:
+ recipiant_list = filter_recipiants_with_unsubscribe(recipiant_list)
+
+ if len(recipiant_list) > 0:
+ recipiant_list = filter_recipiants_with_complaint_records(recipiant_list)
+
+ if len(recipiant_list) > 0:
+ recipiant_list = filter_recipiants_with_bounce_records(recipiant_list)
+
+ return recipiant_list
+
+def filter_recipiants_with_unsubscribe(recipiant_list):
+ """
+ filter message recipiants so we don't send emails to any email that have Unsubscribude
+ """
+ logger.info("unsubscribe filter running")
+
+ logger.info("message.recipients() befor blacklist_emails filter: %s" % recipiant_list)
+ blacklist_emails = list(set([record.email for record in User.objects.filter(aws_ses__unsubscribe=True)]))
+
+ if blacklist_emails:
+ return filter_recipiants_with_blacklist(recipiant_list, blacklist_emails)
+ else:
+ return recipiant_list
+
+def filter_recipiants_with_complaint_records(recipiant_list):
+ """
+ filter message recipiants so we don't send emails to any email that have a ComplaintRecord
+ """
+ logger.info("complaint_records filter running")
+
+ logger.info("message.recipients() befor blacklist_emails filter: %s" % recipiant_list)
+ blacklist_emails = list(set([record.email for record in ComplaintRecord.objects.filter(email__isnull=False)]))
+
+ if blacklist_emails:
+ return filter_recipiants_with_blacklist(recipiant_list, blacklist_emails)
+ else:
+ return recipiant_list
+
+def filter_recipiants_with_bounce_records(recipiant_list):
+ """
+ filter message recipiants so we dont send emails to any email that has more BounceRecord
+ the SES_BOUNCE_LIMIT
+ """
+ logger.info("bounce_records filter running")
+
+ logger.info("message.recipients() befor blacklist_emails filter: %s" % recipiant_list)
+ blacklist_emails = list(set([record.email for record in BounceRecord.objects.filter(email__isnull=False).annotate(total=Count('email')).filter(total__gte=settings.SES_BOUNCE_LIMIT)]))
+ if blacklist_emails:
+ return filter_recipiants_with_blacklist(recipiant_list, blacklist_emails)
+ else:
+ return recipiant_list
+def filter_recipiants_with_blacklist(recipiant_list, blacklist_emails):
+ """
+ filter message recipiants with a list of email you dont want to email
+ """
+ logger.info("blacklist_emails filter list: %s" % blacklist_emails)
+ filtered_recipiant_list = [email for email in recipiant_list if email not in blacklist_emails]
+
+ logger.info("filtered_recipiant_list: %s" % filtered_recipiant_list)
+ return filtered_recipiant_list
+
diff --git a/django_aws_ses/views.py b/django_aws_ses/views.py
new file mode 100644
index 0000000..5881fb7
--- /dev/null
+++ b/django_aws_ses/views.py
@@ -0,0 +1,454 @@
+import json
+
+import boto3
+import pytz
+try:
+ from urllib.request import urlopen
+ from urllib.error import URLError
+except ImportError:
+ from urllib2 import urlopen, URLError
+import copy
+import logging
+from datetime import datetime
+
+from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode
+
+from django.http import HttpResponse, HttpResponseBadRequest
+from django.views.decorators.http import require_POST
+from django.core.cache import cache
+from django.core.exceptions import PermissionDenied
+from django.shortcuts import render, redirect
+from django.contrib.auth import get_user_model
+from django.views.generic.base import TemplateView
+from django.utils.encoding import force_bytes, force_text
+
+from . import settings
+from . import signals
+from . import utils
+from .models import (
+ BounceRecord,
+ ComplaintRecord,
+ SendRecord,
+ UnknownRecord,
+ AwsSesUserAddon
+ )
+
+logger = settings.logger
+
+User = get_user_model()
+
+def superuser_only(view_func):
+ """
+ Limit a view to superuser only.
+ """
+ def _inner(request, *args, **kwargs):
+ if not request.user.is_superuser:
+ raise PermissionDenied
+ return view_func(request, *args, **kwargs)
+ return _inner
+
+
+def stats_to_list(stats_dict, localize=pytz):
+ """
+ Parse the output of ``SESConnection.get_send_statistics()`` in to an
+ ordered list of 15-minute summaries.
+ """
+ # Make a copy, so we don't change the original stats_dict.
+ result = copy.deepcopy(stats_dict)
+ datapoints = []
+ if localize:
+ current_tz = localize.timezone(settings.TIME_ZONE)
+ else:
+ current_tz = None
+ for dp in result['SendDataPoints']:
+ if current_tz:
+ utc_dt = dp['Timestamp']
+ dp['Timestamp'] = current_tz.normalize(
+ utc_dt.astimezone(current_tz))
+ datapoints.append(dp)
+
+ datapoints.sort(key=lambda x: x['Timestamp'])
+
+ return datapoints
+
+
+def emails_parse(emails_dict):
+ """
+ Parse the output of ``SESConnection.list_verified_emails()`` and get
+ a list of emails.
+ """
+ return sorted([email for email in emails_dict['VerifiedEmailAddresses']])
+
+
+def sum_stats(stats_data):
+ """
+ Summarize the bounces, complaints, delivery attempts and rejects from a
+ list of datapoints.
+ """
+ t_bounces = 0
+ t_complaints = 0
+ t_delivery_attempts = 0
+ t_rejects = 0
+ for dp in stats_data:
+ t_bounces += dp['Bounces']
+ t_complaints += dp['Complaints']
+ t_delivery_attempts += dp['DeliveryAttempts']
+ t_rejects += dp['Rejects']
+
+ return {
+ 'Bounces': t_bounces,
+ 'Complaints': t_complaints,
+ 'DeliveryAttempts': t_delivery_attempts,
+ 'Rejects': t_rejects,
+ }
+
+
+@superuser_only
+def dashboard(request):
+ """
+ Graph SES send statistics over time.
+ """
+ cache_key = 'vhash:django_aws_ses_status'
+ cached_view = cache.get(cache_key)
+ if cached_view:
+ return cached_view
+
+ ses_conn = boto3.client(
+ 'ses',
+ aws_access_key_id=settings.ACCESS_KEY,
+ aws_secret_access_key=settings.SECRET_KEY,
+ region_name=settings.AWS_SES_REGION_NAME,
+ endpoint_url=settings.AWS_SES_REGION_ENDPOINT_URL,
+ )
+
+ quota_dict = ses_conn.get_send_quota()
+ verified_emails_dict = ses_conn.list_verified_email_addresses()
+ stats = ses_conn.get_send_statistics()
+
+ verified_emails = emails_parse(verified_emails_dict)
+ ordered_data = stats_to_list(stats)
+ summary = sum_stats(ordered_data)
+
+ extra_context = {
+ 'title': 'SES Statistics',
+ 'datapoints': ordered_data,
+ '24hour_quota': quota_dict['Max24HourSend'],
+ '24hour_sent': quota_dict['SentLast24Hours'],
+ '24hour_remaining':
+ quota_dict['Max24HourSend'] -
+ quota_dict['SentLast24Hours'],
+ 'persecond_rate': quota_dict['MaxSendRate'],
+ 'verified_emails': verified_emails,
+ 'summary': summary,
+ 'access_key': settings.ACCESS_KEY,
+ 'local_time': True,
+ }
+
+ response = render(request, 'django_aws_ses/send_stats.html', extra_context)
+
+ cache.set(cache_key, response, 60 * 15) # Cache for 15 minutes
+ return response
+
+
+@require_POST
+def handle_bounce(request):
+ """
+ Handle a bounced email via an SNS webhook.
+
+ Parse the bounced message and send the appropriate signal.
+ For bounce messages the bounce_received signal is called.
+ For complaint messages the complaint_received signal is called.
+ See: http://docs.aws.amazon.com/sns/latest/gsg/json-formats.html#http-subscription-confirmation-json
+ See: http://docs.amazonwebservices.com/ses/latest/DeveloperGuide/NotificationsViaSNS.html
+
+ In addition to email bounce requests this endpoint also supports the SNS
+ subscription confirmation request. This request is sent to the SNS
+ subscription endpoint when the subscription is registered.
+ See: http://docs.aws.amazon.com/sns/latest/gsg/Subscribe.html
+
+ For the format of the SNS subscription confirmation request see this URL:
+ http://docs.aws.amazon.com/sns/latest/gsg/json-formats.html#http-subscription-confirmation-json
+
+ SNS message signatures are verified by default. This functionality can
+ be disabled by setting AWS_SES_VERIFY_BOUNCE_SIGNATURES to False.
+ However, this is not recommended.
+ See: http://docs.amazonwebservices.com/sns/latest/gsg/SendMessageToHttp.verify.signature.html
+ """
+ logger.warning(u'Received SNS call back')
+
+
+ raw_json = request.body
+
+ try:
+ notification = json.loads(raw_json.decode('utf-8'))
+ except ValueError as e:
+ # TODO: What kind of response should be returned here?
+ logger.warning(u'Received bounce with bad JSON: "%s"', e)
+ return HttpResponseBadRequest()
+
+ # Verify the authenticity of the bounce message.
+ if (settings.VERIFY_BOUNCE_SIGNATURES and
+ not utils.verify_bounce_message(notification)):
+ # Don't send any info back when the notification is not
+ # verified. Simply, don't process it.
+ logger.info(
+ u'Received unverified notification: Type: %s',
+ notification.get('Type'),
+ extra={
+ 'notification': notification,
+ },
+ )
+ return HttpResponse()
+ logger.info('notification.get("Type"): %s' % notification.get("Type"))
+ if notification.get('Type') in ('SubscriptionConfirmation',
+ 'UnsubscribeConfirmation'):
+ # Process the (un)subscription confirmation.
+
+ logger.info(
+ u'Received subscription confirmation: TopicArn: %s',
+ notification.get('TopicArn'),
+ extra={
+ 'notification': notification,
+ },
+ )
+
+ # Get the subscribe url and hit the url to confirm the subscription.
+ subscribe_url = notification.get('SubscribeURL')
+ try:
+ urlopen(subscribe_url).read()
+ except URLError as e:
+ # Some kind of error occurred when confirming the request.
+ logger.error(
+ u'Could not confirm subscription: "%s"', e,
+ extra={
+ 'notification': notification,
+ },
+ exc_info=True,
+ )
+ elif notification.get('Type') == 'Notification':
+ try:
+ message = json.loads(notification['Message'])
+ except ValueError as e:
+ # The message isn't JSON.
+ # Just ignore the notification.
+ logger.warning(u'Received bounce with bad JSON: "%s"', e, extra={
+ 'notification': notification,
+ })
+
+ else:
+
+ mail_obj = message.get('mail')
+ event_type = message.get('notificationType', message.get('eventType'))
+ logger.info('event_type: %s' % event_type)
+ if event_type == 'Bounce':
+ # Bounce
+ bounce_obj = message.get('bounce', {})
+
+ # Logging
+ feedback_id = bounce_obj.get('feedbackId')
+ bounce_type = bounce_obj.get('bounceType')
+ bounce_subtype = bounce_obj.get('bounceSubType')
+ bounce_recipients = bounce_obj.get('bouncedRecipients', [])
+
+ # create a BounceRecord so we can keep from sending to bad emails.
+ logger.info('create records')
+ for recipient in bounce_recipients:
+ logger.info('recipient: %s' % recipient)
+ BounceRecord.objects.create(
+ email = recipient.get('emailAddress', None),
+ status = recipient.get('status', None),
+ action = recipient.get('action', None),
+ diagnostic_code = recipient.get('diagnosticCode', None),
+ bounce_type = bounce_obj.get('bounceType', None),
+ bounce_sub_type = bounce_obj.get('bounceSubType', None),
+ feedback_id = bounce_obj.get('feedbackId', None),
+ reporting_mta = bounce_obj.get('reportingMTA', None),
+ )
+
+ logger.info(
+ u'Received bounce notification: feedbackId: %s, bounceType: %s, bounceSubType: %s',
+ feedback_id, bounce_type, bounce_subtype,
+ extra={
+ 'notification': notification,
+ },
+ )
+
+ signals.bounce_received.send(
+ sender=handle_bounce,
+ mail_obj=mail_obj,
+ bounce_obj=bounce_obj,
+ raw_message=raw_json,
+ )
+
+ elif event_type == 'Complaint':
+ # Complaint
+ complaint_obj = message.get('complaint', {})
+
+ # Logging
+ feedback_id = complaint_obj.get('feedbackId')
+ feedback_type = complaint_obj.get('complaintFeedbackType')
+ complaint_recipients = complaint_obj.get('complainedRecipients')
+ logger.info('create records')
+ for recipient in complaint_recipients:
+ logger.info('recipient: %s' % recipient)
+ ComplaintRecord.objects.create(
+ email = recipient.get('emailAddress', None),
+ sub_type = complaint_obj.get('complaintSubType', None),
+ feedback_id = complaint_obj.get('feedbackId', None),
+ feedback_type = complaint_obj.get('complaintFeedbackType', None),
+ )
+
+ logger.info(
+ u'Received complaint notification: feedbackId: %s, feedbackType: %s',
+ feedback_id, feedback_type,
+ extra={
+ 'notification': notification,
+ },
+ )
+
+ signals.complaint_received.send(
+ sender=handle_bounce,
+ mail_obj=mail_obj,
+ complaint_obj=complaint_obj,
+ raw_message=raw_json,
+ )
+
+ elif event_type in ['Delivery','Send']:
+ # Delivery
+ send_obj = message.get('mail', {})
+
+ logger.info('send_obj: %s' % send_obj)
+
+ source = send_obj.get('source', 'N/A')#settings.DEFAULT_FROM_EMAIL)
+ destinations = send_obj.get('destination', [])
+ message_id = send_obj.get('messageId','N/A')
+ delivery = message.get('delivery', None)
+ aws_process_time = -1
+ smtp_response = 'N/A'
+ if delivery:
+ logger.info('we are a delivery and had a delivery key')
+ aws_process_time = delivery.get('processingTimeMillis',0)
+ smtp_response = delivery.get('smtpResponse', 'N/A')
+
+ common_headers = send_obj.get('commonHeaders', None)
+ subject = "N/A"
+ if common_headers:
+ subject = common_headers.get('subject','N/A')
+ status = event_type
+ logger.info('create records')
+ logger.info('destinations: %s' % destinations)
+ for destination in destinations:
+ try:
+ logger.info('destination: %s' % destination)
+ send_record, created = SendRecord.objects.get_or_create(
+ source = source,
+ destination = destination,
+ status = status,
+ message_id = message_id,
+ defaults={
+ "aws_process_time": aws_process_time,
+ "smtp_response": smtp_response,
+ "subject": subject
+ }
+
+ )
+ if send_record.subject == "N/A":
+ send_record.subject = subject
+
+ if send_record.smtp_response == "N/A":
+ send_record.smtp_response = smtp_response
+
+ if send_record.aws_process_time == -1:
+ send_record.aws_process_time = aws_process_time
+
+ send_record.save()
+ except Exception as e:
+ logger.info("error well trying to get_or_create record: %s" % e)
+ logger.info(
+ u'Received delivery notification: messageId: %s, feedbackType: %s',
+ message_id, feedback_type,
+ extra={
+ 'notification': notification,
+ },
+ )
+
+ signals.delivery_received.send(
+ sender=handle_bounce,
+ mail_obj=mail_obj,
+ delivery_obj=delivery_obj,
+ raw_message=raw_json,
+ )
+
+ else:
+ # We received an unknown notification type. Just log and
+ # ignore it.
+
+ UnknownRecord.objects.create(
+ event_type = eventType,
+ aws_data = str(notification)
+ )
+
+ logger.warning(u"Received unknown event", extra={
+ 'notification': notification,
+ })
+ else:
+
+ UnknownRecord.objects.create(
+ eventType = notification.get('Type'),
+ aws_data = str(notification)
+ )
+
+ logger.info(
+ u'Received unknown notification type: %s',
+ notification.get('Type'),
+ extra={
+ 'notification': notification,
+ },
+ )
+
+ # AWS will consider anything other than 200 to be an error response and
+ # resend the SNS request. We don't need that so we return 200 here.
+ return HttpResponse()
+
+class HandleUnsubscribe(TemplateView):
+
+ http_method_names = ['get']
+
+ template_name = settings.UNSUBSCRIBE_TEMPLET
+ base_template_name = settings.BASE_TEMPLET
+ unsubscribe_message = "We Have Unsubscribed the Following Email"
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ logger.info("in get_context_data ----- self.base_template_name: %s" % self.base_template_name)
+ context['base_template_name'] = self.base_template_name
+ context['unsubscribe_message'] = self.unsubscribe_message
+ return context
+
+ def get(self, request, *args, **kwargs):
+ uuid = self.kwargs['uuid']
+ hash = self.kwargs['hash']
+
+ logger.info("in get ----- self.base_template_name: %s" % self.base_template_name)
+
+ try:
+ uuid = force_text(urlsafe_base64_decode(uuid).decode())
+ logger.info('uuid: %s' % uuid)
+ user = User.objects.get(pk=uuid)
+ logger.info('user.pk: %s' % user.pk)
+ except (TypeError, ValueError, OverflowError, User.DoesNotExist):
+ return redirect(settings.HOME_URL)
+ try:
+ ses = user.aws_ses
+ except AwsSesUserAddon.DoesNotExist:
+ ses = AwsSesUserAddon.objects.create(user=user)
+
+ if user is not None and user.aws_ses.check_unsubscribe_hash(hash):
+ logger.info('ses.pk: %s' % ses.pk)
+ ses.unsubscribe = True
+ ses.save()
+ else:
+ logger.warning("bad hash was provided!")
+ return redirect(settings.HOME_URL)
+
+ return super(HandleUnsubscribe, self).get(request, *args, **kwargs)
\ No newline at end of file