├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE.txt ├── Makefile ├── README.md ├── djcelery_ses ├── __init__.py ├── admin.py ├── backends.py ├── fixtures │ ├── sns.json │ └── subscription.json ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── south_migrations │ ├── 0001_initial.py │ ├── 0002_auto__del_field_messagelog_body__add_field_messagelog_subject.py │ └── __init__.py ├── tasks.py ├── test_runner.py ├── tests.py ├── urls.py ├── utils.py └── views.py ├── runtests.py ├── setup.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.pyc 3 | /dist 4 | /build 5 | .tox 6 | *.eggs 7 | *.egg-info 8 | *.sqlite3 9 | .coverage 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - "2.7" 5 | 6 | env: 7 | - DJANGO="django>=1.6,<1.7" 8 | - DJANGO="django>=1.7,<1.8" 9 | - DJANGO="django>=1.8,<1.9" 10 | 11 | before_install: 12 | - sudo apt-get update -qq 13 | 14 | install: 15 | - pip install coveralls 16 | 17 | script: 18 | - coverage run --source=djcelery_ses setup.py test 19 | 20 | after_success: 21 | - coveralls 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.9.3 2 | 3 | * Add django migration for django 1.7 or later. 4 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 tzangms 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test 2 | test: 3 | python runtests.py 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Django Celery SES 2 | ========================= 3 | 4 | [![PyPI Version](https://badge.fury.io/py/django-celery-ses.png)](https://pypi.python.org/pypi/django-celery-ses) 5 | [![Build Status](https://travis-ci.org/StreetVoice/django-celery-ses.png?branch=master)](https://travis-ci.org/StreetVoice/django-celery-ses) 6 | [![Coverage Status](https://coveralls.io/repos/StreetVoice/django-celery-ses/badge.png?branch=master)](https://coveralls.io/r/StreetVoice/django-celery-ses?branch=master) 7 | 8 | Django Email Backend with Amazon Web Service SES and Celery, developed and used by [StreetVoice](http://streetvoice.com/). 9 | 10 | 11 | This packages provide a EmailBackend to utilize django-celery to send email. You can just plug the EmailBackend in your project without any modify with your code. 12 | 13 | Since Amazon SES requires you to handle Bounce email from SNS notification, django-celery-ses also provides view to handle SNS notification for email address which is blacklisted in Amazon SES. 14 | 15 | What is provided 16 | ================= 17 | 18 | 1. Celery EmailBackend 19 | 2. SNS notification handler 20 | 3. Blacklist to handle Bounce email 21 | 22 | 23 | Installation 24 | ================ 25 | 26 | 1. Install from pip / easy_install 27 | 28 | ```sh 29 | $ pip install django-celery-ses 30 | ``` 31 | 32 | 2. Add `djcelery_ses` to `INSTALLED_APPS` in `settings.py` 33 | 34 | ```python 35 | INSTALLED_APPS = ( 36 | ... 37 | 'djcelery_ses', 38 | ... 39 | ) 40 | ``` 41 | 42 | 3. migrate the database with South ( you have to install South ) 43 | 44 | ```sh 45 | $ ./manage.py migrate 46 | 47 | ``` 48 | 49 | 4. Change the `EMAIL_BACKEND` 50 | 51 | ```python 52 | EMAIL_BACKEND = 'djcelery_ses.backends.CeleryEmailBackend' 53 | ``` 54 | 55 | 5. Add `djcelery_ses` in `urls.py` 56 | 57 | ```python 58 | urlpatterns = patterns('', 59 | ... 60 | (r'^djcelery_ses/', include('djcelery_ses.urls')), 61 | ... 62 | ) 63 | ``` 64 | 65 | 66 | Configuration 67 | =============== 68 | 69 | `django-celery-ses` uses Amazon SES through SMTP, so you have add `EMAIL_*` configuration in `settings.py` 70 | 71 | ```python 72 | EMAIL_USE_TLS = True 73 | EMAIL_HOST = 'email-smtp.us-east-1.amazonaws.com' 74 | EMAIL_HOST_USER = '' 75 | EMAIL_HOST_PASSWORD = '' 76 | EMAIL_PORT = 587 77 | 78 | SERVER_EMAIL = 'StreetVoice ' 79 | DEFAULT_FROM_EMAIL = 'StreetVoice ' 80 | ``` 81 | 82 | Besides these settings, you also have to setting the SES / SNS on AWS to make this package handle bounce mail for you. 83 | 84 | 85 | How to use 86 | ============= 87 | 88 | All you have to do is use `send_mail` or `EmailMessage` just like the old time, you don't have to change your code. 89 | 90 | 91 | 92 | Utilities 93 | ============== 94 | 95 | This package handle Blacklist for you by default, but sometimes, maybe you want to bypass the "blacklist check", you can use `pass_blacklist` to pass the "backlist check" like this. 96 | 97 | ```python 98 | from djcelery_ses.utils import pass_blacklist 99 | from django.core.mail import EmailMessage 100 | 101 | with pass_blacklist: 102 | msg = EmailMessage('title', 'body content', 'noreply@example.com', ['noreply@example.com']) 103 | msg.send() 104 | ``` 105 | 106 | or in some situations, you don't want the email to send through Celery queue, you can use `no_delay`, for example. 107 | 108 | > since version 0.9 109 | 110 | ```python 111 | from djcelery_ses.utils import no_delay 112 | from django.core.mail import send_mail 113 | 114 | with no_delay: 115 | send_mail('title', 'body content', 'noreply@example.com', ['noreply@example.com']) 116 | ``` 117 | 118 | with `no_delay` your email will send out directly without Celey queue. 119 | 120 | 121 | Test 122 | ============== 123 | In order to ensure your changed which can pass in local environment, please run the script: 124 | 125 | ``` 126 | make test 127 | ``` 128 | -------------------------------------------------------------------------------- /djcelery_ses/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '3.1.1' 2 | -------------------------------------------------------------------------------- /djcelery_ses/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from .models import Blacklist, MessageLog 3 | 4 | class BlacklistAdmin(admin.ModelAdmin): 5 | list_display = ('email', 'type', 'created_at') 6 | list_filter = ('type',) 7 | search_fields = ('email',) 8 | admin.site.register(Blacklist, BlacklistAdmin) 9 | 10 | 11 | class MessageLogAdmin(admin.ModelAdmin): 12 | list_display = ('email', 'result', 'created_at') 13 | search_fields = ('email',) 14 | admin.site.register(MessageLog, MessageLogAdmin) 15 | -------------------------------------------------------------------------------- /djcelery_ses/backends.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.conf import settings 3 | from django.core.mail.backends.base import BaseEmailBackend 4 | 5 | from .tasks import send_emails 6 | 7 | 8 | class CeleryEmailBackend(BaseEmailBackend): 9 | def __init__(self, fail_silently=False, **kwargs): 10 | super(CeleryEmailBackend, self).__init__(fail_silently) 11 | self.init_kwargs = kwargs 12 | 13 | def send_messages(self, email_messages, **kwargs): 14 | if not email_messages: 15 | return 0 16 | 17 | kwargs["_backend_init_kwargs"] = self.init_kwargs 18 | 19 | NO_DELAY = getattr(settings, "DJCELERY_SES_NO_DELAY", False) 20 | CHUNK_SIZE = getattr(settings, "DJCELERY_SES_CHUNK_SIZE", 50) 21 | 22 | for index in range(0, len(email_messages), CHUNK_SIZE): 23 | email_messages_chunk = email_messages[index: index + CHUNK_SIZE] 24 | 25 | if NO_DELAY: 26 | send_emails(email_messages_chunk, **kwargs) 27 | else: 28 | send_emails.delay(email_messages_chunk, **kwargs) 29 | 30 | return len(email_messages) 31 | -------------------------------------------------------------------------------- /djcelery_ses/fixtures/sns.json: -------------------------------------------------------------------------------- 1 | { 2 | "Type" : "Notification", 3 | "MessageId" : "8q787fm3-f7fe-5sf4-863e-331bre627f24", 4 | "TopicArn" : "arn:aws:sns:us-east-1:1234567890:ses-bounces-topic", 5 | "Message" : "{\"notificationType\":\"Bounce\",\"bounce\":{\"reportingMTA\":\"dns; a193-136.smtp-out.amazonses.com\",\"bounceType\":\"Transient\",\"bouncedRecipients\":[{\"emailAddress\":\"email@example.com\",\"status\":\"5.0.0\",\"diagnosticCode\":\"smtp; 5.3.0 - Other mail system problem 571-'5.7.1 Message contains spam or virus or sender is blocked : 17624:1603463706|734463C' (delivery attempts: 0)\",\"action\":\"failed\"}],\"bounceSubType\":\"General\",\"timestamp\":\"2013-03-11T22:15:21.000Z\",\"feedbackId\":\"0000013d5b854265-18f16a12-8a99-11e2-aa8d-81a75f1af476-000000\"},\"mail\":{\"timestamp\":\"2013-03-11T22:14:51.000Z\",\"source\":\"otheremail@example.com\",\"messageId\":\"0000013d5b853e2a-820173e1-095e-4h91-9c91-03876f970534-000000\",\"destination\":[\"email@example.com\"]}}", 6 | "Timestamp" : "2013-03-11T22:14:52.935Z", 7 | "SignatureVersion" : "1", 8 | "Signature" : "bY5gjFMgrVnK+4Qw867qHR0cLDXlgZmYb6EdiDAd4hNHMDab4J5MdldldEQwkSFslkdkDsdowlsKAdQvZ9HZwSmEcTRpwgg3Fpp5R/efVnTdUVfJkmBcnhijhWHpxSdEqN9m5vgPhg=", 9 | "SigningCertURL" : "https://sns.us-east-1.amazonaws.com/SimpleNotificationService-f3ecfb7224c72n3fe7bp5KDMMX6de32f.pem", 10 | "UnsubscribeURL" : "https://sns.us-east-1.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:us-east-1:1234567890:ses-bounces-topic:73m9983aa-0f4b-4r87-a5d7-d43pb99c91af" 11 | } 12 | -------------------------------------------------------------------------------- /djcelery_ses/fixtures/subscription.json: -------------------------------------------------------------------------------- 1 | { 2 | "Type" : "SubscriptionConfirmation", 3 | "MessageId" : "165545c9-2a5c-472c-8df2-7ff2be2b3b1b", 4 | "Token" : "2336412f37fb687f5d51e6e241d09c805a5a57b30d712f794cc5f6a988666d92768dd60a747ba6f3beb71854e285d6ad02428b09ceece29417f1f02d609c582afbacc99c583a916b9981dd2728f4ae6fdb82efd087cc3b7849e05798d2d2785c03b0879594eeac82c01f235d0e717736", 5 | "TopicArn" : "arn:aws:sns:us-east-1:123456789012:MyTopic", 6 | "Message" : "You have chosen to subscribe to the topic arn:aws:sns:us-east-1:123456789012:MyTopic.\nTo confirm the subscription, visit the SubscribeURL included in this message.", 7 | "SubscribeURL" : "https://sns.us-east-1.amazonaws.com/?Action=ConfirmSubscription&TopicArn=arn:aws:sns:us-east-1:123456789012:MyTopic&Token=2336412f37fb687f5d51e6e241d09c805a5a57b30d712f794cc5f6a988666d92768dd60a747ba6f3beb71854e285d6ad02428b09ceece29417f1f02d609c582afbacc99c583a916b9981dd2728f4ae6fdb82efd087cc3b7849e05798d2d2785c03b0879594eeac82c01f235d0e717736", 8 | "Timestamp" : "2012-04-26T20:45:04.751Z", 9 | "SignatureVersion" : "1", 10 | "Signature" : "EXAMPLEpH+DcEwjAPg8O9mY8dReBSwksfg2S7WKQcikcNKWLQjwu6A4VbeS0QHVCkhRS7fUQvi2egU3N858fiTDN6bkkOxYDVrY0Ad8L10Hs3zH81mtnPk5uvvolIC1CXGu43obcgFxeL3khZl8IKvO61GWB6jI9b5+gLPoBc1Q=", 11 | "SigningCertURL" : "https://sns.us-east-1.amazonaws.com/SimpleNotificationService-f3ecfb7224c7233fe7bb5f59f96de52f.pem" 12 | } 13 | -------------------------------------------------------------------------------- /djcelery_ses/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='Blacklist', 15 | fields=[ 16 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 17 | ('email', models.EmailField(unique=True, max_length=75)), 18 | ('type', models.PositiveSmallIntegerField(default=0, choices=[(0, b'Bounce'), (1, b'Complaints')])), 19 | ('created_at', models.DateTimeField(auto_now_add=True)), 20 | ], 21 | options={ 22 | }, 23 | bases=(models.Model,), 24 | ), 25 | migrations.CreateModel( 26 | name='MessageLog', 27 | fields=[ 28 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 29 | ('email', models.EmailField(max_length=75)), 30 | ('subject', models.CharField(max_length=255)), 31 | ('result', models.CharField(max_length=1, choices=[(b'1', b'success'), (b'2', b'retry'), (b'3', b'blacklisted')])), 32 | ('created_at', models.DateTimeField(auto_now_add=True)), 33 | ], 34 | options={ 35 | }, 36 | bases=(models.Model,), 37 | ), 38 | ] 39 | -------------------------------------------------------------------------------- /djcelery_ses/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StreetVoice/django-celery-ses/ac38d62a214cb66b7bc796d7e859b5efa8f0aea7/djcelery_ses/migrations/__init__.py -------------------------------------------------------------------------------- /djcelery_ses/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Blacklist(models.Model): 5 | TYPE_CHOICES = ( 6 | (0, 'Bounce'), 7 | (1, 'Complaints'), 8 | ) 9 | email = models.EmailField(unique=True) 10 | type = models.PositiveSmallIntegerField(default=0, choices=TYPE_CHOICES) 11 | created_at = models.DateTimeField(auto_now_add=True) 12 | 13 | def __str__(self): 14 | return self.email 15 | 16 | 17 | RESULT_CODES = ( 18 | ("1", "success"), 19 | ("2", "retry"), 20 | ("3", "blacklisted"), 21 | ) 22 | 23 | class MessageLogManager(models.Manager): 24 | def log(self, message, result_code): 25 | subject = message.subject[:250] + '...' if len(message.subject) > 255 else message.subject 26 | self.create(email=message.to[0], subject=subject, result=result_code) 27 | 28 | 29 | class MessageLog(models.Model): 30 | email = models.EmailField() 31 | subject = models.CharField(max_length=255) 32 | result = models.CharField(max_length=1, choices=RESULT_CODES) 33 | created_at = models.DateTimeField(auto_now_add=True) 34 | 35 | objects = MessageLogManager() 36 | 37 | def __str__(self): 38 | return self.email 39 | -------------------------------------------------------------------------------- /djcelery_ses/south_migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from south.utils import datetime_utils as datetime 3 | from south.db import db 4 | from south.v2 import SchemaMigration 5 | from django.db import models 6 | 7 | 8 | class Migration(SchemaMigration): 9 | 10 | def forwards(self, orm): 11 | # Adding model 'Blacklist' 12 | db.create_table(u'djcelery_ses_blacklist', ( 13 | (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), 14 | ('email', self.gf('django.db.models.fields.EmailField')(unique=True, max_length=75)), 15 | ('type', self.gf('django.db.models.fields.PositiveSmallIntegerField')(default=0)), 16 | ('created_at', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), 17 | )) 18 | db.send_create_signal(u'djcelery_ses', ['Blacklist']) 19 | 20 | # Adding model 'MessageLog' 21 | db.create_table(u'djcelery_ses_messagelog', ( 22 | (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), 23 | ('email', self.gf('django.db.models.fields.EmailField')(max_length=75)), 24 | ('body', self.gf('django.db.models.fields.TextField')()), 25 | ('result', self.gf('django.db.models.fields.CharField')(max_length=1)), 26 | ('created_at', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), 27 | )) 28 | db.send_create_signal(u'djcelery_ses', ['MessageLog']) 29 | 30 | 31 | def backwards(self, orm): 32 | # Deleting model 'Blacklist' 33 | db.delete_table(u'djcelery_ses_blacklist') 34 | 35 | # Deleting model 'MessageLog' 36 | db.delete_table(u'djcelery_ses_messagelog') 37 | 38 | 39 | models = { 40 | u'djcelery_ses.blacklist': { 41 | 'Meta': {'object_name': 'Blacklist'}, 42 | 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), 43 | 'email': ('django.db.models.fields.EmailField', [], {'unique': 'True', 'max_length': '75'}), 44 | u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 45 | 'type': ('django.db.models.fields.PositiveSmallIntegerField', [], {'default': '0'}) 46 | }, 47 | u'djcelery_ses.messagelog': { 48 | 'Meta': {'object_name': 'MessageLog'}, 49 | 'body': ('django.db.models.fields.TextField', [], {}), 50 | 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), 51 | 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75'}), 52 | u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 53 | 'result': ('django.db.models.fields.CharField', [], {'max_length': '1'}) 54 | } 55 | } 56 | 57 | complete_apps = ['djcelery_ses'] -------------------------------------------------------------------------------- /djcelery_ses/south_migrations/0002_auto__del_field_messagelog_body__add_field_messagelog_subject.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from south.utils import datetime_utils as datetime 3 | from south.db import db 4 | from south.v2 import SchemaMigration 5 | from django.db import models 6 | 7 | 8 | class Migration(SchemaMigration): 9 | 10 | def forwards(self, orm): 11 | # Deleting field 'MessageLog.body' 12 | db.delete_column(u'djcelery_ses_messagelog', 'body') 13 | 14 | # Adding field 'MessageLog.subject' 15 | db.add_column(u'djcelery_ses_messagelog', 'subject', 16 | self.gf('django.db.models.fields.CharField')(default='', max_length=255), 17 | keep_default=False) 18 | 19 | 20 | def backwards(self, orm): 21 | # Adding field 'MessageLog.body' 22 | db.add_column(u'djcelery_ses_messagelog', 'body', 23 | self.gf('django.db.models.fields.TextField')(default=''), 24 | keep_default=False) 25 | 26 | # Deleting field 'MessageLog.subject' 27 | db.delete_column(u'djcelery_ses_messagelog', 'subject') 28 | 29 | 30 | models = { 31 | u'djcelery_ses.blacklist': { 32 | 'Meta': {'object_name': 'Blacklist'}, 33 | 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), 34 | 'email': ('django.db.models.fields.EmailField', [], {'unique': 'True', 'max_length': '75'}), 35 | u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 36 | 'type': ('django.db.models.fields.PositiveSmallIntegerField', [], {'default': '0'}) 37 | }, 38 | u'djcelery_ses.messagelog': { 39 | 'Meta': {'object_name': 'MessageLog'}, 40 | 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), 41 | 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75'}), 42 | u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 43 | 'result': ('django.db.models.fields.CharField', [], {'max_length': '1'}), 44 | 'subject': ('django.db.models.fields.CharField', [], {'max_length': '255'}) 45 | } 46 | } 47 | 48 | complete_apps = ['djcelery_ses'] -------------------------------------------------------------------------------- /djcelery_ses/south_migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StreetVoice/django-celery-ses/ac38d62a214cb66b7bc796d7e859b5efa8f0aea7/djcelery_ses/south_migrations/__init__.py -------------------------------------------------------------------------------- /djcelery_ses/tasks.py: -------------------------------------------------------------------------------- 1 | from smtplib import SMTPDataError 2 | 3 | from django.conf import settings 4 | from django.db import IntegrityError 5 | from django.core.mail import get_connection 6 | 7 | from celery import shared_task 8 | from celery.utils.log import get_task_logger 9 | 10 | from .models import Blacklist, MessageLog 11 | 12 | logger = get_task_logger(__name__) 13 | 14 | 15 | CONFIG = getattr(settings, 'CELERY_EMAIL_TASK_CONFIG', {}) 16 | BACKEND = getattr(settings, 'CELERY_EMAIL_BACKEND', 17 | 'django.core.mail.backends.smtp.EmailBackend') 18 | 19 | TASK_CONFIG = { 20 | 'name': 'djcelery_email_send', 21 | 'ignore_result': True, 22 | } 23 | TASK_CONFIG.update(CONFIG) 24 | 25 | 26 | @shared_task(**TASK_CONFIG) 27 | def send_emails(messages, **kwargs): 28 | """ 29 | send mails task 30 | """ 31 | conn = get_connection(backend=BACKEND) 32 | conn.open() 33 | 34 | num = 0 35 | for message in messages: 36 | # check blacklist 37 | CHECK_BLACKLIST = getattr( 38 | settings, 'DJCELERY_SES_CHECK_BLACKLIST', True) 39 | if CHECK_BLACKLIST: 40 | logger.debug('Check blacklist') 41 | 42 | try: 43 | Blacklist.objects.get(email=message.to[0], type=0) 44 | logger.debug("Email already in blacklist.") 45 | continue 46 | except Blacklist.DoesNotExist: 47 | pass 48 | 49 | # send 50 | try: 51 | result = conn.send_messages([message]) 52 | logger.debug("Successfully sent email message to %r.", message.to) 53 | MessageLog.objects.log(message, 1) 54 | num += result 55 | except SMTPDataError as e: 56 | logger.warning("Message to %r, blacklisted.", message.to) 57 | if e.smtp_code == 554: 58 | MessageLog.objects.log(message, 3) 59 | try: 60 | Blacklist(email=message.to[0]).save() 61 | except IntegrityError: 62 | pass 63 | except Exception as e: 64 | MessageLog.objects.log(message, 2) 65 | logger.warning( 66 | "Failed to send email message to %r, retrying.", message.to) 67 | if len(messages) == 1: 68 | send_emails.retry(exc=e) 69 | else: 70 | send_emails.delay([message], **kwargs) 71 | 72 | conn.close() 73 | return num 74 | -------------------------------------------------------------------------------- /djcelery_ses/test_runner.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | from django.conf import settings 4 | from django.test.runner import DiscoverRunner 5 | 6 | from celery import current_app 7 | 8 | 9 | USAGE = """\ 10 | Custom test runner to allow testing of celery delayed tasks. 11 | """ 12 | 13 | 14 | def _set_eager(): 15 | settings.task_always_eager = True 16 | current_app.conf.task_always_eager = True 17 | settings.task_eager_propagates = True # Issue #75 18 | current_app.conf.task_eager_propagates = True 19 | 20 | def _set_pickle(): 21 | settings.task_serializer="pickle" 22 | current_app.conf.task_serializer="pickle" 23 | settings.accept_content=["pickle", "json"] 24 | current_app.conf.accept_content=["pickle", "json"] 25 | 26 | 27 | class CeleryTestSuiteRunner(DiscoverRunner): 28 | """Django test runner allowing testing of celery delayed tasks. 29 | 30 | All tasks are run locally, not in a worker. 31 | 32 | To use this runner set ``settings.TEST_RUNNER``:: 33 | 34 | TEST_RUNNER = 'djcelery_ses.test_runner.CeleryTestSuiteRunner' 35 | 36 | """ 37 | def setup_test_environment(self, **kwargs): 38 | _set_eager() 39 | _set_pickle() 40 | super().setup_test_environment(**kwargs) 41 | -------------------------------------------------------------------------------- /djcelery_ses/tests.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | 3 | from django.test import TestCase 4 | from django.core import mail 5 | from django.core.mail import EmailMessage 6 | from django.test.utils import override_settings 7 | 8 | from .models import Blacklist 9 | from .utils import pass_blacklist, no_delay 10 | 11 | 12 | @override_settings( 13 | EMAIL_BACKEND='djcelery_ses.backends.CeleryEmailBackend', 14 | CELERY_EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend', 15 | ) 16 | class DjcelerySESTest(TestCase): 17 | def test_send_mail(self): 18 | msg = EmailMessage('title', 'body content', 'noreply@example.com', ['noreply@example.com']) 19 | msg.send() 20 | 21 | self.assertEqual(len(mail.outbox), 1) 22 | 23 | def test_blacklist(self): 24 | # Add `noreply@example.com` to Blacklist 25 | Blacklist.objects.create(email='noreply@example.com', type=0) 26 | 27 | # Send email to `noreply@example.com` 28 | msg = EmailMessage('title', 'body content', 'noreply@example.com', ['noreply@example.com']) 29 | msg.send() 30 | 31 | # should be no email in outbox 32 | self.assertEqual(len(mail.outbox), 0) 33 | 34 | def test_pass_blacklist(self): 35 | # Add `noreply@example.com` to Blacklist 36 | Blacklist.objects.create(email='noreply@example.com', type=0) 37 | 38 | # Send email to `noreply@example.com` 39 | with pass_blacklist: 40 | msg = EmailMessage('title', 'body content', 'noreply@example.com', ['noreply@example.com']) 41 | msg.send() 42 | 43 | # should be one email in outbox 44 | self.assertEqual(len(mail.outbox), 1) 45 | 46 | def test_no_delay(self): 47 | with no_delay: 48 | msg = EmailMessage('title', 'body content', 'noreply@example.com', ['noreply@example.com']) 49 | msg.send() 50 | 51 | self.assertEqual(len(mail.outbox), 1) 52 | 53 | class SNSNotificationTest(TestCase): 54 | urls = 'djcelery_ses.urls' 55 | 56 | def test_notification(self): 57 | PROJECT_ROOT = os.path.dirname(os.path.realpath(__file__)) 58 | FIXTURE_DIRS = os.path.join(PROJECT_ROOT, 'fixtures') 59 | 60 | with open(os.path.join(FIXTURE_DIRS, 'sns.json')) as f: 61 | content = f.read() 62 | 63 | self.client.post('/sns_notification/', content, content_type="application/json") 64 | 65 | self.assertEqual(Blacklist.objects.count(), 1) 66 | 67 | def test_error_notification(self): 68 | resp = self.client.post('/sns_notification/', 'hello', content_type="application/json") 69 | self.assertEqual(resp.content.decode(), 'Invalid JSON') 70 | self.assertEqual(resp.status_code, 400) 71 | 72 | 73 | def test_subscription(self): 74 | PROJECT_ROOT = os.path.dirname(os.path.realpath(__file__)) 75 | FIXTURE_DIRS = os.path.join(PROJECT_ROOT, 'fixtures') 76 | 77 | with open(os.path.join(FIXTURE_DIRS, 'subscription.json')) as f: 78 | content = f.read() 79 | 80 | self.client.post('/sns_notification/', content, content_type="application/json") 81 | 82 | -------------------------------------------------------------------------------- /djcelery_ses/urls.py: -------------------------------------------------------------------------------- 1 | 2 | import django 3 | 4 | 5 | if django.VERSION >= (3, 1): 6 | from django.urls import re_path 7 | from djcelery_ses import views 8 | 9 | urlpatterns = [ 10 | re_path(r'^sns_notification/$', views.sns_notification), 11 | ] 12 | 13 | elif django.VERSION >= (1, 9): 14 | from django.conf.urls import url 15 | from djcelery_ses import views 16 | 17 | urlpatterns = [ 18 | url(r'^sns_notification/$', views.sns_notification), 19 | ] 20 | 21 | else: 22 | from django.conf.urls import url, patterns 23 | urlpatterns = patterns( 24 | 'djcelery_ses.views', 25 | url(r'^sns_notification/$', 'sns_notification'), 26 | ) 27 | -------------------------------------------------------------------------------- /djcelery_ses/utils.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | class PassBlacklist(object): 4 | def __enter__(self): 5 | settings.DJCELERY_SES_CHECK_BLACKLIST = False 6 | 7 | def __exit__(self, type, value, tb): 8 | settings.DJCELERY_SES_CHECK_BLACKLIST = True 9 | 10 | pass_blacklist = PassBlacklist() 11 | 12 | 13 | class NoDelay(object): 14 | def __enter__(self): 15 | settings.DJCELERY_SES_NO_DELAY = True 16 | 17 | def __exit__(self, type, value, tb): 18 | settings.DJCELERY_SES_NO_DELAY = False 19 | 20 | no_delay = NoDelay() 21 | -------------------------------------------------------------------------------- /djcelery_ses/views.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import json 3 | import re 4 | 5 | from django.http import HttpResponse, HttpResponseBadRequest 6 | from django.views.decorators.csrf import csrf_exempt 7 | from django.core.mail import mail_admins 8 | from django.core.validators import validate_email 9 | from django.core.exceptions import ValidationError 10 | 11 | from .models import Blacklist 12 | 13 | 14 | @csrf_exempt 15 | def sns_notification(request): 16 | """ 17 | Receive AWS SES bounce SNS notification 18 | """ 19 | 20 | # decode json 21 | try: 22 | data = json.loads(request.read()) 23 | except ValueError: 24 | return HttpResponseBadRequest('Invalid JSON') 25 | 26 | # handle SNS subscription 27 | if data['Type'] == 'SubscriptionConfirmation': 28 | subscribe_url = data['SubscribeURL'] 29 | subscribe_body = """ 30 | Please visit this URL below to confirm your subscription with SNS 31 | 32 | %s """ % subscribe_url 33 | 34 | mail_admins('Please confirm SNS subscription', subscribe_body) 35 | return HttpResponse('OK') 36 | 37 | try: 38 | message = json.loads(data['Message']) 39 | except ValueError: 40 | assert False, data['Message'] 41 | 42 | notification_type = message['notificationType'] 43 | if notification_type not in dict(Blacklist.TYPE_CHOICES).values(): 44 | return HttpResponse('No Email') 45 | 46 | type = 0 if notification_type == 'Bounce' else 1 47 | email = message['mail']['destination'][0] 48 | 49 | try: 50 | validate_email(email) 51 | except ValidationError: 52 | try: 53 | email = re.findall(r"<(.+?)>", email)[0] 54 | except IndexError: 55 | email = None 56 | 57 | if not email: 58 | return HttpResponse('Email Error') 59 | 60 | # add email to blacklist 61 | Blacklist.objects.get_or_create(email=email, defaults={"type": type}) 62 | 63 | return HttpResponse('Done') 64 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import sys 4 | from os.path import dirname, abspath 5 | 6 | from django.conf import settings 7 | import django 8 | 9 | settings.configure( 10 | DATABASES = { 11 | 'default': { 12 | 'ENGINE': 'django.db.backends.sqlite3', 13 | 'NAME': ':memory:' 14 | } 15 | }, 16 | MIDDLEWARE=( 17 | "django.contrib.sessions.middleware.SessionMiddleware", 18 | "django.contrib.auth.middleware.AuthenticationMiddleware", 19 | "django.contrib.messages.middleware.MessageMiddleware", 20 | ), 21 | TEMPLATES=[ 22 | { 23 | "BACKEND": "django.template.backends.django.DjangoTemplates", 24 | "OPTIONS": { 25 | "context_processors": [ 26 | "django.contrib.auth.context_processors.auth", 27 | "django.contrib.messages.context_processors.messages", 28 | ], 29 | } 30 | } 31 | ], 32 | INSTALLED_APPS=[ 33 | 'django.contrib.admin', 34 | 'django.contrib.auth', 35 | 'django.contrib.contenttypes', 36 | 'django.contrib.sessions', 37 | 'django.contrib.sites', 38 | "django.contrib.messages", 39 | 'djcelery_ses', 40 | ], 41 | SITE_ID=1, 42 | DEBUG=False, 43 | ROOT_URLCONF='djcelery_ses.urls', 44 | CELERY_EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend', 45 | TEST_RUNNER='djcelery_ses.test_runner.CeleryTestSuiteRunner', 46 | SECRET_KEY='secret', 47 | ) 48 | 49 | 50 | 51 | def runtests(**test_args): 52 | from django.test.utils import get_runner 53 | django.setup() 54 | 55 | parent = dirname(abspath(__file__)) 56 | sys.path.insert(0, parent) 57 | 58 | TestRunner = get_runner(settings) 59 | test_runner = TestRunner(verbosity=1, interactive=True) 60 | failures = test_runner.run_tests(['djcelery_ses'], test_args) 61 | sys.exit(failures) 62 | 63 | 64 | if __name__ == '__main__': 65 | runtests(*sys.argv[1:]) 66 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | from djcelery_ses import __version__ 3 | 4 | 5 | setup( 6 | name='django-celery-ses', 7 | version=__version__, 8 | description="django-celery-ses", 9 | author='tzangms', 10 | author_email='tzangms@streetvoice.com', 11 | url='http://github.com/StreetVoice/django-celery-ses', 12 | license='MIT', 13 | test_suite='runtests.runtests', 14 | packages=find_packages(), 15 | include_package_data=True, 16 | zip_safe=False, 17 | install_requires=[ 18 | "django >= 1.10, <= 3.2.23", 19 | ], 20 | classifiers=[ 21 | 'Framework :: Django :: 3.0', 22 | 'Framework :: Django :: 3.1', 23 | 'Framework :: Django :: 3.2', 24 | 'Programming Language :: Python', 25 | 'Programming Language :: Python :: 3', 26 | 'Programming Language :: Python :: 3.3', 27 | 'Programming Language :: Python :: 3.4', 28 | 'Programming Language :: Python :: 3.5', 29 | 'Programming Language :: Python :: 3.6', 30 | 'Programming Language :: Python :: 3.7', 31 | 'Programming Language :: Python :: 3.8', 32 | 'Topic :: Software Development :: Libraries :: Python Modules', 33 | 'Framework :: Django', 34 | 'Environment :: Web Environment', 35 | ], 36 | keywords='django,celery,mail', 37 | ) 38 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{36,37}-dj{18,19,110,111} 4 | py{37,38}-dj{111,20,21,22,30,31,32} 5 | skipsdist=True 6 | 7 | [testenv] 8 | basepython = 9 | py36: python3.6 10 | py37: python3.7 11 | py38: python3.8 12 | deps = 13 | pytest 14 | dj110: django>=1.10,<1.11 15 | dj111: django>=1.11,<2.0 16 | dj20: django>=2.0,<2.1 17 | dj21: django>=2.1,<2.2 18 | dj22: django>=2.2,<3.0 19 | dj30: django>=3.0,<3.1 20 | dj31: django>=3.1,<3.2 21 | dj32: django>=3.2,<4.0 22 | commands = python setup.py test 23 | --------------------------------------------------------------------------------