├── example ├── __init__.py ├── local_settings.template.py ├── middleware.py ├── templates │ ├── index.html │ ├── base.html │ └── send-email.html ├── manage.py ├── urls.py ├── views.py └── settings.py ├── django_ses ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── get_ses_statistics.py │ │ └── ses_email_address.py ├── migrations │ ├── __init__.py │ └── 0001_initial.py ├── tests │ ├── utils.py │ ├── __init__.py │ ├── test_urls.py │ ├── settings.py │ ├── test_verifier.py │ ├── commands.py │ ├── backend.py │ ├── views.py │ └── stats.py ├── signals.py ├── urls.py ├── admin.py ├── models.py ├── settings.py ├── templates │ └── django_ses │ │ └── send_stats.html ├── utils.py ├── __init__.py └── views.py ├── .gitignore ├── AUTHORS ├── MANIFEST.in ├── .travis.yml ├── tox.ini ├── LICENSE ├── runtests.py ├── setup.py └── README.rst /example/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_ses/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_ses/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_ses/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.db 3 | local_settings.py 4 | django_ses.egg-info 5 | dist/* 6 | .DS_Store 7 | .tox/* 8 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Harry Marr 2 | Wes Winham 3 | Ross Lawley 4 | -------------------------------------------------------------------------------- /example/local_settings.template.py: -------------------------------------------------------------------------------- 1 | AWS_ACCESS_KEY_ID = 'YOUR-ACCESS-KEY-ID' 2 | AWS_SECRET_ACCESS_KEY = 'YOUR-SECRET-ACCESS-KEY' 3 | 4 | -------------------------------------------------------------------------------- /django_ses/tests/utils.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | def unload_django_ses(): 4 | del sys.modules['django_ses.settings'] 5 | del sys.modules['django_ses'] 6 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS 2 | include LICENSE 3 | include README.rst 4 | recursive-include example * 5 | recursive-include django_ses/templates/django_ses *.html 6 | -------------------------------------------------------------------------------- /django_ses/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from .backend import * 2 | from .commands import * 3 | from .settings import * 4 | from .stats import * 5 | from .views import * 6 | from .test_verifier import * 7 | -------------------------------------------------------------------------------- /django_ses/signals.py: -------------------------------------------------------------------------------- 1 | 2 | from django.dispatch import Signal 3 | 4 | bounce_received = Signal(providing_args=["mail_obj", "bounce_obj", "raw_message"]) 5 | 6 | complaint_received = Signal(providing_args=["mail_obj", "complaint_obj", "raw_message"]) 7 | -------------------------------------------------------------------------------- /example/middleware.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import AnonymousUser 2 | 3 | 4 | class FakeSuperuserMiddleware(object): 5 | 6 | def process_request(self, request): 7 | request.user = AnonymousUser() 8 | request.user.is_superuser = True 9 | 10 | -------------------------------------------------------------------------------- /example/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 | 8 | {% endblock %} 9 | -------------------------------------------------------------------------------- /django_ses/urls.py: -------------------------------------------------------------------------------- 1 | try: 2 | from django.conf.urls import patterns, url 3 | except ImportError: # django < 1.4 4 | from django.conf.urls.defaults import patterns, url 5 | 6 | urlpatterns = patterns('django_ses.views', 7 | url(r'^$', 'dashboard', name='django_ses_stats'), 8 | ) 9 | -------------------------------------------------------------------------------- /django_ses/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import SESStat 4 | 5 | 6 | class SESStatAdmin(admin.ModelAdmin): 7 | list_display = ('date', 'delivery_attempts', 'bounces', 'complaints', 8 | 'rejects') 9 | 10 | admin.site.register(SESStat, SESStatAdmin) 11 | -------------------------------------------------------------------------------- /example/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Django-SES Example 4 | 7 | 8 | 9 |

Django-SES Example

10 | {% block content %}{% endblock %} 11 | 12 | 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: python 3 | env: 4 | - TESTENV=py27-django13 5 | - TESTENV=py27-django14 6 | - TESTENV=py27-django15 7 | - TESTENV=py27-django16 8 | - TESTENV=py27-django17 9 | - TESTENV=py27-django18 10 | - TESTENV=py34-django17 11 | - TESTENV=py34-django18 12 | 13 | before_install: 14 | - pip install tox 15 | 16 | script: tox -e $TESTENV 17 | 18 | notifications: 19 | email: false 20 | irc: false 21 | -------------------------------------------------------------------------------- /django_ses/tests/test_urls.py: -------------------------------------------------------------------------------- 1 | try: 2 | from django.conf.urls import patterns, url 3 | except ImportError: 4 | # Fall back to the old, pre-1.6 style 5 | from django.conf.urls.defaults import patterns, url 6 | 7 | urlpatterns = patterns('django_ses.views', 8 | url(r'^dashboard/$', 'dashboard', name='django_ses_stats'), 9 | url(r'^bounce/$', 'handle_bounce', name='django_ses_bounce'), 10 | #url(r'^complaint/$', 'handle_complaint', name='django_ses_complaint'), 11 | ) 12 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27-{django13,django14,django15,django16,django17,django18},py34-{django17,django18} 3 | downloadcache = {toxworkdir}/.cache 4 | 5 | [testenv] 6 | commands = 7 | python runtests.py 8 | deps = 9 | mock 10 | py27-django13: Django>=1.3,<1.4 11 | py27-django14: Django>=1.4,<1.5 12 | py27-django15: Django>=1.5,<1.6 13 | py27-django16: Django>=1.6,<1.7 14 | {py27,py34}-django17: Django>=1.7,<1.8 15 | {py27,py34}-django18: Django>=1.8,<1.9 16 | -------------------------------------------------------------------------------- /django_ses/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | class SESStat(models.Model): 4 | date = models.DateField(unique=True, db_index=True) 5 | delivery_attempts = models.PositiveIntegerField() 6 | bounces = models.PositiveIntegerField() 7 | complaints = models.PositiveIntegerField() 8 | rejects = models.PositiveIntegerField() 9 | 10 | class Meta: 11 | verbose_name = 'SES Stat' 12 | ordering = ['-date'] 13 | 14 | def __unicode__(self): 15 | return self.date.strftime("%Y-%m-%d") 16 | -------------------------------------------------------------------------------- /example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from django.core.management import execute_manager 3 | try: 4 | import settings # Assumed to be in the same directory. 5 | except ImportError: 6 | import sys 7 | sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. It appears you've customized things.\nYou'll have to run django-admin.py, passing it your settings module.\n(If the file settings.py does indeed exist, it's causing an ImportError somehow.)\n" % __file__) 8 | sys.exit(1) 9 | 10 | import sys 11 | sys.path.insert(0, '..') 12 | 13 | if __name__ == "__main__": 14 | execute_manager(settings) 15 | -------------------------------------------------------------------------------- /example/urls.py: -------------------------------------------------------------------------------- 1 | try: 2 | from django.conf.urls import * 3 | except ImportError: # django < 1.4 4 | from django.conf.urls.defaults import * 5 | 6 | from django.contrib.staticfiles.urls import staticfiles_urlpatterns 7 | 8 | from django.contrib import admin 9 | 10 | admin.autodiscover() 11 | 12 | urlpatterns = patterns('', 13 | url(r'^admin/doc/', include('django.contrib.admindocs.urls')), 14 | url(r'^admin/', include(admin.site.urls)), 15 | 16 | url(r'^$', 'views.index', name='index'), 17 | url(r'^send-email/$', 'views.send_email', name='send-email'), 18 | url(r'^reporting/', include('django_ses.urls')), 19 | 20 | url(r'^bounce/', 'django_ses.views.handle_bounce', name='handle_bounce'), 21 | ) 22 | 23 | urlpatterns += staticfiles_urlpatterns() 24 | -------------------------------------------------------------------------------- /example/templates/send-email.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 | 6 | 7 |
8 | 9 | 10 |
11 | 12 | 13 |
14 | 15 | 16 |
17 | 18 | 19 |
20 | 21 |
22 | {% endblock %} 23 | 24 | -------------------------------------------------------------------------------- /example/views.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | from django.core.urlresolvers import reverse 3 | from django.core.mail import send_mail, EmailMessage 4 | from django.shortcuts import render_to_response 5 | 6 | def index(request): 7 | return render_to_response('index.html') 8 | 9 | def send_email(request): 10 | if request.method == 'POST': 11 | try: 12 | subject = request.POST['subject'] 13 | message = request.POST['message'] 14 | from_email = request.POST['from'] 15 | html_message = bool(request.POST.get('html-message', False)) 16 | recipient_list = [request.POST['to']] 17 | 18 | email = EmailMessage(subject, message, from_email, recipient_list) 19 | if html_message: 20 | email.content_subtype = 'html' 21 | email.send() 22 | except KeyError: 23 | return HttpResponse('Please fill in all fields') 24 | 25 | return HttpResponse('Email sent :)') 26 | else: 27 | return render_to_response('send-email.html') 28 | 29 | -------------------------------------------------------------------------------- /django_ses/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.4 on 2016-04-27 12:52 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='SESStat', 18 | fields=[ 19 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('date', models.DateField(db_index=True, unique=True)), 21 | ('delivery_attempts', models.PositiveIntegerField()), 22 | ('bounces', models.PositiveIntegerField()), 23 | ('complaints', models.PositiveIntegerField()), 24 | ('rejects', models.PositiveIntegerField()), 25 | ], 26 | options={ 27 | 'verbose_name': 'SES Stat', 28 | 'ordering': ['-date'], 29 | }, 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011 Harry Marr 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /django_ses/tests/settings.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.conf import settings 3 | from django_ses.tests.utils import unload_django_ses 4 | 5 | class SettingsImportTest(TestCase): 6 | def test_aws_access_key_given(self): 7 | settings.AWS_ACCESS_KEY_ID = "Yjc4MzQ4MGYzMTBhOWY3ODJhODhmNTBkN2QwY2IyZTdhZmU1NDM1ZQo" 8 | settings.AWS_SECRET_ACCESS_KEY = "NTBjYzAzNzVlMTA0N2FiMmFlODlhYjY5OTYwZjNkNjZmMWNhNzRhOQo" 9 | unload_django_ses() 10 | import django_ses 11 | self.assertEqual(django_ses.settings.ACCESS_KEY, settings.AWS_ACCESS_KEY_ID) 12 | self.assertEqual(django_ses.settings.SECRET_KEY, settings.AWS_SECRET_ACCESS_KEY) 13 | 14 | def test_ses_access_key_given(self): 15 | settings.AWS_SES_ACCESS_KEY_ID = "YmM2M2QwZTE3ODk3NTJmYzZlZDc1MDY0ZmJkMDZjZjhmOTU0MWQ4MAo" 16 | settings.AWS_SES_SECRET_ACCESS_KEY = "NDNiMzRjNzlmZGU0ZDAzZTQxNTkwNzdkNWE5Y2JlNjk4OGFkM2UyZQo" 17 | unload_django_ses() 18 | import django_ses 19 | self.assertEqual(django_ses.settings.ACCESS_KEY, settings.AWS_SES_ACCESS_KEY_ID) 20 | self.assertEqual(django_ses.settings.SECRET_KEY, settings.AWS_SES_SECRET_ACCESS_KEY) 21 | 22 | 23 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | """ 2 | This code provides a mechanism for running django_ses' internal 3 | test suite without having a full Django project. It sets up the 4 | global configuration, then dispatches out to `call_command` to 5 | kick off the test suite. 6 | 7 | ## The Code 8 | """ 9 | 10 | # Setup and configure the minimal settings necessary to 11 | # run the test suite. Note that Django requires that the 12 | # `DATABASES` value be present and configured in order to 13 | # do anything. 14 | 15 | from django.conf import settings 16 | 17 | settings.configure( 18 | INSTALLED_APPS=[ 19 | "django_ses", 20 | ], 21 | DATABASES={ 22 | "default": { 23 | "ENGINE": "django.db.backends.sqlite3", 24 | "NAME": ":memory:", 25 | } 26 | }, 27 | MIDDLEWARE_CLASSES=('django.middleware.common.CommonMiddleware', 28 | 'django.middleware.csrf.CsrfViewMiddleware'), 29 | ROOT_URLCONF='django_ses.tests.test_urls', 30 | ) 31 | 32 | import django 33 | try: 34 | django.setup() 35 | except AttributeError: 36 | pass 37 | 38 | # Start the test suite now that the settings are configured. 39 | from django.core.management import call_command 40 | call_command("test", "django_ses") 41 | -------------------------------------------------------------------------------- /django_ses/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from boto.ses import SESConnection 3 | 4 | __all__ = ('ACCESS_KEY', 'SECRET_KEY', 'AWS_SES_REGION_NAME', 5 | 'AWS_SES_REGION_ENDPOINT', 'AWS_SES_AUTO_THROTTLE', 6 | 'AWS_SES_RETURN_PATH', 'DKIM_DOMAIN', 'DKIM_PRIVATE_KEY', 7 | 'DKIM_SELECTOR', 'DKIM_HEADERS', 'TIME_ZONE') 8 | 9 | ACCESS_KEY = getattr(settings, 'AWS_SES_ACCESS_KEY_ID', 10 | getattr(settings, 'AWS_ACCESS_KEY_ID', None)) 11 | 12 | SECRET_KEY = getattr(settings, 'AWS_SES_SECRET_ACCESS_KEY', 13 | getattr(settings, 'AWS_SECRET_ACCESS_KEY', None)) 14 | 15 | AWS_SES_REGION_NAME = getattr(settings, 'AWS_SES_REGION_NAME', 16 | SESConnection.DefaultRegionName), 17 | AWS_SES_REGION_ENDPOINT = getattr(settings, 'AWS_SES_REGION_ENDPOINT', 18 | SESConnection.DefaultRegionEndpoint) 19 | 20 | AWS_SES_AUTO_THROTTLE = getattr(settings, 'AWS_SES_AUTO_THROTTLE', 0.5) 21 | AWS_SES_RETURN_PATH = getattr(settings, 'AWS_SES_RETURN_PATH', None) 22 | 23 | DKIM_DOMAIN = getattr(settings, "DKIM_DOMAIN", None) 24 | DKIM_PRIVATE_KEY = getattr(settings, 'DKIM_PRIVATE_KEY', None) 25 | DKIM_SELECTOR = getattr(settings, 'DKIM_SELECTOR', 'ses') 26 | DKIM_HEADERS = getattr(settings, 'DKIM_HEADERS', 27 | ('From', 'To', 'Cc', 'Subject')) 28 | 29 | TIME_ZONE = settings.TIME_ZONE 30 | 31 | VERIFY_BOUNCE_SIGNATURES = getattr(settings, 'AWS_SES_VERIFY_BOUNCE_SIGNATURES', True) 32 | 33 | # Domains that are trusted when retrieving the certificate 34 | # used to sign bounce messages. 35 | BOUNCE_CERT_DOMAINS = getattr(settings, 'AWS_SNS_BOUNCE_CERT_TRUSTED_DOMAINS', ( 36 | 'amazonaws.com', 37 | 'amazon.com', 38 | )) 39 | -------------------------------------------------------------------------------- /django_ses/management/commands/get_ses_statistics.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from collections import defaultdict 4 | from datetime import datetime 5 | from optparse import make_option 6 | 7 | from boto.ses import SESConnection 8 | 9 | from django.core.management.base import BaseCommand 10 | 11 | from django_ses.models import SESStat 12 | from django_ses.views import stats_to_list 13 | from django_ses import settings 14 | 15 | 16 | def stat_factory(): 17 | return { 18 | 'delivery_attempts': 0, 19 | 'bounces': 0, 20 | 'complaints': 0, 21 | 'rejects': 0, 22 | } 23 | 24 | 25 | class Command(BaseCommand): 26 | """ 27 | Get SES sending statistic and store the result, grouped by date. 28 | """ 29 | 30 | def handle(self, *args, **options): 31 | 32 | connection = SESConnection( 33 | aws_access_key_id=settings.ACCESS_KEY, 34 | aws_secret_access_key=settings.SECRET_KEY, 35 | ) 36 | stats = connection.get_send_statistics() 37 | data_points = stats_to_list(stats, localize=False) 38 | stats_dict = defaultdict(stat_factory) 39 | 40 | for data in data_points: 41 | attempts = int(data['DeliveryAttempts']) 42 | bounces = int(data['Bounces']) 43 | complaints = int(data['Complaints']) 44 | rejects = int(data['Rejects']) 45 | date = data['Timestamp'].split('T')[0] 46 | stats_dict[date]['delivery_attempts'] += attempts 47 | stats_dict[date]['bounces'] += bounces 48 | stats_dict[date]['complaints'] += complaints 49 | stats_dict[date]['rejects'] += rejects 50 | 51 | for k, v in stats_dict.items(): 52 | stat, created = SESStat.objects.get_or_create( 53 | date=k, 54 | defaults={ 55 | 'delivery_attempts': v['delivery_attempts'], 56 | 'bounces': v['bounces'], 57 | 'complaints': v['complaints'], 58 | 'rejects': v['rejects'], 59 | }) 60 | 61 | # If statistic is not new, modify data if values are different 62 | if not created and stat.delivery_attempts != v['delivery_attempts']: 63 | stat.delivery_attempts = v['delivery_attempts'] 64 | stat.bounces = v['bounces'] 65 | stat.complaints = v['complaints'] 66 | stat.rejects = v['rejects'] 67 | stat.save() 68 | -------------------------------------------------------------------------------- /example/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | DEBUG = True 5 | BASE_PATH = os.path.dirname(__file__) 6 | 7 | DATABASES = { 8 | 'default': { 9 | 'ENGINE': 'django.db.backends.sqlite3', 10 | 'NAME': os.path.join(BASE_PATH, 'example.db'), 11 | } 12 | } 13 | 14 | SECRET_KEY = 'u=0tir)ob&3%uw3h4&&$%!!kffw$h*!_ia46f)qz%2rxnkhak&' 15 | 16 | MIDDLEWARE_CLASSES = ( 17 | 'django.middleware.common.CommonMiddleware', 18 | 'django.contrib.sessions.middleware.SessionMiddleware', 19 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 20 | ) 21 | 22 | TEMPLATE_LOADERS = ( 23 | 'django.template.loaders.filesystem.Loader', 24 | 'django.template.loaders.app_directories.Loader', 25 | ) 26 | 27 | TEMPLATE_DIRS = ( 28 | os.path.join(os.path.dirname(__file__), 'templates'), 29 | ) 30 | 31 | INSTALLED_APPS = ( 32 | 'django.contrib.contenttypes', 33 | 'django.contrib.sessions', 34 | 'django.contrib.admin', 35 | 'django.contrib.auth', 36 | 'django_ses', 37 | ) 38 | 39 | ROOT_URLCONF = 'urls' 40 | STATIC_URL = '/static/' 41 | 42 | EMAIL_BACKEND = 'django_ses.SESBackend' 43 | AWS_ACCESS_KEY_ID = '' 44 | AWS_SECRET_ACCESS_KEY = '' 45 | 46 | LOGGING = { 47 | 'version': 1, 48 | 'disable_existing_loggers': False, 49 | 'formatters': { 50 | 'verbose': { 51 | 'format': '[%(asctime)s][%(name)s] %(levelname)s %(message)s', 52 | 'datefmt': "%Y-%m-%d %H:%M:%S", 53 | }, 54 | 'simple': { 55 | 'format': '%(levelname)s %(message)s' 56 | } 57 | }, 58 | 'handlers': { 59 | 'mail_admins': { 60 | 'level': 'ERROR', 61 | 'class': 'django.utils.log.AdminEmailHandler' 62 | }, 63 | 'stderr': { 64 | 'level': 'ERROR', 65 | 'formatter': 'verbose', 66 | 'class':'logging.StreamHandler', 67 | 'stream': sys.stderr, 68 | }, 69 | 'stdout': { 70 | 'level': 'INFO', 71 | 'formatter': 'verbose', 72 | 'class': 'logging.StreamHandler', 73 | 'stream': sys.stdout, 74 | }, 75 | }, 76 | 'loggers': { 77 | '': { 78 | 'handlers': ['stdout'], 79 | 'level': 'DEBUG', 80 | }, 81 | 'django.request': { 82 | 'handlers': ['mail_admins'], 83 | 'level': 'ERROR', 84 | 'propagate': True, 85 | }, 86 | } 87 | } 88 | 89 | try: 90 | from local_settings import * 91 | except ImportError: 92 | pass 93 | 94 | -------------------------------------------------------------------------------- /django_ses/management/commands/ses_email_address.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | from optparse import make_option 4 | 5 | from boto.regioninfo import RegionInfo 6 | from boto.ses import SESConnection 7 | 8 | from django.core.management.base import BaseCommand 9 | 10 | from django_ses import settings 11 | 12 | 13 | class Command(BaseCommand): 14 | """Verify, delete or list SES email addresses""" 15 | 16 | option_list = BaseCommand.option_list + ( 17 | # -v conflicts with verbose, so use -a 18 | make_option("-a", "--add", dest="add", default=False, 19 | help="""Adds an email to your verified email address list. 20 | This action causes a confirmation email message to be 21 | sent to the specified address."""), 22 | make_option("-d", "--delete", dest="delete", default=False, 23 | help="Removes an email from your verified emails list"), 24 | make_option("-l", "--list", dest="list", default=False, 25 | action="store_true", help="Outputs all verified emails"), 26 | ) 27 | 28 | def handle(self, *args, **options): 29 | 30 | verbosity = options.get('verbosity', 0) 31 | add_email = options.get('add', False) 32 | delete_email = options.get('delete', False) 33 | list_emails = options.get('list', False) 34 | 35 | access_key_id = settings.ACCESS_KEY 36 | access_key = settings.SECRET_KEY 37 | region = RegionInfo( 38 | name=settings.AWS_SES_REGION_NAME, 39 | endpoint=settings.AWS_SES_REGION_ENDPOINT) 40 | 41 | connection = SESConnection( 42 | aws_access_key_id=access_key_id, 43 | aws_secret_access_key=access_key, 44 | region=region) 45 | 46 | if add_email: 47 | if verbosity != '0': 48 | print("Adding email: " + add_email) 49 | connection.verify_email_address(add_email) 50 | elif delete_email: 51 | if verbosity != '0': 52 | print("Removing email: " + delete_email) 53 | connection.delete_verified_email_address(delete_email) 54 | elif list_emails: 55 | if verbosity != '0': 56 | print("Fetching list of verified emails:") 57 | response = connection.list_verified_email_addresses() 58 | emails = response['ListVerifiedEmailAddressesResponse'][ 59 | 'ListVerifiedEmailAddressesResult']['VerifiedEmailAddresses'] 60 | for email in emails: 61 | print(email) 62 | -------------------------------------------------------------------------------- /django_ses/tests/test_verifier.py: -------------------------------------------------------------------------------- 1 | import mock 2 | import base64 3 | 4 | try: 5 | import requests 6 | except ImportError: 7 | requests = None 8 | 9 | try: 10 | import M2Crypto 11 | except ImportError: 12 | M2Crypto = None 13 | 14 | from unittest import TestCase, skipIf 15 | 16 | from django_ses.utils import BounceMessageVerifier 17 | 18 | class BounceMessageVerifierTest(TestCase): 19 | """ 20 | Test for bounce message signature verification 21 | """ 22 | @skipIf(requests is None, "requests is not installed") 23 | @skipIf(M2Crypto is None, "M2Crypto is not installed") 24 | def test_load_certificate(self): 25 | verifier = BounceMessageVerifier({}) 26 | with mock.patch.object(verifier, '_get_cert_url') as get_cert_url: 27 | get_cert_url.return_value = "http://www.example.com/" 28 | with mock.patch.object(requests, 'get') as request_get: 29 | request_get.return_value.status_code = 200 30 | request_get.return_value.content = "Spam" 31 | with mock.patch.object(M2Crypto.X509, 'load_cert_string') as load_cert_string: 32 | self.assertEqual(verifier.certificate, load_cert_string.return_value) 33 | 34 | def test_is_verified(self): 35 | verifier = BounceMessageVerifier({'Signature': base64.b64encode(b'Spam & Eggs')}) 36 | verifier._certificate = mock.Mock() 37 | verify_final = verifier._certificate.get_pubkey.return_value.verify_final 38 | verify_final.return_value = 1 39 | with mock.patch.object(verifier, '_get_bytes_to_sign'): 40 | self.assertTrue(verifier.is_verified()) 41 | 42 | verify_final.assert_called_once_with(b'Spam & Eggs') 43 | 44 | def test_is_verified_bad_value(self): 45 | verifier = BounceMessageVerifier({'Signature': base64.b64encode(b'Spam & Eggs')}) 46 | verifier._certificate = mock.Mock() 47 | verifier._certificate.get_pubkey.return_value.verify_final.return_value = 0 48 | with mock.patch.object(verifier, '_get_bytes_to_sign'): 49 | self.assertFalse(verifier.is_verified()) 50 | 51 | def test_get_cert_url(self): 52 | """ 53 | Test url trust verification 54 | """ 55 | verifier = BounceMessageVerifier({ 56 | 'SigningCertURL': 'https://amazonaws.com/', 57 | }) 58 | self.assertEqual(verifier._get_cert_url(), 'https://amazonaws.com/') 59 | 60 | def test_http_cert_url(self): 61 | """ 62 | Test url trust verification. Non-https urls should be rejected. 63 | """ 64 | verifier = BounceMessageVerifier({ 65 | 'SigningCertURL': 'http://amazonaws.com/', 66 | }) 67 | self.assertEqual(verifier._get_cert_url(), None) 68 | 69 | def test_untrusted_cert_url_domain(self): 70 | """ 71 | Test url trust verification. Untrusted domains should be rejected. 72 | """ 73 | verifier = BounceMessageVerifier({ 74 | 'SigningCertURL': 'https://www.example.com/', 75 | }) 76 | self.assertEqual(verifier._get_cert_url(), None) 77 | -------------------------------------------------------------------------------- /django_ses/tests/commands.py: -------------------------------------------------------------------------------- 1 | import copy 2 | 3 | from django.core.management import call_command 4 | from django.test import TestCase 5 | 6 | from django_ses.models import SESStat 7 | 8 | from boto.ses import SESConnection 9 | 10 | 11 | data_points = [ 12 | { 13 | 'Complaints': '1', 14 | 'Timestamp': '2012-01-01T02:00:00Z', 15 | 'DeliveryAttempts': '2', 16 | 'Bounces': '3', 17 | 'Rejects': '4' 18 | }, 19 | { 20 | 'Complaints': '1', 21 | 'Timestamp': '2012-01-03T02:00:00Z', 22 | 'DeliveryAttempts': '2', 23 | 'Bounces': '3', 24 | 'Rejects': '4' 25 | }, 26 | { 27 | 'Complaints': '1', 28 | 'Timestamp': '2012-01-03T03:00:00Z', 29 | 'DeliveryAttempts': '2', 30 | 'Bounces': '3', 31 | 'Rejects': '4' 32 | } 33 | ] 34 | 35 | 36 | def fake_get_statistics(self): 37 | return { 38 | 'GetSendStatisticsResponse': { 39 | 'GetSendStatisticsResult': { 40 | 'SendDataPoints': data_points 41 | }, 42 | 'ResponseMetadata': { 43 | 'RequestId': '1' 44 | } 45 | } 46 | } 47 | 48 | 49 | def fake_connection_init(self, *args, **kwargs): 50 | pass 51 | 52 | 53 | class SESCommandTest(TestCase): 54 | 55 | def setUp(self): 56 | SESConnection.get_send_statistics = fake_get_statistics 57 | SESConnection.__init__ = fake_connection_init 58 | 59 | def test_get_statistics(self): 60 | # Test the get_ses_statistics management command 61 | call_command('get_ses_statistics') 62 | 63 | # Test that days with a single data point is saved properly 64 | stat = SESStat.objects.get(date='2012-01-01') 65 | self.assertEqual(stat.complaints, 1) 66 | self.assertEqual(stat.delivery_attempts, 2) 67 | self.assertEqual(stat.bounces, 3) 68 | self.assertEqual(stat.rejects, 4) 69 | 70 | # Test that days with multiple data points get saved properly 71 | stat = SESStat.objects.get(date='2012-01-03') 72 | self.assertEqual(stat.complaints, 2) 73 | self.assertEqual(stat.delivery_attempts, 4) 74 | self.assertEqual(stat.bounces, 6) 75 | self.assertEqual(stat.rejects, 8) 76 | 77 | # Changing data points should update database records too 78 | data_points_copy = copy.deepcopy(data_points) 79 | data_points_copy[0]['Complaints'] = '2' 80 | data_points_copy[0]['DeliveryAttempts'] = '3' 81 | data_points_copy[0]['Bounces'] = '4' 82 | data_points_copy[0]['Rejects'] = '5' 83 | 84 | def fake_get_statistics_copy(self): 85 | return { 86 | 'GetSendStatisticsResponse': { 87 | 'GetSendStatisticsResult': { 88 | 'SendDataPoints': data_points_copy 89 | }, 90 | 'ResponseMetadata': { 91 | 'RequestId': '1' 92 | } 93 | } 94 | } 95 | SESConnection.get_send_statistics = fake_get_statistics_copy 96 | call_command('get_ses_statistics') 97 | stat = SESStat.objects.get(date='2012-01-01') 98 | self.assertEqual(stat.complaints, 2) 99 | self.assertEqual(stat.delivery_attempts, 3) 100 | self.assertEqual(stat.bounces, 4) 101 | self.assertEqual(stat.rejects, 5) 102 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from fnmatch import fnmatchcase 5 | 6 | from distutils.util import convert_path 7 | from setuptools import setup, find_packages 8 | 9 | 10 | def read(*path): 11 | return open(os.path.join(os.path.abspath(os.path.dirname(__file__)), 12 | *path)).read() 13 | 14 | # Provided as an attribute, so you can append to these instead 15 | # of replicating them: 16 | standard_exclude = ["*.py", "*.pyc", "*~", ".*", "*.bak"] 17 | standard_exclude_directories = [ 18 | ".*", "CVS", "_darcs", "./build", 19 | "./dist", "EGG-INFO", "*.egg-info" 20 | ] 21 | 22 | 23 | # Copied from paste/util/finddata.py 24 | def find_package_data(where=".", package="", exclude=standard_exclude, 25 | exclude_directories=standard_exclude_directories, 26 | only_in_packages=True, show_ignored=False): 27 | """ 28 | Return a dictionary suitable for use in ``package_data`` 29 | in a distutils ``setup.py`` file. 30 | 31 | The dictionary looks like:: 32 | 33 | {"package": [files]} 34 | 35 | Where ``files`` is a list of all the files in that package that 36 | don't match anything in ``exclude``. 37 | 38 | If ``only_in_packages`` is true, then top-level directories that 39 | are not packages won't be included (but directories under packages 40 | will). 41 | 42 | Directories matching any pattern in ``exclude_directories`` will 43 | be ignored; by default directories with leading ``.``, ``CVS``, 44 | and ``_darcs`` will be ignored. 45 | 46 | If ``show_ignored`` is true, then all the files that aren't 47 | included in package data are shown on stderr (for debugging 48 | purposes). 49 | 50 | Note patterns use wildcards, or can be exact paths (including 51 | leading ``./``), and all searching is case-insensitive. 52 | """ 53 | 54 | out = {} 55 | stack = [(convert_path(where), "", package, only_in_packages)] 56 | while stack: 57 | where, prefix, package, only_in_packages = stack.pop(0) 58 | for name in os.listdir(where): 59 | fn = os.path.join(where, name) 60 | if os.path.isdir(fn): 61 | bad_name = False 62 | for pattern in exclude_directories: 63 | if (fnmatchcase(name, pattern) 64 | or fn.lower() == pattern.lower()): 65 | bad_name = True 66 | if show_ignored: 67 | print >> sys.stderr, ( 68 | "Directory %s ignored by pattern %s" 69 | % (fn, pattern)) 70 | break 71 | if bad_name: 72 | continue 73 | if (os.path.isfile(os.path.join(fn, "__init__.py")) 74 | and not prefix): 75 | if not package: 76 | new_package = name 77 | else: 78 | new_package = package + "." + name 79 | stack.append((fn, "", new_package, False)) 80 | else: 81 | stack.append((fn, prefix + name + "/", package, 82 | only_in_packages)) 83 | elif package or not only_in_packages: 84 | # is a file 85 | bad_name = False 86 | for pattern in exclude: 87 | if (fnmatchcase(name, pattern) 88 | or fn.lower() == pattern.lower()): 89 | bad_name = True 90 | if show_ignored: 91 | print >> sys.stderr, ( 92 | "File %s ignored by pattern %s" 93 | % (fn, pattern)) 94 | break 95 | if bad_name: 96 | continue 97 | out.setdefault(package, []).append(prefix + name) 98 | return out 99 | 100 | 101 | excluded_directories = standard_exclude_directories + ["example", "tests"] 102 | package_data = find_package_data(exclude_directories=excluded_directories) 103 | 104 | DESCRIPTION = "A Django email backend for Amazon's Simple Email Service" 105 | 106 | LONG_DESCRIPTION = None 107 | try: 108 | LONG_DESCRIPTION = open('README.rst').read() 109 | except: 110 | pass 111 | 112 | CLASSIFIERS = [ 113 | 'Development Status :: 4 - Beta', 114 | 'Intended Audience :: Developers', 115 | 'License :: OSI Approved :: MIT License', 116 | 'Operating System :: OS Independent', 117 | 'Programming Language :: Python', 118 | 'Topic :: Software Development :: Libraries :: Python Modules', 119 | 'Framework :: Django', 120 | 'Programming Language :: Python :: 2.7', 121 | 'Programming Language :: Python :: 3.4', 122 | ] 123 | 124 | setup( 125 | name='django-ses', 126 | version='0.7.1', # When changing this, remember to change it in __init__.py 127 | packages=find_packages(exclude=['example']), 128 | package_data=package_data, 129 | author='Harry Marr', 130 | author_email='harry@hmarr.com', 131 | url='http://github.com/hmarr/django-ses/', 132 | license='MIT', 133 | description=DESCRIPTION, 134 | long_description=LONG_DESCRIPTION, 135 | platforms=['any'], 136 | classifiers=CLASSIFIERS, 137 | install_requires=['boto>=2.31.0'], 138 | include_package_data=True, 139 | ) 140 | -------------------------------------------------------------------------------- /django_ses/templates/django_ses/send_stats.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | 3 | {% block extrastyle %} 4 | {{ block.super }} 5 | 6 | {% endblock %} 7 | 8 | {% block extrahead %} 9 | 10 | 40 | {% endblock %} 41 | 42 | {% block bodyclass %}dashboard{% endblock %} 43 | {% block content_title %}

SES Stats

{% endblock %} 44 | 45 | {% block content %} 46 |

Access Key: {{ access_key }}

47 |
48 |
49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 |
Quotas
24 Quota24 SentQuota RemainingPer/s Quota
{{ 24hour_quota }}{{ 24hour_sent }}{{ 24hour_remaining }}{{ persecond_rate }}
68 |
69 | 70 |
71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 |
Sending Stats
Delivery AttemptsBouncesComplaintsRejected
{{ summary.DeliveryAttempts }}{{ summary.Bounces }}{{ summary.Complaints }}{{ summary.Rejects }}
90 |
91 |
92 |
93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | {% for datapoint in datapoints %} 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | {% endfor %} 114 | 115 |
Sending Activity
Delivery AttemptsBouncesComplaintsRejected{% if local_time %}Local Time{% else %}Timestamp{% endif %}
{{ datapoint.DeliveryAttempts }}{{ datapoint.Bounces }}{{ datapoint.Complaints }}{{ datapoint.Rejects }}{{ datapoint.Timestamp }}
116 |
117 |
118 | {% endblock %} 119 | 120 | 121 | {% block sidebar %} 122 | 144 | {% endblock %} 145 | -------------------------------------------------------------------------------- /django_ses/tests/backend.py: -------------------------------------------------------------------------------- 1 | import email 2 | 3 | from django.conf import settings as django_settings 4 | from django.utils.encoding import smart_str 5 | from django.core.mail import send_mail 6 | from django.test import TestCase 7 | 8 | from boto.ses import SESConnection 9 | 10 | import django_ses 11 | from django_ses import settings 12 | 13 | # random key generated with `openssl genrsa 512` 14 | DKIM_PRIVATE_KEY = ''' 15 | -----BEGIN RSA PRIVATE KEY----- 16 | MIIBOwIBAAJBALCKsjD8UUxBESo1OLN6gptp1lD0U85AgXGL571/SQ3k61KhAQ8h 17 | hL3lnfQKn/XCl2oCXscEwgJv43IUs+VETWECAwEAAQJAQ8XK6GFEuHhWJZTu4n/K 18 | ee0keEmDjq9WwgdKfIXLvsgaaNxCObhzv7G5rPU+U/3z1/0CtGR+DOPgoiaI/5HM 19 | XQIhAN4h+o2WzRrz+dD/+zMGC9h1KEFvukIoP62kLOxW0eg/AiEAy3VD+UkRni4H 20 | 6UEJgCe0oZIiBCxj12/wUHFj1cfJYl8CICsndsGjFl2yIEpWMLsM5ag7uoJb7leD 21 | 8jsNthyEEWuJAiEAjeF6w26HEK286pZmD66gskN74TkrbuMqzI4mNsCZ2TUCIQCJ 22 | HuuR7wc0HJ/cfVi8Kgm5B+sHY9/7KDWAYGGnbGgCNA== 23 | -----END RSA PRIVATE KEY----- 24 | ''' 25 | DKIM_PUBLIC_KEY = 'MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBALCKsjD8UUxBESo1OLN6gptp1lD0U85AgXGL571/SQ3k61KhAQ8hhL3lnfQKn/XCl2oCXscEwgJv43IUs+VETWECAwEAAQ==' 26 | 27 | 28 | class FakeSESConnection(SESConnection): 29 | ''' 30 | A fake SES connection for testing purposes.It behaves similarly 31 | to django's dummy backend 32 | (https://docs.djangoproject.com/en/dev/topics/email/#dummy-backend) 33 | 34 | Emails sent with send_raw_email is stored in ``outbox`` attribute 35 | which is a list of kwargs received by ``send_raw_email``. 36 | ''' 37 | outbox = [] 38 | 39 | def __init__(self, *args, **kwargs): 40 | pass 41 | 42 | def send_raw_email(self, **kwargs): 43 | self.outbox.append(kwargs) 44 | response = { 45 | 'SendRawEmailResponse': { 46 | 'SendRawEmailResult': { 47 | 'MessageId': 'fake_message_id', 48 | }, 49 | 'ResponseMetadata': { 50 | 'RequestId': 'fake_request_id', 51 | }, 52 | } 53 | } 54 | return response 55 | 56 | 57 | class FakeSESBackend(django_ses.SESBackend): 58 | ''' 59 | A fake SES backend for testing purposes. It overrides the real SESBackend's 60 | get_rate_limit method so we can run tests without valid AWS credentials. 61 | ''' 62 | 63 | def get_rate_limit(self): 64 | return 10 65 | 66 | 67 | class SESBackendTest(TestCase): 68 | def setUp(self): 69 | # TODO: Fix this -- this is going to cause side effects 70 | django_settings.EMAIL_BACKEND = 'django_ses.tests.backend.FakeSESBackend' 71 | django_ses.SESConnection = FakeSESConnection 72 | self.outbox = FakeSESConnection.outbox 73 | 74 | def tearDown(self): 75 | # Empty outbox everytime test finishes 76 | FakeSESConnection.outbox = [] 77 | 78 | def test_send_mail(self): 79 | send_mail('subject', 'body', 'from@example.com', ['to@example.com']) 80 | message = self.outbox.pop() 81 | mail = email.message_from_string(smart_str(message['raw_message'])) 82 | self.assertEqual(mail['subject'], 'subject') 83 | self.assertEqual(mail['from'], 'from@example.com') 84 | self.assertEqual(mail['to'], 'to@example.com') 85 | self.assertEqual(mail.get_payload(), 'body') 86 | 87 | def test_dkim_mail(self): 88 | # DKIM verification uses DNS to retrieve the public key when checking 89 | # the signature, so we need to replace the standard query response with 90 | # one that always returns the test key. 91 | try: 92 | import dkim 93 | import dns 94 | except ImportError: 95 | return 96 | 97 | def dns_query(qname, rdtype): 98 | name = dns.name.from_text(qname) 99 | response = dns.message.from_text( 100 | 'id 1\n;ANSWER\n%s 60 IN TXT "v=DKIM1; p=%s"' %\ 101 | (qname, DKIM_PUBLIC_KEY)) 102 | return dns.resolver.Answer(name, rdtype, 1, response) 103 | dns.resolver.query = dns_query 104 | 105 | settings.DKIM_DOMAIN = 'example.com' 106 | settings.DKIM_PRIVATE_KEY = DKIM_PRIVATE_KEY 107 | send_mail('subject', 'body', 'from@example.com', ['to@example.com']) 108 | message = self.outbox.pop()['raw_message'] 109 | self.assertTrue(dkim.verify(message)) 110 | self.assertFalse(dkim.verify(message + 'some additional text')) 111 | self.assertFalse(dkim.verify( 112 | message.replace('from@example.com', 'from@spam.com'))) 113 | 114 | def test_return_path(self): 115 | ''' 116 | Ensure that the 'source' argument sent into send_raw_email uses 117 | settings.AWS_SES_RETURN_PATH, defaults to from address. 118 | ''' 119 | settings.AWS_SES_RETURN_PATH = None 120 | send_mail('subject', 'body', 'from@example.com', ['to@example.com']) 121 | self.assertEqual(self.outbox.pop()['source'], 'from@example.com') 122 | 123 | 124 | class SESBackendTestReturn(TestCase): 125 | def setUp(self): 126 | # TODO: Fix this -- this is going to cause side effects 127 | django_settings.EMAIL_BACKEND = 'django_ses.tests.backend.FakeSESBackend' 128 | django_ses.SESConnection = FakeSESConnection 129 | self.outbox = FakeSESConnection.outbox 130 | 131 | def tearDown(self): 132 | # Empty outbox everytime test finishes 133 | FakeSESConnection.outbox = [] 134 | 135 | def test_return_path(self): 136 | settings.AWS_SES_RETURN_PATH = "return@example.com" 137 | send_mail('subject', 'body', 'from@example.com', ['to@example.com']) 138 | self.assertEqual(self.outbox.pop()['source'], 'return@example.com') 139 | -------------------------------------------------------------------------------- /django_ses/tests/views.py: -------------------------------------------------------------------------------- 1 | import mock 2 | 3 | from django.test import TestCase 4 | from django.core.urlresolvers import reverse 5 | 6 | try: 7 | import json 8 | except ImportError: 9 | from django.utils import simplejson as json 10 | 11 | from django_ses.signals import bounce_received, complaint_received 12 | from django_ses import utils as ses_utils 13 | 14 | class HandleBounceTest(TestCase): 15 | """ 16 | Test the bounce web hook handler. 17 | """ 18 | def setUp(self): 19 | self._old_bounce_receivers = bounce_received.receivers 20 | bounce_received.receivers = [] 21 | 22 | self._old_complaint_receivers = complaint_received.receivers 23 | complaint_received.receivers = [] 24 | 25 | def tearDown(self): 26 | bounce_received.receivers = self._old_bounce_receivers 27 | complaint_received.receivers = self._old_complaint_receivers 28 | 29 | def test_handle_bounce(self): 30 | """ 31 | Test handling a normal bounce request. 32 | """ 33 | req_mail_obj = { 34 | "timestamp":"2012-05-25T14:59:38.623-07:00", 35 | "messageId":"000001378603177f-7a5433e7-8edb-42ae-af10-f0181f34d6ee-000000", 36 | "source":"sender@example.com", 37 | "destination":[ 38 | "recipient1@example.com", 39 | "recipient2@example.com", 40 | "recipient3@example.com", 41 | "recipient4@example.com" 42 | ] 43 | } 44 | req_bounce_obj = { 45 | 'bounceType': 'Permanent', 46 | 'bounceSubType': 'General', 47 | 'bouncedRecipients': [ 48 | { 49 | "status":"5.0.0", 50 | "action":"failed", 51 | "diagnosticCode":"smtp; 550 user unknown", 52 | "emailAddress":"recipient1@example.com", 53 | }, 54 | { 55 | "status":"4.0.0", 56 | "action":"delayed", 57 | "emailAddress":"recipient2@example.com", 58 | } 59 | ], 60 | "reportingMTA": "example.com", 61 | "timestamp":"2012-05-25T14:59:38.605-07:00", 62 | "feedbackId":"000001378603176d-5a4b5ad9-6f30-4198-a8c3-b1eb0c270a1d-000000", 63 | } 64 | 65 | message_obj = { 66 | 'notificationType': 'Bounce', 67 | 'mail': req_mail_obj, 68 | 'bounce': req_bounce_obj, 69 | } 70 | 71 | notification = { 72 | "Type" : "Notification", 73 | "MessageId" : "22b80b92-fdea-4c2c-8f9d-bdfb0c7bf324", 74 | "TopicArn" : "arn:aws:sns:us-east-1:123456789012:MyTopic", 75 | "Subject" : "AWS Notification Message", 76 | "Message" : json.dumps(message_obj), 77 | "Timestamp" : "2012-05-02T00:54:06.655Z", 78 | "SignatureVersion" : "1", 79 | "Signature" : "", 80 | "SigningCertURL" : "", 81 | "UnsubscribeURL" : "" 82 | } 83 | 84 | def _handler(sender, mail_obj, bounce_obj, **kwargs): 85 | _handler.called = True 86 | self.assertEquals(req_mail_obj, mail_obj) 87 | self.assertEquals(req_bounce_obj, bounce_obj) 88 | _handler.called = False 89 | bounce_received.connect(_handler) 90 | 91 | # Mock the verification 92 | with mock.patch.object(ses_utils, 'verify_bounce_message') as verify: 93 | verify.return_value = True 94 | 95 | self.client.post(reverse('django_ses_bounce'), 96 | json.dumps(notification), content_type='application/json') 97 | 98 | self.assertTrue(_handler.called) 99 | 100 | def test_handle_complaint(self): 101 | """ 102 | Test handling a normal complaint request. 103 | """ 104 | req_mail_obj = { 105 | "timestamp":"2012-05-25T14:59:38.623-07:00", 106 | "messageId":"000001378603177f-7a5433e7-8edb-42ae-af10-f0181f34d6ee-000000", 107 | "source":"sender@example.com", 108 | "destination": [ 109 | "recipient1@example.com", 110 | "recipient2@example.com", 111 | "recipient3@example.com", 112 | "recipient4@example.com", 113 | ] 114 | } 115 | req_complaint_obj = { 116 | "userAgent":"Comcast Feedback Loop (V0.01)", 117 | "complainedRecipients": [ 118 | { 119 | "emailAddress":"recipient1@example.com", 120 | } 121 | ], 122 | "complaintFeedbackType":"abuse", 123 | "arrivalDate":"2009-12-03T04:24:21.000-05:00", 124 | "timestamp":"2012-05-25T14:59:38.623-07:00", 125 | "feedbackId":"000001378603177f-18c07c78-fa81-4a58-9dd1-fedc3cb8f49a-000000", 126 | } 127 | 128 | message_obj = { 129 | 'notificationType': 'Complaint', 130 | 'mail': req_mail_obj, 131 | 'complaint': req_complaint_obj, 132 | } 133 | 134 | notification = { 135 | "Type" : "Notification", 136 | "MessageId" : "22b80b92-fdea-4c2c-8f9d-bdfb0c7bf324", 137 | "TopicArn" : "arn:aws:sns:us-east-1:123456789012:MyTopic", 138 | "Subject" : "AWS Notification Message", 139 | "Message" : json.dumps(message_obj), 140 | "Timestamp" : "2012-05-02T00:54:06.655Z", 141 | "SignatureVersion" : "1", 142 | "Signature" : "", 143 | "SigningCertURL" : "", 144 | "UnsubscribeURL" : "" 145 | } 146 | 147 | def _handler(sender, mail_obj, complaint_obj, **kwargs): 148 | _handler.called = True 149 | self.assertEquals(req_mail_obj, mail_obj) 150 | self.assertEquals(req_complaint_obj, complaint_obj) 151 | _handler.called = False 152 | complaint_received.connect(_handler) 153 | 154 | # Mock the verification 155 | with mock.patch.object(ses_utils, 'verify_bounce_message') as verify: 156 | verify.return_value = True 157 | 158 | self.client.post(reverse('django_ses_bounce'), 159 | json.dumps(notification), content_type='application/json') 160 | 161 | self.assertTrue(_handler.called) 162 | -------------------------------------------------------------------------------- /django_ses/tests/stats.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from django_ses.views import (emails_parse, stats_to_list, quota_parse, 4 | sum_stats) 5 | 6 | # Mock of what boto's SESConnection.get_send_statistics() returns 7 | STATS_DICT = { 8 | u'GetSendStatisticsResponse': { 9 | u'GetSendStatisticsResult': { 10 | u'SendDataPoints': [ 11 | { 12 | u'Bounces': u'1', 13 | u'Complaints': u'0', 14 | u'DeliveryAttempts': u'11', 15 | u'Rejects': u'0', 16 | u'Timestamp': u'2011-02-28T13:50:00Z', 17 | }, 18 | { 19 | u'Bounces': u'1', 20 | u'Complaints': u'0', 21 | u'DeliveryAttempts': u'3', 22 | u'Rejects': u'0', 23 | u'Timestamp': u'2011-02-24T23:35:00Z', 24 | }, 25 | { 26 | u'Bounces': u'0', 27 | u'Complaints': u'2', 28 | u'DeliveryAttempts': u'8', 29 | u'Rejects': u'0', 30 | u'Timestamp': u'2011-02-24T16:35:00Z', 31 | }, 32 | { 33 | u'Bounces': u'0', 34 | u'Complaints': u'2', 35 | u'DeliveryAttempts': u'33', 36 | u'Rejects': u'0', 37 | u'Timestamp': u'2011-02-25T20:35:00Z', 38 | }, 39 | { 40 | u'Bounces': u'0', 41 | u'Complaints': u'0', 42 | u'DeliveryAttempts': u'3', 43 | u'Rejects': u'3', 44 | u'Timestamp': u'2011-02-28T23:35:00Z', 45 | }, 46 | { 47 | u'Bounces': u'0', 48 | u'Complaints': u'0', 49 | u'DeliveryAttempts': u'2', 50 | u'Rejects': u'3', 51 | u'Timestamp': u'2011-02-25T22:50:00Z', 52 | }, 53 | { 54 | u'Bounces': u'0', 55 | u'Complaints': u'0', 56 | u'DeliveryAttempts': u'6', 57 | u'Rejects': u'0', 58 | u'Timestamp': u'2011-03-01T13:20:00Z', 59 | }, 60 | ], 61 | } 62 | } 63 | } 64 | 65 | QUOTA_DICT = { 66 | u'GetSendQuotaResponse': { 67 | u'GetSendQuotaResult': { 68 | u'Max24HourSend': u'10000.0', 69 | u'MaxSendRate': u'5.0', 70 | u'SentLast24Hours': u'1677.0' 71 | }, 72 | u'ResponseMetadata': { 73 | u'RequestId': u'8f100233-44e7-11e0-a926-a198963635d8' 74 | } 75 | } 76 | } 77 | 78 | VERIFIED_EMAIL_DICT = { 79 | u'ListVerifiedEmailAddressesResponse': { 80 | u'ListVerifiedEmailAddressesResult': { 81 | u'VerifiedEmailAddresses': [ 82 | u'test2@example.com', 83 | u'test1@example.com', 84 | u'test3@example.com' 85 | ] 86 | }, 87 | u'ResponseMetadata': { 88 | u'RequestId': u'9afe9c18-44ed-11e0-802a-25a1a14c5a6e' 89 | } 90 | } 91 | } 92 | 93 | 94 | class StatParsingTest(TestCase): 95 | def setUp(self): 96 | self.stats_dict = STATS_DICT 97 | self.quota_dict = QUOTA_DICT 98 | self.emails_dict = VERIFIED_EMAIL_DICT 99 | 100 | def test_stat_to_list(self): 101 | expected_list = [ 102 | { 103 | u'Bounces': u'0', 104 | u'Complaints': u'2', 105 | u'DeliveryAttempts': u'8', 106 | u'Rejects': u'0', 107 | u'Timestamp': u'2011-02-24T16:35:00Z', 108 | }, 109 | { 110 | u'Bounces': u'1', 111 | u'Complaints': u'0', 112 | u'DeliveryAttempts': u'3', 113 | u'Rejects': u'0', 114 | u'Timestamp': u'2011-02-24T23:35:00Z', 115 | }, 116 | { 117 | u'Bounces': u'0', 118 | u'Complaints': u'2', 119 | u'DeliveryAttempts': u'33', 120 | u'Rejects': u'0', 121 | u'Timestamp': u'2011-02-25T20:35:00Z', 122 | }, 123 | { 124 | u'Bounces': u'0', 125 | u'Complaints': u'0', 126 | u'DeliveryAttempts': u'2', 127 | u'Rejects': u'3', 128 | u'Timestamp': u'2011-02-25T22:50:00Z', 129 | }, 130 | { 131 | u'Bounces': u'1', 132 | u'Complaints': u'0', 133 | u'DeliveryAttempts': u'11', 134 | u'Rejects': u'0', 135 | u'Timestamp': u'2011-02-28T13:50:00Z', 136 | }, 137 | { 138 | u'Bounces': u'0', 139 | u'Complaints': u'0', 140 | u'DeliveryAttempts': u'3', 141 | u'Rejects': u'3', 142 | u'Timestamp': u'2011-02-28T23:35:00Z', 143 | }, 144 | { 145 | u'Bounces': u'0', 146 | u'Complaints': u'0', 147 | u'DeliveryAttempts': u'6', 148 | u'Rejects': u'0', 149 | u'Timestamp': u'2011-03-01T13:20:00Z', 150 | }, 151 | ] 152 | actual = stats_to_list(self.stats_dict, localize=False) 153 | 154 | self.assertEqual(len(actual), len(expected_list)) 155 | self.assertEqual(actual, expected_list) 156 | 157 | def test_quota_parse(self): 158 | expected = { 159 | u'Max24HourSend': u'10000.0', 160 | u'MaxSendRate': u'5.0', 161 | u'SentLast24Hours': u'1677.0', 162 | } 163 | actual = quota_parse(self.quota_dict) 164 | 165 | self.assertEqual(actual, expected) 166 | 167 | def test_emails_parse(self): 168 | expected_list = [ 169 | u'test1@example.com', 170 | u'test2@example.com', 171 | u'test3@example.com', 172 | ] 173 | actual = emails_parse(self.emails_dict) 174 | 175 | self.assertEqual(len(actual), len(expected_list)) 176 | self.assertEqual(actual, expected_list) 177 | 178 | def test_sum_stats(self): 179 | expected = { 180 | 'Bounces': 2, 181 | 'Complaints': 4, 182 | 'DeliveryAttempts': 66, 183 | 'Rejects': 6, 184 | } 185 | 186 | stats = stats_to_list(self.stats_dict) 187 | actual = sum_stats(stats) 188 | 189 | self.assertEqual(actual, expected) 190 | -------------------------------------------------------------------------------- /django_ses/utils.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import logging 3 | from io import StringIO 4 | try: 5 | from urllib.parse import urlparse 6 | except ImportError: 7 | from urlparse import urlparse 8 | from django.core.exceptions import ImproperlyConfigured 9 | from django.utils.encoding import smart_str 10 | from django_ses import settings 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class BounceMessageVerifier(object): 16 | """ 17 | A utility class for validating bounce messages 18 | 19 | See: http://docs.amazonwebservices.com/sns/latest/gsg/SendMessageToHttp.verify.signature.html 20 | """ 21 | 22 | def __init__(self, bounce_dict): 23 | """ 24 | Creates a new bounce message from the given dict. 25 | """ 26 | self._data = bounce_dict 27 | self._verified = None 28 | 29 | def is_verified(self): 30 | """ 31 | Verifies an SES bounce message. 32 | 33 | """ 34 | if self._verified is None: 35 | signature = self._data.get('Signature') 36 | if not signature: 37 | self._verified = False 38 | return self._verified 39 | 40 | # Decode the signature from base64 41 | signature = base64.b64decode(signature) 42 | 43 | # Get the message to sign 44 | sign_bytes = self._get_bytes_to_sign() 45 | if not sign_bytes: 46 | self._verified = False 47 | return self._verified 48 | 49 | if not self.certificate: 50 | self._verified = False 51 | return self._verified 52 | 53 | # Extract the public key 54 | pkey = self.certificate.get_pubkey() 55 | 56 | # Use the public key to verify the signature. 57 | pkey.verify_init() 58 | pkey.verify_update(sign_bytes) 59 | verify_result = pkey.verify_final(signature) 60 | 61 | self._verified = verify_result == 1 62 | 63 | return self._verified 64 | 65 | @property 66 | def certificate(self): 67 | """ 68 | Retrieves the certificate used to sign the bounce message. 69 | 70 | TODO: Cache the certificate based on the cert URL so we don't have to 71 | retrieve it for each bounce message. *We would need to do it in a 72 | secure way so that the cert couldn't be overwritten in the cache* 73 | """ 74 | if not hasattr(self, '_certificate'): 75 | cert_url = self._get_cert_url() 76 | # Only load certificates from a certain domain? 77 | # Without some kind of trusted domain check, any old joe could 78 | # craft a bounce message and sign it using his own certificate 79 | # and we would happily load and verify it. 80 | 81 | if not cert_url: 82 | self._certificate = None 83 | return self._certificate 84 | 85 | try: 86 | import requests 87 | except ImportError: 88 | raise ImproperlyConfigured("requests is required for bounce message verification.") 89 | 90 | try: 91 | import M2Crypto 92 | except ImportError: 93 | raise ImproperlyConfigured("M2Crypto is required for bounce message verification.") 94 | 95 | # We use requests because it verifies the https certificate 96 | # when retrieving the signing certificate. If https was somehow 97 | # hijacked then all bets are off. 98 | response = requests.get(cert_url) 99 | if response.status_code != 200: 100 | logger.warning('Could not download certificate from %s: "%s"', cert_url, response.status_code) 101 | self._certificate = None 102 | return self._certificate 103 | 104 | # Handle errors loading the certificate. 105 | # If the certificate is invalid then return 106 | # false as we couldn't verify the message. 107 | try: 108 | self._certificate = M2Crypto.X509.load_cert_string(response.content) 109 | except M2Crypto.X509.X509Error as e: 110 | logger.warning('Could not load certificate from %s: "%s"', cert_url, e) 111 | self._certificate = None 112 | 113 | return self._certificate 114 | 115 | def _get_cert_url(self): 116 | """ 117 | Get the signing certificate URL. 118 | Only accept urls that match the domains set in the 119 | AWS_SNS_BOUNCE_CERT_TRUSTED_DOMAINS setting. Sub-domains 120 | are allowed. i.e. if amazonaws.com is in the trusted domains 121 | then sns.us-east-1.amazonaws.com will match. 122 | """ 123 | cert_url = self._data.get('SigningCertURL') 124 | if cert_url: 125 | if cert_url.startswith('https://'): 126 | url_obj = urlparse(cert_url) 127 | for trusted_domain in settings.BOUNCE_CERT_DOMAINS: 128 | parts = trusted_domain.split('.') 129 | if url_obj.netloc.split('.')[-len(parts):] == parts: 130 | return cert_url 131 | logger.warning('Untrusted certificate URL: "%s"', cert_url) 132 | else: 133 | logger.warning('No signing certificate URL: "%s"', cert_url) 134 | return None 135 | 136 | def _get_bytes_to_sign(self): 137 | """ 138 | Creates the message used for signing SNS notifications. 139 | This is used to verify the bounce message when it is received. 140 | """ 141 | 142 | # Depending on the message type the fields to add to the message 143 | # differ so we handle that here. 144 | msg_type = self._data.get('Type') 145 | if msg_type == 'Notification': 146 | fields_to_sign = [ 147 | 'Message', 148 | 'MessageId', 149 | 'Subject', 150 | 'Timestamp', 151 | 'TopicArn', 152 | 'Type', 153 | ] 154 | elif (msg_type == 'SubscriptionConfirmation' or 155 | msg_type == 'UnsubscribeConfirmation'): 156 | fields_to_sign = [ 157 | 'Message', 158 | 'MessageId', 159 | 'SubscribeURL', 160 | 'Timestamp', 161 | 'Token', 162 | 'TopicArn', 163 | 'Type', 164 | ] 165 | else: 166 | # Unrecognized type 167 | logger.warning('Unrecognized SNS message Type: "%s"', msg_type) 168 | return None 169 | 170 | outbytes = StringIO() 171 | for field_name in fields_to_sign: 172 | field_value = smart_str(self._data.get(field_name, ''), 173 | errors="replace") 174 | if field_value: 175 | outbytes.write(field_name) 176 | outbytes.write("\n") 177 | outbytes.write(field_value) 178 | outbytes.write("\n") 179 | 180 | return outbytes.getvalue() 181 | 182 | 183 | def verify_bounce_message(msg): 184 | """ 185 | Verify an SES/SNS bounce notification message. 186 | """ 187 | verifier = BounceMessageVerifier(msg) 188 | return verifier.is_verified() 189 | -------------------------------------------------------------------------------- /django_ses/__init__.py: -------------------------------------------------------------------------------- 1 | from django.core.mail.backends.base import BaseEmailBackend 2 | from django_ses import settings 3 | 4 | from boto.regioninfo import RegionInfo 5 | from boto.ses import SESConnection 6 | 7 | from datetime import datetime, timedelta 8 | from time import sleep 9 | 10 | 11 | # When changing this, remember to change it in setup.py 12 | VERSION = (0, "7", 1) 13 | __version__ = '.'.join([str(x) for x in VERSION]) 14 | __author__ = 'Harry Marr' 15 | __all__ = ('SESBackend',) 16 | 17 | # These would be nice to make class-level variables, but the backend is 18 | # re-created for each outgoing email/batch. 19 | # recent_send_times also is not going to work quite right if there are multiple 20 | # email backends with different rate limits returned by SES, but that seems 21 | # like it would be rare. 22 | cached_rate_limits = {} 23 | recent_send_times = [] 24 | 25 | 26 | def dkim_sign(message, dkim_domain=None, dkim_key=None, dkim_selector=None, dkim_headers=None): 27 | """Return signed email message if dkim package and settings are available.""" 28 | try: 29 | import dkim 30 | except ImportError: 31 | pass 32 | else: 33 | if dkim_domain and dkim_key: 34 | sig = dkim.sign(message, 35 | dkim_selector, 36 | dkim_domain, 37 | dkim_key, 38 | include_headers=dkim_headers) 39 | message = sig + message 40 | return message 41 | 42 | 43 | class SESBackend(BaseEmailBackend): 44 | """A Django Email backend that uses Amazon's Simple Email Service. 45 | """ 46 | 47 | def __init__(self, fail_silently=False, aws_access_key=None, 48 | aws_secret_key=None, aws_region_name=None, 49 | aws_region_endpoint=None, aws_auto_throttle=None, 50 | dkim_domain=None, dkim_key=None, dkim_selector=None, 51 | dkim_headers=None, **kwargs): 52 | 53 | super(SESBackend, self).__init__(fail_silently=fail_silently, **kwargs) 54 | self._access_key_id = aws_access_key or settings.ACCESS_KEY 55 | self._access_key = aws_secret_key or settings.SECRET_KEY 56 | self._region = RegionInfo( 57 | name=aws_region_name or settings.AWS_SES_REGION_NAME, 58 | endpoint=aws_region_endpoint or settings.AWS_SES_REGION_ENDPOINT) 59 | self._throttle = aws_auto_throttle or settings.AWS_SES_AUTO_THROTTLE 60 | 61 | self.dkim_domain = dkim_domain or settings.DKIM_DOMAIN 62 | self.dkim_key = dkim_key or settings.DKIM_PRIVATE_KEY 63 | self.dkim_selector = dkim_selector or settings.DKIM_SELECTOR 64 | self.dkim_headers = dkim_headers or settings.DKIM_HEADERS 65 | 66 | self.connection = None 67 | 68 | def open(self): 69 | """Create a connection to the AWS API server. This can be reused for 70 | sending multiple emails. 71 | """ 72 | if self.connection: 73 | return False 74 | 75 | try: 76 | self.connection = SESConnection( 77 | aws_access_key_id=self._access_key_id, 78 | aws_secret_access_key=self._access_key, 79 | region=self._region, 80 | ) 81 | except: 82 | if not self.fail_silently: 83 | raise 84 | 85 | def close(self): 86 | """Close any open HTTP connections to the API server. 87 | """ 88 | try: 89 | self.connection.close() 90 | self.connection = None 91 | except: 92 | if not self.fail_silently: 93 | raise 94 | 95 | def send_messages(self, email_messages): 96 | """Sends one or more EmailMessage objects and returns the number of 97 | email messages sent. 98 | """ 99 | if not email_messages: 100 | return 101 | 102 | new_conn_created = self.open() 103 | if not self.connection: 104 | # Failed silently 105 | return 106 | 107 | num_sent = 0 108 | source = settings.AWS_SES_RETURN_PATH 109 | for message in email_messages: 110 | # Automatic throttling. Assumes that this is the only SES client 111 | # currently operating. The AWS_SES_AUTO_THROTTLE setting is a 112 | # factor to apply to the rate limit, with a default of 0.5 to stay 113 | # well below the actual SES throttle. 114 | # Set the setting to 0 or None to disable throttling. 115 | if self._throttle: 116 | global recent_send_times 117 | 118 | now = datetime.now() 119 | 120 | # Get and cache the current SES max-per-second rate limit 121 | # returned by the SES API. 122 | rate_limit = self.get_rate_limit() 123 | 124 | # Prune from recent_send_times anything more than a few seconds 125 | # ago. Even though SES reports a maximum per-second, the way 126 | # they enforce the limit may not be on a one-second window. 127 | # To be safe, we use a two-second window (but allow 2 times the 128 | # rate limit) and then also have a default rate limit factor of 129 | # 0.5 so that we really limit the one-second amount in two 130 | # seconds. 131 | window = 2.0 # seconds 132 | window_start = now - timedelta(seconds=window) 133 | new_send_times = [] 134 | for time in recent_send_times: 135 | if time > window_start: 136 | new_send_times.append(time) 137 | recent_send_times = new_send_times 138 | 139 | # If the number of recent send times in the last 1/_throttle 140 | # seconds exceeds the rate limit, add a delay. 141 | # Since I'm not sure how Amazon determines at exactly what 142 | # point to throttle, better be safe than sorry and let in, say, 143 | # half of the allowed rate. 144 | if len(new_send_times) > rate_limit * window * self._throttle: 145 | # Sleep the remainder of the window period. 146 | delta = now - new_send_times[0] 147 | total_seconds = (delta.microseconds + (delta.seconds + 148 | delta.days * 24 * 3600) * 10**6) / 10**6 149 | delay = window - total_seconds 150 | if delay > 0: 151 | sleep(delay) 152 | 153 | recent_send_times.append(now) 154 | # end of throttling 155 | 156 | try: 157 | response = self.connection.send_raw_email( 158 | source=source or message.from_email, 159 | destinations=message.recipients(), 160 | raw_message=dkim_sign(message.message().as_string(), 161 | dkim_key=self.dkim_key, 162 | dkim_domain=self.dkim_domain, 163 | dkim_selector=self.dkim_selector, 164 | dkim_headers=self.dkim_headers) 165 | ) 166 | message.extra_headers['status'] = 200 167 | message.extra_headers['message_id'] = response[ 168 | 'SendRawEmailResponse']['SendRawEmailResult']['MessageId'] 169 | message.extra_headers['request_id'] = response[ 170 | 'SendRawEmailResponse']['ResponseMetadata']['RequestId'] 171 | num_sent += 1 172 | except SESConnection.ResponseError as err: 173 | # Store failure information so to post process it if required 174 | error_keys = ['status', 'reason', 'body', 'request_id', 175 | 'error_code', 'error_message'] 176 | for key in error_keys: 177 | message.extra_headers[key] = getattr(err, key, None) 178 | if not self.fail_silently: 179 | raise 180 | 181 | if new_conn_created: 182 | self.close() 183 | 184 | return num_sent 185 | 186 | def get_rate_limit(self): 187 | if self._access_key_id in cached_rate_limits: 188 | return cached_rate_limits[self._access_key_id] 189 | 190 | new_conn_created = self.open() 191 | if not self.connection: 192 | raise Exception( 193 | "No connection is available to check current SES rate limit.") 194 | try: 195 | quota_dict = self.connection.get_send_quota() 196 | max_per_second = quota_dict['GetSendQuotaResponse'][ 197 | 'GetSendQuotaResult']['MaxSendRate'] 198 | ret = float(max_per_second) 199 | cached_rate_limits[self._access_key_id] = ret 200 | return ret 201 | finally: 202 | if new_conn_created: 203 | self.close() 204 | -------------------------------------------------------------------------------- /django_ses/views.py: -------------------------------------------------------------------------------- 1 | try: 2 | from urllib.request import urlopen 3 | from urllib.error import URLError 4 | except ImportError: 5 | from urllib2 import urlopen, URLError 6 | import copy 7 | import logging 8 | from datetime import datetime 9 | 10 | try: 11 | import pytz 12 | except ImportError: 13 | pytz = None 14 | 15 | from boto.regioninfo import RegionInfo 16 | from boto.ses import SESConnection 17 | 18 | from django.http import HttpResponse, HttpResponseBadRequest 19 | from django.views.decorators.http import require_POST 20 | from django.core.cache import cache 21 | from django.core.exceptions import PermissionDenied 22 | from django.shortcuts import render_to_response 23 | from django.template import RequestContext 24 | 25 | try: 26 | import json 27 | except ImportError: 28 | from django.utils import simplejson as json 29 | 30 | from django_ses import settings 31 | from django_ses import signals 32 | from django_ses import utils 33 | 34 | logger = logging.getLogger(__name__) 35 | 36 | def superuser_only(view_func): 37 | """ 38 | Limit a view to superuser only. 39 | """ 40 | def _inner(request, *args, **kwargs): 41 | if not request.user.is_superuser: 42 | raise PermissionDenied 43 | return view_func(request, *args, **kwargs) 44 | return _inner 45 | 46 | 47 | def stats_to_list(stats_dict, localize=pytz): 48 | """ 49 | Parse the output of ``SESConnection.get_send_statistics()`` in to an 50 | ordered list of 15-minute summaries. 51 | """ 52 | result = stats_dict['GetSendStatisticsResponse']['GetSendStatisticsResult'] 53 | # Make a copy, so we don't change the original stats_dict. 54 | result = copy.deepcopy(result) 55 | datapoints = [] 56 | if localize: 57 | current_tz = localize.timezone(settings.TIME_ZONE) 58 | else: 59 | current_tz = None 60 | for dp in result['SendDataPoints']: 61 | if current_tz: 62 | utc_dt = datetime.strptime(dp['Timestamp'], '%Y-%m-%dT%H:%M:%SZ') 63 | utc_dt = localize.utc.localize(utc_dt) 64 | dp['Timestamp'] = current_tz.normalize( 65 | utc_dt.astimezone(current_tz)) 66 | datapoints.append(dp) 67 | 68 | datapoints.sort(key=lambda x: x['Timestamp']) 69 | 70 | return datapoints 71 | 72 | 73 | def quota_parse(quota_dict): 74 | """ 75 | Parse the output of ``SESConnection.get_send_quota()`` to just the results. 76 | """ 77 | return quota_dict['GetSendQuotaResponse']['GetSendQuotaResult'] 78 | 79 | 80 | def emails_parse(emails_dict): 81 | """ 82 | Parse the output of ``SESConnection.list_verified_emails()`` and get 83 | a list of emails. 84 | """ 85 | result = emails_dict['ListVerifiedEmailAddressesResponse'][ 86 | 'ListVerifiedEmailAddressesResult'] 87 | emails = [email for email in result['VerifiedEmailAddresses']] 88 | 89 | return sorted(emails) 90 | 91 | 92 | def sum_stats(stats_data): 93 | """ 94 | Summarize the bounces, complaints, delivery attempts and rejects from a 95 | list of datapoints. 96 | """ 97 | t_bounces = 0 98 | t_complaints = 0 99 | t_delivery_attempts = 0 100 | t_rejects = 0 101 | for dp in stats_data: 102 | t_bounces += int(dp['Bounces']) 103 | t_complaints += int(dp['Complaints']) 104 | t_delivery_attempts += int(dp['DeliveryAttempts']) 105 | t_rejects += int(dp['Rejects']) 106 | 107 | return { 108 | 'Bounces': t_bounces, 109 | 'Complaints': t_complaints, 110 | 'DeliveryAttempts': t_delivery_attempts, 111 | 'Rejects': t_rejects, 112 | } 113 | 114 | 115 | @superuser_only 116 | def dashboard(request): 117 | """ 118 | Graph SES send statistics over time. 119 | """ 120 | cache_key = 'vhash:django_ses_stats' 121 | cached_view = cache.get(cache_key) 122 | if cached_view: 123 | return cached_view 124 | 125 | region = RegionInfo( 126 | name=settings.AWS_SES_REGION_NAME, 127 | endpoint=settings.AWS_SES_REGION_ENDPOINT) 128 | 129 | ses_conn = SESConnection( 130 | aws_access_key_id=settings.ACCESS_KEY, 131 | aws_secret_access_key=settings.SECRET_KEY, 132 | region=region) 133 | 134 | quota_dict = ses_conn.get_send_quota() 135 | verified_emails_dict = ses_conn.list_verified_email_addresses() 136 | stats = ses_conn.get_send_statistics() 137 | 138 | quota = quota_parse(quota_dict) 139 | verified_emails = emails_parse(verified_emails_dict) 140 | ordered_data = stats_to_list(stats) 141 | summary = sum_stats(ordered_data) 142 | 143 | extra_context = { 144 | 'title': 'SES Statistics', 145 | 'datapoints': ordered_data, 146 | '24hour_quota': quota['Max24HourSend'], 147 | '24hour_sent': quota['SentLast24Hours'], 148 | '24hour_remaining': float(quota['Max24HourSend']) - 149 | float(quota['SentLast24Hours']), 150 | 'persecond_rate': quota['MaxSendRate'], 151 | 'verified_emails': verified_emails, 152 | 'summary': summary, 153 | 'access_key': ses_conn.gs_access_key_id, 154 | 'local_time': True if pytz else False, 155 | } 156 | 157 | response = render_to_response( 158 | 'django_ses/send_stats.html', 159 | extra_context, 160 | context_instance=RequestContext(request)) 161 | 162 | cache.set(cache_key, response, 60 * 15) # Cache for 15 minutes 163 | return response 164 | 165 | @require_POST 166 | def handle_bounce(request): 167 | """ 168 | Handle a bounced email via an SNS webhook. 169 | 170 | Parse the bounced message and send the appropriate signal. 171 | For bounce messages the bounce_received signal is called. 172 | For complaint messages the complaint_received signal is called. 173 | See: http://docs.aws.amazon.com/sns/latest/gsg/json-formats.html#http-subscription-confirmation-json 174 | See: http://docs.amazonwebservices.com/ses/latest/DeveloperGuide/NotificationsViaSNS.html 175 | 176 | In addition to email bounce requests this endpoint also supports the SNS 177 | subscription confirmation request. This request is sent to the SNS 178 | subscription endpoint when the subscription is registered. 179 | See: http://docs.aws.amazon.com/sns/latest/gsg/Subscribe.html 180 | 181 | For the format of the SNS subscription confirmation request see this URL: 182 | http://docs.aws.amazon.com/sns/latest/gsg/json-formats.html#http-subscription-confirmation-json 183 | 184 | SNS message signatures are verified by default. This funcionality can 185 | be disabled by setting AWS_SES_VERIFY_BOUNCE_SIGNATURES to False. 186 | However, this is not recommended. 187 | See: http://docs.amazonwebservices.com/sns/latest/gsg/SendMessageToHttp.verify.signature.html 188 | """ 189 | 190 | # For Django >= 1.4 use request.body, otherwise 191 | # use the old request.raw_post_data 192 | if hasattr(request, 'body'): 193 | raw_json = request.body 194 | else: 195 | raw_json = request.raw_post_data 196 | 197 | try: 198 | notification = json.loads(raw_json) 199 | except ValueError as e: 200 | # TODO: What kind of response should be returned here? 201 | logger.warning('Recieved bounce with bad JSON: "%s"', e) 202 | return HttpResponseBadRequest() 203 | 204 | # Verify the authenticity of the bounce message. 205 | if (settings.VERIFY_BOUNCE_SIGNATURES and 206 | not utils.verify_bounce_message(notification)): 207 | # Don't send any info back when the notification is not 208 | # verified. Simply, don't process it. 209 | logger.info('Recieved unverified notification: Type: %s', 210 | notification.get('Type'), 211 | extra={ 212 | 'notification': notification, 213 | }, 214 | ) 215 | return HttpResponse() 216 | 217 | if notification.get('Type') in ('SubscriptionConfirmation', 218 | 'UnsubscribeConfirmation'): 219 | # Process the (un)subscription confirmation. 220 | 221 | logger.info('Recieved subscription confirmation: TopicArn: %s', 222 | notification.get('TopicArn'), 223 | extra={ 224 | 'notification': notification, 225 | }, 226 | ) 227 | 228 | # Get the subscribe url and hit the url to confirm the subscription. 229 | subscribe_url = notification.get('SubscribeURL') 230 | try: 231 | urlopen(subscribe_url).read() 232 | except URLError as e: 233 | # Some kind of error occurred when confirming the request. 234 | logger.error('Could not confirm subscription: "%s"', e, 235 | extra={ 236 | 'notification': notification, 237 | }, 238 | exc_info=True, 239 | ) 240 | elif notification.get('Type') == 'Notification': 241 | try: 242 | message = json.loads(notification['Message']) 243 | except ValueError as e: 244 | # The message isn't JSON. 245 | # Just ignore the notification. 246 | logger.warning('Recieved bounce with bad JSON: "%s"', e, extra={ 247 | 'notification': notification, 248 | }) 249 | else: 250 | mail_obj = message.get('mail') 251 | notification_type = message.get('notificationType') 252 | 253 | if notification_type == 'Bounce': 254 | # Bounce 255 | bounce_obj = message.get('bounce', {}) 256 | 257 | # Logging 258 | feedback_id = bounce_obj.get('feedbackId') 259 | bounce_type = bounce_obj.get('bounceType') 260 | bounce_subtype = bounce_obj.get('bounceSubType') 261 | logger.info( 262 | 'Recieved bounce notification: feedbackId: %s, bounceType: %s, bounceSubType: %s', 263 | feedback_id, bounce_type, bounce_subtype, 264 | extra={ 265 | 'notification': notification, 266 | }, 267 | ) 268 | 269 | signals.bounce_received.send( 270 | sender=handle_bounce, 271 | mail_obj=mail_obj, 272 | bounce_obj=bounce_obj, 273 | raw_message=raw_json, 274 | ) 275 | elif notification_type == 'Complaint': 276 | # Complaint 277 | complaint_obj = message.get('complaint', {}) 278 | 279 | # Logging 280 | feedback_id = complaint_obj.get('feedbackId') 281 | feedback_type = complaint_obj.get('complaintFeedbackType') 282 | logger.info('Recieved complaint notification: feedbackId: %s, feedbackType: %s', 283 | feedback_id, feedback_type, 284 | extra={ 285 | 'notification': notification, 286 | }, 287 | ) 288 | 289 | signals.complaint_received.send( 290 | sender=handle_bounce, 291 | mail_obj=mail_obj, 292 | complaint_obj=complaint_obj, 293 | raw_message=raw_json, 294 | ) 295 | else: 296 | # We received an unknown notification type. Just log and 297 | # ignore it. 298 | logger.warning("Recieved unknown notification", extra={ 299 | 'notification': notification, 300 | }) 301 | else: 302 | logger.info('Recieved unknown notification type: %s', 303 | notification.get('Type'), 304 | extra={ 305 | 'notification': notification, 306 | }, 307 | ) 308 | 309 | # AWS will consider anything other than 200 to be an error response and 310 | # resend the SNS request. We don't need that so we return 200 here. 311 | return HttpResponse() 312 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | Django-SES 3 | ========== 4 | :Info: A Django email backend for Amazon's Simple Email Service 5 | :Author: Harry Marr (http://github.com/hmarr, http://twitter.com/harrymarr) 6 | :Collaborators: Paul Craciunoiu (http://github.com/pcraciunoiu, http://twitter.com/embrangler) 7 | 8 | .. image:: https://travis-ci.org/django-ses/django-ses.svg 9 | :target: https://travis-ci.org/django-ses/django-ses 10 | 11 | A bird's eye view 12 | ================= 13 | Django-SES is a drop-in mail backend for Django_. Instead of sending emails 14 | through a traditional SMTP mail server, Django-SES routes email through 15 | Amazon Web Services' excellent Simple Email Service (SES_). 16 | 17 | 18 | Using Django directly 19 | ===================== 20 | 21 | Amazon SES allows you to also setup usernames and passwords. If you do configure 22 | things that way, you do not need this package. The Django default email backend 23 | is capable of authenticating with Amazon SES and correctly sending email. 24 | 25 | Using django-ses gives you additional features like deliverability reports that 26 | can be hard and/or cumbersome to obtain when using the SMTP interface. 27 | 28 | **Note:** In order to use smtp with Amazon SES, you may have to install some 29 | supporting packages for ssl. Check out `this SMTP SSL email backend for Django`__ 30 | 31 | Why SES instead of SMTP? 32 | ======================== 33 | Configuring, maintaining, and dealing with some complicated edge cases can be 34 | time-consuming. Sending emails with Django-SES might be attractive to you if: 35 | 36 | * You don't want to maintain mail servers. 37 | * You are already deployed on EC2 (In-bound traffic to SES is free from EC2 38 | instances). 39 | * You need to send a high volume of email. 40 | * You don't want to have to worry about PTR records, Reverse DNS, email 41 | whitelist/blacklist services. 42 | * You want to improve delivery rate and inbox cosmetics by DKIM signing 43 | your messages using SES's Easy DKIM feature. 44 | * Django-SES is a truely drop-in replacement for the default mail backend. 45 | Your code should require no changes. 46 | 47 | Getting going 48 | ============= 49 | Assuming you've got Django_ installed, you'll need Boto_ 2.1.0 or higher. Boto_ 50 | is a Python library that wraps the AWS API. 51 | 52 | You can do the following to install boto 2.1.0 (we're using --upgrade here to 53 | make sure you get 2.1.0):: 54 | 55 | pip install --upgrade boto 56 | 57 | Install django-ses:: 58 | 59 | pip install django-ses 60 | 61 | Add the following to your settings.py:: 62 | 63 | EMAIL_BACKEND = 'django_ses.SESBackend' 64 | 65 | # These are optional -- if they're set as environment variables they won't 66 | # need to be set here as well 67 | AWS_ACCESS_KEY_ID = 'YOUR-ACCESS-KEY-ID' 68 | AWS_SECRET_ACCESS_KEY = 'YOUR-SECRET-ACCESS-KEY' 69 | 70 | # Additionally, if you are not using the default AWS region of us-east-1, 71 | # you need to specify a region, like so: 72 | AWS_SES_REGION_NAME = 'us-west-2' 73 | AWS_SES_REGION_ENDPOINT = 'email.us-west-2.amazonaws.com' 74 | 75 | Alternatively, instead of `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`, you 76 | can include the following two settings values. This is useful in situations 77 | where you would like to use a separate access key to send emails via SES than 78 | you would to upload files via S3:: 79 | 80 | AWS_SES_ACCESS_KEY_ID = 'YOUR-ACCESS-KEY-ID' 81 | AWS_SES_SECRET_ACCESS_KEY = 'YOUR-SECRET-ACCESS-KEY' 82 | 83 | Now, when you use ``django.core.mail.send_mail``, Simple Email Service will 84 | send the messages by default. 85 | 86 | Since SES imposes a rate limit and will reject emails after the limit has been 87 | reached, django-ses will attempt to conform to the rate limit by querying the 88 | API for your current limit and then sending no more than that number of 89 | messages in a two-second period (which is half of the rate limit, just to 90 | be sure to stay clear of the limit). This is controlled by the following setting: 91 | 92 | AWS_SES_AUTO_THROTTLE = 0.5 # (default; safety factor applied to rate limit) 93 | 94 | To turn off automatic throttling, set this to None. 95 | 96 | Check out the ``example`` directory for more information. 97 | 98 | DKIM 99 | ==== 100 | 101 | Using DomainKeys_ is entirely optional, however it is recommended by Amazon for 102 | authenticating your email address and improving delivery success rate. See 103 | http://docs.amazonwebservices.com/ses/latest/DeveloperGuide/DKIM.html. 104 | Besides authentication, you might also want to consider using DKIM in order to 105 | remove the `via email-bounces.amazonses.com` message shown to gmail users - 106 | see http://support.google.com/mail/bin/answer.py?hl=en&answer=1311182. 107 | 108 | Currently there are two methods to use DKIM with Django-SES: traditional Manual 109 | Signing and the more recently introduced Amazon Easy DKIM feature. 110 | 111 | Easy DKIM 112 | --------- 113 | Easy DKIM is a feature of Amazon SES that automatically signs every message 114 | that you send from a verified email address or domain with a DKIM signature. 115 | 116 | You can enable Easy DKIM in the AWS Management Console for SES. There you can 117 | also add the required domain verification and DKIM records to Route 53 (or 118 | copy them to your alternate DNS). 119 | 120 | Once enabled and verified Easy DKIM needs no additional dependencies or 121 | DKIM specific settings to work with Django-SES. 122 | 123 | For more information and a setup guide see: 124 | http://docs.aws.amazon.com/ses/latest/DeveloperGuide/easy-dkim.html 125 | 126 | Manual DKIM Signing 127 | ------------------- 128 | To enable Manual DKIM Signing you should install the pydkim_ package and specify values 129 | for the ``DKIM_PRIVATE_KEY`` and ``DKIM_DOMAIN`` settings. You can generate a 130 | private key with a command such as ``openssl genrsa 512`` and get the public key 131 | portion with ``openssl rsa -pubout `_ 304 | 305 | #. Write and run tests. 306 | Write your own test showing the issue has been resolved, or the feature 307 | works as intended. 308 | 309 | Running Tests 310 | ============= 311 | To run the tests:: 312 | 313 | python manage.py test django_ses 314 | --------------------------------------------------------------------------------