├── djcelery_email ├── models.py ├── __init__.py ├── conf.py ├── __about__.py ├── backends.py ├── tasks.py └── utils.py ├── setup.cfg ├── tests ├── image.png ├── __init__.py ├── settings.py └── tests.py ├── requirements.txt ├── MANIFEST.in ├── release.sh ├── runtests.py ├── .gitignore ├── .github └── workflows │ ├── test.yml │ └── python-publish.yml ├── tox.ini ├── LICENSE ├── setup.py ├── README.rst └── CHANGELOGS.md /djcelery_email/models.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 0 3 | -------------------------------------------------------------------------------- /djcelery_email/__init__.py: -------------------------------------------------------------------------------- 1 | from .__about__ import * # noqa 2 | -------------------------------------------------------------------------------- /tests/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/panevo/django-celery-email-reboot/HEAD/tests/image.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django>=3.2,<5.3 2 | celery>=5.2,<6.0 3 | django-appconf 4 | flake8 5 | twine 6 | wheel 7 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include LICENSE 3 | include MANIFEST.in 4 | include requirements.txt 5 | recursive-include tests *.py 6 | recursive-include djcelery_email *.py 7 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # steps to release the lib to pypi. 4 | # only do this after a test build. 5 | 6 | rm -rf dist build *.egg-info 7 | python setup.py sdist bdist_wheel 8 | twine upload -s dist/* 9 | -------------------------------------------------------------------------------- /djcelery_email/conf.py: -------------------------------------------------------------------------------- 1 | from appconf import AppConf 2 | 3 | 4 | class DjangoCeleryEmailAppConf(AppConf): 5 | class Meta: 6 | prefix = 'CELERY_EMAIL' 7 | 8 | TASK_CONFIG = {} 9 | BACKEND = 'django.core.mail.backends.smtp.EmailBackend' 10 | CHUNK_SIZE = 10 11 | MESSAGE_EXTRA_ATTRIBUTES = None 12 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | import django 6 | 7 | from tests import DJCETestSuiteRunner 8 | 9 | if __name__ == "__main__": 10 | os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.settings' 11 | django.setup() 12 | failures = DJCETestSuiteRunner().run_tests(["tests"]) 13 | sys.exit(bool(failures)) 14 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | from django.test.runner import DiscoverRunner 2 | 3 | 4 | class DJCETestSuiteRunner(DiscoverRunner): 5 | def setup_test_environment(self, **kwargs): 6 | # have to do this here as the default test runner overrides EMAIL_BACKEND 7 | super(DJCETestSuiteRunner, self).setup_test_environment(**kwargs) 8 | 9 | from django.conf import settings 10 | settings.EMAIL_BACKEND = 'djcelery_email.backends.CeleryEmailBackend' 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | __pycache__ 21 | 22 | # Installer logs 23 | pip-log.txt 24 | 25 | # Unit test / coverage reports 26 | .coverage 27 | .tox 28 | nosetests.xml 29 | 30 | # Translations 31 | *.mo 32 | 33 | # Mr Developer 34 | .mr.developer.cfg 35 | .project 36 | .pydevproject 37 | 38 | # Python virtual environments 39 | .venv/ 40 | env/ 41 | venv/ 42 | 43 | # IDE configs 44 | .vscode/ -------------------------------------------------------------------------------- /djcelery_email/__about__.py: -------------------------------------------------------------------------------- 1 | __all__ = [ 2 | '__title__', '__summary__', '__uri__', '__version__', '__author__', 3 | '__email__', '__license__', '__copyright__', 4 | ] 5 | 6 | __title__ = 'django-celery-email-reboot' 7 | __summary__ = 'An async Django email backend using celery (forked from django-celery-email)' 8 | __uri__ = 'https://github.com/Panevo/django-celery-email' 9 | 10 | __version__ = '4.2.0' 11 | 12 | __originalauthor__ = 'Paul McLanahan' 13 | __author__ = 'Karlo Krakan' 14 | __email__ = 'karlo.krakan@panevo.com' 15 | 16 | __license__ = 'BSD' 17 | __copyright__ = 'Copyright 2015 {0}'.format(__originalauthor__) 18 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | - pull_request 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v4 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | python -m pip install tox tox-gh-actions 23 | - name: Test with tox 24 | run: tox 25 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | DATABASES = { 2 | 'default': { 3 | 'ENGINE': 'django.db.backends.sqlite3', 4 | 'NAME': ':memory:', 5 | } 6 | } 7 | 8 | 9 | INSTALLED_APPS = ( 10 | 'djcelery_email', 11 | 'appconf', 12 | ) 13 | 14 | SECRET_KEY = 'unique snowflake' 15 | 16 | # Django 1.7 throws dire warnings if this is not set. 17 | # We don't actually use any middleware, given that there are no views. 18 | MIDDLEWARE_CLASSES = () 19 | 20 | CELERY_EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend' 21 | CELERY_EMAIL_TASK_CONFIG = { 22 | 'queue': 'django_email', 23 | 'delivery_mode': 1, # non persistent 24 | 'rate_limit': '50/m', # 50 chunks per minute 25 | } 26 | -------------------------------------------------------------------------------- /djcelery_email/backends.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.mail.backends.base import BaseEmailBackend 3 | 4 | from djcelery_email.tasks import send_emails 5 | from djcelery_email.utils import chunked, email_to_dict 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): 14 | result_tasks = [] 15 | for chunk in chunked(email_messages, settings.CELERY_EMAIL_CHUNK_SIZE): 16 | chunk_messages = [email_to_dict(msg) for msg in chunk] 17 | result_tasks.append(send_emails.delay(chunk_messages, self.init_kwargs)) 18 | return result_tasks 19 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{310}-dj{40,41,42,50}-celery{52,53,54,55,56}, 4 | py{311}-dj{41,42,50,51,52}-celery{52,53,54,55,56}, 5 | py{312}-dj{42,50,51,52,60}-celery{53,54,55,56}, 6 | py{313}-dj{51,52,60}-celery{53,54,55,56}, 7 | py{314}-dj{52,60}-celery{53,54,55,56}, 8 | flake8 9 | skip_missing_interpreters = true 10 | 11 | [testenv] 12 | whitelist_externals = python 13 | commands = python ./runtests.py 14 | deps = 15 | dj32: Django>=3.2,<3.3 16 | dj40: Django>=4.0,<4.1 17 | dj41: Django>=4.1,<4.2 18 | dj42: Django>=4.2,<4.3 19 | dj50: Django>=5.0,<5.1 20 | dj51: Django>=5.1,<5.2 21 | dj52: Django>=5.2,<5.3 22 | dj60: Django>=6.0,<6.1 23 | celery52: celery>=5.2,<5.3 24 | celery53: celery>=5.3,<5.4 25 | celery54: celery>=5.4,<5.5 26 | celery55: celery>=5.5,<5.6 27 | celery56: celery>=5.6,<5.7 28 | 29 | [testenv:flake8] 30 | deps = flake8 31 | commands = flake8 djcelery_email tests 32 | 33 | [flake8] 34 | max-line-length = 120 35 | 36 | [gh-actions] 37 | python = 38 | 3.14: py314, flake8 39 | 3.13: py313, flake8 40 | 3.12: py312, flake8 41 | 3.11: py311 42 | 3.10: py310 43 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | deploy: 20 | 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | - name: Set up Python 26 | uses: actions/setup-python@v3 27 | with: 28 | python-version: '3.x' 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | pip install build 33 | - name: Build package 34 | run: python -m build 35 | - name: Publish package 36 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 37 | with: 38 | user: __token__ 39 | password: ${{ secrets.PYPI_API_TOKEN }} 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010, Paul McLanahan 2 | All rights reserved. 3 | 4 | Additions Copyright (c) 2024, Panevo Services Ltd 5 | All rights reserved. 6 | 7 | Redistribution and use in source and binary forms, with or without modification, 8 | are permitted provided that the following conditions are met: 9 | 10 | * Redistributions of source code must retain the above copyright notice, this 11 | list of conditions and the following disclaimer. 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | * Neither the name of Paul McLanahan nor the names of any contributors may be 16 | used to endorse or promote products derived from this software without 17 | specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 20 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 21 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 23 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 24 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 25 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 26 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 28 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /djcelery_email/tasks.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.mail import EmailMessage, get_connection 3 | 4 | from celery import shared_task 5 | 6 | # Make sure our AppConf is loaded properly. 7 | import djcelery_email.conf # noqa 8 | from djcelery_email.utils import dict_to_email, email_to_dict 9 | 10 | # Messages *must* be dicts, not instances of the EmailMessage class 11 | # This is because we expect Celery to use JSON encoding, and we want to prevent 12 | # code assuming otherwise. 13 | 14 | TASK_CONFIG = {'name': 'djcelery_email_send_multiple', 'ignore_result': True} 15 | TASK_CONFIG.update(settings.CELERY_EMAIL_TASK_CONFIG) 16 | 17 | # import base if string to allow a base celery task 18 | if 'base' in TASK_CONFIG and isinstance(TASK_CONFIG['base'], str): 19 | from django.utils.module_loading import import_string 20 | TASK_CONFIG['base'] = import_string(TASK_CONFIG['base']) 21 | 22 | 23 | @shared_task(**TASK_CONFIG) 24 | def send_emails(messages, backend_kwargs=None, **kwargs): 25 | # backward compat: handle **kwargs and missing backend_kwargs 26 | combined_kwargs = {} 27 | if backend_kwargs is not None: 28 | combined_kwargs.update(backend_kwargs) 29 | combined_kwargs.update(kwargs) 30 | 31 | # backward compat: catch single object or dict 32 | if isinstance(messages, (EmailMessage, dict)): 33 | messages = [messages] 34 | 35 | # make sure they're all dicts 36 | messages = [email_to_dict(m) for m in messages] 37 | 38 | conn = get_connection(backend=settings.CELERY_EMAIL_BACKEND, **combined_kwargs) 39 | try: 40 | conn.open() 41 | except Exception: 42 | logger.exception("Cannot reach CELERY_EMAIL_BACKEND %s", settings.CELERY_EMAIL_BACKEND) 43 | 44 | messages_sent = 0 45 | 46 | for message in messages: 47 | try: 48 | sent = conn.send_messages([dict_to_email(message)]) 49 | if sent is not None: 50 | messages_sent += sent 51 | logger.debug("Successfully sent email message to %r.", message['to']) 52 | except Exception as e: 53 | # Not expecting any specific kind of exception here because it 54 | # could be any number of things, depending on the backend 55 | logger.warning("Failed to send email message to %r, retrying. (%r)", 56 | message['to'], e) 57 | send_emails.retry([[message], combined_kwargs], exc=e, throw=False) 58 | 59 | conn.close() 60 | return messages_sent 61 | 62 | 63 | # backwards compatibility 64 | SendEmailTask = send_email = send_emails 65 | 66 | 67 | try: 68 | from celery.utils.log import get_task_logger 69 | logger = get_task_logger(__name__) 70 | except ImportError: 71 | logger = send_emails.get_logger() 72 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import codecs 4 | 5 | from setuptools import setup, find_packages 6 | 7 | 8 | base_dir = os.path.dirname(__file__) 9 | 10 | with codecs.open(os.path.join(base_dir, 'README.rst'), 'r', encoding='utf8') as f: 11 | long_description = f.read() 12 | 13 | about = {} 14 | with open(os.path.join(base_dir, 'djcelery_email', '__about__.py')) as f: 15 | exec(f.read(), about) 16 | 17 | 18 | setup( 19 | name=about['__title__'], 20 | version=about['__version__'], 21 | description=about['__summary__'], 22 | long_description=long_description, 23 | long_description_content_type='text/x-rst', 24 | license=about['__license__'], 25 | url=about['__uri__'], 26 | author=about['__author__'], 27 | author_email=about['__email__'], 28 | platforms=['any'], 29 | packages=find_packages(exclude=['ez_setup', 'tests']), 30 | scripts=[], 31 | zip_safe=False, 32 | python_requires='>=3.10,<3.15', 33 | install_requires=[ 34 | # Celery for Python 3.10 and 3.11 35 | "celery>=5.2,<5.7; python_version >= '3.10' and python_version <= '3.11'", 36 | # Celery for Python 3.12, 3.13 and 3.14 37 | "celery>=5.3,<5.7; python_version >= '3.12'", 38 | # Django for Python 3.10 39 | "Django>=4.0,<5.1; python_version == '3.10'", 40 | # Django for Python 3.11 41 | "Django>=4.1,<5.3; python_version == '3.11'", 42 | # Django for Python 3.12, 3.13 and 3.14 43 | "Django>=4.2,<6.1; python_version >= '3.12'", 44 | # django-appconf 45 | "django-appconf", 46 | ], 47 | classifiers=[ 48 | 'Development Status :: 5 - Production/Stable', 49 | 'Framework :: Django', 50 | 'Framework :: Django :: 4.0', 51 | 'Framework :: Django :: 4.1', 52 | 'Framework :: Django :: 4.2', 53 | 'Framework :: Django :: 5.0', 54 | 'Framework :: Django :: 5.1', 55 | 'Framework :: Django :: 5.2', 56 | 'Framework :: Django :: 6.0', 57 | 'Operating System :: OS Independent', 58 | 'Programming Language :: Python', 59 | 'Programming Language :: Python :: 3', 60 | 'Programming Language :: Python :: 3.10', 61 | 'Programming Language :: Python :: 3.11', 62 | 'Programming Language :: Python :: 3.12', 63 | 'Programming Language :: Python :: 3.13', 64 | 'Programming Language :: Python :: 3.14', 65 | 'Intended Audience :: Developers', 66 | 'License :: OSI Approved :: BSD License', 67 | 'Operating System :: POSIX', 68 | 'Topic :: Communications', 69 | 'Topic :: Communications :: Email', 70 | 'Topic :: System :: Distributed Computing', 71 | 'Topic :: Software Development :: Libraries :: Python Modules', 72 | ], 73 | ) 74 | -------------------------------------------------------------------------------- /djcelery_email/utils.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import base64 3 | from email.mime.base import MIMEBase 4 | 5 | from django.conf import settings 6 | from django.core.mail import EmailMultiAlternatives, EmailMessage 7 | 8 | 9 | def chunked(iterator, chunksize): 10 | """ 11 | Yields items from 'iterator' in chunks of size 'chunksize'. 12 | 13 | >>> list(chunked([1, 2, 3, 4, 5], chunksize=2)) 14 | [(1, 2), (3, 4), (5,)] 15 | """ 16 | chunk = [] 17 | for idx, item in enumerate(iterator, 1): 18 | chunk.append(item) 19 | if idx % chunksize == 0: 20 | yield chunk 21 | chunk = [] 22 | if chunk: 23 | yield chunk 24 | 25 | 26 | def email_to_dict(message): 27 | if isinstance(message, dict): 28 | return message 29 | 30 | message_dict = {'subject': message.subject, 31 | 'body': message.body, 32 | 'from_email': message.from_email, 33 | 'to': message.to, 34 | 'bcc': message.bcc, 35 | # ignore connection 36 | 'attachments': [], 37 | 'headers': message.extra_headers, 38 | 'cc': message.cc, 39 | 'reply_to': message.reply_to} 40 | 41 | if hasattr(message, 'alternatives'): 42 | message_dict['alternatives'] = message.alternatives 43 | if message.content_subtype != EmailMessage.content_subtype: 44 | message_dict["content_subtype"] = message.content_subtype 45 | if hasattr(message, 'mixed_subtype') and message.mixed_subtype != EmailMessage.mixed_subtype: 46 | message_dict["mixed_subtype"] = message.mixed_subtype 47 | 48 | attachments = message.attachments 49 | for attachment in attachments: 50 | if isinstance(attachment, MIMEBase): 51 | filename = attachment.get_filename('') 52 | binary_contents = attachment.get_payload(decode=True) 53 | mimetype = attachment.get_content_type() 54 | else: 55 | filename, binary_contents, mimetype = attachment 56 | # For a mimetype starting with text/, content is expected to be a string. 57 | if isinstance(binary_contents, str): 58 | binary_contents = binary_contents.encode() 59 | contents = base64.b64encode(binary_contents).decode('ascii') 60 | message_dict['attachments'].append((filename, contents, mimetype)) 61 | 62 | if settings.CELERY_EMAIL_MESSAGE_EXTRA_ATTRIBUTES: 63 | for attr in settings.CELERY_EMAIL_MESSAGE_EXTRA_ATTRIBUTES: 64 | if hasattr(message, attr): 65 | message_dict[attr] = getattr(message, attr) 66 | 67 | return message_dict 68 | 69 | 70 | def dict_to_email(messagedict): 71 | message_kwargs = copy.deepcopy(messagedict) # prevents missing items on retry 72 | 73 | # remove items from message_kwargs until only valid EmailMessage/EmailMultiAlternatives kwargs are left 74 | # and save the removed items to be used as EmailMessage/EmailMultiAlternatives attributes later 75 | message_attributes = ['content_subtype', 'mixed_subtype'] 76 | if settings.CELERY_EMAIL_MESSAGE_EXTRA_ATTRIBUTES: 77 | message_attributes.extend(settings.CELERY_EMAIL_MESSAGE_EXTRA_ATTRIBUTES) 78 | attributes_to_copy = {} 79 | for attr in message_attributes: 80 | if attr in message_kwargs: 81 | attributes_to_copy[attr] = message_kwargs.pop(attr) 82 | 83 | # remove attachments from message_kwargs then reinsert after base64 decoding 84 | attachments = message_kwargs.pop('attachments') 85 | message_kwargs['attachments'] = [] 86 | for attachment in attachments: 87 | filename, contents, mimetype = attachment 88 | contents = base64.b64decode(contents.encode('ascii')) 89 | 90 | # For a mimetype starting with text/, content is expected to be a string. 91 | if mimetype and mimetype.startswith('text/'): 92 | contents = contents.decode() 93 | 94 | message_kwargs['attachments'].append((filename, contents, mimetype)) 95 | 96 | if 'alternatives' in message_kwargs: 97 | message = EmailMultiAlternatives(**message_kwargs) 98 | else: 99 | message = EmailMessage(**message_kwargs) 100 | 101 | # set attributes on message with items removed from message_kwargs earlier 102 | # Skip mixed_subtype and alternative_subtype for Django 6.0+ compatibility 103 | for attr, val in attributes_to_copy.items(): 104 | if attr not in ('mixed_subtype', 'alternative_subtype'): 105 | setattr(message, attr, val) 106 | 107 | return message 108 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =========================================================================== 2 | django-celery-email-reboot - A Celery-backed Django Email Backend 3 | =========================================================================== 4 | 5 | .. image:: https://img.shields.io/pypi/v/django-celery-email-reboot.svg 6 | :target: https://pypi.python.org/pypi/django-celery-email-reboot 7 | 8 | A `Django`_ email backend that uses a `Celery`_ queue for out-of-band sending of the messages. 9 | 10 | This is a fork of `django_celery_email`_. As this package has gone unmaintained for some time, we have decided to maintain the package in order to maintain compatibility with future Django versions, starting with Django 4.x and 5.x. 11 | 12 | This new package is available in pypi under the name `django-celery-email-reboot`: https://pypi.org/project/django-celery-email-reboot/ 13 | 14 | .. _`Celery`: http://celeryproject.org/ 15 | .. _`Django`: http://www.djangoproject.org/ 16 | .. _`django_celery_email`: https://github.com/pmclanahan/django-celery-email 17 | 18 | .. warning:: 19 | 20 | This version requires the following versions: 21 | 22 | * Python >= 3.10, < 3.15 23 | * Celery: 24 | 25 | * >= 5.2, < 5.7 for Python 3.10 and 3.11 26 | * >= 5.3, < 5.7 for Python 3.12, 3.13 and 3.14 27 | 28 | * Django: 29 | 30 | * >= 4.0, < 5.1 for Python 3.10 31 | * >= 4.1, < 5.3 for Python 3.11 32 | * >= 4.2, < 5.3 for Python 3.12, 3.13 and 3.14 33 | 34 | Using django-celery-email-reboot 35 | ================================== 36 | 37 | Install from Pypi using:: 38 | 39 | pip install django-celery-email-reboot 40 | 41 | To enable ``django-celery-email-reboot`` for your project you need to add ``djcelery_email`` to 42 | ``INSTALLED_APPS``:: 43 | 44 | INSTALLED_APPS += ("djcelery_email",) 45 | 46 | You must then set ``django-celery-email`` as your ``EMAIL_BACKEND``:: 47 | 48 | EMAIL_BACKEND = 'djcelery_email.backends.CeleryEmailBackend' 49 | 50 | By default ``django-celery-email`` will use Django's builtin ``SMTP`` email backend 51 | for the actual sending of the mail. If you'd like to use another backend, you 52 | may set it in ``CELERY_EMAIL_BACKEND`` just like you would normally have set 53 | ``EMAIL_BACKEND`` before you were using Celery. In fact, the normal installation 54 | procedure will most likely be to get your email working using only Django, then 55 | change ``EMAIL_BACKEND`` to ``CELERY_EMAIL_BACKEND``, and then add the new 56 | ``EMAIL_BACKEND`` setting from above. 57 | 58 | Mass email are sent in chunks of size ``CELERY_EMAIL_CHUNK_SIZE`` (defaults to 10). 59 | 60 | If you need to set any of the settings (attributes) you'd normally be able to set on a 61 | `Celery Task`_ class had you written it yourself, you may specify them in a ``dict`` 62 | in the ``CELERY_EMAIL_TASK_CONFIG`` setting:: 63 | 64 | CELERY_EMAIL_TASK_CONFIG = { 65 | 'queue' : 'email', 66 | 'rate_limit' : '50/m', # * CELERY_EMAIL_CHUNK_SIZE (default: 10) 67 | ... 68 | } 69 | 70 | There are some default settings. Unless you specify otherwise, the equivalent of the 71 | following settings will apply:: 72 | 73 | CELERY_EMAIL_TASK_CONFIG = { 74 | 'name': 'djcelery_email_send', 75 | 'ignore_result': True, 76 | } 77 | 78 | After this setup is complete, and you have a working Celery install, sending 79 | email will work exactly like it did before, except that the sending will be 80 | handled by your Celery workers:: 81 | 82 | from django.core import mail 83 | 84 | emails = ( 85 | ('Hey Man', "I'm The Dude! So that's what you call me.", 'dude@aol.com', ['mr@lebowski.com']), 86 | ('Dammit Walter', "Let's go bowlin'.", 'dude@aol.com', ['wsobchak@vfw.org']), 87 | ) 88 | results = mail.send_mass_mail(emails) 89 | 90 | ``results`` will be a list of celery `AsyncResult`_ objects that you may ignore, or use to check the 91 | status of the email delivery task, or even wait for it to complete if want. You have to enable a result 92 | backend and set ``ignore_result`` to ``False`` in ``CELERY_EMAIL_TASK_CONFIG`` if you want to use these. 93 | You should also set ``CELERY_EMAIL_CHUNK_SIZE = 1`` in settings if you are concerned about task status 94 | and results. 95 | 96 | See the `Celery docs`_ for more info. 97 | 98 | 99 | ``len(results)`` will be the number of emails you attempted to send divided by CELERY_EMAIL_CHUNK_SIZE, and is in no way a reflection on the success or failure 100 | of their delivery. 101 | 102 | .. _`Celery Task`: http://celery.readthedocs.org/en/latest/userguide/tasks.html#basics 103 | .. _`Celery docs`: http://celery.readthedocs.org/en/latest/userguide/tasks.html#task-states 104 | .. _`AsyncResult`: http://celery.readthedocs.org/en/latest/reference/celery.result.html#celery.result.AsyncResult 105 | -------------------------------------------------------------------------------- /CHANGELOGS.md: -------------------------------------------------------------------------------- 1 | # Changelogs 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [4.2.0] 9 | 10 | ### Added 11 | 12 | - Add support for Python 3.14 13 | - Add support for Celery 5.6 14 | 15 | ### Removed 16 | 17 | - Drop support for Python 3.9 (end of life) 18 | 19 | ## [4.1.0] - 2025-06-17 20 | 21 | - [#4](https://github.com/panevo/django-celery-email-reboot/pull/4) Add support for Django 5.1, 5.2; Celery 5.3, 5.4, 5.5; Python 3.13. Drop support for Python 3.8. 22 | 23 | ## [4.0.1] - 2024-05-28 24 | 25 | ### Changed 26 | 27 | - [#2](https://github.com/panevo/django-celery-email/pull/2) Update package details for PyPI 28 | 29 | ## [4.0.0] - 2024-05-22 30 | 31 | - Support for Django 4.0+ 32 | - Support for Python 3.10+ 33 | - Support for Celery 5.2+ 34 | - Drop support for Django 2.2 35 | - Drop support for Celery 4 36 | - Drop support for Python 3.7 37 | 38 | 3.1.0 - 2021.09.21 39 | ------------------ 40 | 41 | - Support for Django 3.1 42 | - Support for Celery 5 43 | 44 | 3.0.0 - 2019.12.10 45 | ------------------ 46 | 47 | - Support for Django 3.0 48 | - Support for Python 3.8 49 | - Droppped support for Django 1.x, Django 2.0 and Django 2.1 50 | - Droppped support for Python 2.7 51 | 52 | 2.0.2 - 2019.05.29 53 | ------------------ 54 | 55 | - Reduce memory usage by running email_to_dict on chunks. Thanks `Paul Brown`_. 56 | - Simplify dict_to_email for readability and efficiency. Thanks `Paul Brown`_. 57 | - Update test matrix for supported versions of Django, Celery and Python. Thanks `James`_. 58 | 59 | .. _Paul Brown: 60 | .._James: 61 | 62 | 2.0.1 - 2018.18.27 63 | ------------------ 64 | 65 | - Fix bug preventing sending text/* encoded mime attachments. Thanks `Cesar Canassa`_. 66 | 67 | .. _Cesar Canassa: 68 | 69 | 2.0 - 2017.07.10 70 | ---------------- 71 | 72 | - Support for Django 1.11 and Celery 4.0 73 | 74 | - Dropped support for Celery 2.x and 3.x 75 | - Dropped support for Python 3.3 76 | 77 | 1.1.5 - 2016.07.20 78 | ------------------ 79 | 80 | - Support extra email attributes via CELERY_EMAIL_MESSAGE_EXTRA_ATTRIBUTES setting 81 | 82 | - Updated version requirements in README 83 | 84 | 1.1.4 - 2016.01.19 85 | ------------------ 86 | 87 | - Support sending email with embedded images. Thanks `Georg Zimmer`_. 88 | - Document CELERY_EMAIL_CHUNK_SIZE. Thanks `Jonas Haag`_. 89 | - Add exception handling to email backend connection. Thanks `Tom`_. 90 | 91 | .. _Georg Zimmer: 92 | .._Tom: 93 | 94 | 1.1.3 - 2015.11.06 95 | ------------------ 96 | 97 | - Support setting celery.base from string. Thanks `Matthew Jacobi`_. 98 | - Use six for py2/3 string compatibility. Thanks `Matthew Jacobi`_. 99 | - Pass content_subtype back in for retries. Thanks `Mark Joshua Tan`_. 100 | - Rework how tests work, add tox, rework travis-ci matrix. 101 | - Use six from django.utils. 102 | - Release a universal wheel. 103 | 104 | .. _Matthew Jacobi: 105 | .._Mark Joshua Tan: 106 | 107 | 1.1.2 - 2015.07.06 108 | ------------------ 109 | 110 | - Fix for HTML-only emails. Thanks `gnarvaja`_. 111 | 112 | .. _gnarvaja: 113 | 114 | 1.1.1 - 2015.03.20 115 | ------------------ 116 | 117 | - Fix for backward compatibility of task kwarg handling - Thanks `Jeremy Thurgood`_. 118 | 119 | .. _Jeremy Thurgood: 120 | 121 | 1.1.0 - 2015.03.06 122 | ------------------ 123 | 124 | - New PyPI release rolling up 1.0.5 changes and some cleanup. 125 | - More backward compatability in task. Will still accept message objects and lists of message objects. 126 | - Thanks again to everyone who contributed to 1.0.5. 127 | 128 | 1.0.5 - 2014.08.24 129 | ------------------ 130 | 131 | - Django 1.6 support, Travis CI testing, chunked sending & more - thanks `Jonas Haag`_. 132 | - HTML email support - thanks `Andres Riancho`_. 133 | - Support for JSON transit for Celery, sponsored by `DigiACTive`_. 134 | - Drop support for Django 1.2. 135 | 136 | .. _`Jonas Haag`: 137 | .._`Andres Riancho`: 138 | .. _`DigiACTive`: 139 | 140 | 1.0.4 - 2013.10.12 141 | ------------------ 142 | 143 | - Add Django 1.5.2 and Python 3 support. 144 | - Thanks to `Stefan Wehrmeyer`_ for the contribution. 145 | 146 | .. _`Stefan Wehrmeyer`: 147 | 148 | 1.0.3 - 2012.03.06 149 | ------------------ 150 | 151 | - Backend will now pass any kwargs with which it is initialized to the 152 | email sending backend. 153 | - Thanks to `Fedor Tyurin`_ for the contribution. 154 | 155 | .. _`Fedor Tyurin`: 156 | 157 | 1.0.2 - 2012.02.21 158 | ------------------ 159 | 160 | - Task and backend now accept kwargs that can be used in signal handlers. 161 | - Task now returns the result from the email sending backend. 162 | - Thanks to `Yehonatan Daniv`_ for these changes. 163 | 164 | .. _`Yehonatan Daniv`: 165 | 166 | 1.0.1 - 2011.10.06 167 | ------------------ 168 | 169 | - Fixed a bug that resulted in tasks that were throwing errors reporting success. 170 | - If there is an exception thrown by the sending email backend, the result of the task will 171 | now be this exception. 172 | -------------------------------------------------------------------------------- /tests/tests.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os.path 3 | from email.mime.image import MIMEImage 4 | 5 | from django.core import mail 6 | from django.core.mail.backends.base import BaseEmailBackend 7 | from django.core.mail.backends import locmem 8 | from django.core.mail import EmailMultiAlternatives 9 | from django.test import TestCase 10 | from django.test.utils import override_settings 11 | 12 | import celery 13 | from djcelery_email import tasks 14 | from djcelery_email.utils import email_to_dict, dict_to_email 15 | 16 | 17 | def even(n): 18 | return n % 2 == 0 19 | 20 | 21 | def celery_queue_pop(): 22 | """ Pops a single task from Celery's 'memory://' queue. """ 23 | with celery.current_app.connection() as conn: 24 | queue = conn.SimpleQueue('django_email', no_ack=True) 25 | return queue.get().payload 26 | 27 | 28 | class TracingBackend(BaseEmailBackend): 29 | def __init__(self, **kwargs): 30 | self.__class__.kwargs = kwargs 31 | 32 | def send_messages(self, messages): 33 | self.__class__.called = True 34 | 35 | 36 | class UtilTests(TestCase): 37 | @override_settings(CELERY_EMAIL_MESSAGE_EXTRA_ATTRIBUTES=['extra_attribute']) 38 | def test_email_to_dict_extra_attrs(self): 39 | msg = mail.EmailMessage() 40 | msg.extra_attribute = {'name': 'val'} 41 | 42 | self.assertEqual(email_to_dict(msg)['extra_attribute'], msg.extra_attribute) 43 | 44 | @override_settings(CELERY_EMAIL_MESSAGE_EXTRA_ATTRIBUTES=['extra_attribute']) 45 | def test_dict_to_email_extra_attrs(self): 46 | msg_dict = email_to_dict(mail.EmailMessage()) 47 | msg_dict['extra_attribute'] = {'name': 'val'} 48 | 49 | self.assertEqual(email_to_dict(dict_to_email(msg_dict)), msg_dict) 50 | 51 | def check_json_of_msg(self, msg): 52 | serialized = json.dumps(email_to_dict(msg)) 53 | self.assertEqual( 54 | email_to_dict(dict_to_email(json.loads(serialized))), 55 | email_to_dict(msg)) 56 | 57 | def test_email_with_attachment(self): 58 | file_path = os.path.join(os.path.dirname(__file__), 'image.png') 59 | with open(file_path, 'rb') as file: 60 | file_contents = file.read() 61 | msg = mail.EmailMessage( 62 | 'test', 'Testing with Celery! w00t!!', 'from@example.com', 63 | ['to@example.com']) 64 | msg.attach('image.png', file_contents) 65 | self.check_json_of_msg(msg) 66 | 67 | def test_email_with_mime_attachment(self): 68 | file_path = os.path.join(os.path.dirname(__file__), 'image.png') 69 | with open(file_path, 'rb') as file: 70 | file_contents = file.read() 71 | mimg = MIMEImage(file_contents) 72 | msg = mail.EmailMessage( 73 | 'test', 'Testing with Celery! w00t!!', 'from@example.com', 74 | ['to@example.com']) 75 | msg.attach(mimg) 76 | self.check_json_of_msg(msg) 77 | 78 | def test_email_with_attachment_from_file(self): 79 | file_path = os.path.join(os.path.dirname(__file__), 'image.png') 80 | msg = mail.EmailMessage( 81 | 'test', 'Testing with Celery! w00t!!', 'from@example.com', 82 | ['to@example.com']) 83 | msg.attach_file(file_path) 84 | self.check_json_of_msg(msg) 85 | 86 | 87 | class TaskTests(TestCase): 88 | """ 89 | Tests that the 'tasks.send_email(s)' task works correctly: 90 | - should accept a single or multiple messages (as dicts) 91 | - should send all these messages 92 | - should use the backend set in CELERY_EMAIL_BACKEND 93 | - should pass the given kwargs to that backend 94 | - should retry sending failed messages (see TaskErrorTests) 95 | """ 96 | def test_send_single_email_object(self): 97 | """ It should accept and send a single EmailMessage object. """ 98 | msg = mail.EmailMessage() 99 | tasks.send_email(msg, backend_kwargs={}) 100 | self.assertEqual(len(mail.outbox), 1) 101 | # we can't compare them directly as it's converted into a dict 102 | # for JSONification and then back. Compare dicts instead. 103 | self.assertEqual(email_to_dict(msg), email_to_dict(mail.outbox[0])) 104 | 105 | def test_send_single_email_object_no_backend_kwargs(self): 106 | """ It should send email with backend_kwargs not provided. """ 107 | msg = mail.EmailMessage() 108 | tasks.send_email(msg) 109 | self.assertEqual(len(mail.outbox), 1) 110 | # we can't compare them directly as it's converted into a dict 111 | # for JSONification and then back. Compare dicts instead. 112 | self.assertEqual(email_to_dict(msg), email_to_dict(mail.outbox[0])) 113 | 114 | def test_send_single_email_object_response(self): 115 | """ It should return the number of messages sent, 1 here. """ 116 | msg = mail.EmailMessage() 117 | messages_sent = tasks.send_email(msg) 118 | self.assertEqual(messages_sent, 1) 119 | self.assertEqual(len(mail.outbox), 1) 120 | 121 | def test_send_single_email_dict(self): 122 | """ It should accept and send a single EmailMessage dict. """ 123 | msg = mail.EmailMessage() 124 | tasks.send_email(email_to_dict(msg), backend_kwargs={}) 125 | self.assertEqual(len(mail.outbox), 1) 126 | # we can't compare them directly as it's converted into a dict 127 | # for JSONification and then back. Compare dicts instead. 128 | self.assertEqual(email_to_dict(msg), email_to_dict(mail.outbox[0])) 129 | 130 | def test_send_multiple_email_objects(self): 131 | """ It should accept and send a list of EmailMessage objects. """ 132 | N = 10 133 | msgs = [mail.EmailMessage() for i in range(N)] 134 | tasks.send_emails([email_to_dict(msg) for msg in msgs], 135 | backend_kwargs={}) 136 | 137 | self.assertEqual(len(mail.outbox), N) 138 | for i in range(N): 139 | self.assertEqual(email_to_dict(msgs[i]), email_to_dict(mail.outbox[i])) 140 | 141 | def test_send_multiple_email_dicts(self): 142 | """ It should accept and send a list of EmailMessage dicts. """ 143 | N = 10 144 | msgs = [mail.EmailMessage() for i in range(N)] 145 | tasks.send_emails(msgs, backend_kwargs={}) 146 | 147 | self.assertEqual(len(mail.outbox), N) 148 | for i in range(N): 149 | self.assertEqual(email_to_dict(msgs[i]), email_to_dict(mail.outbox[i])) 150 | 151 | def test_send_multiple_email_dicts_response(self): 152 | """ It should return the number of messages sent. """ 153 | N = 10 154 | msgs = [mail.EmailMessage() for i in range(N)] 155 | messages_sent = tasks.send_emails(msgs, backend_kwargs={}) 156 | self.assertEqual(messages_sent, N) 157 | self.assertEqual(len(mail.outbox), N) 158 | 159 | @override_settings(CELERY_EMAIL_BACKEND='tests.tests.TracingBackend') 160 | def test_uses_correct_backend(self): 161 | """ It should use the backend configured in CELERY_EMAIL_BACKEND. """ 162 | TracingBackend.called = False 163 | msg = mail.EmailMessage() 164 | tasks.send_email(email_to_dict(msg), backend_kwargs={}) 165 | self.assertTrue(TracingBackend.called) 166 | 167 | @override_settings(CELERY_EMAIL_BACKEND='tests.tests.TracingBackend') 168 | def test_backend_parameters(self): 169 | """ It should pass kwargs like username and password to the backend. """ 170 | TracingBackend.kwargs = None 171 | msg = mail.EmailMessage() 172 | tasks.send_email(email_to_dict(msg), backend_kwargs={'foo': 'bar'}) 173 | self.assertEqual(TracingBackend.kwargs.get('foo'), 'bar') 174 | 175 | @override_settings(CELERY_EMAIL_BACKEND='tests.tests.TracingBackend') 176 | def test_backend_parameters_kwargs(self): 177 | """ It should pass on kwargs specified as keyword params. """ 178 | TracingBackend.kwargs = None 179 | msg = mail.EmailMessage() 180 | tasks.send_email(email_to_dict(msg), foo='bar') 181 | self.assertEqual(TracingBackend.kwargs.get('foo'), 'bar') 182 | 183 | 184 | class EvenErrorBackend(locmem.EmailBackend): 185 | """ Fails to deliver every 2nd message. """ 186 | def __init__(self, *args, **kwargs): 187 | super(EvenErrorBackend, self).__init__(*args, **kwargs) 188 | self.message_count = 0 189 | 190 | def send_messages(self, messages): 191 | self.message_count += 1 192 | if even(self.message_count-1): 193 | raise RuntimeError("Something went wrong sending the message") 194 | else: 195 | return super(EvenErrorBackend, self).send_messages(messages) 196 | 197 | 198 | class TaskErrorTests(TestCase): 199 | """ 200 | Tests that the 'tasks.send_emails' task does not crash if a single message 201 | could not be sent and that it requeues that message. 202 | """ 203 | # TODO: replace setUp/tearDown with 'unittest.mock' at some point 204 | def setUp(self): 205 | super(TaskErrorTests, self).setUp() 206 | 207 | self._retry_calls = [] 208 | 209 | def mock_retry(*args, **kwargs): 210 | self._retry_calls.append((args, kwargs)) 211 | 212 | self._old_retry = tasks.send_emails.retry 213 | tasks.send_emails.retry = mock_retry 214 | 215 | def tearDown(self): 216 | super(TaskErrorTests, self).tearDown() 217 | tasks.send_emails.retry = self._old_retry 218 | 219 | @override_settings(CELERY_EMAIL_BACKEND='tests.tests.EvenErrorBackend') 220 | def test_send_multiple_emails(self): 221 | N = 10 222 | msgs = [mail.EmailMessage(subject="msg %d" % i) for i in range(N)] 223 | tasks.send_emails([email_to_dict(msg) for msg in msgs], 224 | backend_kwargs={'foo': 'bar'}) 225 | 226 | # Assert that only "odd"/good messages have been sent. 227 | self.assertEqual(len(mail.outbox), 5) 228 | self.assertEqual( 229 | [msg.subject for msg in mail.outbox], 230 | ["msg 1", "msg 3", "msg 5", "msg 7", "msg 9"] 231 | ) 232 | 233 | # Assert that "even"/bad messages have been requeued, 234 | # one retry task per bad message. 235 | self.assertEqual(len(self._retry_calls), 5) 236 | odd_msgs = [msg for idx, msg in enumerate(msgs) if even(idx)] 237 | for msg, (args, kwargs) in zip(odd_msgs, self._retry_calls): 238 | retry_args = args[0] 239 | self.assertEqual(retry_args, [[email_to_dict(msg)], {'foo': 'bar'}]) 240 | self.assertTrue(isinstance(kwargs.get('exc'), RuntimeError)) 241 | self.assertFalse(kwargs.get('throw', True)) 242 | 243 | 244 | class BackendTests(TestCase): 245 | """ 246 | Tests that our *own* email backend ('backends.CeleryEmailBackend') works, 247 | i.e. it submits the correct number of jobs (according to the chunk size) 248 | and passes backend parameters to the task. 249 | """ 250 | # TODO: replace setUp/tearDown with 'unittest.mock' at some point 251 | def setUp(self): 252 | super(BackendTests, self).setUp() 253 | 254 | self._delay_calls = [] 255 | 256 | def mock_delay(*args, **kwargs): 257 | self._delay_calls.append((args, kwargs)) 258 | 259 | self._old_delay = tasks.send_emails.delay 260 | tasks.send_emails.delay = mock_delay 261 | 262 | def tearDown(self): 263 | super(BackendTests, self).tearDown() 264 | tasks.send_emails.delay = self._old_delay 265 | 266 | def test_backend_parameters(self): 267 | """ Our backend should pass kwargs to the 'send_emails' task. """ 268 | kwargs = {'auth_user': 'user', 'auth_password': 'pass'} 269 | mail.send_mass_mail([ 270 | ('test1', 'Testing with Celery! w00t!!', 'from@example.com', ['to@example.com']), 271 | ('test2', 'Testing with Celery! w00t!!', 'from@example.com', ['to@example.com']) 272 | ], **kwargs) 273 | 274 | self.assertEqual(len(self._delay_calls), 1) 275 | args, kwargs = self._delay_calls[0] 276 | messages, backend_kwargs = args 277 | self.assertEqual(messages[0]['subject'], 'test1') 278 | self.assertEqual(messages[1]['subject'], 'test2') 279 | self.assertEqual(backend_kwargs, {'username': 'user', 'password': 'pass'}) 280 | 281 | def test_chunking(self): 282 | """ 283 | Given 11 messages and a chunk size of 4, the backend should queue 284 | 11/4 = 3 jobs (2 jobs with 4 messages and 1 job with 3 messages). 285 | """ 286 | N = 11 287 | chunksize = 4 288 | 289 | with override_settings(CELERY_EMAIL_CHUNK_SIZE=4): 290 | mail.send_mass_mail([ 291 | ("subject", "body", "from@example.com", ["to@example.com"]) 292 | for _ in range(N) 293 | ]) 294 | 295 | num_chunks = 3 # floor(11.0 / 4.0) 296 | self.assertEqual(len(self._delay_calls), num_chunks) 297 | 298 | full_tasks = self._delay_calls[:-1] 299 | last_task = self._delay_calls[-1] 300 | 301 | for args, kwargs in full_tasks: 302 | self.assertEqual(len(args[0]), chunksize) 303 | 304 | args, kwargs = last_task 305 | self.assertEqual(len(args[0]), N % chunksize) 306 | 307 | 308 | class ConfigTests(TestCase): 309 | """ 310 | Tests that our Celery task has been initialized with the correct options 311 | (those set in the CELERY_EMAIL_TASK_CONFIG setting) 312 | """ 313 | def test_setting_extra_configs(self): 314 | self.assertEqual(tasks.send_email.queue, 'django_email') 315 | self.assertEqual(tasks.send_email.delivery_mode, 1) 316 | self.assertEqual(tasks.send_email.rate_limit, '50/m') 317 | 318 | 319 | class IntegrationTests(TestCase): 320 | # We run these tests in ALWAYS_EAGER mode, but they might as well be 321 | # executed using a real backend (maybe we can add that to the test setup in 322 | # the future?) 323 | 324 | def setUp(self): 325 | super(IntegrationTests, self).setUp() 326 | # TODO: replace with 'unittest.mock' at some point 327 | celery.current_app.conf.task_always_eager = True 328 | 329 | def tearDown(self): 330 | super(IntegrationTests, self).tearDown() 331 | celery.current_app.conf.task_always_eager = False 332 | 333 | def test_sending_email(self): 334 | [result] = mail.send_mail('test', 'Testing with Celery! w00t!!', 'from@example.com', 335 | ['to@example.com']) 336 | self.assertEqual(result.get(), 1) 337 | self.assertEqual(len(mail.outbox), 1) 338 | self.assertEqual(mail.outbox[0].subject, 'test') 339 | 340 | def test_sending_html_email(self): 341 | msg = EmailMultiAlternatives('test', 'Testing with Celery! w00t!!', 'from@example.com', 342 | ['to@example.com']) 343 | html = '

