diff --git a/.gitignore b/.gitignore index 306acc2..6d2c1f9 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,4 @@ venv/ # Mac OSX .DS_Store +.idea diff --git a/.travis.yml b/.travis.yml index 491a49e..e1264f9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,8 +2,14 @@ language: python python: - "2.7" - "3.4" +env: + - DJANGO_VERSION=1.7 + - DJANGO_VERSION=1.8 + - DJANGO_VERSION=1.9b1 install: - pip install -r dev-requirements.txt + - pip uninstall django --yes + - pip install -q django==$DJANGO_VERSION - pip install coveralls - pip install -e . before_script: diff --git a/README.rst b/README.rst index 7b99ec3..98aa200 100644 --- a/README.rst +++ b/README.rst @@ -79,6 +79,16 @@ Here at SparkPost, our messages are known as transmissions. Let's use the underl .. _transmissions API: https://www.sparkpost.com/api#/reference/transmissions +Django Integration +------------------ +The SparkPost python library comes with an email backend for Django. Put the following configuration in `settings.py` file. + +.. code-block:: python + + SPARKPOST_API_KEY = 'API_KEY' + EMAIL_BACKEND = 'sparkpost.django.email_backend.SparkPostEmailBackend' + +Replace *API_KEY* with an actual API key that you've generated in `Get a Key`_ section. Documentation ------------- diff --git a/dev-requirements.txt b/dev-requirements.txt index f09f5b5..b445d41 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -5,3 +5,4 @@ requests==2.5.1 responses==0.3.0 wheel twine +Django>=1.8,<1.9 \ No newline at end of file diff --git a/docs/django/backend.rst b/docs/django/backend.rst new file mode 100644 index 0000000..ee0544d --- /dev/null +++ b/docs/django/backend.rst @@ -0,0 +1,56 @@ +Django Email Backend +==================== + +The SparkPost python library comes with an email backend for Django. + +Configure Django +---------------- + +To configure Django to use SparkPost, put the following configuration in `settings.py` file. + +.. code-block:: python + + SPARKPOST_API_KEY = 'API_KEY' + EMAIL_BACKEND = 'sparkpost.django.email_backend.SparkPostEmailBackend' + +Replace *API_KEY* with an actual API key. + + +Sending an email +---------------- + +Django is now configured to use the SparkPost email backend. You can now send mail using Django's `send_mail` method: + +.. code-block:: python + + from django.core.mail import send_mail + + send_mail( + subject='hello from sparkpost', + message='Hello Rock stars!' + from_email='from@yourdomain.com', + recipient_list=['to@friendsdomain.com'], + html_message='

Hello Rock stars!

