├── .gitignore ├── .travis.yml ├── AUTHORS.md ├── CHANGELOG.md ├── LICENSE ├── README.rst ├── build-docs.sh ├── djmail ├── __init__.py ├── admin.py ├── apps.py ├── backends │ ├── __init__.py │ ├── async.py │ ├── base.py │ ├── celery.py │ └── default.py ├── core.py ├── exceptions.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── djmail_delete_old_messages.py │ │ └── djmail_retry_send_messages.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20161118_1347.py │ └── __init__.py ├── models.py ├── signals.py ├── tasks.py ├── template_mail.py ├── templates │ └── emails │ │ ├── test_email1-body-html.html │ │ ├── test_email1-subject.html │ │ ├── test_email2-body-html.html │ │ ├── test_email2-body-text.html │ │ ├── test_email2-subject.html │ │ ├── test_email3-body-text.html │ │ ├── test_email3-subject.html │ │ └── test_email_error_with_no_body-subject.html └── utils.py ├── doc ├── Makefile ├── asciidoc.conf ├── djmail.asciidoc └── static │ ├── asciidoc.css │ ├── asciidoc.js │ ├── niwi.css │ └── pygments.css ├── requirements.txt ├── runtests.py ├── setup.py └── tests ├── __init__.py ├── mocks.py ├── test_settings.py └── tests.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .*swp 3 | doc/index.html 4 | build/ 5 | dist* 6 | .mypy_cache/ 7 | .env 8 | /*.egg* 9 | *.swp 10 | \#* 11 | .\#* 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: python 3 | 4 | cache: pip 5 | 6 | python: 7 | - "3.8" 8 | - "3.7" 9 | - "3.6" 10 | 11 | env: 12 | - DJANGO="Django>=2.0,<2.1" 13 | - DJANGO="Django>=2.1,<2.2" 14 | - DJANGO="Django>=2.2,<3.0" 15 | - DJANGO="Django>=3.0,<3.1" 16 | - DJANGO="https://github.com/django/django/archive/master.tar.gz" 17 | 18 | matrix: 19 | exclude: 20 | - python: "3.8" 21 | env: DJANGO="Django>=2.0,<2.1" 22 | - python: "3.8" 23 | env: DJANGO="Django>=2.1,<2.2" 24 | allow_failures: 25 | - env: DJANGO="https://github.com/django/django/archive/master.tar.gz" 26 | 27 | install: 28 | - travis_retry pip install $DJANGO 29 | - travis_retry pip install psycopg2==2.8.4 30 | - travis_retry pip install celery==4.1.1 31 | 32 | services: 33 | - postgresql 34 | 35 | script: 36 | - python -Wall runtests.py 37 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | The PRIMARY AUTHORS is: 2 | 3 | - Andrey Antukh 4 | 5 | Tha ACTUAL MAINTENER is: 6 | 7 | - David Barragán Merino 8 | 9 | And here is an inevitably incomplete list of MUCH-APPRECIATED CONTRIBUTORS -- 10 | people who have submitted patches, reported bugs, fix documentationd and 11 | generally made djmail that much better: 12 | 13 | - Adam Dobrawy 14 | - Alexandre Macabies 15 | - David Fischer 16 | - Jesús Espino 17 | - Kirill Klenov 18 | - Mathieu Hinderyckx 19 | - Mauricio de Abreu Antunes 20 | 21 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog # 2 | 3 | ## 2.0.0 (2020-02-02) 4 | 5 | - Djmail doesn't support Python 2.x anymore. (thanks @mhindery) 6 | - Djmail is compatible only with Django >= 2.0. (thanks @mhindery) 7 | 8 | 9 | ## 1.1.0 (2018-06-06) 10 | 11 | - Fix minor error with async backend. 12 | - Now djmail is compatible with Django 1.11, Django 2 and Python 3.6. 13 | - Remove compatibility with python 3.3. 14 | - Show email html body in the admin panel (PR #45 thanks to @jdruiter). 15 | 16 | 17 | ## 1.0.1 (2017-03-11) 18 | 19 | - Fix some minor PEP8 errors. 20 | - Add template names to exception about empty body, to be able to simply update them. 21 | - Remove duplication of code in _render_message_body_as_html and _render_message_body_as_text. 22 | 23 | 24 | ## 1.0.0 (2016-11-29) 25 | 26 | - Now Djmail is compatible only with Django >= 1.8. 27 | - Use celery >= 4.x and remove djcelery dependency. 28 | - Add management command 'djmail_delete_old_messages'. 29 | - Fix Issue #36 'Celery backend fails when CELERY_TASK_SERIALIZER not defined'. 30 | - Minor style improvements and code reorganization. 31 | 32 | Note: Special thanks to @mathieuhinderyckx for makeing tjis release posible. 33 | 34 | 35 | ## 0.13.1 (2016-11-14) 36 | 37 | - Fix management command 'djmail_retry_send_messages', 38 | now is compatibile with django 1.10. 39 | 40 | 41 | ## 0.13 (2016-05-29) 42 | 43 | - Add compatibility with django 1.10. 44 | Thanks to @ad-m. 45 | 46 | 47 | ## 0.12 (2015-12-06) 48 | 49 | - Pass extra kwargs to EmailMultiAlternatives in MagicMailBuilder. 50 | Thanks to @ad-m. 51 | - Premailer: Fix call when html is None and create a mixin to be more reusable. 52 | Thanks to @davidfischer-ch. 53 | - Declare any non-HTML body as text/plain (e.g. JSON). 54 | Thanks to @davidfischer-ch. 55 | - Fix issue #22: Celery backend: Handle other serializers than pickle (JSON, yaml...) 56 | Thanks to @davidfischer-ch. 57 | 58 | 59 | ## 0.11 (2015-09-07) 60 | 61 | - Translate email subject. 62 | - Now the project will be maintened by David Barragan (@bameda). 63 | 64 | 65 | ## 0.10 (2015-01-21) 66 | 67 | - Drop compatibility with django 1.4 68 | - Refactored email rendering making it more efficient using translation switching. 69 | - Better handling different type of emails (html-only, text-only and both). 70 | 71 | 72 | ## 0.9 (2014-09-13) 73 | 74 | - code cleaning (pep8) (by @davidfischer-ch) 75 | - fix wrong parameters on management command (by @mathieuhinderyckx) 76 | 77 | 78 | ## 0.8 (2014-07-06) 79 | 80 | - Fixed errors' handling in python2. 81 | 82 | 83 | ## 0.7 (2014-06-12) 84 | 85 | - Add missing modules not included in the previous version. 86 | - Django 1.4.x support added. 87 | - Minor code cleaning. 88 | 89 | 90 | ## 0.6 (2014-06-05) 91 | 92 | - Better control for empty bodies. 93 | 94 | 95 | ## 0.5 (2013-10-27) 96 | 97 | - New documentation. 98 | - Runtests improvements. 99 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013-2015 Andrey Antukh 2 | Copyright (c) 2015-2020 David Barragan 3 | 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions 8 | are met: 9 | 1. Redistributions of source code must retain the above copyright 10 | notice, this list of conditions and the following disclaimer. 11 | 2. Redistributions in binary form must reproduce the above copyright 12 | notice, this list of conditions and the following disclaimer in the 13 | documentation and/or other materials provided with the distribution. 14 | 3. The name of the author may not be used to endorse or promote products 15 | derived from this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR 18 | IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES 19 | OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 20 | IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, 21 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT 22 | NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 23 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 24 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 25 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF 26 | THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | djmail 2 | ====== 3 | 4 | .. image:: https://travis-ci.org/bameda/djmail.svg?branch=master 5 | :target: https://travis-ci.org/bameda/djmail 6 | 7 | 8 | djmail is a BSD Licensed, simple and nonobstructive django email middleware. 9 | 10 | Why use djmail? Because it: 11 | 12 | - Sends emails asynchronously without additional libraries. 13 | - Sends emails using celery tasks. 14 | - Can retry sending failed messages (with cron task or celery periodic task). 15 | - Can assign delivery priority. 16 | - Has a powerful class to build emails from templates. 17 | - Works transparently (works as middleware for native django email backends) 18 | 19 | djmail was created by Andrey Antukh (@niwinz) and is maintained by David Barragán (@bameda). 20 | 21 | You can read the full documentation at https://bameda.github.io/djmail/. 22 | -------------------------------------------------------------------------------- /build-docs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | (cd doc; make) 3 | cp -vr doc/index.html /tmp/index.html; 4 | cp -vr doc/static /tmp/static 5 | git checkout gh-pages; 6 | git pull 7 | git reset --hard 8 | rm -rf * 9 | mv -fv /tmp/index.html . 10 | mv -fv /tmp/static . 11 | git add --all index.html 12 | git add --all static 13 | git commit -a -m "Update doc" 14 | git push 15 | git checkout master 16 | cd .. 17 | -------------------------------------------------------------------------------- /djmail/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | default_app_config = 'djmail.apps.DjMailConfig' 4 | -------------------------------------------------------------------------------- /djmail/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.utils.safestring import mark_safe 3 | 4 | from .models import Message 5 | 6 | 7 | @admin.register(Message) 8 | class MessageAdmin(admin.ModelAdmin): 9 | list_display = ['subject', 'from_email', 'to_email', 'status', 'created_at', 'sent_at'] 10 | list_filter = ['status', 'created_at', 'sent_at'] 11 | search_fields = ['subject', 'from_email', 'to_email'] 12 | date_hierarchy = 'created_at' 13 | readonly_fields = ['uuid', 'subject', 'status', 'from_email', 'to_email', 'body_text', 'body_html', 'body_html_show', 'priority', 'retry_count', 'created_at', 'sent_at', 'exception'] 14 | 15 | fields = ['subject', 'status', 'from_email', 'to_email', 'body_text', 'body_html_show', 'created_at', 'sent_at'] 16 | 17 | @mark_safe 18 | def body_html_show(self, instance): 19 | return instance.body_html 20 | body_html_show.short_description = "Body html" 21 | -------------------------------------------------------------------------------- /djmail/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DjMailConfig(AppConfig): 5 | name = 'djmail' 6 | verbose_name = "DjMail" 7 | 8 | def ready(self): 9 | from . import signals 10 | super(DjMailConfig, self).ready() 11 | -------------------------------------------------------------------------------- /djmail/backends/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | -------------------------------------------------------------------------------- /djmail/backends/async.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals 4 | 5 | import functools 6 | from concurrent.futures import Future, ThreadPoolExecutor 7 | 8 | from django.db import connection 9 | 10 | from . import base 11 | from .. import core 12 | 13 | # TODO: parametrize this 14 | executor = ThreadPoolExecutor(max_workers=1) 15 | 16 | 17 | def _close_connection_on_finish(function): 18 | """ 19 | Decorator for future task, that closes 20 | Django database connection when it ends. 21 | """ 22 | 23 | @functools.wraps(function) 24 | def _decorator(*args, **kwargs): 25 | try: 26 | return function(*args, **kwargs) 27 | finally: 28 | connection.close() 29 | 30 | return _decorator 31 | 32 | 33 | class EmailBackend(base.BaseEmailBackend): 34 | """ 35 | Asynchronous email back-end that uses a 36 | thread pool for sending emails. 37 | """ 38 | 39 | def send_messages(self, email_messages): 40 | if len(email_messages) == 0: 41 | future = Future() 42 | future.set_result(0) 43 | return future 44 | 45 | @_close_connection_on_finish 46 | def _send(messages): 47 | return core._send_messages(messages) 48 | 49 | return executor.submit(_send, email_messages) 50 | -------------------------------------------------------------------------------- /djmail/backends/base.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals 4 | 5 | 6 | class BaseEmailBackend(object): 7 | """ 8 | Base class that implements a Django 9 | mail back-end interface. 10 | """ 11 | 12 | def __init__(self, *args, **kwargs): 13 | self.args = args 14 | self.kwargs = kwargs 15 | 16 | def open(self): 17 | pass 18 | 19 | def close(self): 20 | pass 21 | 22 | def send_messages(self, email_messages): 23 | raise NotImplementedError 24 | -------------------------------------------------------------------------------- /djmail/backends/celery.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import absolute_import 4 | 5 | from django.conf import settings 6 | 7 | from . import base 8 | from .. import tasks 9 | from .. import utils 10 | 11 | 12 | class EmailBackend(base.BaseEmailBackend): 13 | """ 14 | Asynchronous email back-end that uses 15 | Celery task for sending emails. 16 | """ 17 | 18 | def send_messages(self, email_messages): 19 | if len(email_messages) == 0: 20 | return 0 21 | 22 | if getattr(settings, 'CELERY_TASK_SERIALIZER', 'json') in ('json', ): 23 | email_messages = [utils.serialize_email_message(e) for e in email_messages] 24 | return tasks.send_messages.delay(email_messages) 25 | -------------------------------------------------------------------------------- /djmail/backends/default.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals 4 | 5 | from . import base 6 | from .. import core 7 | 8 | 9 | class EmailBackend(base.BaseEmailBackend): 10 | """ 11 | Default email back-end that sends e-mails 12 | synchronously. 13 | """ 14 | 15 | def send_messages(self, email_messages): 16 | if len(email_messages) == 0: 17 | return 0 18 | 19 | return core._send_messages(email_messages) 20 | -------------------------------------------------------------------------------- /djmail/core.py: -------------------------------------------------------------------------------- 1 | import io 2 | import logging 3 | import sys 4 | import traceback 5 | 6 | from django.conf import settings 7 | from django.core.mail import get_connection 8 | from django.core.paginator import Paginator 9 | from django.utils import timezone 10 | 11 | from .models import Message 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | def _chunked_iterate_queryset(queryset, chunk_size=10): 17 | """ 18 | Given a queryset, use a paginator for iterating over the queryset 19 | but obtaining from database delimited set of result parametrized 20 | with `chunk_size` parameter. 21 | """ 22 | paginator = Paginator(queryset, chunk_size) 23 | for page_index in paginator.page_range: 24 | page = paginator.page(page_index) 25 | for item in page.object_list: 26 | yield item 27 | 28 | 29 | def _safe_send_message(message_model, connection): 30 | """ 31 | Given a message model, try to send it, if it fails, 32 | increment retry count and save stack trace in 33 | message model. 34 | """ 35 | email = message_model.get_email_message() 36 | sended = 0 37 | 38 | with io.StringIO() as f: 39 | try: 40 | sended = connection.send_messages([email]) 41 | except Exception: 42 | traceback.print_exc(file=f) 43 | f.seek(0) 44 | message_model.exception = f.read() 45 | logger.error(message_model.exception) 46 | else: 47 | if sended: 48 | message_model.status = Message.STATUS_SENT 49 | message_model.sent_at = timezone.now() 50 | else: 51 | message_model.status = Message.STATUS_FAILED 52 | message_model.retry_count += 1 53 | 54 | message_model.save() 55 | 56 | # Celery backend return an AsyncResult object 57 | return 1 if sended else 0 58 | 59 | 60 | def _get_real_backend(): 61 | real_backend_path = getattr( 62 | settings, 'DJMAIL_REAL_BACKEND', 63 | 'django.core.mail.backends.console.EmailBackend') 64 | return get_connection(backend=real_backend_path, fail_silently=False) 65 | 66 | 67 | def _send_messages(email_messages): 68 | connection = _get_real_backend() 69 | 70 | # Create a messages on a database for correct 71 | # tracking of their status. 72 | email_models = [ 73 | Message.from_email_message( 74 | email, save=True) for email in email_messages 75 | ] 76 | 77 | # Open connection for send all messages 78 | connection.open() 79 | try: 80 | sended_counter = 0 81 | for email, model_instance in zip(email_messages, email_models): 82 | if hasattr(email, "priority"): 83 | if email.priority <= Message.PRIORITY_LOW: 84 | model_instance.priority = email.priority 85 | model_instance.status = Message.STATUS_PENDING 86 | model_instance.save() 87 | sended_counter += 1 88 | continue 89 | 90 | sended_counter += _safe_send_message(model_instance, connection) 91 | finally: 92 | connection.close() 93 | return sended_counter 94 | 95 | 96 | def _send_pending_messages(): 97 | """ 98 | Send pending, low priority messages. 99 | """ 100 | queryset = Message.objects.filter(status=Message.STATUS_PENDING)\ 101 | .order_by('-priority', 'created_at') 102 | connection = _get_real_backend() 103 | connection.open() 104 | try: 105 | sended_counter = 0 106 | for message_model in _chunked_iterate_queryset(queryset, 100): 107 | # Use one unique connection for sending all messages 108 | sended_counter += _safe_send_message(message_model, connection) 109 | finally: 110 | connection.close() 111 | return sended_counter 112 | 113 | 114 | def _retry_send_messages(): 115 | """ 116 | Retry to send failed messages. 117 | """ 118 | max_retry_value = getattr(settings, 'DJMAIL_MAX_RETRY_NUMBER', 3) 119 | queryset = Message.objects.filter(status=Message.STATUS_FAILED)\ 120 | .filter(retry_count__lte=max_retry_value)\ 121 | .order_by('-priority', 'created_at') 122 | 123 | connection = _get_real_backend() 124 | connection.open() 125 | try: 126 | sended_counter = 0 127 | for message_model in _chunked_iterate_queryset(queryset, 100): 128 | sended_counter += _safe_send_message(message_model, connection) 129 | finally: 130 | connection.close() 131 | return sended_counter 132 | 133 | 134 | def _mark_discarded_messages(): 135 | """ 136 | Search messages exceeding the global retry 137 | limit and marks them as discarded. 138 | """ 139 | max_retry_value = getattr(settings, 'DJMAIL_MAX_RETRY_NUMBER', 3) 140 | queryset = Message.objects.filter( 141 | status=Message.STATUS_FAILED, retry_count__gt=max_retry_value) 142 | return queryset.update(status=Message.STATUS_DISCARDED) 143 | -------------------------------------------------------------------------------- /djmail/exceptions.py: -------------------------------------------------------------------------------- 1 | class TemplateNotFound(RuntimeError): 2 | pass 3 | -------------------------------------------------------------------------------- /djmail/management/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | -------------------------------------------------------------------------------- /djmail/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | -------------------------------------------------------------------------------- /djmail/management/commands/djmail_delete_old_messages.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from datetime import datetime, timedelta 4 | from django.core.management.base import BaseCommand 5 | from djmail.models import Message 6 | 7 | 8 | class Command(BaseCommand): 9 | help = 'Remove (succesfully sent) messages older than specified amount of days.' 10 | 11 | def add_arguments(self, parser): 12 | parser.add_argument( 13 | '--days', 14 | type=int, 15 | default=183, # default = 6 months 16 | help='Number of days to use as cut-off for deletion') 17 | 18 | def handle(self, *args, **options): 19 | Message.objects.filter( 20 | sent_at__lt=datetime.now() - timedelta(days=options['days']), 21 | status=Message.STATUS_SENT).delete() 22 | -------------------------------------------------------------------------------- /djmail/management/commands/djmail_retry_send_messages.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals 4 | 5 | from django.core.management.base import BaseCommand 6 | 7 | from ... import core 8 | 9 | 10 | class Command(BaseCommand): 11 | def handle(self, *args, **options): 12 | core._send_pending_messages() 13 | core._mark_discarded_messages() 14 | core._retry_send_messages() 15 | 16 | return 0 17 | -------------------------------------------------------------------------------- /djmail/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals 4 | 5 | from django.db import models, migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='Message', 16 | fields=[ 17 | ('uuid', models.CharField(max_length=40, serialize=False, primary_key=True)), 18 | ('from_email', models.CharField(max_length=1024, blank=True)), 19 | ('to_email', models.TextField(blank=True)), 20 | ('body_text', models.TextField(blank=True)), 21 | ('body_html', models.TextField(blank=True)), 22 | ('subject', models.CharField(max_length=1024, blank=True)), 23 | ('data', models.TextField(editable=False, blank=True)), 24 | ('retry_count', models.SmallIntegerField(default=-1)), 25 | ('status', models.SmallIntegerField(default=10, choices=[(10, 'Draft'), (30, 'Sent'), (40, 'Failed'), (50, 'Discarded')])), 26 | ('priority', models.SmallIntegerField(default=50)), 27 | ('created_at', models.DateTimeField(auto_now_add=True)), 28 | ('sent_at', models.DateTimeField(default=None, null=True)), 29 | ('exception', models.TextField(blank=True)), 30 | ], 31 | options={ 32 | 'ordering': ['created_at'], 33 | 'verbose_name': 'Message', 34 | 'verbose_name_plural': 'Messages', 35 | }, 36 | bases=(models.Model,), 37 | ), 38 | ] 39 | -------------------------------------------------------------------------------- /djmail/migrations/0002_auto_20161118_1347.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.3 on 2016-11-18 13:47 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('djmail', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterModelOptions( 16 | name='message', 17 | options={'ordering': ['-created_at'], 'verbose_name': 'Message', 'verbose_name_plural': 'Messages'}, 18 | ), 19 | migrations.AlterField( 20 | model_name='message', 21 | name='priority', 22 | field=models.SmallIntegerField(choices=[(20, 'Low'), (50, 'Standard')], default=50), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /djmail/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bameda/djmail/be0ad9474f28611a65eba964c1cac9eff5465aed/djmail/migrations/__init__.py -------------------------------------------------------------------------------- /djmail/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from . import utils 4 | 5 | 6 | class Message(models.Model): 7 | STATUS_DRAFT = 10 8 | STATUS_PENDING = 20 9 | STATUS_SENT = 30 10 | STATUS_FAILED = 40 11 | STATUS_DISCARDED = 50 12 | STATUS_CHOICES = ( 13 | (STATUS_DRAFT, 'Draft'), 14 | (STATUS_SENT, 'Sent'), 15 | (STATUS_FAILED, 'Failed'), 16 | (STATUS_DISCARDED, 'Discarded'), ) 17 | PRIORITY_LOW = 20 18 | PRIORITY_STANDARD = 50 19 | PRIORITY_CHOICES = ( 20 | (PRIORITY_LOW, 'Low'), 21 | (PRIORITY_STANDARD, 'Standard'), 22 | ) 23 | 24 | uuid = models.CharField(max_length=40, primary_key=True) 25 | 26 | from_email = models.CharField(max_length=1024, blank=True) 27 | to_email = models.TextField(blank=True) 28 | body_text = models.TextField(blank=True) 29 | body_html = models.TextField(blank=True) 30 | subject = models.CharField(max_length=1024, blank=True) 31 | 32 | data = models.TextField(blank=True, editable=False) 33 | 34 | retry_count = models.SmallIntegerField(default=-1) 35 | status = models.SmallIntegerField(choices=STATUS_CHOICES, default=STATUS_DRAFT) 36 | priority = models.SmallIntegerField(choices=PRIORITY_CHOICES, default=PRIORITY_STANDARD) 37 | 38 | created_at = models.DateTimeField(auto_now_add=True) 39 | sent_at = models.DateTimeField(null=True, default=None) 40 | exception = models.TextField(editable=True, blank=True) 41 | 42 | def get_email_message(self): 43 | return utils.deserialize_email_message(self.data) 44 | 45 | @classmethod 46 | def from_email_message(cls, email_message, save=False): 47 | def get_body_key(body_type): 48 | """Declare HTML body subtype as text/html else as text/plain.""" 49 | return 'body_{}'.format('html' if body_type.split('/')[-1] == 'html' else 'text') 50 | 51 | kwargs = { 52 | "from_email": utils.force_str(email_message.from_email), 53 | "to_email": ",".join(utils.force_str(x) for x in email_message.to), 54 | "subject": utils.force_str(email_message.subject), 55 | "data": utils.serialize_email_message(email_message), 56 | get_body_key(email_message.content_subtype): 57 | utils.force_str(email_message.body) 58 | } 59 | 60 | # Update the body (if missing) from the alternatives 61 | for alt_body, alt_type in getattr(email_message, 'alternatives', None) or []: 62 | kwargs.setdefault(get_body_key(alt_type), alt_body) 63 | 64 | instance = cls(**kwargs) 65 | if save: 66 | instance.save() 67 | 68 | return instance 69 | 70 | class Meta: 71 | ordering = ['-created_at'] 72 | verbose_name = 'Message' 73 | verbose_name_plural = 'Messages' 74 | -------------------------------------------------------------------------------- /djmail/signals.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from django.db.models.signals import pre_save 4 | from django.dispatch import receiver 5 | 6 | from .models import Message 7 | 8 | 9 | @receiver(pre_save, sender=Message, dispatch_uid='message_uuid_signal') 10 | def generate_uuid(sender, instance, **kwargs): 11 | if not instance.uuid: 12 | instance.uuid = str(uuid.uuid1()) 13 | -------------------------------------------------------------------------------- /djmail/tasks.py: -------------------------------------------------------------------------------- 1 | from celery import Celery, shared_task 2 | 3 | from . import core, utils 4 | 5 | app = Celery('djmail') 6 | 7 | app.config_from_object('django.conf:settings', namespace='CELERY') 8 | 9 | 10 | @shared_task 11 | def send_messages(messages): 12 | """ 13 | Celery standard task for sending messages asynchronously. 14 | """ 15 | return core._send_messages([ 16 | utils.deserialize_email_message(m) 17 | if isinstance(m, str) else m for m in messages 18 | ]) 19 | 20 | 21 | @shared_task 22 | def retry_send_messages(): 23 | """ 24 | Celery periodic task retrying to send failed messages. 25 | """ 26 | core._send_pending_messages() 27 | core._mark_discarded_messages() 28 | core._retry_send_messages() 29 | -------------------------------------------------------------------------------- /djmail/template_mail.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from contextlib import contextmanager 3 | 4 | from django.conf import settings 5 | from django.core import mail 6 | from django.template import TemplateDoesNotExist, loader 7 | from django.utils import translation 8 | 9 | from . import exceptions as exc 10 | from . import utils 11 | from .models import Message 12 | 13 | log = logging.getLogger(__name__) 14 | 15 | 16 | def _get_body_template_prototype(): 17 | return getattr(settings, 'DJMAIL_BODY_TEMPLATE_PROTOTYPE', 'emails/{name}-body-{type}.{ext}') 18 | 19 | 20 | def _get_subject_template_prototype(): 21 | return getattr(settings, 'DJMAIL_SUBJECT_TEMPLATE_PROTOTYPE', 'emails/{name}-subject.{ext}') 22 | 23 | 24 | def _get_template_extension(): 25 | return getattr(settings, 'DJMAIL_TEMPLATE_EXTENSION', 'html') 26 | 27 | 28 | @contextmanager 29 | def language(lang): 30 | old_language = translation.get_language() 31 | try: 32 | translation.activate(lang) 33 | yield 34 | finally: 35 | translation.activate(old_language) 36 | 37 | 38 | class TemplateMail(object): 39 | name = None 40 | 41 | def __init__(self, name=None): 42 | self._email = None 43 | if name is not None: 44 | self.name = name 45 | self._initialize_settings() 46 | 47 | def _initialize_settings(self): 48 | self._body_template_name = _get_body_template_prototype() 49 | self._subject_template_name = _get_subject_template_prototype() 50 | 51 | def _get_template_name(self, t): 52 | return self._body_template_name.format( 53 | **{'ext': _get_template_extension(), 54 | 'name': self.name, 55 | 'type': t}) 56 | 57 | def _render_message_body(self, context, t): 58 | template_name = self._get_template_name(t) 59 | 60 | try: 61 | return loader.render_to_string(template_name, context) 62 | except TemplateDoesNotExist as e: 63 | log.warning("Template '{0}' does not exists.".format(e)) 64 | 65 | def _render_message_body_as_html(self, context): 66 | return self._render_message_body(context, 'html') 67 | 68 | def _render_message_body_as_txt(self, context): 69 | return self._render_message_body(context, 'text') 70 | 71 | def _render_message_subject(self, context): 72 | template_name = self._subject_template_name.format( 73 | ext=_get_template_extension(), name=self.name) 74 | try: 75 | subject = loader.render_to_string(template_name, context) 76 | except TemplateDoesNotExist as e: 77 | raise exc.TemplateNotFound( 78 | "Template '{0}' does not exists.".format(e)) 79 | return ' '.join(subject.strip().split()) 80 | 81 | def make_email_object(self, to, context, **kwargs): 82 | if not isinstance(to, (list, tuple)): 83 | to = [to] 84 | 85 | lang = context.get('lang', None) or settings.LANGUAGE_CODE 86 | with language(lang): 87 | subject = self._render_message_subject(context) 88 | body_html = self._render_message_body_as_html(context) 89 | body_txt = self._render_message_body_as_txt(context) 90 | 91 | if not body_txt and not body_html: 92 | raise exc.TemplateNotFound( 93 | "Body of email message shouldn't be empty. " 94 | "Update '{text}' or '{html}'".format(html=self._get_template_name('html'), 95 | text=self._get_template_name('text'))) 96 | 97 | if body_txt and body_html: 98 | email = mail.EmailMultiAlternatives(**kwargs) 99 | email.body = body_txt 100 | email.attach_alternative(body_html, 'text/html') 101 | 102 | elif not body_txt and body_html: 103 | email = mail.EmailMessage(**kwargs) 104 | email.content_subtype = 'html' 105 | email.body = body_html 106 | 107 | else: 108 | email = mail.EmailMessage(**kwargs) 109 | email.body = body_txt 110 | 111 | email.to = to 112 | email.subject = subject 113 | 114 | return email 115 | 116 | def send(self, to, context, **kwargs): 117 | email = self.make_email_object(to, context, **kwargs) 118 | return email.send() 119 | 120 | 121 | class InlineCSSMixin(object): 122 | 123 | def _render_message_body_as_html(self, context): 124 | """ 125 | Transform CSS into in-line style attributes. 126 | """ 127 | import premailer 128 | html = super(InlineCSSMixin, 129 | self)._render_message_body_as_html(context) 130 | return premailer.transform(html) if html else html 131 | 132 | 133 | class InlineCSSTemplateMail(InlineCSSMixin, TemplateMail): 134 | pass 135 | 136 | 137 | class MagicMailBuilder(object): 138 | 139 | def __init__(self, 140 | email_attr='email', 141 | lang_attr='lang', 142 | template_mail_cls=TemplateMail): 143 | self._email_attr = email_attr 144 | self._lang_attr = lang_attr 145 | self._template_mail_cls = template_mail_cls 146 | 147 | def __getattr__(self, name): 148 | def _dynamic_email_generator(to, context, priority=Message.PRIORITY_STANDARD, **kwargs): 149 | lang = None 150 | 151 | if not isinstance(to, str): 152 | if not hasattr(to, self._email_attr): 153 | raise AttributeError( 154 | "to' parameter does not have '{0._email_attr}' attribute". 155 | format(self)) 156 | 157 | lang = getattr(to, self._lang_attr, None) 158 | to = getattr(to, self._email_attr) 159 | 160 | if lang is not None: 161 | context['lang'] = lang 162 | 163 | template_email = self._template_mail_cls(name=name) 164 | email_instance = template_email.make_email_object(to, context, **kwargs) 165 | email_instance.priority = priority 166 | return email_instance 167 | 168 | return _dynamic_email_generator 169 | 170 | 171 | def make_email(name, to, context=None, template_mail_cls=TemplateMail, **kwargs): 172 | """ 173 | Helper for build email objects. 174 | """ 175 | if context is None: 176 | context = {'to': to} 177 | instance = template_mail_cls(name) 178 | return instance.make_email_object(to, context, **kwargs) 179 | -------------------------------------------------------------------------------- /djmail/templates/emails/test_email1-body-html.html: -------------------------------------------------------------------------------- 1 | Mail1: {{ name }} 2 | -------------------------------------------------------------------------------- /djmail/templates/emails/test_email1-subject.html: -------------------------------------------------------------------------------- 1 | Subject1: {{ name }} 2 | -------------------------------------------------------------------------------- /djmail/templates/emails/test_email2-body-html.html: -------------------------------------------------------------------------------- 1 | Body 2 | -------------------------------------------------------------------------------- /djmail/templates/emails/test_email2-body-text.html: -------------------------------------------------------------------------------- 1 | body 2 | -------------------------------------------------------------------------------- /djmail/templates/emails/test_email2-subject.html: -------------------------------------------------------------------------------- 1 | Subject2: {{ name }} 2 | -------------------------------------------------------------------------------- /djmail/templates/emails/test_email3-body-text.html: -------------------------------------------------------------------------------- 1 | Mail1: {{ name }} 2 | -------------------------------------------------------------------------------- /djmail/templates/emails/test_email3-subject.html: -------------------------------------------------------------------------------- 1 | Subject1: {{ name }} 2 | -------------------------------------------------------------------------------- /djmail/templates/emails/test_email_error_with_no_body-subject.html: -------------------------------------------------------------------------------- 1 | Subject1: {{ name }} 2 | -------------------------------------------------------------------------------- /djmail/utils.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import pickle 3 | 4 | from django.utils.encoding import force_bytes 5 | from django.utils.encoding import force_str 6 | 7 | __all__ = ('force_bytes', 'force_str', 8 | 'deserialize_email_message', 'serialize_email_message') 9 | 10 | 11 | def deserialize_email_message(data): 12 | return pickle.loads(base64.b64decode(force_bytes(data))) 13 | 14 | 15 | def serialize_email_message(email_message): 16 | return force_str(base64.b64encode(pickle.dumps(email_message))) 17 | -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | all: doc 2 | doc: 3 | asciidoc -b html5 -o index.html djmail.asciidoc 4 | -------------------------------------------------------------------------------- /doc/asciidoc.conf: -------------------------------------------------------------------------------- 1 | [attributes] 2 | source-highlighter=pygments 3 | encoding=utf-8 4 | max-width=800px 5 | linkcss= 6 | stylesdir=static 7 | scriptsdir=static 8 | toc= 9 | 10 | [miscellaneous] 11 | newline=\n 12 | -------------------------------------------------------------------------------- /doc/djmail.asciidoc: -------------------------------------------------------------------------------- 1 | djmail documentation 2 | ==================== 3 | David Barragan, 4 | 2.0.0, 2020-02-02 5 | 6 | :toc: 7 | :numbered: 8 | 9 | 10 | Introduction 11 | ------------ 12 | 13 | _djmail_ is a xref:license[BSD Licensed], simple and nonobstructive django email middleware. 14 | _djmail_ was created by Andrey Antukh and is maintened by David Barragán 15 | 16 | 17 | Why use djmail? 18 | --------------- 19 | 20 | Because it: 21 | 22 | - Sends emails asynchronously without additional libraries. 23 | - Sends emails using celery tasks. 24 | - Can retry sending failed messages (with cron task or celery periodic task). 25 | - Can assign delivery priority. 26 | - Has a powerfull class to build emails from templates. 27 | - Works transparently (works as middleware for native django email backends) 28 | 29 | 30 | How to install? 31 | ~~~~~~~~~~~~~~~ 32 | 33 | The simplest way to get djmail for your project, is installing it using *pip*: 34 | 35 | [source,text] 36 | ---- 37 | pip install djmail 38 | ---- 39 | 40 | 41 | User guide 42 | ---------- 43 | 44 | As I have mentioned previously, _djmail_ works as a middleware. 45 | 46 | - It saves emails using django orm and track its status. If an exception is raised during sending, 47 | it marks the email as *failed* and stores the exception traceback for posterior analysis. 48 | - Exposes usefull commands and methods for retring to send failed messages. 49 | - Handles priority, with ability to delay sending of messages with lowest priorty 50 | to periodic task (with cron or celery). 51 | 52 | To get djmail working on your project, you should configure it. An example setup: 53 | 54 | .Add it to the `INSTALLED_APPS` your Django settings: 55 | 56 | [source,python] 57 | ---- 58 | INSTALLED_APPS = [ 59 | ..., 60 | 'djmail', 61 | ..., 62 | ] 63 | ---- 64 | 65 | .Set the email backends in the settings. 66 | 67 | [source,python] 68 | ---- 69 | EMAIL_BACKEND="djmail.backends.default.EmailBackend" 70 | DJMAIL_REAL_BACKEND="django.core.mail.backends.console.EmailBackend" 71 | ---- 72 | 73 | This specifies: emails are managed by djmail default backend and actually sent using 74 | console based django builtin backend. 75 | 76 | 77 | How to use? 78 | ~~~~~~~~~~~ 79 | 80 | djmail works as a middleware, and the simplest way to use it, is to not think, just send 81 | emails the way you did before. 82 | 83 | 84 | Backends 85 | ~~~~~~~~ 86 | 87 | _djmail_ comes with three backends: 88 | 89 | - *djmail.backends.default.EmailBackend* + 90 | send emails synchronously using real backend. 91 | - *djmail.backends.async.EmailBackend* + 92 | send emails asynchronously using `concurrent.futures`. 93 | - *djmail.backends.celery.EmailBackend* + 94 | send emails asynchronously using celery tasks. 95 | 96 | 97 | Management Commands 98 | ~~~~~~~~~~~~~~~~~~ 99 | 100 | _djmail_ has two built-in management commands: 101 | 102 | - *djmail_retry_send_messages* + 103 | Sends all mail in the queue, also retrying previously failed email messages. After the global retry limit is reached, an email is marked as discarded. 104 | - *djmail_delete_old_messages --days=183* + 105 | _djmail_ stores all messages in the database, which could eventually take up a lot of database space for data which is not important to keep. This command deletes all succesfully sent messages older than the provided age (when the `--days` parameter is ommited, the default value is 6 months). 106 | 107 | An example setup using cronjobs (`sudo crontab -e`) would be 108 | 109 | ---- 110 | @hourly python manage.py djmail_retry_send_messages 111 | @weekly python manage.py djmail_delete_old_messages --days=31 112 | ---- 113 | 114 | 115 | Template Emails 116 | --------------- 117 | 118 | _djmail_ comes with two util classes for easily building emails from templates: 119 | 120 | - *djmail.template_mail.TemplateMail*: low level interface. 121 | - *djmail.template_mail.MagicMailBuilder*: high level interface with some magic. 122 | 123 | 124 | TemplateMail 125 | ~~~~~~~~~~~~ 126 | 127 | This is a low level interface for buiding emails using templates. It allows to statically define email classes for posterior 128 | usage: 129 | 130 | [source,python] 131 | ---- 132 | from djmail import template_mail 133 | # Define a subclass of TemplateMail 134 | class SomeTemplateEmail(template_mail.TemplateMail): 135 | name = "some_email" 136 | 137 | # Create an instance 138 | email = SomeTemplateEmail() 139 | 140 | # Build and send message with specified context 141 | email.send("to@example.com", {"template": "context"}) 142 | ---- 143 | 144 | Also you can obtain a native django email instance from TemplateMail instance: 145 | 146 | [source, python] 147 | ---- 148 | # Create a instance 149 | template_email = SomeTemplateEmail() 150 | 151 | # obtain a native django email object 152 | email = template_email.make_email_object("to@example.com", 153 | {"template": "context"}) 154 | email.send() 155 | ---- 156 | 157 | `TemplateMail` default implementation searches these templates: 158 | 159 | - `emails/some_email-body-html.html` 160 | - `emails/some_email-body-text.html` 161 | - `emails/some_email-subject.html` 162 | 163 | NOTE: Text version of email is ommited if template does not exists. 164 | 165 | 166 | MagicMailBuilder 167 | ~~~~~~~~~~~~~~~~ 168 | 169 | This is a more powerful way for building email messages from templates. Behind the scenes, it uses 170 | `TemplateMail` implementation but exposes a dynamic api that allows building of subclasses on demand. 171 | 172 | .Example that represents the same behavior as previous example using dynamic api of `MagicMailBuilder` 173 | [source,python] 174 | ---- 175 | from djmail import template_mail 176 | # Create MagicMailBuilder instance 177 | mails = template_mail.MagicMailBuilder() 178 | 179 | # Create a native email object. 180 | # NOTE: The method name represents a email name. 181 | email = mails.some_email("to@example.com", {"template": "context"}) 182 | email.send() 183 | ---- 184 | 185 | Additionally, instead of receiver email address you can pass a django model 186 | instance that represents a user (it should have "email" field for work): 187 | 188 | [source,python] 189 | ---- 190 | class MyUser(models.Model): 191 | email = models.CharField(max_length=200) 192 | lang = models.CharField(max_length=200, default="es") 193 | # [...] 194 | 195 | user = MyUser.objects.get(pk=1) 196 | email = mails.some_email(user, {"template": "context"}) 197 | ---- 198 | 199 | Magic builder is really magic, and if your user class has lang field, magic builder uses it to setup a correct user language 200 | for rendering email in user locale. 201 | 202 | NOTE: Also, you can specify a custom "lang" on context for same purpose. 203 | 204 | Settings 205 | -------- 206 | 207 | djmail exposes some additional settings for costumizing a great part of default behavior. 208 | 209 | - *DJMAIL_REAL_BACKEND* + 210 | Indicates to djmail which django email backend to use for delivering email messages. + 211 | Default: `django.core.mail.backends.console.EmailBackend` 212 | - *DJMAIL_MAX_RETRY_NUMBER* + 213 | Set a default maximum retry number for delivering failed messages. + 214 | Default: 3 215 | - *DJMAIL_BODY_TEMPLATE_PROTOTYPE* + 216 | Prototype for making body template path. + 217 | Default: `emails/{name}-body-{type}.{ext}` 218 | - *DJMAIL_SUBJECT_TEMPLATE_PROTOTYPE* + 219 | Prototype for make subject template path. + 220 | Default: `emails/{name}-subject.{ext}` 221 | - *DJMAIL_TEMPLATE_EXTENSION* + 222 | Extension used for build a final path of email templates. + 223 | Default: `html` 224 | 225 | 226 | [[license]] 227 | License 228 | ------- 229 | 230 | [source,text] 231 | ---- 232 | Copyright (c) 2013-2015 Andrey Antukh 233 | Copyright (c) 2015-2020 David Barragan 234 | 235 | All rights reserved. 236 | 237 | Redistribution and use in source and binary forms, with or without 238 | modification, are permitted provided that the following conditions 239 | are met: 240 | 1. Redistributions of source code must retain the above copyright 241 | notice, this list of conditions and the following disclaimer. 242 | 2. Redistributions in binary form must reproduce the above copyright 243 | notice, this list of conditions and the following disclaimer in the 244 | documentation and/or other materials provided with the distribution. 245 | 3. The name of the author may not be used to endorse or promote products 246 | derived from this software without specific prior written permission. 247 | 248 | THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR 249 | IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES 250 | OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 251 | IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, 252 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT 253 | NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 254 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 255 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 256 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF 257 | THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 258 | ---- 259 | -------------------------------------------------------------------------------- /doc/static/asciidoc.css: -------------------------------------------------------------------------------- 1 | /* Shared CSS for AsciiDoc xhtml11 and html5 backends */ 2 | 3 | /* Default font. */ 4 | body { 5 | font-family: Georgia,serif; 6 | font-family: 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, Sans-Serif; 7 | } 8 | 9 | /* Title font. */ 10 | h1, h2, h3, h4, h5, h6, 11 | div.title, caption.title, 12 | thead, p.table.header, 13 | #toctitle, 14 | #author, #revnumber, #revdate, #revremark, 15 | #footer { 16 | font-family: Arial,Helvetica,sans-serif; 17 | font-family: Georgia, 'Times New Roman', Times, Serif; 18 | } 19 | 20 | body { 21 | margin: 1em 5% 1em 5%; 22 | } 23 | 24 | a { 25 | color: blue; 26 | text-decoration: underline; 27 | } 28 | a:visited { 29 | color: fuchsia; 30 | } 31 | 32 | em { 33 | font-style: italic; 34 | color: navy; 35 | } 36 | 37 | strong { 38 | font-weight: bold; 39 | color: #083194; 40 | } 41 | 42 | h1, h2, h3, h4, h5, h6 { 43 | color: #527bbd; 44 | margin-top: 1.2em; 45 | margin-bottom: 0.5em; 46 | line-height: 1.3; 47 | } 48 | 49 | h1, h2, h3 { 50 | border-bottom: 2px solid silver; 51 | } 52 | h2 { 53 | padding-top: 0.5em; 54 | } 55 | h3 { 56 | float: left; 57 | } 58 | h3 + * { 59 | clear: left; 60 | } 61 | h5 { 62 | font-size: 1.0em; 63 | } 64 | 65 | div.sectionbody { 66 | margin-left: 0; 67 | } 68 | 69 | hr { 70 | border: 1px solid silver; 71 | } 72 | 73 | p { 74 | margin-top: 0.5em; 75 | margin-bottom: 0.5em; 76 | } 77 | 78 | ul, ol, li > p { 79 | margin-top: 0; 80 | } 81 | ul > li { color: #aaa; } 82 | ul > li > * { color: black; } 83 | 84 | .monospaced, code, pre { 85 | font-family: "Courier New", Courier, monospace; 86 | font-size: inherit; 87 | color: navy; 88 | padding: 0; 89 | margin: 0; 90 | } 91 | pre { 92 | white-space: pre-wrap; 93 | } 94 | 95 | #author { 96 | color: #527bbd; 97 | font-weight: bold; 98 | font-size: 1.1em; 99 | } 100 | #email { 101 | } 102 | #revnumber, #revdate, #revremark { 103 | } 104 | 105 | #footer { 106 | font-size: small; 107 | border-top: 2px solid silver; 108 | padding-top: 0.5em; 109 | margin-top: 4.0em; 110 | } 111 | #footer-text { 112 | float: left; 113 | padding-bottom: 0.5em; 114 | } 115 | #footer-badges { 116 | float: right; 117 | padding-bottom: 0.5em; 118 | } 119 | 120 | #preamble { 121 | margin-top: 1.5em; 122 | margin-bottom: 1.5em; 123 | } 124 | div.imageblock, div.exampleblock, div.verseblock, 125 | div.quoteblock, div.literalblock, div.listingblock, div.sidebarblock, 126 | div.admonitionblock { 127 | margin-top: 1.0em; 128 | margin-bottom: 1.5em; 129 | } 130 | div.admonitionblock { 131 | margin-top: 2.0em; 132 | margin-bottom: 2.0em; 133 | margin-right: 10%; 134 | color: #606060; 135 | } 136 | 137 | div.content { /* Block element content. */ 138 | padding: 0; 139 | } 140 | 141 | /* Block element titles. */ 142 | div.title, caption.title { 143 | color: #527bbd; 144 | font-weight: bold; 145 | text-align: left; 146 | margin-top: 1.0em; 147 | margin-bottom: 0.5em; 148 | } 149 | div.title + * { 150 | margin-top: 0; 151 | } 152 | 153 | td div.title:first-child { 154 | margin-top: 0.0em; 155 | } 156 | div.content div.title:first-child { 157 | margin-top: 0.0em; 158 | } 159 | div.content + div.title { 160 | margin-top: 0.0em; 161 | } 162 | 163 | div.sidebarblock > div.content { 164 | background: #ffffee; 165 | border: 1px solid #dddddd; 166 | border-left: 4px solid #f0f0f0; 167 | padding: 0.5em; 168 | } 169 | 170 | div.listingblock > div.content { 171 | border: 1px solid #dddddd; 172 | border-left: 5px solid #f0f0f0; 173 | background: #f8f8f8; 174 | padding: 0.5em; 175 | } 176 | 177 | div.quoteblock, div.verseblock { 178 | padding-left: 1.0em; 179 | margin-left: 1.0em; 180 | margin-right: 10%; 181 | border-left: 5px solid #f0f0f0; 182 | color: #888; 183 | } 184 | 185 | div.quoteblock > div.attribution { 186 | padding-top: 0.5em; 187 | text-align: right; 188 | } 189 | 190 | div.verseblock > pre.content { 191 | font-family: inherit; 192 | font-size: inherit; 193 | } 194 | div.verseblock > div.attribution { 195 | padding-top: 0.75em; 196 | text-align: left; 197 | } 198 | /* DEPRECATED: Pre version 8.2.7 verse style literal block. */ 199 | div.verseblock + div.attribution { 200 | text-align: left; 201 | } 202 | 203 | div.admonitionblock .icon { 204 | vertical-align: top; 205 | font-size: 1.1em; 206 | font-weight: bold; 207 | text-decoration: underline; 208 | color: #527bbd; 209 | padding-right: 0.5em; 210 | } 211 | div.admonitionblock td.content { 212 | padding-left: 0.5em; 213 | border-left: 3px solid #dddddd; 214 | } 215 | 216 | div.exampleblock > div.content { 217 | border-left: 3px solid #dddddd; 218 | padding-left: 0.5em; 219 | } 220 | 221 | div.imageblock div.content { padding-left: 0; } 222 | span.image img { border-style: none; vertical-align: text-bottom; } 223 | a.image:visited { color: white; } 224 | 225 | dl { 226 | margin-top: 0.8em; 227 | margin-bottom: 0.8em; 228 | } 229 | dt { 230 | margin-top: 0.5em; 231 | margin-bottom: 0; 232 | font-style: normal; 233 | color: navy; 234 | } 235 | dd > *:first-child { 236 | margin-top: 0.1em; 237 | } 238 | 239 | ul, ol { 240 | list-style-position: outside; 241 | } 242 | ol.arabic { 243 | list-style-type: decimal; 244 | } 245 | ol.loweralpha { 246 | list-style-type: lower-alpha; 247 | } 248 | ol.upperalpha { 249 | list-style-type: upper-alpha; 250 | } 251 | ol.lowerroman { 252 | list-style-type: lower-roman; 253 | } 254 | ol.upperroman { 255 | list-style-type: upper-roman; 256 | } 257 | 258 | div.compact ul, div.compact ol, 259 | div.compact p, div.compact p, 260 | div.compact div, div.compact div { 261 | margin-top: 0.1em; 262 | margin-bottom: 0.1em; 263 | } 264 | 265 | tfoot { 266 | font-weight: bold; 267 | } 268 | td > div.verse { 269 | white-space: pre; 270 | } 271 | 272 | div.hdlist { 273 | margin-top: 0.8em; 274 | margin-bottom: 0.8em; 275 | } 276 | div.hdlist tr { 277 | padding-bottom: 15px; 278 | } 279 | dt.hdlist1.strong, td.hdlist1.strong { 280 | font-weight: bold; 281 | } 282 | td.hdlist1 { 283 | vertical-align: top; 284 | font-style: normal; 285 | padding-right: 0.8em; 286 | color: navy; 287 | } 288 | td.hdlist2 { 289 | vertical-align: top; 290 | } 291 | div.hdlist.compact tr { 292 | margin: 0; 293 | padding-bottom: 0; 294 | } 295 | 296 | .comment { 297 | background: yellow; 298 | } 299 | 300 | .footnote, .footnoteref { 301 | font-size: 0.8em; 302 | } 303 | 304 | span.footnote, span.footnoteref { 305 | vertical-align: super; 306 | } 307 | 308 | #footnotes { 309 | margin: 20px 0 20px 0; 310 | padding: 7px 0 0 0; 311 | } 312 | 313 | #footnotes div.footnote { 314 | margin: 0 0 5px 0; 315 | } 316 | 317 | #footnotes hr { 318 | border: none; 319 | border-top: 1px solid silver; 320 | height: 1px; 321 | text-align: left; 322 | margin-left: 0; 323 | width: 20%; 324 | min-width: 100px; 325 | } 326 | 327 | div.colist td { 328 | padding-right: 0.5em; 329 | padding-bottom: 0.3em; 330 | vertical-align: top; 331 | } 332 | div.colist td img { 333 | margin-top: 0.3em; 334 | } 335 | 336 | @media print { 337 | #footer-badges { display: none; } 338 | } 339 | 340 | #toc { 341 | margin-bottom: 2.5em; 342 | } 343 | 344 | #toctitle { 345 | color: #527bbd; 346 | font-size: 1.1em; 347 | font-weight: bold; 348 | margin-top: 1.0em; 349 | margin-bottom: 0.1em; 350 | } 351 | 352 | div.toclevel0, div.toclevel1, div.toclevel2, div.toclevel3, div.toclevel4 { 353 | margin-top: 0; 354 | margin-bottom: 0; 355 | } 356 | div.toclevel2 { 357 | margin-left: 2em; 358 | font-size: 0.9em; 359 | } 360 | div.toclevel3 { 361 | margin-left: 4em; 362 | font-size: 0.9em; 363 | } 364 | div.toclevel4 { 365 | margin-left: 6em; 366 | font-size: 0.9em; 367 | } 368 | 369 | span.aqua { color: aqua; } 370 | span.black { color: black; } 371 | span.blue { color: blue; } 372 | span.fuchsia { color: fuchsia; } 373 | span.gray { color: gray; } 374 | span.green { color: green; } 375 | span.lime { color: lime; } 376 | span.maroon { color: maroon; } 377 | span.navy { color: navy; } 378 | span.olive { color: olive; } 379 | span.purple { color: purple; } 380 | span.red { color: red; } 381 | span.silver { color: silver; } 382 | span.teal { color: teal; } 383 | span.white { color: white; } 384 | span.yellow { color: yellow; } 385 | 386 | span.aqua-background { background: aqua; } 387 | span.black-background { background: black; } 388 | span.blue-background { background: blue; } 389 | span.fuchsia-background { background: fuchsia; } 390 | span.gray-background { background: gray; } 391 | span.green-background { background: green; } 392 | span.lime-background { background: lime; } 393 | span.maroon-background { background: maroon; } 394 | span.navy-background { background: navy; } 395 | span.olive-background { background: olive; } 396 | span.purple-background { background: purple; } 397 | span.red-background { background: red; } 398 | span.silver-background { background: silver; } 399 | span.teal-background { background: teal; } 400 | span.white-background { background: white; } 401 | span.yellow-background { background: yellow; } 402 | 403 | span.big { font-size: 2em; } 404 | span.small { font-size: 0.6em; } 405 | 406 | span.underline { text-decoration: underline; } 407 | span.overline { text-decoration: overline; } 408 | span.line-through { text-decoration: line-through; } 409 | 410 | div.unbreakable { page-break-inside: avoid; } 411 | 412 | 413 | /* 414 | * xhtml11 specific 415 | * 416 | * */ 417 | 418 | div.tableblock { 419 | margin-top: 1.0em; 420 | margin-bottom: 1.5em; 421 | } 422 | div.tableblock > table { 423 | border: 3px solid #527bbd; 424 | } 425 | thead, p.table.header { 426 | font-weight: bold; 427 | color: #527bbd; 428 | } 429 | p.table { 430 | margin-top: 0; 431 | } 432 | /* Because the table frame attribute is overriden by CSS in most browsers. */ 433 | div.tableblock > table[frame="void"] { 434 | border-style: none; 435 | } 436 | div.tableblock > table[frame="hsides"] { 437 | border-left-style: none; 438 | border-right-style: none; 439 | } 440 | div.tableblock > table[frame="vsides"] { 441 | border-top-style: none; 442 | border-bottom-style: none; 443 | } 444 | 445 | 446 | /* 447 | * html5 specific 448 | * 449 | * */ 450 | 451 | table.tableblock { 452 | margin-top: 1.0em; 453 | margin-bottom: 1.5em; 454 | } 455 | thead, p.tableblock.header { 456 | font-weight: bold; 457 | color: #527bbd; 458 | } 459 | p.tableblock { 460 | margin-top: 0; 461 | } 462 | table.tableblock { 463 | border-width: 3px; 464 | border-spacing: 0px; 465 | border-style: solid; 466 | border-color: #527bbd; 467 | border-collapse: collapse; 468 | } 469 | th.tableblock, td.tableblock { 470 | border-width: 1px; 471 | padding: 4px; 472 | border-style: solid; 473 | border-color: #527bbd; 474 | } 475 | 476 | table.tableblock.frame-topbot { 477 | border-left-style: hidden; 478 | border-right-style: hidden; 479 | } 480 | table.tableblock.frame-sides { 481 | border-top-style: hidden; 482 | border-bottom-style: hidden; 483 | } 484 | table.tableblock.frame-none { 485 | border-style: hidden; 486 | } 487 | 488 | th.tableblock.halign-left, td.tableblock.halign-left { 489 | text-align: left; 490 | } 491 | th.tableblock.halign-center, td.tableblock.halign-center { 492 | text-align: center; 493 | } 494 | th.tableblock.halign-right, td.tableblock.halign-right { 495 | text-align: right; 496 | } 497 | 498 | th.tableblock.valign-top, td.tableblock.valign-top { 499 | vertical-align: top; 500 | } 501 | th.tableblock.valign-middle, td.tableblock.valign-middle { 502 | vertical-align: middle; 503 | } 504 | th.tableblock.valign-bottom, td.tableblock.valign-bottom { 505 | vertical-align: bottom; 506 | } 507 | 508 | 509 | /* 510 | * manpage specific 511 | * 512 | * */ 513 | 514 | body.manpage h1 { 515 | padding-top: 0.5em; 516 | padding-bottom: 0.5em; 517 | border-top: 2px solid silver; 518 | border-bottom: 2px solid silver; 519 | } 520 | body.manpage h2 { 521 | border-style: none; 522 | } 523 | body.manpage div.sectionbody { 524 | margin-left: 3em; 525 | } 526 | 527 | @media print { 528 | body.manpage div#toc { display: none; } 529 | } 530 | -------------------------------------------------------------------------------- /doc/static/asciidoc.js: -------------------------------------------------------------------------------- 1 | var asciidoc = { // Namespace. 2 | 3 | ///////////////////////////////////////////////////////////////////// 4 | // Table Of Contents generator 5 | ///////////////////////////////////////////////////////////////////// 6 | 7 | /* Author: Mihai Bazon, September 2002 8 | * http://students.infoiasi.ro/~mishoo 9 | * 10 | * Table Of Content generator 11 | * Version: 0.4 12 | * 13 | * Feel free to use this script under the terms of the GNU General Public 14 | * License, as long as you do not remove or alter this notice. 15 | */ 16 | 17 | /* modified by Troy D. Hanson, September 2006. License: GPL */ 18 | /* modified by Stuart Rackham, 2006, 2009. License: GPL */ 19 | 20 | // toclevels = 1..4. 21 | toc: function (toclevels) { 22 | 23 | function getText(el) { 24 | var text = ""; 25 | for (var i = el.firstChild; i != null; i = i.nextSibling) { 26 | if (i.nodeType == 3 /* Node.TEXT_NODE */) // IE doesn't speak constants. 27 | text += i.data; 28 | else if (i.firstChild != null) 29 | text += getText(i); 30 | } 31 | return text; 32 | } 33 | 34 | function TocEntry(el, text, toclevel) { 35 | this.element = el; 36 | this.text = text; 37 | this.toclevel = toclevel; 38 | } 39 | 40 | function tocEntries(el, toclevels) { 41 | var result = new Array; 42 | var re = new RegExp('[hH]([1-'+(toclevels+1)+'])'); 43 | // Function that scans the DOM tree for header elements (the DOM2 44 | // nodeIterator API would be a better technique but not supported by all 45 | // browsers). 46 | var iterate = function (el) { 47 | for (var i = el.firstChild; i != null; i = i.nextSibling) { 48 | if (i.nodeType == 1 /* Node.ELEMENT_NODE */) { 49 | var mo = re.exec(i.tagName); 50 | if (mo && (i.getAttribute("class") || i.getAttribute("className")) != "float") { 51 | result[result.length] = new TocEntry(i, getText(i), mo[1]-1); 52 | } 53 | iterate(i); 54 | } 55 | } 56 | } 57 | iterate(el); 58 | return result; 59 | } 60 | 61 | var toc = document.getElementById("toc"); 62 | if (!toc) { 63 | return; 64 | } 65 | 66 | // Delete existing TOC entries in case we're reloading the TOC. 67 | var tocEntriesToRemove = []; 68 | var i; 69 | for (i = 0; i < toc.childNodes.length; i++) { 70 | var entry = toc.childNodes[i]; 71 | if (entry.nodeName.toLowerCase() == 'div' 72 | && entry.getAttribute("class") 73 | && entry.getAttribute("class").match(/^toclevel/)) 74 | tocEntriesToRemove.push(entry); 75 | } 76 | for (i = 0; i < tocEntriesToRemove.length; i++) { 77 | toc.removeChild(tocEntriesToRemove[i]); 78 | } 79 | 80 | // Rebuild TOC entries. 81 | var entries = tocEntries(document.getElementById("content"), toclevels); 82 | for (var i = 0; i < entries.length; ++i) { 83 | var entry = entries[i]; 84 | if (entry.element.id == "") 85 | entry.element.id = "_toc_" + i; 86 | var a = document.createElement("a"); 87 | a.href = "#" + entry.element.id; 88 | a.appendChild(document.createTextNode(entry.text)); 89 | var div = document.createElement("div"); 90 | div.appendChild(a); 91 | div.className = "toclevel" + entry.toclevel; 92 | toc.appendChild(div); 93 | } 94 | if (entries.length == 0) 95 | toc.parentNode.removeChild(toc); 96 | }, 97 | 98 | 99 | ///////////////////////////////////////////////////////////////////// 100 | // Footnotes generator 101 | ///////////////////////////////////////////////////////////////////// 102 | 103 | /* Based on footnote generation code from: 104 | * http://www.brandspankingnew.net/archive/2005/07/format_footnote.html 105 | */ 106 | 107 | footnotes: function () { 108 | // Delete existing footnote entries in case we're reloading the footnotes. 109 | var i; 110 | var noteholder = document.getElementById("footnotes"); 111 | if (!noteholder) { 112 | return; 113 | } 114 | var entriesToRemove = []; 115 | for (i = 0; i < noteholder.childNodes.length; i++) { 116 | var entry = noteholder.childNodes[i]; 117 | if (entry.nodeName.toLowerCase() == 'div' && entry.getAttribute("class") == "footnote") 118 | entriesToRemove.push(entry); 119 | } 120 | for (i = 0; i < entriesToRemove.length; i++) { 121 | noteholder.removeChild(entriesToRemove[i]); 122 | } 123 | 124 | // Rebuild footnote entries. 125 | var cont = document.getElementById("content"); 126 | var spans = cont.getElementsByTagName("span"); 127 | var refs = {}; 128 | var n = 0; 129 | for (i=0; i" + n + "]"; 140 | spans[i].setAttribute("data-note", note); 141 | } 142 | noteholder.innerHTML += 143 | "
" + 144 | "" + 145 | n + ". " + note + "
"; 146 | var id =spans[i].getAttribute("id"); 147 | if (id != null) refs["#"+id] = n; 148 | } 149 | } 150 | if (n == 0) 151 | noteholder.parentNode.removeChild(noteholder); 152 | else { 153 | // Process footnoterefs. 154 | for (i=0; i" + n + "]"; 162 | } 163 | } 164 | } 165 | }, 166 | 167 | install: function(toclevels) { 168 | var timerId; 169 | 170 | function reinstall() { 171 | asciidoc.footnotes(); 172 | if (toclevels) { 173 | asciidoc.toc(toclevels); 174 | } 175 | } 176 | 177 | function reinstallAndRemoveTimer() { 178 | clearInterval(timerId); 179 | reinstall(); 180 | } 181 | 182 | timerId = setInterval(reinstall, 500); 183 | if (document.addEventListener) 184 | document.addEventListener("DOMContentLoaded", reinstallAndRemoveTimer, false); 185 | else 186 | window.onload = reinstallAndRemoveTimer; 187 | } 188 | 189 | } 190 | -------------------------------------------------------------------------------- /doc/static/niwi.css: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css?family=Lato:400,700|Roboto+Slab:400,700|Anonymous+Pro:400,700"); 2 | 3 | /* Shared CSS for AsciiDoc xhtml11 and html5 backends */ 4 | 5 | /* Default font. */ 6 | body { 7 | font-family: Georgia,serif; 8 | } 9 | 10 | /* Title font. */ 11 | h1, h2, h3, h4, h5, h6, 12 | div.title, caption.title, 13 | thead, p.table.header, 14 | #toctitle, 15 | #author, #revnumber, #revdate, #revremark, 16 | #footer { 17 | font-family: Arial,Helvetica,sans-serif; 18 | } 19 | 20 | body { 21 | margin: 1em 5% 1em 5%; 22 | } 23 | 24 | a { 25 | color: blue; 26 | text-decoration: underline; 27 | } 28 | a:visited { 29 | color: fuchsia; 30 | } 31 | 32 | em { 33 | font-style: italic; 34 | color: navy; 35 | } 36 | 37 | strong { 38 | font-weight: bold; 39 | color: #083194; 40 | } 41 | 42 | h1, h2, h3, h4, h5, h6 { 43 | color: #527bbd; 44 | margin-top: 1.2em; 45 | margin-bottom: 0.5em; 46 | line-height: 1.3; 47 | } 48 | 49 | h1, h2, h3 { 50 | border-bottom: 2px solid silver; 51 | } 52 | h2 { 53 | padding-top: 0.5em; 54 | } 55 | h3 { 56 | float: left; 57 | } 58 | h3 + * { 59 | clear: left; 60 | } 61 | h5 { 62 | font-size: 1.0em; 63 | } 64 | 65 | div.sectionbody { 66 | margin-left: 0; 67 | } 68 | 69 | hr { 70 | border: 1px solid silver; 71 | } 72 | 73 | p { 74 | margin-top: 0.5em; 75 | margin-bottom: 0.5em; 76 | } 77 | 78 | ul, ol, li > p { 79 | margin-top: 0; 80 | } 81 | ul > li { color: #aaa; } 82 | ul > li > * { color: black; } 83 | 84 | pre { 85 | padding: 0; 86 | margin: 0; 87 | font-size: 14px; 88 | } 89 | 90 | #author { 91 | color: #527bbd; 92 | font-weight: bold; 93 | font-size: 1.1em; 94 | } 95 | #email { 96 | } 97 | #revnumber, #revdate, #revremark { 98 | } 99 | 100 | #footer { 101 | font-size: small; 102 | border-top: 2px solid silver; 103 | padding-top: 0.5em; 104 | margin-top: 4.0em; 105 | } 106 | #footer-text { 107 | float: left; 108 | padding-bottom: 0.5em; 109 | } 110 | #footer-badges { 111 | float: right; 112 | padding-bottom: 0.5em; 113 | } 114 | 115 | #preamble { 116 | margin-top: 1.5em; 117 | margin-bottom: 1.5em; 118 | } 119 | div.imageblock, div.exampleblock, div.verseblock, 120 | div.quoteblock, div.literalblock, div.listingblock, div.sidebarblock, 121 | div.admonitionblock { 122 | margin-top: 1.0em; 123 | margin-bottom: 1.5em; 124 | } 125 | div.admonitionblock { 126 | margin-top: 2.0em; 127 | margin-bottom: 2.0em; 128 | margin-right: 10%; 129 | color: #606060; 130 | } 131 | 132 | div.content { /* Block element content. */ 133 | padding: 0; 134 | } 135 | 136 | /* Block element titles. */ 137 | div.title, caption.title { 138 | color: #527bbd; 139 | font-weight: bold; 140 | text-align: left; 141 | margin-top: 1.0em; 142 | margin-bottom: 0.5em; 143 | } 144 | div.title + * { 145 | margin-top: 0; 146 | } 147 | 148 | td div.title:first-child { 149 | margin-top: 0.0em; 150 | } 151 | div.content div.title:first-child { 152 | margin-top: 0.0em; 153 | } 154 | div.content + div.title { 155 | margin-top: 0.0em; 156 | } 157 | 158 | div.sidebarblock > div.content { 159 | background: #ffffee; 160 | border: 1px solid #dddddd; 161 | border-left: 4px solid #f0f0f0; 162 | padding: 0.5em; 163 | } 164 | 165 | div.listingblock > div.content { 166 | border: 1px solid #dddddd; 167 | border-left: 5px solid #f0f0f0; 168 | background: #f8f8f8; 169 | padding: 0.5em; 170 | } 171 | 172 | div.quoteblock, div.verseblock { 173 | padding-left: 1.0em; 174 | margin-left: 1.0em; 175 | margin-right: 10%; 176 | border-left: 5px solid #f0f0f0; 177 | color: #777777; 178 | } 179 | 180 | div.quoteblock > div.attribution { 181 | padding-top: 0.5em; 182 | text-align: right; 183 | } 184 | 185 | div.verseblock > pre.content { 186 | font-family: inherit; 187 | font-size: inherit; 188 | } 189 | div.verseblock > div.attribution { 190 | padding-top: 0.75em; 191 | text-align: left; 192 | } 193 | /* DEPRECATED: Pre version 8.2.7 verse style literal block. */ 194 | div.verseblock + div.attribution { 195 | text-align: left; 196 | } 197 | 198 | div.admonitionblock .icon { 199 | vertical-align: top; 200 | font-size: 1.1em; 201 | font-weight: bold; 202 | text-decoration: underline; 203 | color: #527bbd; 204 | padding-right: 0.5em; 205 | } 206 | div.admonitionblock td.content { 207 | padding-left: 0.5em; 208 | border-left: 3px solid #dddddd; 209 | } 210 | 211 | div.exampleblock > div.content { 212 | border-left: 3px solid #dddddd; 213 | padding-left: 0.5em; 214 | } 215 | 216 | div.imageblock div.content { padding-left: 0; } 217 | span.image img { border-style: none; } 218 | a.image:visited { color: white; } 219 | 220 | dl { 221 | margin-top: 0.8em; 222 | margin-bottom: 0.8em; 223 | } 224 | dt { 225 | margin-top: 0.5em; 226 | margin-bottom: 0; 227 | font-style: normal; 228 | color: navy; 229 | } 230 | dd > *:first-child { 231 | margin-top: 0.1em; 232 | } 233 | 234 | ul, ol { 235 | list-style-position: outside; 236 | } 237 | ol.arabic { 238 | list-style-type: decimal; 239 | } 240 | ol.loweralpha { 241 | list-style-type: lower-alpha; 242 | } 243 | ol.upperalpha { 244 | list-style-type: upper-alpha; 245 | } 246 | ol.lowerroman { 247 | list-style-type: lower-roman; 248 | } 249 | ol.upperroman { 250 | list-style-type: upper-roman; 251 | } 252 | 253 | div.compact ul, div.compact ol, 254 | div.compact p, div.compact p, 255 | div.compact div, div.compact div { 256 | margin-top: 0.1em; 257 | margin-bottom: 0.1em; 258 | } 259 | 260 | tfoot { 261 | font-weight: bold; 262 | } 263 | td > div.verse { 264 | white-space: pre; 265 | } 266 | 267 | div.hdlist { 268 | margin-top: 0.8em; 269 | margin-bottom: 0.8em; 270 | } 271 | div.hdlist tr { 272 | padding-bottom: 15px; 273 | } 274 | dt.hdlist1.strong, td.hdlist1.strong { 275 | font-weight: bold; 276 | } 277 | td.hdlist1 { 278 | vertical-align: top; 279 | font-style: normal; 280 | padding-right: 0.8em; 281 | color: navy; 282 | } 283 | td.hdlist2 { 284 | vertical-align: top; 285 | } 286 | div.hdlist.compact tr { 287 | margin: 0; 288 | padding-bottom: 0; 289 | } 290 | 291 | .comment { 292 | background: yellow; 293 | } 294 | 295 | .footnote, .footnoteref { 296 | font-size: 0.8em; 297 | } 298 | 299 | span.footnote, span.footnoteref { 300 | vertical-align: super; 301 | } 302 | 303 | #footnotes { 304 | margin: 20px 0 20px 0; 305 | padding: 7px 0 0 0; 306 | } 307 | 308 | #footnotes div.footnote { 309 | margin: 0 0 5px 0; 310 | } 311 | 312 | #footnotes hr { 313 | border: none; 314 | border-top: 1px solid silver; 315 | height: 1px; 316 | text-align: left; 317 | margin-left: 0; 318 | width: 20%; 319 | min-width: 100px; 320 | } 321 | 322 | div.colist td { 323 | padding-right: 0.5em; 324 | padding-bottom: 0.3em; 325 | vertical-align: top; 326 | } 327 | div.colist td img { 328 | margin-top: 0.3em; 329 | } 330 | 331 | @media print { 332 | #footer-badges { display: none; } 333 | } 334 | 335 | #toc { 336 | margin-bottom: 2.5em; 337 | } 338 | 339 | #toctitle { 340 | color: #527bbd; 341 | font-size: 1.1em; 342 | font-weight: bold; 343 | margin-top: 1.0em; 344 | margin-bottom: 0.1em; 345 | } 346 | 347 | div.toclevel1, div.toclevel2, div.toclevel3, div.toclevel4 { 348 | margin-top: 0; 349 | margin-bottom: 0; 350 | } 351 | div.toclevel2 { 352 | margin-left: 2em; 353 | font-size: 0.9em; 354 | } 355 | div.toclevel3 { 356 | margin-left: 4em; 357 | font-size: 0.9em; 358 | } 359 | div.toclevel4 { 360 | margin-left: 6em; 361 | font-size: 0.9em; 362 | } 363 | 364 | span.aqua { color: aqua; } 365 | span.black { color: black; } 366 | span.blue { color: blue; } 367 | span.fuchsia { color: fuchsia; } 368 | span.gray { color: gray; } 369 | span.green { color: green; } 370 | span.lime { color: lime; } 371 | span.maroon { color: maroon; } 372 | span.navy { color: navy; } 373 | span.olive { color: olive; } 374 | span.purple { color: purple; } 375 | span.red { color: red; } 376 | span.silver { color: silver; } 377 | span.teal { color: teal; } 378 | span.white { color: white; } 379 | span.yellow { color: yellow; } 380 | 381 | span.aqua-background { background: aqua; } 382 | span.black-background { background: black; } 383 | span.blue-background { background: blue; } 384 | span.fuchsia-background { background: fuchsia; } 385 | span.gray-background { background: gray; } 386 | span.green-background { background: green; } 387 | span.lime-background { background: lime; } 388 | span.maroon-background { background: maroon; } 389 | span.navy-background { background: navy; } 390 | span.olive-background { background: olive; } 391 | span.purple-background { background: purple; } 392 | span.red-background { background: red; } 393 | span.silver-background { background: silver; } 394 | span.teal-background { background: teal; } 395 | span.white-background { background: white; } 396 | span.yellow-background { background: yellow; } 397 | 398 | span.big { font-size: 2em; } 399 | span.small { font-size: 0.6em; } 400 | 401 | span.underline { text-decoration: underline; } 402 | span.overline { text-decoration: overline; } 403 | span.line-through { text-decoration: line-through; } 404 | 405 | 406 | /* 407 | * xhtml11 specific 408 | * 409 | * */ 410 | 411 | tt { 412 | font-family: monospace; 413 | font-size: inherit; 414 | color: navy; 415 | } 416 | 417 | div.tableblock { 418 | margin-top: 1.0em; 419 | margin-bottom: 1.5em; 420 | } 421 | div.tableblock > table { 422 | border: 3px solid #527bbd; 423 | } 424 | thead, p.table.header { 425 | font-weight: bold; 426 | color: #527bbd; 427 | } 428 | p.table { 429 | margin-top: 0; 430 | } 431 | /* Because the table frame attribute is overriden by CSS in most browsers. */ 432 | div.tableblock > table[frame="void"] { 433 | border-style: none; 434 | } 435 | div.tableblock > table[frame="hsides"] { 436 | border-left-style: none; 437 | border-right-style: none; 438 | } 439 | div.tableblock > table[frame="vsides"] { 440 | border-top-style: none; 441 | border-bottom-style: none; 442 | } 443 | 444 | 445 | /* 446 | * html5 specific 447 | * 448 | * */ 449 | 450 | .monospaced { 451 | font-family: monospace; 452 | font-size: inherit; 453 | color: navy; 454 | } 455 | 456 | table.tableblock { 457 | margin-top: 1.0em; 458 | margin-bottom: 1.5em; 459 | } 460 | thead, p.tableblock.header { 461 | font-weight: bold; 462 | color: #527bbd; 463 | } 464 | p.tableblock { 465 | margin-top: 0; 466 | } 467 | table.tableblock { 468 | border-width: 3px; 469 | border-spacing: 0px; 470 | border-style: solid; 471 | border-color: #527bbd; 472 | border-collapse: collapse; 473 | } 474 | th.tableblock, td.tableblock { 475 | border-width: 1px; 476 | padding: 4px; 477 | border-style: solid; 478 | border-color: #527bbd; 479 | } 480 | 481 | table.tableblock.frame-topbot { 482 | border-left-style: hidden; 483 | border-right-style: hidden; 484 | } 485 | table.tableblock.frame-sides { 486 | border-top-style: hidden; 487 | border-bottom-style: hidden; 488 | } 489 | table.tableblock.frame-none { 490 | border-style: hidden; 491 | } 492 | 493 | th.tableblock.halign-left, td.tableblock.halign-left { 494 | text-align: left; 495 | } 496 | th.tableblock.halign-center, td.tableblock.halign-center { 497 | text-align: center; 498 | } 499 | th.tableblock.halign-right, td.tableblock.halign-right { 500 | text-align: right; 501 | } 502 | 503 | th.tableblock.valign-top, td.tableblock.valign-top { 504 | vertical-align: top; 505 | } 506 | th.tableblock.valign-middle, td.tableblock.valign-middle { 507 | vertical-align: middle; 508 | } 509 | th.tableblock.valign-bottom, td.tableblock.valign-bottom { 510 | vertical-align: bottom; 511 | } 512 | 513 | 514 | /* 515 | * manpage specific 516 | * 517 | * */ 518 | 519 | body.manpage h1 { 520 | padding-top: 0.5em; 521 | padding-bottom: 0.5em; 522 | border-top: 2px solid silver; 523 | border-bottom: 2px solid silver; 524 | } 525 | body.manpage h2 { 526 | border-style: none; 527 | } 528 | body.manpage div.sectionbody { 529 | margin-left: 3em; 530 | } 531 | 532 | @media print { 533 | body.manpage div#toc { display: none; } 534 | } 535 | 536 | 537 | /* 538 | * Theme specific overrides of the preceding (asciidoc.css) CSS. 539 | * 540 | */ 541 | body { 542 | font-family: Garamond, Georgia, serif; 543 | font-family: "Lato","proxima-nova","Helvetica Neue",Arial,sans-serif; 544 | font-size: 19px; 545 | color: #3E4349; 546 | line-height: 1.3em; 547 | } 548 | h1, h2, h3, h4, h5, h6, 549 | div.title, caption.title, 550 | thead, p.table.header, 551 | #toctitle, 552 | #author, #revnumber, #revdate, #revremark, 553 | #footer { 554 | font-family: Garmond, Georgia, serif; 555 | font-family: "Roboto Slab","ff-tisa-web-pro","Georgia",Arial,sans-serif; 556 | border-bottom-width: 0; 557 | color: #3E4349; 558 | } 559 | div.title, caption.title { color: #596673; font-weight: normal; font-size:14px; } 560 | h1 { font-size: 240%; } 561 | h2 { font-size: 180%; } 562 | h3 { font-size: 150%; } 563 | h4 { font-size: 130%; } 564 | h5 { font-size: 115%; } 565 | h6 { font-size: 100%; } 566 | #header h1 { margin-top: 0; } 567 | #toc { 568 | color: #444444; 569 | line-height: 1.5; 570 | padding-top: 1.5em; 571 | } 572 | #toctitle { 573 | font-size: 20px; 574 | } 575 | #toc a { 576 | border-bottom: 1px dotted #999999; 577 | color: #444444 !important; 578 | text-decoration: none !important; 579 | } 580 | #toc a:hover { 581 | border-bottom: 1px solid #6D4100; 582 | color: #6D4100 !important; 583 | text-decoration: none !important; 584 | } 585 | div.toclevel1 { margin-top: 0.2em; font-size: 16px; } 586 | div.toclevel2 { margin-top: 0.15em; font-size: 14px; } 587 | em, dt, td.hdlist1 { color: black; } 588 | strong { color: #3E4349; } 589 | a { color: #004B6B; text-decoration: none; border-bottom: 1px dotted #004B6B; } 590 | a:visited { color: #615FA0; border-bottom: 1px dotted #615FA0; } 591 | a:hover { color: #6D4100; border-bottom: 1px solid #6D4100; } 592 | div.tableblock > table, table.tableblock { border: 3px solid #E8E8E8; } 593 | th.tableblock, td.tableblock { border: 1px solid #E8E8E8; } 594 | ul > li > * { color: #3E4349; } 595 | pre { 596 | font-family: "Anonymous Pro", Consolas,Menlo,'Deja Vu Sans Mono','Bitstream Vera Sans Mono',monospace; 597 | font-size: 16px; 598 | } 599 | tt, .monospaced { 600 | color: black; 601 | font-family: 'Deja Vu Sans Mono', monospace; 602 | font-size: 16px; 603 | } 604 | div.exampleblock > div.content, div.sidebarblock > div.content, div.listingblock > div.content { border-width: 0 0 0 3px; border-color: #E8E8E8; } 605 | div.verseblock { border-left-width: 0; margin-left: 3em; } 606 | div.quoteblock { border-left-width: 3px; margin-left: 0; margin-right: 0;} 607 | div.admonitionblock td.content { border-left: 3px solid #E8E8E8; } 608 | -------------------------------------------------------------------------------- /doc/static/pygments.css: -------------------------------------------------------------------------------- 1 | .hll { 2 | background-color:#eee; 3 | } 4 | .c { 5 | color:#408090; 6 | font-style:italic; 7 | } 8 | .err { 9 | border:1px solid #FF0000; 10 | } 11 | .k { 12 | color:#007020; 13 | font-weight:bold; 14 | } 15 | .o { 16 | color:#666666; 17 | } 18 | .cm { 19 | color:#408090; 20 | font-style:italic; 21 | } 22 | .cp { 23 | color:#007020; 24 | } 25 | .c1 { 26 | color:#408090; 27 | font-style:italic; 28 | } 29 | .cs { 30 | background-color:#FFF0F0; 31 | color:#408090; 32 | } 33 | .gd { 34 | color:#A00000; 35 | } 36 | .ge { 37 | font-style:italic; 38 | } 39 | .gr { 40 | color:#FF0000; 41 | } 42 | .gh { 43 | color:#000080; 44 | font-weight:bold; 45 | } 46 | .gi { 47 | color:#00A000; 48 | } 49 | .go { 50 | color:#303030; 51 | } 52 | .gp { 53 | color:#C65D09; 54 | font-weight:bold; 55 | } 56 | .gs { 57 | font-weight:bold; 58 | } 59 | .gu { 60 | color:#800080; 61 | font-weight:bold; 62 | } 63 | .gt { 64 | color:#0040D0; 65 | } 66 | .kc { 67 | color:#007020; 68 | font-weight:bold; 69 | } 70 | .kd { 71 | color:#007020; 72 | font-weight:bold; 73 | } 74 | .kn { 75 | color:#007020; 76 | font-weight:bold; 77 | } 78 | .kp { 79 | color:#007020; 80 | } 81 | .kr { 82 | color:#007020; 83 | font-weight:bold; 84 | } 85 | .kt { 86 | color:#902000; 87 | } 88 | .m { 89 | color:#208050; 90 | } 91 | .s { 92 | color:#4070A0; 93 | } 94 | .na { 95 | color:#4070A0; 96 | } 97 | .nb { 98 | color:#007020; 99 | } 100 | .nc { 101 | color:#0E84B5; 102 | font-weight:bold; 103 | } 104 | .no { 105 | color:#60ADD5; 106 | } 107 | .nd { 108 | color:#555555; 109 | font-weight:bold; 110 | } 111 | .ni { 112 | color:#D55537; 113 | font-weight:bold; 114 | } 115 | .ne { 116 | color:#007020; 117 | } 118 | .nf { 119 | color:#06287E; 120 | } 121 | .nl { 122 | color:#002070; 123 | font-weight:bold; 124 | } 125 | .nn { 126 | color:#0E84B5; 127 | font-weight:bold; 128 | } 129 | .nt { 130 | color:#062873; 131 | font-weight:bold; 132 | } 133 | .nv { 134 | color:#BB60D5; 135 | } 136 | .ow { 137 | color:#007020; 138 | font-weight:bold; 139 | } 140 | .w { 141 | color:#BBBBBB; 142 | } 143 | .mf { 144 | color:#208050; 145 | } 146 | .mh { 147 | color:#208050; 148 | } 149 | .mi { 150 | color:#208050; 151 | } 152 | .mo { 153 | color:#208050; 154 | } 155 | .sb { 156 | color:#4070A0; 157 | } 158 | .sc { 159 | color:#4070A0; 160 | } 161 | .sd { 162 | color:#4070A0; 163 | font-style:italic; 164 | } 165 | .s2 { 166 | color:#4070A0; 167 | } 168 | .se { 169 | color:#4070A0; 170 | font-weight:bold; 171 | } 172 | .sh { 173 | color:#4070A0; 174 | } 175 | .si { 176 | color:#70A0D0; 177 | font-style:italic; 178 | } 179 | .sx { 180 | color:#C65D09; 181 | } 182 | .sr { 183 | color:#235388; 184 | } 185 | .s1 { 186 | color:#4070A0; 187 | } 188 | .ss { 189 | color:#517918; 190 | } 191 | .bp { 192 | color:#007020; 193 | } 194 | .vc { 195 | color:#BB60D5; 196 | } 197 | .vg { 198 | color:#BB60D5; 199 | } 200 | .vi { 201 | color:#BB60D5; 202 | } 203 | .il { 204 | color:#208050; 205 | } 206 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | django>=2.0 2 | psycopg2==2.8.4 3 | celery==4.1.1 4 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | import django 6 | from django.conf import settings 7 | from django.test.utils import get_runner 8 | 9 | if __name__ == "__main__": 10 | os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.test_settings' 11 | django.setup() 12 | TestRunner = get_runner(settings) 13 | test_runner = TestRunner() 14 | failures = test_runner.run_tests(["tests"]) 15 | sys.exit(bool(failures)) 16 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | 3 | description = """ 4 | Simple, powerful and non-obstructive django email middleware. 5 | """ 6 | 7 | with open("README.rst", "r") as fh: 8 | long_description = fh.read() 9 | 10 | setup( 11 | name='djmail', 12 | url='https://github.com/bameda/djmail', 13 | author='Andrey Antukh', 14 | author_email='niwi@niwi.nz', 15 | maintainer='David Barragán Merino', 16 | maintainer_email='bameda@dbarraagan.com', 17 | license='BSD', 18 | version='2.0.0', 19 | packages=find_packages(exclude=['contrib', 'docs', 'test*']), 20 | description=description.strip(), 21 | long_description=long_description, 22 | long_description_content_type='text/x-rst', 23 | zip_safe=False, 24 | include_package_data=True, 25 | package_data={ 26 | '': ['*.html'], 27 | }, 28 | classifiers=[ 29 | # 'Development Status :: 5 - Production/Stable', 30 | 'Development Status :: 4 - Beta', 31 | 'Operating System :: OS Independent', 32 | 'Environment :: Web Environment', 33 | 'Framework :: Django', 34 | 'License :: OSI Approved :: BSD License', 35 | 'Intended Audience :: Developers', 36 | 'Programming Language :: Python', 37 | 'Programming Language :: Python :: 3', 38 | 'Programming Language :: Python :: 3.6', 39 | 'Programming Language :: Python :: 3.7', 40 | 'Programming Language :: Python :: 3.8', 41 | 'Topic :: Software Development :: Libraries', 42 | 'Topic :: Utilities', 43 | ] 44 | ) 45 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- -------------------------------------------------------------------------------- /tests/mocks.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals 4 | 5 | from django.core.mail.backends.locmem import EmailBackend as BaseEmailBackend 6 | 7 | 8 | class BrokenEmailBackend(BaseEmailBackend): 9 | def send_messages(self, messages): 10 | return 0 11 | -------------------------------------------------------------------------------- /tests/test_settings.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | SECRET_KEY = 'p23jof024jf5-94j3f023jf230=fj234fp34fijo' 4 | 5 | DATABASES = { 6 | 'default': { 7 | 'ENGINE': 'django.db.backends.postgresql', 8 | 'NAME': 'postgres', 9 | } 10 | } 11 | 12 | INSTALLED_APPS = [ 13 | 'djmail', 14 | 'tests' 15 | ] 16 | 17 | TEMPLATES = [ 18 | { 19 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 20 | 'DIRS': [], 21 | 'APP_DIRS': True, 22 | }, 23 | ] 24 | 25 | CELERY_TASK_ALWAYS_EAGER = True 26 | -------------------------------------------------------------------------------- /tests/tests.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals 4 | 5 | import json 6 | import sys 7 | from datetime import datetime, timedelta 8 | from io import StringIO 9 | 10 | from django.core import mail 11 | from django.core.mail import EmailMessage 12 | from django.core.management import call_command 13 | from django.test import TestCase 14 | from django.test.utils import override_settings 15 | 16 | from djmail import core, exceptions, utils 17 | from djmail.models import Message 18 | from djmail.template_mail import MagicMailBuilder, TemplateMail, make_email 19 | 20 | 21 | class EmailTestCaseMixin(object): 22 | def setUp(self): 23 | Message.objects.all().delete() 24 | self.email = EmailMessage('Hello', 'Body goes here', 'from@example.com', ['to1@example.com', 'to2@example.com']) 25 | 26 | def assertEmailEqual(self, a, b): 27 | # Can't do simple equality comparison... That sucks! 28 | self.assertEqual(a.__class__, b.__class__) 29 | self.assertEqual(a.__dict__, b.__dict__) 30 | self.assertEqual(dir(a), dir(b)) 31 | 32 | 33 | class TestEmailSending(EmailTestCaseMixin, TestCase): 34 | @override_settings( 35 | EMAIL_BACKEND='djmail.backends.default.EmailBackend', 36 | DJMAIL_REAL_BACKEND='django.core.mail.backends.locmem.EmailBackend') 37 | def test_simple_send_email(self): 38 | self.email.send() 39 | self.assertEqual(len(mail.outbox), 1) 40 | self.assertEqual(Message.objects.count(), 1) 41 | 42 | @override_settings( 43 | EMAIL_BACKEND='djmail.backends.async.EmailBackend', 44 | DJMAIL_REAL_BACKEND='django.core.mail.backends.locmem.EmailBackend', 45 | DJMAIL_SEND_ASYNC=True) 46 | def test_async_send_email(self): 47 | # If async is activated, send method returns a thread instead of 48 | # a number of sent messages. 49 | future = self.email.send() 50 | future.result() 51 | 52 | self.assertEqual(len(mail.outbox), 1) 53 | self.assertEqual(Message.objects.count(), 1) 54 | 55 | @override_settings( 56 | EMAIL_BACKEND='djmail.backends.default.EmailBackend', 57 | DJMAIL_REAL_BACKEND='tests.mocks.BrokenEmailBackend') 58 | def test_failing_simple_send_email(self): 59 | number_sent_emails = self.email.send() 60 | 61 | self.assertEqual(number_sent_emails, 0) 62 | self.assertEqual(len(mail.outbox), 0) 63 | self.assertEqual(Message.objects.count(), 1) 64 | self.assertEqual(Message.objects.get().status, Message.STATUS_FAILED) 65 | 66 | @override_settings( 67 | EMAIL_BACKEND='djmail.backends.async.EmailBackend', 68 | DJMAIL_REAL_BACKEND='tests.mocks.BrokenEmailBackend') 69 | def test_failing_async_send_email(self): 70 | future = self.email.send() 71 | future.result() 72 | 73 | self.assertEqual(len(mail.outbox), 0) 74 | self.assertEqual(Message.objects.count(), 1) 75 | self.assertEqual(Message.objects.get().status, Message.STATUS_FAILED) 76 | 77 | @override_settings( 78 | EMAIL_BACKEND='djmail.backends.celery.EmailBackend', 79 | DJMAIL_REAL_BACKEND='django.core.mail.backends.locmem.EmailBackend') 80 | def test_async_send_email_with_celery(self): 81 | result = self.email.send() 82 | 83 | self.assertEqual(len(mail.outbox), 1) 84 | self.assertEqual(Message.objects.count(), 1) 85 | 86 | @override_settings( 87 | EMAIL_BACKEND='djmail.backends.celery.EmailBackend', 88 | DJMAIL_REAL_BACKEND='tests.mocks.BrokenEmailBackend') 89 | def test_failing_async_send_email_with_celery(self): 90 | result = self.email.send() 91 | 92 | self.assertEqual(len(mail.outbox), 0) 93 | self.assertEqual(Message.objects.count(), 1) 94 | self.assertEqual(Message.objects.get().status, Message.STATUS_FAILED) 95 | 96 | @override_settings( 97 | EMAIL_BACKEND='djmail.backends.celery.EmailBackend', 98 | DJMAIL_REAL_BACKEND='tests.mocks.BrokenEmailBackend') 99 | def test_failing_retry_send_01(self): 100 | message_model = Message.from_email_message(self.email) 101 | message_model.status = Message.STATUS_FAILED 102 | message_model.retry_count = 1 103 | message_model.save() 104 | 105 | core._retry_send_messages() 106 | 107 | message_model_2 = Message.objects.get(pk=message_model.pk) 108 | self.assertEqual(message_model_2.retry_count, 2) 109 | 110 | @override_settings( 111 | EMAIL_BACKEND='djmail.backends.celery.EmailBackend', 112 | DJMAIL_REAL_BACKEND='tests.mocks.BrokenEmailBackend', 113 | DJMAIL_MAX_RETRY_NUMBER=2) 114 | def test_failing_retry_send_02(self): 115 | message_model = Message.from_email_message(self.email) 116 | message_model.status = Message.STATUS_FAILED 117 | message_model.retry_count = 3 118 | message_model.save() 119 | 120 | core._mark_discarded_messages() 121 | 122 | message_model_2 = Message.objects.get(pk=message_model.pk) 123 | self.assertEqual(message_model_2.retry_count, 3) 124 | self.assertEqual(message_model_2.status, Message.STATUS_DISCARDED) 125 | 126 | 127 | class TestTemplateEmailSending(EmailTestCaseMixin, TestCase): 128 | @override_settings( 129 | EMAIL_BACKEND='djmail.backends.default.EmailBackend', 130 | DJMAIL_REAL_BACKEND='django.core.mail.backends.locmem.EmailBackend') 131 | def test_simple_send_email_1(self): 132 | class SimpleTemplateMail(TemplateMail): 133 | name = 'test_email1' 134 | 135 | email = SimpleTemplateMail() 136 | email.send('to@example.com', {'name': 'foo'}) 137 | 138 | self.assertEqual(len(mail.outbox), 1) 139 | self.assertEqual(Message.objects.count(), 1) 140 | 141 | m = mail.outbox[0] 142 | self.assertEqual(m.subject, u'Subject1: foo') 143 | self.assertEqual(m.body, u'Mail1: foo\n') 144 | 145 | @override_settings( 146 | EMAIL_BACKEND='djmail.backends.default.EmailBackend', 147 | DJMAIL_REAL_BACKEND='django.core.mail.backends.locmem.EmailBackend') 148 | def test_simple_send_email_2(self): 149 | class SimpleTemplateMail(TemplateMail): 150 | name = 'test_email2' 151 | 152 | email = SimpleTemplateMail() 153 | email.send('to@example.com', {'name': 'foo'}) 154 | 155 | self.assertEqual(len(mail.outbox), 1) 156 | self.assertEqual(Message.objects.count(), 1) 157 | 158 | m = mail.outbox[0] 159 | self.assertEqual(m.subject, u'Subject2: foo') 160 | self.assertEqual(m.body, u'body\n') 161 | self.assertEqual(m.alternatives, [(u'Body\n', 'text/html')]) 162 | 163 | @override_settings( 164 | EMAIL_BACKEND='djmail.backends.default.EmailBackend', 165 | DJMAIL_REAL_BACKEND='django.core.mail.backends.locmem.EmailBackend') 166 | def test_simple_send_email_with_magic_builder_1(self): 167 | mails = MagicMailBuilder() 168 | 169 | email = mails.test_email2('to@example.com', {'name': 'foo'}) 170 | email.send() 171 | 172 | self.assertEqual(len(mail.outbox), 1) 173 | self.assertEqual(Message.objects.count(), 1) 174 | 175 | self.assertEqual(email.subject, u'Subject2: foo') 176 | self.assertEqual(email.body, u"body\n") 177 | self.assertEqual(email.alternatives, [(u'Body\n', 'text/html')]) 178 | 179 | @override_settings( 180 | EMAIL_BACKEND="djmail.backends.default.EmailBackend", 181 | DJMAIL_REAL_BACKEND="django.core.mail.backends.locmem.EmailBackend") 182 | def test_simple_send_email_with_magic_builder_1_with_extra_kwargs(self): 183 | mails = MagicMailBuilder() 184 | 185 | email = mails.test_email2( 186 | "to@example.com", {"name": "foo"}, from_email="no-reply@test.com") 187 | email.send() 188 | 189 | self.assertEqual(len(mail.outbox), 1) 190 | self.assertEqual(Message.objects.count(), 1) 191 | 192 | self.assertEqual(email.subject, 'Subject2: foo') 193 | self.assertEqual(email.body, 'body\n') 194 | self.assertEqual(email.alternatives, [(u'Body\n', 'text/html')]) 195 | 196 | def test_simple_email_building(self): 197 | email = make_email( 198 | 'test_email1', to='to@example.com', context={'name': 'foo'}) 199 | 200 | self.assertEqual(email.subject, 'Subject1: foo') 201 | self.assertEqual(email.body, 'Mail1: foo\n') 202 | 203 | def test_proper_handlign_different_uses_cases(self): 204 | from django.core import mail 205 | 206 | email1 = make_email( 207 | 'test_email1', to='to@example.com', context={'name': 'foo'}) 208 | 209 | email2 = make_email( 210 | 'test_email2', to='to@example.com', context={'name': 'foo'}) 211 | 212 | email3 = make_email( 213 | 'test_email3', to='to@example.com', context={'name': 'foo'}) 214 | 215 | self.assertIsInstance(email1, mail.EmailMessage) 216 | self.assertEqual(email1.content_subtype, 'html') 217 | 218 | self.assertIsInstance(email2, mail.EmailMultiAlternatives) 219 | 220 | self.assertIsInstance(email3, mail.EmailMessage) 221 | self.assertEqual(email3.content_subtype, 'plain') 222 | 223 | @override_settings( 224 | EMAIL_BACKEND='djmail.backends.default.EmailBackend', 225 | DJMAIL_REAL_BACKEND='django.core.mail.backends.locmem.EmailBackend') 226 | def test_simple_send_email_with_magic_builder_1_with_low_priority(self): 227 | mails = MagicMailBuilder() 228 | 229 | email = mails.test_email2( 230 | 'to@example.com', {'name': 'foo'}, priority=10) 231 | email.send() 232 | 233 | self.assertEqual(len(mail.outbox), 0) 234 | self.assertEqual(Message.objects.count(), 1) 235 | 236 | m1 = Message.objects.get() 237 | self.assertEqual(m1.status, Message.STATUS_PENDING) 238 | self.assertEqual(m1.priority, 10) 239 | 240 | core._send_pending_messages() 241 | 242 | self.assertEqual(len(mail.outbox), 1) 243 | self.assertEqual(Message.objects.count(), 1) 244 | 245 | m2 = Message.objects.get() 246 | self.assertEqual(m2.status, Message.STATUS_SENT) 247 | self.assertEqual(m2.priority, 10) 248 | 249 | def test_error_when_there_is_no_email_body_templates(self): 250 | with self.assertRaises(exceptions.TemplateNotFound): 251 | email = make_email( 252 | 'test_email_error_with_no_body', to='to@example.com', context={'name': 'foo'}) 253 | 254 | 255 | 256 | class SerializationEmailTests(EmailTestCaseMixin, TestCase): 257 | def test_serialization_loop(self): 258 | data = utils.serialize_email_message(self.email) 259 | email_bis = utils.deserialize_email_message(data) 260 | self.assertEmailEqual(self.email, email_bis) 261 | 262 | def test_json_serialization_loop(self): 263 | with self.assertRaises(TypeError): 264 | json.dumps(self.email) 265 | json_data = json.dumps(utils.serialize_email_message(self.email)) 266 | email_bis = utils.deserialize_email_message(json.loads(json_data)) 267 | self.assertEmailEqual(self.email, email_bis) 268 | 269 | @override_settings( 270 | EMAIL_BACKEND='djmail.backends.default.EmailBackend', 271 | DJMAIL_REAL_BACKEND='django.core.mail.backends.locmem.EmailBackend') 272 | def test_simple_send_email_with_magic_builder_1(self): 273 | mails = MagicMailBuilder() 274 | 275 | email = mails.test_email2('to@example.com', {'name': 'foo'}) 276 | email.send() 277 | 278 | model = Message.objects.get() 279 | self.assertEqual(email.from_email, model.from_email) 280 | self.assertEqual(email.to, model.to_email.split(',')) 281 | self.assertEqual(email.subject, model.subject) 282 | self.assertEqual(email.body, model.body_text) 283 | 284 | 285 | class CleanupManagementCommand(EmailTestCaseMixin, TestCase): 286 | def setUp(self): 287 | super(CleanupManagementCommand, self).setUp() 288 | # Create a message that was successfully sent 1 year ago 289 | self.old_log = Message.from_email_message(self.email) 290 | self.old_log.status = Message.STATUS_SENT 291 | self.old_log.sent_at = datetime.now() - timedelta(days=365) 292 | self.old_log.save() 293 | 294 | @override_settings( 295 | EMAIL_BACKEND='djmail.backends.default.EmailBackend', 296 | DJMAIL_REAL_BACKEND='django.core.mail.backends.locmem.EmailBackend') 297 | def test_delete_old_message_with_default_days(self): 298 | self.email.send() 299 | self.assertEqual(Message.objects.count(), 2) 300 | out = StringIO() 301 | sys.stout = out 302 | call_command('djmail_delete_old_messages', stdout=out) 303 | self.assertEqual(Message.objects.count(), 1) 304 | 305 | @override_settings( 306 | EMAIL_BACKEND='djmail.backends.default.EmailBackend', 307 | DJMAIL_REAL_BACKEND='django.core.mail.backends.locmem.EmailBackend') 308 | def test_retain_old_message_with_specified_days(self): 309 | self.email.send() 310 | self.assertEqual(Message.objects.count(), 2) 311 | out = StringIO() 312 | sys.stout = out 313 | call_command('djmail_delete_old_messages', '--days', '366', stdout=out) 314 | self.assertEqual(Message.objects.count(), 2) 315 | --------------------------------------------------------------------------------