├── 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 | --------------------------------------------------------------------------------