diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..5853e638 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + commit-message: + prefix: "chore(ci): " + groups: + github-actions: + patterns: + - "*" + open-pull-requests-limit: 1 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 00000000..a8aca6fc --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,36 @@ +name: Publish django-post_office + +on: + push: + tags: + - '*' + +jobs: + publish: + name: "Publish release" + runs-on: "ubuntu-latest" + + environment: + name: deploy + + strategy: + matrix: + python-version: ["3.9"] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install build --user + - name: Build 🐍 Python 📦 Package + run: python -m build --sdist --wheel --outdir dist/ + - name: Publish 🐍 Python 📦 Package to PyPI + if: startsWith(github.ref, 'refs/tags') + uses: pypa/gh-action-pypi-publish@master + with: + password: ${{ secrets.PYPI_API_TOKEN_POST_OFFICE }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 015aed08..4502f3ed 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,37 +2,47 @@ name: Test on: push: - branches: [ master ] pull_request: - branches: [ master ] permissions: contents: read -jobs: +jobs: + ruff-format: + runs-on: ubuntu-latest + timeout-minutes: 1 + steps: + - uses: actions/checkout@v4 + - uses: chartboost/ruff-action@v1 + with: + version: 0.4.8 + args: 'format --check' build: runs-on: ubuntu-latest name: Python${{ matrix.python-version }}/Django${{ matrix.django-version }} strategy: matrix: - python-version: ["3.9"] - django-version: ["3.2.16", "4.0.8", "4.1.3"] + python-version: ["3.9", "3.10", "3.11", "3.12"] + django-version: ["4.2", "5.0"] + exclude: + - python-version: "3.9" + django-version: "5.0" + steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4.2.0 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip - pip install django==${{ matrix.django-version }} - pip install jsonfield pytz + pip install "Django~=${{ matrix.django-version }}.0" - name: Run Test run: | - `which django-admin` test post_office --settings=post_office.test_settings --pythonpath=. \ No newline at end of file + `which django-admin` test post_office --settings=post_office.test_settings --pythonpath=. diff --git a/CHANGELOG.md b/CHANGELOG.md index b63565e6..a65f8f72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,31 @@ Changelog ========= +Version 3.9.0 (2024-06-19) +-------------------------- +* Added a new `LOCK_FILE_NAME` which lets you change post office's lock file name. Thanks @mogost! +* Fixes a bug where `email_queued` signal is not sent in certain cases. Thanks @diesieben07! +* Fixes an issue where attachment admin page would not render with large number of emails. Thanks @petrprikryl! +* Fixes a crash when email instances are made with context, but without a template. Thanks @pacahon! +* Other miscellaneous fixes and house keeping tasks by @mogost! + +Version 3.8.0 (2023-10-22) +-------------------------- +* Added `BATCH_DELIVERY_TIMEOUT` that specifies the maximum time allowed for each batch to be delivered. Defaults to 180 seconds. Thanks @selwin! + +Version 3.7.1 (2023-08-08) +-------------------------- +* Optimized a queryset in `get_queued()` that doesn't use indexes in Postgres. Thanks @marsha97! +* Removed `date_hierarchy` option which causes admin to load slowly on DBs with a large number of emails. Thanks @selwin! +* Optimized `cleanup_expired_mails()` so that deletes emails in smaller batches. Thanks @marsha97! + +Version 3.7.0 (2023-05-30) +-------------------------- +* Changed JSON columns to use Django's `JSONField` and drop `jsonfield` dependency. Thanks @jrief! +* Fixed saving HTML emails that have `quoted_printable`. Thanks @gabn88! +* Fixes an issue where emails are rendered without context in Django's admin interface. Thanks @zagl! +* This version no longer supports Django 3.1. + Version 3.6.3 (2022-10-27) -------------------------- * Fixed an issue where emails may not be rendered with context. Thanks @zagl! diff --git a/README.md b/README.md index b498f20c..c963cd7e 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ Django Post Office is a simple app to send and manage your emails in Django. Some awesome features are: +- Designed to scale, handles millions of emails efficiently - Allows you to send email asynchronously - Multi backend support - Supports HTML email @@ -12,8 +13,7 @@ Django. Some awesome features are: - Built in scheduling support - Works well with task queues like [RQ](http://python-rq.org) or [Celery](http://www.celeryproject.org) -- Uses multiprocessing (and threading) to send a large number of - emails in parallel +- Uses multiprocessing and threading to send a large number of emails in parallel ## Dependencies @@ -27,10 +27,11 @@ will otherwise be stripped for security reasons. ## Installation -[![Build -Status](https://travis-ci.org/ui/django-post_office.png?branch=master)](https://travis-ci.org/ui/django-post_office) [![PyPI version](https://img.shields.io/pypi/v/django-post_office.svg)](https://pypi.org/project/django-post_office/) ![Software license](https://img.shields.io/pypi/l/django-post_office.svg) +[![Build Status](https://github.com/ui/django-post_office/actions/workflows/test.yml/badge.svg)](https://github.com/ui/django-post_office/actions) +[![PyPI](https://img.shields.io/pypi/pyversions/django-post_office.svg)]() +[![PyPI version](https://img.shields.io/pypi/v/django-post_office.svg)](https://pypi.python.org/pypi/django-post_office) +[![PyPI](https://img.shields.io/pypi/l/django-post_office.svg)]() -Install from PyPI (or [manually download from PyPI](http://pypi.python.org/pypi/django-post_office)): ```sh pip install django-post_office @@ -311,6 +312,7 @@ inlined images, use the following code snippet: ```python from django.core.mail import EmailMultiAlternatives +from django.template.loader import get_template subject, body = "Hello", "Plain text body" from_email, to_email = "no-reply@example.com", "john@example.com" @@ -328,6 +330,7 @@ plain text body, use this code snippet: ```python from django.core.mail import EmailMultiAlternatives +from django.template.loader import get_template subject, from_email, to_email = "Hello", "no-reply@example.com", "john@example.com" template = get_template('email-template-name.html', using='post_office') @@ -409,15 +412,29 @@ put in Django's `settings.py` to fine tune `post-office`'s behavior. ### Batch Size -If you may want to limit the number of emails sent in a batch (sometimes +If you may want to limit the number of emails sent in a batch ( useful in a low memory environment), use the `BATCH_SIZE` argument to -limit the number of queued emails fetched in one batch. +limit the number of queued emails fetched in one batch. `BATCH_SIZE` defaults to 100. ```python # Put this in settings.py POST_OFFICE = { ... - 'BATCH_SIZE': 50, + 'BATCH_SIZE': 100, +} +``` + +Version 3.8 introduces a companion setting called `BATCH_DELIVERY_TIMEOUT`. This setting +specifies the maximum time allowed for each batch to be delivered, this is useful to guard against +cases where delivery process never terminates. Defaults to 180. + +If you send a large number of emails in a single batch on a slow connection, consider increasing this number. + +```python +# Put this in settings.py +POST_OFFICE = { + ... + 'BATCH_DELIVERY_TIMEOUT': 180, } ``` @@ -435,6 +452,17 @@ POST_OFFICE = { } ``` +### Lock File Name +The default lock file name is `post_office`, but this can be altered by setting `LOCK_FILE_NAME` in the configuration. + +```python +# Put this in settings.py +POST_OFFICE = { + ... + 'LOCK_FILE_NAME': 'custom_lock_file', +} +``` + ### Override Recipients Defaults to `None`. This option is useful if you want to redirect all @@ -550,7 +578,7 @@ POST_OFFICE = { } ``` -`CONTEXT_FIELD_CLASS` defaults to `jsonfield.JSONField`. +`CONTEXT_FIELD_CLASS` defaults to `django.db.models.JSONField`. ### Logging @@ -665,7 +693,7 @@ Attachments are not supported with `mail.send_many()`. To run the test suite: ```python -`which django-admin.py` test post_office --settings=post_office.test_settings --pythonpath=. +`which django-admin` test post_office --settings=post_office.test_settings --pythonpath=. ``` You can run the full test suite for all supported versions of Django and Python with: diff --git a/post_office/__init__.py b/post_office/__init__.py index 28f31ab2..b7343d6d 100644 --- a/post_office/__init__.py +++ b/post_office/__init__.py @@ -1,11 +1,7 @@ -import django from ast import literal_eval from os.path import dirname, join -with open(join(dirname(__file__), 'version.txt'), 'r') as fh: +with open(join(dirname(__file__), 'version.txt')) as fh: VERSION = literal_eval(fh.read()) from .backends import EmailBackend - -if django.VERSION < (3, 2): # pragma: no cover - default_app_config = 'post_office.apps.PostOfficeConfig' diff --git a/post_office/admin.py b/post_office/admin.py index 4e1bb172..41dea32f 100644 --- a/post_office/admin.py +++ b/post_office/admin.py @@ -8,8 +8,7 @@ from django.db import models from django.forms import BaseInlineFormSet from django.forms.widgets import TextInput -from django.http.response import (HttpResponse, HttpResponseNotFound, - HttpResponseRedirect) +from django.http.response import HttpResponse, HttpResponseNotFound, HttpResponseRedirect from django.template import Context, Template from django.urls import re_path, reverse from django.utils.html import format_html @@ -17,14 +16,12 @@ from django.utils.translation import gettext_lazy as _ from .fields import CommaSeparatedEmailField -from .mail import send from .models import STATUS, Attachment, Email, EmailTemplate, Log from .sanitizer import clean_html def get_message_preview(instance): - return ('{0}...'.format(instance.message[:25]) if len(instance.message) > 25 - else instance.message) + return f'{instance.message[:25]}...' if len(instance.message) > 25 else instance.message get_message_preview.short_description = 'Message' @@ -33,7 +30,7 @@ def get_message_preview(instance): class AttachmentInline(admin.StackedInline): model = Attachment.emails.through extra = 0 - autocomplete_fields = ["attachment"] + autocomplete_fields = ['attachment'] def get_formset(self, request, obj=None, **kwargs): self.parent_obj = obj @@ -52,7 +49,7 @@ def get_queryset(self, request): a.id for a in queryset if isinstance(a.attachment.headers, dict) - and a.attachment.headers.get("Content-Disposition", "").startswith("inline") + and a.attachment.headers.get('Content-Disposition', '').startswith('inline') ] return queryset.exclude(id__in=inlined_attachments) @@ -70,7 +67,6 @@ def has_change_permission(self, request, obj=None): class CommaSeparatedEmailWidget(TextInput): - def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.attrs.update({'class': 'vTextField'}) @@ -80,7 +76,7 @@ def format_value(self, value): if not value: return '' if isinstance(value, str): - value = [value, ] + value = [value] return ','.join([item for item in value]) @@ -93,20 +89,29 @@ def requeue(modeladmin, request, queryset): class EmailAdmin(admin.ModelAdmin): - list_display = ['truncated_message_id', 'to_display', 'shortened_subject', 'status', 'last_updated', 'scheduled_time', 'use_template'] + list_display = [ + 'truncated_message_id', + 'to_display', + 'shortened_subject', + 'status', + 'last_updated', + 'scheduled_time', + 'use_template', + ] search_fields = ['to', 'subject'] - readonly_fields = ['message_id', 'render_subject', 'render_plaintext_body', 'render_html_body'] - date_hierarchy = 'last_updated' + readonly_fields = ['message_id', 'render_subject', 'render_plaintext_body', 'render_html_body'] inlines = [AttachmentInline, LogInline] list_filter = ['status', 'template__language', 'template__name'] - formfield_overrides = { - CommaSeparatedEmailField: {'widget': CommaSeparatedEmailWidget} - } + formfield_overrides = {CommaSeparatedEmailField: {'widget': CommaSeparatedEmailWidget}} actions = [requeue] def get_urls(self): urls = [ - re_path(r'^(?P\d+)/image/(?P[0-9a-f]{32})$', self.fetch_email_image, name='post_office_email_image'), + re_path( + r'^(?P\d+)/image/(?P[0-9a-f]{32})$', + self.fetch_email_image, + name='post_office_email_image', + ), re_path(r'^(?P\d+)/resend/$', self.resend, name='resend'), ] urls.extend(super().get_urls()) @@ -123,9 +128,9 @@ def truncated_message_id(self, instance): return Truncator(instance.message_id[1:-1]).chars(10) return str(instance.id) - to_display.short_description = _("To") + to_display.short_description = _('To') to_display.admin_order_field = 'to' - truncated_message_id.short_description = "Message-ID" + truncated_message_id.short_description = 'Message-ID' def has_add_permission(self, request): return False @@ -143,13 +148,13 @@ def shortened_subject(self, instance): subject = instance.subject return Truncator(subject).chars(100) - shortened_subject.short_description = _("Subject") + shortened_subject.short_description = _('Subject') shortened_subject.admin_order_field = 'subject' def use_template(self, instance): return bool(instance.template_id) - use_template.short_description = _("Use Template") + use_template.short_description = _('Use Template') use_template.boolean = True def get_fieldsets(self, request, obj=None): @@ -168,17 +173,11 @@ def get_fieldsets(self, request, obj=None): has_html_content = True if has_html_content: - fieldsets.append( - (_("HTML Email"), {'fields': ['render_subject', 'render_html_body']}) - ) + fieldsets.append((_('HTML Email'), {'fields': ['render_subject', 'render_html_body']})) if has_plaintext_content: - fieldsets.append( - (_("Text Email"), {'classes': ['collapse'], 'fields': ['render_plaintext_body']}) - ) + fieldsets.append((_('Text Email'), {'classes': ['collapse'], 'fields': ['render_plaintext_body']})) elif has_plaintext_content: - fieldsets.append( - (_("Text Email"), {'fields': ['render_subject', 'render_plaintext_body']}) - ) + fieldsets.append((_('Text Email'), {'fields': ['render_subject', 'render_plaintext_body']})) return fieldsets @@ -186,14 +185,14 @@ def render_subject(self, instance): message = instance.email_message() return message.subject - render_subject.short_description = _("Subject") + render_subject.short_description = _('Subject') def render_plaintext_body(self, instance): for message in instance.email_message().message().walk(): if isinstance(message, SafeMIMEText) and message.get_content_type() == 'text/plain': return format_html('
{}
', message.get_payload()) - render_plaintext_body.short_description = _("Mail Body") + render_plaintext_body.short_description = _('Mail Body') def render_html_body(self, instance): pattern = re.compile('cid:([0-9a-f]{32})') @@ -204,7 +203,7 @@ def render_html_body(self, instance): payload = message.get_payload(decode=True).decode('utf-8') return clean_html(pattern.sub(url, payload)) - render_html_body.short_description = _("HTML Body") + render_html_body.short_description = _('HTML Body') def fetch_email_image(self, request, pk, content_id): instance = self.get_object(request, pk) @@ -216,7 +215,7 @@ def fetch_email_image(self, request, pk, content_id): def resend(self, request, pk): instance = self.get_object(request, pk) instance.dispatch() - messages.info(request, "Email has been sent again") + messages.info(request, 'Email has been sent again') return HttpResponseRedirect(reverse('admin:post_office_email_change', args=[instance.pk])) @@ -251,14 +250,13 @@ class EmailTemplateAdminForm(forms.ModelForm): language = forms.ChoiceField( choices=settings.LANGUAGES, required=False, - label=_("Language"), - help_text=_("Render template in alternative language"), + label=_('Language'), + help_text=_('Render template in alternative language'), ) class Meta: model = EmailTemplate - fields = ['name', 'description', 'subject', 'content', 'html_content', 'language', - 'default_template'] + fields = ['name', 'description', 'subject', 'content', 'html_content', 'language', 'default_template'] def __init__(self, *args, **kwargs): instance = kwargs.get('instance') @@ -272,10 +270,8 @@ class EmailTemplateInline(admin.StackedInline): formset = EmailTemplateAdminFormSet model = EmailTemplate extra = 0 - fields = ('language', 'subject', 'content', 'html_content',) - formfield_overrides = { - models.CharField: {'widget': SubjectField} - } + fields = ('language', 'subject', 'content', 'html_content') + formfield_overrides = {models.CharField: {'widget': SubjectField}} def get_max_num(self, request, obj=None, **kwargs): return len(settings.LANGUAGES) @@ -286,30 +282,26 @@ class EmailTemplateAdmin(admin.ModelAdmin): list_display = ('name', 'description_shortened', 'subject', 'languages_compact', 'created') search_fields = ('name', 'description', 'subject') fieldsets = [ - (None, { - 'fields': ('name', 'description'), - }), - (_("Default Content"), { - 'fields': ('subject', 'content', 'html_content'), - }), + (None, {'fields': ('name', 'description')}), + (_('Default Content'), {'fields': ('subject', 'content', 'html_content')}), ] inlines = (EmailTemplateInline,) if settings.USE_I18N else () - formfield_overrides = { - models.CharField: {'widget': SubjectField} - } + formfield_overrides = {models.CharField: {'widget': SubjectField}} def get_queryset(self, request): return self.model.objects.filter(default_template__isnull=True) def description_shortened(self, instance): return Truncator(instance.description.split('\n')[0]).chars(200) - description_shortened.short_description = _("Description") + + description_shortened.short_description = _('Description') description_shortened.admin_order_field = 'description' def languages_compact(self, instance): languages = [tt.language for tt in instance.translated_templates.order_by('language')] return ', '.join(languages) - languages_compact.short_description = _("Languages") + + languages_compact.short_description = _('Languages') def save_model(self, request, obj, form, change): obj.save() @@ -322,7 +314,8 @@ def save_model(self, request, obj, form, change): class AttachmentAdmin(admin.ModelAdmin): list_display = ['name', 'file'] filter_horizontal = ['emails'] - search_fields = ["name"] + search_fields = ['name'] + autocomplete_fields = ['emails'] admin.site.register(Email, EmailAdmin) diff --git a/post_office/apps.py b/post_office/apps.py index f4896a4c..a4daed5c 100644 --- a/post_office/apps.py +++ b/post_office/apps.py @@ -4,7 +4,7 @@ class PostOfficeConfig(AppConfig): name = 'post_office' - verbose_name = _("Post Office") + verbose_name = _('Post Office') default_auto_field = 'django.db.models.AutoField' def ready(self): diff --git a/post_office/backends.py b/post_office/backends.py index 00bfb3df..b93e49f9 100644 --- a/post_office/backends.py +++ b/post_office/backends.py @@ -2,11 +2,10 @@ from email.mime.base import MIMEBase from django.core.files.base import ContentFile from django.core.mail.backends.base import BaseEmailBackend -import quopri from .settings import get_default_priority -class EmailBackend(BaseEmailBackend): +class EmailBackend(BaseEmailBackend): def open(self): pass @@ -19,26 +18,29 @@ def send_messages(self, email_messages): email messages sent. """ from .mail import create - from .models import STATUS + from .models import STATUS, Email from .utils import create_attachments + from .signals import email_queued if not email_messages: return + default_priority = get_default_priority() num_sent = 0 + emails = [] for email_message in email_messages: subject = email_message.subject from_email = email_message.from_email headers = email_message.extra_headers if email_message.reply_to: - reply_to_header = ", ".join(str(v) for v in email_message.reply_to) - headers.setdefault("Reply-To", reply_to_header) - message = email_message.body # The plaintext message is called body + reply_to_header = ', '.join(str(v) for v in email_message.reply_to) + headers.setdefault('Reply-To', reply_to_header) + message = email_message.body # The plaintext message is called body html_body = '' # The default if no html body can be found if hasattr(email_message, 'alternatives') and len(email_message.alternatives) > 0: for alternative in email_message.alternatives: if alternative[1] == 'text/html': - html_body = alternative[0] + html_body = alternative[0] attachment_files = {} for attachment in email_message.attachments: @@ -51,19 +53,30 @@ def send_messages(self, email_messages): else: attachment_files[attachment[0]] = ContentFile(attachment[1]) - email = create(sender=from_email, - recipients=email_message.to, cc=email_message.cc, - bcc=email_message.bcc, subject=subject, - message=message, html_message=html_body, - headers=headers) + email = create( + sender=from_email, + recipients=email_message.to, + cc=email_message.cc, + bcc=email_message.bcc, + subject=subject, + message=message, + html_message=html_body, + headers=headers, + ) if attachment_files: attachments = create_attachments(attachment_files) email.attachments.add(*attachments) - if get_default_priority() == 'now': + emails.append(email) + + if default_priority == 'now': status = email.dispatch() if status == STATUS.sent: num_sent += 1 + + if default_priority != 'now': + email_queued.send(sender=Email, emails=emails) + return num_sent diff --git a/post_office/connections.py b/post_office/connections.py index 43f1d7bd..435749ee 100644 --- a/post_office/connections.py +++ b/post_office/connections.py @@ -12,6 +12,7 @@ class ConnectionHandler: Ensures only one instance of each alias exists per thread. """ + def __init__(self): self._connections = local() diff --git a/post_office/fields.py b/post_office/fields.py index a8ad5ada..0bc7b97b 100644 --- a/post_office/fields.py +++ b/post_office/fields.py @@ -6,7 +6,7 @@ class CommaSeparatedEmailField(TextField): default_validators = [validate_comma_separated_emails] - description = _("Comma-separated emails") + description = _('Comma-separated emails') def __init__(self, *args, **kwargs): kwargs['blank'] = True @@ -52,6 +52,7 @@ def south_field_triple(self): Taken from smiley chris' easy_thumbnails """ from south.modelsinspector import introspector + field_class = 'django.db.models.fields.TextField' args, kwargs = introspector(self) return (field_class, args, kwargs) diff --git a/post_office/logutils.py b/post_office/logutils.py index ce445c96..1b49c8a3 100644 --- a/post_office/logutils.py +++ b/post_office/logutils.py @@ -7,30 +7,20 @@ def setup_loghandlers(level=None): # Setup logging for post_office if not already configured logger = logging.getLogger('post_office') if not logger.handlers: - dictConfig({ - "version": 1, - "disable_existing_loggers": False, - - "formatters": { - "post_office": { - "format": "[%(levelname)s]%(asctime)s PID %(process)d: %(message)s", - "datefmt": "%Y-%m-%d %H:%M:%S", + dictConfig( + { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'post_office': { + 'format': '[%(levelname)s]%(asctime)s PID %(process)d: %(message)s', + 'datefmt': '%Y-%m-%d %H:%M:%S', + }, }, - }, - - "handlers": { - "post_office": { - "level": "DEBUG", - "class": "logging.StreamHandler", - "formatter": "post_office" + 'handlers': { + 'post_office': {'level': 'DEBUG', 'class': 'logging.StreamHandler', 'formatter': 'post_office'}, }, - }, - - "loggers": { - "post_office": { - "handlers": ["post_office"], - "level": level or "DEBUG" - } + 'loggers': {'post_office': {'handlers': ['post_office'], 'level': level or 'DEBUG'}}, } - }) + ) return logger diff --git a/post_office/mail.py b/post_office/mail.py index e072b98b..cc2900bb 100644 --- a/post_office/mail.py +++ b/post_office/mail.py @@ -1,5 +1,3 @@ -import sys - from django.conf import settings from django.core.exceptions import ValidationError from django.db import connection as db_connection @@ -15,21 +13,47 @@ from .logutils import setup_loghandlers from .models import Email, EmailTemplate, Log, PRIORITY, STATUS from .settings import ( - get_available_backends, get_batch_size, get_log_level, get_max_retries, get_message_id_enabled, - get_message_id_fqdn, get_retry_timedelta, get_sending_order, get_threads_per_process, + get_available_backends, + get_batch_delivery_timeout, + get_batch_size, + get_log_level, + get_max_retries, + get_message_id_enabled, + get_message_id_fqdn, + get_retry_timedelta, + get_sending_order, + get_threads_per_process, ) from .signals import email_queued from .utils import ( - create_attachments, get_email_template, parse_emails, parse_priority, split_emails, + create_attachments, + get_email_template, + parse_emails, + parse_priority, + split_emails, ) -logger = setup_loghandlers("INFO") - - -def create(sender, recipients=None, cc=None, bcc=None, subject='', message='', - html_message='', context=None, scheduled_time=None, expires_at=None, headers=None, - template=None, priority=None, render_on_delivery=False, commit=True, - backend=''): +logger = setup_loghandlers('INFO') + + +def create( + sender, + recipients=None, + cc=None, + bcc=None, + subject='', + message='', + html_message='', + context=None, + scheduled_time=None, + expires_at=None, + headers=None, + template=None, + priority=None, + render_on_delivery=False, + commit=True, + backend='', +): """ Creates an email from supplied keyword arguments. If template is specified, email subject and content will be rendered during delivery. @@ -58,12 +82,15 @@ def create(sender, recipients=None, cc=None, bcc=None, subject='', message='', scheduled_time=scheduled_time, expires_at=expires_at, message_id=message_id, - headers=headers, priority=priority, status=status, - context=context, template=template, backend_alias=backend + headers=headers, + priority=priority, + status=status, + context=context, + template=template, + backend_alias=backend, ) else: - if template: subject = template.subject message = template.content @@ -85,7 +112,9 @@ def create(sender, recipients=None, cc=None, bcc=None, subject='', message='', scheduled_time=scheduled_time, expires_at=expires_at, message_id=message_id, - headers=headers, priority=priority, status=status, + headers=headers, + priority=priority, + status=status, backend_alias=backend, template=template, ) @@ -96,11 +125,27 @@ def create(sender, recipients=None, cc=None, bcc=None, subject='', message='', return email -def send(recipients=None, sender=None, template=None, context=None, subject='', - message='', html_message='', scheduled_time=None, expires_at=None, headers=None, - priority=None, attachments=None, render_on_delivery=False, - log_level=None, commit=True, cc=None, bcc=None, language='', - backend=''): +def send( + recipients=None, + sender=None, + template=None, + context=None, + subject='', + message='', + html_message='', + scheduled_time=None, + expires_at=None, + headers=None, + priority=None, + attachments=None, + render_on_delivery=False, + log_level=None, + commit=True, + cc=None, + bcc=None, + language='', + backend='', +): try: recipients = parse_emails(recipients) except ValidationError as e: @@ -150,9 +195,24 @@ def send(recipients=None, sender=None, template=None, context=None, subject='', if backend and backend not in get_available_backends().keys(): raise ValueError('%s is not a valid backend alias' % backend) - email = create(sender, recipients, cc, bcc, subject, message, html_message, - context, scheduled_time, expires_at, headers, template, priority, - render_on_delivery, commit=commit, backend=backend) + email = create( + sender, + recipients, + cc, + bcc, + subject, + message, + html_message, + context, + scheduled_time, + expires_at, + headers, + template, + priority, + render_on_delivery, + commit=commit, + backend=backend, + ) if attachments: attachments = create_attachments(attachments) @@ -160,7 +220,8 @@ def send(recipients=None, sender=None, template=None, context=None, subject='', if priority == PRIORITY.now: email.dispatch(log_level=log_level) - email_queued.send(sender=Email, emails=[email]) + elif commit: + email_queued.send(sender=Email, emails=[email]) return email @@ -185,14 +246,13 @@ def get_queued(): - Has expires_at after the current time or is None """ now = timezone.now() - query = ( - (Q(status=STATUS.queued) | Q(status=STATUS.requeued)) & - (Q(scheduled_time__lte=now) | Q(scheduled_time__isnull=True)) & - (Q(expires_at__gt=now) | Q(expires_at__isnull=True)) + query = (Q(scheduled_time__lte=now) | Q(scheduled_time=None)) & (Q(expires_at__gt=now) | Q(expires_at=None)) + return ( + Email.objects.filter(query, status__in=[STATUS.queued, STATUS.requeued]) + .select_related('template') + .order_by(*get_sending_order()) + .prefetch_related('attachments')[: get_batch_size()] ) - return Email.objects.filter(query) \ - .select_related('template') \ - .order_by(*get_sending_order()).prefetch_related('attachments')[:get_batch_size()] def send_queued(processes=1, log_level=None): @@ -203,8 +263,7 @@ def send_queued(processes=1, log_level=None): total_sent, total_failed, total_requeued = 0, 0, 0 total_email = len(queued_emails) - logger.info('Started sending %s emails with %s processes.' % - (total_email, processes)) + logger.info('Started sending %s emails with %s processes.' % (total_email, processes)) if log_level is None: log_level = get_log_level() @@ -224,8 +283,29 @@ def send_queued(processes=1, log_level=None): email_lists = split_emails(queued_emails, processes) pool = Pool(processes) - results = pool.map(_send_bulk, email_lists) + + tasks = [] + for email_list in email_lists: + tasks.append(pool.apply_async(_send_bulk, args=(email_list,))) + + timeout = get_batch_delivery_timeout() + results = [] + + # Wait for all tasks to complete with a timeout + # The get method is used with a timeout to wait for each result + for task in tasks: + results.append(task.get(timeout=timeout)) + # for task in tasks: + # try: + # # Wait for all tasks to complete with a timeout + # # The get method is used with a timeout to wait for each result + # results.append(task.get(timeout=timeout)) + # except (TimeoutError, ContextTimeoutError): + # logger.exception("Process timed out after %d seconds" % timeout) + + # results = pool.map(_send_bulk, email_lists) pool.terminate() + pool.join() total_sent = sum(result[0] for result in results) total_failed = sum(result[1] for result in results) @@ -233,7 +313,10 @@ def send_queued(processes=1, log_level=None): logger.info( '%s emails attempted, %s sent, %s failed, %s requeued', - total_email, total_sent, total_failed, total_requeued, + total_email, + total_sent, + total_failed, + total_requeued, ) return total_sent, total_failed, total_requeued @@ -257,8 +340,7 @@ def _send_bulk(emails, uses_multiprocessing=True, log_level=None): def send(email): try: - email.dispatch(log_level=log_level, commit=False, - disconnect_after_delivery=False) + email.dispatch(log_level=log_level, commit=False, disconnect_after_delivery=False) sent_emails.append(email) logger.debug('Successfully sent email #%d' % email.id) except Exception as e: @@ -279,7 +361,24 @@ def send(email): number_of_threads = min(get_threads_per_process(), email_count) pool = ThreadPool(number_of_threads) - pool.map(send, emails) + results = [] + for email in emails: + results.append(pool.apply_async(send, args=(email,))) + + timeout = get_batch_delivery_timeout() + + # Wait for all tasks to complete with a timeout + # The get method is used with a timeout to wait for each result + for result in results: + result.get(timeout=timeout) + # for result in results: + # try: + # # Wait for all tasks to complete with a timeout + # # The get method is used with a timeout to wait for each result + # result.get(timeout=timeout) + # except TimeoutError: + # logger.exception("Process timed out after %d seconds" % timeout) + pool.close() pool.join() @@ -312,20 +411,21 @@ def send(email): # If log level is 0, log nothing, 1 logs only sending failures # and 2 means log both successes and failures if log_level >= 1: - logs = [] - for (email, exception) in failed_emails: + for email, exception in failed_emails: logs.append( - Log(email=email, status=STATUS.failed, + Log( + email=email, + status=STATUS.failed, message=str(exception), - exception_type=type(exception).__name__) + exception_type=type(exception).__name__, + ) ) if logs: Log.objects.bulk_create(logs) if log_level == 2: - logs = [] for email in sent_emails: logs.append(Log(email=email, status=STATUS.sent)) @@ -335,7 +435,10 @@ def send(email): logger.info( 'Process finished, %s attempted, %s sent, %s failed, %s requeued', - email_count, len(sent_emails), num_failed, num_requeued, + email_count, + len(sent_emails), + num_failed, + num_requeued, ) return len(sent_emails), num_failed, num_requeued @@ -363,4 +466,4 @@ def send_queued_mail_until_done(processes=1, log_level=None): except TimeoutException: logger.info('Sending queued mail required too long, terminating now.') except LockedException: - logger.info('Failed to acquire lock from database, terminating now.') \ No newline at end of file + logger.info('Failed to acquire lock, terminating now.') diff --git a/post_office/management/commands/cleanup_mail.py b/post_office/management/commands/cleanup_mail.py index bdbe354e..fadf2ab4 100644 --- a/post_office/management/commands/cleanup_mail.py +++ b/post_office/management/commands/cleanup_mail.py @@ -10,18 +10,17 @@ class Command(BaseCommand): help = 'Place deferred messages back in the queue.' def add_arguments(self, parser): - parser.add_argument('-d', '--days', - type=int, default=90, - help="Cleanup mails older than this many days, defaults to 90.") + parser.add_argument( + '-d', '--days', type=int, default=90, help='Cleanup mails older than this many days, defaults to 90.' + ) - parser.add_argument('-da', '--delete-attachments', action='store_true', - help="Delete orphaned attachments.") + parser.add_argument('-da', '--delete-attachments', action='store_true', help='Delete orphaned attachments.') - parser.add_argument('-b', '--batch-size', type=int, default=1000, help="Batch size for cleanup.") + parser.add_argument('-b', '--batch-size', type=int, default=1000, help='Batch size for cleanup.') def handle(self, verbosity, days, delete_attachments, batch_size, **options): # Delete mails and their related logs and queued created before X days cutoff_date = now() - datetime.timedelta(days) num_emails, num_attachments = cleanup_expired_mails(cutoff_date, delete_attachments, batch_size) - msg = "Deleted {0} mails created before {1} and {2} attachments." + msg = 'Deleted {0} mails created before {1} and {2} attachments.' self.stdout.write(msg.format(num_emails, cutoff_date, num_attachments)) diff --git a/post_office/management/commands/send_queued_mail.py b/post_office/management/commands/send_queued_mail.py index 28160c7d..d3ed040f 100644 --- a/post_office/management/commands/send_queued_mail.py +++ b/post_office/management/commands/send_queued_mail.py @@ -6,13 +6,15 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument( - '-p', '--processes', + '-p', + '--processes', type=int, default=1, help='Number of processes used to send emails', ) parser.add_argument( '-l', '--log-level', + '--log-level', type=int, help='"0" to log nothing, "1" to only log errors', ) diff --git a/post_office/migrations/0001_initial.py b/post_office/migrations/0001_initial.py index 4ccf618e..ff7fcd38 100644 --- a/post_office/migrations/0001_initial.py +++ b/post_office/migrations/0001_initial.py @@ -1,14 +1,12 @@ from django.db import models, migrations -import jsonfield.fields + import post_office.fields import post_office.validators import post_office.models class Migration(migrations.Migration): - - dependencies = [ - ] + dependencies = [] operations = [ migrations.CreateModel( @@ -18,31 +16,42 @@ class Migration(migrations.Migration): ('file', models.FileField(upload_to=post_office.models.get_upload_path)), ('name', models.CharField(help_text='The original filename', max_length=255)), ], - options={ - }, + options={}, bases=(models.Model,), ), migrations.CreateModel( name='Email', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('from_email', models.CharField(max_length=254, validators=[post_office.validators.validate_email_with_name])), + ( + 'from_email', + models.CharField(max_length=254, validators=[post_office.validators.validate_email_with_name]), + ), ('to', post_office.fields.CommaSeparatedEmailField(blank=True)), ('cc', post_office.fields.CommaSeparatedEmailField(blank=True)), ('bcc', post_office.fields.CommaSeparatedEmailField(blank=True)), ('subject', models.CharField(max_length=255, blank=True)), ('message', models.TextField(blank=True)), ('html_message', models.TextField(blank=True)), - ('status', models.PositiveSmallIntegerField(blank=True, null=True, db_index=True, choices=[(0, 'sent'), (1, 'failed'), (2, 'queued')])), - ('priority', models.PositiveSmallIntegerField(blank=True, null=True, choices=[(0, 'low'), (1, 'medium'), (2, 'high'), (3, 'now')])), + ( + 'status', + models.PositiveSmallIntegerField( + blank=True, null=True, db_index=True, choices=[(0, 'sent'), (1, 'failed'), (2, 'queued')] + ), + ), + ( + 'priority', + models.PositiveSmallIntegerField( + blank=True, null=True, choices=[(0, 'low'), (1, 'medium'), (2, 'high'), (3, 'now')] + ), + ), ('created', models.DateTimeField(auto_now_add=True, db_index=True)), ('last_updated', models.DateTimeField(auto_now=True, db_index=True)), ('scheduled_time', models.DateTimeField(db_index=True, null=True, blank=True)), - ('headers', jsonfield.fields.JSONField(null=True, blank=True)), - ('context', jsonfield.fields.JSONField(null=True, blank=True)), + ('headers', models.JSONField(null=True, blank=True)), + ('context', models.JSONField(null=True, blank=True)), ], - options={ - }, + options={}, bases=(models.Model,), ), migrations.CreateModel( @@ -51,14 +60,21 @@ class Migration(migrations.Migration): ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('name', models.CharField(help_text="e.g: 'welcome_email'", max_length=255)), ('description', models.TextField(help_text='Description of this template.', blank=True)), - ('subject', models.CharField(blank=True, max_length=255, validators=[post_office.validators.validate_template_syntax])), + ( + 'subject', + models.CharField( + blank=True, max_length=255, validators=[post_office.validators.validate_template_syntax] + ), + ), ('content', models.TextField(blank=True, validators=[post_office.validators.validate_template_syntax])), - ('html_content', models.TextField(blank=True, validators=[post_office.validators.validate_template_syntax])), + ( + 'html_content', + models.TextField(blank=True, validators=[post_office.validators.validate_template_syntax]), + ), ('created', models.DateTimeField(auto_now_add=True)), ('last_updated', models.DateTimeField(auto_now=True)), ], - options={ - }, + options={}, bases=(models.Model,), ), migrations.CreateModel( @@ -69,16 +85,25 @@ class Migration(migrations.Migration): ('status', models.PositiveSmallIntegerField(choices=[(0, 'sent'), (1, 'failed')])), ('exception_type', models.CharField(max_length=255, blank=True)), ('message', models.TextField()), - ('email', models.ForeignKey(related_name='logs', editable=False, on_delete=models.deletion.CASCADE, to='post_office.Email', )), + ( + 'email', + models.ForeignKey( + related_name='logs', + editable=False, + on_delete=models.deletion.CASCADE, + to='post_office.Email', + ), + ), ], - options={ - }, + options={}, bases=(models.Model,), ), migrations.AddField( model_name='email', name='template', - field=models.ForeignKey(blank=True, on_delete=models.deletion.SET_NULL, to='post_office.EmailTemplate', null=True), + field=models.ForeignKey( + blank=True, on_delete=models.deletion.SET_NULL, to='post_office.EmailTemplate', null=True + ), preserve_default=True, ), migrations.AddField( diff --git a/post_office/migrations/0002_add_i18n_and_backend_alias.py b/post_office/migrations/0002_add_i18n_and_backend_alias.py index 32fc4a10..be8c2177 100644 --- a/post_office/migrations/0002_add_i18n_and_backend_alias.py +++ b/post_office/migrations/0002_add_i18n_and_backend_alias.py @@ -4,7 +4,6 @@ class Migration(migrations.Migration): - dependencies = [ ('post_office', '0001_initial'), ] @@ -22,12 +21,111 @@ class Migration(migrations.Migration): migrations.AddField( model_name='emailtemplate', name='default_template', - field=models.ForeignKey(related_name='translated_templates', default=None, to='post_office.EmailTemplate', null=True, on_delete=models.deletion.SET_NULL), + field=models.ForeignKey( + related_name='translated_templates', + default=None, + to='post_office.EmailTemplate', + null=True, + on_delete=models.deletion.SET_NULL, + ), ), migrations.AddField( model_name='emailtemplate', name='language', - field=models.CharField(default='', help_text='Render template in alternative language', max_length=12, blank=True, choices=[('af', 'Afrikaans'), ('ar', 'Arabic'), ('ast', 'Asturian'), ('az', 'Azerbaijani'), ('bg', 'Bulgarian'), ('be', 'Belarusian'), ('bn', 'Bengali'), ('br', 'Breton'), ('bs', 'Bosnian'), ('ca', 'Catalan'), ('cs', 'Czech'), ('cy', 'Welsh'), ('da', 'Danish'), ('de', 'German'), ('el', 'Greek'), ('en', 'English'), ('en-au', 'Australian English'), ('en-gb', 'British English'), ('eo', 'Esperanto'), ('es', 'Spanish'), ('es-ar', 'Argentinian Spanish'), ('es-mx', 'Mexican Spanish'), ('es-ni', 'Nicaraguan Spanish'), ('es-ve', 'Venezuelan Spanish'), ('et', 'Estonian'), ('eu', 'Basque'), ('fa', 'Persian'), ('fi', 'Finnish'), ('fr', 'French'), ('fy', 'Frisian'), ('ga', 'Irish'), ('gl', 'Galician'), ('he', 'Hebrew'), ('hi', 'Hindi'), ('hr', 'Croatian'), ('hu', 'Hungarian'), ('ia', 'Interlingua'), ('id', 'Indonesian'), ('io', 'Ido'), ('is', 'Icelandic'), ('it', 'Italian'), ('ja', 'Japanese'), ('ka', 'Georgian'), ('kk', 'Kazakh'), ('km', 'Khmer'), ('kn', 'Kannada'), ('ko', 'Korean'), ('lb', 'Luxembourgish'), ('lt', 'Lithuanian'), ('lv', 'Latvian'), ('mk', 'Macedonian'), ('ml', 'Malayalam'), ('mn', 'Mongolian'), ('mr', 'Marathi'), ('my', 'Burmese'), ('nb', 'Norwegian Bokmal'), ('ne', 'Nepali'), ('nl', 'Dutch'), ('nn', 'Norwegian Nynorsk'), ('os', 'Ossetic'), ('pa', 'Punjabi'), ('pl', 'Polish'), ('pt', 'Portuguese'), ('pt-br', 'Brazilian Portuguese'), ('ro', 'Romanian'), ('ru', 'Russian'), ('sk', 'Slovak'), ('sl', 'Slovenian'), ('sq', 'Albanian'), ('sr', 'Serbian'), ('sr-latn', 'Serbian Latin'), ('sv', 'Swedish'), ('sw', 'Swahili'), ('ta', 'Tamil'), ('te', 'Telugu'), ('th', 'Thai'), ('tr', 'Turkish'), ('tt', 'Tatar'), ('udm', 'Udmurt'), ('uk', 'Ukrainian'), ('ur', 'Urdu'), ('vi', 'Vietnamese'), ('zh-cn', 'Simplified Chinese'), ('zh-hans', 'Simplified Chinese'), ('zh-hant', 'Traditional Chinese'), ('zh-tw', 'Traditional Chinese')]), + field=models.CharField( + default='', + help_text='Render template in alternative language', + max_length=12, + blank=True, + choices=[ + ('af', 'Afrikaans'), + ('ar', 'Arabic'), + ('ast', 'Asturian'), + ('az', 'Azerbaijani'), + ('bg', 'Bulgarian'), + ('be', 'Belarusian'), + ('bn', 'Bengali'), + ('br', 'Breton'), + ('bs', 'Bosnian'), + ('ca', 'Catalan'), + ('cs', 'Czech'), + ('cy', 'Welsh'), + ('da', 'Danish'), + ('de', 'German'), + ('el', 'Greek'), + ('en', 'English'), + ('en-au', 'Australian English'), + ('en-gb', 'British English'), + ('eo', 'Esperanto'), + ('es', 'Spanish'), + ('es-ar', 'Argentinian Spanish'), + ('es-mx', 'Mexican Spanish'), + ('es-ni', 'Nicaraguan Spanish'), + ('es-ve', 'Venezuelan Spanish'), + ('et', 'Estonian'), + ('eu', 'Basque'), + ('fa', 'Persian'), + ('fi', 'Finnish'), + ('fr', 'French'), + ('fy', 'Frisian'), + ('ga', 'Irish'), + ('gl', 'Galician'), + ('he', 'Hebrew'), + ('hi', 'Hindi'), + ('hr', 'Croatian'), + ('hu', 'Hungarian'), + ('ia', 'Interlingua'), + ('id', 'Indonesian'), + ('io', 'Ido'), + ('is', 'Icelandic'), + ('it', 'Italian'), + ('ja', 'Japanese'), + ('ka', 'Georgian'), + ('kk', 'Kazakh'), + ('km', 'Khmer'), + ('kn', 'Kannada'), + ('ko', 'Korean'), + ('lb', 'Luxembourgish'), + ('lt', 'Lithuanian'), + ('lv', 'Latvian'), + ('mk', 'Macedonian'), + ('ml', 'Malayalam'), + ('mn', 'Mongolian'), + ('mr', 'Marathi'), + ('my', 'Burmese'), + ('nb', 'Norwegian Bokmal'), + ('ne', 'Nepali'), + ('nl', 'Dutch'), + ('nn', 'Norwegian Nynorsk'), + ('os', 'Ossetic'), + ('pa', 'Punjabi'), + ('pl', 'Polish'), + ('pt', 'Portuguese'), + ('pt-br', 'Brazilian Portuguese'), + ('ro', 'Romanian'), + ('ru', 'Russian'), + ('sk', 'Slovak'), + ('sl', 'Slovenian'), + ('sq', 'Albanian'), + ('sr', 'Serbian'), + ('sr-latn', 'Serbian Latin'), + ('sv', 'Swedish'), + ('sw', 'Swahili'), + ('ta', 'Tamil'), + ('te', 'Telugu'), + ('th', 'Thai'), + ('tr', 'Turkish'), + ('tt', 'Tatar'), + ('udm', 'Udmurt'), + ('uk', 'Ukrainian'), + ('ur', 'Urdu'), + ('vi', 'Vietnamese'), + ('zh-cn', 'Simplified Chinese'), + ('zh-hans', 'Simplified Chinese'), + ('zh-hant', 'Traditional Chinese'), + ('zh-tw', 'Traditional Chinese'), + ], + ), ), migrations.AlterField( model_name='email', @@ -42,7 +140,9 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='email', name='from_email', - field=models.CharField(max_length=254, verbose_name='Email From', validators=[post_office.validators.validate_email_with_name]), + field=models.CharField( + max_length=254, verbose_name='Email From', validators=[post_office.validators.validate_email_with_name] + ), ), migrations.AlterField( model_name='email', @@ -67,20 +167,29 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='emailtemplate', name='content', - field=models.TextField(blank=True, verbose_name='Content', validators=[post_office.validators.validate_template_syntax]), + field=models.TextField( + blank=True, verbose_name='Content', validators=[post_office.validators.validate_template_syntax] + ), ), migrations.AlterField( model_name='emailtemplate', name='html_content', - field=models.TextField(blank=True, verbose_name='HTML content', validators=[post_office.validators.validate_template_syntax]), + field=models.TextField( + blank=True, verbose_name='HTML content', validators=[post_office.validators.validate_template_syntax] + ), ), migrations.AlterField( model_name='emailtemplate', name='subject', - field=models.CharField(blank=True, max_length=255, verbose_name='Subject', validators=[post_office.validators.validate_template_syntax]), + field=models.CharField( + blank=True, + max_length=255, + verbose_name='Subject', + validators=[post_office.validators.validate_template_syntax], + ), ), migrations.AlterUniqueTogether( name='emailtemplate', - unique_together=set([('language', 'default_template')]), + unique_together={('language', 'default_template')}, ), ] diff --git a/post_office/migrations/0003_longer_subject.py b/post_office/migrations/0003_longer_subject.py index c24cab42..a63a2f9e 100644 --- a/post_office/migrations/0003_longer_subject.py +++ b/post_office/migrations/0003_longer_subject.py @@ -3,7 +3,6 @@ class Migration(migrations.Migration): - dependencies = [ ('post_office', '0002_add_i18n_and_backend_alias'), ] @@ -17,6 +16,99 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='emailtemplate', name='language', - field=models.CharField(blank=True, choices=[('af', 'Afrikaans'), ('ar', 'Arabic'), ('ast', 'Asturian'), ('az', 'Azerbaijani'), ('bg', 'Bulgarian'), ('be', 'Belarusian'), ('bn', 'Bengali'), ('br', 'Breton'), ('bs', 'Bosnian'), ('ca', 'Catalan'), ('cs', 'Czech'), ('cy', 'Welsh'), ('da', 'Danish'), ('de', 'German'), ('el', 'Greek'), ('en', 'English'), ('en-au', 'Australian English'), ('en-gb', 'British English'), ('eo', 'Esperanto'), ('es', 'Spanish'), ('es-ar', 'Argentinian Spanish'), ('es-co', 'Colombian Spanish'), ('es-mx', 'Mexican Spanish'), ('es-ni', 'Nicaraguan Spanish'), ('es-ve', 'Venezuelan Spanish'), ('et', 'Estonian'), ('eu', 'Basque'), ('fa', 'Persian'), ('fi', 'Finnish'), ('fr', 'French'), ('fy', 'Frisian'), ('ga', 'Irish'), ('gd', 'Scottish Gaelic'), ('gl', 'Galician'), ('he', 'Hebrew'), ('hi', 'Hindi'), ('hr', 'Croatian'), ('hu', 'Hungarian'), ('ia', 'Interlingua'), ('id', 'Indonesian'), ('io', 'Ido'), ('is', 'Icelandic'), ('it', 'Italian'), ('ja', 'Japanese'), ('ka', 'Georgian'), ('kk', 'Kazakh'), ('km', 'Khmer'), ('kn', 'Kannada'), ('ko', 'Korean'), ('lb', 'Luxembourgish'), ('lt', 'Lithuanian'), ('lv', 'Latvian'), ('mk', 'Macedonian'), ('ml', 'Malayalam'), ('mn', 'Mongolian'), ('mr', 'Marathi'), ('my', 'Burmese'), ('nb', 'Norwegian Bokmal'), ('ne', 'Nepali'), ('nl', 'Dutch'), ('nn', 'Norwegian Nynorsk'), ('os', 'Ossetic'), ('pa', 'Punjabi'), ('pl', 'Polish'), ('pt', 'Portuguese'), ('pt-br', 'Brazilian Portuguese'), ('ro', 'Romanian'), ('ru', 'Russian'), ('sk', 'Slovak'), ('sl', 'Slovenian'), ('sq', 'Albanian'), ('sr', 'Serbian'), ('sr-latn', 'Serbian Latin'), ('sv', 'Swedish'), ('sw', 'Swahili'), ('ta', 'Tamil'), ('te', 'Telugu'), ('th', 'Thai'), ('tr', 'Turkish'), ('tt', 'Tatar'), ('udm', 'Udmurt'), ('uk', 'Ukrainian'), ('ur', 'Urdu'), ('vi', 'Vietnamese'), ('zh-hans', 'Simplified Chinese'), ('zh-hant', 'Traditional Chinese')], default='', help_text='Render template in alternative language', max_length=12), + field=models.CharField( + blank=True, + choices=[ + ('af', 'Afrikaans'), + ('ar', 'Arabic'), + ('ast', 'Asturian'), + ('az', 'Azerbaijani'), + ('bg', 'Bulgarian'), + ('be', 'Belarusian'), + ('bn', 'Bengali'), + ('br', 'Breton'), + ('bs', 'Bosnian'), + ('ca', 'Catalan'), + ('cs', 'Czech'), + ('cy', 'Welsh'), + ('da', 'Danish'), + ('de', 'German'), + ('el', 'Greek'), + ('en', 'English'), + ('en-au', 'Australian English'), + ('en-gb', 'British English'), + ('eo', 'Esperanto'), + ('es', 'Spanish'), + ('es-ar', 'Argentinian Spanish'), + ('es-co', 'Colombian Spanish'), + ('es-mx', 'Mexican Spanish'), + ('es-ni', 'Nicaraguan Spanish'), + ('es-ve', 'Venezuelan Spanish'), + ('et', 'Estonian'), + ('eu', 'Basque'), + ('fa', 'Persian'), + ('fi', 'Finnish'), + ('fr', 'French'), + ('fy', 'Frisian'), + ('ga', 'Irish'), + ('gd', 'Scottish Gaelic'), + ('gl', 'Galician'), + ('he', 'Hebrew'), + ('hi', 'Hindi'), + ('hr', 'Croatian'), + ('hu', 'Hungarian'), + ('ia', 'Interlingua'), + ('id', 'Indonesian'), + ('io', 'Ido'), + ('is', 'Icelandic'), + ('it', 'Italian'), + ('ja', 'Japanese'), + ('ka', 'Georgian'), + ('kk', 'Kazakh'), + ('km', 'Khmer'), + ('kn', 'Kannada'), + ('ko', 'Korean'), + ('lb', 'Luxembourgish'), + ('lt', 'Lithuanian'), + ('lv', 'Latvian'), + ('mk', 'Macedonian'), + ('ml', 'Malayalam'), + ('mn', 'Mongolian'), + ('mr', 'Marathi'), + ('my', 'Burmese'), + ('nb', 'Norwegian Bokmal'), + ('ne', 'Nepali'), + ('nl', 'Dutch'), + ('nn', 'Norwegian Nynorsk'), + ('os', 'Ossetic'), + ('pa', 'Punjabi'), + ('pl', 'Polish'), + ('pt', 'Portuguese'), + ('pt-br', 'Brazilian Portuguese'), + ('ro', 'Romanian'), + ('ru', 'Russian'), + ('sk', 'Slovak'), + ('sl', 'Slovenian'), + ('sq', 'Albanian'), + ('sr', 'Serbian'), + ('sr-latn', 'Serbian Latin'), + ('sv', 'Swedish'), + ('sw', 'Swahili'), + ('ta', 'Tamil'), + ('te', 'Telugu'), + ('th', 'Thai'), + ('tr', 'Turkish'), + ('tt', 'Tatar'), + ('udm', 'Udmurt'), + ('uk', 'Ukrainian'), + ('ur', 'Urdu'), + ('vi', 'Vietnamese'), + ('zh-hans', 'Simplified Chinese'), + ('zh-hant', 'Traditional Chinese'), + ], + default='', + help_text='Render template in alternative language', + max_length=12, + ), ), ] diff --git a/post_office/migrations/0004_auto_20160607_0901.py b/post_office/migrations/0004_auto_20160607_0901.py index 0fd7f0bd..6b31a287 100644 --- a/post_office/migrations/0004_auto_20160607_0901.py +++ b/post_office/migrations/0004_auto_20160607_0901.py @@ -1,12 +1,11 @@ # Generated by Django 1.9.6 on 2016-06-07 07:01 from django.db import migrations, models import django.db.models.deletion -import jsonfield.fields + import post_office.models class Migration(migrations.Migration): - dependencies = [ ('post_office', '0003_longer_subject'), ] @@ -27,7 +26,9 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='attachment', name='emails', - field=models.ManyToManyField(related_name='attachments', to='post_office.Email', verbose_name='Email addresses'), + field=models.ManyToManyField( + related_name='attachments', to='post_office.Email', verbose_name='Email addresses' + ), ), migrations.AlterField( model_name='attachment', @@ -47,17 +48,22 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='email', name='context', - field=jsonfield.fields.JSONField(blank=True, null=True, verbose_name='Context'), + field=models.JSONField(blank=True, null=True, verbose_name='Context'), ), migrations.AlterField( model_name='email', name='headers', - field=jsonfield.fields.JSONField(blank=True, null=True, verbose_name='Headers'), + field=models.JSONField(blank=True, null=True, verbose_name='Headers'), ), migrations.AlterField( model_name='email', name='priority', - field=models.PositiveSmallIntegerField(blank=True, choices=[(0, 'low'), (1, 'medium'), (2, 'high'), (3, 'now')], null=True, verbose_name='Priority'), + field=models.PositiveSmallIntegerField( + blank=True, + choices=[(0, 'low'), (1, 'medium'), (2, 'high'), (3, 'now')], + null=True, + verbose_name='Priority', + ), ), migrations.AlterField( model_name='email', @@ -67,17 +73,36 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='email', name='status', - field=models.PositiveSmallIntegerField(blank=True, choices=[(0, 'sent'), (1, 'failed'), (2, 'queued')], db_index=True, null=True, verbose_name='Status'), + field=models.PositiveSmallIntegerField( + blank=True, + choices=[(0, 'sent'), (1, 'failed'), (2, 'queued')], + db_index=True, + null=True, + verbose_name='Status', + ), ), migrations.AlterField( model_name='email', name='template', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='post_office.EmailTemplate', verbose_name='Email template'), + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to='post_office.EmailTemplate', + verbose_name='Email template', + ), ), migrations.AlterField( model_name='emailtemplate', name='default_template', - field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='translated_templates', to='post_office.EmailTemplate', verbose_name='Default template'), + field=models.ForeignKey( + default=None, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='translated_templates', + to='post_office.EmailTemplate', + verbose_name='Default template', + ), ), migrations.AlterField( model_name='emailtemplate', @@ -87,7 +112,13 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='emailtemplate', name='language', - field=models.CharField(blank=True, default='', help_text='Render template in alternative language', max_length=12, verbose_name='Language'), + field=models.CharField( + blank=True, + default='', + help_text='Render template in alternative language', + max_length=12, + verbose_name='Language', + ), ), migrations.AlterField( model_name='emailtemplate', @@ -97,7 +128,13 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='log', name='email', - field=models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='logs', to='post_office.Email', verbose_name='Email address'), + field=models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name='logs', + to='post_office.Email', + verbose_name='Email address', + ), ), migrations.AlterField( model_name='log', diff --git a/post_office/migrations/0005_auto_20170515_0013.py b/post_office/migrations/0005_auto_20170515_0013.py index bbcf1628..fdf34db8 100644 --- a/post_office/migrations/0005_auto_20170515_0013.py +++ b/post_office/migrations/0005_auto_20170515_0013.py @@ -3,7 +3,6 @@ class Migration(migrations.Migration): - dependencies = [ ('post_office', '0004_auto_20160607_0901'), ] @@ -11,6 +10,6 @@ class Migration(migrations.Migration): operations = [ migrations.AlterUniqueTogether( name='emailtemplate', - unique_together=set([('name', 'language', 'default_template')]), + unique_together={('name', 'language', 'default_template')}, ), ] diff --git a/post_office/migrations/0006_attachment_mimetype.py b/post_office/migrations/0006_attachment_mimetype.py index ef41e32d..f217cbe3 100644 --- a/post_office/migrations/0006_attachment_mimetype.py +++ b/post_office/migrations/0006_attachment_mimetype.py @@ -2,7 +2,6 @@ class Migration(migrations.Migration): - dependencies = [ ('post_office', '0005_auto_20170515_0013'), ] diff --git a/post_office/migrations/0007_auto_20170731_1342.py b/post_office/migrations/0007_auto_20170731_1342.py index a3c32fec..22a7bc6c 100644 --- a/post_office/migrations/0007_auto_20170731_1342.py +++ b/post_office/migrations/0007_auto_20170731_1342.py @@ -3,7 +3,6 @@ class Migration(migrations.Migration): - dependencies = [ ('post_office', '0006_attachment_mimetype'), ] diff --git a/post_office/migrations/0008_attachment_headers.py b/post_office/migrations/0008_attachment_headers.py index 62b7251c..44d18969 100644 --- a/post_office/migrations/0008_attachment_headers.py +++ b/post_office/migrations/0008_attachment_headers.py @@ -1,10 +1,8 @@ # Generated by Django 1.11.16 on 2018-11-30 08:54 -from django.db import migrations -import jsonfield.fields +from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ('post_office', '0007_auto_20170731_1342'), ] @@ -13,6 +11,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name='attachment', name='headers', - field=jsonfield.fields.JSONField(blank=True, null=True, verbose_name='Headers'), + field=models.JSONField(blank=True, null=True, verbose_name='Headers'), ), ] diff --git a/post_office/migrations/0009_requeued_mode.py b/post_office/migrations/0009_requeued_mode.py index 0b974810..c877fc32 100644 --- a/post_office/migrations/0009_requeued_mode.py +++ b/post_office/migrations/0009_requeued_mode.py @@ -4,7 +4,6 @@ class Migration(migrations.Migration): - dependencies = [ ('post_office', '0008_attachment_headers'), ] @@ -18,7 +17,13 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='email', name='status', - field=models.PositiveSmallIntegerField(blank=True, choices=[(0, 'sent'), (1, 'failed'), (2, 'queued'), (3, 'requeued')], db_index=True, null=True, verbose_name='Status'), + field=models.PositiveSmallIntegerField( + blank=True, + choices=[(0, 'sent'), (1, 'failed'), (2, 'queued'), (3, 'requeued')], + db_index=True, + null=True, + verbose_name='Status', + ), ), migrations.AddField( model_name='email', diff --git a/post_office/migrations/0010_message_id.py b/post_office/migrations/0010_message_id.py index c2546aed..f7966143 100644 --- a/post_office/migrations/0010_message_id.py +++ b/post_office/migrations/0010_message_id.py @@ -14,12 +14,11 @@ def forwards(apps, schema_editor): if email.status in [STATUS.queued, STATUS.requeued]: # create a unique Message-ID for all emails which have not been send yet randint1, randint2 = random.getrandbits(64), random.getrandbits(16) - email.message_id = '<{}.{}.{}@{}>'.format(email.id, randint1, randint2, msg_id_fqdn) + email.message_id = f'<{email.id}.{randint1}.{randint2}@{msg_id_fqdn}>' email.save() class Migration(migrations.Migration): - dependencies = [ ('post_office', '0009_requeued_mode'), ] @@ -33,7 +32,9 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='email', name='expires_at', - field=models.DateTimeField(blank=True, help_text="Email won't be sent after this timestamp", null=True, verbose_name='Expires at'), + field=models.DateTimeField( + blank=True, help_text="Email won't be sent after this timestamp", null=True, verbose_name='Expires at' + ), ), migrations.RunPython(forwards, reverse_code=migrations.RunPython.noop), ] diff --git a/post_office/migrations/0011_models_help_text.py b/post_office/migrations/0011_models_help_text.py index 7e900d9a..e4426913 100644 --- a/post_office/migrations/0011_models_help_text.py +++ b/post_office/migrations/0011_models_help_text.py @@ -4,7 +4,6 @@ class Migration(migrations.Migration): - dependencies = [ ('post_office', '0010_message_id'), ] @@ -18,11 +17,19 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='email', name='expires_at', - field=models.DateTimeField(blank=True, help_text="Email won't be sent after this timestamp", null=True, verbose_name='Expires'), + field=models.DateTimeField( + blank=True, help_text="Email won't be sent after this timestamp", null=True, verbose_name='Expires' + ), ), migrations.AlterField( model_name='email', name='scheduled_time', - field=models.DateTimeField(blank=True, db_index=True, help_text='The scheduled sending time', null=True, verbose_name='Scheduled Time'), + field=models.DateTimeField( + blank=True, + db_index=True, + help_text='The scheduled sending time', + null=True, + verbose_name='Scheduled Time', + ), ), ] diff --git a/post_office/models.py b/post_office/models.py index 542c2a3b..98de0d2a 100644 --- a/post_office/models.py +++ b/post_office/models.py @@ -10,7 +10,6 @@ from django.utils.encoding import smart_str from django.utils.translation import pgettext_lazy, gettext_lazy as _ from django.utils import timezone -from jsonfield import JSONField from post_office import cache from post_office.fields import CommaSeparatedEmailField @@ -21,7 +20,7 @@ from .validators import validate_email_with_name, validate_template_syntax -logger = setup_loghandlers("INFO") +logger = setup_loghandlers('INFO') PRIORITY = namedtuple('PRIORITY', 'low medium high now')._make(range(4)) @@ -33,53 +32,54 @@ class Email(models.Model): A model to hold email information. """ - PRIORITY_CHOICES = [(PRIORITY.low, _("low")), (PRIORITY.medium, _("medium")), - (PRIORITY.high, _("high")), (PRIORITY.now, _("now"))] - STATUS_CHOICES = [(STATUS.sent, _("sent")), (STATUS.failed, _("failed")), - (STATUS.queued, _("queued")), (STATUS.requeued, _("requeued"))] - - from_email = models.CharField(_("Email From"), max_length=254, - validators=[validate_email_with_name]) - to = CommaSeparatedEmailField(_("Email To")) - cc = CommaSeparatedEmailField(_("Cc")) - bcc = CommaSeparatedEmailField(_("Bcc")) - subject = models.CharField(_("Subject"), max_length=989, blank=True) - message = models.TextField(_("Message"), blank=True) - html_message = models.TextField(_("HTML Message"), blank=True) + PRIORITY_CHOICES = [ + (PRIORITY.low, _('low')), + (PRIORITY.medium, _('medium')), + (PRIORITY.high, _('high')), + (PRIORITY.now, _('now')), + ] + STATUS_CHOICES = [ + (STATUS.sent, _('sent')), + (STATUS.failed, _('failed')), + (STATUS.queued, _('queued')), + (STATUS.requeued, _('requeued')), + ] + + from_email = models.CharField(_('Email From'), max_length=254, validators=[validate_email_with_name]) + to = CommaSeparatedEmailField(_('Email To')) + cc = CommaSeparatedEmailField(_('Cc')) + bcc = CommaSeparatedEmailField(_('Bcc')) + subject = models.CharField(_('Subject'), max_length=989, blank=True) + message = models.TextField(_('Message'), blank=True) + html_message = models.TextField(_('HTML Message'), blank=True) """ Emails with 'queued' status will get processed by ``send_queued`` command. Status field will then be set to ``failed`` or ``sent`` depending on whether it's successfully delivered. """ - status = models.PositiveSmallIntegerField( - _("Status"), - choices=STATUS_CHOICES, db_index=True, - blank=True, null=True) - priority = models.PositiveSmallIntegerField(_("Priority"), - choices=PRIORITY_CHOICES, - blank=True, null=True) + status = models.PositiveSmallIntegerField(_('Status'), choices=STATUS_CHOICES, db_index=True, blank=True, null=True) + priority = models.PositiveSmallIntegerField(_('Priority'), choices=PRIORITY_CHOICES, blank=True, null=True) created = models.DateTimeField(auto_now_add=True, db_index=True) last_updated = models.DateTimeField(db_index=True, auto_now=True) - scheduled_time = models.DateTimeField(_("Scheduled Time"), - blank=True, null=True, db_index=True, - help_text=_("The scheduled sending time")) - expires_at = models.DateTimeField(_("Expires"), - blank=True, null=True, - help_text=_("Email won't be sent after this timestamp")) - message_id = models.CharField("Message-ID", null=True, max_length=255, editable=False) + scheduled_time = models.DateTimeField( + _('Scheduled Time'), blank=True, null=True, db_index=True, help_text=_('The scheduled sending time') + ) + expires_at = models.DateTimeField( + _('Expires'), blank=True, null=True, help_text=_("Email won't be sent after this timestamp") + ) + message_id = models.CharField('Message-ID', null=True, max_length=255, editable=False) number_of_retries = models.PositiveIntegerField(null=True, blank=True) - headers = JSONField(_('Headers'), blank=True, null=True) - template = models.ForeignKey('post_office.EmailTemplate', blank=True, - null=True, verbose_name=_("Email template"), - on_delete=models.CASCADE) + headers = models.JSONField(_('Headers'), blank=True, null=True) + template = models.ForeignKey( + 'post_office.EmailTemplate', blank=True, null=True, verbose_name=_('Email template'), on_delete=models.CASCADE + ) context = context_field_class(_('Context'), blank=True, null=True) - backend_alias = models.CharField(_("Backend alias"), blank=True, default='', - max_length=64) + backend_alias = models.CharField(_('Backend alias'), blank=True, default='', max_length=64) class Meta: app_label = 'post_office' - verbose_name = pgettext_lazy("Email address", "Email") - verbose_name_plural = pgettext_lazy("Email addresses", "Emails") + verbose_name = pgettext_lazy('Email address', 'Email') + verbose_name_plural = pgettext_lazy('Email addresses', 'Emails') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -105,7 +105,7 @@ def prepare_email_message(self): if get_override_recipients(): self.to = get_override_recipients() - if self.context is not None: + if self.template is not None and self.context is not None: engine = get_template_engine() subject = engine.from_string(self.template.subject).render(self.context) plaintext_message = engine.from_string(self.template.content).render(self.context) @@ -122,7 +122,7 @@ def prepare_email_message(self): if isinstance(self.headers, dict) or self.expires_at or self.message_id: headers = dict(self.headers or {}) if self.expires_at: - headers.update({'Expires': self.expires_at.strftime("%a, %-d %b %H:%M:%S %z")}) + headers.update({'Expires': self.expires_at.strftime('%a, %-d %b %H:%M:%S %z')}) if self.message_id: headers.update({'Message-ID': self.message_id}) else: @@ -131,24 +131,42 @@ def prepare_email_message(self): if html_message: if plaintext_message: msg = EmailMultiAlternatives( - subject=subject, body=plaintext_message, from_email=self.from_email, - to=self.to, bcc=self.bcc, cc=self.cc, - headers=headers, connection=connection) - msg.attach_alternative(html_message, "text/html") + subject=subject, + body=plaintext_message, + from_email=self.from_email, + to=self.to, + bcc=self.bcc, + cc=self.cc, + headers=headers, + connection=connection, + ) + msg.attach_alternative(html_message, 'text/html') else: msg = EmailMultiAlternatives( - subject=subject, body=html_message, from_email=self.from_email, - to=self.to, bcc=self.bcc, cc=self.cc, - headers=headers, connection=connection) + subject=subject, + body=html_message, + from_email=self.from_email, + to=self.to, + bcc=self.bcc, + cc=self.cc, + headers=headers, + connection=connection, + ) msg.content_subtype = 'html' if hasattr(multipart_template, 'attach_related'): multipart_template.attach_related(msg) else: msg = EmailMessage( - subject=subject, body=plaintext_message, from_email=self.from_email, - to=self.to, bcc=self.bcc, cc=self.cc, - headers=headers, connection=connection) + subject=subject, + body=plaintext_message, + from_email=self.from_email, + to=self.to, + bcc=self.bcc, + cc=self.cc, + headers=headers, + connection=connection, + ) for attachment in self.attachments.all(): if attachment.headers: @@ -167,8 +185,7 @@ def prepare_email_message(self): self._cached_email_message = msg return msg - def dispatch(self, log_level=None, - disconnect_after_delivery=True, commit=True): + def dispatch(self, log_level=None, disconnect_after_delivery=True, commit=True): """ Sends email and log the result. """ @@ -203,17 +220,15 @@ def dispatch(self, log_level=None, # and 2 means log both successes and failures if log_level == 1: if status == STATUS.failed: - self.logs.create(status=status, message=message, - exception_type=exception_type) + self.logs.create(status=status, message=message, exception_type=exception_type) elif log_level == 2: - self.logs.create(status=status, message=message, - exception_type=exception_type) + self.logs.create(status=status, message=message, exception_type=exception_type) return status def clean(self): if self.scheduled_time and self.expires_at and self.scheduled_time > self.expires_at: - raise ValidationError(_("The scheduled time may not be later than the expires time.")) + raise ValidationError(_('The scheduled time may not be later than the expires time.')) def save(self, *args, **kwargs): self.full_clean() @@ -225,10 +240,11 @@ class Log(models.Model): A model to record sending email sending activities. """ - STATUS_CHOICES = [(STATUS.sent, _("sent")), (STATUS.failed, _("failed"))] + STATUS_CHOICES = [(STATUS.sent, _('sent')), (STATUS.failed, _('failed'))] - email = models.ForeignKey(Email, editable=False, related_name='logs', - verbose_name=_('Email address'), on_delete=models.CASCADE) + email = models.ForeignKey( + Email, editable=False, related_name='logs', verbose_name=_('Email address'), on_delete=models.CASCADE + ) date = models.DateTimeField(auto_now_add=True) status = models.PositiveSmallIntegerField(_('Status'), choices=STATUS_CHOICES) exception_type = models.CharField(_('Exception type'), max_length=255, blank=True) @@ -236,8 +252,8 @@ class Log(models.Model): class Meta: app_label = 'post_office' - verbose_name = _("Log") - verbose_name_plural = _("Logs") + verbose_name = _('Log') + verbose_name_plural = _('Logs') def __str__(self): return str(self.date) @@ -252,31 +268,39 @@ class EmailTemplate(models.Model): """ Model to hold template information from db """ + name = models.CharField(_('Name'), max_length=255, help_text=_("e.g: 'welcome_email'")) - description = models.TextField(_('Description'), blank=True, - help_text=_("Description of this template.")) + description = models.TextField(_('Description'), blank=True, help_text=_('Description of this template.')) created = models.DateTimeField(auto_now_add=True) last_updated = models.DateTimeField(auto_now=True) - subject = models.CharField(max_length=255, blank=True, - verbose_name=_("Subject"), validators=[validate_template_syntax]) - content = models.TextField(blank=True, - verbose_name=_("Content"), validators=[validate_template_syntax]) - html_content = models.TextField(blank=True, - verbose_name=_("HTML content"), validators=[validate_template_syntax]) - language = models.CharField(max_length=12, - verbose_name=_("Language"), - help_text=_("Render template in alternative language"), - default='', blank=True) - default_template = models.ForeignKey('self', related_name='translated_templates', - null=True, default=None, verbose_name=_('Default template'), on_delete=models.CASCADE) + subject = models.CharField( + max_length=255, blank=True, verbose_name=_('Subject'), validators=[validate_template_syntax] + ) + content = models.TextField(blank=True, verbose_name=_('Content'), validators=[validate_template_syntax]) + html_content = models.TextField(blank=True, verbose_name=_('HTML content'), validators=[validate_template_syntax]) + language = models.CharField( + max_length=12, + verbose_name=_('Language'), + help_text=_('Render template in alternative language'), + default='', + blank=True, + ) + default_template = models.ForeignKey( + 'self', + related_name='translated_templates', + null=True, + default=None, + verbose_name=_('Default template'), + on_delete=models.CASCADE, + ) objects = EmailTemplateManager() class Meta: app_label = 'post_office' unique_together = ('name', 'language', 'default_template') - verbose_name = _("Email Template") - verbose_name_plural = _("Email Templates") + verbose_name = _('Email Template') + verbose_name_plural = _('Email Templates') ordering = ['name'] def __str__(self): @@ -300,28 +324,26 @@ def get_upload_path(instance, filename): if not instance.name: instance.name = filename # set original filename date = timezone.now().date() - filename = '{name}.{ext}'.format(name=uuid4().hex, - ext=filename.split('.')[-1]) + filename = '{name}.{ext}'.format(name=uuid4().hex, ext=filename.split('.')[-1]) - return os.path.join('post_office_attachments', str(date.year), - str(date.month), str(date.day), filename) + return os.path.join('post_office_attachments', str(date.year), str(date.month), str(date.day), filename) class Attachment(models.Model): """ A model describing an email attachment. """ + file = models.FileField(_('File'), upload_to=get_upload_path) - name = models.CharField(_('Name'), max_length=255, help_text=_("The original filename")) - emails = models.ManyToManyField(Email, related_name='attachments', - verbose_name=_('Emails')) + name = models.CharField(_('Name'), max_length=255, help_text=_('The original filename')) + emails = models.ManyToManyField(Email, related_name='attachments', verbose_name=_('Emails')) mimetype = models.CharField(max_length=255, default='', blank=True) - headers = JSONField(_('Headers'), blank=True, null=True) + headers = models.JSONField(_('Headers'), blank=True, null=True) class Meta: app_label = 'post_office' - verbose_name = _("Attachment") - verbose_name_plural = _("Attachments") + verbose_name = _('Attachment') + verbose_name_plural = _('Attachments') def __str__(self): return self.name diff --git a/post_office/sanitizer.py b/post_office/sanitizer.py index 887c7d88..71ed49df 100644 --- a/post_office/sanitizer.py +++ b/post_office/sanitizer.py @@ -6,16 +6,27 @@ except ImportError: # if bleach is not installed, render HTML as escaped text to prevent XSS attacks heading = gettext_lazy("Install 'bleach' to render HTML properly.") - clean_html = lambda body: format_html('

{heading}

\n
{body}
', - heading=heading, body=body) + clean_html = lambda body: format_html('

{heading}

\n
{body}
', heading=heading, body=body) else: styles = [ - 'border', 'border-top', 'border-right', 'border-bottom', 'border-left', + 'border', + 'border-top', + 'border-right', + 'border-bottom', + 'border-left', 'border-radius', 'box-shadow', 'height', - 'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', - 'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', + 'margin', + 'margin-top', + 'margin-right', + 'margin-bottom', + 'margin-left', + 'padding', + 'padding-top', + 'padding-right', + 'padding-bottom', + 'padding-left', 'width', 'max-width', 'min-width', @@ -67,7 +78,7 @@ 'list-style-position', 'list-style-tyle', ] - tags=[ + tags = [ 'a', 'abbr', 'acronym', @@ -80,7 +91,12 @@ 'em', 'div', 'font', - 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', 'head', 'hr', 'i', @@ -92,11 +108,17 @@ 'pre', 'span', 'strong', - 'table', 'tbody', 'tfoot', 'td', 'th', 'thead', 'tr', + 'table', + 'tbody', + 'tfoot', + 'td', + 'th', + 'thead', + 'tr', 'u', 'ul', ] - attributes={ + attributes = { 'a': ['class', 'href', 'id', 'style', 'target'], 'abbr': ['class', 'id', 'style'], 'acronym': ['class', 'id', 'style'], @@ -126,11 +148,56 @@ 'pre': ['class', 'id', 'style'], 'span': ['class', 'id', 'style'], 'strong': ['class', 'id', 'style'], - 'table': ['class', 'id', 'style', 'align', 'bgcolor', 'border', 'cellpadding', 'cellspacing', 'dir', 'frame', 'rules', 'width'], + 'table': [ + 'class', + 'id', + 'style', + 'align', + 'bgcolor', + 'border', + 'cellpadding', + 'cellspacing', + 'dir', + 'frame', + 'rules', + 'width', + ], 'tbody': ['class', 'id', 'style'], 'tfoot': ['class', 'id', 'style'], - 'td': ['class', 'id', 'style', 'abbr', 'align', 'bgcolor', 'colspan', 'dir', 'height', 'lang', 'rowspan', 'scope', 'style', 'valign', 'width'], - 'th': ['class', 'id', 'style', 'abbr', 'align', 'background', 'bgcolor', 'colspan', 'dir', 'height', 'lang', 'scope', 'style', 'valign', 'width'], + 'td': [ + 'class', + 'id', + 'style', + 'abbr', + 'align', + 'bgcolor', + 'colspan', + 'dir', + 'height', + 'lang', + 'rowspan', + 'scope', + 'style', + 'valign', + 'width', + ], + 'th': [ + 'class', + 'id', + 'style', + 'abbr', + 'align', + 'background', + 'bgcolor', + 'colspan', + 'dir', + 'height', + 'lang', + 'scope', + 'style', + 'valign', + 'width', + ], 'thead': ['class', 'id', 'style'], 'tr': ['class', 'id', 'style', 'align', 'bgcolor', 'dir', 'style', 'valign'], 'u': ['class', 'id', 'style'], @@ -138,24 +205,29 @@ } try: from bleach.css_sanitizer import CSSSanitizer + css_sanitizer = CSSSanitizer( - allowed_css_properties=styles, + allowed_css_properties=styles, + ) + clean_html = lambda body: mark_safe( + bleach.clean( + body, + tags=tags, + attributes=attributes, + strip=True, + strip_comments=True, + css_sanitizer=css_sanitizer, + ) ) - clean_html = lambda body: mark_safe(bleach.clean( - body, - tags=tags, - attributes=attributes, - strip=True, - strip_comments=True, - css_sanitizer=css_sanitizer, - )) except ModuleNotFoundError: # if bleach version is prior to 5.0.0 - clean_html = lambda body: mark_safe(bleach.clean( - body, - tags=tags, - attributes=attributes, - strip=True, - strip_comments=True, - styles=styles, - )) + clean_html = lambda body: mark_safe( + bleach.clean( + body, + tags=tags, + attributes=attributes, + strip=True, + strip_comments=True, + styles=styles, + ) + ) diff --git a/post_office/settings.py b/post_office/settings.py index ae20d029..25b4c3c2 100644 --- a/post_office/settings.py +++ b/post_office/settings.py @@ -16,7 +16,7 @@ def get_backend(alias='default'): def get_available_backends(): - """ Returns a dictionary of defined backend classes. For example: + """Returns a dictionary of defined backend classes. For example: { 'default': 'django.core.mail.backends.smtp.EmailBackend', 'locmem': 'django.core.mail.backends.locmem.EmailBackend', @@ -33,16 +33,13 @@ def get_available_backends(): # } backend = get_config().get('EMAIL_BACKEND') if backend: - warnings.warn('Please use the new POST_OFFICE["BACKENDS"] settings', - DeprecationWarning) + warnings.warn('Please use the new POST_OFFICE["BACKENDS"] settings', DeprecationWarning) backends['default'] = backend return backends # Fall back to Django's EMAIL_BACKEND definition - backends['default'] = getattr( - settings, 'EMAIL_BACKEND', - 'django.core.mail.backends.smtp.EmailBackend') + backends['default'] = getattr(settings, 'EMAIL_BACKEND', 'django.core.mail.backends.smtp.EmailBackend') # If EMAIL_BACKEND is set to use PostOfficeBackend # and POST_OFFICE_BACKEND is not set, fall back to SMTP @@ -54,12 +51,12 @@ def get_available_backends(): def get_cache_backend(): if hasattr(settings, 'CACHES'): - if "post_office" in settings.CACHES: - return caches["post_office"] + if 'post_office' in settings.CACHES: + return caches['post_office'] else: # Sometimes this raises InvalidCacheBackendError, which is ok too try: - return caches["default"] + return caches['default'] except InvalidCacheBackendError: pass return None @@ -83,6 +80,10 @@ def get_celery_enabled(): return get_config().get('CELERY_ENABLED', False) +def get_lock_file_name(): + return get_config().get('LOCK_FILE_NAME', 'post_office') + + def get_threads_per_process(): return get_config().get('THREADS_PER_PROCESS', 5) @@ -124,6 +125,10 @@ def get_message_id_fqdn(): return get_config().get('MESSAGE_ID_FQDN', DNS_NAME) -CONTEXT_FIELD_CLASS = get_config().get('CONTEXT_FIELD_CLASS', - 'jsonfield.JSONField') +# BATCH_DELIVERY_TIMEOUT defaults to 180 seconds (3 minutes) +def get_batch_delivery_timeout(): + return get_config().get('BATCH_DELIVERY_TIMEOUT', 180) + + +CONTEXT_FIELD_CLASS = get_config().get('CONTEXT_FIELD_CLASS', 'django.db.models.JSONField') context_field_class = import_string(CONTEXT_FIELD_CLASS) diff --git a/post_office/tasks.py b/post_office/tasks.py index d0594c3c..a931cce5 100644 --- a/post_office/tasks.py +++ b/post_office/tasks.py @@ -4,6 +4,7 @@ example by other task queue systems such as Huey, which use the same pattern of auto-discovering tasks in "tasks" submodules. """ + import datetime from django.utils.timezone import now @@ -19,12 +20,14 @@ else: raise NotImplementedError() except (ImportError, NotImplementedError): + def queued_mail_handler(sender, **kwargs): """ To be called by :func:`post_office.signals.email_queued.send()` for triggering asynchronous mail delivery – if provided by an external queue, such as Celery. """ else: + @shared_task(ignore_result=True) def send_queued_mail(*args, **kwargs): """ diff --git a/post_office/template/__init__.py b/post_office/template/__init__.py index 02fa5054..a8cb0d5e 100644 --- a/post_office/template/__init__.py +++ b/post_office/template/__init__.py @@ -14,5 +14,5 @@ def render_to_string(template_name, context=None, request=None, using=None): template = get_template(template_name, using=using) try: return template.render(context, request), template.template._attached_images - except Exception as a: + except Exception: return template.render(context, request) diff --git a/post_office/template/backends/post_office.py b/post_office/template/backends/post_office.py index 624a4218..5fd4ae2d 100644 --- a/post_office/template/backends/post_office.py +++ b/post_office/template/backends/post_office.py @@ -12,7 +12,7 @@ def __init__(self, template, backend): super().__init__(template, backend) def attach_related(self, email_message): - assert isinstance(email_message, EmailMultiAlternatives), "Parameter must be of type EmailMultiAlternatives" + assert isinstance(email_message, EmailMultiAlternatives), 'Parameter must be of type EmailMultiAlternatives' email_message.mixed_subtype = 'related' for attachment in self.template._attached_images: email_message.attach(attachment) @@ -23,6 +23,7 @@ class PostOfficeTemplates(BaseEngine): Customized Template Engine which keeps track on referenced images and stores them as attachments to be used in multipart email messages. """ + app_dirname = 'templates' def __init__(self, params): @@ -32,9 +33,7 @@ def __init__(self, params): options.setdefault('debug', settings.DEBUG) options.setdefault( 'file_charset', - settings.FILE_CHARSET - if settings.is_overridden('FILE_CHARSET') - else 'utf-8', + settings.FILE_CHARSET if settings.is_overridden('FILE_CHARSET') else 'utf-8', ) libraries = options.get('libraries', {}) options['libraries'] = self.get_templatetag_libraries(libraries) diff --git a/post_office/templatetags/post_office.py b/post_office/templatetags/post_office.py index e56c8a3c..f5068188 100644 --- a/post_office/templatetags/post_office.py +++ b/post_office/templatetags/post_office.py @@ -13,8 +13,9 @@ @register.simple_tag(takes_context=True) def inline_image(context, file): - assert hasattr(context.template, '_attached_images'), \ - "You must use template engine 'post_office' when rendering images using templatetag 'inline_image'." + assert hasattr( + context.template, '_attached_images' + ), "You must use template engine 'post_office' when rendering images using templatetag 'inline_image'." if isinstance(file, ImageFile): fileobj = file elif os.path.isabs(file) and os.path.exists(file): @@ -23,7 +24,7 @@ def inline_image(context, file): try: absfilename = finders.find(file) if absfilename is None: - raise FileNotFoundError("No such file: {}".format(file)) + raise FileNotFoundError(f'No such file: {file}') except Exception: if settings.DEBUG: raise @@ -33,6 +34,6 @@ def inline_image(context, file): image = MIMEImage(raw_data) md5sum = hashlib.md5(raw_data).hexdigest() image.add_header('Content-Disposition', 'inline', filename=md5sum) - image.add_header('Content-ID', '<{}>'.format(md5sum)) + image.add_header('Content-ID', f'<{md5sum}>') context.template._attached_images.append(image) - return 'cid:{}'.format(md5sum) + return f'cid:{md5sum}' diff --git a/post_office/test_settings.py b/post_office/test_settings.py index 0409f31c..ada72d85 100644 --- a/post_office/test_settings.py +++ b/post_office/test_settings.py @@ -1,10 +1,11 @@ import os import platform -if platform.system() in ["Darwin"]: +if platform.system() in ['Darwin']: from multiprocessing import set_start_method + # required since Python-3.8. See #319 - set_start_method("fork") + set_start_method('fork') BASE_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -33,7 +34,7 @@ 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 'TIMEOUT': 36000, 'KEY_PREFIX': 'post-office', - } + }, } POST_OFFICE = { @@ -43,10 +44,12 @@ 'error': 'post_office.tests.test_backends.ErrorRaisingBackend', 'smtp': 'django.core.mail.backends.smtp.EmailBackend', 'connection_tester': 'post_office.tests.test_mail.ConnectionTestingBackend', + 'slow_backend': 'post_office.tests.test_mail.SlowTestBackend', }, 'CELERY_ENABLED': False, 'MAX_RETRIES': 2, 'MESSAGE_ID_ENABLED': True, + 'BATCH_DELIVERY_TIMEOUT': 2, 'MESSAGE_ID_FQDN': 'example.com', } @@ -89,7 +92,8 @@ 'django.contrib.messages.context_processors.messages', ], }, - }, { + }, + { 'BACKEND': 'post_office.template.backends.post_office.PostOfficeTemplates', 'APP_DIRS': True, 'DIRS': [os.path.join(BASE_DIR, 'tests/templates')], @@ -103,11 +107,10 @@ 'django.template.context_processors.tz', 'django.template.context_processors.request', ] - } - } + }, + }, ] STATICFILES_DIRS = [os.path.join(BASE_DIR, 'tests/static')] DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' - diff --git a/post_office/tests/test_backends.py b/post_office/tests/test_backends.py index 0ec944ab..e19b2116 100644 --- a/post_office/tests/test_backends.py +++ b/post_office/tests/test_backends.py @@ -1,5 +1,7 @@ import os from email.mime.image import MIMEImage +from unittest import mock + from django.conf import settings from django.core.files.images import File from django.core.mail import EmailMultiAlternatives, send_mail, EmailMessage @@ -22,7 +24,6 @@ def send_messages(self, email_messages): class BackendTest(TestCase): - @override_settings(EMAIL_BACKEND='post_office.EmailBackend') def test_email_backend(self): """ @@ -35,9 +36,7 @@ def test_email_backend(self): self.assertEqual(email.priority, PRIORITY.medium) def test_email_backend_setting(self): - """ - - """ + """ """ old_email_backend = getattr(settings, 'EMAIL_BACKEND', None) old_post_office_backend = getattr(settings, 'POST_OFFICE_BACKEND', None) if hasattr(settings, 'EMAIL_BACKEND'): @@ -70,9 +69,8 @@ def test_sending_html_email(self): """ "text/html" attachments to Email should be persisted into the database """ - message = EmailMultiAlternatives('subject', 'body', 'from@example.com', - ['recipient@example.com']) - message.attach_alternative('html', "text/html") + message = EmailMultiAlternatives('subject', 'body', 'from@example.com', ['recipient@example.com']) + message.attach_alternative('html', 'text/html') message.send() email = Email.objects.latest('id') self.assertEqual(email.html_message, 'html') @@ -82,9 +80,9 @@ def test_headers_sent(self): """ Test that headers are correctly set on the outgoing emails. """ - message = EmailMessage('subject', 'body', 'from@example.com', - ['recipient@example.com'], - headers={'Reply-To': 'reply@example.com'}) + message = EmailMessage( + 'subject', 'body', 'from@example.com', ['recipient@example.com'], headers={'Reply-To': 'reply@example.com'} + ) message.send() email = Email.objects.latest('id') self.assertEqual(email.headers, {'Reply-To': 'reply@example.com'}) @@ -95,9 +93,15 @@ def test_reply_to_added_as_header(self): Test that 'Reply-To' headers are correctly set on the outgoing emails, when EmailMessage property reply_to is set. """ - message = EmailMessage('subject', 'body', 'from@example.com', - ['recipient@example.com'], - reply_to=['replyto@example.com', ],) + message = EmailMessage( + 'subject', + 'body', + 'from@example.com', + ['recipient@example.com'], + reply_to=[ + 'replyto@example.com', + ], + ) message.send() email = Email.objects.latest('id') self.assertEqual(email.headers, {'Reply-To': 'replyto@example.com'}) @@ -110,18 +114,21 @@ def test_reply_to_favors_explict_header(self): Then the explicit header value is favored over the message property reply_to, adopting the behaviour of message() in django.core.mail.message.EmailMessage. """ - message = EmailMessage('subject', 'body', 'from@example.com', - ['recipient@example.com'], - reply_to=['replyto-from-property@example.com'], - headers={'Reply-To': 'replyto-from-header@example.com'}) + message = EmailMessage( + 'subject', + 'body', + 'from@example.com', + ['recipient@example.com'], + reply_to=['replyto-from-property@example.com'], + headers={'Reply-To': 'replyto-from-header@example.com'}, + ) message.send() email = Email.objects.latest('id') self.assertEqual(email.headers, {'Reply-To': 'replyto-from-header@example.com'}) @override_settings(EMAIL_BACKEND='post_office.EmailBackend') def test_backend_attachments(self): - message = EmailMessage('subject', 'body', 'from@example.com', - ['recipient@example.com']) + message = EmailMessage('subject', 'body', 'from@example.com', ['recipient@example.com']) message.attach('attachment.txt', b'attachment content') message.send() @@ -133,8 +140,7 @@ def test_backend_attachments(self): @override_settings(EMAIL_BACKEND='post_office.EmailBackend') def test_backend_image_attachments(self): - message = EmailMessage('subject', 'body', 'from@example.com', - ['recipient@example.com']) + message = EmailMessage('subject', 'body', 'from@example.com', ['recipient@example.com']) filename = os.path.join(os.path.dirname(__file__), 'static/dummy.png') fileobj = File(open(filename, 'rb'), name='dummy.png') @@ -155,8 +161,8 @@ def test_backend_image_attachments(self): EMAIL_BACKEND='post_office.EmailBackend', POST_OFFICE={ 'DEFAULT_PRIORITY': 'now', - 'BACKENDS': {'default': 'django.core.mail.backends.dummy.EmailBackend'} - } + 'BACKENDS': {'default': 'django.core.mail.backends.dummy.EmailBackend'}, + }, ) def test_default_priority_now(self): # If DEFAULT_PRIORITY is "now", mails should be sent right away @@ -164,3 +170,18 @@ def test_default_priority_now(self): email = Email.objects.latest('id') self.assertEqual(email.status, STATUS.sent) self.assertEqual(num_sent, 1) + + @override_settings( + EMAIL_BACKEND='post_office.EmailBackend', + POST_OFFICE={ + 'DEFAULT_PRIORITY': 'medium', + 'BACKENDS': {'default': 'django.core.mail.backends.dummy.EmailBackend'}, + }, + ) + @mock.patch('post_office.signals.email_queued.send') + def test_email_queued_signal(self, mock): + # If DEFAULT_PRIORITY is not "now", the email_queued signal should be sent + send_mail('Test', 'Message', 'from1@example.com', ['to@example.com']) + email = Email.objects.latest('id') + self.assertEqual(email.status, STATUS.queued) + self.assertEqual(mock.call_count, 1) diff --git a/post_office/tests/test_cache.py b/post_office/tests/test_cache.py index bde6dbd1..9356e5cd 100644 --- a/post_office/tests/test_cache.py +++ b/post_office/tests/test_cache.py @@ -6,7 +6,6 @@ class CacheTest(TestCase): - def test_get_backend_settings(self): """Test basic get backend function and its settings""" # Sanity check @@ -14,7 +13,7 @@ def test_get_backend_settings(self): self.assertTrue(get_cache_backend()) # If no post office key is defined, it should return default - del(settings.CACHES['post_office']) + del settings.CACHES['post_office'] self.assertTrue(get_cache_backend()) # If no caches key in settings, it should return None @@ -23,14 +22,14 @@ def test_get_backend_settings(self): def test_get_cache_key(self): """ - Test for converting names to cache key + Test for converting names to cache key """ self.assertEqual('post_office:template:test', cache.get_cache_key('test')) self.assertEqual('post_office:template:test-slugify', cache.get_cache_key('test slugify')) def test_basic_cache_operations(self): """ - Test basic cache operations + Test basic cache operations """ # clean test cache cache.cache_backend.clear() diff --git a/post_office/tests/test_commands.py b/post_office/tests/test_commands.py index 49486064..e5322ec5 100644 --- a/post_office/tests/test_commands.py +++ b/post_office/tests/test_commands.py @@ -11,20 +11,15 @@ class CommandTest(TestCase): - def test_cleanup_mail_with_orphaned_attachments(self): self.assertEqual(Email.objects.count(), 0) - email = Email.objects.create(to=['to@example.com'], - from_email='from@example.com', - subject='Subject') + email = Email.objects.create(to=['to@example.com'], from_email='from@example.com', subject='Subject') email.created = now() - datetime.timedelta(31) email.save() attachment = Attachment() - attachment.file.save( - 'test.txt', content=ContentFile('test file content'), save=True - ) + attachment.file.save('test.txt', content=ContentFile('test file content'), save=True) email.attachments.add(attachment) attachment_path = attachment.file.name @@ -43,16 +38,12 @@ def test_cleanup_mail_with_orphaned_attachments(self): # Check if the email attachment's actual file have been deleted Email.objects.all().delete() - email = Email.objects.create(to=['to@example.com'], - from_email='from@example.com', - subject='Subject') + email = Email.objects.create(to=['to@example.com'], from_email='from@example.com', subject='Subject') email.created = now() - datetime.timedelta(31) email.save() attachment = Attachment() - attachment.file.save( - 'test.txt', content=ContentFile('test file content'), save=True - ) + attachment.file.save('test.txt', content=ContentFile('test file content'), save=True) email.attachments.add(attachment) attachment_path = attachment.file.name @@ -64,7 +55,6 @@ def test_cleanup_mail_with_orphaned_attachments(self): self.assertEqual(Email.objects.count(), 0) self.assertEqual(Attachment.objects.count(), 0) - def test_cleanup_mail(self): """ The ``cleanup_mail`` command deletes mails older than a specified @@ -73,8 +63,7 @@ def test_cleanup_mail(self): self.assertEqual(Email.objects.count(), 0) # The command shouldn't delete today's email - email = Email.objects.create(from_email='from@example.com', - to=['to@example.com']) + email = Email.objects.create(from_email='from@example.com', to=['to@example.com']) call_command('cleanup_mail', days=30) self.assertEqual(Email.objects.count(), 1) @@ -88,7 +77,7 @@ def test_cleanup_mail(self): 'BACKENDS': { 'default': 'django.core.mail.backends.dummy.EmailBackend', }, - 'BATCH_SIZE': 1 + 'BATCH_SIZE': 1, } @override_settings(POST_OFFICE=TEST_SETTINGS) @@ -100,10 +89,8 @@ def test_send_queued_mail(self): # Make sure that send_queued_mail with empty queue does not raise error call_command('send_queued_mail', processes=1) - Email.objects.create(from_email='from@example.com', - to=['to@example.com'], status=STATUS.queued) - Email.objects.create(from_email='from@example.com', - to=['to@example.com'], status=STATUS.queued) + Email.objects.create(from_email='from@example.com', to=['to@example.com'], status=STATUS.queued) + Email.objects.create(from_email='from@example.com', to=['to@example.com'], status=STATUS.queued) call_command('send_queued_mail', processes=1) self.assertEqual(Email.objects.filter(status=STATUS.sent).count(), 2) self.assertEqual(Email.objects.filter(status=STATUS.queued).count(), 0) @@ -112,18 +99,15 @@ def test_successful_deliveries_logging(self): """ Successful deliveries are only logged when log_level is 2. """ - email = Email.objects.create(from_email='from@example.com', - to=['to@example.com'], status=STATUS.queued) + email = Email.objects.create(from_email='from@example.com', to=['to@example.com'], status=STATUS.queued) call_command('send_queued_mail', log_level=0) self.assertEqual(email.logs.count(), 0) - email = Email.objects.create(from_email='from@example.com', - to=['to@example.com'], status=STATUS.queued) + email = Email.objects.create(from_email='from@example.com', to=['to@example.com'], status=STATUS.queued) call_command('send_queued_mail', log_level=1) self.assertEqual(email.logs.count(), 0) - email = Email.objects.create(from_email='from@example.com', - to=['to@example.com'], status=STATUS.queued) + email = Email.objects.create(from_email='from@example.com', to=['to@example.com'], status=STATUS.queued) call_command('send_queued_mail', log_level=2) self.assertEqual(email.logs.count(), 1) @@ -131,20 +115,20 @@ def test_failed_deliveries_logging(self): """ Failed deliveries are logged when log_level is 1 and 2. """ - email = Email.objects.create(from_email='from@example.com', - to=['to@example.com'], status=STATUS.queued, - backend_alias='error') + email = Email.objects.create( + from_email='from@example.com', to=['to@example.com'], status=STATUS.queued, backend_alias='error' + ) call_command('send_queued_mail', log_level=0) self.assertEqual(email.logs.count(), 0) - email = Email.objects.create(from_email='from@example.com', - to=['to@example.com'], status=STATUS.queued, - backend_alias='error') + email = Email.objects.create( + from_email='from@example.com', to=['to@example.com'], status=STATUS.queued, backend_alias='error' + ) call_command('send_queued_mail', log_level=1) self.assertEqual(email.logs.count(), 1) - email = Email.objects.create(from_email='from@example.com', - to=['to@example.com'], status=STATUS.queued, - backend_alias='error') + email = Email.objects.create( + from_email='from@example.com', to=['to@example.com'], status=STATUS.queued, backend_alias='error' + ) call_command('send_queued_mail', log_level=2) self.assertEqual(email.logs.count(), 1) diff --git a/post_office/tests/test_connections.py b/post_office/tests/test_connections.py index aecae757..3ed7110f 100644 --- a/post_office/tests/test_connections.py +++ b/post_office/tests/test_connections.py @@ -6,7 +6,6 @@ class ConnectionTest(TestCase): - def test_get_connection(self): # Ensure ConnectionHandler returns the right connection self.assertTrue(isinstance(connections['error'], ErrorRaisingBackend)) diff --git a/post_office/tests/test_forms.py b/post_office/tests/test_forms.py index fec63097..29a3d825 100644 --- a/post_office/tests/test_forms.py +++ b/post_office/tests/test_forms.py @@ -11,10 +11,9 @@ class EmailTemplateFormTest(TestCase): def setUp(self) -> None: - self.form_set = formset_factory(EmailTemplateAdminForm, - extra=2) + self.form_set = formset_factory(EmailTemplateAdminForm, extra=2) self.client = Client() - self.user = User.objects.create_superuser(username='testuser', password='abc123456', email="testemail@test.com") + self.user = User.objects.create_superuser(username='testuser', password='abc123456', email='testemail@test.com') self.client.force_login(self.user) def test_can_create_a_email_template_with_the_same_attributes(self): @@ -23,26 +22,42 @@ def test_can_create_a_email_template_with_the_same_attributes(self): 'form-INITIAL_FORMS': '0', 'form-MAX_NUM_FORMS': '', 'name': 'Test', - 'email_photos-TOTAL_FORMS': '1', 'email_photos-INITIAL_FORMS': '0', - 'email_photos-MIN_NUM_FORMS': '0', 'email_photos-MAX_NUM_FORMS': '1', 'email_photos-0-id': '', - 'email_photos-0-email_template': '', 'email_photos-0-photo': '', 'email_photos-__prefix__-id': '', - 'email_photos-__prefix__-email_template': '', 'email_photos-__prefix__-photo': '', - 'translated_templates-TOTAL_FORMS': '2', 'translated_templates-INITIAL_FORMS': '0', - 'translated_templates-MIN_NUM_FORMS': '0', 'translated_templates-MAX_NUM_FORMS': '2', - 'translated_templates-0-language': 'es', 'translated_templates-0-subject': '', - 'translated_templates-0-content': '', 'translated_templates-0-html_content': '', - 'translated_templates-0-id': '', 'translated_templates-0-default_template': '', - 'translated_templates-1-language': 'es', 'translated_templates-1-subject': '', - 'translated_templates-1-content': '', 'translated_templates-1-html_content': '', - 'translated_templates-1-id': '', 'translated_templates-1-default_template': '', - 'translated_templates-__prefix__-language': 'es', 'translated_templates-__prefix__-subject': '', - 'translated_templates-__prefix__-content': '', 'translated_templates-__prefix__-html_content': '', - 'translated_templates-__prefix__-id': '', 'translated_templates-__prefix__-default_template': '', - '_save': 'Save' + 'email_photos-TOTAL_FORMS': '1', + 'email_photos-INITIAL_FORMS': '0', + 'email_photos-MIN_NUM_FORMS': '0', + 'email_photos-MAX_NUM_FORMS': '1', + 'email_photos-0-id': '', + 'email_photos-0-email_template': '', + 'email_photos-0-photo': '', + 'email_photos-__prefix__-id': '', + 'email_photos-__prefix__-email_template': '', + 'email_photos-__prefix__-photo': '', + 'translated_templates-TOTAL_FORMS': '2', + 'translated_templates-INITIAL_FORMS': '0', + 'translated_templates-MIN_NUM_FORMS': '0', + 'translated_templates-MAX_NUM_FORMS': '2', + 'translated_templates-0-language': 'es', + 'translated_templates-0-subject': '', + 'translated_templates-0-content': '', + 'translated_templates-0-html_content': '', + 'translated_templates-0-id': '', + 'translated_templates-0-default_template': '', + 'translated_templates-1-language': 'es', + 'translated_templates-1-subject': '', + 'translated_templates-1-content': '', + 'translated_templates-1-html_content': '', + 'translated_templates-1-id': '', + 'translated_templates-1-default_template': '', + 'translated_templates-__prefix__-language': 'es', + 'translated_templates-__prefix__-subject': '', + 'translated_templates-__prefix__-content': '', + 'translated_templates-__prefix__-html_content': '', + 'translated_templates-__prefix__-id': '', + 'translated_templates-__prefix__-default_template': '', + '_save': 'Save', } add_template_url = reverse('admin:post_office_emailtemplate_add') response = self.client.post(add_template_url, email_template, follow=True) - self.assertContains(response, "Duplicate template for language 'Spanish'.", - html=True) + self.assertContains(response, 'Duplicate template for language 'Spanish'.', html=True) diff --git a/post_office/tests/test_html_email.py b/post_office/tests/test_html_email.py index 8dbc2186..507a4fbd 100644 --- a/post_office/tests/test_html_email.py +++ b/post_office/tests/test_html_email.py @@ -13,26 +13,28 @@ from post_office.models import Email, EmailTemplate, STATUS from post_office.template import render_to_string from post_office.template.backends.post_office import PostOfficeTemplates -from post_office.mail import create, send, send_queued +from post_office.mail import send, send_queued class HTMLMailTest(TestCase): - def test_text(self): template = get_template('hello.html', using='post_office') self.assertIsInstance(template.backend, PostOfficeTemplates) - context = {'foo': "Bar"} + context = {'foo': 'Bar'} content = template.render(context) self.assertHTMLEqual(content, '

Bar

') def test_html(self): template = get_template('image.html', using='post_office') body = template.render({'imgsrc': 'dummy.png'}) - self.assertHTMLEqual(body, """ + self.assertHTMLEqual( + body, + """

Testing image attachments

-""") - subject = "[Django Post-Office unit tests] attached image" +""", + ) + subject = '[Django Post-Office unit tests] attached image' msg = EmailMultiAlternatives(subject, body, to=['john@example.com']) template.attach_related(msg) msg.content_subtype = 'html' @@ -53,9 +55,9 @@ def test_html(self): self.assertEqual(part['Content-ID'], '') def test_mixed(self): - body = "Testing mixed text and html attachments" + body = 'Testing mixed text and html attachments' html, attached_images = render_to_string('image.html', {'imgsrc': 'dummy.png'}, using='post_office') - subject = "[django-SHOP unit tests] attached image" + subject = '[django-SHOP unit tests] attached image' msg = EmailMultiAlternatives(subject, body, to=['john@example.com']) msg.attach_alternative(html, 'text/html') for attachment in attached_images: @@ -85,11 +87,14 @@ def test_image(self): imagefile = ImageFile(open(filename, 'rb'), name=relfilename) template = get_template('image.html', using='post_office') body = template.render({'imgsrc': imagefile}) - self.assertHTMLEqual(body, """ + self.assertHTMLEqual( + body, + """

Testing image attachments

-""") - subject = "[Django Post-Office unit tests] attached image" +""", + ) + subject = '[Django Post-Office unit tests] attached image' msg = EmailMultiAlternatives(subject, body, to=['john@example.com']) template.attach_related(msg) # this message can be send by email @@ -107,23 +112,31 @@ def test_image(self): self.assertEqual(part.get_filename(), 'f5c66340b8af7dc946cd25d84fdf8c90') self.assertEqual(part['Content-ID'], '') - @override_settings(EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend', POST_OFFICE={ - 'BACKENDS': {'locmem': 'django.core.mail.backends.locmem.EmailBackend'}, - 'TEMPLATE_ENGINE': 'post_office', - }) + @override_settings( + EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend', + POST_OFFICE={ + 'BACKENDS': {'locmem': 'django.core.mail.backends.locmem.EmailBackend'}, + 'TEMPLATE_ENGINE': 'post_office', + }, + ) def test_send_with_html_template(self): template = EmailTemplate.objects.create( - name="Test Inlined Images", - subject="[django-SHOP unit tests] attached image", + name='Test Inlined Images', + subject='[django-SHOP unit tests] attached image', html_content=""" {% load post_office %}

Testing image attachments

-""" +""", ) filename = os.path.join(os.path.dirname(__file__), 'static/dummy.png') context = {'imgsrc': filename} - queued_mail = send(recipients=['to@example.com'], sender='from@example.com', - template=template, context=context, render_on_delivery=True) + queued_mail = send( + recipients=['to@example.com'], + sender='from@example.com', + template=template, + context=context, + render_on_delivery=True, + ) queued_mail = Email.objects.get(id=queued_mail.id) send_queued() self.assertEqual(Email.objects.get(id=queued_mail.id).status, STATUS.sent) @@ -132,41 +145,43 @@ def test_send_with_html_template(self): class EmailAdminTest(TestCase): def setUp(self) -> None: self.client = Client() - self.user = get_user_model().objects.create_superuser(username='testuser', - password='secret', - email="test@example.com") + self.user = get_user_model().objects.create_superuser( + username='testuser', password='secret', email='test@example.com' + ) self.client.force_login(self.user) @override_settings(EMAIL_BACKEND='post_office.EmailBackend') def test_email_change_view(self): template = get_template('image.html', using='post_office') body = template.render({'imgsrc': 'dummy.png'}) - subject = "[Django Post-Office unit tests] attached image" + subject = '[Django Post-Office unit tests] attached image' msg = EmailMultiAlternatives(subject, body, to=['john@example.com']) msg.content_subtype = 'html' template.attach_related(msg) msg.send() # check that in the Email's detail view, the message is rendered + self.assertEqual(Email.objects.count(), 1) # TODO: remove this email = Email.objects.latest('id') parts = email.email_message().message().walk() part = next(parts) - self.assertEqual(part.get_content_type(), 'multipart/mixed') + self.assertIsInstance(part, SafeMIMEMultipart) part = next(parts) - self.assertEqual(part.get_content_type(), 'text/html') + self.assertIsInstance(part, SafeMIMEText) part = next(parts) self.assertEqual(part.get_content_type(), 'image/png') content_id = part['Content-Id'][1:33] email_change_url = reverse('admin:post_office_email_change', args=(email.pk,)) response = self.client.get(email_change_url, follow=True) - self.assertContains(response, "[Django Post-Office unit tests] attached image") + self.assertContains(response, '[Django Post-Office unit tests] attached image') email_image_url = reverse('admin:post_office_email_image', kwargs={'pk': email.pk, 'content_id': content_id}) try: import bleach - self.assertContains(response, "

Testing image attachments

") - self.assertContains(response, 'Testing image attachments') + self.assertContains(response, f'', email.message_id)) def test_send_many(self): - """Test send_many creates the right emails """ + """Test send_many creates the right emails""" kwargs_list = [ {'sender': 'from@example.com', 'recipients': ['a@example.com']}, {'sender': 'from@example.com', 'recipients': ['b@example.com']}, @@ -238,9 +249,13 @@ def test_send_with_attachments(self): 'attachment_file1.txt': ContentFile('content'), 'attachment_file2.txt': ContentFile('content'), } - email = send(recipients=['a@example.com', 'b@example.com'], - sender='from@example.com', message='message', - subject='subject', attachments=attachments) + email = send( + recipients=['a@example.com', 'b@example.com'], + sender='from@example.com', + message='message', + subject='subject', + attachments=attachments, + ) self.assertTrue(email.pk) self.assertEqual(email.attachments.count(), 2) @@ -251,23 +266,19 @@ def test_send_with_render_on_delivery(self): fields being saved """ template = EmailTemplate.objects.create( - subject='Subject {{ name }}', - content='Content {{ name }}', - html_content='HTML {{ name }}' + subject='Subject {{ name }}', content='Content {{ name }}', html_content='HTML {{ name }}' ) context = {'name': 'test'} - email = send(recipients=['a@example.com', 'b@example.com'], - template=template, context=context, - render_on_delivery=True) + email = send( + recipients=['a@example.com', 'b@example.com'], template=template, context=context, render_on_delivery=True + ) self.assertEqual(email.subject, '') self.assertEqual(email.message, '') self.assertEqual(email.html_message, '') self.assertEqual(email.template, template) # context shouldn't be persisted when render_on_delivery = False - email = send(recipients=['a@example.com'], - template=template, context=context, - render_on_delivery=False) + email = send(recipients=['a@example.com'], template=template, context=context, render_on_delivery=False) self.assertEqual(email.context, None) def test_send_with_attachments_multiple_recipients(self): @@ -276,9 +287,13 @@ def test_send_with_attachments_multiple_recipients(self): 'attachment_file1.txt': ContentFile('content'), 'attachment_file2.txt': ContentFile('content'), } - email = send(recipients=['a@example.com', 'b@example.com'], - sender='from@example.com', message='message', - subject='subject', attachments=attachments) + email = send( + recipients=['a@example.com', 'b@example.com'], + sender='from@example.com', + message='message', + subject='subject', + attachments=attachments, + ) self.assertEqual(email.attachments.count(), 2) self.assertEqual(Attachment.objects.count(), 2) @@ -288,14 +303,15 @@ def test_create_with_template(self): won't be rendered, context also won't be saved.""" template = EmailTemplate.objects.create( - subject='Subject {{ name }}', - content='Content {{ name }}', - html_content='HTML {{ name }}' + subject='Subject {{ name }}', content='Content {{ name }}', html_content='HTML {{ name }}' ) context = {'name': 'test'} email = create( - sender='from@example.com', recipients=['to@example.com'], - template=template, context=context, render_on_delivery=True + sender='from@example.com', + recipients=['to@example.com'], + template=template, + context=context, + render_on_delivery=True, ) self.assertEqual(email.subject, '') self.assertEqual(email.message, '') @@ -308,15 +324,10 @@ def test_create_with_template_and_empty_context(self): will be rendered, context won't be saved.""" template = EmailTemplate.objects.create( - subject='Subject {% now "Y" %}', - content='Content {% now "Y" %}', - html_content='HTML {% now "Y" %}' + subject='Subject {% now "Y" %}', content='Content {% now "Y" %}', html_content='HTML {% now "Y" %}' ) context = None - email = create( - sender='from@example.com', recipients=['to@example.com'], - template=template, context=context - ) + email = create(sender='from@example.com', recipients=['to@example.com'], template=template, context=context) today = timezone.datetime.today() current_year = today.year self.assertEqual(email.subject, 'Subject %d' % current_year) @@ -328,19 +339,26 @@ def test_create_with_template_and_empty_context(self): def test_backend_alias(self): """Test backend_alias field is properly set.""" - email = send(recipients=['a@example.com'], - sender='from@example.com', message='message', - subject='subject') + email = send(recipients=['a@example.com'], sender='from@example.com', message='message', subject='subject') self.assertEqual(email.backend_alias, '') - email = send(recipients=['a@example.com'], - sender='from@example.com', message='message', - subject='subject', backend='locmem') + email = send( + recipients=['a@example.com'], + sender='from@example.com', + message='message', + subject='subject', + backend='locmem', + ) self.assertEqual(email.backend_alias, 'locmem') with self.assertRaises(ValueError): - send(recipients=['a@example.com'], sender='from@example.com', - message='message', subject='subject', backend='foo') + send( + recipients=['a@example.com'], + sender='from@example.com', + message='message', + subject='subject', + backend='foo', + ) @override_settings(LANGUAGES=(('en', 'English'), ('ru', 'Russian'))) def test_send_with_template(self): @@ -348,22 +366,19 @@ def test_send_with_template(self): will be rendered, context won't be saved.""" template = EmailTemplate.objects.create( - subject='Subject {{ name }}', - content='Content {{ name }}', - html_content='HTML {{ name }}' + subject='Subject {{ name }}', content='Content {{ name }}', html_content='HTML {{ name }}' ) russian_template = EmailTemplate( default_template=template, language='ru', subject='предмет {{ name }}', content='содержание {{ name }}', - html_content='HTML {{ name }}' + html_content='HTML {{ name }}', ) russian_template.save() context = {'name': 'test'} - email = send(recipients=['to@example.com'], sender='from@example.com', - template=template, context=context) + email = send(recipients=['to@example.com'], sender='from@example.com', template=template, context=context) email = Email.objects.get(id=email.id) self.assertEqual(email.subject, 'Subject test') self.assertEqual(email.message, 'Content test') @@ -372,8 +387,9 @@ def test_send_with_template(self): self.assertIsNotNone(email.template) # check, if we use the Russian version - email = send(recipients=['to@example.com'], sender='from@example.com', - template=russian_template, context=context) + email = send( + recipients=['to@example.com'], sender='from@example.com', template=russian_template, context=context + ) email = Email.objects.get(id=email.id) self.assertEqual(email.subject, 'предмет test') self.assertEqual(email.message, 'содержание test') @@ -382,36 +398,43 @@ def test_send_with_template(self): self.assertIsNotNone(email.template) # Check that send picks template with the right language - email = send(recipients=['to@example.com'], sender='from@example.com', - template=template, context=context, language='ru') + email = send( + recipients=['to@example.com'], sender='from@example.com', template=template, context=context, language='ru' + ) email = Email.objects.get(id=email.id) self.assertEqual(email.subject, 'предмет test') - email = send(recipients=['to@example.com'], sender='from@example.com', - template=template, context=context, language='ru', - render_on_delivery=True) + email = send( + recipients=['to@example.com'], + sender='from@example.com', + template=template, + context=context, + language='ru', + render_on_delivery=True, + ) self.assertEqual(email.template.language, 'ru') def test_send_bulk_with_faulty_template(self): template = EmailTemplate.objects.create( - subject='{% if foo %}Subject {{ name }}', - content='Content {{ name }}', - html_content='HTML {{ name }}' + subject='{% if foo %}Subject {{ name }}', content='Content {{ name }}', html_content='HTML {{ name }}' + ) + email = Email.objects.create( + to='to@example.com', from_email='from@example.com', template=template, status=STATUS.queued ) - email = Email.objects.create(to='to@example.com', from_email='from@example.com', - template=template, status=STATUS.queued) _send_bulk([email], uses_multiprocessing=False) email = Email.objects.get(id=email.id) self.assertEqual(email.status, STATUS.sent) + @override_settings(USE_TZ=False) def test_retry_failed(self): self.assertEqual(get_retry_timedelta(), timezone.timedelta(minutes=15)) self.assertEqual(get_max_retries(), 2) # attempt to send email for the first time with patch('django.utils.timezone.now', side_effect=lambda: timezone.datetime(2020, 5, 18, 8, 0, 0)): - email = create('from@example.com', recipients=['to@example.com'], subject='subject', message='message', - backend='error') + email = create( + 'from@example.com', recipients=['to@example.com'], subject='subject', message='message', backend='error' + ) self.assertIsNotNone(email.pk) self.assertEqual(email.created, timezone.datetime(2020, 5, 18, 8, 0, 0)) self.assertEqual(email.status, STATUS.queued) @@ -452,22 +475,31 @@ def test_retry_failed(self): @override_settings(USE_TZ=True) def test_expired(self): - tzinfo = pytz.timezone('Asia/Jakarta') - email = create('from@example.com', recipients=['to@example.com'], subject='subject', message='message', - expires_at=timezone.datetime(2020, 5, 18, 9, 0, 1, tzinfo=tzinfo)) + tzinfo = ZoneInfo('Asia/Jakarta') + email = create( + 'from@example.com', + recipients=['to@example.com'], + subject='subject', + message='message', + expires_at=timezone.datetime(2020, 5, 18, 9, 0, 1, tzinfo=tzinfo), + ) self.assertEqual(email.expires_at, timezone.datetime(2020, 5, 18, 9, 0, 1, tzinfo=tzinfo)) msg = email.prepare_email_message() - self.assertEqual(msg.extra_headers['Expires'], 'Mon, 18 May 09:00:01 +0707') + self.assertEqual(msg.extra_headers['Expires'], 'Mon, 18 May 09:00:01 +0700') # check that email is not sent after its expire_at date - with patch('django.utils.timezone.now', side_effect=lambda: timezone.datetime(2020, 5, 18, 9, 0, 2, tzinfo=tzinfo)): + with patch( + 'django.utils.timezone.now', side_effect=lambda: timezone.datetime(2020, 5, 18, 9, 0, 2, tzinfo=tzinfo) + ): self.assertEqual(email.status, STATUS.queued) result = send_queued() self.assertTupleEqual(result, (0, 0, 0)) email.refresh_from_db() # check that email is sent before its expire_at date - with patch('django.utils.timezone.now', side_effect=lambda: timezone.datetime(2020, 5, 18, 9, 0, 0, tzinfo=tzinfo)): + with patch( + 'django.utils.timezone.now', side_effect=lambda: timezone.datetime(2020, 5, 18, 9, 0, 0, tzinfo=tzinfo) + ): self.assertEqual(email.status, STATUS.queued) result = send_queued() self.assertTupleEqual(result, (1, 0, 0)) @@ -476,17 +508,40 @@ def test_expired(self): def test_invalid_expired(self): with self.assertRaises(ValidationError): - create('from@example.com', recipients=['to@example.com'], subject='subject', - message='message', - scheduled_time=timezone.datetime(2020, 5, 18, 9, 0, 1), - expires_at=timezone.datetime(2020, 5, 18, 9, 0, 0)) + create( + 'from@example.com', + recipients=['to@example.com'], + subject='subject', + message='message', + scheduled_time=timezone.datetime(2020, 5, 18, 9, 0, 1), + expires_at=timezone.datetime(2020, 5, 18, 9, 0, 0), + ) + + def test_batch_delivery_timeout(self): + """ + Ensure that batch delivery timeout is respected. + """ + email = Email.objects.create( + to=['to@example.com'], + from_email='bob@example.com', + subject='', + message='', + status=STATUS.queued, + backend_alias='slow_backend', + ) + start_time = timezone.now() + # slow backend sleeps for 5 seconds, so we should get a timeout error since we set + # BATCH_DELIVERY_TIMEOUT timeout to 2 seconds in test_settings.py + with self.assertRaises(TimeoutError): + send_queued() + end_time = timezone.now() + # Assert that running time is less than 3 seconds (2 seconds timeout + 1 second buffer) + self.assertTrue(end_time - start_time < timezone.timedelta(seconds=3)) @patch('post_office.signals.email_queued.send') def test_backend_signal(self, mock): """ Check that the post_office signal handler is fired """ - email = send(recipients=['a@example.com'], - sender='from@example.com', message='message', - subject='subject') + email = send(recipients=['a@example.com'], sender='from@example.com', message='message', subject='subject') mock.assert_called_once_with(sender=Email, emails=[email]) diff --git a/post_office/tests/test_models.py b/post_office/tests/test_models.py index 77ec5202..c4bb30ed 100644 --- a/post_office/tests/test_models.py +++ b/post_office/tests/test_models.py @@ -1,4 +1,3 @@ -import django import json import os @@ -18,7 +17,6 @@ class ModelTest(TestCase): - def test_email_message(self): """ Test to make sure that model's "email_message" method @@ -26,9 +24,13 @@ def test_email_message(self): """ # If ``html_message`` is set, ``EmailMultiAlternatives`` is expected - email = Email.objects.create(to=['to@example.com'], - from_email='from@example.com', subject='Subject', - message='Message', html_message='

HTML

') + email = Email.objects.create( + to=['to@example.com'], + from_email='from@example.com', + subject='Subject', + message='Message', + html_message='

HTML

', + ) message = email.email_message() self.assertEqual(type(message), EmailMultiAlternatives) self.assertEqual(message.from_email, 'from@example.com') @@ -38,9 +40,9 @@ def test_email_message(self): self.assertEqual(message.alternatives, [('

HTML

', 'text/html')]) # Without ``html_message``, ``EmailMessage`` class is expected - email = Email.objects.create(to=['to@example.com'], - from_email='from@example.com', subject='Subject', - message='Message') + email = Email.objects.create( + to=['to@example.com'], from_email='from@example.com', subject='Subject', message='Message' + ) message = email.email_message() self.assertEqual(type(message), EmailMessage) self.assertEqual(message.from_email, 'from@example.com') @@ -53,13 +55,29 @@ def test_email_message_render(self): Ensure Email instance with template is properly rendered. """ template = EmailTemplate.objects.create( - subject='Subject {{ name }}', - content='Content {{ name }}', - html_content='HTML {{ name }}' + subject='Subject {{ name }}', content='Content {{ name }}', html_content='HTML {{ name }}' ) context = {'name': 'test'} - email = Email.objects.create(to=['to@example.com'], template=template, - from_email='from@e.com', context=context) + email = Email.objects.create(to=['to@example.com'], template=template, from_email='from@e.com', context=context) + message = email.email_message() + self.assertEqual(message.subject, 'Subject test') + self.assertEqual(message.body, 'Content test') + self.assertEqual(message.alternatives[0][0], 'HTML test') + + def test_email_message_prepare_without_template_and_with_context(self): + """ + Ensure Email instance without template but with context is properly prepared. + """ + context = {'name': 'test'} + email = Email.objects.create( + to=['to@example.com'], + template=None, + subject='Subject test', + message='Content test', + html_message='HTML test', + from_email='from@e.com', + context=context, + ) message = email.email_message() self.assertEqual(message.subject, 'Subject test') self.assertEqual(message.body, 'Content test') @@ -69,16 +87,26 @@ def test_dispatch(self): """ Ensure that email.dispatch() actually sends out the email """ - email = Email.objects.create(to=['to@example.com'], from_email='from@example.com', - subject='Test dispatch', message='Message', backend_alias='locmem') + email = Email.objects.create( + to=['to@example.com'], + from_email='from@example.com', + subject='Test dispatch', + message='Message', + backend_alias='locmem', + ) email.dispatch() self.assertEqual(mail.outbox[0].subject, 'Test dispatch') def test_dispatch_with_override_recipients(self): previous_settings = settings.POST_OFFICE setattr(settings, 'POST_OFFICE', {'OVERRIDE_RECIPIENTS': ['override@gmail.com']}) - email = Email.objects.create(to=['to@example.com'], from_email='from@example.com', - subject='Test dispatch', message='Message', backend_alias='locmem') + email = Email.objects.create( + to=['to@example.com'], + from_email='from@example.com', + subject='Test dispatch', + message='Message', + backend_alias='locmem', + ) email.dispatch() self.assertEqual(mail.outbox[0].to, ['override@gmail.com']) settings.POST_OFFICE = previous_settings @@ -87,8 +115,14 @@ def test_status_and_log(self): """ Ensure that status and log are set properly on successful sending """ - email = Email.objects.create(to=['to@example.com'], from_email='from@example.com', - subject='Test', message='Message', backend_alias='locmem', id=333) + email = Email.objects.create( + to=['to@example.com'], + from_email='from@example.com', + subject='Test', + message='Message', + backend_alias='locmem', + id=333, + ) # Ensure that after dispatch status and logs are correctly set email.dispatch() log = Log.objects.latest('id') @@ -99,9 +133,13 @@ def test_status_and_log_on_error(self): """ Ensure that status and log are set properly on sending failure """ - email = Email.objects.create(to=['to@example.com'], from_email='from@example.com', - subject='Test', message='Message', - backend_alias='error') + email = Email.objects.create( + to=['to@example.com'], + from_email='from@example.com', + subject='Test', + message='Message', + backend_alias='error', + ) # Ensure that after dispatch status and logs are correctly set email.dispatch() log = Log.objects.latest('id') @@ -115,9 +153,13 @@ def test_errors_while_getting_connection_are_logged(self): """ Ensure that status and log are set properly on sending failure """ - email = Email.objects.create(to=['to@example.com'], subject='Test', - from_email='from@example.com', - message='Message', backend_alias='random') + email = Email.objects.create( + to=['to@example.com'], + subject='Test', + from_email='from@example.com', + message='Message', + backend_alias='random', + ) # Ensure that after dispatch status and logs are correctly set email.dispatch() log = Log.objects.latest('id') @@ -128,8 +170,7 @@ def test_errors_while_getting_connection_are_logged(self): def test_default_sender(self): email = send(['to@example.com'], subject='foo') - self.assertEqual(email.from_email, - django_settings.DEFAULT_FROM_EMAIL) + self.assertEqual(email.from_email, django_settings.DEFAULT_FROM_EMAIL) def test_send_argument_checking(self): """ @@ -137,18 +178,16 @@ def test_send_argument_checking(self): - "template" is used with "subject", "message" or "html_message" - recipients is not in tuple or list format """ - self.assertRaises(ValueError, send, ['to@example.com'], 'from@a.com', - template='foo', subject='bar') - self.assertRaises(ValueError, send, ['to@example.com'], 'from@a.com', - template='foo', message='bar') - self.assertRaises(ValueError, send, ['to@example.com'], 'from@a.com', - template='foo', html_message='bar') - self.assertRaises(ValueError, send, 'to@example.com', 'from@a.com', - template='foo', html_message='bar') - self.assertRaises(ValueError, send, cc='cc@example.com', sender='from@a.com', - template='foo', html_message='bar') - self.assertRaises(ValueError, send, bcc='bcc@example.com', sender='from@a.com', - template='foo', html_message='bar') + self.assertRaises(ValueError, send, ['to@example.com'], 'from@a.com', template='foo', subject='bar') + self.assertRaises(ValueError, send, ['to@example.com'], 'from@a.com', template='foo', message='bar') + self.assertRaises(ValueError, send, ['to@example.com'], 'from@a.com', template='foo', html_message='bar') + self.assertRaises(ValueError, send, 'to@example.com', 'from@a.com', template='foo', html_message='bar') + self.assertRaises( + ValueError, send, cc='cc@example.com', sender='from@a.com', template='foo', html_message='bar' + ) + self.assertRaises( + ValueError, send, bcc='bcc@example.com', sender='from@a.com', template='foo', html_message='bar' + ) def test_send_with_template(self): """ @@ -156,33 +195,41 @@ def test_send_with_template(self): """ Email.objects.all().delete() headers = {'Reply-to': 'reply@email.com'} - email_template = EmailTemplate.objects.create(name='foo', subject='bar', - content='baz') + email_template = EmailTemplate.objects.create(name='foo', subject='bar', content='baz') scheduled_time = datetime.now() + timedelta(days=1) - email = send(recipients=['to1@example.com', 'to2@example.com'], sender='from@a.com', - headers=headers, template=email_template, - scheduled_time=scheduled_time) + email = send( + recipients=['to1@example.com', 'to2@example.com'], + sender='from@a.com', + headers=headers, + template=email_template, + scheduled_time=scheduled_time, + ) self.assertEqual(email.to, ['to1@example.com', 'to2@example.com']) self.assertEqual(email.headers, headers) self.assertEqual(email.scheduled_time, scheduled_time) # Test without header Email.objects.all().delete() - email = send(recipients=['to1@example.com', 'to2@example.com'], sender='from@a.com', - template=email_template) + email = send(recipients=['to1@example.com', 'to2@example.com'], sender='from@a.com', template=email_template) self.assertEqual(email.to, ['to1@example.com', 'to2@example.com']) self.assertEqual(email.headers, None) def test_send_without_template(self): headers = {'Reply-to': 'reply@email.com'} scheduled_time = datetime.now() + timedelta(days=1) - email = send(sender='from@a.com', - recipients=['to1@example.com', 'to2@example.com'], - cc=['cc1@example.com', 'cc2@example.com'], - bcc=['bcc1@example.com', 'bcc2@example.com'], - subject='foo', message='bar', html_message='baz', - context={'name': 'Alice'}, headers=headers, - scheduled_time=scheduled_time, priority=PRIORITY.low) + email = send( + sender='from@a.com', + recipients=['to1@example.com', 'to2@example.com'], + cc=['cc1@example.com', 'cc2@example.com'], + bcc=['bcc1@example.com', 'bcc2@example.com'], + subject='foo', + message='bar', + html_message='baz', + context={'name': 'Alice'}, + headers=headers, + scheduled_time=scheduled_time, + priority=PRIORITY.low, + ) self.assertEqual(email.to, ['to1@example.com', 'to2@example.com']) self.assertEqual(email.cc, ['cc1@example.com', 'cc2@example.com']) @@ -195,10 +242,15 @@ def test_send_without_template(self): self.assertEqual(email.scheduled_time, scheduled_time) # Same thing, but now with context - email = send(['to1@example.com'], 'from@a.com', - subject='Hi {{ name }}', message='Message {{ name }}', - html_message='{{ name }}', - context={'name': 'Bob'}, headers=headers) + email = send( + ['to1@example.com'], + 'from@a.com', + subject='Hi {{ name }}', + message='Message {{ name }}', + html_message='{{ name }}', + context={'name': 'Bob'}, + headers=headers, + ) self.assertEqual(email.to, ['to1@example.com']) self.assertEqual(email.subject, 'Hi Bob') self.assertEqual(email.message, 'Message Bob') @@ -214,22 +266,21 @@ def test_invalid_syntax(self): name='cost', subject='Hi there!{{ }}', content='Welcome {{ name|titl }} to the site.', - html_content='{% block content %}

Welcome to the site

' + html_content='{% block content %}

Welcome to the site

', ) - EmailTemplateForm = modelform_factory(EmailTemplate, - exclude=['template']) + EmailTemplateForm = modelform_factory(EmailTemplate, exclude=['template']) form = EmailTemplateForm(data) self.assertFalse(form.is_valid()) - self.assertEqual(form.errors['default_template'], ['This field is required.']) + self.assertEqual(form.errors['default_template'], ['This field is required.']) self.assertEqual(form.errors['content'], ["Invalid filter: 'titl'"]) - self.assertIn(form.errors['html_content'], - [['Unclosed tags: endblock '], - ["Unclosed tag on line 1: 'block'. Looking for one of: endblock."]]) - self.assertIn(form.errors['subject'], - [['Empty variable tag'], ['Empty variable tag on line 1']]) + self.assertIn( + form.errors['html_content'], + [['Unclosed tags: endblock '], ["Unclosed tag on line 1: 'block'. Looking for one of: endblock."]], + ) + self.assertIn(form.errors['subject'], [['Empty variable tag'], ['Empty variable tag on line 1']]) def test_string_priority(self): """ @@ -249,10 +300,7 @@ def test_string_priority_exception(self): with self.assertRaises(ValueError) as context: invalid_priority_send() - self.assertEqual( - str(context.exception), - 'Invalid priority, must be one of: low, medium, high, now' - ) + self.assertEqual(str(context.exception), 'Invalid priority, must be one of: low, medium, high, now') def test_send_recipient_display_name(self): """ @@ -265,50 +313,35 @@ def test_send_recipient_display_name(self): def test_attachment_filename(self): attachment = Attachment() - attachment.file.save( - 'test.txt', - content=ContentFile('test file content'), - save=True - ) + attachment.file.save('test.txt', content=ContentFile('test file content'), save=True) self.assertEqual(attachment.name, 'test.txt') # Test that it is saved to the correct subdirectory date = timezone.now().date() - expected_path = os.path.join('post_office_attachments', str(date.year), - str(date.month), str(date.day)) + expected_path = os.path.join('post_office_attachments', str(date.year), str(date.month), str(date.day)) self.assertTrue(expected_path in attachment.file.name) def test_attachments_email_message(self): - email = Email.objects.create(to=['to@example.com'], - from_email='from@example.com', - subject='Subject') + email = Email.objects.create(to=['to@example.com'], from_email='from@example.com', subject='Subject') attachment = Attachment() - attachment.file.save( - 'test.txt', content=ContentFile('test file content'), save=True - ) + attachment.file.save('test.txt', content=ContentFile('test file content'), save=True) email.attachments.add(attachment) message = email.email_message() - self.assertEqual(message.attachments, - [('test.txt', 'test file content', 'text/plain')]) + self.assertEqual(message.attachments, [('test.txt', 'test file content', 'text/plain')]) def test_attachments_email_message_with_mimetype(self): - email = Email.objects.create(to=['to@example.com'], - from_email='from@example.com', - subject='Subject') + email = Email.objects.create(to=['to@example.com'], from_email='from@example.com', subject='Subject') attachment = Attachment() - attachment.file.save( - 'test.txt', content=ContentFile('test file content'), save=True - ) + attachment.file.save('test.txt', content=ContentFile('test file content'), save=True) attachment.mimetype = 'text/plain' attachment.save() email.attachments.add(attachment) message = email.email_message() - self.assertEqual(message.attachments, - [('test.txt', 'test file content', 'text/plain')]) + self.assertEqual(message.attachments, [('test.txt', 'test file content', 'text/plain')]) def test_translated_template_uses_default_templates_name(self): template = EmailTemplate.objects.create(name='name') @@ -316,10 +349,8 @@ def test_translated_template_uses_default_templates_name(self): self.assertEqual(id_template.name, template.name) def test_models_repr(self): - self.assertEqual(repr(EmailTemplate(name='test', language='en')), - '') - self.assertEqual(repr(Email(to=['test@example.com'])), - "") + self.assertEqual(repr(EmailTemplate(name='test', language='en')), '') + self.assertEqual(repr(Email(to=['test@example.com'])), "") def test_natural_key(self): template = EmailTemplate.objects.create(name='name') diff --git a/post_office/tests/test_utils.py b/post_office/tests/test_utils.py index 313a428a..38f589f4 100644 --- a/post_office/tests/test_utils.py +++ b/post_office/tests/test_utils.py @@ -5,26 +5,22 @@ from django.test.utils import override_settings from ..models import Email, STATUS, PRIORITY, EmailTemplate, Attachment -from ..utils import (create_attachments, get_email_template, parse_emails, - parse_priority, send_mail, split_emails) +from ..utils import create_attachments, get_email_template, parse_emails, parse_priority, send_mail, split_emails from ..validators import validate_email_with_name, validate_comma_separated_emails @override_settings(EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend') class UtilsTest(TestCase): - def test_mail_status(self): """ Check that send_mail assigns the right status field to Email instances """ - send_mail('subject', 'message', 'from@example.com', ['to@example.com'], - priority=PRIORITY.medium) + send_mail('subject', 'message', 'from@example.com', ['to@example.com'], priority=PRIORITY.medium) email = Email.objects.latest('id') self.assertEqual(email.status, STATUS.queued) # Emails sent with "now" priority is sent right away - send_mail('subject', 'message', 'from@example.com', ['to@example.com'], - priority=PRIORITY.now) + send_mail('subject', 'message', 'from@example.com', ['to@example.com'], priority=PRIORITY.now) email = Email.objects.latest('id') self.assertEqual(email.status, STATUS.sent) @@ -32,8 +28,13 @@ def test_email_validator(self): # These should validate validate_email_with_name('email@example.com') validate_email_with_name('Alice Bob ') - Email.objects.create(to=['to@example.com'], from_email='Alice ', - subject='Test', message='Message', status=STATUS.sent) + Email.objects.create( + to=['to@example.com'], + from_email='Alice ', + subject='Test', + message='Message', + status=STATUS.sent, + ) # Should also support international domains validate_email_with_name('Alice Bob ') @@ -46,17 +47,16 @@ def test_email_validator(self): def test_comma_separated_email_list_validator(self): # These should validate validate_comma_separated_emails(['email@example.com']) - validate_comma_separated_emails( - ['email@example.com', 'email2@example.com', 'email3@example.com'] - ) + validate_comma_separated_emails(['email@example.com', 'email2@example.com', 'email3@example.com']) validate_comma_separated_emails(['Alice Bob ']) # Should also support international domains validate_comma_separated_emails(['email@example.co.id']) # These should raise ValidationError - self.assertRaises(ValidationError, validate_comma_separated_emails, - ['email@example.com', 'invalid_mail', 'email@example.com']) + self.assertRaises( + ValidationError, validate_comma_separated_emails, ['email@example.com', 'invalid_mail', 'email@example.com'] + ) def test_get_template_email(self): # Sanity Check @@ -73,8 +73,7 @@ def test_get_template_email(self): self.assertEqual(template, get_email_template(name)) # Repeat with language support - template = EmailTemplate.objects.create(name=name, content='test', - language='en') + template = EmailTemplate.objects.create(name=name, content='test', language='en') # First query should hit database self.assertNumQueries(1, lambda: get_email_template(name, 'en')) # Second query should hit cache instead @@ -87,23 +86,18 @@ def test_template_caching_settings(self): """Check if POST_OFFICE_CACHE and POST_OFFICE_TEMPLATE_CACHE understood correctly """ + def is_cache_used(suffix='', desired_cache=False): - """Raise exception if real cache usage not equal to desired_cache value - """ + """Raise exception if real cache usage not equal to desired_cache value""" # to avoid cache cleaning - just create new template name = 'can_i/suport_cache_settings%s' % suffix - self.assertRaises( - EmailTemplate.DoesNotExist, get_email_template, name - ) + self.assertRaises(EmailTemplate.DoesNotExist, get_email_template, name) EmailTemplate.objects.create(name=name, content='test') # First query should hit database anyway self.assertNumQueries(1, lambda: get_email_template(name)) # Second query should hit cache instead only if we want it - self.assertNumQueries( - 0 if desired_cache else 1, - lambda: get_email_template(name) - ) + self.assertNumQueries(0 if desired_cache else 1, lambda: get_email_template(name)) return # default - use cache @@ -113,14 +107,9 @@ def is_cache_used(suffix='', desired_cache=False): with self.settings(POST_OFFICE_CACHE=False): is_cache_used(suffix='cache_disabled_global', desired_cache=False) with self.settings(POST_OFFICE_TEMPLATE_CACHE=False): - is_cache_used( - suffix='cache_disabled_for_templates', desired_cache=False - ) + is_cache_used(suffix='cache_disabled_for_templates', desired_cache=False) with self.settings(POST_OFFICE_CACHE=True, POST_OFFICE_TEMPLATE_CACHE=False): - is_cache_used( - suffix='cache_disabled_for_templates_but_enabled_global', - desired_cache=False - ) + is_cache_used(suffix='cache_disabled_for_templates_but_enabled_global', desired_cache=False) return def test_split_emails(self): @@ -134,48 +123,44 @@ def test_split_emails(self): self.assertEqual(expected_size, [len(emails) for emails in email_list]) def test_create_attachments(self): - attachments = create_attachments({ - 'attachment_file1.txt': ContentFile('content'), - 'attachment_file2.txt': ContentFile('content'), - }) + attachments = create_attachments( + { + 'attachment_file1.txt': ContentFile('content'), + 'attachment_file2.txt': ContentFile('content'), + } + ) self.assertEqual(len(attachments), 2) self.assertIsInstance(attachments[0], Attachment) self.assertTrue(attachments[0].pk) self.assertEqual(attachments[0].file.read(), b'content') self.assertTrue(attachments[0].name.startswith('attachment_file')) - self.assertEquals(attachments[0].mimetype, '') + self.assertEqual(attachments[0].mimetype, '') def test_create_attachments_with_mimetype(self): - attachments = create_attachments({ - 'attachment_file1.txt': { - 'file': ContentFile('content'), - 'mimetype': 'text/plain' - }, - 'attachment_file2.jpg': { - 'file': ContentFile('content'), - 'mimetype': 'text/plain' + attachments = create_attachments( + { + 'attachment_file1.txt': {'file': ContentFile('content'), 'mimetype': 'text/plain'}, + 'attachment_file2.jpg': {'file': ContentFile('content'), 'mimetype': 'text/plain'}, } - }) + ) self.assertEqual(len(attachments), 2) self.assertIsInstance(attachments[0], Attachment) self.assertTrue(attachments[0].pk) - self.assertEquals(attachments[0].file.read(), b'content') + self.assertEqual(attachments[0].file.read(), b'content') self.assertTrue(attachments[0].name.startswith('attachment_file')) - self.assertEquals(attachments[0].mimetype, 'text/plain') + self.assertEqual(attachments[0].mimetype, 'text/plain') def test_create_attachments_open_file(self): - attachments = create_attachments({ - 'attachment_file.py': __file__, - }) + attachments = create_attachments({'attachment_file.py': __file__}) self.assertEqual(len(attachments), 1) self.assertIsInstance(attachments[0], Attachment) self.assertTrue(attachments[0].pk) self.assertTrue(attachments[0].file.read()) - self.assertEquals(attachments[0].name, 'attachment_file.py') - self.assertEquals(attachments[0].mimetype, '') + self.assertEqual(attachments[0].name, 'attachment_file.py') + self.assertEqual(attachments[0].mimetype, '') def test_parse_priority(self): self.assertEqual(parse_priority('now'), PRIORITY.now) @@ -185,20 +170,11 @@ def test_parse_priority(self): def test_parse_emails(self): # Converts a single email to list of email - self.assertEqual( - parse_emails('test@example.com'), - ['test@example.com'] - ) + self.assertEqual(parse_emails('test@example.com'), ['test@example.com']) # None is converted into an empty list self.assertEqual(parse_emails(None), []) # Raises ValidationError if email is invalid - self.assertRaises( - ValidationError, - parse_emails, 'invalid_email' - ) - self.assertRaises( - ValidationError, - parse_emails, ['invalid_email', 'test@example.com'] - ) + self.assertRaises(ValidationError, parse_emails, 'invalid_email') + self.assertRaises(ValidationError, parse_emails, ['invalid_email', 'test@example.com']) diff --git a/post_office/utils.py b/post_office/utils.py index 86f58056..9c5321cb 100644 --- a/post_office/utils.py +++ b/post_office/utils.py @@ -6,11 +6,20 @@ from post_office import cache from .models import Email, PRIORITY, STATUS, EmailTemplate, Attachment from .settings import get_default_priority +from .signals import email_queued from .validators import validate_email_with_name -def send_mail(subject, message, from_email, recipient_list, html_message='', - scheduled_time=None, headers=None, priority=PRIORITY.medium): +def send_mail( + subject, + message, + from_email, + recipient_list, + html_message='', + scheduled_time=None, + headers=None, + priority=PRIORITY.medium, +): """ Add a new message to the mail queue. This is a replacement for Django's ``send_mail`` core email method. @@ -20,14 +29,23 @@ def send_mail(subject, message, from_email, recipient_list, html_message='', status = None if priority == PRIORITY.now else STATUS.queued emails = [ Email.objects.create( - from_email=from_email, to=address, subject=subject, - message=message, html_message=html_message, status=status, - headers=headers, priority=priority, scheduled_time=scheduled_time - ) for address in recipient_list + from_email=from_email, + to=address, + subject=subject, + message=message, + html_message=html_message, + status=status, + headers=headers, + priority=priority, + scheduled_time=scheduled_time, + ) + for address in recipient_list ] if priority == PRIORITY.now: for email in emails: email.dispatch() + else: + email_queued.send(sender=Email, emails=emails) return emails @@ -73,7 +91,6 @@ def create_attachments(attachment_files): """ attachments = [] for filename, filedata in attachment_files.items(): - if isinstance(filedata, dict): content = filedata.get('file', None) mimetype = filedata.get('mimetype', None) @@ -113,8 +130,7 @@ def parse_priority(priority): priority = getattr(PRIORITY, priority, None) if priority is None: - raise ValueError('Invalid priority, must be one of: %s' % - ', '.join(PRIORITY._fields)) + raise ValueError('Invalid priority, must be one of: %s' % ', '.join(PRIORITY._fields)) return priority @@ -146,23 +162,29 @@ def cleanup_expired_mails(cutoff_date, delete_attachments=True, batch_size=1000) Optionally also delete pending attachments. Return the number of deleted emails and attachments. """ - expired_emails_ids = Email.objects.filter(created__lt=cutoff_date).values_list('id', flat=True) - email_id_batches = split_emails(expired_emails_ids, batch_size) total_deleted_emails = 0 - - for email_ids in email_id_batches: - # Delete email and incr total_deleted_emails counter + + while True: + email_ids = Email.objects.filter(created__lt=cutoff_date).values_list('id', flat=True)[:batch_size] + if not email_ids: + break + _, deleted_data = Email.objects.filter(id__in=email_ids).delete() if deleted_data: total_deleted_emails += deleted_data['post_office.Email'] + attachments_count = 0 if delete_attachments: - attachments = Attachment.objects.filter(emails=None) - for attachment in attachments: - # Delete the actual file - attachment.file.delete() - attachments_count, _ = attachments.delete() - else: - attachments_count = 0 + while True: + attachments = Attachment.objects.filter(emails=None)[:batch_size] + if not attachments: + break + attachment_ids = set() + for attachment in attachments: + # Delete the actual file + attachment.file.delete() + attachment_ids.add(attachment.id) + deleted_count, _ = Attachment.objects.filter(id__in=attachment_ids).delete() + attachments_count += deleted_count return total_deleted_emails, attachments_count diff --git a/post_office/version.txt b/post_office/version.txt index 74f9ad7d..bce8d42b 100644 --- a/post_office/version.txt +++ b/post_office/version.txt @@ -1 +1 @@ -3, 6, 3 \ No newline at end of file +3, 9, 0 \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..fa8051a5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,10 @@ +[tool.ruff] +line-length = 120 +indent-width = 4 +target-version = "py39" + +[tool.ruff.format] +quote-style = "single" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" diff --git a/setup.py b/setup.py index 1866f19d..d697ca6e 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,6 @@ class Tox(TestCommand): - def initialize_options(self): TestCommand.initialize_options(self) self.tox_args = None @@ -20,6 +19,7 @@ def run_tests(self): # import here, cause outside the eggs aren't loaded import tox import shlex + args = self.tox_args if args: args = shlex.split(self.tox_args) @@ -27,7 +27,7 @@ def run_tests(self): sys.exit(errno) -with open(join(dirname(__file__), 'post_office/version.txt'), 'r') as fh: +with open(join(dirname(__file__), 'post_office/version.txt')) as fh: VERSION = '.'.join(map(str, literal_eval(fh.read()))) TESTS_REQUIRE = ['tox >= 2.3'] @@ -47,25 +47,27 @@ def run_tests(self): zip_safe=False, include_package_data=True, package_data={'': ['README.rst']}, + python_requires='>=3.9', install_requires=[ 'bleach[css]', - 'django>=2.2', - 'jsonfield>=3.0', - 'pytz', + 'django>=4.2', ], classifiers=[ 'Development Status :: 5 - Production/Stable', 'Environment :: Web Environment', 'Framework :: Django', + 'Framework :: Django :: 4.2', + 'Framework :: Django :: 5.0', 'Intended Audience :: Developers', 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', 'Programming Language :: Python', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3 :: Only', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', 'Topic :: Communications :: Email', 'Topic :: Internet :: WWW/HTTP', 'Topic :: Software Development :: Libraries :: Python Modules', @@ -75,5 +77,5 @@ def run_tests(self): 'test': TESTS_REQUIRE, 'prevent-XSS': ['bleach'], }, - cmdclass={'test': Tox} + cmdclass={'test': Tox}, )