first commit of python module project
git-svn-id: https://vault.zeeksgeeks.com/svn/django_aws_ses/trunk@4 ed966f06-d3d6-432b-bc91-693151a5c6b4
This commit is contained in:
parent
014debbf9d
commit
6329154ffa
|
@ -0,0 +1,17 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<projectDescription>
|
||||
<name>trunk</name>
|
||||
<comment></comment>
|
||||
<projects>
|
||||
</projects>
|
||||
<buildSpec>
|
||||
<buildCommand>
|
||||
<name>org.python.pydev.PyDevBuilder</name>
|
||||
<arguments>
|
||||
</arguments>
|
||||
</buildCommand>
|
||||
</buildSpec>
|
||||
<natures>
|
||||
<nature>org.python.pydev.pythonNature</nature>
|
||||
</natures>
|
||||
</projectDescription>
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<?eclipse-pydev version="1.0"?><pydev_project>
|
||||
<pydev_property name="org.python.pydev.PYTHON_PROJECT_INTERPRETER">Default</pydev_property>
|
||||
<pydev_property name="org.python.pydev.PYTHON_PROJECT_VERSION">python interpreter</pydev_property>
|
||||
</pydev_project>
|
|
@ -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.
|
|
@ -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',)
|
|
@ -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)
|
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class DjangoAwsSesBackendConfig(AppConfig):
|
||||
name = 'django_aws_ses'
|
||||
verbose_name = 'Django AWS SES'
|
|
@ -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()
|
|
@ -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)
|
||||
|
|
@ -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')
|
|
@ -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()
|
|
@ -0,0 +1,14 @@
|
|||
{% load static %}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{% block title1 %}N/A {% endblock title1 %} | {% block title2 %} {{ site.domain }}{% endblock title2 %}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="body_contaner" class=" cardContenet">
|
||||
{% block content %}
|
||||
if you see this, something is wrong!
|
||||
{% endblock content %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,144 @@
|
|||
{% extends "admin/base_site.html" %}
|
||||
|
||||
{% block extrastyle %}
|
||||
{{ block.super }}
|
||||
<style>table {width: 100%;}</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block extrahead %}
|
||||
<script type="text/javascript" src="https://www.google.com/jsapi"></script>
|
||||
<script type="text/javascript">
|
||||
google.load("visualization", "1", {packages:["corechart"]});
|
||||
google.setOnLoadCallback(drawChart);
|
||||
function drawChart() {
|
||||
var data = new google.visualization.DataTable();
|
||||
data.addColumn('string', 'Time');
|
||||
data.addColumn('number', 'Delivery Attempts');
|
||||
data.addColumn('number', 'Bounces');
|
||||
data.addColumn('number', 'Complaints');
|
||||
data.addColumn('number', 'Rejected');
|
||||
data.addRows({{ datapoints|length }});
|
||||
{% for datapoint in datapoints %}
|
||||
data.setValue({{ forloop.counter0 }}, 0, {% if local_time %}'{{ datapoint.Timestamp }}'{% else %}'{{ datapoint.Timestamp|slice:"11:19" }} {{ datapoint.Timestamp|slice:":10" }}'{% endif %});
|
||||
data.setValue({{ forloop.counter0 }}, 1, {{ datapoint.DeliveryAttempts }});
|
||||
data.setValue({{ forloop.counter0 }}, 2, {{ datapoint.Bounces }});
|
||||
data.setValue({{ forloop.counter0 }}, 3, {{ datapoint.Complaints }});
|
||||
data.setValue({{ forloop.counter0 }}, 4, {{ datapoint.Rejects }});
|
||||
{% endfor %}
|
||||
|
||||
var chart = new google.visualization.LineChart(document.getElementById('chart'));
|
||||
chart.draw(data, {
|
||||
width: 498,
|
||||
height: 300,
|
||||
title: 'Sending Stats',
|
||||
hAxis: {textPosition: 'none'},
|
||||
chartArea: {left:30,top:30,width:460,height:230},
|
||||
legend: 'bottom'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block bodyclass %}dashboard{% endblock %}
|
||||
{% block content_title %}<h1>SES Stats</h1>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p>Access Key: <span id="aws_access_key_id">{{ access_key }}</span></p>
|
||||
<div id="content-main">
|
||||
<div class="module">
|
||||
<table id="quota">
|
||||
<caption>Quotas</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>24 Quota</th>
|
||||
<th>24 Sent</th>
|
||||
<th>Quota Remaining</th>
|
||||
<th>Per/s Quota</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{{ 24hour_quota }}</td>
|
||||
<td>{{ 24hour_sent }}</td>
|
||||
<td>{{ 24hour_remaining }}</td>
|
||||
<td>{{ persecond_rate }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="module">
|
||||
<table id="sending_totals">
|
||||
<caption>Sending Stats</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Delivery Attempts</th>
|
||||
<th>Bounces</th>
|
||||
<th>Complaints</th>
|
||||
<th>Rejected</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{{ summary.DeliveryAttempts }}</td>
|
||||
<td>{{ summary.Bounces }}</td>
|
||||
<td>{{ summary.Complaints }}</td>
|
||||
<td>{{ summary.Rejects }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div id="chart"></div>
|
||||
</div>
|
||||
<div class="module">
|
||||
<table id="sending_stats">
|
||||
<caption>Sending Activity</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:35px">Delivery Attempts</th>
|
||||
<th>Bounces</th>
|
||||
<th>Complaints</th>
|
||||
<th>Rejected</th>
|
||||
<th>{% if local_time %}Local Time{% else %}Timestamp{% endif %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for datapoint in datapoints %}
|
||||
<tr>
|
||||
<td>{{ datapoint.DeliveryAttempts }}</td>
|
||||
<td>{{ datapoint.Bounces }}</td>
|
||||
<td>{{ datapoint.Complaints }}</td>
|
||||
<td>{{ datapoint.Rejects }}</td>
|
||||
<td>{{ datapoint.Timestamp }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block sidebar %}
|
||||
<div id="content-related">
|
||||
<div class="module" id="recent-actions-module">
|
||||
<h2>Verified Emails</h2>
|
||||
<table id="verified_emails">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email Address</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for email_address in verified_emails %}
|
||||
<tr>
|
||||
<td>{{ email_address }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr><td><strong>{{ verified_emails|length }}</strong></td></tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -0,0 +1,11 @@
|
|||
{% extends base_template_name %}
|
||||
|
||||
{% block title1 %}
|
||||
Unsubscribe
|
||||
{% endblock title1 %}
|
||||
|
||||
{% block content %}
|
||||
<div>
|
||||
<h3>{{ unsubscribe_message }}</h3>
|
||||
</div>
|
||||
{% endblock content %}
|
|
@ -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<uuid>[0-9a-zA-Z]+)/(?P<hash>[0-9a-zA-Z]+)/$', HandleUnsubscribe.as_view(), name='aws_ses_unsubscribe')
|
||||
]
|
|
@ -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
|
||||
|
|
@ -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)
|
Loading…
Reference in New Issue