', + ) + + +Supported version +----------------- +SparkPost will support all versions of Django that are within extended support period. Refer to `Django Supported_Version`_. + +Current supported versions are: + * 1.7 + * 1.8 + * 1.9b1 + + +.. _Django Supported_Version: https://www.djangoproject.com/download/#supported-versions + + +Additional documentation +------------------------ + +See our `Using SparkPost with Django`_ in support article. + +.. _Using SparkPost with Django: https://support.sparkpost.com/customer/en/portal/articles/2169630-using-sparkpost-with-django?b_id=7411 + diff --git a/docs/index.rst b/docs/index.rst index 40a5f41..b19fec8 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -58,6 +58,16 @@ Auto-generated API reference for python-sparkpost: api +Using in Django +--------------- + +Configure Django to use SparkPost email backend + +.. toctree:: + :maxdepth: 2 + + django/backend + Additional documentation ------------------------ diff --git a/sparkpost/django/__init__.py b/sparkpost/django/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sparkpost/django/email_backend.py b/sparkpost/django/email_backend.py new file mode 100644 index 0000000..73ad412 --- /dev/null +++ b/sparkpost/django/email_backend.py @@ -0,0 +1,76 @@ +from django.conf import settings +from django.core.mail.backends.base import BaseEmailBackend + +from sparkpost import SparkPost + +from .exceptions import UnsupportedContent +from .exceptions import UnsupportedParam + + +class SparkPostEmailBackend(BaseEmailBackend): + """ + SparkPost wrapper for Django email backend + """ + + def __init__(self, fail_silently=False, **kwargs): + super(SparkPostEmailBackend, self)\ + .__init__(fail_silently=fail_silently, **kwargs) + + sp_api_key = getattr(settings, 'SPARKPOST_API_KEY', None) + + self.client = SparkPost(sp_api_key) + + def send_messages(self, email_messages): + """ + Send emails, returns integer representing number of successful emails + """ + success = 0 + for message in email_messages: + try: + response = self._send(message) + success += response['total_accepted_recipients'] + except Exception: + if not self.fail_silently: + raise + return success + + def _send(self, message): + self.check_unsupported(message) + self.check_attachments(message) + + params = dict( + recipients=message.to, + text=message.body, + from_email=message.from_email, + subject=message.subject + ) + + if hasattr(message, 'alternatives') and len(message.alternatives) > 0: + for alternative in message.alternatives: + + if alternative[1] == 'text/html': + params['html'] = alternative[0] + else: + raise UnsupportedContent( + 'Content type %s is not supported' % alternative[1] + ) + + return self.client.transmissions.send(**params) + + @staticmethod + def check_attachments(message): + if len(message.attachments): + raise UnsupportedContent( + 'The SparkPost Django email backend does not ' + 'currently support attachment.' + ) + + @staticmethod + def check_unsupported(message): + unsupported_params = ['cc', 'bcc', 'reply_to'] + for param in unsupported_params: + if len(getattr(message, param, [])): + raise UnsupportedParam( + 'The SparkPost Django email backend does not currently ' + 'support %s.' % param + ) diff --git a/sparkpost/django/exceptions.py b/sparkpost/django/exceptions.py new file mode 100644 index 0000000..bda998a --- /dev/null +++ b/sparkpost/django/exceptions.py @@ -0,0 +1,6 @@ +class UnsupportedContent(Exception): + pass + + +class UnsupportedParam(Exception): + pass diff --git a/test/django/test_email_backend.py b/test/django/test_email_backend.py new file mode 100644 index 0000000..90ab4fe --- /dev/null +++ b/test/django/test_email_backend.py @@ -0,0 +1,208 @@ +import pytest +import mock +from distutils.version import StrictVersion + +from django import get_version +from django.conf import settings +from django.core.mail import send_mail +from django.core.mail import send_mass_mail +from django.core.mail import EmailMessage +from django.core.mail import EmailMultiAlternatives + +from sparkpost.django.email_backend import SparkPostEmailBackend +from sparkpost.django.exceptions import UnsupportedParam +from sparkpost.django.exceptions import UnsupportedContent +from sparkpost.transmissions import Transmissions + +try: + from StringIO import StringIO +except ImportError: + from io import StringIO + +API_KEY = 'API_Key' + +settings.configure( + DEBUG=True, + EMAIL_BACKEND='sparkpost.django.email_backend.SparkPostEmailBackend', + SPARKPOST_API_KEY=API_KEY +) + + +def at_least_version(version): + return StrictVersion(get_version()) > StrictVersion(version) + + +def get_params(overrides=None): + if overrides is None: + overrides = {} + + defaults = { + 'subject': 'test subject', + 'message': 'test body', + 'from_email': 'from@example.com', + 'recipient_list': ['to@example.com'], + } + + params = defaults.copy() + params.update(overrides) + return params + + +def mailer(params): + return send_mail(**params) + + +def test_password_retrieval(): + backend = SparkPostEmailBackend() + assert backend.client.api_key == API_KEY + + +def test_fail_silently(): + # should not raise + with mock.patch.object(Transmissions, 'send') as mock_send: + mock_send.side_effect = Exception('i should not be raised') + mailer(get_params({'fail_silently': True})) + + # should raise + with mock.patch.object(Transmissions, 'send') as mock_send: + mock_send.side_effect = Exception('i should be raised') + with pytest.raises(Exception): + mailer(get_params()) + + +def test_successful_sending(): + with mock.patch.object(Transmissions, 'send') as mock_send: + mock_send.return_value = {'total_accepted_recipients': 1, + 'total_rejected_recipients': 2} + + result = mailer(get_params({ + 'recipient_list': ['to1@example.com', 'to2@example.com'], + 'fail_silently': True + })) + assert result == 1 + + with mock.patch.object(Transmissions, 'send') as mock_send: + mock_send.return_value = {'total_accepted_recipients': 10, + 'total_rejected_recipients': 2} + + result = mailer( + get_params( + {'recipient_list': ['to1@example.com', 'to2@example.com'], + 'fail_silently': True + } + )) + + assert result == 10 + + +def test_send_number_of_emails_correctly(): + with mock.patch.object(Transmissions, 'send') as mock_send: + mailer(get_params({ + 'recipient_list': ['to1@example.com', 'to2@example.com'], + 'fail_silently': True + })) + assert mock_send.call_count == 1 + + with mock.patch.object(Transmissions, 'send') as mock_send: + message1 = ('message 1 subject', 'message 1 body', 'from@example.com', + ['to1@example.com']) + message2 = ('message 2 subject', 'message 2 body', 'from@example.com', + ['to2@example.com']) + message3 = ('message 3 subject', 'message 3 body', 'from@example.com', + ['to3@example.com']) + + send_mass_mail((message1, message2, message3), fail_silently=False) + assert mock_send.call_count == 3 + + +def test_params(): + recipients = ['to1@example.com', 'to2@example.com'] + with mock.patch.object(Transmissions, 'send'): + mailer(get_params( + {'recipient_list': recipients, + 'fail_silently': True + } + )) + + Transmissions.send.assert_called_with(recipients=recipients, + text='test body', + from_email='from@example.com', + subject='test subject' + ) + + +def test_content_types(): + def new_send(**kwargs): + assert kwargs['text'] == 'hello there' + assert kwargs['html'] == '

