├── post_office
├── management
│ ├── __init__.py
│ └── commands
│ │ ├── __init__.py
│ │ ├── send_queued_mail.py
│ │ └── cleanup_mail.py
├── migrations
│ ├── __init__.py
│ ├── 0006_attachment_mimetype.py
│ ├── 0005_auto_20170515_0013.py
│ ├── 0007_auto_20170731_1342.py
│ ├── 0008_attachment_headers.py
│ ├── 0012_alter_attachment_file_storage.py
│ ├── 0009_requeued_mode.py
│ ├── 0013_email_recipient_delivery_status_alter_log_status.py
│ ├── 0011_models_help_text.py
│ ├── 0010_message_id.py
│ ├── 0003_longer_subject.py
│ ├── 0001_initial.py
│ ├── 0004_auto_20160607_0901.py
│ └── 0002_add_i18n_and_backend_alias.py
├── templatetags
│ ├── __init__.py
│ └── post_office.py
├── template
│ ├── backends
│ │ ├── __init__.py
│ │ └── post_office.py
│ └── __init__.py
├── version.py
├── views.py
├── tests
│ ├── templates
│ │ ├── hello.html
│ │ └── image.html
│ ├── dummy.png
│ ├── static
│ │ └── dummy.png
│ ├── __init__.py
│ ├── test_connections.py
│ ├── test_views.py
│ ├── test_settings.py
│ ├── test_cache.py
│ ├── test_lockfile.py
│ ├── test_admin.py
│ ├── test_forms.py
│ ├── test_commands.py
│ ├── test_backends.py
│ ├── test_utils.py
│ └── test_html_email.py
├── locale
│ ├── de
│ │ └── LC_MESSAGES
│ │ │ ├── django.mo
│ │ │ └── django.po
│ ├── es
│ │ └── LC_MESSAGES
│ │ │ ├── django.mo
│ │ │ └── django.po
│ ├── it
│ │ └── LC_MESSAGES
│ │ │ ├── django.mo
│ │ │ └── django.po
│ ├── pl
│ │ └── LC_MESSAGES
│ │ │ ├── django.mo
│ │ │ └── django.po
│ ├── ru_RU
│ │ └── LC_MESSAGES
│ │ │ ├── django.mo
│ │ │ └── django.po
│ └── cs
│ │ └── LC_MESSAGES
│ │ └── django.po
├── __init__.py
├── test_urls.py
├── templates
│ └── admin
│ │ └── post_office
│ │ ├── email
│ │ └── change_form.html
│ │ └── emailtemplate
│ │ └── change_form.html
├── apps.py
├── signals.py
├── cache.py
├── logutils.py
├── connections.py
├── validators.py
├── tasks.py
├── fields.py
├── backends.py
├── test_settings.py
├── settings.py
├── lockfile.py
├── utils.py
├── sanitizer.py
└── admin.py
├── .github
├── dependabot.yml
└── workflows
│ ├── publish.yml
│ └── test.yml
├── MANIFEST.in
├── AUTHORS.rst
├── .gitignore
├── tox.ini
├── LICENSE.txt
├── .travis.yml
├── pyproject.toml
└── CHANGELOG.md
/post_office/management/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/post_office/migrations/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/post_office/templatetags/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/post_office/management/commands/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/post_office/template/backends/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/post_office/version.py:
--------------------------------------------------------------------------------
1 | VERSION = '3.10.1'
2 |
--------------------------------------------------------------------------------
/post_office/views.py:
--------------------------------------------------------------------------------
1 | # Create your views here.
2 |
--------------------------------------------------------------------------------
/post_office/tests/templates/hello.html:
--------------------------------------------------------------------------------
1 |
{{ foo }}
--------------------------------------------------------------------------------
/post_office/tests/dummy.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ui/django-post_office/HEAD/post_office/tests/dummy.png
--------------------------------------------------------------------------------
/post_office/tests/static/dummy.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ui/django-post_office/HEAD/post_office/tests/static/dummy.png
--------------------------------------------------------------------------------
/post_office/locale/de/LC_MESSAGES/django.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ui/django-post_office/HEAD/post_office/locale/de/LC_MESSAGES/django.mo
--------------------------------------------------------------------------------
/post_office/locale/es/LC_MESSAGES/django.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ui/django-post_office/HEAD/post_office/locale/es/LC_MESSAGES/django.mo
--------------------------------------------------------------------------------
/post_office/locale/it/LC_MESSAGES/django.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ui/django-post_office/HEAD/post_office/locale/it/LC_MESSAGES/django.mo
--------------------------------------------------------------------------------
/post_office/locale/pl/LC_MESSAGES/django.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ui/django-post_office/HEAD/post_office/locale/pl/LC_MESSAGES/django.mo
--------------------------------------------------------------------------------
/post_office/locale/ru_RU/LC_MESSAGES/django.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ui/django-post_office/HEAD/post_office/locale/ru_RU/LC_MESSAGES/django.mo
--------------------------------------------------------------------------------
/post_office/__init__.py:
--------------------------------------------------------------------------------
1 | from .backends import EmailBackend
2 | from .version import VERSION as VERSION_STRING
3 |
4 | VERSION = VERSION_STRING.split('.')
5 |
--------------------------------------------------------------------------------
/post_office/tests/templates/image.html:
--------------------------------------------------------------------------------
1 | {% load post_office %}
2 | Testing image attachments
3 |
4 |
--------------------------------------------------------------------------------
/post_office/test_urls.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 | from django.urls import re_path
3 |
4 | urlpatterns = [
5 | re_path(r'^admin/', admin.site.urls),
6 | ]
7 |
--------------------------------------------------------------------------------
/post_office/templates/admin/post_office/email/change_form.html:
--------------------------------------------------------------------------------
1 | {% extends "admin/change_form.html" %}{% load i18n %}
2 | {% block object-tools-items %}
3 | {{ block.super }}
4 | {% trans "Resend" %}
5 | {% endblock object-tools-items %}
6 |
--------------------------------------------------------------------------------
/post_office/tests/__init__.py:
--------------------------------------------------------------------------------
1 | from .test_backends import BackendTest
2 | from .test_commands import CommandTest
3 | from .test_lockfile import LockTest
4 | from .test_mail import MailTest
5 | from .test_models import ModelTest
6 | from .test_utils import UtilsTest
7 | from .test_cache import CacheTest
8 | from .test_views import AdminViewTest
9 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: "github-actions"
4 | directory: "/"
5 | schedule:
6 | interval: "weekly"
7 | commit-message:
8 | prefix: "chore(ci): "
9 | groups:
10 | github-actions:
11 | patterns:
12 | - "*"
13 | open-pull-requests-limit: 1
14 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include LICENSE.txt
2 | include README.rst
3 | include AUTHORS.rst
4 | recursive-include post_office *.py
5 | recursive-include post_office/locale *.po
6 | recursive-include post_office/locale *.mo
7 | recursive-include post_office/templates *.html
8 | recursive-exclude post_office/tests *.py
9 | recursive-exclude post_office/ test_*.py
10 |
--------------------------------------------------------------------------------
/AUTHORS.rst:
--------------------------------------------------------------------------------
1 | Author:
2 |
3 | * Selwin Ong (@selwin)
4 |
5 | Contributors:
6 |
7 | * Gilang Chandrasa (@gchandrasa)
8 | * Steven -only- (@SeiryuZ)
9 | * Wouter de Vries (@wadevries)
10 | * Yuri Prezument (@yprez)
11 | * Ștefan Daniel Mihăilă (@stefan-mihaila)
12 | * Wojciech Banaś (@fizista)
13 | * Maestro Health
14 | * Jacob Rief (@jacobrief)
15 | * Alexandr Artemyev (@mogost)
16 | * Antonio Hinojo (@ahmontero)
17 | * Filip Dobrovolny (@fdobrovolny)
--------------------------------------------------------------------------------
/post_office/migrations/0006_attachment_mimetype.py:
--------------------------------------------------------------------------------
1 | from django.db import models, migrations
2 |
3 |
4 | class Migration(migrations.Migration):
5 | dependencies = [
6 | ('post_office', '0005_auto_20170515_0013'),
7 | ]
8 |
9 | operations = [
10 | migrations.AddField(
11 | model_name='attachment',
12 | name='mimetype',
13 | field=models.CharField(default='', max_length=255, blank=True),
14 | ),
15 | ]
16 |
--------------------------------------------------------------------------------
/post_office/migrations/0005_auto_20170515_0013.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 1.11.1 on 2017-05-15 00:13
2 | from django.db import migrations
3 |
4 |
5 | class Migration(migrations.Migration):
6 | dependencies = [
7 | ('post_office', '0004_auto_20160607_0901'),
8 | ]
9 |
10 | operations = [
11 | migrations.AlterUniqueTogether(
12 | name='emailtemplate',
13 | unique_together={('name', 'language', 'default_template')},
14 | ),
15 | ]
16 |
--------------------------------------------------------------------------------
/post_office/migrations/0007_auto_20170731_1342.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 1.11.3 on 2017-07-31 11:42
2 | from django.db import migrations
3 |
4 |
5 | class Migration(migrations.Migration):
6 | dependencies = [
7 | ('post_office', '0006_attachment_mimetype'),
8 | ]
9 |
10 | operations = [
11 | migrations.AlterModelOptions(
12 | name='emailtemplate',
13 | options={'ordering': ['name'], 'verbose_name': 'Email Template', 'verbose_name_plural': 'Email Templates'},
14 | ),
15 | ]
16 |
--------------------------------------------------------------------------------
/post_office/migrations/0008_attachment_headers.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 1.11.16 on 2018-11-30 08:54
2 | from django.db import migrations, models
3 |
4 |
5 | class Migration(migrations.Migration):
6 | dependencies = [
7 | ('post_office', '0007_auto_20170731_1342'),
8 | ]
9 |
10 | operations = [
11 | migrations.AddField(
12 | model_name='attachment',
13 | name='headers',
14 | field=models.JSONField(blank=True, null=True, verbose_name='Headers'),
15 | ),
16 | ]
17 |
--------------------------------------------------------------------------------
/post_office/tests/test_connections.py:
--------------------------------------------------------------------------------
1 | from django.core.mail import backends
2 | from django.test import TestCase
3 |
4 | from .test_backends import ErrorRaisingBackend
5 | from ..connections import connections
6 |
7 |
8 | class ConnectionTest(TestCase):
9 | def test_get_connection(self):
10 | # Ensure ConnectionHandler returns the right connection
11 | self.assertTrue(isinstance(connections['error'], ErrorRaisingBackend))
12 | self.assertTrue(isinstance(connections['locmem'], backends.locmem.EmailBackend))
13 |
--------------------------------------------------------------------------------
/post_office/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 | from django.utils.translation import gettext_lazy as _
3 |
4 |
5 | class PostOfficeConfig(AppConfig):
6 | name = 'post_office'
7 | verbose_name = _('Post Office')
8 | default_auto_field = 'django.db.models.AutoField'
9 |
10 | def ready(self):
11 | from post_office import tasks
12 | from post_office.settings import get_celery_enabled
13 | from post_office.signals import email_queued
14 |
15 | if get_celery_enabled():
16 | email_queued.connect(tasks.queued_mail_handler)
17 |
--------------------------------------------------------------------------------
/post_office/signals.py:
--------------------------------------------------------------------------------
1 | from django.dispatch import Signal
2 |
3 | email_queued = Signal()
4 | """
5 | This signal is triggered whenever Post Office pushes one or more emails into its queue.
6 | The Emails objects added to the queue are passed as list to the callback handler.
7 | It can be connected to any handler function using this signature:
8 |
9 | Example:
10 | from django.dispatch import receiver
11 | from post_office.signal import email_queued
12 |
13 | @receiver(email_queued)
14 | def my_callback(sender, emails, **kwargs):
15 | print("Just added {} mails to the sending queue".format(len(emails)))
16 | """
17 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 |
5 | # C extensions
6 | *.so
7 |
8 | # Distribution / packaging
9 | build/
10 | develop-eggs/
11 | dist/
12 | eggs/
13 | sdist/
14 | tmp/
15 | *.egg-info/
16 | .installed.cfg
17 | *.egg
18 |
19 | # Installer logs
20 | pip-log.txt
21 | pip-delete-this-directory.txt
22 |
23 | # Unit test / coverage reports
24 | .tox/
25 | .tmp/
26 | ghostdriver.log
27 |
28 | # Editor configs
29 | .project
30 | .pydevproject
31 | .python-version
32 | .ruby-version
33 | .settings/
34 | .idea/
35 |
36 | ## generic files to ignore
37 | *~
38 | *.lock
39 | .mypy_cache
40 |
41 | post_office_attachments
42 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | envlist =
3 | py{39,310,311,312}-django42
4 | py{310,311,312}-django50
5 | py{310,311,312,313}-django51
6 | py{310,311,312,313}-django52
7 | py{310,311,312,313}-djangomain
8 |
9 | [testenv]
10 | setenv =
11 | PYTHONPATH={toxinidir}
12 | DJANGO_SETTINGS_MODULE=post_office.test_settings
13 |
14 | deps =
15 | django42: Django>=4.2,<4.3
16 | django50: Django>=5.0,<5.1
17 | django51: Django>=5.1,<5.2
18 | django52: Django>=5.2,<5.3
19 | djangomain: https://github.com/django/django/archive/main.tar.gz
20 |
21 | allowlist_externals = which
22 |
23 | commands =
24 | which python
25 | python -V
26 | python -c "import django; print('Django ' + django.__version__)"
27 | django-admin test post_office ./
28 |
--------------------------------------------------------------------------------
/post_office/migrations/0012_alter_attachment_file_storage.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 5.1 on 2025-05-27 23:00
2 |
3 | import post_office.models
4 | import post_office.settings
5 | from django.db import migrations, models
6 |
7 |
8 | class Migration(migrations.Migration):
9 | dependencies = [
10 | ('post_office', '0011_models_help_text'),
11 | ]
12 |
13 | operations = [
14 | migrations.AlterField(
15 | model_name='attachment',
16 | name='file',
17 | field=models.FileField(
18 | storage=post_office.settings.get_file_storage,
19 | upload_to=post_office.models.get_upload_path,
20 | verbose_name='File',
21 | ),
22 | ),
23 | ]
24 |
--------------------------------------------------------------------------------
/post_office/cache.py:
--------------------------------------------------------------------------------
1 | from django.template.defaultfilters import slugify
2 |
3 | from .settings import get_cache_backend
4 |
5 | # Stripped down version of caching functions from django-dbtemplates
6 | # https://github.com/jezdez/django-dbtemplates/blob/develop/dbtemplates/utils/cache.py
7 | cache_backend = get_cache_backend()
8 |
9 |
10 | def get_cache_key(name):
11 | """
12 | Prefixes and slugify the key name
13 | """
14 | return 'post_office:template:%s' % (slugify(name))
15 |
16 |
17 | def set(name, content):
18 | return cache_backend.set(get_cache_key(name), content)
19 |
20 |
21 | def get(name):
22 | return cache_backend.get(get_cache_key(name))
23 |
24 |
25 | def delete(name):
26 | return cache_backend.delete(get_cache_key(name))
27 |
--------------------------------------------------------------------------------
/post_office/template/__init__.py:
--------------------------------------------------------------------------------
1 | from django.template.loader import get_template, select_template
2 |
3 |
4 | def render_to_string(template_name, context=None, request=None, using=None):
5 | """
6 | Loads a template and renders it with a context. Returns a tuple containing the rendered template string
7 | and a list of attached images.
8 |
9 | template_name may be a string or a list of strings.
10 | """
11 | if isinstance(template_name, (list, tuple)):
12 | template = select_template(template_name, using=using)
13 | else:
14 | template = get_template(template_name, using=using)
15 | try:
16 | return template.render(context, request), template.template._attached_images
17 | except Exception:
18 | return template.render(context, request)
19 |
--------------------------------------------------------------------------------
/post_office/management/commands/send_queued_mail.py:
--------------------------------------------------------------------------------
1 | from django.core.management.base import BaseCommand
2 |
3 | from ...lockfile import default_lockfile
4 | from ...mail import send_queued_mail_until_done
5 |
6 |
7 | class Command(BaseCommand):
8 | def add_arguments(self, parser):
9 | parser.add_argument(
10 | '-p',
11 | '--processes',
12 | type=int,
13 | default=1,
14 | help='Number of processes used to send emails',
15 | )
16 | parser.add_argument(
17 | '-L',
18 | '--lockfile',
19 | default=default_lockfile,
20 | help='Absolute path of lockfile to acquire',
21 | )
22 | parser.add_argument(
23 | '-l',
24 | '--log-level',
25 | type=int,
26 | help='"0" to log nothing, "1" to only log errors',
27 | )
28 |
29 | def handle(self, *args, **options):
30 | send_queued_mail_until_done(options['lockfile'], options['processes'], options.get('log_level'))
31 |
--------------------------------------------------------------------------------
/post_office/logutils.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from logging.config import dictConfig
3 |
4 |
5 | # Taken from https://github.com/nvie/rq/blob/master/rq/logutils.py
6 | def setup_loghandlers(level=None):
7 | # Setup logging for post_office if not already configured
8 | logger = logging.getLogger('post_office')
9 | if not logger.handlers:
10 | dictConfig(
11 | {
12 | 'version': 1,
13 | 'disable_existing_loggers': False,
14 | 'formatters': {
15 | 'post_office': {
16 | 'format': '[%(levelname)s]%(asctime)s PID %(process)d: %(message)s',
17 | 'datefmt': '%Y-%m-%d %H:%M:%S',
18 | },
19 | },
20 | 'handlers': {
21 | 'post_office': {'level': 'DEBUG', 'class': 'logging.StreamHandler', 'formatter': 'post_office'},
22 | },
23 | 'loggers': {'post_office': {'handlers': ['post_office'], 'level': level or 'DEBUG'}},
24 | }
25 | )
26 | return logger
27 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | # name: Publish django-post_office
2 |
3 | # on:
4 | # push:
5 | # tags:
6 | # - '*'
7 |
8 | # jobs:
9 | # publish:
10 | # name: "Publish release"
11 | # runs-on: "ubuntu-latest"
12 |
13 | # environment:
14 | # name: deploy
15 |
16 | # strategy:
17 | # matrix:
18 | # python-version: ["3.9"]
19 |
20 | # steps:
21 | # - uses: actions/checkout@v4
22 | # - name: Set up Python ${{ matrix.python-version }}
23 | # uses: actions/setup-python@v5
24 | # with:
25 | # python-version: ${{ matrix.python-version }}
26 | # - name: Install dependencies
27 | # run: |
28 | # python -m pip install --upgrade pip
29 | # python -m pip install build --user
30 | # - name: Build 🐍 Python 📦 Package
31 | # run: python -m build --sdist --wheel --outdir dist/
32 | # - name: Publish 🐍 Python 📦 Package to PyPI
33 | # if: startsWith(github.ref, 'refs/tags')
34 | # uses: pypa/gh-action-pypi-publish@master
35 | # with:
36 | # password: ${{ secrets.PYPI_API_TOKEN_POST_OFFICE }}
37 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | Copyright (c) 2012 Selwin Ong
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining
4 | a copy of this software and associated documentation files (the
5 | "Software"), to deal in the Software without restriction, including
6 | without limitation the rights to use, copy, modify, merge, publish,
7 | distribute, sublicense, and/or sell copies of the Software, and to
8 | permit persons to whom the Software is furnished to do so, subject to
9 | the following conditions:
10 |
11 | The above copyright notice and this permission notice shall be
12 | included in all copies or substantial portions of the Software.
13 |
14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
/post_office/migrations/0009_requeued_mode.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 2.2.11 on 2020-05-10 08:59
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 | dependencies = [
8 | ('post_office', '0008_attachment_headers'),
9 | ]
10 |
11 | operations = [
12 | migrations.AddField(
13 | model_name='email',
14 | name='number_of_retries',
15 | field=models.PositiveIntegerField(blank=True, null=True),
16 | ),
17 | migrations.AlterField(
18 | model_name='email',
19 | name='status',
20 | field=models.PositiveSmallIntegerField(
21 | blank=True,
22 | choices=[(0, 'sent'), (1, 'failed'), (2, 'queued'), (3, 'requeued')],
23 | db_index=True,
24 | null=True,
25 | verbose_name='Status',
26 | ),
27 | ),
28 | migrations.AddField(
29 | model_name='email',
30 | name='expires_at',
31 | field=models.DateTimeField(blank=True, null=True, verbose_name='Expires timestamp for email'),
32 | ),
33 | ]
34 |
--------------------------------------------------------------------------------
/post_office/migrations/0013_email_recipient_delivery_status_alter_log_status.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 5.2.4 on 2025-07-15 04:42
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ('post_office', '0012_alter_attachment_file_storage'),
10 | ]
11 |
12 | operations = [
13 | migrations.AddField(
14 | model_name='email',
15 | name='recipient_delivery_status',
16 | field=models.PositiveSmallIntegerField(blank=True, choices=[(10, 'Accepted'), (20, 'Delivered'), (30, 'Opened'), (40, 'Clicked'), (50, 'Deferred'), (60, 'Soft Bounced'), (70, 'Hard Bounced'), (80, 'Spam Complaint'), (90, 'Unsubscribed')], null=True, verbose_name='Recipient Delivery Status'),
17 | ),
18 | migrations.AlterField(
19 | model_name='log',
20 | name='status',
21 | field=models.PositiveSmallIntegerField(choices=[(0, 'sent'), (1, 'failed'), (10, 'Accepted'), (20, 'Delivered'), (30, 'Opened'), (40, 'Clicked'), (50, 'Deferred'), (60, 'Soft Bounced'), (70, 'Hard Bounced'), (80, 'Spam Complaint'), (90, 'Unsubscribed')], verbose_name='Status'),
22 | ),
23 | ]
24 |
--------------------------------------------------------------------------------
/post_office/management/commands/cleanup_mail.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from django.core.management.base import BaseCommand
4 | from django.utils.timezone import now
5 |
6 | from ...utils import cleanup_expired_mails
7 |
8 |
9 | class Command(BaseCommand):
10 | help = 'Place deferred messages back in the queue.'
11 |
12 | def add_arguments(self, parser):
13 | parser.add_argument(
14 | '-d', '--days', type=int, default=90, help='Cleanup mails older than this many days, defaults to 90.'
15 | )
16 |
17 | parser.add_argument('-da', '--delete-attachments', action='store_true', help='Delete orphaned attachments.')
18 |
19 | parser.add_argument('-b', '--batch-size', type=int, default=1000, help='Batch size for cleanup.')
20 |
21 | def handle(self, verbosity, days, delete_attachments, batch_size, **options):
22 | # Delete mails and their related logs and queued created before X days
23 | cutoff_date = now() - datetime.timedelta(days)
24 | num_emails, num_attachments = cleanup_expired_mails(cutoff_date, delete_attachments, batch_size)
25 | msg = 'Deleted {0} mails created before {1} and {2} attachments.'
26 | self.stdout.write(msg.format(num_emails, cutoff_date, num_attachments))
27 |
--------------------------------------------------------------------------------
/post_office/migrations/0011_models_help_text.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.0.10 on 2020-11-02 22:48
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 | dependencies = [
8 | ('post_office', '0010_message_id'),
9 | ]
10 |
11 | operations = [
12 | migrations.AlterField(
13 | model_name='attachment',
14 | name='emails',
15 | field=models.ManyToManyField(related_name='attachments', to='post_office.Email', verbose_name='Emails'),
16 | ),
17 | migrations.AlterField(
18 | model_name='email',
19 | name='expires_at',
20 | field=models.DateTimeField(
21 | blank=True, help_text="Email won't be sent after this timestamp", null=True, verbose_name='Expires'
22 | ),
23 | ),
24 | migrations.AlterField(
25 | model_name='email',
26 | name='scheduled_time',
27 | field=models.DateTimeField(
28 | blank=True,
29 | db_index=True,
30 | help_text='The scheduled sending time',
31 | null=True,
32 | verbose_name='Scheduled Time',
33 | ),
34 | ),
35 | ]
36 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: python
2 | dist: bionic
3 |
4 | matrix:
5 | fast_finish: true
6 | include:
7 | # Python 3.7
8 | - python: 3.7
9 | env: TOXENV=py37-django22,py37-django31,py37-django32,py37-django40
10 |
11 | # Python 3.8
12 | - python: 3.8
13 | env: TOXENV=py38-django22,py38-django31,py38-django32,py38-django40
14 |
15 | # Python 3.9
16 | - python: 3.9
17 | env: TOXENV=py39-django22,py39-django31,py39-django32,py39-django40
18 |
19 | # Django Master
20 | - python: 3.7
21 | env: TOXENV=py37-djangomaster
22 | - python: 3.8
23 | env: TOXENV=py38-djangomaster
24 | - python: 3.9
25 | env: TOXENV=py39-djangomaster
26 |
27 | allow_failures:
28 | - python: 3.7
29 | env: TOXENV=py37-djangomaster
30 | - python: 3.8
31 | env: TOXENV=py38-djangomaster
32 | - python: 3.9
33 | env: TOXENV=py39-djangomaster
34 |
35 | # before_install:
36 | # Workaround for a permissions issue with Travis virtual machine images
37 | # that breaks Python's multiprocessing:
38 | # https://github.com/travis-ci/travis-cookbooks/issues/155
39 | # - sudo rm -rf /dev/shm
40 | # - sudo ln -s /run/shm /dev/shm
41 |
42 | install:
43 | - pip install tox-travis
44 |
45 | script:
46 | - tox
47 |
--------------------------------------------------------------------------------
/post_office/tests/test_views.py:
--------------------------------------------------------------------------------
1 | from django.contrib.auth.models import User
2 | from django.test.client import Client
3 | from django.test import TestCase
4 | from django.urls import reverse
5 |
6 | from post_office import mail
7 | from post_office.models import Email
8 |
9 |
10 | admin_username = 'real_test_admin'
11 | admin_email = 'read@admin.com'
12 | admin_pass = 'admin_pass'
13 |
14 |
15 | class AdminViewTest(TestCase):
16 | def setUp(self):
17 | user = User.objects.create_superuser(admin_username, admin_email, admin_pass)
18 | self.client = Client()
19 | self.client.login(username=user.username, password=admin_pass)
20 |
21 | # Small test to make sure the admin interface is loaded
22 | def test_admin_interface(self):
23 | response = self.client.get(reverse('admin:index'))
24 | self.assertEqual(response.status_code, 200)
25 |
26 | def test_admin_change_page(self):
27 | """Ensure that changing an email object in admin works."""
28 | mail.send(recipients=['test@example.com'], headers={'foo': 'bar'})
29 | email = Email.objects.latest('id')
30 | response = self.client.get(reverse('admin:post_office_email_change', args=[email.id]))
31 | self.assertEqual(response.status_code, 200)
32 |
--------------------------------------------------------------------------------
/post_office/connections.py:
--------------------------------------------------------------------------------
1 | from threading import local
2 |
3 | from django.core.mail import get_connection
4 |
5 | from .settings import get_backend
6 |
7 |
8 | # Copied from Django 1.8's django.core.cache.CacheHandler
9 | class ConnectionHandler:
10 | """
11 | A Cache Handler to manage access to Cache instances.
12 |
13 | Ensures only one instance of each alias exists per thread.
14 | """
15 |
16 | def __init__(self):
17 | self._connections = local()
18 |
19 | def __getitem__(self, alias):
20 | try:
21 | return self._connections.connections[alias]
22 | except AttributeError:
23 | self._connections.connections = {}
24 | except KeyError:
25 | pass
26 |
27 | try:
28 | backend = get_backend(alias)
29 | except KeyError:
30 | raise KeyError('%s is not a valid backend alias' % alias)
31 |
32 | connection = get_connection(backend)
33 | connection.open()
34 | self._connections.connections[alias] = connection
35 | return connection
36 |
37 | def all(self):
38 | return getattr(self._connections, 'connections', {}).values()
39 |
40 | def close(self):
41 | for connection in self.all():
42 | connection.close()
43 |
44 |
45 | connections = ConnectionHandler()
46 |
--------------------------------------------------------------------------------
/post_office/tests/test_settings.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase, override_settings
2 |
3 | from ..settings import get_file_storage
4 |
5 |
6 | class TestFileStorageSettings(TestCase):
7 | @override_settings(
8 | STORAGES={
9 | 'default': {
10 | 'BACKEND': 'django.core.files.storage.FileSystemStorage',
11 | },
12 | 'staticfiles': {
13 | 'BACKEND': 'django.contrib.staticfiles.storage.StaticFilesStorage',
14 | },
15 | },
16 | POST_OFFICE={},
17 | )
18 | def test_default_file_storage(self):
19 | self.assertEqual(get_file_storage().__class__.__name__, 'FileSystemStorage')
20 |
21 | @override_settings(
22 | STORAGES={
23 | 'default': {
24 | 'BACKEND': 'django.core.files.storage.FileSystemStorage',
25 | },
26 | 'staticfiles': {
27 | 'BACKEND': 'django.contrib.staticfiles.storage.StaticFilesStorage',
28 | },
29 | 'post_office': {
30 | 'BACKEND': 'django.core.files.storage.InMemoryStorage',
31 | },
32 | },
33 | POST_OFFICE={
34 | 'FILE_STORAGE': 'post_office',
35 | },
36 | )
37 | def test_configured_file_storage(self):
38 | self.assertEqual(get_file_storage().__class__.__name__, 'InMemoryStorage')
39 |
--------------------------------------------------------------------------------
/post_office/templatetags/post_office.py:
--------------------------------------------------------------------------------
1 | from email.mime.image import MIMEImage
2 | import hashlib
3 | import os
4 |
5 | from django import template
6 | from django.conf import settings
7 | from django.contrib.staticfiles import finders
8 | from django.core.files import File
9 | from django.core.files.images import ImageFile
10 |
11 | register = template.Library()
12 |
13 |
14 | @register.simple_tag(takes_context=True)
15 | def inline_image(context, file):
16 | assert hasattr(context.template, '_attached_images'), (
17 | "You must use template engine 'post_office' when rendering images using templatetag 'inline_image'."
18 | )
19 | if isinstance(file, ImageFile):
20 | fileobj = file
21 | elif os.path.isabs(file) and os.path.exists(file):
22 | fileobj = File(open(file, 'rb'), name=file)
23 | else:
24 | try:
25 | absfilename = finders.find(file)
26 | if absfilename is None:
27 | raise FileNotFoundError(f'No such file: {file}')
28 | except Exception:
29 | if settings.DEBUG:
30 | raise
31 | return ''
32 | fileobj = File(open(absfilename, 'rb'), name=file)
33 | raw_data = fileobj.read()
34 | image = MIMEImage(raw_data)
35 | md5sum = hashlib.md5(raw_data).hexdigest()
36 | image.add_header('Content-Disposition', 'inline', filename=md5sum)
37 | image.add_header('Content-ID', f'<{md5sum}>')
38 | context.template._attached_images.append(image)
39 | return f'cid:{md5sum}'
40 |
--------------------------------------------------------------------------------
/post_office/validators.py:
--------------------------------------------------------------------------------
1 | from django.core.exceptions import ValidationError
2 | from django.core.validators import validate_email
3 | from django.template import Template, TemplateSyntaxError, TemplateDoesNotExist
4 | from django.utils.encoding import force_str
5 |
6 |
7 | def validate_email_with_name(value):
8 | """
9 | Validate email address.
10 |
11 | Both "Recipient Name " and "email@example.com" are valid.
12 | """
13 | value = force_str(value)
14 |
15 | recipient = value
16 | if '<' in value and '>' in value:
17 | start = value.find('<') + 1
18 | end = value.find('>')
19 | if start < end:
20 | recipient = value[start:end]
21 |
22 | validate_email(recipient)
23 |
24 |
25 | def validate_comma_separated_emails(value):
26 | """
27 | Validate every email address in a comma separated list of emails.
28 | """
29 | if not isinstance(value, (tuple, list)):
30 | raise ValidationError('Email list must be a list/tuple.')
31 |
32 | for email in value:
33 | try:
34 | validate_email_with_name(email)
35 | except ValidationError:
36 | raise ValidationError('Invalid email: %s' % email, code='invalid')
37 |
38 |
39 | def validate_template_syntax(source):
40 | """
41 | Basic Django Template syntax validation. This allows for robuster template
42 | authoring.
43 | """
44 | try:
45 | Template(source)
46 | except (TemplateSyntaxError, TemplateDoesNotExist) as err:
47 | raise ValidationError(str(err))
48 |
--------------------------------------------------------------------------------
/post_office/tests/test_cache.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 | from django.test import TestCase
3 |
4 | from post_office import cache
5 | from ..settings import get_cache_backend
6 |
7 |
8 | class CacheTest(TestCase):
9 | def test_get_backend_settings(self):
10 | """Test basic get backend function and its settings"""
11 | # Sanity check
12 | self.assertTrue('post_office' in settings.CACHES)
13 | self.assertTrue(get_cache_backend())
14 |
15 | # If no post office key is defined, it should return default
16 | del settings.CACHES['post_office']
17 | self.assertTrue(get_cache_backend())
18 |
19 | # If no caches key in settings, it should return None
20 | delattr(settings, 'CACHES')
21 | self.assertEqual(None, get_cache_backend())
22 |
23 | def test_get_cache_key(self):
24 | """
25 | Test for converting names to cache key
26 | """
27 | self.assertEqual('post_office:template:test', cache.get_cache_key('test'))
28 | self.assertEqual('post_office:template:test-slugify', cache.get_cache_key('test slugify'))
29 |
30 | def test_basic_cache_operations(self):
31 | """
32 | Test basic cache operations
33 | """
34 | # clean test cache
35 | cache.cache_backend.clear()
36 | self.assertEqual(None, cache.get('test-cache'))
37 | cache.set('test-cache', 'awesome content')
38 | self.assertTrue('awesome content', cache.get('test-cache'))
39 | cache.delete('test-cache')
40 | self.assertEqual(None, cache.get('test-cache'))
41 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Test
2 |
3 | on:
4 | push:
5 | pull_request:
6 |
7 | permissions:
8 | contents: read
9 |
10 | jobs:
11 | ruff-format:
12 | runs-on: ubuntu-latest
13 | timeout-minutes: 1
14 | steps:
15 | - uses: actions/checkout@v6
16 | - uses: astral-sh/ruff-action@v3
17 | with:
18 | version: 0.11.10
19 | args: "format --check"
20 |
21 | build:
22 | runs-on: ubuntu-latest
23 | name: Python${{ matrix.python-version }}/Django${{ matrix.django-version }}
24 | strategy:
25 | matrix:
26 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
27 | django-version: ["4.2", "5.0", "5.1", "5.2"]
28 | exclude:
29 | - python-version: "3.9"
30 | django-version: "5.0"
31 | - python-version: "3.9"
32 | django-version: "5.1"
33 | - python-version: "3.9"
34 | django-version: "5.2"
35 | - python-version: "3.13"
36 | django-version: "4.2"
37 | - python-version: "3.13"
38 | django-version: "5.0"
39 |
40 | steps:
41 | - uses: actions/checkout@v6
42 |
43 | - name: Set up Python ${{ matrix.python-version }}
44 | uses: actions/setup-python@v6
45 | with:
46 | python-version: ${{ matrix.python-version }}
47 |
48 | - name: Install dependencies
49 | run: |
50 | python -m pip install --upgrade pip
51 | pip install "Django~=${{ matrix.django-version }}.0"
52 |
53 | - name: Run Test
54 | run: |
55 | `which django-admin` test post_office --settings=post_office.test_settings --pythonpath=.
56 |
--------------------------------------------------------------------------------
/post_office/migrations/0010_message_id.py:
--------------------------------------------------------------------------------
1 | import random
2 | from django.db import migrations, models
3 |
4 | from post_office.models import STATUS
5 | from post_office.settings import get_message_id_enabled, get_message_id_fqdn
6 |
7 |
8 | def forwards(apps, schema_editor):
9 | if not get_message_id_enabled():
10 | return
11 | msg_id_fqdn = get_message_id_fqdn()
12 | Email = apps.get_model('post_office', 'Email')
13 | for email in Email.objects.using(schema_editor.connection.alias).filter(message_id__isnull=True):
14 | if email.status in [STATUS.queued, STATUS.requeued]:
15 | # create a unique Message-ID for all emails which have not been send yet
16 | randint1, randint2 = random.getrandbits(64), random.getrandbits(16)
17 | email.message_id = f'<{email.id}.{randint1}.{randint2}@{msg_id_fqdn}>'
18 | email.save()
19 |
20 |
21 | class Migration(migrations.Migration):
22 | dependencies = [
23 | ('post_office', '0009_requeued_mode'),
24 | ]
25 |
26 | operations = [
27 | migrations.AddField(
28 | model_name='email',
29 | name='message_id',
30 | field=models.CharField(editable=False, max_length=255, null=True, verbose_name='Message-ID'),
31 | ),
32 | migrations.AlterField(
33 | model_name='email',
34 | name='expires_at',
35 | field=models.DateTimeField(
36 | blank=True, help_text="Email won't be sent after this timestamp", null=True, verbose_name='Expires at'
37 | ),
38 | ),
39 | migrations.RunPython(forwards, reverse_code=migrations.RunPython.noop),
40 | ]
41 |
--------------------------------------------------------------------------------
/post_office/tasks.py:
--------------------------------------------------------------------------------
1 | """
2 | Only define the tasks and handler if we can import celery.
3 | This allows the module to be imported in environments without Celery, for
4 | example by other task queue systems such as Huey, which use the same pattern
5 | of auto-discovering tasks in "tasks" submodules.
6 | """
7 |
8 | import datetime
9 |
10 | from django.utils.timezone import now
11 |
12 | from post_office.mail import send_queued_mail_until_done
13 | from post_office.utils import cleanup_expired_mails
14 |
15 | from .settings import get_celery_enabled
16 |
17 | try:
18 | if get_celery_enabled():
19 | from celery import shared_task
20 | else:
21 | raise NotImplementedError()
22 | except (ImportError, NotImplementedError):
23 |
24 | def queued_mail_handler(sender, **kwargs):
25 | """
26 | To be called by :func:`post_office.signals.email_queued.send()` for triggering asynchronous
27 | mail delivery – if provided by an external queue, such as Celery.
28 | """
29 | else:
30 |
31 | @shared_task(ignore_result=True)
32 | def send_queued_mail(*args, **kwargs):
33 | """
34 | To be called by the Celery task manager.
35 | """
36 | send_queued_mail_until_done()
37 |
38 | def queued_mail_handler(sender, **kwargs):
39 | """
40 | Trigger an asynchronous mail delivery.
41 | """
42 | send_queued_mail.delay()
43 |
44 | @shared_task(ignore_result=True)
45 | def cleanup_mail(*args, **kwargs):
46 | days = kwargs.get('days', 90)
47 | cutoff_date = now() - datetime.timedelta(days)
48 | delete_attachments = kwargs.get('delete_attachments', True)
49 | cleanup_expired_mails(cutoff_date, delete_attachments)
50 |
--------------------------------------------------------------------------------
/post_office/templates/admin/post_office/emailtemplate/change_form.html:
--------------------------------------------------------------------------------
1 | {% extends "admin/change_form.html" %}
2 |
3 | {% block extrahead %}
4 | {{ block.super }}
5 |
7 |
9 | {% endblock %}
10 |
11 | {% block admin_change_form_document_ready %}
12 | {{ block.super }}
13 |
14 |
16 |
18 |
20 |
21 |
36 | {% endblock %}
37 |
38 |
--------------------------------------------------------------------------------
/post_office/fields.py:
--------------------------------------------------------------------------------
1 | from django.db.models import TextField
2 | from django.utils.translation import gettext_lazy as _
3 |
4 | from .validators import validate_comma_separated_emails
5 |
6 |
7 | class CommaSeparatedEmailField(TextField):
8 | default_validators = [validate_comma_separated_emails]
9 | description = _('Comma-separated emails')
10 |
11 | def __init__(self, *args, **kwargs):
12 | kwargs['blank'] = True
13 | super().__init__(*args, **kwargs)
14 |
15 | def formfield(self, **kwargs):
16 | defaults = {
17 | 'error_messages': {
18 | 'invalid': _('Only comma separated emails are allowed.'),
19 | }
20 | }
21 | defaults.update(kwargs)
22 | return super().formfield(**defaults)
23 |
24 | def from_db_value(self, value, expression, connection):
25 | return self.to_python(value)
26 |
27 | def get_prep_value(self, value):
28 | """
29 | We need to accomodate queries where a single email,
30 | or list of email addresses is supplied as arguments. For example:
31 |
32 | - Email.objects.filter(to='mail@example.com')
33 | - Email.objects.filter(to=['one@example.com', 'two@example.com'])
34 | """
35 | if isinstance(value, str):
36 | return value
37 | else:
38 | return ', '.join(map(lambda s: s.strip(), value))
39 |
40 | def to_python(self, value):
41 | if isinstance(value, str):
42 | if value == '':
43 | return []
44 | else:
45 | return [s.strip() for s in value.split(',')]
46 | else:
47 | return value
48 |
49 | def south_field_triple(self):
50 | """
51 | Return a suitable description of this field for South.
52 | Taken from smiley chris' easy_thumbnails
53 | """
54 | from south.modelsinspector import introspector
55 |
56 | field_class = 'django.db.models.fields.TextField'
57 | args, kwargs = introspector(self)
58 | return (field_class, args, kwargs)
59 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["setuptools>=61.2"]
3 | build-backend = "setuptools.build_meta"
4 |
5 | [project]
6 | name = "django-post_office"
7 | license = "MIT"
8 | requires-python = ">=3.9"
9 | authors = [{name = "Selwin Ong", email = "selwin.ong@gmail.com"}]
10 | description = "A Django app to monitor and send mail asynchronously, complete with template support."
11 | urls = {Homepage = "https://github.com/ui/django-post_office"}
12 | classifiers = [
13 | "Development Status :: 5 - Production/Stable",
14 | "Environment :: Web Environment",
15 | "Framework :: Django",
16 | "Framework :: Django :: 4.2",
17 | "Framework :: Django :: 5.0",
18 | "Framework :: Django :: 5.1",
19 | "Framework :: Django :: 5.2",
20 | "Intended Audience :: Developers",
21 | "Operating System :: OS Independent",
22 | "Programming Language :: Python",
23 | "Programming Language :: Python :: 3",
24 | "Programming Language :: Python :: 3 :: Only",
25 | "Programming Language :: Python :: 3.9",
26 | "Programming Language :: Python :: 3.10",
27 | "Programming Language :: Python :: 3.11",
28 | "Programming Language :: Python :: 3.12",
29 | "Programming Language :: Python :: 3.13",
30 | "Topic :: Communications :: Email",
31 | "Topic :: Internet :: WWW/HTTP",
32 | "Topic :: Software Development :: Libraries :: Python Modules",
33 | ]
34 | dependencies = [
35 | "bleach[css]",
36 | "django>=4.2",
37 | ]
38 | dynamic = ["version"]
39 |
40 | [project.readme]
41 | file = "README.md"
42 | content-type = "text/markdown"
43 |
44 | [project.optional-dependencies]
45 | test = [
46 | "tox >= 2.3",
47 | ]
48 | prevent-xss = [
49 | "bleach",
50 | ]
51 |
52 | [tool.setuptools]
53 | zip-safe = false
54 |
55 | [tool.setuptools.dynamic]
56 | version = {attr = "post_office.version.VERSION"}
57 |
58 | [tool.ruff]
59 | line-length = 120
60 | indent-width = 4
61 | target-version = "py39"
62 | exclude = [
63 | "migrations",
64 | ]
65 |
66 | [tool.ruff.format]
67 | quote-style = "single"
68 | indent-style = "space"
69 | skip-magic-trailing-comma = false
70 | line-ending = "auto"
71 |
--------------------------------------------------------------------------------
/post_office/tests/test_lockfile.py:
--------------------------------------------------------------------------------
1 | import time
2 | import os
3 |
4 | from django.test import TestCase
5 |
6 | from ..lockfile import FileLock, FileLocked
7 |
8 |
9 | def setup_fake_lock(lock_file_name):
10 | pid = os.getpid()
11 | lockfile = '%s.lock' % pid
12 | try:
13 | os.remove(lock_file_name)
14 | except OSError:
15 | pass
16 | os.symlink(lockfile, lock_file_name)
17 |
18 |
19 | class LockTest(TestCase):
20 | def test_process_killed_force_unlock(self):
21 | pid = os.getpid()
22 | lockfile = '%s.lock' % pid
23 | setup_fake_lock('test.lock')
24 |
25 | with open(lockfile, 'w+') as f:
26 | f.write('9999999')
27 | assert os.path.exists(lockfile)
28 | with FileLock('test'):
29 | assert True
30 |
31 | def test_force_unlock_in_same_process(self):
32 | pid = os.getpid()
33 | lockfile = '%s.lock' % pid
34 | os.symlink(lockfile, 'test.lock')
35 |
36 | with open(lockfile, 'w+') as f:
37 | f.write(str(os.getpid()))
38 |
39 | with FileLock('test', force=True):
40 | assert True
41 |
42 | def test_exception_after_timeout(self):
43 | pid = os.getpid()
44 | lockfile = '%s.lock' % pid
45 | setup_fake_lock('test.lock')
46 |
47 | with open(lockfile, 'w+') as f:
48 | f.write(str(os.getpid()))
49 |
50 | try:
51 | with FileLock('test', timeout=1):
52 | assert False
53 | except FileLocked:
54 | assert True
55 |
56 | def test_force_after_timeout(self):
57 | pid = os.getpid()
58 | lockfile = '%s.lock' % pid
59 | setup_fake_lock('test.lock')
60 |
61 | with open(lockfile, 'w+') as f:
62 | f.write(str(os.getpid()))
63 |
64 | timeout = 1
65 | start = time.time()
66 | with FileLock('test', timeout=timeout, force=True):
67 | assert True
68 | end = time.time()
69 | assert end - start > timeout
70 |
71 | def test_get_lock_pid(self):
72 | """Ensure get_lock_pid() works properly"""
73 | with FileLock('test', timeout=1, force=True) as lock:
74 | self.assertEqual(lock.get_lock_pid(), int(os.getpid()))
75 |
--------------------------------------------------------------------------------
/post_office/template/backends/post_office.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 | from django.core.mail import EmailMultiAlternatives
3 | from django.template import TemplateDoesNotExist
4 | from django.template.backends.base import BaseEngine
5 | from django.template.backends.django import Template as DjangoTemplate, reraise, get_installed_libraries
6 | from django.template.engine import Engine
7 |
8 |
9 | class Template(DjangoTemplate):
10 | def __init__(self, template, backend):
11 | template._attached_images = []
12 | super().__init__(template, backend)
13 |
14 | def attach_related(self, email_message):
15 | assert isinstance(email_message, EmailMultiAlternatives), 'Parameter must be of type EmailMultiAlternatives'
16 | email_message.mixed_subtype = 'related'
17 | for attachment in self.template._attached_images:
18 | email_message.attach(attachment)
19 |
20 |
21 | class PostOfficeTemplates(BaseEngine):
22 | """
23 | Customized Template Engine which keeps track on referenced images and stores them as attachments
24 | to be used in multipart email messages.
25 | """
26 |
27 | app_dirname = 'templates'
28 |
29 | def __init__(self, params):
30 | params = params.copy()
31 | options = params.pop('OPTIONS').copy()
32 | options.setdefault('autoescape', True)
33 | options.setdefault('debug', settings.DEBUG)
34 | options.setdefault(
35 | 'file_charset',
36 | settings.FILE_CHARSET if settings.is_overridden('FILE_CHARSET') else 'utf-8',
37 | )
38 | libraries = options.get('libraries', {})
39 | options['libraries'] = self.get_templatetag_libraries(libraries)
40 | super().__init__(params)
41 | self.engine = Engine(self.dirs, self.app_dirs, **options)
42 |
43 | def from_string(self, template_code):
44 | return Template(self.engine.from_string(template_code), self)
45 |
46 | def get_template(self, template_name):
47 | try:
48 | template = self.engine.get_template(template_name)
49 | return Template(template, self)
50 | except TemplateDoesNotExist as exc:
51 | reraise(exc, self)
52 |
53 | def get_templatetag_libraries(self, custom_libraries):
54 | libraries = get_installed_libraries()
55 | libraries.update(custom_libraries)
56 | return libraries
57 |
--------------------------------------------------------------------------------
/post_office/tests/test_admin.py:
--------------------------------------------------------------------------------
1 | import json
2 | import os
3 |
4 | from datetime import datetime, timedelta
5 |
6 | from django.conf import settings as django_settings, settings
7 | from django.contrib.admin.sites import AdminSite
8 | from django.core import mail
9 | from django.core import serializers
10 | from django.core.files.base import ContentFile
11 | from django.core.mail import EmailMessage, EmailMultiAlternatives
12 | from django.forms.models import modelform_factory
13 | from django.test import TestCase
14 | from django.utils import timezone
15 |
16 | from ..admin import AttachmentInline
17 | from ..models import Email, Log, PRIORITY, STATUS, EmailTemplate, Attachment
18 | from ..mail import send
19 |
20 |
21 | class MockRequest:
22 | pass
23 |
24 |
25 | class MockSuperUser:
26 | def has_perm(self, perm, obj=None):
27 | return True
28 |
29 |
30 | class EmailAdminTest(TestCase):
31 | def setUp(self):
32 | self.site = AdminSite()
33 |
34 | self.request = MockRequest()
35 | self.request.user = MockSuperUser()
36 |
37 | def test_attachmentinline_contentdisposition_header(self):
38 | email = Email.objects.create(to=['to@example.com'], from_email='from@example.com', subject='Subject')
39 |
40 | mt = 'text/plain'
41 | attachment_1 = Attachment(mimetype=mt, headers={"Doesn't Have Content-Disposition Header": 'Nope'})
42 | attachment_1.file.save('test_attachment_1.txt', content=ContentFile('test file content 1'), save=True)
43 | email.attachments.add(attachment_1)
44 |
45 | attachment_2 = Attachment(
46 | mimetype=mt, headers={'Content-Disposition': "Content-Disposition header doesn't start with 'inline'"}
47 | )
48 | attachment_2.file.save('test_attachment_2.txt', content=ContentFile('test file content 2'), save=True)
49 | email.attachments.add(attachment_2)
50 |
51 | attachment_3 = Attachment(mimetype=mt, headers={'Content-Disposition': 'inline something'})
52 | attachment_3.file.save('test_attachment_3.txt', content=ContentFile('test file content 3'), save=True)
53 | email.attachments.add(attachment_3)
54 |
55 | email.email_message()
56 |
57 | attachment_inline = AttachmentInline(Email, self.site)
58 | attachment_inline.parent_obj = email
59 |
60 | qs_result = attachment_inline.get_queryset(self.request)
61 | with self.assertNumQueries(1):
62 | non_inline_attachments = [a_through.attachment for a_through in qs_result]
63 |
64 | self.assertIn(attachment_1, non_inline_attachments)
65 | self.assertIn(attachment_2, non_inline_attachments)
66 | self.assertNotIn(attachment_3, non_inline_attachments)
67 | self.assertEqual(len(non_inline_attachments), 2)
68 |
--------------------------------------------------------------------------------
/post_office/tests/test_forms.py:
--------------------------------------------------------------------------------
1 | from django.forms import formset_factory
2 | from django.test import TestCase, Client
3 | from django.contrib.auth import get_user_model
4 | from django.urls import reverse
5 |
6 | from post_office.admin import EmailTemplateAdminForm
7 |
8 |
9 | User = get_user_model()
10 |
11 |
12 | class EmailTemplateFormTest(TestCase):
13 | def setUp(self) -> None:
14 | self.form_set = formset_factory(EmailTemplateAdminForm, extra=2)
15 | self.client = Client()
16 | self.user = User.objects.create_superuser(username='testuser', password='abc123456', email='testemail@test.com')
17 | self.client.force_login(self.user)
18 |
19 | def test_can_create_a_email_template_with_the_same_attributes(self):
20 | email_template = {
21 | 'form-TOTAL_FORMS': '3',
22 | 'form-INITIAL_FORMS': '0',
23 | 'form-MAX_NUM_FORMS': '',
24 | 'name': 'Test',
25 | 'email_photos-TOTAL_FORMS': '1',
26 | 'email_photos-INITIAL_FORMS': '0',
27 | 'email_photos-MIN_NUM_FORMS': '0',
28 | 'email_photos-MAX_NUM_FORMS': '1',
29 | 'email_photos-0-id': '',
30 | 'email_photos-0-email_template': '',
31 | 'email_photos-0-photo': '',
32 | 'email_photos-__prefix__-id': '',
33 | 'email_photos-__prefix__-email_template': '',
34 | 'email_photos-__prefix__-photo': '',
35 | 'translated_templates-TOTAL_FORMS': '2',
36 | 'translated_templates-INITIAL_FORMS': '0',
37 | 'translated_templates-MIN_NUM_FORMS': '0',
38 | 'translated_templates-MAX_NUM_FORMS': '2',
39 | 'translated_templates-0-language': 'es',
40 | 'translated_templates-0-subject': '',
41 | 'translated_templates-0-content': '',
42 | 'translated_templates-0-html_content': '',
43 | 'translated_templates-0-id': '',
44 | 'translated_templates-0-default_template': '',
45 | 'translated_templates-1-language': 'es',
46 | 'translated_templates-1-subject': '',
47 | 'translated_templates-1-content': '',
48 | 'translated_templates-1-html_content': '',
49 | 'translated_templates-1-id': '',
50 | 'translated_templates-1-default_template': '',
51 | 'translated_templates-__prefix__-language': 'es',
52 | 'translated_templates-__prefix__-subject': '',
53 | 'translated_templates-__prefix__-content': '',
54 | 'translated_templates-__prefix__-html_content': '',
55 | 'translated_templates-__prefix__-id': '',
56 | 'translated_templates-__prefix__-default_template': '',
57 | '_save': 'Save',
58 | }
59 |
60 | add_template_url = reverse('admin:post_office_emailtemplate_add')
61 |
62 | response = self.client.post(add_template_url, email_template, follow=True)
63 | self.assertContains(response, 'Duplicate template for language 'Spanish'.', html=True)
64 |
--------------------------------------------------------------------------------
/post_office/backends.py:
--------------------------------------------------------------------------------
1 | from collections import OrderedDict
2 | from email.mime.base import MIMEBase
3 | from django.core.files.base import ContentFile
4 | from django.core.mail.backends.base import BaseEmailBackend
5 | from .settings import get_default_priority
6 |
7 |
8 | class EmailBackend(BaseEmailBackend):
9 | def open(self):
10 | pass
11 |
12 | def close(self):
13 | pass
14 |
15 | def send_messages(self, email_messages):
16 | """
17 | Queue one or more EmailMessage objects and returns the number of
18 | email messages sent.
19 | """
20 | from .mail import create
21 | from .models import STATUS, Email
22 | from .utils import create_attachments
23 | from .signals import email_queued
24 |
25 | if not email_messages:
26 | return
27 |
28 | default_priority = get_default_priority()
29 | num_sent = 0
30 | emails = []
31 | for email_message in email_messages:
32 | subject = email_message.subject
33 | from_email = email_message.from_email
34 | headers = email_message.extra_headers
35 | if email_message.reply_to:
36 | reply_to_header = ', '.join(str(v) for v in email_message.reply_to)
37 | headers.setdefault('Reply-To', reply_to_header)
38 | message = email_message.body # The plaintext message is called body
39 | html_body = '' # The default if no html body can be found
40 | if hasattr(email_message, 'alternatives') and len(email_message.alternatives) > 0:
41 | for alternative in email_message.alternatives:
42 | if alternative[1] == 'text/html':
43 | html_body = alternative[0]
44 |
45 | attachment_files = {}
46 | for attachment in email_message.attachments:
47 | if isinstance(attachment, MIMEBase):
48 | attachment_files[attachment.get_filename()] = {
49 | 'file': ContentFile(attachment.get_payload()),
50 | 'mimetype': attachment.get_content_type(),
51 | 'headers': OrderedDict(attachment.items()),
52 | }
53 | else:
54 | attachment_files[attachment[0]] = ContentFile(attachment[1])
55 |
56 | email = create(
57 | sender=from_email,
58 | recipients=email_message.to,
59 | cc=email_message.cc,
60 | bcc=email_message.bcc,
61 | subject=subject,
62 | message=message,
63 | html_message=html_body,
64 | headers=headers,
65 | )
66 |
67 | if attachment_files:
68 | attachments = create_attachments(attachment_files)
69 |
70 | email.attachments.add(*attachments)
71 |
72 | emails.append(email)
73 |
74 | if default_priority == 'now':
75 | status = email.dispatch()
76 | if status == STATUS.sent:
77 | num_sent += 1
78 |
79 | if default_priority != 'now':
80 | email_queued.send(sender=Email, emails=emails)
81 |
82 | return num_sent
83 |
--------------------------------------------------------------------------------
/post_office/test_settings.py:
--------------------------------------------------------------------------------
1 | import os
2 | import platform
3 |
4 | if platform.system() in ['Darwin']:
5 | from multiprocessing import set_start_method
6 |
7 | # required since Python-3.8. See #319
8 | set_start_method('fork')
9 |
10 | BASE_DIR = os.path.dirname(os.path.abspath(__file__))
11 |
12 | DATABASES = {
13 | 'default': {
14 | 'ENGINE': 'django.db.backends.sqlite3',
15 | },
16 | }
17 |
18 | # Default values: True
19 | # POST_OFFICE_CACHE = True
20 | # POST_OFFICE_TEMPLATE_CACHE = True
21 |
22 |
23 | CACHES = {
24 | 'default': {
25 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
26 | 'TIMEOUT': 36000,
27 | 'KEY_PREFIX': 'post-office',
28 | },
29 | 'post_office': {
30 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
31 | 'TIMEOUT': 36000,
32 | 'KEY_PREFIX': 'post-office',
33 | },
34 | }
35 |
36 | POST_OFFICE = {
37 | 'BACKENDS': {
38 | 'default': 'django.core.mail.backends.dummy.EmailBackend',
39 | 'locmem': 'django.core.mail.backends.locmem.EmailBackend',
40 | 'error': 'post_office.tests.test_backends.ErrorRaisingBackend',
41 | 'smtp': 'django.core.mail.backends.smtp.EmailBackend',
42 | 'connection_tester': 'post_office.tests.test_mail.ConnectionTestingBackend',
43 | 'slow_backend': 'post_office.tests.test_mail.SlowTestBackend',
44 | },
45 | 'CELERY_ENABLED': False,
46 | 'MAX_RETRIES': 2,
47 | 'MESSAGE_ID_ENABLED': True,
48 | 'BATCH_DELIVERY_TIMEOUT': 2,
49 | 'MESSAGE_ID_FQDN': 'example.com',
50 | }
51 |
52 |
53 | INSTALLED_APPS = (
54 | 'django.contrib.admin',
55 | 'django.contrib.auth',
56 | 'django.contrib.contenttypes',
57 | 'django.contrib.messages',
58 | 'django.contrib.sessions',
59 | 'post_office',
60 | )
61 |
62 | SECRET_KEY = 'a'
63 |
64 | ROOT_URLCONF = 'post_office.test_urls'
65 |
66 | DEFAULT_FROM_EMAIL = 'webmaster@example.com'
67 |
68 | MIDDLEWARE = [
69 | 'django.contrib.sessions.middleware.SessionMiddleware',
70 | 'django.contrib.auth.middleware.AuthenticationMiddleware',
71 | 'django.contrib.messages.middleware.MessageMiddleware',
72 | ]
73 |
74 | TEMPLATES = [
75 | {
76 | 'BACKEND': 'django.template.backends.django.DjangoTemplates',
77 | 'DIRS': [],
78 | 'APP_DIRS': True,
79 | 'OPTIONS': {
80 | 'context_processors': [
81 | 'django.contrib.auth.context_processors.auth',
82 | 'django.template.context_processors.debug',
83 | 'django.template.context_processors.i18n',
84 | 'django.template.context_processors.media',
85 | 'django.template.context_processors.request',
86 | 'django.template.context_processors.static',
87 | 'django.template.context_processors.tz',
88 | 'django.contrib.messages.context_processors.messages',
89 | ],
90 | },
91 | },
92 | {
93 | 'BACKEND': 'post_office.template.backends.post_office.PostOfficeTemplates',
94 | 'APP_DIRS': True,
95 | 'DIRS': [os.path.join(BASE_DIR, 'tests/templates')],
96 | 'OPTIONS': {
97 | 'context_processors': [
98 | 'django.contrib.auth.context_processors.auth',
99 | 'django.template.context_processors.debug',
100 | 'django.template.context_processors.i18n',
101 | 'django.template.context_processors.media',
102 | 'django.template.context_processors.static',
103 | 'django.template.context_processors.tz',
104 | 'django.template.context_processors.request',
105 | ]
106 | },
107 | },
108 | ]
109 |
110 | STATICFILES_DIRS = [os.path.join(BASE_DIR, 'tests/static')]
111 |
112 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
113 |
--------------------------------------------------------------------------------
/post_office/settings.py:
--------------------------------------------------------------------------------
1 | import warnings
2 |
3 | from django.conf import settings
4 | from django.core.cache import caches
5 | from django.core.cache.backends.base import InvalidCacheBackendError
6 | from django.core.files.storage import default_storage, storages
7 | from django.core.mail.utils import DNS_NAME
8 | from django.template import engines as template_engines
9 | from django.utils.functional import LazyObject
10 |
11 | from django.utils.module_loading import import_string
12 |
13 | import datetime
14 |
15 |
16 | def get_backend(alias='default'):
17 | return get_available_backends()[alias]
18 |
19 |
20 | def get_available_backends():
21 | """Returns a dictionary of defined backend classes. For example:
22 | {
23 | 'default': 'django.core.mail.backends.smtp.EmailBackend',
24 | 'locmem': 'django.core.mail.backends.locmem.EmailBackend',
25 | }
26 | """
27 | backends = get_config().get('BACKENDS', {})
28 |
29 | if backends:
30 | return backends
31 |
32 | # Try to get backend settings from old style
33 | # POST_OFFICE = {
34 | # 'EMAIL_BACKEND': 'mybackend'
35 | # }
36 | backend = get_config().get('EMAIL_BACKEND')
37 | if backend:
38 | warnings.warn('Please use the new POST_OFFICE["BACKENDS"] settings', DeprecationWarning)
39 |
40 | backends['default'] = backend
41 | return backends
42 |
43 | # Fall back to Django's EMAIL_BACKEND definition
44 | backends['default'] = getattr(settings, 'EMAIL_BACKEND', 'django.core.mail.backends.smtp.EmailBackend')
45 |
46 | # If EMAIL_BACKEND is set to use PostOfficeBackend
47 | # and POST_OFFICE_BACKEND is not set, fall back to SMTP
48 | if 'post_office.EmailBackend' in backends['default']:
49 | backends['default'] = 'django.core.mail.backends.smtp.EmailBackend'
50 |
51 | return backends
52 |
53 |
54 | def get_cache_backend():
55 | if hasattr(settings, 'CACHES'):
56 | if 'post_office' in settings.CACHES:
57 | return caches['post_office']
58 | else:
59 | # Sometimes this raises InvalidCacheBackendError, which is ok too
60 | try:
61 | return caches['default']
62 | except InvalidCacheBackendError:
63 | pass
64 | return None
65 |
66 |
67 | def get_config():
68 | """
69 | Returns Post Office's configuration in dictionary format. e.g:
70 | POST_OFFICE = {
71 | 'BATCH_SIZE': 1000
72 | }
73 | """
74 | return getattr(settings, 'POST_OFFICE', {})
75 |
76 |
77 | def get_batch_size():
78 | return get_config().get('BATCH_SIZE', 100)
79 |
80 |
81 | def get_celery_enabled():
82 | return get_config().get('CELERY_ENABLED', False)
83 |
84 |
85 | def get_lock_file_name():
86 | return get_config().get('LOCK_FILE_NAME', 'post_office')
87 |
88 |
89 | def get_threads_per_process():
90 | return get_config().get('THREADS_PER_PROCESS', 5)
91 |
92 |
93 | def get_default_priority():
94 | return get_config().get('DEFAULT_PRIORITY', 'medium')
95 |
96 |
97 | def get_log_level():
98 | return get_config().get('LOG_LEVEL', 2)
99 |
100 |
101 | def get_sending_order():
102 | return get_config().get('SENDING_ORDER', ['-priority'])
103 |
104 |
105 | def get_template_engine():
106 | using = get_config().get('TEMPLATE_ENGINE', 'django')
107 | return template_engines[using]
108 |
109 |
110 | def get_override_recipients():
111 | return get_config().get('OVERRIDE_RECIPIENTS', None)
112 |
113 |
114 | def get_max_retries():
115 | return get_config().get('MAX_RETRIES', 0)
116 |
117 |
118 | def get_retry_timedelta():
119 | return get_config().get('RETRY_INTERVAL', datetime.timedelta(minutes=15))
120 |
121 |
122 | def get_message_id_enabled():
123 | return get_config().get('MESSAGE_ID_ENABLED', False)
124 |
125 |
126 | def get_message_id_fqdn():
127 | return get_config().get('MESSAGE_ID_FQDN', DNS_NAME)
128 |
129 |
130 | # BATCH_DELIVERY_TIMEOUT defaults to 180 seconds (3 minutes)
131 | def get_batch_delivery_timeout():
132 | return get_config().get('BATCH_DELIVERY_TIMEOUT', 180)
133 |
134 |
135 | def get_file_storage():
136 | if storage_name := get_config().get('FILE_STORAGE', None):
137 | return storages[storage_name]
138 | return default_storage
139 |
140 |
141 | CONTEXT_FIELD_CLASS = get_config().get('CONTEXT_FIELD_CLASS', 'django.db.models.JSONField')
142 | context_field_class = import_string(CONTEXT_FIELD_CLASS)
143 |
--------------------------------------------------------------------------------
/post_office/migrations/0003_longer_subject.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 1.9 on 2016-02-04 08:08
2 | from django.db import migrations, models
3 |
4 |
5 | class Migration(migrations.Migration):
6 | dependencies = [
7 | ('post_office', '0002_add_i18n_and_backend_alias'),
8 | ]
9 |
10 | operations = [
11 | migrations.AlterField(
12 | model_name='email',
13 | name='subject',
14 | field=models.CharField(blank=True, max_length=989, verbose_name='Subject'),
15 | ),
16 | migrations.AlterField(
17 | model_name='emailtemplate',
18 | name='language',
19 | field=models.CharField(
20 | blank=True,
21 | choices=[
22 | ('af', 'Afrikaans'),
23 | ('ar', 'Arabic'),
24 | ('ast', 'Asturian'),
25 | ('az', 'Azerbaijani'),
26 | ('bg', 'Bulgarian'),
27 | ('be', 'Belarusian'),
28 | ('bn', 'Bengali'),
29 | ('br', 'Breton'),
30 | ('bs', 'Bosnian'),
31 | ('ca', 'Catalan'),
32 | ('cs', 'Czech'),
33 | ('cy', 'Welsh'),
34 | ('da', 'Danish'),
35 | ('de', 'German'),
36 | ('el', 'Greek'),
37 | ('en', 'English'),
38 | ('en-au', 'Australian English'),
39 | ('en-gb', 'British English'),
40 | ('eo', 'Esperanto'),
41 | ('es', 'Spanish'),
42 | ('es-ar', 'Argentinian Spanish'),
43 | ('es-co', 'Colombian Spanish'),
44 | ('es-mx', 'Mexican Spanish'),
45 | ('es-ni', 'Nicaraguan Spanish'),
46 | ('es-ve', 'Venezuelan Spanish'),
47 | ('et', 'Estonian'),
48 | ('eu', 'Basque'),
49 | ('fa', 'Persian'),
50 | ('fi', 'Finnish'),
51 | ('fr', 'French'),
52 | ('fy', 'Frisian'),
53 | ('ga', 'Irish'),
54 | ('gd', 'Scottish Gaelic'),
55 | ('gl', 'Galician'),
56 | ('he', 'Hebrew'),
57 | ('hi', 'Hindi'),
58 | ('hr', 'Croatian'),
59 | ('hu', 'Hungarian'),
60 | ('ia', 'Interlingua'),
61 | ('id', 'Indonesian'),
62 | ('io', 'Ido'),
63 | ('is', 'Icelandic'),
64 | ('it', 'Italian'),
65 | ('ja', 'Japanese'),
66 | ('ka', 'Georgian'),
67 | ('kk', 'Kazakh'),
68 | ('km', 'Khmer'),
69 | ('kn', 'Kannada'),
70 | ('ko', 'Korean'),
71 | ('lb', 'Luxembourgish'),
72 | ('lt', 'Lithuanian'),
73 | ('lv', 'Latvian'),
74 | ('mk', 'Macedonian'),
75 | ('ml', 'Malayalam'),
76 | ('mn', 'Mongolian'),
77 | ('mr', 'Marathi'),
78 | ('my', 'Burmese'),
79 | ('nb', 'Norwegian Bokmal'),
80 | ('ne', 'Nepali'),
81 | ('nl', 'Dutch'),
82 | ('nn', 'Norwegian Nynorsk'),
83 | ('os', 'Ossetic'),
84 | ('pa', 'Punjabi'),
85 | ('pl', 'Polish'),
86 | ('pt', 'Portuguese'),
87 | ('pt-br', 'Brazilian Portuguese'),
88 | ('ro', 'Romanian'),
89 | ('ru', 'Russian'),
90 | ('sk', 'Slovak'),
91 | ('sl', 'Slovenian'),
92 | ('sq', 'Albanian'),
93 | ('sr', 'Serbian'),
94 | ('sr-latn', 'Serbian Latin'),
95 | ('sv', 'Swedish'),
96 | ('sw', 'Swahili'),
97 | ('ta', 'Tamil'),
98 | ('te', 'Telugu'),
99 | ('th', 'Thai'),
100 | ('tr', 'Turkish'),
101 | ('tt', 'Tatar'),
102 | ('udm', 'Udmurt'),
103 | ('uk', 'Ukrainian'),
104 | ('ur', 'Urdu'),
105 | ('vi', 'Vietnamese'),
106 | ('zh-hans', 'Simplified Chinese'),
107 | ('zh-hant', 'Traditional Chinese'),
108 | ],
109 | default='',
110 | help_text='Render template in alternative language',
111 | max_length=12,
112 | ),
113 | ),
114 | ]
115 |
--------------------------------------------------------------------------------
/post_office/lockfile.py:
--------------------------------------------------------------------------------
1 | # This module is taken from https://gist.github.com/ionrock/3015700
2 |
3 | # A file lock implementation that tries to avoid platform specific
4 | # issues. It is inspired by a whole bunch of different implementations
5 | # listed below.
6 |
7 | # - https://bitbucket.org/jaraco/yg.lockfile/src/6c448dcbf6e5/yg/lockfile/__init__.py
8 | # - http://svn.zope.org/zc.lockfile/trunk/src/zc/lockfile/__init__.py?rev=121133&view=markup
9 | # - http://stackoverflow.com/questions/489861/locking-a-file-in-python
10 | # - http://www.evanfosmark.com/2009/01/cross-platform-file-locking-support-in-python/
11 | # - http://packages.python.org/lockfile/lockfile.html
12 |
13 | # There are some tests below and a blog posting conceptually the
14 | # problems I wanted to try and solve. The tests reflect these ideas.
15 |
16 | # - http://ionrock.wordpress.com/2012/06/28/file-locking-in-python/
17 |
18 | # I'm not advocating using this package. But if you do happen to try it
19 | # out and have suggestions please let me know.
20 |
21 | import os
22 | import platform
23 | import tempfile
24 | import time
25 |
26 | from post_office.settings import get_lock_file_name
27 |
28 |
29 | class FileLocked(Exception):
30 | pass
31 |
32 |
33 | class FileLock:
34 | def __init__(self, lock_filename, timeout=None, force=False):
35 | self.lock_filename = '%s.lock' % lock_filename
36 | self.timeout = timeout
37 | self.force = force
38 | self._pid = str(os.getpid())
39 | # Store pid in a file in the same directory as desired lockname
40 | self.pid_filename = os.path.join(os.path.dirname(self.lock_filename), self._pid) + '.lock'
41 |
42 | def get_lock_pid(self):
43 | try:
44 | return int(open(self.lock_filename).read())
45 | except OSError:
46 | # If we can't read symbolic link, there are two possibilities:
47 | # 1. The symbolic link is dead (point to non existing file)
48 | # 2. Symbolic link is not there
49 | # In either case, we can safely release the lock
50 | self.release()
51 | except ValueError:
52 | # most likely an empty or otherwise invalid lock file
53 | self.release()
54 |
55 | def valid_lock(self):
56 | """
57 | See if the lock exists and is left over from an old process.
58 | """
59 |
60 | lock_pid = self.get_lock_pid()
61 |
62 | # If we're unable to get lock_pid
63 | if lock_pid is None:
64 | return False
65 |
66 | # this is our process
67 | if self._pid == lock_pid:
68 | return True
69 |
70 | # it is/was another process
71 | # see if it is running
72 | try:
73 | os.kill(lock_pid, 0)
74 | except OSError:
75 | self.release()
76 | return False
77 |
78 | # it is running
79 | return True
80 |
81 | def is_locked(self, force=False):
82 | # We aren't locked
83 | if not self.valid_lock():
84 | return False
85 |
86 | # We are locked, but we want to force it without waiting
87 | if not self.timeout:
88 | if self.force:
89 | self.release()
90 | return False
91 | else:
92 | # We're not waiting or forcing the lock
93 | raise FileLocked()
94 |
95 | # Locked, but want to wait for an unlock
96 | interval = 0.1
97 | intervals = int(self.timeout / interval)
98 |
99 | while intervals:
100 | if self.valid_lock():
101 | intervals -= 1
102 | time.sleep(interval)
103 | # print('stopping %s' % intervals)
104 | else:
105 | return True
106 |
107 | # check one last time
108 | if self.valid_lock():
109 | if self.force:
110 | self.release()
111 | else:
112 | # still locked :(
113 | raise FileLocked()
114 |
115 | def acquire(self):
116 | """Create a pid filename and create a symlink (the actual lock file)
117 | across platforms that points to it. Symlink is used because it's an
118 | atomic operation across platforms.
119 | """
120 |
121 | pid_file = os.open(self.pid_filename, os.O_CREAT | os.O_EXCL | os.O_RDWR)
122 | os.write(pid_file, str(os.getpid()).encode('utf-8'))
123 | os.close(pid_file)
124 |
125 | if hasattr(os, 'symlink') and platform.system() != 'Windows':
126 | os.symlink(self.pid_filename, self.lock_filename)
127 | else:
128 | # Windows platforms doesn't support symlinks, at least not through the os API
129 | self.lock_filename = self.pid_filename
130 |
131 | def release(self):
132 | """Try to delete the lock files. Doesn't matter if we fail"""
133 | if self.lock_filename != self.pid_filename:
134 | try:
135 | os.unlink(self.lock_filename)
136 | except OSError:
137 | pass
138 |
139 | try:
140 | os.remove(self.pid_filename)
141 | except OSError:
142 | pass
143 |
144 | def __enter__(self):
145 | if not self.is_locked():
146 | self.acquire()
147 | return self
148 |
149 | def __exit__(self, type, value, traceback):
150 | self.release()
151 |
152 |
153 | default_lockfile = os.path.join(tempfile.gettempdir(), get_lock_file_name())
154 |
--------------------------------------------------------------------------------
/post_office/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | from django.db import models, migrations
2 |
3 | import post_office.fields
4 | import post_office.validators
5 | import post_office.models
6 |
7 |
8 | class Migration(migrations.Migration):
9 | dependencies = []
10 |
11 | operations = [
12 | migrations.CreateModel(
13 | name='Attachment',
14 | fields=[
15 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
16 | ('file', models.FileField(upload_to=post_office.models.get_upload_path)),
17 | ('name', models.CharField(help_text='The original filename', max_length=255)),
18 | ],
19 | options={},
20 | bases=(models.Model,),
21 | ),
22 | migrations.CreateModel(
23 | name='Email',
24 | fields=[
25 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
26 | (
27 | 'from_email',
28 | models.CharField(max_length=254, validators=[post_office.validators.validate_email_with_name]),
29 | ),
30 | ('to', post_office.fields.CommaSeparatedEmailField(blank=True)),
31 | ('cc', post_office.fields.CommaSeparatedEmailField(blank=True)),
32 | ('bcc', post_office.fields.CommaSeparatedEmailField(blank=True)),
33 | ('subject', models.CharField(max_length=255, blank=True)),
34 | ('message', models.TextField(blank=True)),
35 | ('html_message', models.TextField(blank=True)),
36 | (
37 | 'status',
38 | models.PositiveSmallIntegerField(
39 | blank=True, null=True, db_index=True, choices=[(0, 'sent'), (1, 'failed'), (2, 'queued')]
40 | ),
41 | ),
42 | (
43 | 'priority',
44 | models.PositiveSmallIntegerField(
45 | blank=True, null=True, choices=[(0, 'low'), (1, 'medium'), (2, 'high'), (3, 'now')]
46 | ),
47 | ),
48 | ('created', models.DateTimeField(auto_now_add=True, db_index=True)),
49 | ('last_updated', models.DateTimeField(auto_now=True, db_index=True)),
50 | ('scheduled_time', models.DateTimeField(db_index=True, null=True, blank=True)),
51 | ('headers', models.JSONField(null=True, blank=True)),
52 | ('context', models.JSONField(null=True, blank=True)),
53 | ],
54 | options={},
55 | bases=(models.Model,),
56 | ),
57 | migrations.CreateModel(
58 | name='EmailTemplate',
59 | fields=[
60 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
61 | ('name', models.CharField(help_text="e.g: 'welcome_email'", max_length=255)),
62 | ('description', models.TextField(help_text='Description of this template.', blank=True)),
63 | (
64 | 'subject',
65 | models.CharField(
66 | blank=True, max_length=255, validators=[post_office.validators.validate_template_syntax]
67 | ),
68 | ),
69 | ('content', models.TextField(blank=True, validators=[post_office.validators.validate_template_syntax])),
70 | (
71 | 'html_content',
72 | models.TextField(blank=True, validators=[post_office.validators.validate_template_syntax]),
73 | ),
74 | ('created', models.DateTimeField(auto_now_add=True)),
75 | ('last_updated', models.DateTimeField(auto_now=True)),
76 | ],
77 | options={},
78 | bases=(models.Model,),
79 | ),
80 | migrations.CreateModel(
81 | name='Log',
82 | fields=[
83 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
84 | ('date', models.DateTimeField(auto_now_add=True)),
85 | ('status', models.PositiveSmallIntegerField(choices=[(0, 'sent'), (1, 'failed')])),
86 | ('exception_type', models.CharField(max_length=255, blank=True)),
87 | ('message', models.TextField()),
88 | (
89 | 'email',
90 | models.ForeignKey(
91 | related_name='logs',
92 | editable=False,
93 | on_delete=models.deletion.CASCADE,
94 | to='post_office.Email',
95 | ),
96 | ),
97 | ],
98 | options={},
99 | bases=(models.Model,),
100 | ),
101 | migrations.AddField(
102 | model_name='email',
103 | name='template',
104 | field=models.ForeignKey(
105 | blank=True, on_delete=models.deletion.SET_NULL, to='post_office.EmailTemplate', null=True
106 | ),
107 | preserve_default=True,
108 | ),
109 | migrations.AddField(
110 | model_name='attachment',
111 | name='emails',
112 | field=models.ManyToManyField(related_name='attachments', to='post_office.Email'),
113 | preserve_default=True,
114 | ),
115 | ]
116 |
--------------------------------------------------------------------------------
/post_office/tests/test_commands.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import os
3 |
4 | from django.core.files.base import ContentFile
5 | from django.core.management import call_command
6 | from django.test import TestCase
7 | from django.test.utils import override_settings
8 | from django.utils.timezone import now
9 |
10 | from ..models import Attachment, Email, STATUS
11 |
12 |
13 | class CommandTest(TestCase):
14 | def test_cleanup_mail_with_orphaned_attachments(self):
15 | self.assertEqual(Email.objects.count(), 0)
16 | email = Email.objects.create(to=['to@example.com'], from_email='from@example.com', subject='Subject')
17 |
18 | email.created = now() - datetime.timedelta(31)
19 | email.save()
20 |
21 | attachment = Attachment()
22 | attachment.file.save('test.txt', content=ContentFile('test file content'), save=True)
23 | email.attachments.add(attachment)
24 | attachment_path = attachment.file.name
25 |
26 | # We have orphaned attachment now
27 | call_command('cleanup_mail', days=30)
28 | self.assertEqual(Email.objects.count(), 0)
29 | self.assertEqual(Attachment.objects.count(), 1)
30 |
31 | # Actually cleanup orphaned attachments
32 | call_command('cleanup_mail', '-da', days=30)
33 | self.assertEqual(Email.objects.count(), 0)
34 | self.assertEqual(Attachment.objects.count(), 0)
35 |
36 | # Check that the actual file has been deleted as well
37 | self.assertFalse(os.path.exists(attachment_path))
38 |
39 | # Check if the email attachment's actual file have been deleted
40 | Email.objects.all().delete()
41 | email = Email.objects.create(to=['to@example.com'], from_email='from@example.com', subject='Subject')
42 | email.created = now() - datetime.timedelta(31)
43 | email.save()
44 |
45 | attachment = Attachment()
46 | attachment.file.save('test.txt', content=ContentFile('test file content'), save=True)
47 | email.attachments.add(attachment)
48 | attachment_path = attachment.file.name
49 |
50 | # Simulate that the files have been deleted by accidents
51 | os.remove(attachment_path)
52 |
53 | # No exceptions should break the cleanup
54 | call_command('cleanup_mail', '-da', days=30)
55 | self.assertEqual(Email.objects.count(), 0)
56 | self.assertEqual(Attachment.objects.count(), 0)
57 |
58 | def test_cleanup_mail(self):
59 | """
60 | The ``cleanup_mail`` command deletes mails older than a specified
61 | amount of days
62 | """
63 | self.assertEqual(Email.objects.count(), 0)
64 |
65 | # The command shouldn't delete today's email
66 | email = Email.objects.create(from_email='from@example.com', to=['to@example.com'])
67 | call_command('cleanup_mail', days=30)
68 | self.assertEqual(Email.objects.count(), 1)
69 |
70 | # Email older than 30 days should be deleted
71 | email.created = now() - datetime.timedelta(31)
72 | email.save()
73 | call_command('cleanup_mail', days=30)
74 | self.assertEqual(Email.objects.count(), 0)
75 |
76 | TEST_SETTINGS = {
77 | 'BACKENDS': {
78 | 'default': 'django.core.mail.backends.dummy.EmailBackend',
79 | },
80 | 'BATCH_SIZE': 1,
81 | }
82 |
83 | @override_settings(POST_OFFICE=TEST_SETTINGS)
84 | def test_send_queued_mail(self):
85 | """
86 | Ensure that ``send_queued_mail`` behaves properly and sends all queued
87 | emails in two batches.
88 | """
89 | # Make sure that send_queued_mail with empty queue does not raise error
90 | call_command('send_queued_mail', processes=1)
91 |
92 | Email.objects.create(from_email='from@example.com', to=['to@example.com'], status=STATUS.queued)
93 | Email.objects.create(from_email='from@example.com', to=['to@example.com'], status=STATUS.queued)
94 | call_command('send_queued_mail', processes=1)
95 | self.assertEqual(Email.objects.filter(status=STATUS.sent).count(), 2)
96 | self.assertEqual(Email.objects.filter(status=STATUS.queued).count(), 0)
97 |
98 | def test_successful_deliveries_logging(self):
99 | """
100 | Successful deliveries are only logged when log_level is 2.
101 | """
102 | email = Email.objects.create(from_email='from@example.com', to=['to@example.com'], status=STATUS.queued)
103 | call_command('send_queued_mail', log_level=0)
104 | self.assertEqual(email.logs.count(), 0)
105 |
106 | email = Email.objects.create(from_email='from@example.com', to=['to@example.com'], status=STATUS.queued)
107 | call_command('send_queued_mail', log_level=1)
108 | self.assertEqual(email.logs.count(), 0)
109 |
110 | email = Email.objects.create(from_email='from@example.com', to=['to@example.com'], status=STATUS.queued)
111 | call_command('send_queued_mail', log_level=2)
112 | self.assertEqual(email.logs.count(), 1)
113 |
114 | def test_failed_deliveries_logging(self):
115 | """
116 | Failed deliveries are logged when log_level is 1 and 2.
117 | """
118 | email = Email.objects.create(
119 | from_email='from@example.com', to=['to@example.com'], status=STATUS.queued, backend_alias='error'
120 | )
121 | call_command('send_queued_mail', log_level=0)
122 | self.assertEqual(email.logs.count(), 0)
123 |
124 | email = Email.objects.create(
125 | from_email='from@example.com', to=['to@example.com'], status=STATUS.queued, backend_alias='error'
126 | )
127 | call_command('send_queued_mail', log_level=1)
128 | self.assertEqual(email.logs.count(), 1)
129 |
130 | email = Email.objects.create(
131 | from_email='from@example.com', to=['to@example.com'], status=STATUS.queued, backend_alias='error'
132 | )
133 | call_command('send_queued_mail', log_level=2)
134 | self.assertEqual(email.logs.count(), 1)
135 |
--------------------------------------------------------------------------------
/post_office/migrations/0004_auto_20160607_0901.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 1.9.6 on 2016-06-07 07:01
2 | from django.db import migrations, models
3 | import django.db.models.deletion
4 |
5 | import post_office.models
6 |
7 |
8 | class Migration(migrations.Migration):
9 | dependencies = [
10 | ('post_office', '0003_longer_subject'),
11 | ]
12 |
13 | operations = [
14 | migrations.AlterModelOptions(
15 | name='attachment',
16 | options={'verbose_name': 'Attachment', 'verbose_name_plural': 'Attachments'},
17 | ),
18 | migrations.AlterModelOptions(
19 | name='email',
20 | options={'verbose_name': 'Email', 'verbose_name_plural': 'Emails'},
21 | ),
22 | migrations.AlterModelOptions(
23 | name='log',
24 | options={'verbose_name': 'Log', 'verbose_name_plural': 'Logs'},
25 | ),
26 | migrations.AlterField(
27 | model_name='attachment',
28 | name='emails',
29 | field=models.ManyToManyField(
30 | related_name='attachments', to='post_office.Email', verbose_name='Email addresses'
31 | ),
32 | ),
33 | migrations.AlterField(
34 | model_name='attachment',
35 | name='file',
36 | field=models.FileField(upload_to=post_office.models.get_upload_path, verbose_name='File'),
37 | ),
38 | migrations.AlterField(
39 | model_name='attachment',
40 | name='name',
41 | field=models.CharField(help_text='The original filename', max_length=255, verbose_name='Name'),
42 | ),
43 | migrations.AlterField(
44 | model_name='email',
45 | name='backend_alias',
46 | field=models.CharField(blank=True, default='', max_length=64, verbose_name='Backend alias'),
47 | ),
48 | migrations.AlterField(
49 | model_name='email',
50 | name='context',
51 | field=models.JSONField(blank=True, null=True, verbose_name='Context'),
52 | ),
53 | migrations.AlterField(
54 | model_name='email',
55 | name='headers',
56 | field=models.JSONField(blank=True, null=True, verbose_name='Headers'),
57 | ),
58 | migrations.AlterField(
59 | model_name='email',
60 | name='priority',
61 | field=models.PositiveSmallIntegerField(
62 | blank=True,
63 | choices=[(0, 'low'), (1, 'medium'), (2, 'high'), (3, 'now')],
64 | null=True,
65 | verbose_name='Priority',
66 | ),
67 | ),
68 | migrations.AlterField(
69 | model_name='email',
70 | name='scheduled_time',
71 | field=models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='The scheduled sending time'),
72 | ),
73 | migrations.AlterField(
74 | model_name='email',
75 | name='status',
76 | field=models.PositiveSmallIntegerField(
77 | blank=True,
78 | choices=[(0, 'sent'), (1, 'failed'), (2, 'queued')],
79 | db_index=True,
80 | null=True,
81 | verbose_name='Status',
82 | ),
83 | ),
84 | migrations.AlterField(
85 | model_name='email',
86 | name='template',
87 | field=models.ForeignKey(
88 | blank=True,
89 | null=True,
90 | on_delete=django.db.models.deletion.CASCADE,
91 | to='post_office.EmailTemplate',
92 | verbose_name='Email template',
93 | ),
94 | ),
95 | migrations.AlterField(
96 | model_name='emailtemplate',
97 | name='default_template',
98 | field=models.ForeignKey(
99 | default=None,
100 | null=True,
101 | on_delete=django.db.models.deletion.CASCADE,
102 | related_name='translated_templates',
103 | to='post_office.EmailTemplate',
104 | verbose_name='Default template',
105 | ),
106 | ),
107 | migrations.AlterField(
108 | model_name='emailtemplate',
109 | name='description',
110 | field=models.TextField(blank=True, help_text='Description of this template.', verbose_name='Description'),
111 | ),
112 | migrations.AlterField(
113 | model_name='emailtemplate',
114 | name='language',
115 | field=models.CharField(
116 | blank=True,
117 | default='',
118 | help_text='Render template in alternative language',
119 | max_length=12,
120 | verbose_name='Language',
121 | ),
122 | ),
123 | migrations.AlterField(
124 | model_name='emailtemplate',
125 | name='name',
126 | field=models.CharField(help_text="e.g: 'welcome_email'", max_length=255, verbose_name='Name'),
127 | ),
128 | migrations.AlterField(
129 | model_name='log',
130 | name='email',
131 | field=models.ForeignKey(
132 | editable=False,
133 | on_delete=django.db.models.deletion.CASCADE,
134 | related_name='logs',
135 | to='post_office.Email',
136 | verbose_name='Email address',
137 | ),
138 | ),
139 | migrations.AlterField(
140 | model_name='log',
141 | name='exception_type',
142 | field=models.CharField(blank=True, max_length=255, verbose_name='Exception type'),
143 | ),
144 | migrations.AlterField(
145 | model_name='log',
146 | name='message',
147 | field=models.TextField(verbose_name='Message'),
148 | ),
149 | migrations.AlterField(
150 | model_name='log',
151 | name='status',
152 | field=models.PositiveSmallIntegerField(choices=[(0, 'sent'), (1, 'failed')], verbose_name='Status'),
153 | ),
154 | ]
155 |
--------------------------------------------------------------------------------
/post_office/utils.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 | from django.core.exceptions import ValidationError
3 | from django.core.files import File
4 | from django.utils.encoding import force_str
5 |
6 | from post_office import cache
7 | from .models import Email, PRIORITY, STATUS, EmailTemplate, Attachment
8 | from .settings import get_default_priority
9 | from .signals import email_queued
10 | from .validators import validate_email_with_name
11 |
12 |
13 | def send_mail(
14 | subject,
15 | message,
16 | from_email,
17 | recipient_list,
18 | html_message='',
19 | scheduled_time=None,
20 | headers=None,
21 | priority=PRIORITY.medium,
22 | ):
23 | """
24 | Add a new message to the mail queue. This is a replacement for Django's
25 | ``send_mail`` core email method.
26 | """
27 |
28 | subject = force_str(subject)
29 | status = None if priority == PRIORITY.now else STATUS.queued
30 | emails = [
31 | Email.objects.create(
32 | from_email=from_email,
33 | to=address,
34 | subject=subject,
35 | message=message,
36 | html_message=html_message,
37 | status=status,
38 | headers=headers,
39 | priority=priority,
40 | scheduled_time=scheduled_time,
41 | )
42 | for address in recipient_list
43 | ]
44 | if priority == PRIORITY.now:
45 | for email in emails:
46 | email.dispatch()
47 | else:
48 | email_queued.send(sender=Email, emails=emails)
49 | return emails
50 |
51 |
52 | def get_email_template(name, language=''):
53 | """
54 | Function that returns an email template instance, from cache or DB.
55 | """
56 | use_cache = getattr(settings, 'POST_OFFICE_CACHE', True)
57 | if use_cache:
58 | use_cache = getattr(settings, 'POST_OFFICE_TEMPLATE_CACHE', True)
59 | if not use_cache:
60 | return EmailTemplate.objects.get(name=name, language=language)
61 | else:
62 | composite_name = '%s:%s' % (name, language)
63 | email_template = cache.get(composite_name)
64 |
65 | if email_template is None:
66 | email_template = EmailTemplate.objects.get(name=name, language=language)
67 | cache.set(composite_name, email_template)
68 |
69 | return email_template
70 |
71 |
72 | def split_emails(emails, split_count=1):
73 | # Group emails into X sublists
74 | # taken from http://www.garyrobinson.net/2008/04/splitting-a-pyt.html
75 | # Strange bug, only return 100 email if we do not evaluate the list
76 | if list(emails):
77 | return [emails[i::split_count] for i in range(split_count)]
78 |
79 | return []
80 |
81 |
82 | def create_attachments(attachment_files):
83 | """
84 | Create Attachment instances from files
85 |
86 | attachment_files is a dict of:
87 | * Key - the filename to be used for the attachment.
88 | * Value - file-like object, or a filename to open OR a dict of {'file': file-like-object, 'mimetype': string}
89 |
90 | Returns a list of Attachment objects
91 | """
92 | attachments = []
93 | for filename, filedata in attachment_files.items():
94 | if isinstance(filedata, dict):
95 | content = filedata.get('file', None)
96 | mimetype = filedata.get('mimetype', None)
97 | headers = filedata.get('headers', None)
98 | else:
99 | content = filedata
100 | mimetype = None
101 | headers = None
102 |
103 | opened_file = None
104 |
105 | if isinstance(content, str):
106 | # `content` is a filename - try to open the file
107 | opened_file = open(content, 'rb')
108 | content = File(opened_file)
109 |
110 | attachment = Attachment()
111 | if mimetype:
112 | attachment.mimetype = mimetype
113 | attachment.headers = headers
114 | attachment.name = filename
115 | attachment.file.save(filename, content=content, save=True)
116 |
117 | attachments.append(attachment)
118 |
119 | if opened_file is not None:
120 | opened_file.close()
121 |
122 | return attachments
123 |
124 |
125 | def parse_priority(priority):
126 | if priority is None:
127 | priority = get_default_priority()
128 | # If priority is given as a string, returns the enum representation
129 | if isinstance(priority, str):
130 | priority = getattr(PRIORITY, priority, None)
131 |
132 | if priority is None:
133 | raise ValueError('Invalid priority, must be one of: %s' % ', '.join(PRIORITY._fields))
134 | return priority
135 |
136 |
137 | def parse_emails(emails):
138 | """
139 | A function that returns a list of valid email addresses.
140 | This function will also convert a single email address into
141 | a list of email addresses.
142 | None value is also converted into an empty list.
143 | """
144 |
145 | if isinstance(emails, str):
146 | emails = [emails]
147 | elif emails is None:
148 | emails = []
149 |
150 | for email in emails:
151 | try:
152 | validate_email_with_name(email)
153 | except ValidationError:
154 | raise ValidationError('%s is not a valid email address' % email)
155 |
156 | return emails
157 |
158 |
159 | def cleanup_expired_mails(cutoff_date, delete_attachments=True, batch_size=1000):
160 | """
161 | Delete all emails before the given cutoff date.
162 | Optionally also delete pending attachments.
163 | Return the number of deleted emails and attachments.
164 | """
165 | total_deleted_emails = 0
166 |
167 | while True:
168 | email_ids = Email.objects.filter(created__lt=cutoff_date).values_list('id', flat=True)[:batch_size]
169 | if not email_ids:
170 | break
171 |
172 | _, deleted_data = Email.objects.filter(id__in=email_ids).delete()
173 | if deleted_data:
174 | total_deleted_emails += deleted_data['post_office.Email']
175 |
176 | attachments_count = 0
177 | if delete_attachments:
178 | while True:
179 | attachments = Attachment.objects.filter(emails=None)[:batch_size]
180 | if not attachments:
181 | break
182 | attachment_ids = set()
183 | for attachment in attachments:
184 | # Delete the actual file
185 | attachment.file.delete()
186 | attachment_ids.add(attachment.id)
187 | deleted_count, _ = Attachment.objects.filter(id__in=attachment_ids).delete()
188 | attachments_count += deleted_count
189 |
190 | return total_deleted_emails, attachments_count
191 |
--------------------------------------------------------------------------------
/post_office/locale/cs/LC_MESSAGES/django.po:
--------------------------------------------------------------------------------
1 | # SOME DESCRIPTIVE TITLE.
2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3 | # This file is distributed under the same license as the PACKAGE package.
4 | # FIRST AUTHOR , YEAR.
5 | #
6 | #, fuzzy
7 | msgid ""
8 | msgstr ""
9 | "Project-Id-Version: PACKAGE VERSION\n"
10 | "Report-Msgid-Bugs-To: \n"
11 | "POT-Creation-Date: 2025-07-14 13:17+0200\n"
12 | "PO-Revision-Date: 2025-07-14 13:17+0200\n"
13 | "Last-Translator: Filip Dobrovolny \n"
14 | "Language-Team: Filip Dobrovolny \n"
15 | "Language: cs \n"
16 | "MIME-Version: 1.0\n"
17 | "Content-Type: text/plain; charset=UTF-8\n"
18 | "Content-Transfer-Encoding: 8bit\n"
19 | "Plural-Forms: nplurals=4; plural=(n == 1 && n % 1 == 0) ? 0 : (n >= 2 && n "
20 | "<= 4 && n % 1 == 0) ? 1: (n % 1 != 0 ) ? 2 : 3;\n"
21 | #: post_office/admin.py:122
22 | msgid "To"
23 | msgstr "Komu"
24 |
25 | #: post_office/admin.py:138 post_office/admin.py:185 post_office/models.py:52
26 | #: post_office/models.py:280
27 | msgid "Subject"
28 | msgstr "Předmět"
29 |
30 | #: post_office/admin.py:155
31 | msgid "Use Template"
32 | msgstr "Použít šablonu"
33 |
34 | #: post_office/admin.py:177
35 | msgid "HTML Email"
36 | msgstr "HTML Email"
37 |
38 | #: post_office/admin.py:179 post_office/admin.py:181
39 | msgid "Text Email"
40 | msgstr "Textový Email"
41 |
42 | #: post_office/admin.py:190
43 | msgid "Mail Body"
44 | msgstr "Tělo zprávy"
45 |
46 | #: post_office/admin.py:196
47 | msgid "HTML Body"
48 | msgstr "HTML Tělo"
49 |
50 | #: post_office/admin.py:242
51 | #, python-brace-format
52 | msgid "Duplicate template for language '{language}'."
53 | msgstr "Duplicitní šablona pro jazyk '{language}'."
54 |
55 | #: post_office/admin.py:252 post_office/models.py:286
56 | msgid "Language"
57 | msgstr "Jazyk"
58 |
59 | #: post_office/admin.py:253 post_office/models.py:287
60 | msgid "Render template in alternative language"
61 | msgstr "Vykreslit šablonu v alternativním jazyce"
62 |
63 | #: post_office/admin.py:286
64 | msgid "Default Content"
65 | msgstr "Výchozí obsah"
66 |
67 | #: post_office/admin.py:295 post_office/models.py:276
68 | msgid "Description"
69 | msgstr "Popis"
70 |
71 | #: post_office/admin.py:301
72 | msgid "Languages"
73 | msgstr "Jazyky"
74 |
75 | #: post_office/apps.py:7
76 | msgid "Post Office"
77 | msgstr "Pošta"
78 |
79 | #: post_office/fields.py:9
80 | msgid "Comma-separated emails"
81 | msgstr "Emaily oddělené čárkami"
82 |
83 | #: post_office/fields.py:18
84 | msgid "Only comma separated emails are allowed."
85 | msgstr "Povoleny jsou pouze emaily oddělené čárkami."
86 |
87 | #: post_office/models.py:36
88 | msgid "low"
89 | msgstr "nízká"
90 |
91 | #: post_office/models.py:37
92 | msgid "medium"
93 | msgstr "střední"
94 |
95 | #: post_office/models.py:38
96 | msgid "high"
97 | msgstr "vysoká"
98 |
99 | #: post_office/models.py:39
100 | msgid "now"
101 | msgstr "nyní"
102 |
103 | #: post_office/models.py:42 post_office/models.py:246
104 | msgid "sent"
105 | msgstr "odesláno"
106 |
107 | #: post_office/models.py:43 post_office/models.py:246
108 | msgid "failed"
109 | msgstr "selhalo"
110 |
111 | #: post_office/models.py:44
112 | msgid "queued"
113 | msgstr "ve frontě"
114 |
115 | #: post_office/models.py:45
116 | msgid "requeued"
117 | msgstr "znovu ve frontě"
118 |
119 | #: post_office/models.py:48
120 | msgid "Email From"
121 | msgstr "Email Od"
122 |
123 | #: post_office/models.py:49
124 | msgid "Email To"
125 | msgstr "Email Komu"
126 |
127 | #: post_office/models.py:50
128 | msgid "Cc"
129 | msgstr "Cc"
130 |
131 | #: post_office/models.py:51
132 | msgid "Bcc"
133 | msgstr "Bcc"
134 |
135 | #: post_office/models.py:53 post_office/models.py:254
136 | msgid "Message"
137 | msgstr "Zpráva"
138 |
139 | #: post_office/models.py:54
140 | msgid "HTML Message"
141 | msgstr "HTML Zpráva"
142 |
143 | #: post_office/models.py:60 post_office/models.py:252
144 | msgid "Status"
145 | msgstr "Stav"
146 |
147 | #: post_office/models.py:61
148 | msgid "Priority"
149 | msgstr "Priorita"
150 |
151 | #: post_office/models.py:65
152 | msgid "Scheduled Time"
153 | msgstr "Naplánovaný čas"
154 |
155 | #: post_office/models.py:65
156 | msgid "The scheduled sending time"
157 | msgstr "Naplánovaný čas odeslání"
158 |
159 | #: post_office/models.py:68
160 | msgid "Expires"
161 | msgstr "Vyprší"
162 |
163 | #: post_office/models.py:68
164 | msgid "Email won't be sent after this timestamp"
165 | msgstr "Email nebude odeslán po tomto časovém razítku"
166 |
167 | #: post_office/models.py:72 post_office/models.py:344
168 | msgid "Headers"
169 | msgstr "Hlavičky"
170 |
171 | #: post_office/models.py:74
172 | msgid "Email template"
173 | msgstr "Emailová šablona"
174 |
175 | #: post_office/models.py:76
176 | msgid "Context"
177 | msgstr "Kontext"
178 |
179 | #: post_office/models.py:77
180 | msgid "Backend alias"
181 | msgstr "Alias backendu"
182 |
183 | #: post_office/models.py:81
184 | msgctxt "Email address"
185 | msgid "Email"
186 | msgstr "Email"
187 |
188 | #: post_office/models.py:82
189 | msgctxt "Email addresses"
190 | msgid "Emails"
191 | msgstr "Emaily"
192 |
193 | #: post_office/models.py:234
194 | msgid "The scheduled time may not be later than the expires time."
195 | msgstr "Naplánovaný čas nesmí být později než čas vypršení platnosti."
196 |
197 | #: post_office/models.py:249
198 | msgid "Email address"
199 | msgstr "Emailová adresa"
200 |
201 | #: post_office/models.py:253
202 | msgid "Exception type"
203 | msgstr "Typ výjimky"
204 |
205 | #: post_office/models.py:258
206 | msgid "Log"
207 | msgstr "Protokol"
208 |
209 | #: post_office/models.py:259
210 | msgid "Logs"
211 | msgstr "Protokoly"
212 |
213 | #: post_office/models.py:275 post_office/models.py:341
214 | msgid "Name"
215 | msgstr "Název"
216 |
217 | #: post_office/models.py:275
218 | msgid "e.g: 'welcome_email'"
219 | msgstr "např. 'uvitaci_email'"
220 |
221 | #: post_office/models.py:276
222 | msgid "Description of this template."
223 | msgstr "Popis této šablony."
224 |
225 | #: post_office/models.py:282
226 | msgid "Content"
227 | msgstr "Obsah"
228 |
229 | #: post_office/models.py:283
230 | msgid "HTML content"
231 | msgstr "HTML obsah"
232 |
233 | #: post_office/models.py:296
234 | msgid "Default template"
235 | msgstr "Výchozí šablona"
236 |
237 | #: post_office/models.py:305
238 | msgid "Email Template"
239 | msgstr "Emailová šablona"
240 |
241 | #: post_office/models.py:306
242 | msgid "Email Templates"
243 | msgstr "Emailové šablony"
244 |
245 | #: post_office/models.py:340
246 | msgid "File"
247 | msgstr "Soubor"
248 |
249 | #: post_office/models.py:341
250 | msgid "The original filename"
251 | msgstr "Původní název souboru"
252 |
253 | #: post_office/models.py:342
254 | msgid "Emails"
255 | msgstr "Emaily"
256 |
257 | #: post_office/models.py:348
258 | msgid "Attachment"
259 | msgstr "Příloha"
260 |
261 | #: post_office/models.py:349
262 | msgid "Attachments"
263 | msgstr "Přílohy"
264 |
265 | #: post_office/sanitizer.py:8
266 | msgid "Install 'bleach' to render HTML properly."
267 | msgstr "Nainstalujte 'bleach' pro správné vykreslení HTML."
268 |
269 | #: post_office/templates/admin/post_office/email/change_form.html:4
270 | msgid "Resend"
271 | msgstr "Odeslat znovu"
272 |
--------------------------------------------------------------------------------
/post_office/sanitizer.py:
--------------------------------------------------------------------------------
1 | from django.utils.html import mark_safe, format_html
2 | from django.utils.translation import gettext_lazy
3 |
4 | try:
5 | import bleach
6 | except ImportError:
7 | # if bleach is not installed, render HTML as escaped text to prevent XSS attacks
8 | heading = gettext_lazy("Install 'bleach' to render HTML properly.")
9 | clean_html = lambda body: format_html('{heading}
\n{body}
', heading=heading, body=body)
10 | else:
11 | styles = [
12 | 'border',
13 | 'border-top',
14 | 'border-right',
15 | 'border-bottom',
16 | 'border-left',
17 | 'border-radius',
18 | 'box-shadow',
19 | 'height',
20 | 'margin',
21 | 'margin-top',
22 | 'margin-right',
23 | 'margin-bottom',
24 | 'margin-left',
25 | 'padding',
26 | 'padding-top',
27 | 'padding-right',
28 | 'padding-bottom',
29 | 'padding-left',
30 | 'width',
31 | 'max-width',
32 | 'min-width',
33 | 'border-collapse',
34 | 'border-spacing',
35 | 'caption-side',
36 | 'empty-cells',
37 | 'table-layout',
38 | 'direction',
39 | 'font',
40 | 'font-family',
41 | 'font-style',
42 | 'font-variant',
43 | 'font-size',
44 | 'font-weight',
45 | 'letter-spacing',
46 | 'line-height',
47 | 'text-align',
48 | 'text-decoration',
49 | 'text-indent',
50 | 'text-overflow',
51 | 'text-shadow',
52 | 'text-transform',
53 | 'white-space',
54 | 'word-spacing',
55 | 'word-wrap',
56 | 'vertical-align',
57 | 'color',
58 | 'background',
59 | 'background-color',
60 | 'background-image',
61 | 'background-position',
62 | 'background-repeat',
63 | 'bottom',
64 | 'clear',
65 | 'cursor',
66 | 'display',
67 | 'float',
68 | 'left',
69 | 'opacity',
70 | 'outline',
71 | 'overflow',
72 | 'position',
73 | 'resize',
74 | 'right',
75 | 'top',
76 | 'visibility',
77 | 'z-index',
78 | 'list-style-position',
79 | 'list-style-tyle',
80 | ]
81 | tags = [
82 | 'a',
83 | 'abbr',
84 | 'acronym',
85 | 'b',
86 | 'blockquote',
87 | 'br',
88 | 'caption',
89 | 'center',
90 | 'code',
91 | 'em',
92 | 'div',
93 | 'font',
94 | 'h1',
95 | 'h2',
96 | 'h3',
97 | 'h4',
98 | 'h5',
99 | 'h6',
100 | 'head',
101 | 'hr',
102 | 'i',
103 | 'img',
104 | 'label',
105 | 'li',
106 | 'ol',
107 | 'p',
108 | 'pre',
109 | 'span',
110 | 'strong',
111 | 'table',
112 | 'tbody',
113 | 'tfoot',
114 | 'td',
115 | 'th',
116 | 'thead',
117 | 'tr',
118 | 'u',
119 | 'ul',
120 | ]
121 | attributes = {
122 | 'a': ['class', 'href', 'id', 'style', 'target'],
123 | 'abbr': ['class', 'id', 'style'],
124 | 'acronym': ['class', 'id', 'style'],
125 | 'b': ['class', 'id', 'style'],
126 | 'blockquote': ['class', 'id', 'style'],
127 | 'br': ['class', 'id', 'style'],
128 | 'caption': ['class', 'id', 'style'],
129 | 'center': ['class', 'id', 'style'],
130 | 'code': ['class', 'id', 'style'],
131 | 'em': ['class', 'id', 'style'],
132 | 'div': ['class', 'id', 'style', 'align', 'dir'],
133 | 'font': ['class', 'id', 'style', 'color', 'face', 'size'],
134 | 'h1': ['class', 'id', 'style', 'align', 'dir'],
135 | 'h2': ['class', 'id', 'style', 'align', 'dir'],
136 | 'h3': ['class', 'id', 'style', 'align', 'dir'],
137 | 'h4': ['class', 'id', 'style', 'align', 'dir'],
138 | 'h5': ['class', 'id', 'style', 'align', 'dir'],
139 | 'h6': ['class', 'id', 'style', 'align', 'dir'],
140 | 'head': ['dir', 'lang'],
141 | 'hr': ['align', 'size', 'width'],
142 | 'i': ['class', 'id', 'style'],
143 | 'img': ['class', 'id', 'style', 'align', 'border', 'height', 'hspace', 'src', 'usemap', 'vspace', 'width'],
144 | 'label': ['class', 'id', 'style'],
145 | 'li': ['class', 'id', 'style', 'dir', 'type'],
146 | 'ol': ['class', 'id', 'style', 'dir', 'type'],
147 | 'p': ['class', 'id', 'style', 'align', 'dir'],
148 | 'pre': ['class', 'id', 'style'],
149 | 'span': ['class', 'id', 'style'],
150 | 'strong': ['class', 'id', 'style'],
151 | 'table': [
152 | 'class',
153 | 'id',
154 | 'style',
155 | 'align',
156 | 'bgcolor',
157 | 'border',
158 | 'cellpadding',
159 | 'cellspacing',
160 | 'dir',
161 | 'frame',
162 | 'rules',
163 | 'width',
164 | ],
165 | 'tbody': ['class', 'id', 'style'],
166 | 'tfoot': ['class', 'id', 'style'],
167 | 'td': [
168 | 'class',
169 | 'id',
170 | 'style',
171 | 'abbr',
172 | 'align',
173 | 'bgcolor',
174 | 'colspan',
175 | 'dir',
176 | 'height',
177 | 'lang',
178 | 'rowspan',
179 | 'scope',
180 | 'style',
181 | 'valign',
182 | 'width',
183 | ],
184 | 'th': [
185 | 'class',
186 | 'id',
187 | 'style',
188 | 'abbr',
189 | 'align',
190 | 'background',
191 | 'bgcolor',
192 | 'colspan',
193 | 'dir',
194 | 'height',
195 | 'lang',
196 | 'scope',
197 | 'style',
198 | 'valign',
199 | 'width',
200 | ],
201 | 'thead': ['class', 'id', 'style'],
202 | 'tr': ['class', 'id', 'style', 'align', 'bgcolor', 'dir', 'style', 'valign'],
203 | 'u': ['class', 'id', 'style'],
204 | 'ul': ['class', 'id', 'style', 'dir', 'type'],
205 | }
206 | try:
207 | from bleach.css_sanitizer import CSSSanitizer
208 |
209 | css_sanitizer = CSSSanitizer(
210 | allowed_css_properties=styles,
211 | )
212 | clean_html = lambda body: mark_safe(
213 | bleach.clean(
214 | body,
215 | tags=tags,
216 | attributes=attributes,
217 | strip=True,
218 | strip_comments=True,
219 | css_sanitizer=css_sanitizer,
220 | )
221 | )
222 | except ModuleNotFoundError:
223 | # if bleach version is prior to 5.0.0
224 | clean_html = lambda body: mark_safe(
225 | bleach.clean(
226 | body,
227 | tags=tags,
228 | attributes=attributes,
229 | strip=True,
230 | strip_comments=True,
231 | styles=styles,
232 | )
233 | )
234 |
--------------------------------------------------------------------------------
/post_office/locale/it/LC_MESSAGES/django.po:
--------------------------------------------------------------------------------
1 | # SOME DESCRIPTIVE TITLE.
2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3 | # This file is distributed under the same license as the PACKAGE package.
4 | # FIRST AUTHOR , YEAR.
5 | #
6 | #, fuzzy
7 | msgid ""
8 | msgstr ""
9 | "Project-Id-Version: PACKAGE VERSION\n"
10 | "Report-Msgid-Bugs-To: \n"
11 | "POT-Creation-Date: 2024-11-27 16:59+0400\n"
12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
13 | "Last-Translator: FULL NAME \n"
14 | "Language-Team: LANGUAGE \n"
15 | "Language: \n"
16 | "MIME-Version: 1.0\n"
17 | "Content-Type: text/plain; charset=UTF-8\n"
18 | "Content-Transfer-Encoding: 8bit\n"
19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n"
20 |
21 | #: post_office/admin.py:131
22 | msgid "To"
23 | msgstr "A"
24 |
25 | #: post_office/admin.py:151 post_office/admin.py:188 post_office/models.py:52
26 | #: post_office/models.py:277
27 | msgid "Subject"
28 | msgstr "Soggetto"
29 |
30 | #: post_office/admin.py:157
31 | #, fuzzy
32 | msgid "Use Template"
33 | msgstr "Email Template"
34 |
35 | #: post_office/admin.py:176
36 | msgid "HTML Email"
37 | msgstr "Email HTML"
38 |
39 | #: post_office/admin.py:178 post_office/admin.py:180
40 | msgid "Text Email"
41 | msgstr "Email di testo"
42 |
43 | #: post_office/admin.py:195
44 | msgid "Mail Body"
45 | msgstr "Corpo del messaggio"
46 |
47 | #: post_office/admin.py:206
48 | msgid "HTML Body"
49 | msgstr "Corpo HTML"
50 |
51 | #: post_office/admin.py:243
52 | #, python-brace-format
53 | msgid "Duplicate template for language '{language}'."
54 | msgstr "Template duplicato per la lingua '{language}'."
55 |
56 | #: post_office/admin.py:253 post_office/models.py:283
57 | #, fuzzy
58 | #| msgid "Languages"
59 | msgid "Language"
60 | msgstr "Lingue"
61 |
62 | #: post_office/admin.py:254 post_office/models.py:284
63 | msgid "Render template in alternative language"
64 | msgstr "Rendere template in un altra lingua"
65 |
66 | #: post_office/admin.py:286
67 | #, fuzzy
68 | msgid "Default Content"
69 | msgstr "Contenuto"
70 |
71 | #: post_office/admin.py:297 post_office/models.py:273
72 | msgid "Description"
73 | msgstr "Descrizione"
74 |
75 | #: post_office/admin.py:304
76 | msgid "Languages"
77 | msgstr "Lingue"
78 |
79 | #: post_office/apps.py:7
80 | msgid "Post Office"
81 | msgstr "Ufficio Postale"
82 |
83 | #: post_office/fields.py:9
84 | msgid "Comma-separated emails"
85 | msgstr "Emails separati da virgole"
86 |
87 | #: post_office/fields.py:18
88 | msgid "Only comma separated emails are allowed."
89 | msgstr "Sono consentiti soltanto emails separati da virgole"
90 |
91 | #: post_office/models.py:36
92 | msgid "low"
93 | msgstr "bassa"
94 |
95 | #: post_office/models.py:37
96 | msgid "medium"
97 | msgstr "media"
98 |
99 | #: post_office/models.py:38
100 | msgid "high"
101 | msgstr "alta"
102 |
103 | #: post_office/models.py:39
104 | msgid "now"
105 | msgstr "immediata"
106 |
107 | #: post_office/models.py:42 post_office/models.py:243
108 | msgid "sent"
109 | msgstr "inviato"
110 |
111 | #: post_office/models.py:43 post_office/models.py:243
112 | msgid "failed"
113 | msgstr "fallito"
114 |
115 | #: post_office/models.py:44
116 | msgid "queued"
117 | msgstr "in attesa"
118 |
119 | #: post_office/models.py:45
120 | #, fuzzy
121 | #| msgid "queued"
122 | msgid "requeued"
123 | msgstr "rimesso in coda"
124 |
125 | #: post_office/models.py:48
126 | msgid "Email From"
127 | msgstr "Email da"
128 |
129 | #: post_office/models.py:49
130 | msgid "Email To"
131 | msgstr "Email per"
132 |
133 | #: post_office/models.py:50
134 | msgid "Cc"
135 | msgstr "Copia"
136 |
137 | #: post_office/models.py:51
138 | msgid "Bcc"
139 | msgstr "Bcc"
140 |
141 | #: post_office/models.py:53 post_office/models.py:251
142 | msgid "Message"
143 | msgstr "Messaggio"
144 |
145 | #: post_office/models.py:54
146 | msgid "HTML Message"
147 | msgstr "HTML Messaggio"
148 |
149 | #: post_office/models.py:60 post_office/models.py:249
150 | msgid "Status"
151 | msgstr "Stato"
152 |
153 | #: post_office/models.py:61
154 | msgid "Priority"
155 | msgstr "Priorità"
156 |
157 | #: post_office/models.py:65
158 | msgid "Scheduled Time"
159 | msgstr "Tempo programmato"
160 |
161 | #: post_office/models.py:65
162 | msgid "The scheduled sending time"
163 | msgstr "L'orario di invio programmato"
164 |
165 | #: post_office/models.py:68
166 | msgid "Expires"
167 | msgstr "Scade"
168 |
169 | #: post_office/models.py:68
170 | msgid "Email won't be sent after this timestamp"
171 | msgstr "L'email non verrà inviata dopo questa data"
172 |
173 | #: post_office/models.py:72 post_office/models.py:341
174 | msgid "Headers"
175 | msgstr "Intestazioni"
176 |
177 | #: post_office/models.py:74
178 | #, fuzzy
179 | msgid "Email template"
180 | msgstr "Email Template"
181 |
182 | #: post_office/models.py:76
183 | #, fuzzy
184 | #| msgid "Content"
185 | msgid "Context"
186 | msgstr "Contesto"
187 |
188 | #: post_office/models.py:77
189 | msgid "Backend alias"
190 | msgstr "Alias del backend"
191 |
192 | #: post_office/models.py:81
193 | #, fuzzy
194 | #| msgid "Email To"
195 | msgctxt "Email address"
196 | msgid "Email"
197 | msgstr "Email per"
198 |
199 | #: post_office/models.py:82
200 | #, fuzzy
201 | #| msgid "Email To"
202 | msgctxt "Email addresses"
203 | msgid "Emails"
204 | msgstr "Email per"
205 |
206 | #: post_office/models.py:231
207 | msgid "The scheduled time may not be later than the expires time."
208 | msgstr "Il tempo programmato non può essere successivo al tempo di scadenza."
209 |
210 | #: post_office/models.py:246
211 | #, fuzzy
212 | msgid "Email address"
213 | msgstr "Email Templates"
214 |
215 | #: post_office/models.py:250
216 | msgid "Exception type"
217 | msgstr "Tipo di eccezione"
218 |
219 | #: post_office/models.py:255
220 | msgid "Log"
221 | msgstr "Registro"
222 |
223 | #: post_office/models.py:256
224 | msgid "Logs"
225 | msgstr "Registri"
226 |
227 | #: post_office/models.py:272 post_office/models.py:338
228 | msgid "Name"
229 | msgstr "Nome"
230 |
231 | #: post_office/models.py:272
232 | msgid "e.g: 'welcome_email'"
233 | msgstr "per es. 'welcome_email'"
234 |
235 | #: post_office/models.py:273
236 | msgid "Description of this template."
237 | msgstr "Descrizione di questa template."
238 |
239 | #: post_office/models.py:279
240 | msgid "Content"
241 | msgstr "Contenuto"
242 |
243 | #: post_office/models.py:280
244 | msgid "HTML content"
245 | msgstr "Contenuto in HTML"
246 |
247 | #: post_office/models.py:293
248 | #, fuzzy
249 | msgid "Default template"
250 | msgstr "Template predefinito"
251 |
252 | #: post_office/models.py:302
253 | #, fuzzy
254 | msgid "Email Template"
255 | msgstr "Email Template"
256 |
257 | #: post_office/models.py:303
258 | #, fuzzy
259 | msgid "Email Templates"
260 | msgstr "Email Templates"
261 |
262 | #: post_office/models.py:337
263 | msgid "File"
264 | msgstr "File"
265 |
266 | #: post_office/models.py:338
267 | msgid "The original filename"
268 | msgstr "Nome del file originale"
269 |
270 | #: post_office/models.py:339
271 | #, fuzzy
272 | #| msgid "Email To"
273 | msgid "Emails"
274 | msgstr "Email per"
275 |
276 | #: post_office/models.py:345
277 | msgid "Attachment"
278 | msgstr "Allegato"
279 |
280 | #: post_office/models.py:346
281 | msgid "Attachments"
282 | msgstr "Allegati"
283 |
284 | #: post_office/sanitizer.py:8
285 | msgid "Install 'bleach' to render HTML properly."
286 | msgstr "Installare 'bleach' per renderizzare correttamente l'HTML."
287 |
288 | #: post_office/templates/admin/post_office/email/change_form.html:4
289 | msgid "Resend"
290 | msgstr "Reinvia"
291 |
--------------------------------------------------------------------------------
/post_office/locale/de/LC_MESSAGES/django.po:
--------------------------------------------------------------------------------
1 | # SOME DESCRIPTIVE TITLE.
2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3 | # This file is distributed under the same license as the PACKAGE package.
4 | # FIRST AUTHOR , YEAR.
5 | #
6 | #, fuzzy
7 | msgid ""
8 | msgstr ""
9 | "Project-Id-Version: PACKAGE VERSION\n"
10 | "Report-Msgid-Bugs-To: \n"
11 | "POT-Creation-Date: 2024-11-27 16:59+0400\n"
12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
13 | "Last-Translator: Jacob Rief \n"
14 | "Language-Team: Jacob Rief \n"
15 | "Language: \n"
16 | "MIME-Version: 1.0\n"
17 | "Content-Type: text/plain; charset=UTF-8\n"
18 | "Content-Transfer-Encoding: 8bit\n"
19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n"
20 |
21 | #: post_office/admin.py:131
22 | msgid "To"
23 | msgstr "An"
24 |
25 | #: post_office/admin.py:151 post_office/admin.py:188 post_office/models.py:52
26 | #: post_office/models.py:277
27 | msgid "Subject"
28 | msgstr "Betreff"
29 |
30 | #: post_office/admin.py:157
31 | #, fuzzy
32 | #| msgid "Email Template"
33 | msgid "Use Template"
34 | msgstr "Vorlage verwenden"
35 |
36 | #: post_office/admin.py:176
37 | #, fuzzy
38 | #| msgctxt "Email address"
39 | #| msgid "Email"
40 | msgid "HTML Email"
41 | msgstr "HTML E-Mail"
42 |
43 | #: post_office/admin.py:178 post_office/admin.py:180
44 | #, fuzzy
45 | #| msgctxt "Email address"
46 | #| msgid "Email"
47 | msgid "Text Email"
48 | msgstr "Text E-Mail"
49 |
50 | #: post_office/admin.py:195
51 | msgid "Mail Body"
52 | msgstr "E-Mail Text"
53 |
54 | #: post_office/admin.py:206
55 | msgid "HTML Body"
56 | msgstr "HTML Inhalt"
57 |
58 | #: post_office/admin.py:243
59 | #, python-brace-format
60 | msgid "Duplicate template for language '{language}'."
61 | msgstr "Doppelte Vorlage für Sprache '{language}'."
62 |
63 | #: post_office/admin.py:253 post_office/models.py:283
64 | msgid "Language"
65 | msgstr "Sprache"
66 |
67 | #: post_office/admin.py:254 post_office/models.py:284
68 | msgid "Render template in alternative language"
69 | msgstr "Vorlage in alternativer Sprache rendern"
70 |
71 | #: post_office/admin.py:286
72 | msgid "Default Content"
73 | msgstr "Standard-Inhalt"
74 |
75 | #: post_office/admin.py:297 post_office/models.py:273
76 | msgid "Description"
77 | msgstr "Beschreibung"
78 |
79 | #: post_office/admin.py:304
80 | msgid "Languages"
81 | msgstr "Sprachen"
82 |
83 | #: post_office/apps.py:7
84 | msgid "Post Office"
85 | msgstr "Post Office"
86 |
87 | #: post_office/fields.py:9
88 | msgid "Comma-separated emails"
89 | msgstr "Durch Kommas getrennte Emails"
90 |
91 | #: post_office/fields.py:18
92 | msgid "Only comma separated emails are allowed."
93 | msgstr "Nur durch Kommas getrennte Emails sind erlaubt"
94 |
95 | #: post_office/models.py:36
96 | msgid "low"
97 | msgstr "niedrig"
98 |
99 | #: post_office/models.py:37
100 | msgid "medium"
101 | msgstr "mittel"
102 |
103 | #: post_office/models.py:38
104 | msgid "high"
105 | msgstr "hoch"
106 |
107 | #: post_office/models.py:39
108 | msgid "now"
109 | msgstr "sofort"
110 |
111 | #: post_office/models.py:42 post_office/models.py:243
112 | msgid "sent"
113 | msgstr "gesendet"
114 |
115 | #: post_office/models.py:43 post_office/models.py:243
116 | msgid "failed"
117 | msgstr "fehlgeschlagen"
118 |
119 | #: post_office/models.py:44
120 | msgid "queued"
121 | msgstr "in der Warteschleife"
122 |
123 | #: post_office/models.py:45
124 | #, fuzzy
125 | #| msgid "queued"
126 | msgid "requeued"
127 | msgstr "erneut eingereiht"
128 |
129 | #: post_office/models.py:48
130 | msgid "Email From"
131 | msgstr "E-Mail Von"
132 |
133 | #: post_office/models.py:49
134 | msgid "Email To"
135 | msgstr "E-Mail An"
136 |
137 | #: post_office/models.py:50
138 | msgid "Cc"
139 | msgstr "Kopie"
140 |
141 | #: post_office/models.py:51
142 | msgid "Bcc"
143 | msgstr "Blinde Kopie"
144 |
145 | #: post_office/models.py:53 post_office/models.py:251
146 | msgid "Message"
147 | msgstr "Nachricht"
148 |
149 | #: post_office/models.py:54
150 | msgid "HTML Message"
151 | msgstr "HTML Nachricht"
152 |
153 | #: post_office/models.py:60 post_office/models.py:249
154 | msgid "Status"
155 | msgstr "Status"
156 |
157 | #: post_office/models.py:61
158 | msgid "Priority"
159 | msgstr "Priorität"
160 |
161 | #: post_office/models.py:65
162 | #, fuzzy
163 | #| msgid "The scheduled sending time"
164 | msgid "Scheduled Time"
165 | msgstr "Geplante Versandzeit"
166 |
167 | #: post_office/models.py:65
168 | msgid "The scheduled sending time"
169 | msgstr "Geplante Versandzeit"
170 |
171 | #: post_office/models.py:68
172 | msgid "Expires"
173 | msgstr "Verfällt"
174 |
175 | #: post_office/models.py:68
176 | msgid "Email won't be sent after this timestamp"
177 | msgstr "E-Mail wird nach diesem Zeitpunkt nicht mehr versendet"
178 |
179 | #: post_office/models.py:72 post_office/models.py:341
180 | msgid "Headers"
181 | msgstr "Kopfbereich"
182 |
183 | #: post_office/models.py:74
184 | msgid "Email template"
185 | msgstr "E-Mail Vorlage"
186 |
187 | #: post_office/models.py:76
188 | msgid "Context"
189 | msgstr "Inhalt"
190 |
191 | #: post_office/models.py:77
192 | msgid "Backend alias"
193 | msgstr "Backend alias"
194 |
195 | #: post_office/models.py:81
196 | msgctxt "Email address"
197 | msgid "Email"
198 | msgstr "E-Mail"
199 |
200 | #: post_office/models.py:82
201 | msgctxt "Email addresses"
202 | msgid "Emails"
203 | msgstr "E-Mails"
204 |
205 | #: post_office/models.py:231
206 | msgid "The scheduled time may not be later than the expires time."
207 | msgstr "Die geplante Zeit darf nicht später als die Ablaufzeit sein."
208 |
209 | #: post_office/models.py:246
210 | msgid "Email address"
211 | msgstr "E-Mail Adresse"
212 |
213 | #: post_office/models.py:250
214 | msgid "Exception type"
215 | msgstr "Ausnahme"
216 |
217 | #: post_office/models.py:255
218 | msgid "Log"
219 | msgstr "Log"
220 |
221 | #: post_office/models.py:256
222 | msgid "Logs"
223 | msgstr "Logs"
224 |
225 | #: post_office/models.py:272 post_office/models.py:338
226 | msgid "Name"
227 | msgstr "Name"
228 |
229 | #: post_office/models.py:272
230 | msgid "e.g: 'welcome_email'"
231 | msgstr "z.B. 'welcome_email'"
232 |
233 | #: post_office/models.py:273
234 | msgid "Description of this template."
235 | msgstr "Beschreibung dieser Vorlage"
236 |
237 | #: post_office/models.py:279
238 | msgid "Content"
239 | msgstr "Inhalt"
240 |
241 | #: post_office/models.py:280
242 | msgid "HTML content"
243 | msgstr "HTML Inhalt"
244 |
245 | #: post_office/models.py:293
246 | msgid "Default template"
247 | msgstr "Standard-Vorlage"
248 |
249 | #: post_office/models.py:302
250 | msgid "Email Template"
251 | msgstr "E-Mail Vorlage"
252 |
253 | #: post_office/models.py:303
254 | msgid "Email Templates"
255 | msgstr "E-Mail Vorlagen"
256 |
257 | #: post_office/models.py:337
258 | msgid "File"
259 | msgstr "Datei"
260 |
261 | #: post_office/models.py:338
262 | msgid "The original filename"
263 | msgstr "Ursprünglicher Dateiname"
264 |
265 | #: post_office/models.py:339
266 | #, fuzzy
267 | #| msgctxt "Email addresses"
268 | #| msgid "Emails"
269 | msgid "Emails"
270 | msgstr "E-Mails"
271 |
272 | #: post_office/models.py:345
273 | msgid "Attachment"
274 | msgstr "Anhang"
275 |
276 | #: post_office/models.py:346
277 | msgid "Attachments"
278 | msgstr "Anhänge"
279 |
280 | #: post_office/sanitizer.py:8
281 | msgid "Install 'bleach' to render HTML properly."
282 | msgstr "Installieren Sie 'bleach', um HTML korrekt darzustellen."
283 |
284 | #: post_office/templates/admin/post_office/email/change_form.html:4
285 | msgid "Resend"
286 | msgstr "Erneut senden"
287 |
288 | #~ msgid "Email addresses"
289 | #~ msgstr "E-Mail Adressen"
290 |
--------------------------------------------------------------------------------
/post_office/locale/pl/LC_MESSAGES/django.po:
--------------------------------------------------------------------------------
1 | # SOME DESCRIPTIVE TITLE.
2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3 | # This file is distributed under the same license as the PACKAGE package.
4 | # FIRST AUTHOR , YEAR.
5 | #
6 | #, fuzzy
7 | msgid ""
8 | msgstr ""
9 | "Project-Id-Version: PACKAGE VERSION\n"
10 | "Report-Msgid-Bugs-To: \n"
11 | "POT-Creation-Date: 2024-11-27 16:59+0400\n"
12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
13 | "Last-Translator: FULL NAME \n"
14 | "Language-Team: LANGUAGE \n"
15 | "Language: \n"
16 | "MIME-Version: 1.0\n"
17 | "Content-Type: text/plain; charset=UTF-8\n"
18 | "Content-Transfer-Encoding: 8bit\n"
19 | "Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 "
20 | "|| n%100>=20) ? 1 : 2);\n"
21 |
22 | #: post_office/admin.py:131
23 | msgid "To"
24 | msgstr "Do"
25 |
26 | #: post_office/admin.py:151 post_office/admin.py:188 post_office/models.py:52
27 | #: post_office/models.py:277
28 | msgid "Subject"
29 | msgstr "Temat"
30 |
31 | #: post_office/admin.py:157
32 | #, fuzzy
33 | #| msgid "Email Template"
34 | msgid "Use Template"
35 | msgstr "Szablon emaila"
36 |
37 | #: post_office/admin.py:176
38 | #, fuzzy
39 | #| msgctxt "Email address"
40 | #| msgid "Email"
41 | msgid "HTML Email"
42 | msgstr "Email HTML"
43 |
44 | #: post_office/admin.py:178 post_office/admin.py:180
45 | #, fuzzy
46 | #| msgctxt "Email address"
47 | #| msgid "Email"
48 | msgid "Text Email"
49 | msgstr "Email tekstowy"
50 |
51 | #: post_office/admin.py:195
52 | msgid "Mail Body"
53 | msgstr "Treść wiadomości"
54 |
55 | #: post_office/admin.py:206
56 | msgid "HTML Body"
57 | msgstr "Treść HTML"
58 |
59 | #: post_office/admin.py:243
60 | #, python-brace-format
61 | msgid "Duplicate template for language '{language}'."
62 | msgstr "Duplikat szablonu dla języka '{language}'."
63 |
64 | #: post_office/admin.py:253 post_office/models.py:283
65 | msgid "Language"
66 | msgstr "Język"
67 |
68 | #: post_office/admin.py:254 post_office/models.py:284
69 | msgid "Render template in alternative language"
70 | msgstr "Wygeneruj szablon w alternatywnym języku"
71 |
72 | #: post_office/admin.py:286
73 | msgid "Default Content"
74 | msgstr "Domyślna zawartość"
75 |
76 | #: post_office/admin.py:297 post_office/models.py:273
77 | msgid "Description"
78 | msgstr "Opis"
79 |
80 | #: post_office/admin.py:304
81 | msgid "Languages"
82 | msgstr "Języki"
83 |
84 | #: post_office/apps.py:7
85 | msgid "Post Office"
86 | msgstr "Poczta"
87 |
88 | #: post_office/fields.py:9
89 | msgid "Comma-separated emails"
90 | msgstr "Oddzielone przecinkami emaile"
91 |
92 | #: post_office/fields.py:18
93 | msgid "Only comma separated emails are allowed."
94 | msgstr "Tylko oddzielone przecinkami emaile są dozwolone."
95 |
96 | #: post_office/models.py:36
97 | msgid "low"
98 | msgstr "niski"
99 |
100 | #: post_office/models.py:37
101 | msgid "medium"
102 | msgstr "średni"
103 |
104 | #: post_office/models.py:38
105 | msgid "high"
106 | msgstr "wysoki"
107 |
108 | #: post_office/models.py:39
109 | msgid "now"
110 | msgstr "natychmiastowy"
111 |
112 | #: post_office/models.py:42 post_office/models.py:243
113 | msgid "sent"
114 | msgstr "wysłany"
115 |
116 | #: post_office/models.py:43 post_office/models.py:243
117 | msgid "failed"
118 | msgstr "odrzucony"
119 |
120 | #: post_office/models.py:44
121 | msgid "queued"
122 | msgstr "w kolejce"
123 |
124 | #: post_office/models.py:45
125 | #, fuzzy
126 | #| msgid "queued"
127 | msgid "requeued"
128 | msgstr "ponownie w kolejce"
129 |
130 | #: post_office/models.py:48
131 | msgid "Email From"
132 | msgstr "Email od"
133 |
134 | #: post_office/models.py:49
135 | msgid "Email To"
136 | msgstr "Email do"
137 |
138 | #: post_office/models.py:50
139 | msgid "Cc"
140 | msgstr "Cc"
141 |
142 | #: post_office/models.py:51
143 | msgid "Bcc"
144 | msgstr "Ukryta kopia"
145 |
146 | #: post_office/models.py:53 post_office/models.py:251
147 | msgid "Message"
148 | msgstr "Wiadomość"
149 |
150 | #: post_office/models.py:54
151 | msgid "HTML Message"
152 | msgstr "Wiadomość w HTML"
153 |
154 | #: post_office/models.py:60 post_office/models.py:249
155 | msgid "Status"
156 | msgstr "Status"
157 |
158 | #: post_office/models.py:61
159 | msgid "Priority"
160 | msgstr "Priorytet"
161 |
162 | #: post_office/models.py:65
163 | #, fuzzy
164 | #| msgid "The scheduled sending time"
165 | msgid "Scheduled Time"
166 | msgstr "Zaplanowany czas wysłania"
167 |
168 | #: post_office/models.py:65
169 | msgid "The scheduled sending time"
170 | msgstr "Zaplanowany czas wysłania"
171 |
172 | #: post_office/models.py:68
173 | msgid "Expires"
174 | msgstr "Wygasa"
175 |
176 | #: post_office/models.py:68
177 | msgid "Email won't be sent after this timestamp"
178 | msgstr "Email nie zostanie wysłany po tym terminie"
179 |
180 | #: post_office/models.py:72 post_office/models.py:341
181 | msgid "Headers"
182 | msgstr "Nagłówki"
183 |
184 | #: post_office/models.py:74
185 | msgid "Email template"
186 | msgstr "Szablon emaila"
187 |
188 | #: post_office/models.py:76
189 | msgid "Context"
190 | msgstr "Kontekst"
191 |
192 | #: post_office/models.py:77
193 | msgid "Backend alias"
194 | msgstr "Backend alias"
195 |
196 | #: post_office/models.py:81
197 | msgctxt "Email address"
198 | msgid "Email"
199 | msgstr "Email"
200 |
201 | #: post_office/models.py:82
202 | msgctxt "Email addresses"
203 | msgid "Emails"
204 | msgstr "Emaile"
205 |
206 | #: post_office/models.py:231
207 | msgid "The scheduled time may not be later than the expires time."
208 | msgstr "Zaplanowany czas nie może być późniejszy niż czas wygaśnięcia."
209 |
210 | #: post_office/models.py:246
211 | msgid "Email address"
212 | msgstr "Adres email"
213 |
214 | #: post_office/models.py:250
215 | msgid "Exception type"
216 | msgstr "Typ wyjątku"
217 |
218 | #: post_office/models.py:255
219 | msgid "Log"
220 | msgstr "Log"
221 |
222 | #: post_office/models.py:256
223 | msgid "Logs"
224 | msgstr "Logi"
225 |
226 | #: post_office/models.py:272 post_office/models.py:338
227 | msgid "Name"
228 | msgstr "Nazwa"
229 |
230 | #: post_office/models.py:272
231 | msgid "e.g: 'welcome_email'"
232 | msgstr "np: 'powitalny_email'"
233 |
234 | #: post_office/models.py:273
235 | msgid "Description of this template."
236 | msgstr "Opis tego szablonu"
237 |
238 | #: post_office/models.py:279
239 | msgid "Content"
240 | msgstr "Zawartość"
241 |
242 | #: post_office/models.py:280
243 | msgid "HTML content"
244 | msgstr "Zawartość w HTML"
245 |
246 | #: post_office/models.py:293
247 | msgid "Default template"
248 | msgstr "Domyślna zawartość"
249 |
250 | #: post_office/models.py:302
251 | msgid "Email Template"
252 | msgstr "Szablon emaila"
253 |
254 | #: post_office/models.py:303
255 | msgid "Email Templates"
256 | msgstr "Szablony emaili"
257 |
258 | #: post_office/models.py:337
259 | msgid "File"
260 | msgstr "Plik"
261 |
262 | #: post_office/models.py:338
263 | msgid "The original filename"
264 | msgstr "Oryginalna nazwa pliku"
265 |
266 | #: post_office/models.py:339
267 | #, fuzzy
268 | #| msgctxt "Email addresses"
269 | #| msgid "Emails"
270 | msgid "Emails"
271 | msgstr "Emaile"
272 |
273 | #: post_office/models.py:345
274 | msgid "Attachment"
275 | msgstr "Załącznik"
276 |
277 | #: post_office/models.py:346
278 | msgid "Attachments"
279 | msgstr "Załączniki"
280 |
281 | #: post_office/sanitizer.py:8
282 | msgid "Install 'bleach' to render HTML properly."
283 | msgstr "Zainstaluj 'bleach' aby poprawnie renderować HTML."
284 |
285 | #: post_office/templates/admin/post_office/email/change_form.html:4
286 | msgid "Resend"
287 | msgstr "Wyślij ponownie"
288 |
289 | #~ msgid "Email addresses"
290 | #~ msgstr "Adresy email"
291 |
--------------------------------------------------------------------------------
/post_office/locale/ru_RU/LC_MESSAGES/django.po:
--------------------------------------------------------------------------------
1 | # SOME DESCRIPTIVE TITLE.
2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3 | # This file is distributed under the same license as the PACKAGE package.
4 | # FIRST AUTHOR , YEAR.
5 | #
6 | msgid ""
7 | msgstr ""
8 | "Project-Id-Version: \n"
9 | "Report-Msgid-Bugs-To: \n"
10 | "POT-Creation-Date: 2024-11-27 16:59+0400\n"
11 | "PO-Revision-Date: 2017-08-07 16:49+0300\n"
12 | "Last-Translator: \n"
13 | "Language-Team: \n"
14 | "Language: ru_RU\n"
15 | "MIME-Version: 1.0\n"
16 | "Content-Type: text/plain; charset=UTF-8\n"
17 | "Content-Transfer-Encoding: 8bit\n"
18 | "Plural-Forms: nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
19 | "n%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || "
20 | "(n%100>=11 && n%100<=14)? 2 : 3);\n"
21 | "X-Generator: Poedit 1.8.11\n"
22 |
23 | #: post_office/admin.py:131
24 | msgid "To"
25 | msgstr "Кому"
26 |
27 | #: post_office/admin.py:151 post_office/admin.py:188 post_office/models.py:52
28 | #: post_office/models.py:277
29 | msgid "Subject"
30 | msgstr "Тема"
31 |
32 | #: post_office/admin.py:157
33 | #, fuzzy
34 | #| msgid "Email Template"
35 | msgid "Use Template"
36 | msgstr "Шаблон письма"
37 |
38 | #: post_office/admin.py:176
39 | #, fuzzy
40 | #| msgctxt "Email address"
41 | #| msgid "Email"
42 | msgid "HTML Email"
43 | msgstr "Письмо"
44 |
45 | #: post_office/admin.py:178 post_office/admin.py:180
46 | #, fuzzy
47 | #| msgctxt "Email address"
48 | #| msgid "Email"
49 | msgid "Text Email"
50 | msgstr "Письмо"
51 |
52 | #: post_office/admin.py:195
53 | msgid "Mail Body"
54 | msgstr "Текст письма"
55 |
56 | #: post_office/admin.py:206
57 | msgid "HTML Body"
58 | msgstr "HTML содержимое"
59 |
60 | #: post_office/admin.py:243
61 | #, python-brace-format
62 | msgid "Duplicate template for language '{language}'."
63 | msgstr "Дубликат шаблона для языка '{language}'."
64 |
65 | #: post_office/admin.py:253 post_office/models.py:283
66 | msgid "Language"
67 | msgstr "Язык"
68 |
69 | #: post_office/admin.py:254 post_office/models.py:284
70 | msgid "Render template in alternative language"
71 | msgstr "Отправить письмо на другом языке"
72 |
73 | #: post_office/admin.py:286
74 | msgid "Default Content"
75 | msgstr "Содержимое по умолчанию"
76 |
77 | #: post_office/admin.py:297 post_office/models.py:273
78 | msgid "Description"
79 | msgstr "Описание"
80 |
81 | #: post_office/admin.py:304
82 | msgid "Languages"
83 | msgstr "Языки"
84 |
85 | #: post_office/apps.py:7
86 | msgid "Post Office"
87 | msgstr "Менеджер почты"
88 |
89 | #: post_office/fields.py:9
90 | msgid "Comma-separated emails"
91 | msgstr "Список адресов, разделенных запятыми"
92 |
93 | #: post_office/fields.py:18
94 | msgid "Only comma separated emails are allowed."
95 | msgstr "Разрешен только разделенный запятыми список адресов."
96 |
97 | #: post_office/models.py:36
98 | msgid "low"
99 | msgstr "низкий"
100 |
101 | #: post_office/models.py:37
102 | msgid "medium"
103 | msgstr "средний"
104 |
105 | #: post_office/models.py:38
106 | msgid "high"
107 | msgstr "высокий"
108 |
109 | #: post_office/models.py:39
110 | msgid "now"
111 | msgstr "сейчас"
112 |
113 | #: post_office/models.py:42 post_office/models.py:243
114 | msgid "sent"
115 | msgstr "отправлен"
116 |
117 | #: post_office/models.py:43 post_office/models.py:243
118 | msgid "failed"
119 | msgstr "ошибка"
120 |
121 | #: post_office/models.py:44
122 | msgid "queued"
123 | msgstr "в очереди"
124 |
125 | #: post_office/models.py:45
126 | #, fuzzy
127 | #| msgid "queued"
128 | msgid "requeued"
129 | msgstr "в очереди"
130 |
131 | #: post_office/models.py:48
132 | msgid "Email From"
133 | msgstr "Отправитель"
134 |
135 | #: post_office/models.py:49
136 | msgid "Email To"
137 | msgstr "Получатель"
138 |
139 | #: post_office/models.py:50
140 | msgid "Cc"
141 | msgstr "Копия"
142 |
143 | #: post_office/models.py:51
144 | msgid "Bcc"
145 | msgstr "Скрытая копия"
146 |
147 | #: post_office/models.py:53 post_office/models.py:251
148 | msgid "Message"
149 | msgstr "Сообщение"
150 |
151 | #: post_office/models.py:54
152 | msgid "HTML Message"
153 | msgstr "HTML-сообщение"
154 |
155 | #: post_office/models.py:60 post_office/models.py:249
156 | msgid "Status"
157 | msgstr "Статус"
158 |
159 | #: post_office/models.py:61
160 | msgid "Priority"
161 | msgstr "Приоритет"
162 |
163 | #: post_office/models.py:65
164 | #, fuzzy
165 | #| msgid "The scheduled sending time"
166 | msgid "Scheduled Time"
167 | msgstr "Запланированное время"
168 |
169 | #: post_office/models.py:65
170 | msgid "The scheduled sending time"
171 | msgstr "Запланированное время отправки"
172 |
173 | #: post_office/models.py:68
174 | msgid "Expires"
175 | msgstr "Истекает"
176 |
177 | #: post_office/models.py:68
178 | msgid "Email won't be sent after this timestamp"
179 | msgstr "Письмо не будет отправлено после этой даты"
180 |
181 | #: post_office/models.py:72 post_office/models.py:341
182 | msgid "Headers"
183 | msgstr "Заголовки"
184 |
185 | #: post_office/models.py:74
186 | msgid "Email template"
187 | msgstr "Шаблон письма"
188 |
189 | #: post_office/models.py:76
190 | msgid "Context"
191 | msgstr "Контекст"
192 |
193 | #: post_office/models.py:77
194 | msgid "Backend alias"
195 | msgstr "Имя бекенда"
196 |
197 | #: post_office/models.py:81
198 | msgctxt "Email address"
199 | msgid "Email"
200 | msgstr "Письмо"
201 |
202 | #: post_office/models.py:82
203 | msgctxt "Email addresses"
204 | msgid "Emails"
205 | msgstr "Письма"
206 |
207 | #: post_office/models.py:231
208 | msgid "The scheduled time may not be later than the expires time."
209 | msgstr "Запланированное время не может быть позже времени истечения срока."
210 |
211 | #: post_office/models.py:246
212 | msgid "Email address"
213 | msgstr "Email-адрес"
214 |
215 | #: post_office/models.py:250
216 | msgid "Exception type"
217 | msgstr "Тип исключения"
218 |
219 | #: post_office/models.py:255
220 | msgid "Log"
221 | msgstr "Лог"
222 |
223 | #: post_office/models.py:256
224 | msgid "Logs"
225 | msgstr "Логи"
226 |
227 | #: post_office/models.py:272 post_office/models.py:338
228 | msgid "Name"
229 | msgstr "Имя"
230 |
231 | #: post_office/models.py:272
232 | msgid "e.g: 'welcome_email'"
233 | msgstr "например: 'welcome_email'"
234 |
235 | #: post_office/models.py:273
236 | msgid "Description of this template."
237 | msgstr "Описание шаблона."
238 |
239 | #: post_office/models.py:279
240 | msgid "Content"
241 | msgstr "Содержимое"
242 |
243 | #: post_office/models.py:280
244 | msgid "HTML content"
245 | msgstr "HTML-содержимое"
246 |
247 | #: post_office/models.py:293
248 | msgid "Default template"
249 | msgstr "Шаблон по умолчанию"
250 |
251 | #: post_office/models.py:302
252 | msgid "Email Template"
253 | msgstr "Шаблон письма"
254 |
255 | #: post_office/models.py:303
256 | msgid "Email Templates"
257 | msgstr "Шаблоны писем"
258 |
259 | #: post_office/models.py:337
260 | msgid "File"
261 | msgstr "Файл"
262 |
263 | #: post_office/models.py:338
264 | msgid "The original filename"
265 | msgstr "Исходное имя файла"
266 |
267 | #: post_office/models.py:339
268 | #, fuzzy
269 | #| msgctxt "Email addresses"
270 | #| msgid "Emails"
271 | msgid "Emails"
272 | msgstr "Письма"
273 |
274 | #: post_office/models.py:345
275 | msgid "Attachment"
276 | msgstr "Вложение"
277 |
278 | #: post_office/models.py:346
279 | msgid "Attachments"
280 | msgstr "Вложения"
281 |
282 | #: post_office/sanitizer.py:8
283 | msgid "Install 'bleach' to render HTML properly."
284 | msgstr "Установите 'bleach' для корректного отображения HTML."
285 |
286 | #: post_office/templates/admin/post_office/email/change_form.html:4
287 | msgid "Resend"
288 | msgstr "Отправить повторно"
289 |
290 | #~ msgid "Email addresses"
291 | #~ msgstr "Email адреса"
292 |
--------------------------------------------------------------------------------
/post_office/locale/es/LC_MESSAGES/django.po:
--------------------------------------------------------------------------------
1 | # SOME DESCRIPTIVE TITLE.
2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3 | # This file is distributed under the same license as the PACKAGE package.
4 | # FIRST AUTHOR , YEAR.
5 | #
6 | #, fuzzy
7 | msgid ""
8 | msgstr ""
9 | "Project-Id-Version: PACKAGE VERSION\n"
10 | "Report-Msgid-Bugs-To: \n"
11 | "POT-Creation-Date: 2024-11-27 16:59+0400\n"
12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
13 | "Last-Translator: Antonio Hinojo \n"
14 | "Language-Team: Antonio Hinojo \n"
15 | "Language: \n"
16 | "MIME-Version: 1.0\n"
17 | "Content-Type: text/plain; charset=UTF-8\n"
18 | "Content-Transfer-Encoding: 8bit\n"
19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n"
20 |
21 | #: post_office/admin.py:131
22 | msgid "To"
23 | msgstr "Para"
24 |
25 | #: post_office/admin.py:151 post_office/admin.py:188 post_office/models.py:52
26 | #: post_office/models.py:277
27 | msgid "Subject"
28 | msgstr "Asunto"
29 |
30 | #: post_office/admin.py:157
31 | #, fuzzy
32 | #| msgid "Email Template"
33 | msgid "Use Template"
34 | msgstr "Usar plantilla"
35 |
36 | #: post_office/admin.py:176
37 | msgid "HTML Email"
38 | msgstr "Correo HTML"
39 |
40 | #: post_office/admin.py:178 post_office/admin.py:180
41 | msgid "Text Email"
42 | msgstr "Correo de texto"
43 |
44 | #: post_office/admin.py:195
45 | msgid "Mail Body"
46 | msgstr "Cuerpo del correo"
47 |
48 | #: post_office/admin.py:206
49 | msgid "HTML Body"
50 | msgstr "Cuerpo HTML"
51 |
52 | #: post_office/admin.py:243
53 | #, python-brace-format
54 | msgid "Duplicate template for language '{language}'."
55 | msgstr "Template duplicada para el idioma '{language}'."
56 |
57 | #: post_office/admin.py:253 post_office/models.py:283
58 | #, fuzzy
59 | #| msgid "Languages"
60 | msgid "Language"
61 | msgstr "Idiomas"
62 |
63 | #: post_office/admin.py:254 post_office/models.py:284
64 | msgid "Render template in alternative language"
65 | msgstr "Renderizar la template en idioma alternativo"
66 |
67 | #: post_office/admin.py:286
68 | msgid "Default Content"
69 | msgstr "Contenido por defecto"
70 |
71 | #: post_office/admin.py:297 post_office/models.py:273
72 | msgid "Description"
73 | msgstr "Descripción"
74 |
75 | #: post_office/admin.py:304
76 | msgid "Languages"
77 | msgstr "Idiomas"
78 |
79 | #: post_office/apps.py:7
80 | msgid "Post Office"
81 | msgstr "Oficina de Correos"
82 |
83 | #: post_office/fields.py:9
84 | msgid "Comma-separated emails"
85 | msgstr "Emails separados por comas"
86 |
87 | #: post_office/fields.py:18
88 | msgid "Only comma separated emails are allowed."
89 | msgstr "Sólo están permitidos emails separados por coma."
90 |
91 | #: post_office/models.py:36
92 | msgid "low"
93 | msgstr "baja"
94 |
95 | #: post_office/models.py:37
96 | msgid "medium"
97 | msgstr "media"
98 |
99 | #: post_office/models.py:38
100 | msgid "high"
101 | msgstr "alta"
102 |
103 | #: post_office/models.py:39
104 | msgid "now"
105 | msgstr "ahora"
106 |
107 | #: post_office/models.py:42 post_office/models.py:243
108 | msgid "sent"
109 | msgstr "enviado"
110 |
111 | #: post_office/models.py:43 post_office/models.py:243
112 | msgid "failed"
113 | msgstr "falló"
114 |
115 | #: post_office/models.py:44
116 | msgid "queued"
117 | msgstr "encolado"
118 |
119 | #: post_office/models.py:45
120 | #, fuzzy
121 | #| msgid "queued"
122 | msgid "requeued"
123 | msgstr "reencolado"
124 |
125 | #: post_office/models.py:48
126 | msgid "Email From"
127 | msgstr "Remitente"
128 |
129 | #: post_office/models.py:49
130 | msgid "Email To"
131 | msgstr "Destinatario"
132 |
133 | #: post_office/models.py:50
134 | msgid "Cc"
135 | msgstr "Copia"
136 |
137 | #: post_office/models.py:51
138 | msgid "Bcc"
139 | msgstr "Bcc"
140 |
141 | #: post_office/models.py:53 post_office/models.py:251
142 | msgid "Message"
143 | msgstr "Mensaje"
144 |
145 | #: post_office/models.py:54
146 | msgid "HTML Message"
147 | msgstr "Mensaje en HTML"
148 |
149 | #: post_office/models.py:60 post_office/models.py:249
150 | msgid "Status"
151 | msgstr "Estado"
152 |
153 | #: post_office/models.py:61
154 | msgid "Priority"
155 | msgstr "Prioridad"
156 |
157 | #: post_office/models.py:65
158 | msgid "Scheduled Time"
159 | msgstr "Tiempo programado"
160 |
161 | #: post_office/models.py:65
162 | msgid "The scheduled sending time"
163 | msgstr "El tiempo programado para el envío"
164 |
165 | #: post_office/models.py:68
166 | msgid "Expires"
167 | msgstr "Expira"
168 |
169 | #: post_office/models.py:68
170 | msgid "Email won't be sent after this timestamp"
171 | msgstr "El correo no se enviará después de esta fecha"
172 |
173 | #: post_office/models.py:72 post_office/models.py:341
174 | msgid "Headers"
175 | msgstr "Encabezados"
176 |
177 | #: post_office/models.py:74
178 | #, fuzzy
179 | #| msgid "Email Template"
180 | msgid "Email template"
181 | msgstr "Template de correo"
182 |
183 | #: post_office/models.py:76
184 | #, fuzzy
185 | #| msgid "Content"
186 | msgid "Context"
187 | msgstr "Contexto"
188 |
189 | #: post_office/models.py:77
190 | msgid "Backend alias"
191 | msgstr "Alias del backend"
192 |
193 | #: post_office/models.py:81
194 | #, fuzzy
195 | #| msgid "Email To"
196 | msgctxt "Email address"
197 | msgid "Email"
198 | msgstr "Destinatario"
199 |
200 | #: post_office/models.py:82
201 | #, fuzzy
202 | #| msgid "Email To"
203 | msgctxt "Email addresses"
204 | msgid "Emails"
205 | msgstr "Destinatario"
206 |
207 | #: post_office/models.py:231
208 | msgid "The scheduled time may not be later than the expires time."
209 | msgstr "El tiempo programado no puede ser posterior al tiempo de expiración."
210 |
211 | #: post_office/models.py:246
212 | #, fuzzy
213 | #| msgid "Email Templates"
214 | msgid "Email address"
215 | msgstr "Templates de correos"
216 |
217 | #: post_office/models.py:250
218 | msgid "Exception type"
219 | msgstr "Tipo de excepción"
220 |
221 | #: post_office/models.py:255
222 | msgid "Log"
223 | msgstr "Registro"
224 |
225 | #: post_office/models.py:256
226 | msgid "Logs"
227 | msgstr "Registros"
228 |
229 | #: post_office/models.py:272 post_office/models.py:338
230 | msgid "Name"
231 | msgstr "Nombre"
232 |
233 | #: post_office/models.py:272
234 | msgid "e.g: 'welcome_email'"
235 | msgstr "per ej. 'email_bienvenida'"
236 |
237 | #: post_office/models.py:273
238 | msgid "Description of this template."
239 | msgstr "Descripción de esta template."
240 |
241 | #: post_office/models.py:279
242 | msgid "Content"
243 | msgstr "Contenido"
244 |
245 | #: post_office/models.py:280
246 | msgid "HTML content"
247 | msgstr "Contenido en HTML"
248 |
249 | #: post_office/models.py:293
250 | #, fuzzy
251 | #| msgid "Default Content"
252 | msgid "Default template"
253 | msgstr "Contenido por defecto"
254 |
255 | #: post_office/models.py:302
256 | msgid "Email Template"
257 | msgstr "Template de correo"
258 |
259 | #: post_office/models.py:303
260 | msgid "Email Templates"
261 | msgstr "Templates de correos"
262 |
263 | #: post_office/models.py:337
264 | msgid "File"
265 | msgstr "Archivo"
266 |
267 | #: post_office/models.py:338
268 | msgid "The original filename"
269 | msgstr "Nombre del archivo original"
270 |
271 | #: post_office/models.py:339
272 | #, fuzzy
273 | #| msgid "Email To"
274 | msgid "Emails"
275 | msgstr "Destinatario"
276 |
277 | #: post_office/models.py:345
278 | msgid "Attachment"
279 | msgstr "Adjunto"
280 |
281 | #: post_office/models.py:346
282 | msgid "Attachments"
283 | msgstr "Adjuntos"
284 |
285 | #: post_office/sanitizer.py:8
286 | msgid "Install 'bleach' to render HTML properly."
287 | msgstr "Instale 'bleach' para renderizar HTML correctamente."
288 |
289 | #: post_office/templates/admin/post_office/email/change_form.html:4
290 | msgid "Resend"
291 | msgstr "Reenviar"
292 |
--------------------------------------------------------------------------------
/post_office/migrations/0002_add_i18n_and_backend_alias.py:
--------------------------------------------------------------------------------
1 | from django.db import models, migrations
2 | import post_office.validators
3 | import post_office.fields
4 |
5 |
6 | class Migration(migrations.Migration):
7 | dependencies = [
8 | ('post_office', '0001_initial'),
9 | ]
10 |
11 | operations = [
12 | migrations.AlterModelOptions(
13 | name='emailtemplate',
14 | options={'verbose_name': 'Email Template', 'verbose_name_plural': 'Email Templates'},
15 | ),
16 | migrations.AddField(
17 | model_name='email',
18 | name='backend_alias',
19 | field=models.CharField(default='', max_length=64, blank=True),
20 | ),
21 | migrations.AddField(
22 | model_name='emailtemplate',
23 | name='default_template',
24 | field=models.ForeignKey(
25 | related_name='translated_templates',
26 | default=None,
27 | to='post_office.EmailTemplate',
28 | null=True,
29 | on_delete=models.deletion.SET_NULL,
30 | ),
31 | ),
32 | migrations.AddField(
33 | model_name='emailtemplate',
34 | name='language',
35 | field=models.CharField(
36 | default='',
37 | help_text='Render template in alternative language',
38 | max_length=12,
39 | blank=True,
40 | choices=[
41 | ('af', 'Afrikaans'),
42 | ('ar', 'Arabic'),
43 | ('ast', 'Asturian'),
44 | ('az', 'Azerbaijani'),
45 | ('bg', 'Bulgarian'),
46 | ('be', 'Belarusian'),
47 | ('bn', 'Bengali'),
48 | ('br', 'Breton'),
49 | ('bs', 'Bosnian'),
50 | ('ca', 'Catalan'),
51 | ('cs', 'Czech'),
52 | ('cy', 'Welsh'),
53 | ('da', 'Danish'),
54 | ('de', 'German'),
55 | ('el', 'Greek'),
56 | ('en', 'English'),
57 | ('en-au', 'Australian English'),
58 | ('en-gb', 'British English'),
59 | ('eo', 'Esperanto'),
60 | ('es', 'Spanish'),
61 | ('es-ar', 'Argentinian Spanish'),
62 | ('es-mx', 'Mexican Spanish'),
63 | ('es-ni', 'Nicaraguan Spanish'),
64 | ('es-ve', 'Venezuelan Spanish'),
65 | ('et', 'Estonian'),
66 | ('eu', 'Basque'),
67 | ('fa', 'Persian'),
68 | ('fi', 'Finnish'),
69 | ('fr', 'French'),
70 | ('fy', 'Frisian'),
71 | ('ga', 'Irish'),
72 | ('gl', 'Galician'),
73 | ('he', 'Hebrew'),
74 | ('hi', 'Hindi'),
75 | ('hr', 'Croatian'),
76 | ('hu', 'Hungarian'),
77 | ('ia', 'Interlingua'),
78 | ('id', 'Indonesian'),
79 | ('io', 'Ido'),
80 | ('is', 'Icelandic'),
81 | ('it', 'Italian'),
82 | ('ja', 'Japanese'),
83 | ('ka', 'Georgian'),
84 | ('kk', 'Kazakh'),
85 | ('km', 'Khmer'),
86 | ('kn', 'Kannada'),
87 | ('ko', 'Korean'),
88 | ('lb', 'Luxembourgish'),
89 | ('lt', 'Lithuanian'),
90 | ('lv', 'Latvian'),
91 | ('mk', 'Macedonian'),
92 | ('ml', 'Malayalam'),
93 | ('mn', 'Mongolian'),
94 | ('mr', 'Marathi'),
95 | ('my', 'Burmese'),
96 | ('nb', 'Norwegian Bokmal'),
97 | ('ne', 'Nepali'),
98 | ('nl', 'Dutch'),
99 | ('nn', 'Norwegian Nynorsk'),
100 | ('os', 'Ossetic'),
101 | ('pa', 'Punjabi'),
102 | ('pl', 'Polish'),
103 | ('pt', 'Portuguese'),
104 | ('pt-br', 'Brazilian Portuguese'),
105 | ('ro', 'Romanian'),
106 | ('ru', 'Russian'),
107 | ('sk', 'Slovak'),
108 | ('sl', 'Slovenian'),
109 | ('sq', 'Albanian'),
110 | ('sr', 'Serbian'),
111 | ('sr-latn', 'Serbian Latin'),
112 | ('sv', 'Swedish'),
113 | ('sw', 'Swahili'),
114 | ('ta', 'Tamil'),
115 | ('te', 'Telugu'),
116 | ('th', 'Thai'),
117 | ('tr', 'Turkish'),
118 | ('tt', 'Tatar'),
119 | ('udm', 'Udmurt'),
120 | ('uk', 'Ukrainian'),
121 | ('ur', 'Urdu'),
122 | ('vi', 'Vietnamese'),
123 | ('zh-cn', 'Simplified Chinese'),
124 | ('zh-hans', 'Simplified Chinese'),
125 | ('zh-hant', 'Traditional Chinese'),
126 | ('zh-tw', 'Traditional Chinese'),
127 | ],
128 | ),
129 | ),
130 | migrations.AlterField(
131 | model_name='email',
132 | name='bcc',
133 | field=post_office.fields.CommaSeparatedEmailField(verbose_name='Bcc', blank=True),
134 | ),
135 | migrations.AlterField(
136 | model_name='email',
137 | name='cc',
138 | field=post_office.fields.CommaSeparatedEmailField(verbose_name='Cc', blank=True),
139 | ),
140 | migrations.AlterField(
141 | model_name='email',
142 | name='from_email',
143 | field=models.CharField(
144 | max_length=254, verbose_name='Email From', validators=[post_office.validators.validate_email_with_name]
145 | ),
146 | ),
147 | migrations.AlterField(
148 | model_name='email',
149 | name='html_message',
150 | field=models.TextField(verbose_name='HTML Message', blank=True),
151 | ),
152 | migrations.AlterField(
153 | model_name='email',
154 | name='message',
155 | field=models.TextField(verbose_name='Message', blank=True),
156 | ),
157 | migrations.AlterField(
158 | model_name='email',
159 | name='subject',
160 | field=models.CharField(max_length=255, verbose_name='Subject', blank=True),
161 | ),
162 | migrations.AlterField(
163 | model_name='email',
164 | name='to',
165 | field=post_office.fields.CommaSeparatedEmailField(verbose_name='Email To', blank=True),
166 | ),
167 | migrations.AlterField(
168 | model_name='emailtemplate',
169 | name='content',
170 | field=models.TextField(
171 | blank=True, verbose_name='Content', validators=[post_office.validators.validate_template_syntax]
172 | ),
173 | ),
174 | migrations.AlterField(
175 | model_name='emailtemplate',
176 | name='html_content',
177 | field=models.TextField(
178 | blank=True, verbose_name='HTML content', validators=[post_office.validators.validate_template_syntax]
179 | ),
180 | ),
181 | migrations.AlterField(
182 | model_name='emailtemplate',
183 | name='subject',
184 | field=models.CharField(
185 | blank=True,
186 | max_length=255,
187 | verbose_name='Subject',
188 | validators=[post_office.validators.validate_template_syntax],
189 | ),
190 | ),
191 | migrations.AlterUniqueTogether(
192 | name='emailtemplate',
193 | unique_together={('language', 'default_template')},
194 | ),
195 | ]
196 |
--------------------------------------------------------------------------------
/post_office/tests/test_backends.py:
--------------------------------------------------------------------------------
1 | import os
2 | from email.mime.image import MIMEImage
3 | from unittest import mock
4 |
5 | from django.conf import settings
6 | from django.core.files.images import File
7 | from django.core.mail import EmailMultiAlternatives, send_mail, EmailMessage
8 | from django.core.mail.backends.base import BaseEmailBackend
9 | from django.test import TestCase
10 | from django.test.utils import override_settings
11 |
12 | from ..models import Email, STATUS, PRIORITY
13 | from ..settings import get_backend
14 |
15 |
16 | class ErrorRaisingBackend(BaseEmailBackend):
17 | """
18 | An EmailBackend that always raises an error during sending
19 | to test if django_mailer handles sending error correctly
20 | """
21 |
22 | def send_messages(self, email_messages):
23 | raise Exception('Fake Error')
24 |
25 |
26 | class BackendTest(TestCase):
27 | @override_settings(EMAIL_BACKEND='post_office.EmailBackend')
28 | def test_email_backend(self):
29 | """
30 | Ensure that email backend properly queue email messages.
31 | """
32 | send_mail('Test', 'Message', 'from@example.com', ['to@example.com'])
33 | email = Email.objects.latest('id')
34 | self.assertEqual(email.subject, 'Test')
35 | self.assertEqual(email.status, STATUS.queued)
36 | self.assertEqual(email.priority, PRIORITY.medium)
37 |
38 | def test_email_backend_setting(self):
39 | """ """
40 | old_email_backend = getattr(settings, 'EMAIL_BACKEND', None)
41 | old_post_office_backend = getattr(settings, 'POST_OFFICE_BACKEND', None)
42 | if hasattr(settings, 'EMAIL_BACKEND'):
43 | delattr(settings, 'EMAIL_BACKEND')
44 | if hasattr(settings, 'POST_OFFICE_BACKEND'):
45 | delattr(settings, 'POST_OFFICE_BACKEND')
46 |
47 | previous_settings = settings.POST_OFFICE
48 | delattr(settings, 'POST_OFFICE')
49 | # If no email backend is set, backend should default to SMTP
50 | self.assertEqual(get_backend(), 'django.core.mail.backends.smtp.EmailBackend')
51 |
52 | # If EMAIL_BACKEND is set to PostOfficeBackend, use SMTP to send by default
53 | setattr(settings, 'EMAIL_BACKEND', 'post_office.EmailBackend')
54 | self.assertEqual(get_backend(), 'django.core.mail.backends.smtp.EmailBackend')
55 |
56 | # If EMAIL_BACKEND is set on new dictionary-styled settings, use that
57 | setattr(settings, 'POST_OFFICE', {'EMAIL_BACKEND': 'test'})
58 | self.assertEqual(get_backend(), 'test')
59 | delattr(settings, 'POST_OFFICE')
60 |
61 | if old_email_backend:
62 | setattr(settings, 'EMAIL_BACKEND', old_email_backend)
63 | else:
64 | delattr(settings, 'EMAIL_BACKEND')
65 | setattr(settings, 'POST_OFFICE', previous_settings)
66 |
67 | @override_settings(EMAIL_BACKEND='post_office.EmailBackend')
68 | def test_sending_html_email(self):
69 | """
70 | "text/html" attachments to Email should be persisted into the database
71 | """
72 | message = EmailMultiAlternatives('subject', 'body', 'from@example.com', ['recipient@example.com'])
73 | message.attach_alternative('html', 'text/html')
74 | message.send()
75 | email = Email.objects.latest('id')
76 | self.assertEqual(email.html_message, 'html')
77 |
78 | @override_settings(EMAIL_BACKEND='post_office.EmailBackend')
79 | def test_headers_sent(self):
80 | """
81 | Test that headers are correctly set on the outgoing emails.
82 | """
83 | message = EmailMessage(
84 | 'subject', 'body', 'from@example.com', ['recipient@example.com'], headers={'Reply-To': 'reply@example.com'}
85 | )
86 | message.send()
87 | email = Email.objects.latest('id')
88 | self.assertEqual(email.headers, {'Reply-To': 'reply@example.com'})
89 |
90 | @override_settings(EMAIL_BACKEND='post_office.EmailBackend')
91 | def test_reply_to_added_as_header(self):
92 | """
93 | Test that 'Reply-To' headers are correctly set on the outgoing emails,
94 | when EmailMessage property reply_to is set.
95 | """
96 | message = EmailMessage(
97 | 'subject',
98 | 'body',
99 | 'from@example.com',
100 | ['recipient@example.com'],
101 | reply_to=[
102 | 'replyto@example.com',
103 | ],
104 | )
105 | message.send()
106 | email = Email.objects.latest('id')
107 | self.assertEqual(email.headers, {'Reply-To': 'replyto@example.com'})
108 |
109 | @override_settings(EMAIL_BACKEND='post_office.EmailBackend')
110 | def test_reply_to_favors_explict_header(self):
111 | """
112 | Test that 'Reply-To' headers are correctly set, when reply_to property of
113 | the message object is set and "Reply-To" is also set explictly as a header.
114 | Then the explicit header value is favored over the message property reply_to,
115 | adopting the behaviour of message() in django.core.mail.message.EmailMessage.
116 | """
117 | message = EmailMessage(
118 | 'subject',
119 | 'body',
120 | 'from@example.com',
121 | ['recipient@example.com'],
122 | reply_to=['replyto-from-property@example.com'],
123 | headers={'Reply-To': 'replyto-from-header@example.com'},
124 | )
125 | message.send()
126 | email = Email.objects.latest('id')
127 | self.assertEqual(email.headers, {'Reply-To': 'replyto-from-header@example.com'})
128 |
129 | @override_settings(EMAIL_BACKEND='post_office.EmailBackend')
130 | def test_backend_attachments(self):
131 | message = EmailMessage('subject', 'body', 'from@example.com', ['recipient@example.com'])
132 |
133 | message.attach('attachment.txt', b'attachment content')
134 | message.send()
135 |
136 | email = Email.objects.latest('id')
137 | self.assertEqual(email.attachments.count(), 1)
138 | self.assertEqual(email.attachments.all()[0].name, 'attachment.txt')
139 | self.assertEqual(email.attachments.all()[0].file.read(), b'attachment content')
140 |
141 | @override_settings(EMAIL_BACKEND='post_office.EmailBackend')
142 | def test_backend_image_attachments(self):
143 | message = EmailMessage('subject', 'body', 'from@example.com', ['recipient@example.com'])
144 |
145 | filename = os.path.join(os.path.dirname(__file__), 'static/dummy.png')
146 | fileobj = File(open(filename, 'rb'), name='dummy.png')
147 | image = MIMEImage(fileobj.read())
148 | image.add_header('Content-Disposition', 'inline', filename='dummy.png')
149 | image.add_header('Content-ID', '<{dummy.png}>')
150 | message.attach(image)
151 | message.send()
152 |
153 | email = Email.objects.latest('id')
154 | self.assertEqual(email.attachments.count(), 1)
155 | self.assertEqual(email.attachments.all()[0].name, 'dummy.png')
156 | self.assertEqual(email.attachments.all()[0].file.read(), image.get_payload().encode())
157 | self.assertEqual(email.attachments.all()[0].headers.get('Content-ID'), '<{dummy.png}>')
158 | self.assertEqual(email.attachments.all()[0].headers.get('Content-Disposition'), 'inline; filename="dummy.png"')
159 |
160 | @override_settings(
161 | EMAIL_BACKEND='post_office.EmailBackend',
162 | POST_OFFICE={
163 | 'DEFAULT_PRIORITY': 'now',
164 | 'BACKENDS': {'default': 'django.core.mail.backends.dummy.EmailBackend'},
165 | },
166 | )
167 | def test_default_priority_now(self):
168 | # If DEFAULT_PRIORITY is "now", mails should be sent right away
169 | num_sent = send_mail('Test', 'Message', 'from1@example.com', ['to@example.com'])
170 | email = Email.objects.latest('id')
171 | self.assertEqual(email.status, STATUS.sent)
172 | self.assertEqual(num_sent, 1)
173 |
174 | @override_settings(
175 | EMAIL_BACKEND='post_office.EmailBackend',
176 | POST_OFFICE={
177 | 'DEFAULT_PRIORITY': 'medium',
178 | 'BACKENDS': {'default': 'django.core.mail.backends.dummy.EmailBackend'},
179 | },
180 | )
181 | @mock.patch('post_office.signals.email_queued.send')
182 | def test_email_queued_signal(self, mock):
183 | # If DEFAULT_PRIORITY is not "now", the email_queued signal should be sent
184 | send_mail('Test', 'Message', 'from1@example.com', ['to@example.com'])
185 | email = Email.objects.latest('id')
186 | self.assertEqual(email.status, STATUS.queued)
187 | self.assertEqual(mock.call_count, 1)
188 |
--------------------------------------------------------------------------------
/post_office/tests/test_utils.py:
--------------------------------------------------------------------------------
1 | from django.core.files.base import ContentFile
2 | from django.core.exceptions import ValidationError
3 |
4 | from django.test import TestCase
5 | from django.test.utils import override_settings
6 |
7 | from ..models import Email, STATUS, PRIORITY, EmailTemplate, Attachment
8 | from ..utils import create_attachments, get_email_template, parse_emails, parse_priority, send_mail, split_emails
9 | from ..validators import validate_email_with_name, validate_comma_separated_emails
10 |
11 |
12 | @override_settings(EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend')
13 | class UtilsTest(TestCase):
14 | def test_mail_status(self):
15 | """
16 | Check that send_mail assigns the right status field to Email instances
17 | """
18 | send_mail('subject', 'message', 'from@example.com', ['to@example.com'], priority=PRIORITY.medium)
19 | email = Email.objects.latest('id')
20 | self.assertEqual(email.status, STATUS.queued)
21 |
22 | # Emails sent with "now" priority is sent right away
23 | send_mail('subject', 'message', 'from@example.com', ['to@example.com'], priority=PRIORITY.now)
24 | email = Email.objects.latest('id')
25 | self.assertEqual(email.status, STATUS.sent)
26 |
27 | def test_email_validator(self):
28 | # These should validate
29 | validate_email_with_name('email@example.com')
30 | validate_email_with_name('Alice Bob ')
31 | Email.objects.create(
32 | to=['to@example.com'],
33 | from_email='Alice ',
34 | subject='Test',
35 | message='Message',
36 | status=STATUS.sent,
37 | )
38 |
39 | # Should also support international domains
40 | validate_email_with_name('Alice Bob ')
41 |
42 | # These should raise ValidationError
43 | self.assertRaises(ValidationError, validate_email_with_name, 'invalid')
44 | self.assertRaises(ValidationError, validate_email_with_name, 'Al ')
45 | self.assertRaises(ValidationError, validate_email_with_name, 'Al <>')
46 |
47 | def test_comma_separated_email_list_validator(self):
48 | # These should validate
49 | validate_comma_separated_emails(['email@example.com'])
50 | validate_comma_separated_emails(['email@example.com', 'email2@example.com', 'email3@example.com'])
51 | validate_comma_separated_emails(['Alice Bob '])
52 |
53 | # Should also support international domains
54 | validate_comma_separated_emails(['email@example.co.id'])
55 |
56 | # These should raise ValidationError
57 | self.assertRaises(
58 | ValidationError, validate_comma_separated_emails, ['email@example.com', 'invalid_mail', 'email@example.com']
59 | )
60 |
61 | def test_get_template_email(self):
62 | # Sanity Check
63 | name = 'customer/happy-holidays'
64 | self.assertRaises(EmailTemplate.DoesNotExist, get_email_template, name)
65 | template = EmailTemplate.objects.create(name=name, content='test')
66 |
67 | # First query should hit database
68 | self.assertNumQueries(1, lambda: get_email_template(name))
69 | # Second query should hit cache instead
70 | self.assertNumQueries(0, lambda: get_email_template(name))
71 |
72 | # It should return the correct template
73 | self.assertEqual(template, get_email_template(name))
74 |
75 | # Repeat with language support
76 | template = EmailTemplate.objects.create(name=name, content='test', language='en')
77 | # First query should hit database
78 | self.assertNumQueries(1, lambda: get_email_template(name, 'en'))
79 | # Second query should hit cache instead
80 | self.assertNumQueries(0, lambda: get_email_template(name, 'en'))
81 |
82 | # It should return the correct template
83 | self.assertEqual(template, get_email_template(name, 'en'))
84 |
85 | def test_template_caching_settings(self):
86 | """Check if POST_OFFICE_CACHE and POST_OFFICE_TEMPLATE_CACHE understood
87 | correctly
88 | """
89 |
90 | def is_cache_used(suffix='', desired_cache=False):
91 | """Raise exception if real cache usage not equal to desired_cache value"""
92 | # to avoid cache cleaning - just create new template
93 | name = 'can_i/suport_cache_settings%s' % suffix
94 | self.assertRaises(EmailTemplate.DoesNotExist, get_email_template, name)
95 | EmailTemplate.objects.create(name=name, content='test')
96 |
97 | # First query should hit database anyway
98 | self.assertNumQueries(1, lambda: get_email_template(name))
99 | # Second query should hit cache instead only if we want it
100 | self.assertNumQueries(0 if desired_cache else 1, lambda: get_email_template(name))
101 | return
102 |
103 | # default - use cache
104 | is_cache_used(suffix='with_default_cache', desired_cache=True)
105 |
106 | # disable cache
107 | with self.settings(POST_OFFICE_CACHE=False):
108 | is_cache_used(suffix='cache_disabled_global', desired_cache=False)
109 | with self.settings(POST_OFFICE_TEMPLATE_CACHE=False):
110 | is_cache_used(suffix='cache_disabled_for_templates', desired_cache=False)
111 | with self.settings(POST_OFFICE_CACHE=True, POST_OFFICE_TEMPLATE_CACHE=False):
112 | is_cache_used(suffix='cache_disabled_for_templates_but_enabled_global', desired_cache=False)
113 | return
114 |
115 | def test_split_emails(self):
116 | """
117 | Check that split emails correctly divide email lists for multiprocessing
118 | """
119 | for i in range(225):
120 | Email.objects.create(from_email='from@example.com', to=['to@example.com'])
121 | expected_size = [57, 56, 56, 56]
122 | email_list = split_emails(Email.objects.all(), 4)
123 | self.assertEqual(expected_size, [len(emails) for emails in email_list])
124 |
125 | def test_create_attachments(self):
126 | attachments = create_attachments(
127 | {
128 | 'attachment_file1.txt': ContentFile('content'),
129 | 'attachment_file2.txt': ContentFile('content'),
130 | }
131 | )
132 |
133 | self.assertEqual(len(attachments), 2)
134 | self.assertIsInstance(attachments[0], Attachment)
135 | self.assertTrue(attachments[0].pk)
136 | self.assertEqual(attachments[0].file.read(), b'content')
137 | self.assertTrue(attachments[0].name.startswith('attachment_file'))
138 | self.assertEqual(attachments[0].mimetype, '')
139 |
140 | def test_create_attachments_with_mimetype(self):
141 | attachments = create_attachments(
142 | {
143 | 'attachment_file1.txt': {'file': ContentFile('content'), 'mimetype': 'text/plain'},
144 | 'attachment_file2.jpg': {'file': ContentFile('content'), 'mimetype': 'text/plain'},
145 | }
146 | )
147 |
148 | self.assertEqual(len(attachments), 2)
149 | self.assertIsInstance(attachments[0], Attachment)
150 | self.assertTrue(attachments[0].pk)
151 | self.assertEqual(attachments[0].file.read(), b'content')
152 | self.assertTrue(attachments[0].name.startswith('attachment_file'))
153 | self.assertEqual(attachments[0].mimetype, 'text/plain')
154 |
155 | def test_create_attachments_open_file(self):
156 | attachments = create_attachments({'attachment_file.py': __file__})
157 |
158 | self.assertEqual(len(attachments), 1)
159 | self.assertIsInstance(attachments[0], Attachment)
160 | self.assertTrue(attachments[0].pk)
161 | self.assertTrue(attachments[0].file.read())
162 | self.assertEqual(attachments[0].name, 'attachment_file.py')
163 | self.assertEqual(attachments[0].mimetype, '')
164 |
165 | def test_parse_priority(self):
166 | self.assertEqual(parse_priority('now'), PRIORITY.now)
167 | self.assertEqual(parse_priority('high'), PRIORITY.high)
168 | self.assertEqual(parse_priority('medium'), PRIORITY.medium)
169 | self.assertEqual(parse_priority('low'), PRIORITY.low)
170 |
171 | def test_parse_emails(self):
172 | # Converts a single email to list of email
173 | self.assertEqual(parse_emails('test@example.com'), ['test@example.com'])
174 |
175 | # None is converted into an empty list
176 | self.assertEqual(parse_emails(None), [])
177 |
178 | # Raises ValidationError if email is invalid
179 | self.assertRaises(ValidationError, parse_emails, 'invalid_email')
180 | self.assertRaises(ValidationError, parse_emails, ['invalid_email', 'test@example.com'])
181 |
--------------------------------------------------------------------------------
/post_office/tests/test_html_email.py:
--------------------------------------------------------------------------------
1 | import os
2 | from email.mime.image import MIMEImage
3 |
4 | from django.contrib.auth import get_user_model
5 | from django.core.mail import EmailMultiAlternatives
6 | from django.core.mail.message import SafeMIMEMultipart, SafeMIMEText
7 | from django.core.files.images import ImageFile
8 | from django.template.loader import get_template
9 | from django.test import Client, TestCase
10 | from django.test.utils import override_settings
11 | from django.urls import reverse
12 |
13 | from post_office.models import Email, EmailTemplate, STATUS
14 | from post_office.template import render_to_string
15 | from post_office.template.backends.post_office import PostOfficeTemplates
16 | from post_office.mail import send, send_queued
17 |
18 |
19 | class HTMLMailTest(TestCase):
20 | def test_text(self):
21 | template = get_template('hello.html', using='post_office')
22 | self.assertIsInstance(template.backend, PostOfficeTemplates)
23 | context = {'foo': 'Bar'}
24 | content = template.render(context)
25 | self.assertHTMLEqual(content, 'Bar ')
26 |
27 | def test_html(self):
28 | template = get_template('image.html', using='post_office')
29 | body = template.render({'imgsrc': 'dummy.png'})
30 | self.assertHTMLEqual(
31 | body,
32 | """
33 | Testing image attachments
34 |
35 | """,
36 | )
37 | subject = '[Django Post-Office unit tests] attached image'
38 | msg = EmailMultiAlternatives(subject, body, to=['john@example.com'])
39 | template.attach_related(msg)
40 | msg.content_subtype = 'html'
41 | self.assertEqual(msg.mixed_subtype, 'related')
42 | # this message can be send by email
43 | parts = msg.message().walk()
44 | part = next(parts)
45 | self.assertIsInstance(part, SafeMIMEMultipart)
46 | part = next(parts)
47 | self.assertIsInstance(part, SafeMIMEText)
48 | self.assertHTMLEqual(part.get_payload(), body)
49 | part = next(parts)
50 | self.assertIsInstance(part, MIMEImage)
51 | self.assertEqual(part.get_content_type(), 'image/png')
52 | self.assertEqual(part['Content-Disposition'], 'inline; filename="f5c66340b8af7dc946cd25d84fdf8c90"')
53 | self.assertEqual(part.get_content_disposition(), 'inline')
54 | self.assertEqual(part.get_filename(), 'f5c66340b8af7dc946cd25d84fdf8c90')
55 | self.assertEqual(part['Content-ID'], '')
56 |
57 | def test_mixed(self):
58 | body = 'Testing mixed text and html attachments'
59 | html, attached_images = render_to_string('image.html', {'imgsrc': 'dummy.png'}, using='post_office')
60 | subject = '[django-SHOP unit tests] attached image'
61 | msg = EmailMultiAlternatives(subject, body, to=['john@example.com'])
62 | msg.attach_alternative(html, 'text/html')
63 | for attachment in attached_images:
64 | msg.attach(attachment)
65 | msg.mixed_subtype = 'related'
66 | # this message can be send by email
67 | parts = msg.message().walk()
68 | part = next(parts)
69 | self.assertIsInstance(part, SafeMIMEMultipart)
70 | part = next(parts)
71 | self.assertIsInstance(part, SafeMIMEMultipart)
72 | part = next(parts)
73 | self.assertIsInstance(part, SafeMIMEText)
74 | self.assertEqual(part.get_content_type(), 'text/plain')
75 | self.assertHTMLEqual(part.get_payload(), body)
76 | part = next(parts)
77 | self.assertIsInstance(part, SafeMIMEText)
78 | self.assertEqual(part.get_content_type(), 'text/html')
79 | self.assertHTMLEqual(part.get_payload(), html)
80 | part = next(parts)
81 | self.assertIsInstance(part, MIMEImage)
82 | self.assertEqual(part.get_content_type(), 'image/png')
83 |
84 | def test_image(self):
85 | relfilename = 'static/dummy.png'
86 | filename = os.path.join(os.path.dirname(__file__), relfilename)
87 | imagefile = ImageFile(open(filename, 'rb'), name=relfilename)
88 | template = get_template('image.html', using='post_office')
89 | body = template.render({'imgsrc': imagefile})
90 | self.assertHTMLEqual(
91 | body,
92 | """
93 | Testing image attachments
94 |
95 | """,
96 | )
97 | subject = '[Django Post-Office unit tests] attached image'
98 | msg = EmailMultiAlternatives(subject, body, to=['john@example.com'])
99 | template.attach_related(msg)
100 | # this message can be send by email
101 | parts = msg.message().walk()
102 | part = next(parts)
103 | self.assertIsInstance(part, SafeMIMEMultipart)
104 | part = next(parts)
105 | self.assertIsInstance(part, SafeMIMEText)
106 | self.assertEqual(part.get_payload(), body)
107 | part = next(parts)
108 | self.assertIsInstance(part, MIMEImage)
109 | self.assertEqual(part.get_content_type(), 'image/png')
110 | self.assertEqual(part['Content-Disposition'], 'inline; filename="f5c66340b8af7dc946cd25d84fdf8c90"')
111 | self.assertEqual(part.get_content_disposition(), 'inline')
112 | self.assertEqual(part.get_filename(), 'f5c66340b8af7dc946cd25d84fdf8c90')
113 | self.assertEqual(part['Content-ID'], '')
114 |
115 | @override_settings(
116 | EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend',
117 | POST_OFFICE={
118 | 'BACKENDS': {'locmem': 'django.core.mail.backends.locmem.EmailBackend'},
119 | 'TEMPLATE_ENGINE': 'post_office',
120 | },
121 | )
122 | def test_send_with_html_template(self):
123 | template = EmailTemplate.objects.create(
124 | name='Test Inlined Images',
125 | subject='[django-SHOP unit tests] attached image',
126 | html_content="""
127 | {% load post_office %}
128 | Testing image attachments
129 | """,
130 | )
131 | filename = os.path.join(os.path.dirname(__file__), 'static/dummy.png')
132 | context = {'imgsrc': filename}
133 | queued_mail = send(
134 | recipients=['to@example.com'],
135 | sender='from@example.com',
136 | template=template,
137 | context=context,
138 | render_on_delivery=True,
139 | )
140 | queued_mail = Email.objects.get(id=queued_mail.id)
141 | send_queued()
142 | self.assertEqual(Email.objects.get(id=queued_mail.id).status, STATUS.sent)
143 |
144 |
145 | class EmailAdminTest(TestCase):
146 | def setUp(self) -> None:
147 | self.client = Client()
148 | self.user = get_user_model().objects.create_superuser(
149 | username='testuser', password='secret', email='test@example.com'
150 | )
151 | self.client.force_login(self.user)
152 |
153 | @override_settings(EMAIL_BACKEND='post_office.EmailBackend')
154 | def test_email_change_view(self):
155 | template = get_template('image.html', using='post_office')
156 | body = template.render({'imgsrc': 'dummy.png'})
157 | subject = '[Django Post-Office unit tests] attached image'
158 | msg = EmailMultiAlternatives(subject, body, to=['john@example.com'])
159 | msg.content_subtype = 'html'
160 | template.attach_related(msg)
161 | msg.send()
162 |
163 | # check that in the Email's detail view, the message is rendered
164 | self.assertEqual(Email.objects.count(), 1) # TODO: remove this
165 | email = Email.objects.latest('id')
166 | parts = email.email_message().message().walk()
167 | part = next(parts)
168 | self.assertIsInstance(part, SafeMIMEMultipart)
169 | part = next(parts)
170 | self.assertIsInstance(part, SafeMIMEText)
171 | part = next(parts)
172 | self.assertEqual(part.get_content_type(), 'image/png')
173 | content_id = part['Content-Id'][1:33]
174 | email_change_url = reverse('admin:post_office_email_change', args=(email.pk,))
175 | response = self.client.get(email_change_url, follow=True)
176 | self.assertContains(response, '[Django Post-Office unit tests] attached image')
177 | email_image_url = reverse('admin:post_office_email_image', kwargs={'pk': email.pk, 'content_id': content_id})
178 | try:
179 | import bleach
180 |
181 | self.assertContains(response, 'Testing image attachments ')
182 | self.assertContains(response, f' 25 else instance.message
27 |
28 |
29 | class AttachmentInline(admin.StackedInline):
30 | model = Attachment.emails.through
31 | extra = 0
32 | autocomplete_fields = ['attachment']
33 |
34 | def get_formset(self, request, obj=None, **kwargs):
35 | self.parent_obj = obj
36 | return super().get_formset(request, obj, **kwargs)
37 |
38 | def get_queryset(self, request):
39 | """
40 | Exclude inlined attachments from queryset, because they usually have meaningless names and
41 | are displayed anyway.
42 | """
43 | queryset = super().get_queryset(request)
44 | if self.parent_obj:
45 | queryset = queryset.filter(email=self.parent_obj)
46 |
47 | # From https://stackoverflow.com/a/67266338
48 | return queryset.exclude(
49 | **{
50 | 'attachment__headers__Content-Disposition__isnull': False,
51 | 'attachment__headers__Content-Disposition__startswith': 'inline',
52 | }
53 | ).select_related('attachment')
54 |
55 |
56 | class LogInline(admin.TabularInline):
57 | model = Log
58 | readonly_fields = fields = ['date', 'status', 'exception_type', 'message']
59 | can_delete = False
60 |
61 | def has_add_permission(self, request, obj=None):
62 | return False
63 |
64 | def has_change_permission(self, request, obj=None):
65 | return False
66 |
67 |
68 | class CommaSeparatedEmailWidget(TextInput):
69 | def __init__(self, *args, **kwargs):
70 | super().__init__(*args, **kwargs)
71 | self.attrs.update({'class': 'vTextField'})
72 |
73 | def format_value(self, value):
74 | # If the value is a string wrap it in a list so it does not get sliced.
75 | if not value:
76 | return ''
77 | if isinstance(value, str):
78 | value = [value]
79 | return ','.join([item for item in value])
80 |
81 |
82 | @admin.action(description='Requeue selected emails')
83 | def requeue(modeladmin, request, queryset):
84 | """An admin action to requeue emails."""
85 | queryset.update(status=STATUS.queued)
86 |
87 |
88 | @admin.register(Email)
89 | class EmailAdmin(admin.ModelAdmin):
90 | list_display = [
91 | 'truncated_message_id',
92 | 'to_display',
93 | 'shortened_subject',
94 | 'status',
95 | 'last_updated',
96 | 'scheduled_time',
97 | 'use_template',
98 | ]
99 | search_fields = ['to', 'subject']
100 | readonly_fields = ['message_id', 'render_subject', 'render_plaintext_body', 'render_html_body']
101 | inlines = [AttachmentInline, LogInline]
102 | list_filter = ['status', 'template__language', 'template__name']
103 | formfield_overrides = {CommaSeparatedEmailField: {'widget': CommaSeparatedEmailWidget}}
104 | actions = [requeue]
105 |
106 | def get_urls(self):
107 | urls = [
108 | re_path(
109 | r'^(?P\d+)/image/(?P[0-9a-f]{32})$',
110 | self.fetch_email_image,
111 | name='post_office_email_image',
112 | ),
113 | path('/resend/', self.resend, name='resend'),
114 | ]
115 | urls.extend(super().get_urls())
116 | return urls
117 |
118 | def get_queryset(self, request):
119 | return super().get_queryset(request).select_related('template')
120 |
121 | @admin.display(
122 | description=_('To'),
123 | ordering='to',
124 | )
125 | def to_display(self, instance):
126 | return ', '.join(instance.to)
127 |
128 | @admin.display(description='Message-ID')
129 | def truncated_message_id(self, instance):
130 | if instance.message_id:
131 | return Truncator(instance.message_id[1:-1]).chars(10)
132 | return str(instance.id)
133 |
134 | def has_add_permission(self, request):
135 | return False
136 |
137 | @admin.display(
138 | description=_('Subject'),
139 | ordering='subject',
140 | )
141 | def shortened_subject(self, instance):
142 | if instance.context:
143 | template_cache_key = '_subject_template_' + str(instance.template_id)
144 | template = getattr(self, template_cache_key, None)
145 | if template is None:
146 | # cache compiled template to speed up rendering of list view
147 | template = Template(instance.template.subject)
148 | setattr(self, template_cache_key, template)
149 | subject = template.render(Context(instance.context))
150 | else:
151 | subject = instance.subject
152 | return Truncator(subject).chars(100)
153 |
154 | @admin.display(
155 | description=_('Use Template'),
156 | boolean=True,
157 | )
158 | def use_template(self, instance):
159 | return bool(instance.template_id)
160 |
161 | def get_fieldsets(self, request, obj=None):
162 | fields = ['from_email', 'to', 'cc', 'bcc', 'priority', ('status', 'scheduled_time')]
163 | if obj.message_id:
164 | fields.insert(0, 'message_id')
165 | fieldsets = [(None, {'fields': fields})]
166 | has_plaintext_content, has_html_content = False, False
167 | for part in obj.email_message().message().walk():
168 | if not isinstance(part, SafeMIMEText):
169 | continue
170 | content_type = part.get_content_type()
171 | if content_type == 'text/plain':
172 | has_plaintext_content = True
173 | elif content_type == 'text/html':
174 | has_html_content = True
175 |
176 | if has_html_content:
177 | fieldsets.append((_('HTML Email'), {'fields': ['render_subject', 'render_html_body']}))
178 | if has_plaintext_content:
179 | fieldsets.append((_('Text Email'), {'classes': ['collapse'], 'fields': ['render_plaintext_body']}))
180 | elif has_plaintext_content:
181 | fieldsets.append((_('Text Email'), {'fields': ['render_subject', 'render_plaintext_body']}))
182 |
183 | return fieldsets
184 |
185 | @admin.display(description=_('Subject'))
186 | def render_subject(self, instance):
187 | message = instance.email_message()
188 | return message.subject
189 |
190 | @admin.display(description=_('Mail Body'))
191 | def render_plaintext_body(self, instance):
192 | for message in instance.email_message().message().walk():
193 | if isinstance(message, SafeMIMEText) and message.get_content_type() == 'text/plain':
194 | return format_html('{} ', message.get_payload())
195 |
196 | @admin.display(description=_('HTML Body'))
197 | def render_html_body(self, instance):
198 | pattern = re.compile('cid:([0-9a-f]{32})')
199 | url = reverse('admin:post_office_email_image', kwargs={'pk': instance.id, 'content_id': 32 * '0'})
200 | url = url.replace(32 * '0', r'\1')
201 | for message in instance.email_message().message().walk():
202 | if isinstance(message, SafeMIMEText) and message.get_content_type() == 'text/html':
203 | payload = message.get_payload(decode=True).decode('utf-8')
204 | return clean_html(pattern.sub(url, payload))
205 |
206 | def fetch_email_image(self, request, pk, content_id):
207 | instance = self.get_object(request, pk)
208 | for message in instance.email_message().message().walk():
209 | if message.get_content_maintype() == 'image' and message.get('Content-Id')[1:33] == content_id:
210 | return HttpResponse(message.get_payload(decode=True), content_type=message.get_content_type())
211 | return HttpResponseNotFound()
212 |
213 | def resend(self, request, pk):
214 | instance = self.get_object(request, pk)
215 | instance.dispatch()
216 | messages.info(request, 'Email has been sent again')
217 | return HttpResponseRedirect(reverse('admin:post_office_email_change', args=[instance.pk]))
218 |
219 |
220 | @admin.register(Log)
221 | class LogAdmin(admin.ModelAdmin):
222 | list_display = ('date', 'email', 'status', get_message_preview)
223 |
224 |
225 | class SubjectField(TextInput):
226 | def __init__(self, *args, **kwargs):
227 | super().__init__(*args, **kwargs)
228 | self.attrs.update({'style': 'width: 610px;'})
229 |
230 |
231 | class EmailTemplateAdminFormSet(BaseInlineFormSet):
232 | def clean(self):
233 | """
234 | Check that no two Email templates have the same default_template and language.
235 | """
236 | super().clean()
237 | data = set()
238 | for form in self.forms:
239 | default_template = form.cleaned_data['default_template']
240 | language = form.cleaned_data['language']
241 | if (default_template.id, language) in data:
242 | msg = _("Duplicate template for language '{language}'.")
243 | language = dict(form.fields['language'].choices)[language]
244 | raise ValidationError(msg.format(language=language))
245 | data.add((default_template.id, language))
246 |
247 |
248 | class EmailTemplateAdminForm(forms.ModelForm):
249 | language = forms.ChoiceField(
250 | choices=settings.LANGUAGES,
251 | required=False,
252 | label=_('Language'),
253 | help_text=_('Render template in alternative language'),
254 | )
255 |
256 | class Meta:
257 | model = EmailTemplate
258 | fields = ['name', 'description', 'subject', 'content', 'html_content', 'language', 'default_template']
259 |
260 | def __init__(self, *args, **kwargs):
261 | instance = kwargs.get('instance')
262 | super().__init__(*args, **kwargs)
263 | if instance and instance.language:
264 | self.fields['language'].disabled = True
265 |
266 |
267 | class EmailTemplateInline(admin.StackedInline):
268 | form = EmailTemplateAdminForm
269 | formset = EmailTemplateAdminFormSet
270 | model = EmailTemplate
271 | extra = 0
272 | fields = ('language', 'subject', 'content', 'html_content')
273 | formfield_overrides = {models.CharField: {'widget': SubjectField}}
274 |
275 | def get_max_num(self, request, obj=None, **kwargs):
276 | return len(settings.LANGUAGES)
277 |
278 |
279 | @admin.register(EmailTemplate)
280 | class EmailTemplateAdmin(admin.ModelAdmin):
281 | form = EmailTemplateAdminForm
282 | list_display = ('name', 'description_shortened', 'subject', 'languages_compact', 'created')
283 | search_fields = ('name', 'description', 'subject')
284 | fieldsets = [
285 | (None, {'fields': ('name', 'description')}),
286 | (_('Default Content'), {'fields': ('subject', 'content', 'html_content')}),
287 | ]
288 | inlines = (EmailTemplateInline,) if settings.USE_I18N else ()
289 | formfield_overrides = {models.CharField: {'widget': SubjectField}}
290 |
291 | def get_queryset(self, request):
292 | return self.model.objects.filter(default_template__isnull=True)
293 |
294 | @admin.display(
295 | description=_('Description'),
296 | ordering='description',
297 | )
298 | def description_shortened(self, instance):
299 | return Truncator(instance.description.split('\n')[0]).chars(200)
300 |
301 | @admin.display(description=_('Languages'))
302 | def languages_compact(self, instance):
303 | languages = [tt.language for tt in instance.translated_templates.order_by('language')]
304 | return ', '.join(languages)
305 |
306 | def save_model(self, request, obj, form, change):
307 | obj.save()
308 |
309 | # if the name got changed, also change the translated templates to match again
310 | if 'name' in form.changed_data:
311 | obj.translated_templates.update(name=obj.name)
312 |
313 |
314 | @admin.register(Attachment)
315 | class AttachmentAdmin(admin.ModelAdmin):
316 | list_display = ['name', 'file']
317 | filter_horizontal = ['emails']
318 | search_fields = ['name']
319 | autocomplete_fields = ['emails']
320 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | Changelog
2 | =========
3 |
4 | Version 3.10.1 (2025-08-04)
5 | ---------------------------
6 | * Fixed an issue where `email.last_updated` is not updated during email delivery. Thanks @marsha97!
7 |
8 | Version 3.10.0 (2025-07-15)
9 | ---------------------------
10 | * Added `email.recipient_delivery_status` field for tracking delivery issues. Thanks @hery733!
11 | * Added `FILE_STORAGE` option in `POST_OFFICE` config to specify the storage backend for attachments. Thanks @blag!
12 | * Added Czech translation. Thanks @fdobrovolny!
13 | * Lots of packaging work and converted setup.py to pyproject.toml. Thanks @blag!
14 | * Various admin improvements. Thanks @blag!
15 | * Updated tests and translations. Thanks @samiashi!
16 | * `mail.send_many()` now returns a list of `Email` instances. Thanks @chromium7!
17 |
18 | Version 3.9.0 (2024-06-19)
19 | --------------------------
20 | * Added a new `LOCK_FILE_NAME` which lets you change post office's lock file name. Thanks @mogost!
21 | * Fixes a bug where `email_queued` signal is not sent in certain cases. Thanks @diesieben07!
22 | * Fixes an issue where attachment admin page would not render with large number of emails. Thanks @petrprikryl!
23 | * Fixes a crash when email instances are made with context, but without a template. Thanks @pacahon!
24 | * Other miscellaneous fixes and house keeping tasks by @mogost!
25 |
26 | Version 3.8.0 (2023-10-22)
27 | --------------------------
28 | * Added `BATCH_DELIVERY_TIMEOUT` that specifies the maximum time allowed for each batch to be delivered. Defaults to 180 seconds. Thanks @selwin!
29 |
30 | Version 3.7.1 (2023-08-08)
31 | --------------------------
32 | * Optimized a queryset in `get_queued()` that doesn't use indexes in Postgres. Thanks @marsha97!
33 | * Removed `date_hierarchy` option which causes admin to load slowly on DBs with a large number of emails. Thanks @selwin!
34 | * Optimized `cleanup_expired_mails()` so that deletes emails in smaller batches. Thanks @marsha97!
35 |
36 | Version 3.7.0 (2023-05-30)
37 | --------------------------
38 | * Changed JSON columns to use Django's `JSONField` and drop `jsonfield` dependency. Thanks @jrief!
39 | * Fixed saving HTML emails that have `quoted_printable`. Thanks @gabn88!
40 | * Fixes an issue where emails are rendered without context in Django's admin interface. Thanks @zagl!
41 | * This version no longer supports Django 3.1.
42 |
43 | Version 3.6.3 (2022-10-27)
44 | --------------------------
45 | * Fixed an issue where emails may not be rendered with context. Thanks @zagl!
46 | * Fixed a few packaging issues. Thanks @zagl and @adamchainz!
47 | * `send_messages()` now mimics Django's SMTP Backend return value. Thanks @JiriKr!
48 |
49 | Version 3.6.2 (2022-10-12)
50 | --------------------------
51 | * Improvement to attachment handling in admin interface. Thanks @petrprikryl!
52 | * Fixed a bug where HTML body is not displayed in admin interface. Thanks @robbieadi!
53 | * Explicitly specify `default_auto_field` to supress migration warnings. Thanks @CirXe0N!
54 | * Fixed a bug where `email.template` is not saved in certain cases. Thanks @franciscobmacedo!
55 |
56 | Version 3.6.1 (2022-07-04)
57 | --------------------------
58 | * Support for bleach >= 5.0. Thanks @franciscobmacedo!
59 | * Ensure that `Reply-To` headers are correctly set. Thanks @christophmeissner!
60 | * Add a `Resend` button in admin to easily resend an email. Thanks @domdinicola!
61 |
62 |
63 | Version 3.6.0 (2021-12-21)
64 | --------------------------
65 | * Support for Django 4.0. Thanks @domdinicola!
66 | * `cleanup_mail` now deletes emails in batches, which is much nicer to DB when deleting millions of emails. Thanks @stevemc4!
67 | * Failure to send an email are now logged as an exception. Thanks @SaturnFromTitan!
68 | * Added `es` locale. Thanks @ahmontero!
69 | * Fixed admin template discovery issue for case-sensitive filesystems. Thanks @fasih!
70 | * Fixes: `SMTPServerDisconnected` error. Thanks @weimens!
71 | * Various maintenance work by @jrief and @mogost.
72 |
73 | Version 3.5.3 (2020-12-04)
74 | --------------------------
75 | * Fixed an issue with Celery integration that could cause duplicate emails. Thanks @jrief!
76 |
77 | Version 3.5.2 (2020-11-05)
78 | --------------------------
79 | * Fixed an issue where Post Office's admin interface doesn't show. Thanks @christianciu!
80 |
81 | Version 3.5.1 (2020-11-03)
82 | --------------------------
83 | * Added missing migration file (some model fields now offer a help text).
84 |
85 | Version 3.5.0 (2020-10-31)
86 | --------------------------
87 | * Added the capability to configure retries via `MAX_RETRIES` and `RETRY_INTERVAL` configuration settings. Thanks @Houtmann and @jrief!
88 | * The admin interface has been improved to show previews of emails. If you want HTML emails to be rendered,
89 | please install `bleach`. Thanks @jrief!
90 | * Add `Message-ID` to the Email model. This allows administrators to trace back emails. Thanks @jrief!
91 | * Added `CELERY_ENABLED` settings. Thanks @elineda!
92 | * Fixes an issue that prevents PDS attachments from being sent. Thanks @patroqueeet!
93 |
94 | Version 3.4.1 (2020-05-16)
95 | --------------------------
96 | * Allow `tasks.py` to be imported when Celery is not installed. This allows
97 | auto-discovery by other task systems such as Huey to succeed.
98 | * Fix duplicate key problem in template editor in Django admin.
99 |
100 | Version 3.4.0 (2020-04-13)
101 | --------------------------
102 | * Signals that emails have been put into the queue.
103 | * [Celery](http://www.celeryproject.org/) integration for immediate asynchronous delivery.
104 | * Changed version handling.
105 |
106 | Version 3.3.1 (2020-02-28)
107 | --------------------------
108 | * Drop support for Django < 2.2.
109 | * Revert ``jsonfield2`` back to ``jsonfield`` to make upgrade from < 3.3.0 smoother. Thanks @rpkilby!
110 |
111 | Version 3.3.0
112 | -------------
113 | * Support for Django 3.0. Thanks @Mogost!
114 | * Drop support for Django < 1.11 and Python < 3.5. Thanks @Mogost!
115 | * Replace unsupported dependency ``jsonfield`` with supported fork ``jsonfield2``. Thanks @Mogost!
116 | * Added `OVERRIDE_RECIPIENTS` for testing purposes. Thanks @Houtmann!
117 | * Improved admin interface. Thanks @ilikerobots and @cwuebbels!
118 |
119 | Version 3.2.1
120 | -------------
121 | * Fix #264: Replace unicode elipsis against 3 dots.
122 |
123 | Version 3.2.0
124 | -------------
125 | * Drop support for Python-3.3.
126 | * Drop support for Django-1.8 and 1.9.
127 | * Add functionality to attach images as inlines to email body.
128 | * Add special template engine to render HTML emails with inlined images.
129 | * Update German translation strings.
130 |
131 | Version 3.1.0 (2018-07-24)
132 | --------------------------
133 | * Improvements to attachments are handled. Thanks @SeiryuZ!
134 | * Added `--delete-attachments` flag to `cleanup_mail` management command. Thanks @Seiryuz!
135 | * I18n improvements. Thanks @vsevolod-skripnik and @delneg!
136 | * Django admin improvements. Thanks @kakulukia!
137 |
138 | Version 3.0.4
139 | -------------
140 | * Added compatibility with Django 2.0. Thanks @PreActionTech and @PetrDlouhy!
141 | * Added natural key support to `EmailTemplate` model. Thanks @maximlomakin!
142 |
143 | Version 3.0.3
144 | -------------
145 | * Fixed memory leak when multiprocessing is used.
146 | * Fixed a possible error when adding a new email from Django admin. Thanks @ivlevdenis!
147 |
148 | Version 3.0.2
149 | -------------
150 | * `_send_bulk` now properly catches exceptions when preparing email messages.
151 |
152 | Version 3.0.1
153 | -------------
154 | * Fixed an infinite loop bug in `send_queued_mail` management command.
155 |
156 | Version 3.0.0
157 | -------------
158 | * `_send_bulk` now allows each process to use multiple threads to send emails.
159 | * Added support for mimetypes in email attachments. Thanks @clickonchris!
160 | * An `EmailTemplate` can now be used as defaults multiple times in one language. Thanks @sac7e!
161 | * `send_queued_mail` management command will now check whether there are more queued emails to be sent before exiting.
162 | * Drop support for Django < 1.8. Thanks @fendyh!
163 |
164 | Version 2.0.8
165 | -------------
166 | * Django 1.10 compatibility fixes. Thanks @hockeybuggy!
167 | * Fixed an issue where Django would sometimes create migration files for post-office. Thanks @fizista!
168 |
169 | Version 2.0.7
170 | -------------
171 | * Fixed an issue with sending email to recipients with display name. Thanks @yprez!
172 |
173 | Version 2.0.6
174 | -------------
175 | * Fixes Django 1.10 deprecation warnings and other minor improvements. Thanks @yprez!
176 | * Email.subject can now accept up to 989 characters. This should also fix minor migration issues. Thanks @yprez!
177 |
178 | Version 2.0.5
179 | -------------
180 | * Fixes more Django 1.8 deprecation warnings.
181 | * `Email.dispatch()` now closes backend connection by default. Thanks @zwack
182 | * Compatibility fixes for Django 1.9. Thanks @yprez!
183 |
184 | Version 2.0.2
185 | -------------
186 | * `Email.dispatch()` now closes backend connection by default. Thanks @zwack
187 | * Compatibility fixes for Django 1.9. Thanks @yprez!
188 |
189 | Version 2.0.1
190 | -------------
191 | * Fixes migration related packaging issues.
192 | * Fixes deprecation warning in Django 1.8.
193 |
194 | Version 2.0
195 | -----------
196 | * Added multi backend support. Now you can use multiple email backends with ``post-office``!
197 | * Added multi language support. Thanks @jrief!
198 |
199 | Version 1.1.2
200 | -------------
201 | * Adds Django 1.8 compatibility.
202 |
203 | Version 1.1.1
204 | -------------
205 | * Fixes a migration error. Thanks @garry-cairns!
206 |
207 | Version 1.1.0
208 | -------------
209 | * Support for Django 1.7 migrations. If you're still on Django < 1.7,
210 | South migration files are stored in ``south_migrations`` directory.
211 |
212 | Version 1.0.0
213 | -------------
214 | * **IMPORTANT**: in older versions, passing multiple ``recipients`` into
215 | ``mail.send()`` will create multiple emails, each addressed to one recipient.
216 | Starting from ``1.0.0``, only one email with multiple recipients will be created.
217 | * Added ``LOG_LEVEL`` setting.
218 | * ``mail.send()`` now supports ``cc`` and ``bcc``.
219 | Thanks Ștefan Daniel Mihăilă (@stefan-mihaila)!
220 | * Improvements to ``admin`` interface; you can now easily requeue multiple emails.
221 | * ``Log`` model now stores the type of exception caught during sending.
222 | * ``send_templated_mail`` command is now deprecated.
223 | * Added ``EMAIL_BACKEND`` setting to the new dictionary-styled settings.
224 |
225 | Version 0.8.4
226 | -------------
227 | * ``send_queued_mail`` now accepts an extra ``--log-level`` argument.
228 | * ``mail.send()`` now accepts an extra ``log_level`` argument.
229 | * Drop unused/low cardinality indexes to free up RAM on large tables.
230 |
231 | Version 0.8.3
232 | -------------
233 | * ``send_queued_mail`` now accepts ``--lockfile`` argument.
234 | * Lockfile implementation has been modified to use symlink, which is an atomic operation
235 | across platforms.
236 |
237 | Version 0.8.2
238 | -------------
239 | * Added ``CONTEXT_FIELD_CLASS`` setting to allow other kinds of context field serializers.
240 |
241 | Version 0.8.1
242 | -------------
243 | * Fixed a bug that causes context to be saved when ``render_on_delivery`` is False
244 |
245 | Version 0.8.0
246 | -------------
247 | * Added a new setting ``DEFAULT_PRIORITY`` to set the default priority for emails.
248 | Thanks Maik Hoepfel (@maikhoepfel)!
249 | * ``mail.send()`` gains a ``render_on_delivery`` argument that may potentially
250 | result in significant storage space savings.
251 | * Uses a new locking mechanism that can detect zombie PID files.
252 |
253 | Version 0.7.2
254 | -------------
255 | * Made a few tweaks that makes ``post_office`` much more efficient on systems with
256 | large number of rows (millions).
257 |
258 | Version 0.7.1
259 | -------------
260 | * Python 3 compatibility fix.
261 |
262 | Version 0.7.0
263 | -------------
264 | * Added support for sending attachments. Thanks @yprez!
265 | * Added ``description`` field to ``EmailTemplate`` model to store human readable
266 | description of templates. Thanks Michael P. Jung (@bikeshedder)!
267 | * Changed ``django-jsonfield`` dependency to ``jsonfield`` for Python 3 support reasons.
268 | * Minor bug fixes.
269 |
270 | Version 0.6.0
271 | -------------
272 | * Support for Python 3!
273 | * Added mail.send_many() that's much more performant when sending
274 | a large number emails
275 |
276 | Version 0.5.2
277 | -------------
278 | * Added logging
279 | * Added BATCH_SIZE configuration option
280 |
281 | Version 0.5.1
282 | -------------
283 | * Fixes various multiprocessing bugs
284 |
285 | Version 0.5.0
286 | -------------
287 | * Email sending can now be parallelized using multiple processes (multiprocessing)
288 | * Email templates are now validated before save
289 | * Fixed a bug where custom headers aren't properly sent
290 |
291 | Version 0.4.0
292 | -------------
293 | * Added support for sending emails with custom headers (you'll need to run
294 | South when upgrading from earlier versions)
295 | * Added support for scheduled email sending
296 | * Backend now properly persist emails with HTML alternatives
297 |
298 | Version 0.3.1
299 | -------------
300 | * **IMPORTANT**: ``mail.send`` now expects recipient email addresses as the first
301 | argument. This change is to allow optional ``sender`` parameter which defaults
302 | to ``settings.DEFAULT_FROM_EMAIL``
303 | * Fixed a bug where all emails sent from ``mail.send`` have medium priority
304 |
305 | Version 0.3.0
306 | -------------
307 | * **IMPORTANT**: added South migration. If you use South and had post-office
308 | installed before 0.3.0, you may need to manually resolve migration conflicts
309 | * Allow unicode messages to be displayed in ``/admin``
310 | * Introduced a new ``mail.send`` function that provides a nicer API to send emails
311 | * ``created`` fields now use ``auto_now_add``
312 | * ``last_updated`` fields now use ``auto_now``
313 |
314 | Version 0.2.1
315 | -------------
316 | * Fixed typo in ``admin.py``
317 |
318 | Version 0.2
319 | -----------
320 | * Allows sending emails via database backed templates
321 |
322 | Version 0.1.5
323 | -------------
324 | * Errors when opening connection in ``Email.dispatch`` method are now logged
325 |
--------------------------------------------------------------------------------