Testing with Celery! w00t!!

' 344 | msg.attach_alternative(html, 'text/html') 345 | [result] = msg.send() 346 | self.assertEqual(result.get(), 1) 347 | self.assertEqual(len(mail.outbox), 1) 348 | self.assertEqual(mail.outbox[0].subject, 'test') 349 | self.assertEqual(len(mail.outbox[0].alternatives), 1) 350 | self.assertEqual(list(mail.outbox[0].alternatives[0]), [html, 'text/html']) 351 | 352 | def test_sending_mail_with_text_attachment(self): 353 | msg = mail.EmailMessage( 354 | 'test', 'Testing with Celery! w00t!!', 'from@example.com', 355 | ['to@example.com']) 356 | msg.attach('image.png', 'csv content', 'text/csv') 357 | [result] = msg.send() 358 | self.assertEqual(result.get(), 1) 359 | self.assertEqual(len(mail.outbox), 1) 360 | self.assertEqual(mail.outbox[0].subject, 'test') 361 | self.assertEqual(mail.outbox[0].content_subtype, "plain") 362 | 363 | def test_sending_html_only_email(self): 364 | msg = mail.EmailMessage('test', 'Testing with Celery! w00t!!', 'from@example.com', 365 | ['to@example.com']) 366 | msg.content_subtype = "html" 367 | [result] = msg.send() 368 | self.assertEqual(result.get(), 1) 369 | self.assertEqual(len(mail.outbox), 1) 370 | self.assertEqual(mail.outbox[0].subject, 'test') 371 | self.assertEqual(mail.outbox[0].content_subtype, "html") 372 | 373 | def test_sending_mass_email(self): 374 | emails = ( 375 | ('mass 1', 'mass message 1', 'from@example.com', ['to@example.com']), 376 | ('mass 2', 'mass message 2', 'from@example.com', ['to@example.com']), 377 | ) 378 | [result] = mail.send_mass_mail(emails) 379 | self.assertEqual(result.get(), 2) 380 | self.assertEqual(len(mail.outbox), 2) 381 | self.assertEqual(mail.outbox[0].subject, 'mass 1') 382 | self.assertEqual(mail.outbox[1].subject, 'mass 2') 383 | 384 | def test_sending_mass_email_chunked(self): 385 | emails = [ 386 | ('mass %i' % i, 'message', 'from@example.com', ['to@example.com']) 387 | for i in range(11)] 388 | with override_settings(CELERY_EMAIL_CHUNK_SIZE=4): 389 | [result1, result2, result3] = mail.send_mass_mail(emails) 390 | self.assertEqual(result1.get(), 4) 391 | self.assertEqual(result2.get(), 4) 392 | self.assertEqual(result3.get(), 3) 393 | self.assertEqual(len(mail.outbox), 11) 394 | self.assertEqual(mail.outbox[0].subject, 'mass 0') 395 | self.assertEqual(mail.outbox[1].subject, 'mass 1') 396 | --------------------------------------------------------------------------------