Hello There

' + + return { + 'total_accepted_recipients': 0, + 'total_rejected_recipients': 0 + } + + with mock.patch.object(Transmissions, 'send') as mock_send: + mock_send.side_effect = new_send + send_mail( + 'test subject', + 'hello there', + 'from@example.com', + ['to@example.com'], + html_message='

Hello There

' + ) + + +def test_unsupported_content_types(): + params = get_params() + + with pytest.raises(UnsupportedContent): + mail = EmailMultiAlternatives( + params['subject'], + 'plain text', + params['from_email'], + params['recipient_list']) + mail.attach_alternative('non-plain content', 'text/alien') + mail.send() + + +def test_attachment(): + params = get_params() + params['body'] = params.pop('message') + params['to'] = params.pop('recipient_list') + + attachment = StringIO() + attachment.write('hello file') + email = EmailMessage(**params) + email.attach('file.txt', attachment, 'text/plain') + + with pytest.raises(UnsupportedContent): + email.send() + + +def test_cc_bcc_reply_to(): + params = get_params({ + 'cc': ['cc1@example.com', 'cc2@example.com'] + }) + params['body'] = params.pop('message') + params['to'] = params.pop('recipient_list') + + # test cc exception + with pytest.raises(UnsupportedParam): + email = EmailMessage(**params) + email.send() + params.pop('cc') + + # test bcc exception + params['bcc'] = ['bcc1@example.com', 'bcc1@example.com'] + with pytest.raises(UnsupportedParam): + email = EmailMessage(**params) + email.send() + params.pop('bcc') + + if at_least_version('1.8'): # reply_to is supported from django 1.8 + # test reply_to exception + params['reply_to'] = ['devnull@example.com'] + with pytest.raises(UnsupportedParam): + email = EmailMessage(**params) + email.send() + params.pop('reply_to')