From 6329154ffa37f578bff0e70679014a6b3b059cd5 Mon Sep 17 00:00:00 2001 From: Raymond Jessop Date: Fri, 19 Feb 2021 01:59:04 +0000 Subject: [PATCH] first commit of python module project git-svn-id: https://vault.zeeksgeeks.com/svn/django_aws_ses/trunk@4 ed966f06-d3d6-432b-bc91-693151a5c6b4 --- .project | 17 + .pydevproject | 5 + LICENSE | 22 + README.md | 0 django_aws_ses/__init__.py | 8 + django_aws_ses/admin.py | 76 +++ django_aws_ses/apps.py | 6 + django_aws_ses/backends.py | 253 ++++++++++ django_aws_ses/models.py | 136 ++++++ django_aws_ses/settings.py | 82 ++++ django_aws_ses/signals.py | 8 + .../templates/django_aws_ses/base.html | 14 + .../templates/django_aws_ses/send_stats.html | 144 ++++++ .../templates/django_aws_ses/unsebscribe.html | 11 + django_aws_ses/urls.py | 17 + django_aws_ses/utils.py | 280 +++++++++++ django_aws_ses/views.py | 454 ++++++++++++++++++ 17 files changed, 1533 insertions(+) create mode 100644 .project create mode 100644 .pydevproject create mode 100644 LICENSE create mode 100644 README.md create mode 100644 django_aws_ses/__init__.py create mode 100644 django_aws_ses/admin.py create mode 100644 django_aws_ses/apps.py create mode 100644 django_aws_ses/backends.py create mode 100644 django_aws_ses/models.py create mode 100644 django_aws_ses/settings.py create mode 100644 django_aws_ses/signals.py create mode 100644 django_aws_ses/templates/django_aws_ses/base.html create mode 100644 django_aws_ses/templates/django_aws_ses/send_stats.html create mode 100644 django_aws_ses/templates/django_aws_ses/unsebscribe.html create mode 100644 django_aws_ses/urls.py create mode 100644 django_aws_ses/utils.py create mode 100644 django_aws_ses/views.py 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 Quota24 SentQuota RemainingPer/s Quota
{{ 24hour_quota }}{{ 24hour_sent }}{{ 24hour_remaining }}{{ persecond_rate }}
+
+ +
+ + + + + + + + + + + + + + + + + + +
Sending Stats
Delivery AttemptsBouncesComplaintsRejected
{{ summary.DeliveryAttempts }}{{ summary.Bounces }}{{ summary.Complaints }}{{ summary.Rejects }}
+
+
+
+ + + + + + + + + + + + + {% for datapoint in datapoints %} + + + + + + + + {% endfor %} + +
Sending Activity
Delivery AttemptsBouncesComplaintsRejected{% if local_time %}Local Time{% else %}Timestamp{% endif %}
{{ datapoint.DeliveryAttempts }}{{ datapoint.Bounces }}{{ datapoint.Complaints }}{{ datapoint.Rejects }}{{ datapoint.Timestamp }}
+
+
+{% endblock %} + + +{% block sidebar %} + +{% 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