added post_office and crontab

This commit is contained in:
Esther Kleinhenz 2018-10-30 17:59:14 +01:00
parent 292e9eda2a
commit 38a25437b6
70 changed files with 6526 additions and 8 deletions

@ -8,14 +8,14 @@ from django.shortcuts import redirect
from django.contrib.auth.decorators import login_required
from django.contrib.admin.views.decorators import staff_member_required
from django.contrib.auth import authenticate, login, logout
from django.db.models import Q
import sys
import collections
from taggit_templatetags2.views import TagCanvasListView
from django.contrib.auth.models import User
from django.contrib import messages
""" from post_office import mail
"""
from post_office.models import EmailTemplate
from post_office import mail
import logging
@ -182,10 +182,17 @@ def blog_search_list_view(request):
def tag_cloud(request):
return render(request, 'tag_cloud.html', {})
""" mail.send(
EmailTemplate.objects.create(
name='weekly-update',
subject='Hi' + User.objects.get(username=request.user) + '!',
content='How are you feeling today?' + Post.objects.filter(published_date__lte=timezone.now()).order_by('published_date'),
html_content='Hi <strong>{{ name }}</strong>, how are you feeling today?',
)
mail.send(
'kleinhenz.e@gmail.com', # List of email addresses also accepted
'esther.kleinhenz@web.de',
subject='My email',
message='Hi there!',
html_message='Hi <strong>there</strong>!',
) """
template='weekly-update',
context={'name': 'alice'},
)

@ -47,6 +47,7 @@ INSTALLED_APPS = [
'taggit',
'taggit_templatetags2',
'kombu.transport.django',
'post_office',
]
MIDDLEWARE = [
@ -251,3 +252,12 @@ if DEBUG:
DEBUG_TOOLBAR_CONFIG = {
'INTERCEPT_REDIRECTS': False,
}
EMAIL_BACKEND = 'post_office.EmailBackend'
EMAIL_HOST = 'smtp.web.de'
EMAIL_HOST_USER = "esther.kleinhenz@web.de"
EMAIL_PORT = 25 # default smtp port
EMAIL_HOST_PASSWORD = "2mSchneeinMikkeli"
EMAIL_USE_TLS = True
DEFAULT_FROM_EMAIL = 'your.generic.test.email@web.de'

@ -0,0 +1,654 @@
==================
Django Post Office
==================
Django Post Office is a simple app to send and manage your emails in Django.
Some awesome features are:
* Allows you to send email asynchronously
* Multi backend support
* Supports HTML email
* Supports database based email templates
* Built in scheduling support
* Works well with task queues like `RQ <http://python-rq.org>`_ or `Celery <http://www.celeryproject.org>`_
* Uses multiprocessing (and threading) to send a large number of emails in parallel
* Supports multilingual email templates (i18n)
Dependencies
============
* `django >= 1.8 <http://djangoproject.com/>`_
* `django-jsonfield <https://github.com/bradjasper/django-jsonfield>`_
Installation
============
|Build Status|
* Install from PyPI (or you `manually download from PyPI <http://pypi.python.org/pypi/django-post_office>`_)::
pip install django-post_office
* Add ``post_office`` to your INSTALLED_APPS in django's ``settings.py``:
.. code-block:: python
INSTALLED_APPS = (
# other apps
"post_office",
)
* Run ``migrate``::
python manage.py migrate
* Set ``post_office.EmailBackend`` as your ``EMAIL_BACKEND`` in django's ``settings.py``:
.. code-block:: python
EMAIL_BACKEND = 'post_office.EmailBackend'
Quickstart
==========
Send a simple email is really easy:
.. code-block:: python
from post_office import mail
mail.send(
'recipient@example.com', # List of email addresses also accepted
'from@example.com',
subject='My email',
message='Hi there!',
html_message='Hi <strong>there</strong>!',
)
If you want to use templates, ensure that Django's admin interface is enabled. Create an
``EmailTemplate`` instance via ``admin`` and do the following:
.. code-block:: python
from post_office import mail
mail.send(
'recipient@example.com', # List of email addresses also accepted
'from@example.com',
template='welcome_email', # Could be an EmailTemplate instance or name
context={'foo': 'bar'},
)
The above command will put your email on the queue so you can use the
command in your webapp without slowing down the request/response cycle too much.
To actually send them out, run ``python manage.py send_queued_mail``.
You can schedule this management command to run regularly via cron::
* * * * * (/usr/bin/python manage.py send_queued_mail >> send_mail.log 2>&1)
or, if you use uWSGI_ as application server, add this short snipped to the
project's ``wsgi.py`` file:
.. code-block:: python
from django.core.wsgi import get_wsgi_application
application = get_wsgi_application()
# add this block of code
try:
import uwsgidecorators
from django.core.management import call_command
@uwsgidecorators.timer(10)
def send_queued_mail(num):
"""Send queued mail every 10 seconds"""
call_command('send_queued_mail', processes=1)
except ImportError:
print("uwsgidecorators not found. Cron and timers are disabled")
Alternatively you can also use the decorator ``@uwsgidecorators.cron(minute, hour, day, month, weekday)``.
This will schedule a task at specific times. Use ``-1`` to signal any time, it corresponds to the ``*``
in cron.
Please note that ``uwsgidecorators`` are available only, if the application has been started
with **uWSGI**. However, Django's internal ``./manange.py runserver`` also access this file,
therefore wrap the block into an exception handler as shown above.
This configuration is very useful in environments, such as Docker containers, where you
don't have a running cron-daemon.
Usage
=====
mail.send()
-----------
``mail.send`` is the most important function in this library, it takes these
arguments:
+--------------------+----------+--------------------------------------------------+
| Argument | Required | Description |
+--------------------+----------+--------------------------------------------------+
| recipients | Yes | list of recipient email addresses |
+--------------------+----------+--------------------------------------------------+
| sender | No | Defaults to ``settings.DEFAULT_FROM_EMAIL``, |
| | | display name is allowed (``John <john@a.com>``) |
+--------------------+----------+--------------------------------------------------+
| subject | No | Email subject (if ``template`` is not specified) |
+--------------------+----------+--------------------------------------------------+
| message | No | Email content (if ``template`` is not specified) |
+--------------------+----------+--------------------------------------------------+
| html_message | No | HTML content (if ``template`` is not specified) |
+--------------------+----------+--------------------------------------------------+
| template | No | ``EmailTemplate`` instance or name |
+--------------------+----------+--------------------------------------------------+
| language | No | Language in which you want to send the email in |
| | | (if you have multilingual email templates.) |
+--------------------+----------+--------------------------------------------------+
| cc | No | list emails, will appear in ``cc`` field |
+--------------------+----------+--------------------------------------------------+
| bcc | No | list of emails, will appear in `bcc` field |
+--------------------+----------+--------------------------------------------------+
| attachments | No | Email attachments - A dictionary where the keys |
| | | are the filenames and the values are either: |
| | | |
| | | * files |
| | | * file-like objects |
| | | * full path of the file |
+--------------------+----------+--------------------------------------------------+
| context | No | A dictionary, used to render templated email |
+--------------------+----------+--------------------------------------------------+
| headers | No | A dictionary of extra headers on the message |
+--------------------+----------+--------------------------------------------------+
| scheduled_time | No | A date/datetime object indicating when the email |
| | | should be sent |
+--------------------+----------+--------------------------------------------------+
| priority | No | ``high``, ``medium``, ``low`` or ``now`` |
| | | (send_immediately) |
+--------------------+----------+--------------------------------------------------+
| backend | No | Alias of the backend you want to use. |
| | | ``default`` will be used if not specified. |
+--------------------+----------+--------------------------------------------------+
| render_on_delivery | No | Setting this to ``True`` causes email to be |
| | | lazily rendered during delivery. ``template`` |
| | | is required when ``render_on_delivery`` is True. |
| | | This way content is never stored in the DB. |
| | | May result in significant space savings. |
+--------------------+----------+--------------------------------------------------+
Here are a few examples.
If you just want to send out emails without using database templates. You can
call the ``send`` command without the ``template`` argument.
.. code-block:: python
from post_office import mail
mail.send(
['recipient1@example.com'],
'from@example.com',
subject='Welcome!',
message='Welcome home, {{ name }}!',
html_message='Welcome home, <b>{{ name }}</b>!',
headers={'Reply-to': 'reply@example.com'},
scheduled_time=date(2014, 1, 1),
context={'name': 'Alice'},
)
``post_office`` is also task queue friendly. Passing ``now`` as priority into
``send_mail`` will deliver the email right away (instead of queuing it),
regardless of how many emails you have in your queue:
.. code-block:: python
from post_office import mail
mail.send(
['recipient1@example.com'],
'from@example.com',
template='welcome_email',
context={'foo': 'bar'},
priority='now',
)
This is useful if you already use something like `django-rq <https://github.com/ui/django-rq>`_
to send emails asynchronously and only need to store email related activities and logs.
If you want to send an email with attachments:
.. code-block:: python
from django.core.files.base import ContentFile
from post_office import mail
mail.send(
['recipient1@example.com'],
'from@example.com',
template='welcome_email',
context={'foo': 'bar'},
priority='now',
attachments={
'attachment1.doc': '/path/to/file/file1.doc',
'attachment2.txt': ContentFile('file content'),
'attachment3.txt': { 'file': ContentFile('file content'), 'mimetype': 'text/plain'},
}
)
Template Tags and Variables
---------------------------
``post-office`` supports Django's template tags and variables.
For example, if you put "Hello, {{ name }}" in the subject line and pass in
``{'name': 'Alice'}`` as context, you will get "Hello, Alice" as subject:
.. code-block:: python
from post_office.models import EmailTemplate
from post_office import mail
EmailTemplate.objects.create(
name='morning_greeting',
subject='Morning, {{ name|capfirst }}',
content='Hi {{ name }}, how are you feeling today?',
html_content='Hi <strong>{{ name }}</strong>, how are you feeling today?',
)
mail.send(
['recipient@example.com'],
'from@example.com',
template='morning_greeting',
context={'name': 'alice'},
)
# This will create an email with the following content:
subject = 'Morning, Alice',
content = 'Hi alice, how are you feeling today?'
content = 'Hi <strong>alice</strong>, how are you feeling today?'
Multilingual Email Templates
----------------------------
You can easily create email templates in various different languanges.
For example:
.. code-block:: python
template = EmailTemplate.objects.create(
name='hello',
subject='Hello world!',
)
# Add an Indonesian version of this template:
indonesian_template = template.translated_templates.create(
language='id',
subject='Halo Dunia!'
)
Sending an email using template in a non default languange is
also similarly easy:
.. code-block:: python
mail.send(
['recipient@example.com'],
'from@example.com',
template=template, # Sends using the default template
)
mail.send(
['recipient@example.com'],
'from@example.com',
template=template,
language='id', # Sends using Indonesian template
)
Custom Email Backends
---------------------
By default, ``post_office`` uses django's ``smtp.EmailBackend``. If you want to
use a different backend, you can do so by configuring ``BACKENDS``.
For example if you want to use `django-ses <https://github.com/hmarr/django-ses>`_::
POST_OFFICE = {
'BACKENDS': {
'default': 'smtp.EmailBackend',
'ses': 'django_ses.SESBackend',
}
}
You can then choose what backend you want to use when sending mail:
.. code-block:: python
# If you omit `backend_alias` argument, `default` will be used
mail.send(
['recipient@example.com'],
'from@example.com',
subject='Hello',
)
# If you want to send using `ses` backend
mail.send(
['recipient@example.com'],
'from@example.com',
subject='Hello',
backend='ses',
)
Management Commands
-------------------
* ``send_queued_mail`` - send queued emails, those aren't successfully sent
will be marked as ``failed``. Accepts the following arguments:
+---------------------------+--------------------------------------------------+
| Argument | Description |
+---------------------------+--------------------------------------------------+
| ``--processes`` or ``-p`` | Number of parallel processes to send email. |
| | Defaults to 1 |
+---------------------------+--------------------------------------------------+
| ``--lockfile`` or ``-L`` | Full path to file used as lock file. Defaults to |
| | ``/tmp/post_office.lock`` |
+---------------------------+--------------------------------------------------+
* ``cleanup_mail`` - delete all emails created before an X number of days
(defaults to 90).
+---------------------------+--------------------------------------------------+
| Argument | Description |
+---------------------------+--------------------------------------------------+
| ``--days`` or ``-d`` | Email older than this argument will be deleted. |
| | Defaults to 90 |
+---------------------------+--------------------------------------------------+
| ``--delete-attachments`` | Flag to delete orphaned attachment records and |
| or ``-da`` | files on disk. If flag is not set, |
| | on disk attachments files won't be deleted. |
+---------------------------+--------------------------------------------------+
You may want to set these up via cron to run regularly::
* * * * * (cd $PROJECT; python manage.py send_queued_mail --processes=1 >> $PROJECT/cron_mail.log 2>&1)
0 1 * * * (cd $PROJECT; python manage.py cleanup_mail --days=30 --delete-attachments >> $PROJECT/cron_mail_cleanup.log 2>&1)
Settings
========
This section outlines all the settings and configurations that you can put
in Django's ``settings.py`` to fine tune ``post-office``'s behavior.
Batch Size
----------
If you may want to limit the number of emails sent in a batch (sometimes useful
in a low memory environment), use the ``BATCH_SIZE`` argument to limit the
number of queued emails fetched in one batch.
.. code-block:: python
# Put this in settings.py
POST_OFFICE = {
'BATCH_SIZE': 50
}
Default Priority
----------------
The default priority for emails is ``medium``, but this can be altered by
setting ``DEFAULT_PRIORITY``. Integration with asynchronous email backends
(e.g. based on Celery) becomes trivial when set to ``now``.
.. code-block:: python
# Put this in settings.py
POST_OFFICE = {
'DEFAULT_PRIORITY': 'now'
}
Log Level
---------
The default log level is 2 (logs both successful and failed deliveries)
This behavior can be changed by setting ``LOG_LEVEL``.
.. code-block:: python
# Put this in settings.py
POST_OFFICE = {
'LOG_LEVEL': 1 # Log only failed deliveries
}
The different options are:
* ``0`` logs nothing
* ``1`` logs only failed deliveries
* ``2`` logs everything (both successful and failed delivery attempts)
Sending Order
-------------
The default sending order for emails is ``-priority``, but this can be altered by
setting ``SENDING_ORDER``. For example, if you want to send queued emails in FIFO order :
.. code-block:: python
# Put this in settings.py
POST_OFFICE = {
'SENDING_ORDER': ['created']
}
Context Field Serializer
------------------------
If you need to store complex Python objects for deferred rendering
(i.e. setting ``render_on_delivery=True``), you can specify your own context
field class to store context variables. For example if you want to use
`django-picklefield <https://github.com/gintas/django-picklefield/tree/master/src/picklefield>`_:
.. code-block:: python
# Put this in settings.py
POST_OFFICE = {
'CONTEXT_FIELD_CLASS': 'picklefield.fields.PickledObjectField'
}
``CONTEXT_FIELD_CLASS`` defaults to ``jsonfield.JSONField``.
Logging
-------
You can configure ``post-office``'s logging from Django's ``settings.py``. For
example:
.. code-block:: python
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"post_office": {
"format": "[%(levelname)s]%(asctime)s PID %(process)d: %(message)s",
"datefmt": "%d-%m-%Y %H:%M:%S",
},
},
"handlers": {
"post_office": {
"level": "DEBUG",
"class": "logging.StreamHandler",
"formatter": "post_office"
},
# If you use sentry for logging
'sentry': {
'level': 'ERROR',
'class': 'raven.contrib.django.handlers.SentryHandler',
},
},
'loggers': {
"post_office": {
"handlers": ["post_office", "sentry"],
"level": "INFO"
},
},
}
Threads
-------
``post-office`` >= 3.0 allows you to use multiple threads to dramatically speed up
the speed at which emails are sent. By default, ``post-office`` uses 5 threads per process.
You can tweak this setting by changing ``THREADS_PER_PROCESS`` setting.
This may dramatically increase the speed of bulk email delivery, depending on which email
backends you use. In my tests, multi threading speeds up email backends that use HTTP based
(REST) delivery mechanisms but doesn't seem to help SMTP based backends.
.. code-block:: python
# Put this in settings.py
POST_OFFICE = {
'THREADS_PER_PROCESS': 10
}
Performance
===========
Caching
-------
if Django's caching mechanism is configured, ``post_office`` will cache
``EmailTemplate`` instances . If for some reason you want to disable caching,
set ``POST_OFFICE_CACHE`` to ``False`` in ``settings.py``:
.. code-block:: python
## All cache key will be prefixed by post_office:template:
## To turn OFF caching, you need to explicitly set POST_OFFICE_CACHE to False in settings
POST_OFFICE_CACHE = False
## Optional: to use a non default cache backend, add a "post_office" entry in CACHES
CACHES = {
'post_office': {
'BACKEND': 'django.core.cache.backends.memcached.PyLibMCCache',
'LOCATION': '127.0.0.1:11211',
}
}
send_many()
-----------
``send_many()`` is much more performant (generates less database queries) when
sending a large number of emails. ``send_many()`` is almost identical to ``mail.send()``,
with the exception that it accepts a list of keyword arguments that you'd
usually pass into ``mail.send()``:
.. code-block:: python
from post_office import mail
first_email = {
'sender': 'from@example.com',
'recipients': ['alice@example.com'],
'subject': 'Hi!',
'message': 'Hi Alice!'
}
second_email = {
'sender': 'from@example.com',
'recipients': ['bob@example.com'],
'subject': 'Hi!',
'message': 'Hi Bob!'
}
kwargs_list = [first_email, second_email]
mail.send_many(kwargs_list)
Attachments are not supported with ``mail.send_many()``.
Running Tests
=============
To run the test suite::
`which django-admin.py` test post_office --settings=post_office.test_settings --pythonpath=.
You can run the full test suite with::
tox
or::
python setup.py test
Changelog
=========
Version 3.1.0 (2018-07-24)
--------------------------
* Improvements to attachments are handled. Thanks @SeiryuZ!
* Added ``--delete-attachments`` flag to ``cleanup_mail`` management command. Thanks @Seiryuz!
* I18n improvements. Thanks @vsevolod-skripnik and @delneg!
* Django admin improvements. Thanks @kakulukia!
Version 3.0.4
-------------
* Added compatibility with Django 2.0. Thanks @PreActionTech and @PetrDlouhy!
* Added natural key support to `EmailTemplate` model. Thanks @maximlomakin!
Version 3.0.2
-------------
- Fixed memory leak when multiprocessing is used.
- Fixed a possible error when adding a new email from Django admin. Thanks @ivlevdenis!
Version 3.0.2
-------------
- `_send_bulk` now properly catches exceptions when preparing email messages.
Version 3.0.1
-------------
- Fixed an infinite loop bug in `send_queued_mail` management command.
Version 3.0.0
-------------
* `_send_bulk` now allows each process to use multiple threads to send emails.
* Added support for mimetypes in email attachments. Thanks @clickonchris!
* An `EmailTemplate` can now be used as defaults multiple times in one language. Thanks @sac7e!
* `send_queued_mail` management command will now check whether there are more queued emails to be sent before exiting.
* Drop support for Django < 1.8. Thanks @fendyh!
Full changelog can be found `here <https://github.com/ui/django-post_office/blob/master/CHANGELOG.md>`_.
Created and maintained by the cool guys at `Stamps <https://stamps.co.id>`_,
Indonesia's most elegant CRM/loyalty platform.
.. |Build Status| image:: https://travis-ci.org/ui/django-post_office.png?branch=master
:target: https://travis-ci.org/ui/django-post_office
.. _uWSGI: https://uwsgi-docs.readthedocs.org/en/latest/

@ -0,0 +1,685 @@
Metadata-Version: 2.0
Name: django-post-office
Version: 3.1.0
Summary: A Django app to monitor and send mail asynchronously, complete with template support.
Home-page: https://github.com/ui/django-post_office
Author: Selwin Ong
Author-email: selwin.ong@gmail.com
License: MIT
Description-Content-Type: UNKNOWN
Platform: UNKNOWN
Classifier: Development Status :: 5 - Production/Stable
Classifier: Environment :: Web Environment
Classifier: Framework :: Django
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 2
Classifier: Programming Language :: Python :: 2.7
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.3
Classifier: Programming Language :: Python :: 3.4
Classifier: Programming Language :: Python :: 3.5
Classifier: Programming Language :: Python :: 3.6
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Dist: django (>=1.8)
Requires-Dist: jsonfield
Provides-Extra: test
Requires-Dist: tox (>=2.3); extra == 'test'
==================
Django Post Office
==================
Django Post Office is a simple app to send and manage your emails in Django.
Some awesome features are:
* Allows you to send email asynchronously
* Multi backend support
* Supports HTML email
* Supports database based email templates
* Built in scheduling support
* Works well with task queues like `RQ <http://python-rq.org>`_ or `Celery <http://www.celeryproject.org>`_
* Uses multiprocessing (and threading) to send a large number of emails in parallel
* Supports multilingual email templates (i18n)
Dependencies
============
* `django >= 1.8 <http://djangoproject.com/>`_
* `django-jsonfield <https://github.com/bradjasper/django-jsonfield>`_
Installation
============
|Build Status|
* Install from PyPI (or you `manually download from PyPI <http://pypi.python.org/pypi/django-post_office>`_)::
pip install django-post_office
* Add ``post_office`` to your INSTALLED_APPS in django's ``settings.py``:
.. code-block:: python
INSTALLED_APPS = (
# other apps
"post_office",
)
* Run ``migrate``::
python manage.py migrate
* Set ``post_office.EmailBackend`` as your ``EMAIL_BACKEND`` in django's ``settings.py``:
.. code-block:: python
EMAIL_BACKEND = 'post_office.EmailBackend'
Quickstart
==========
Send a simple email is really easy:
.. code-block:: python
from post_office import mail
mail.send(
'recipient@example.com', # List of email addresses also accepted
'from@example.com',
subject='My email',
message='Hi there!',
html_message='Hi <strong>there</strong>!',
)
If you want to use templates, ensure that Django's admin interface is enabled. Create an
``EmailTemplate`` instance via ``admin`` and do the following:
.. code-block:: python
from post_office import mail
mail.send(
'recipient@example.com', # List of email addresses also accepted
'from@example.com',
template='welcome_email', # Could be an EmailTemplate instance or name
context={'foo': 'bar'},
)
The above command will put your email on the queue so you can use the
command in your webapp without slowing down the request/response cycle too much.
To actually send them out, run ``python manage.py send_queued_mail``.
You can schedule this management command to run regularly via cron::
* * * * * (/usr/bin/python manage.py send_queued_mail >> send_mail.log 2>&1)
or, if you use uWSGI_ as application server, add this short snipped to the
project's ``wsgi.py`` file:
.. code-block:: python
from django.core.wsgi import get_wsgi_application
application = get_wsgi_application()
# add this block of code
try:
import uwsgidecorators
from django.core.management import call_command
@uwsgidecorators.timer(10)
def send_queued_mail(num):
"""Send queued mail every 10 seconds"""
call_command('send_queued_mail', processes=1)
except ImportError:
print("uwsgidecorators not found. Cron and timers are disabled")
Alternatively you can also use the decorator ``@uwsgidecorators.cron(minute, hour, day, month, weekday)``.
This will schedule a task at specific times. Use ``-1`` to signal any time, it corresponds to the ``*``
in cron.
Please note that ``uwsgidecorators`` are available only, if the application has been started
with **uWSGI**. However, Django's internal ``./manange.py runserver`` also access this file,
therefore wrap the block into an exception handler as shown above.
This configuration is very useful in environments, such as Docker containers, where you
don't have a running cron-daemon.
Usage
=====
mail.send()
-----------
``mail.send`` is the most important function in this library, it takes these
arguments:
+--------------------+----------+--------------------------------------------------+
| Argument | Required | Description |
+--------------------+----------+--------------------------------------------------+
| recipients | Yes | list of recipient email addresses |
+--------------------+----------+--------------------------------------------------+
| sender | No | Defaults to ``settings.DEFAULT_FROM_EMAIL``, |
| | | display name is allowed (``John <john@a.com>``) |
+--------------------+----------+--------------------------------------------------+
| subject | No | Email subject (if ``template`` is not specified) |
+--------------------+----------+--------------------------------------------------+
| message | No | Email content (if ``template`` is not specified) |
+--------------------+----------+--------------------------------------------------+
| html_message | No | HTML content (if ``template`` is not specified) |
+--------------------+----------+--------------------------------------------------+
| template | No | ``EmailTemplate`` instance or name |
+--------------------+----------+--------------------------------------------------+
| language | No | Language in which you want to send the email in |
| | | (if you have multilingual email templates.) |
+--------------------+----------+--------------------------------------------------+
| cc | No | list emails, will appear in ``cc`` field |
+--------------------+----------+--------------------------------------------------+
| bcc | No | list of emails, will appear in `bcc` field |
+--------------------+----------+--------------------------------------------------+
| attachments | No | Email attachments - A dictionary where the keys |
| | | are the filenames and the values are either: |
| | | |
| | | * files |
| | | * file-like objects |
| | | * full path of the file |
+--------------------+----------+--------------------------------------------------+
| context | No | A dictionary, used to render templated email |
+--------------------+----------+--------------------------------------------------+
| headers | No | A dictionary of extra headers on the message |
+--------------------+----------+--------------------------------------------------+
| scheduled_time | No | A date/datetime object indicating when the email |
| | | should be sent |
+--------------------+----------+--------------------------------------------------+
| priority | No | ``high``, ``medium``, ``low`` or ``now`` |
| | | (send_immediately) |
+--------------------+----------+--------------------------------------------------+
| backend | No | Alias of the backend you want to use. |
| | | ``default`` will be used if not specified. |
+--------------------+----------+--------------------------------------------------+
| render_on_delivery | No | Setting this to ``True`` causes email to be |
| | | lazily rendered during delivery. ``template`` |
| | | is required when ``render_on_delivery`` is True. |
| | | This way content is never stored in the DB. |
| | | May result in significant space savings. |
+--------------------+----------+--------------------------------------------------+
Here are a few examples.
If you just want to send out emails without using database templates. You can
call the ``send`` command without the ``template`` argument.
.. code-block:: python
from post_office import mail
mail.send(
['recipient1@example.com'],
'from@example.com',
subject='Welcome!',
message='Welcome home, {{ name }}!',
html_message='Welcome home, <b>{{ name }}</b>!',
headers={'Reply-to': 'reply@example.com'},
scheduled_time=date(2014, 1, 1),
context={'name': 'Alice'},
)
``post_office`` is also task queue friendly. Passing ``now`` as priority into
``send_mail`` will deliver the email right away (instead of queuing it),
regardless of how many emails you have in your queue:
.. code-block:: python
from post_office import mail
mail.send(
['recipient1@example.com'],
'from@example.com',
template='welcome_email',
context={'foo': 'bar'},
priority='now',
)
This is useful if you already use something like `django-rq <https://github.com/ui/django-rq>`_
to send emails asynchronously and only need to store email related activities and logs.
If you want to send an email with attachments:
.. code-block:: python
from django.core.files.base import ContentFile
from post_office import mail
mail.send(
['recipient1@example.com'],
'from@example.com',
template='welcome_email',
context={'foo': 'bar'},
priority='now',
attachments={
'attachment1.doc': '/path/to/file/file1.doc',
'attachment2.txt': ContentFile('file content'),
'attachment3.txt': { 'file': ContentFile('file content'), 'mimetype': 'text/plain'},
}
)
Template Tags and Variables
---------------------------
``post-office`` supports Django's template tags and variables.
For example, if you put "Hello, {{ name }}" in the subject line and pass in
``{'name': 'Alice'}`` as context, you will get "Hello, Alice" as subject:
.. code-block:: python
from post_office.models import EmailTemplate
from post_office import mail
EmailTemplate.objects.create(
name='morning_greeting',
subject='Morning, {{ name|capfirst }}',
content='Hi {{ name }}, how are you feeling today?',
html_content='Hi <strong>{{ name }}</strong>, how are you feeling today?',
)
mail.send(
['recipient@example.com'],
'from@example.com',
template='morning_greeting',
context={'name': 'alice'},
)
# This will create an email with the following content:
subject = 'Morning, Alice',
content = 'Hi alice, how are you feeling today?'
content = 'Hi <strong>alice</strong>, how are you feeling today?'
Multilingual Email Templates
----------------------------
You can easily create email templates in various different languanges.
For example:
.. code-block:: python
template = EmailTemplate.objects.create(
name='hello',
subject='Hello world!',
)
# Add an Indonesian version of this template:
indonesian_template = template.translated_templates.create(
language='id',
subject='Halo Dunia!'
)
Sending an email using template in a non default languange is
also similarly easy:
.. code-block:: python
mail.send(
['recipient@example.com'],
'from@example.com',
template=template, # Sends using the default template
)
mail.send(
['recipient@example.com'],
'from@example.com',
template=template,
language='id', # Sends using Indonesian template
)
Custom Email Backends
---------------------
By default, ``post_office`` uses django's ``smtp.EmailBackend``. If you want to
use a different backend, you can do so by configuring ``BACKENDS``.
For example if you want to use `django-ses <https://github.com/hmarr/django-ses>`_::
POST_OFFICE = {
'BACKENDS': {
'default': 'smtp.EmailBackend',
'ses': 'django_ses.SESBackend',
}
}
You can then choose what backend you want to use when sending mail:
.. code-block:: python
# If you omit `backend_alias` argument, `default` will be used
mail.send(
['recipient@example.com'],
'from@example.com',
subject='Hello',
)
# If you want to send using `ses` backend
mail.send(
['recipient@example.com'],
'from@example.com',
subject='Hello',
backend='ses',
)
Management Commands
-------------------
* ``send_queued_mail`` - send queued emails, those aren't successfully sent
will be marked as ``failed``. Accepts the following arguments:
+---------------------------+--------------------------------------------------+
| Argument | Description |
+---------------------------+--------------------------------------------------+
| ``--processes`` or ``-p`` | Number of parallel processes to send email. |
| | Defaults to 1 |
+---------------------------+--------------------------------------------------+
| ``--lockfile`` or ``-L`` | Full path to file used as lock file. Defaults to |
| | ``/tmp/post_office.lock`` |
+---------------------------+--------------------------------------------------+
* ``cleanup_mail`` - delete all emails created before an X number of days
(defaults to 90).
+---------------------------+--------------------------------------------------+
| Argument | Description |
+---------------------------+--------------------------------------------------+
| ``--days`` or ``-d`` | Email older than this argument will be deleted. |
| | Defaults to 90 |
+---------------------------+--------------------------------------------------+
| ``--delete-attachments`` | Flag to delete orphaned attachment records and |
| or ``-da`` | files on disk. If flag is not set, |
| | on disk attachments files won't be deleted. |
+---------------------------+--------------------------------------------------+
You may want to set these up via cron to run regularly::
* * * * * (cd $PROJECT; python manage.py send_queued_mail --processes=1 >> $PROJECT/cron_mail.log 2>&1)
0 1 * * * (cd $PROJECT; python manage.py cleanup_mail --days=30 --delete-attachments >> $PROJECT/cron_mail_cleanup.log 2>&1)
Settings
========
This section outlines all the settings and configurations that you can put
in Django's ``settings.py`` to fine tune ``post-office``'s behavior.
Batch Size
----------
If you may want to limit the number of emails sent in a batch (sometimes useful
in a low memory environment), use the ``BATCH_SIZE`` argument to limit the
number of queued emails fetched in one batch.
.. code-block:: python
# Put this in settings.py
POST_OFFICE = {
'BATCH_SIZE': 50
}
Default Priority
----------------
The default priority for emails is ``medium``, but this can be altered by
setting ``DEFAULT_PRIORITY``. Integration with asynchronous email backends
(e.g. based on Celery) becomes trivial when set to ``now``.
.. code-block:: python
# Put this in settings.py
POST_OFFICE = {
'DEFAULT_PRIORITY': 'now'
}
Log Level
---------
The default log level is 2 (logs both successful and failed deliveries)
This behavior can be changed by setting ``LOG_LEVEL``.
.. code-block:: python
# Put this in settings.py
POST_OFFICE = {
'LOG_LEVEL': 1 # Log only failed deliveries
}
The different options are:
* ``0`` logs nothing
* ``1`` logs only failed deliveries
* ``2`` logs everything (both successful and failed delivery attempts)
Sending Order
-------------
The default sending order for emails is ``-priority``, but this can be altered by
setting ``SENDING_ORDER``. For example, if you want to send queued emails in FIFO order :
.. code-block:: python
# Put this in settings.py
POST_OFFICE = {
'SENDING_ORDER': ['created']
}
Context Field Serializer
------------------------
If you need to store complex Python objects for deferred rendering
(i.e. setting ``render_on_delivery=True``), you can specify your own context
field class to store context variables. For example if you want to use
`django-picklefield <https://github.com/gintas/django-picklefield/tree/master/src/picklefield>`_:
.. code-block:: python
# Put this in settings.py
POST_OFFICE = {
'CONTEXT_FIELD_CLASS': 'picklefield.fields.PickledObjectField'
}
``CONTEXT_FIELD_CLASS`` defaults to ``jsonfield.JSONField``.
Logging
-------
You can configure ``post-office``'s logging from Django's ``settings.py``. For
example:
.. code-block:: python
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"post_office": {
"format": "[%(levelname)s]%(asctime)s PID %(process)d: %(message)s",
"datefmt": "%d-%m-%Y %H:%M:%S",
},
},
"handlers": {
"post_office": {
"level": "DEBUG",
"class": "logging.StreamHandler",
"formatter": "post_office"
},
# If you use sentry for logging
'sentry': {
'level': 'ERROR',
'class': 'raven.contrib.django.handlers.SentryHandler',
},
},
'loggers': {
"post_office": {
"handlers": ["post_office", "sentry"],
"level": "INFO"
},
},
}
Threads
-------
``post-office`` >= 3.0 allows you to use multiple threads to dramatically speed up
the speed at which emails are sent. By default, ``post-office`` uses 5 threads per process.
You can tweak this setting by changing ``THREADS_PER_PROCESS`` setting.
This may dramatically increase the speed of bulk email delivery, depending on which email
backends you use. In my tests, multi threading speeds up email backends that use HTTP based
(REST) delivery mechanisms but doesn't seem to help SMTP based backends.
.. code-block:: python
# Put this in settings.py
POST_OFFICE = {
'THREADS_PER_PROCESS': 10
}
Performance
===========
Caching
-------
if Django's caching mechanism is configured, ``post_office`` will cache
``EmailTemplate`` instances . If for some reason you want to disable caching,
set ``POST_OFFICE_CACHE`` to ``False`` in ``settings.py``:
.. code-block:: python
## All cache key will be prefixed by post_office:template:
## To turn OFF caching, you need to explicitly set POST_OFFICE_CACHE to False in settings
POST_OFFICE_CACHE = False
## Optional: to use a non default cache backend, add a "post_office" entry in CACHES
CACHES = {
'post_office': {
'BACKEND': 'django.core.cache.backends.memcached.PyLibMCCache',
'LOCATION': '127.0.0.1:11211',
}
}
send_many()
-----------
``send_many()`` is much more performant (generates less database queries) when
sending a large number of emails. ``send_many()`` is almost identical to ``mail.send()``,
with the exception that it accepts a list of keyword arguments that you'd
usually pass into ``mail.send()``:
.. code-block:: python
from post_office import mail
first_email = {
'sender': 'from@example.com',
'recipients': ['alice@example.com'],
'subject': 'Hi!',
'message': 'Hi Alice!'
}
second_email = {
'sender': 'from@example.com',
'recipients': ['bob@example.com'],
'subject': 'Hi!',
'message': 'Hi Bob!'
}
kwargs_list = [first_email, second_email]
mail.send_many(kwargs_list)
Attachments are not supported with ``mail.send_many()``.
Running Tests
=============
To run the test suite::
`which django-admin.py` test post_office --settings=post_office.test_settings --pythonpath=.
You can run the full test suite with::
tox
or::
python setup.py test
Changelog
=========
Version 3.1.0 (2018-07-24)
--------------------------
* Improvements to attachments are handled. Thanks @SeiryuZ!
* Added ``--delete-attachments`` flag to ``cleanup_mail`` management command. Thanks @Seiryuz!
* I18n improvements. Thanks @vsevolod-skripnik and @delneg!
* Django admin improvements. Thanks @kakulukia!
Version 3.0.4
-------------
* Added compatibility with Django 2.0. Thanks @PreActionTech and @PetrDlouhy!
* Added natural key support to `EmailTemplate` model. Thanks @maximlomakin!
Version 3.0.2
-------------
- Fixed memory leak when multiprocessing is used.
- Fixed a possible error when adding a new email from Django admin. Thanks @ivlevdenis!
Version 3.0.2
-------------
- `_send_bulk` now properly catches exceptions when preparing email messages.
Version 3.0.1
-------------
- Fixed an infinite loop bug in `send_queued_mail` management command.
Version 3.0.0
-------------
* `_send_bulk` now allows each process to use multiple threads to send emails.
* Added support for mimetypes in email attachments. Thanks @clickonchris!
* An `EmailTemplate` can now be used as defaults multiple times in one language. Thanks @sac7e!
* `send_queued_mail` management command will now check whether there are more queued emails to be sent before exiting.
* Drop support for Django < 1.8. Thanks @fendyh!
Full changelog can be found `here <https://github.com/ui/django-post_office/blob/master/CHANGELOG.md>`_.
Created and maintained by the cool guys at `Stamps <https://stamps.co.id>`_,
Indonesia's most elegant CRM/loyalty platform.
.. |Build Status| image:: https://travis-ci.org/ui/django-post_office.png?branch=master
:target: https://travis-ci.org/ui/django-post_office
.. _uWSGI: https://uwsgi-docs.readthedocs.org/en/latest/

@ -0,0 +1,95 @@
django_post_office-3.1.0.dist-info/DESCRIPTION.rst,sha256=fq6f9SdPAs7jJl7BWe_S9ko0vLKU4Yarl_f1aTu68_Q,22039
django_post_office-3.1.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
django_post_office-3.1.0.dist-info/METADATA,sha256=ax9BP4Si7dNXTUuIQ5bMgtr0u0JvkEQjvDKdP8qXavY,23265
django_post_office-3.1.0.dist-info/RECORD,,
django_post_office-3.1.0.dist-info/WHEEL,sha256=kdsN-5OJAZIiHN-iO4Rhl82KyS0bDWf4uBwMbkNafr8,110
django_post_office-3.1.0.dist-info/metadata.json,sha256=EM2Mho7BIVj-ZWfq4Cl6uwNx6as_NqbCf1MMU0qaxLU,1353
django_post_office-3.1.0.dist-info/top_level.txt,sha256=UM3cswoGuzxOuDS20Qu9QVSXmMCn5t3ZZniWAtZ6Dn4,12
post_office/__init__.py,sha256=eVpmOKhL7n_V2TQ2upYQRcfmVbrKEFFYsM71Hv3F738,114
post_office/__pycache__/__init__.cpython-36.pyc,,
post_office/__pycache__/admin.cpython-36.pyc,,
post_office/__pycache__/apps.cpython-36.pyc,,
post_office/__pycache__/backends.cpython-36.pyc,,
post_office/__pycache__/cache.cpython-36.pyc,,
post_office/__pycache__/compat.cpython-36.pyc,,
post_office/__pycache__/connections.cpython-36.pyc,,
post_office/__pycache__/fields.cpython-36.pyc,,
post_office/__pycache__/lockfile.cpython-36.pyc,,
post_office/__pycache__/logutils.cpython-36.pyc,,
post_office/__pycache__/mail.cpython-36.pyc,,
post_office/__pycache__/models.cpython-36.pyc,,
post_office/__pycache__/settings.cpython-36.pyc,,
post_office/__pycache__/test_settings.cpython-36.pyc,,
post_office/__pycache__/test_urls.cpython-36.pyc,,
post_office/__pycache__/utils.cpython-36.pyc,,
post_office/__pycache__/validators.cpython-36.pyc,,
post_office/__pycache__/views.cpython-36.pyc,,
post_office/admin.py,sha256=wQqT_r9mBJ3E2t_aKSF2T8WHxgly6a4UmcyF3eyDA7Q,4922
post_office/apps.py,sha256=dv8AJIBvIec64Xy2BFKKd1R-8eEbLT0XBBiHXWQzS5Q,188
post_office/backends.py,sha256=5s2UTtFz2DIdaJMtzJV06bm4pP6IsaSrx1eHt3qK6yY,1855
post_office/cache.py,sha256=O39Foxqg8gxn2TBaTiT6wlyTKFE7Y6F-rSPLrB6ESkk,646
post_office/compat.py,sha256=5adNRlBcuvNqwDBjIDnbX2wVabGnj3fQTRr9e5PT-0I,1076
post_office/connections.py,sha256=REO_ns9KT42rhBFX4b8ABzBpkGiR8uO429j4DSOhKeU,1145
post_office/fields.py,sha256=9MgoIuXNm6mqrAuTqzMxfd4JiOSFx7DXWILXWnOU7ds,1973
post_office/locale/de/LC_MESSAGES/django.mo,sha256=JGLLjuEXSDqKzq0RnBjnIZX23aV0988oHNGCgD9J3sk,1632
post_office/locale/de/LC_MESSAGES/django.po,sha256=WBTJcJK_0SGC6O4nbE2DMaCLdrmQRHyE1RRRUbedmQE,2395
post_office/locale/it/LC_MESSAGES/django.mo,sha256=6hqUjb2Y3T21QnOya5bgY-gkMi4QhJxEoKv6QSAF9Mg,1611
post_office/locale/it/LC_MESSAGES/django.po,sha256=h-hoj6fTJwcgVB9vf9UKsEvzjWJ2nmYFoDj5JlYQro0,2379
post_office/locale/pl/LC_MESSAGES/django.mo,sha256=k_bOkQR787kAqHKUGxcRco5Mkggl2Hwr_bR3d05q6Ug,2698
post_office/locale/pl/LC_MESSAGES/django.po,sha256=tDDtQeMufPFoSYSgILfvgCSzqnpRG6V_Sngbwwhsv_4,4300
post_office/locale/ru_RU/LC_MESSAGES/django.mo,sha256=Op7j2z0Hii-uT6WVa7aqen60Rahz1RNXyp7GoHPnQ_E,3274
post_office/locale/ru_RU/LC_MESSAGES/django.po,sha256=UIsDRCnv0tc11sjFSUgJjOLcXGr-lHgIpp2ZOcAhrls,4901
post_office/lockfile.py,sha256=RS3c_b5jWi1z1pGH-7TsT8gwN5ueDdtHSpiIkbE64l4,4594
post_office/logutils.py,sha256=gTa5EeuZu3UHiyR-4XvGVl-LRJ1vM8lHoLW274wTDNs,1066
post_office/mail.py,sha256=O8kZsccs30rKpDAreIM6mC6YfdI72Mu0gv_Fb-ks8DY,10135
post_office/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
post_office/management/__pycache__/__init__.cpython-36.pyc,,
post_office/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
post_office/management/commands/__pycache__/__init__.cpython-36.pyc,,
post_office/management/commands/__pycache__/cleanup_mail.cpython-36.pyc,,
post_office/management/commands/__pycache__/send_queued_mail.cpython-36.pyc,,
post_office/management/commands/cleanup_mail.py,sha256=LKO3SWjbIMzlXsbUDychhkX3Lt9AO8eZdtAdYo0Nin0,1412
post_office/management/commands/send_queued_mail.py,sha256=ALkDylHVflNLydBTI96iBl6xXJtCW-W1ziG4BFhOisA,1999
post_office/migrations/0001_initial.py,sha256=7TaB2WEljVeM1niR6N_cYWz4PwXT5mxirHRlfwIdJeg,4635
post_office/migrations/0002_add_i18n_and_backend_alias.py,sha256=BtWyGrAybC5n7VhUup3GiREZJk6iMgxmSgMAy0IzmeE,5489
post_office/migrations/0003_longer_subject.py,sha256=ZnXopRJEeY_gfvj01t5lthde98HJZa91W3GrmyXp3lg,2749
post_office/migrations/0004_auto_20160607_0901.py,sha256=37BgmxLeGYL0RzYI-5flsCKs3obgqzNQmWEVZyitTLQ,5025
post_office/migrations/0005_auto_20170515_0013.py,sha256=EuA_SPdWF1e-TVBTzrO-z1sFZbiJaPV1ClLhw_GPlvc,456
post_office/migrations/0006_attachment_mimetype.py,sha256=UBDnxzP1KWCaEy1nBYQW6Zuv2EB-36w82jBxVAlkhgY,435
post_office/migrations/0007_auto_20170731_1342.py,sha256=LK8zW5vrxdjG07w5NMr3kisgdx-aBu1PEu2H-LPaNgM,498
post_office/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
post_office/migrations/__pycache__/0001_initial.cpython-36.pyc,,
post_office/migrations/__pycache__/0002_add_i18n_and_backend_alias.cpython-36.pyc,,
post_office/migrations/__pycache__/0003_longer_subject.cpython-36.pyc,,
post_office/migrations/__pycache__/0004_auto_20160607_0901.cpython-36.pyc,,
post_office/migrations/__pycache__/0005_auto_20170515_0013.cpython-36.pyc,,
post_office/migrations/__pycache__/0006_attachment_mimetype.cpython-36.pyc,,
post_office/migrations/__pycache__/0007_auto_20170731_1342.cpython-36.pyc,,
post_office/migrations/__pycache__/__init__.cpython-36.pyc,,
post_office/models.py,sha256=1WOu1px_vWwJzRAtmfoxfIRRx5jPOhjTS2cwltZ6GPM,10942
post_office/settings.py,sha256=PlKALPJvOo9AK326JjSMqI6U5-aO-aZSHqn8cSZUjaI,2584
post_office/test_settings.py,sha256=DkeMMDFiF6s2PCW0FoHv9pXJ8Lu5M5Vo25hdzKV-y3I,2293
post_office/test_urls.py,sha256=jFmkObKRb7-bE2nWqfQ49eSC51F3ayWRCrtymaEr378,123
post_office/tests/__init__.py,sha256=r8KNPnxhYYPrY_1Ko9SSo7Y1DcHlrZL2h6dRtqDePXk,287
post_office/tests/__pycache__/__init__.cpython-36.pyc,,
post_office/tests/__pycache__/test_backends.cpython-36.pyc,,
post_office/tests/__pycache__/test_cache.cpython-36.pyc,,
post_office/tests/__pycache__/test_commands.cpython-36.pyc,,
post_office/tests/__pycache__/test_connections.cpython-36.pyc,,
post_office/tests/__pycache__/test_lockfile.cpython-36.pyc,,
post_office/tests/__pycache__/test_mail.cpython-36.pyc,,
post_office/tests/__pycache__/test_models.cpython-36.pyc,,
post_office/tests/__pycache__/test_utils.cpython-36.pyc,,
post_office/tests/__pycache__/test_views.cpython-36.pyc,,
post_office/tests/test_backends.py,sha256=ZCP30v7ZO_sqsW-HHmIqQYWYE7DsQCQZGG7mxXaZhPc,4636
post_office/tests/test_cache.py,sha256=qKxuSytd8md9JgOFn_R1C0wLEt4ta_akN0c7A6Mwa9E,1437
post_office/tests/test_commands.py,sha256=NXijHn86wXaVrE1hcxiKvJKx-Tn4Ce6YmRPNqyt5U6A,6059
post_office/tests/test_connections.py,sha256=QL_EIy_Pjdv4dCNtLLEF_h2Vso3DrObcG1C4Ds06AIw,459
post_office/tests/test_lockfile.py,sha256=MMLgRhwV5xW72DlWR62yM_KMqQCutCfbDx8iWNs_Sas,1943
post_office/tests/test_mail.py,sha256=fJydVnGF5GRojLg2Oq2l_f_RyJQjVlmxMo5iFXUIREo,16449
post_office/tests/test_models.py,sha256=_1gtj0yL6ur1MQ2o_iq567TXAib1oReD1LB_LilNSvo,14960
post_office/tests/test_utils.py,sha256=_AqeqTZZmsGDQHosVeESOvzMCQ2uD_R0hbMCJrxDIso,8476
post_office/tests/test_views.py,sha256=feYG3bTnQDVRYDKCIsKX_UJerKEaegZXGze20NHrT_Y,1208
post_office/utils.py,sha256=aC8oilDFjQKBtNgZc__GhVX_y22cQrFPU5oKZ-CX-sc,4556
post_office/validators.py,sha256=q8umHXtqM4F93vh0g6m7x-U2eM347w9L6qVXXelF-v4,1409
post_office/views.py,sha256=F42JXgnqFqK0fajXeutyJJxwOszRxoLMNkIhfc4Z7KI,26

@ -0,0 +1,6 @@
Wheel-Version: 1.0
Generator: bdist_wheel (0.30.0)
Root-Is-Purelib: true
Tag: py2-none-any
Tag: py3-none-any

@ -0,0 +1 @@
{"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Framework :: Django", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Topic :: Internet :: WWW/HTTP", "Topic :: Software Development :: Libraries :: Python Modules"], "description_content_type": "UNKNOWN", "extensions": {"python.details": {"contacts": [{"email": "selwin.ong@gmail.com", "name": "Selwin Ong", "role": "author"}], "document_names": {"description": "DESCRIPTION.rst"}, "project_urls": {"Home": "https://github.com/ui/django-post_office"}}}, "extras": ["test"], "generator": "bdist_wheel (0.30.0)", "license": "MIT", "metadata_version": "2.0", "name": "django-post-office", "run_requires": [{"requires": ["django (>=1.8)", "jsonfield"]}, {"extra": "test", "requires": ["tox (>=2.3)"]}], "summary": "A Django app to monitor and send mail asynchronously, complete with template support.", "test_requires": [{"requires": ["tox (>=2.3)"]}], "version": "3.1.0"}

@ -0,0 +1,121 @@
django-jsonfield
----------------
django-jsonfield is a reusable Django field that allows you to store validated JSON in your model.
It silently takes care of serialization. To use, simply add the field to one of your models.
Python 3 & Django 1.8 through 1.11 supported!
**Use PostgreSQL?** 1.0.0 introduced a breaking change to the underlying data type, so if you were using < 1.0.0 please read https://github.com/dmkoch/django-jsonfield/issues/57 before upgrading. Also, consider switching to Django's native JSONField that was added in Django 1.9.
**Note:** There are a couple of third-party add-on JSONFields for Django. This project is django-jsonfield here on GitHub but is named `jsonfield on PyPI`_. There is another `django-jsonfield on Bitbucket`_, but that one is `django-jsonfield on PyPI`_. I realize this naming conflict is confusing and I am open to merging the two projects.
.. _jsonfield on PyPI: https://pypi.python.org/pypi/jsonfield
.. _django-jsonfield on Bitbucket: https://bitbucket.org/schinckel/django-jsonfield
.. _django-jsonfield on PyPI: https://pypi.python.org/pypi/django-jsonfield
**Note:** Django 1.9 added native PostgreSQL JSON support in `django.contrib.postgres.fields.JSONField`_. This module is still useful if you need to support JSON in databases other than PostgreSQL or are creating a third-party module that needs to be database-agnostic. But if you're an end user using PostgreSQL and want full-featured JSON support, I recommend using the built-in JSONField from Django instead of this module.
.. _django.contrib.postgres.fields.JSONField: https://docs.djangoproject.com/en/dev/ref/contrib/postgres/fields/#jsonfield
**Note:** Semver is followed after the 1.0 release.
Installation
------------
.. code-block:: python
pip install jsonfield
Usage
-----
.. code-block:: python
from django.db import models
from jsonfield import JSONField
class MyModel(models.Model):
json = JSONField()
Advanced Usage
--------------
By default python deserializes json into dict objects. This behavior differs from the standard json behavior because python dicts do not have ordered keys.
To overcome this limitation and keep the sort order of OrderedDict keys the deserialisation can be adjusted on model initialisation:
.. code-block:: python
import collections
class MyModel(models.Model):
json = JSONField(load_kwargs={'object_pairs_hook': collections.OrderedDict})
Other Fields
------------
**jsonfield.JSONCharField**
If you need to use your JSON field in an index or other constraint, you can use **JSONCharField** which subclasses **CharField** instead of **TextField**. You'll also need to specify a **max_length** parameter if you use this field.
Compatibility
--------------
django-jsonfield aims to support the same versions of Django currently maintained by the main Django project. See `Django supported versions`_, currently:
* Django 1.8 (LTS) with Python 2.7, 3.3, 3.4, or 3.5
* Django 1.9 with Python 2.7, 3.4, or 3.5
* Django 1.10 with Python 2.7, 3.4, or 3.5
* Django 1.11 (LTS) with Python 2.7, 3.4, 3.5 or 3.6
.. _Django supported versions: https://www.djangoproject.com/download/#supported-versions
Testing django-jsonfield Locally
--------------------------------
To test against all supported versions of Django:
.. code-block:: shell
$ docker-compose build && docker-compose up
Or just one version (for example Django 1.10 on Python 3.5):
.. code-block:: shell
$ docker-compose build && docker-compose run tox tox -e py35-1.10
Travis CI
---------
.. image:: https://travis-ci.org/dmkoch/django-jsonfield.svg?branch=master
:target: https://travis-ci.org/dmkoch/django-jsonfield
Contact
-------
Web: http://bradjasper.com
Twitter: `@bradjasper`_
Email: `contact@bradjasper.com`_
.. _contact@bradjasper.com: mailto:contact@bradjasper.com
.. _@bradjasper: https://twitter.com/bradjasper
Changes
-------
Take a look at the `changelog`_.
.. _changelog: https://github.com/dmkoch/django-jsonfield/blob/master/CHANGES.rst

@ -0,0 +1,143 @@
Metadata-Version: 2.0
Name: jsonfield
Version: 2.0.2
Summary: A reusable Django field that allows you to store validated JSON in your model.
Home-page: https://github.com/dmkoch/django-jsonfield/
Author: Dan Koch
Author-email: dmkoch@gmail.com
License: MIT
Platform: UNKNOWN
Classifier: Environment :: Web Environment
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 2.7
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.3
Classifier: Programming Language :: Python :: 3.4
Classifier: Programming Language :: Python :: 3.5
Classifier: Programming Language :: Python :: 3.6
Classifier: Framework :: Django
Requires-Dist: Django (>=1.8.0)
django-jsonfield
----------------
django-jsonfield is a reusable Django field that allows you to store validated JSON in your model.
It silently takes care of serialization. To use, simply add the field to one of your models.
Python 3 & Django 1.8 through 1.11 supported!
**Use PostgreSQL?** 1.0.0 introduced a breaking change to the underlying data type, so if you were using < 1.0.0 please read https://github.com/dmkoch/django-jsonfield/issues/57 before upgrading. Also, consider switching to Django's native JSONField that was added in Django 1.9.
**Note:** There are a couple of third-party add-on JSONFields for Django. This project is django-jsonfield here on GitHub but is named `jsonfield on PyPI`_. There is another `django-jsonfield on Bitbucket`_, but that one is `django-jsonfield on PyPI`_. I realize this naming conflict is confusing and I am open to merging the two projects.
.. _jsonfield on PyPI: https://pypi.python.org/pypi/jsonfield
.. _django-jsonfield on Bitbucket: https://bitbucket.org/schinckel/django-jsonfield
.. _django-jsonfield on PyPI: https://pypi.python.org/pypi/django-jsonfield
**Note:** Django 1.9 added native PostgreSQL JSON support in `django.contrib.postgres.fields.JSONField`_. This module is still useful if you need to support JSON in databases other than PostgreSQL or are creating a third-party module that needs to be database-agnostic. But if you're an end user using PostgreSQL and want full-featured JSON support, I recommend using the built-in JSONField from Django instead of this module.
.. _django.contrib.postgres.fields.JSONField: https://docs.djangoproject.com/en/dev/ref/contrib/postgres/fields/#jsonfield
**Note:** Semver is followed after the 1.0 release.
Installation
------------
.. code-block:: python
pip install jsonfield
Usage
-----
.. code-block:: python
from django.db import models
from jsonfield import JSONField
class MyModel(models.Model):
json = JSONField()
Advanced Usage
--------------
By default python deserializes json into dict objects. This behavior differs from the standard json behavior because python dicts do not have ordered keys.
To overcome this limitation and keep the sort order of OrderedDict keys the deserialisation can be adjusted on model initialisation:
.. code-block:: python
import collections
class MyModel(models.Model):
json = JSONField(load_kwargs={'object_pairs_hook': collections.OrderedDict})
Other Fields
------------
**jsonfield.JSONCharField**
If you need to use your JSON field in an index or other constraint, you can use **JSONCharField** which subclasses **CharField** instead of **TextField**. You'll also need to specify a **max_length** parameter if you use this field.
Compatibility
--------------
django-jsonfield aims to support the same versions of Django currently maintained by the main Django project. See `Django supported versions`_, currently:
* Django 1.8 (LTS) with Python 2.7, 3.3, 3.4, or 3.5
* Django 1.9 with Python 2.7, 3.4, or 3.5
* Django 1.10 with Python 2.7, 3.4, or 3.5
* Django 1.11 (LTS) with Python 2.7, 3.4, 3.5 or 3.6
.. _Django supported versions: https://www.djangoproject.com/download/#supported-versions
Testing django-jsonfield Locally
--------------------------------
To test against all supported versions of Django:
.. code-block:: shell
$ docker-compose build && docker-compose up
Or just one version (for example Django 1.10 on Python 3.5):
.. code-block:: shell
$ docker-compose build && docker-compose run tox tox -e py35-1.10
Travis CI
---------
.. image:: https://travis-ci.org/dmkoch/django-jsonfield.svg?branch=master
:target: https://travis-ci.org/dmkoch/django-jsonfield
Contact
-------
Web: http://bradjasper.com
Twitter: `@bradjasper`_
Email: `contact@bradjasper.com`_
.. _contact@bradjasper.com: mailto:contact@bradjasper.com
.. _@bradjasper: https://twitter.com/bradjasper
Changes
-------
Take a look at the `changelog`_.
.. _changelog: https://github.com/dmkoch/django-jsonfield/blob/master/CHANGES.rst

@ -0,0 +1,19 @@
jsonfield-2.0.2.dist-info/DESCRIPTION.rst,sha256=ol_8lnYqTVXq5ExDwAmbBhSUylv5skifu1MSqZJUszU,4077
jsonfield-2.0.2.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
jsonfield-2.0.2.dist-info/METADATA,sha256=fILysyClwPP4RqmlbgBEyjyhmBkYfqG_laVx_21m19M,4892
jsonfield-2.0.2.dist-info/RECORD,,
jsonfield-2.0.2.dist-info/WHEEL,sha256=o2k-Qa-RMNIJmUdIc7KU6VWR_ErNRbWNlxDIpl7lm34,110
jsonfield-2.0.2.dist-info/metadata.json,sha256=EkZdQOU_zbNU_Uf6f6l2RPH3n9HoVNU0X8V0DbM5LIY,1010
jsonfield-2.0.2.dist-info/top_level.txt,sha256=vKhrOliM1tJJBXUhSXSoHcWs_90pUa7ogHvn-mzxGKQ,10
jsonfield/__init__.py,sha256=JzGSlVByVSYtIsp9iNf1S8pgxFdZ3ZY6y9Uys8hk2hs,53
jsonfield/__pycache__/__init__.cpython-36.pyc,,
jsonfield/__pycache__/encoder.cpython-36.pyc,,
jsonfield/__pycache__/fields.cpython-36.pyc,,
jsonfield/__pycache__/models.cpython-36.pyc,,
jsonfield/__pycache__/subclassing.cpython-36.pyc,,
jsonfield/__pycache__/tests.cpython-36.pyc,,
jsonfield/encoder.py,sha256=LamzI8S3leLOW0RG80-YUb2oxZxtUhuHXpKogYadL2w,2306
jsonfield/fields.py,sha256=09LBgXPcTxo6-rDwVewn8NhwtZ9yWe1DpoZehGVAcjk,6064
jsonfield/models.py,sha256=yXIA5LSYKowbs8bQWcU1TJ4Yc10MdEP5zWS5QD5P38E,43
jsonfield/subclassing.py,sha256=g1aGtzSlz3OisbQZgRtjDXVvQEqh0jNcMvZvBWHZ_ow,2236
jsonfield/tests.py,sha256=c0E2QV2l2gc3OLTVEOsePHl8Nt3gdaTcU02ShZksAOo,14949

@ -0,0 +1,6 @@
Wheel-Version: 1.0
Generator: bdist_wheel (0.29.0)
Root-Is-Purelib: true
Tag: py2-none-any
Tag: py3-none-any

@ -0,0 +1 @@
{"classifiers": ["Environment :: Web Environment", "Intended Audience :: Developers", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Framework :: Django"], "extensions": {"python.details": {"contacts": [{"email": "dmkoch@gmail.com", "name": "Dan Koch", "role": "author"}], "document_names": {"description": "DESCRIPTION.rst"}, "project_urls": {"Home": "https://github.com/dmkoch/django-jsonfield/"}}}, "extras": [], "generator": "bdist_wheel (0.29.0)", "license": "MIT", "metadata_version": "2.0", "name": "jsonfield", "run_requires": [{"requires": ["Django (>=1.8.0)"]}], "summary": "A reusable Django field that allows you to store validated JSON in your model.", "test_requires": [{"requires": ["Django (>=1.8.0)"]}], "version": "2.0.2"}

@ -0,0 +1 @@
from .fields import JSONField, JSONCharField # noqa

@ -0,0 +1,58 @@
from django.db.models.query import QuerySet
from django.utils import six, timezone
from django.utils.encoding import force_text
from django.utils.functional import Promise
import datetime
import decimal
import json
import uuid
class JSONEncoder(json.JSONEncoder):
"""
JSONEncoder subclass that knows how to encode date/time/timedelta,
decimal types, generators and other basic python objects.
Taken from https://github.com/tomchristie/django-rest-framework/blob/master/rest_framework/utils/encoders.py
"""
def default(self, obj): # noqa
# For Date Time string spec, see ECMA 262
# http://ecma-international.org/ecma-262/5.1/#sec-15.9.1.15
if isinstance(obj, Promise):
return force_text(obj)
elif isinstance(obj, datetime.datetime):
representation = obj.isoformat()
if obj.microsecond:
representation = representation[:23] + representation[26:]
if representation.endswith('+00:00'):
representation = representation[:-6] + 'Z'
return representation
elif isinstance(obj, datetime.date):
return obj.isoformat()
elif isinstance(obj, datetime.time):
if timezone and timezone.is_aware(obj):
raise ValueError("JSON can't represent timezone-aware times.")
representation = obj.isoformat()
if obj.microsecond:
representation = representation[:12]
return representation
elif isinstance(obj, datetime.timedelta):
return six.text_type(obj.total_seconds())
elif isinstance(obj, decimal.Decimal):
# Serializers will coerce decimals to strings by default.
return float(obj)
elif isinstance(obj, uuid.UUID):
return six.text_type(obj)
elif isinstance(obj, QuerySet):
return tuple(obj)
elif hasattr(obj, 'tolist'):
# Numpy arrays and array scalars.
return obj.tolist()
elif hasattr(obj, '__getitem__'):
try:
return dict(obj)
except:
pass
elif hasattr(obj, '__iter__'):
return tuple(item for item in obj)
return super(JSONEncoder, self).default(obj)

@ -0,0 +1,183 @@
import copy
from django.db import models
from django.utils.translation import ugettext_lazy as _
try:
from django.utils import six
except ImportError:
import six
try:
import json
except ImportError:
from django.utils import simplejson as json
from django.forms import fields
try:
from django.forms.utils import ValidationError
except ImportError:
from django.forms.util import ValidationError
from .subclassing import SubfieldBase
from .encoder import JSONEncoder
class JSONFormFieldBase(object):
def __init__(self, *args, **kwargs):
self.load_kwargs = kwargs.pop('load_kwargs', {})
super(JSONFormFieldBase, self).__init__(*args, **kwargs)
def to_python(self, value):
if isinstance(value, six.string_types) and value:
try:
return json.loads(value, **self.load_kwargs)
except ValueError:
raise ValidationError(_("Enter valid JSON"))
return value
def clean(self, value):
if not value and not self.required:
return None
# Trap cleaning errors & bubble them up as JSON errors
try:
return super(JSONFormFieldBase, self).clean(value)
except TypeError:
raise ValidationError(_("Enter valid JSON"))
class JSONFormField(JSONFormFieldBase, fields.CharField):
pass
class JSONCharFormField(JSONFormFieldBase, fields.CharField):
pass
class JSONFieldBase(six.with_metaclass(SubfieldBase, models.Field)):
def __init__(self, *args, **kwargs):
self.dump_kwargs = kwargs.pop('dump_kwargs', {
'cls': JSONEncoder,
'separators': (',', ':')
})
self.load_kwargs = kwargs.pop('load_kwargs', {})
super(JSONFieldBase, self).__init__(*args, **kwargs)
def pre_init(self, value, obj):
"""Convert a string value to JSON only if it needs to be deserialized.
SubfieldBase metaclass has been modified to call this method instead of
to_python so that we can check the obj state and determine if it needs to be
deserialized"""
try:
if obj._state.adding:
# Make sure the primary key actually exists on the object before
# checking if it's empty. This is a special case for South datamigrations
# see: https://github.com/bradjasper/django-jsonfield/issues/52
if getattr(obj, "pk", None) is not None:
if isinstance(value, six.string_types):
try:
return json.loads(value, **self.load_kwargs)
except ValueError:
raise ValidationError(_("Enter valid JSON"))
except AttributeError:
# south fake meta class doesn't create proper attributes
# see this:
# https://github.com/bradjasper/django-jsonfield/issues/52
pass
return value
def to_python(self, value):
"""The SubfieldBase metaclass calls pre_init instead of to_python, however to_python
is still necessary for Django's deserializer"""
return value
def get_prep_value(self, value):
"""Convert JSON object to a string"""
if self.null and value is None:
return None
return json.dumps(value, **self.dump_kwargs)
def _get_val_from_obj(self, obj):
# This function created to replace Django deprecated version
# https://code.djangoproject.com/ticket/24716
if obj is not None:
return getattr(obj, self.attname)
else:
return self.get_default()
def value_to_string(self, obj):
value = self._get_val_from_obj(obj)
return self.get_db_prep_value(value, None)
def value_from_object(self, obj):
value = super(JSONFieldBase, self).value_from_object(obj)
if self.null and value is None:
return None
return self.dumps_for_display(value)
def dumps_for_display(self, value):
return json.dumps(value, **self.dump_kwargs)
def formfield(self, **kwargs):
if "form_class" not in kwargs:
kwargs["form_class"] = self.form_class
field = super(JSONFieldBase, self).formfield(**kwargs)
if isinstance(field, JSONFormFieldBase):
field.load_kwargs = self.load_kwargs
if not field.help_text:
field.help_text = "Enter valid JSON"
return field
def get_default(self):
"""
Returns the default value for this field.
The default implementation on models.Field calls force_unicode
on the default, which means you can't set arbitrary Python
objects as the default. To fix this, we just return the value
without calling force_unicode on it. Note that if you set a
callable as a default, the field will still call it. It will
*not* try to pickle and encode it.
"""
if self.has_default():
if callable(self.default):
return self.default()
return copy.deepcopy(self.default)
# If the field doesn't have a default, then we punt to models.Field.
return super(JSONFieldBase, self).get_default()
class JSONField(JSONFieldBase, models.TextField):
"""JSONField is a generic textfield that serializes/deserializes JSON objects"""
form_class = JSONFormField
def dumps_for_display(self, value):
kwargs = {"indent": 2}
kwargs.update(self.dump_kwargs)
return json.dumps(value, **kwargs)
class JSONCharField(JSONFieldBase, models.CharField):
"""JSONCharField is a generic textfield that serializes/deserializes JSON objects,
stored in the database like a CharField, which enables it to be used
e.g. in unique keys"""
form_class = JSONCharFormField
try:
from south.modelsinspector import add_introspection_rules
add_introspection_rules([], ["^jsonfield\.fields\.(JSONField|JSONCharField)"])
except ImportError:
pass

@ -0,0 +1 @@
# Django needs this to see it as a project

@ -0,0 +1,62 @@
# This file was copied from django.db.models.fields.subclassing so that we could
# change the Creator.__set__ behavior. Read the comment below for full details.
"""
Convenience routines for creating non-trivial Field subclasses, as well as
backwards compatibility utilities.
Add SubfieldBase as the __metaclass__ for your Field subclass, implement
to_python() and the other necessary methods and everything will work seamlessly.
"""
class SubfieldBase(type):
"""
A metaclass for custom Field subclasses. This ensures the model's attribute
has the descriptor protocol attached to it.
"""
def __new__(cls, name, bases, attrs):
new_class = super(SubfieldBase, cls).__new__(cls, name, bases, attrs)
new_class.contribute_to_class = make_contrib(
new_class, attrs.get('contribute_to_class')
)
return new_class
class Creator(object):
"""
A placeholder class that provides a way to set the attribute on the model.
"""
def __init__(self, field):
self.field = field
def __get__(self, obj, type=None):
if obj is None:
return self
return obj.__dict__[self.field.name]
def __set__(self, obj, value):
# Usually this would call to_python, but we've changed it to pre_init
# so that we can tell which state we're in. By passing an obj,
# we can definitively tell if a value has already been deserialized
# More: https://github.com/bradjasper/django-jsonfield/issues/33
obj.__dict__[self.field.name] = self.field.pre_init(value, obj)
def make_contrib(superclass, func=None):
"""
Returns a suitable contribute_to_class() method for the Field subclass.
If 'func' is passed in, it is the existing contribute_to_class() method on
the subclass and it is called before anything else. It is assumed in this
case that the existing contribute_to_class() calls all the necessary
superclass methods.
"""
def contribute_to_class(self, cls, name):
if func:
func(self, cls, name)
else:
super(superclass, self).contribute_to_class(cls, name)
setattr(cls, self.name, Creator(self))
return contribute_to_class

@ -0,0 +1,392 @@
from decimal import Decimal
import django
from django import forms
from django.core.serializers import deserialize, serialize
from django.core.serializers.base import DeserializationError
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.test import TestCase
try:
import json
except ImportError:
from django.utils import simplejson as json
from .fields import JSONField, JSONCharField
try:
from django.forms.utils import ValidationError
except ImportError:
from django.forms.util import ValidationError
from django.utils.six import string_types
from collections import OrderedDict
class JsonModel(models.Model):
json = JSONField()
default_json = JSONField(default={"check": 12})
complex_default_json = JSONField(default=[{"checkcheck": 1212}])
empty_default = JSONField(default={})
class GenericForeignKeyObj(models.Model):
name = models.CharField('Foreign Obj', max_length=255, null=True)
class JSONModelWithForeignKey(models.Model):
json = JSONField(null=True)
foreign_obj = GenericForeignKey()
object_id = models.PositiveIntegerField(blank=True, null=True, db_index=True)
content_type = models.ForeignKey(ContentType, blank=True, null=True,
on_delete=models.CASCADE)
class JsonCharModel(models.Model):
json = JSONCharField(max_length=100)
default_json = JSONCharField(max_length=100, default={"check": 34})
class ComplexEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, complex):
return {
'__complex__': True,
'real': obj.real,
'imag': obj.imag,
}
return json.JSONEncoder.default(self, obj)
def as_complex(dct):
if '__complex__' in dct:
return complex(dct['real'], dct['imag'])
return dct
class JSONModelCustomEncoders(models.Model):
# A JSON field that can store complex numbers
json = JSONField(
dump_kwargs={'cls': ComplexEncoder, "indent": 4},
load_kwargs={'object_hook': as_complex},
)
class JSONModelWithForeignKeyTestCase(TestCase):
def test_object_create(self):
foreign_obj = GenericForeignKeyObj.objects.create(name='Brain')
JSONModelWithForeignKey.objects.create(foreign_obj=foreign_obj)
class JSONFieldTest(TestCase):
"""JSONField Wrapper Tests"""
json_model = JsonModel
def test_json_field_create(self):
"""Test saving a JSON object in our JSONField"""
json_obj = {
"item_1": "this is a json blah",
"blergh": "hey, hey, hey"}
obj = self.json_model.objects.create(json=json_obj)
new_obj = self.json_model.objects.get(id=obj.id)
self.assertEqual(new_obj.json, json_obj)
def test_string_in_json_field(self):
"""Test saving an ordinary Python string in our JSONField"""
json_obj = 'blah blah'
obj = self.json_model.objects.create(json=json_obj)
new_obj = self.json_model.objects.get(id=obj.id)
self.assertEqual(new_obj.json, json_obj)
def test_float_in_json_field(self):
"""Test saving a Python float in our JSONField"""
json_obj = 1.23
obj = self.json_model.objects.create(json=json_obj)
new_obj = self.json_model.objects.get(id=obj.id)
self.assertEqual(new_obj.json, json_obj)
def test_int_in_json_field(self):
"""Test saving a Python integer in our JSONField"""
json_obj = 1234567
obj = self.json_model.objects.create(json=json_obj)
new_obj = self.json_model.objects.get(id=obj.id)
self.assertEqual(new_obj.json, json_obj)
def test_decimal_in_json_field(self):
"""Test saving a Python Decimal in our JSONField"""
json_obj = Decimal(12.34)
obj = self.json_model.objects.create(json=json_obj)
new_obj = self.json_model.objects.get(id=obj.id)
# here we must know to convert the returned string back to Decimal,
# since json does not support that format
self.assertEqual(Decimal(new_obj.json), json_obj)
def test_json_field_modify(self):
"""Test modifying a JSON object in our JSONField"""
json_obj_1 = {'a': 1, 'b': 2}
json_obj_2 = {'a': 3, 'b': 4}
obj = self.json_model.objects.create(json=json_obj_1)
self.assertEqual(obj.json, json_obj_1)
obj.json = json_obj_2
self.assertEqual(obj.json, json_obj_2)
obj.save()
self.assertEqual(obj.json, json_obj_2)
self.assertTrue(obj)
def test_json_field_load(self):
"""Test loading a JSON object from the DB"""
json_obj_1 = {'a': 1, 'b': 2}
obj = self.json_model.objects.create(json=json_obj_1)
new_obj = self.json_model.objects.get(id=obj.id)
self.assertEqual(new_obj.json, json_obj_1)
def test_json_list(self):
"""Test storing a JSON list"""
json_obj = ["my", "list", "of", 1, "objs", {"hello": "there"}]
obj = self.json_model.objects.create(json=json_obj)
new_obj = self.json_model.objects.get(id=obj.id)
self.assertEqual(new_obj.json, json_obj)
def test_empty_objects(self):
"""Test storing empty objects"""
for json_obj in [{}, [], 0, '', False]:
obj = self.json_model.objects.create(json=json_obj)
new_obj = self.json_model.objects.get(id=obj.id)
self.assertEqual(json_obj, obj.json)
self.assertEqual(json_obj, new_obj.json)
def test_custom_encoder(self):
"""Test encoder_cls and object_hook"""
value = 1 + 3j # A complex number
obj = JSONModelCustomEncoders.objects.create(json=value)
new_obj = JSONModelCustomEncoders.objects.get(pk=obj.pk)
self.assertEqual(value, new_obj.json)
def test_django_serializers(self):
"""Test serializing/deserializing jsonfield data"""
for json_obj in [{}, [], 0, '', False, {'key': 'value', 'num': 42,
'ary': list(range(5)),
'dict': {'k': 'v'}}]:
obj = self.json_model.objects.create(json=json_obj)
new_obj = self.json_model.objects.get(id=obj.id)
self.assert_(new_obj)
queryset = self.json_model.objects.all()
ser = serialize('json', queryset)
for dobj in deserialize('json', ser):
obj = dobj.object
pulled = self.json_model.objects.get(id=obj.pk)
self.assertEqual(obj.json, pulled.json)
def test_default_parameters(self):
"""Test providing a default value to the model"""
model = JsonModel()
model.json = {"check": 12}
self.assertEqual(model.json, {"check": 12})
self.assertEqual(type(model.json), dict)
self.assertEqual(model.default_json, {"check": 12})
self.assertEqual(type(model.default_json), dict)
def test_invalid_json(self):
# invalid json data {] in the json and default_json fields
ser = '[{"pk": 1, "model": "jsonfield.jsoncharmodel", ' \
'"fields": {"json": "{]", "default_json": "{]"}}]'
with self.assertRaises(DeserializationError) as cm:
next(deserialize('json', ser))
# Django 2.0+ uses PEP 3134 exception chaining
if django.VERSION < (2, 0,):
inner = cm.exception.args[0]
else:
inner = cm.exception.__context__
self.assertTrue(isinstance(inner, ValidationError))
self.assertEqual('Enter valid JSON', inner.messages[0])
def test_integer_in_string_in_json_field(self):
"""Test saving the Python string '123' in our JSONField"""
json_obj = '123'
obj = self.json_model.objects.create(json=json_obj)
new_obj = self.json_model.objects.get(id=obj.id)
self.assertEqual(new_obj.json, json_obj)
def test_boolean_in_string_in_json_field(self):
"""Test saving the Python string 'true' in our JSONField"""
json_obj = 'true'
obj = self.json_model.objects.create(json=json_obj)
new_obj = self.json_model.objects.get(id=obj.id)
self.assertEqual(new_obj.json, json_obj)
def test_pass_by_reference_pollution(self):
"""Make sure the default parameter is copied rather than passed by reference"""
model = JsonModel()
model.default_json["check"] = 144
model.complex_default_json[0]["checkcheck"] = 144
self.assertEqual(model.default_json["check"], 144)
self.assertEqual(model.complex_default_json[0]["checkcheck"], 144)
# Make sure when we create a new model, it resets to the default value
# and not to what we just set it to (it would be if it were passed by reference)
model = JsonModel()
self.assertEqual(model.default_json["check"], 12)
self.assertEqual(model.complex_default_json[0]["checkcheck"], 1212)
def test_normal_regex_filter(self):
"""Make sure JSON model can filter regex"""
JsonModel.objects.create(json={"boom": "town"})
JsonModel.objects.create(json={"move": "town"})
JsonModel.objects.create(json={"save": "town"})
self.assertEqual(JsonModel.objects.count(), 3)
self.assertEqual(JsonModel.objects.filter(json__regex=r"boom").count(), 1)
self.assertEqual(JsonModel.objects.filter(json__regex=r"town").count(), 3)
def test_save_blank_object(self):
"""Test that JSON model can save a blank object as none"""
model = JsonModel()
self.assertEqual(model.empty_default, {})
model.save()
self.assertEqual(model.empty_default, {})
model1 = JsonModel(empty_default={"hey": "now"})
self.assertEqual(model1.empty_default, {"hey": "now"})
model1.save()
self.assertEqual(model1.empty_default, {"hey": "now"})
class JSONCharFieldTest(JSONFieldTest):
json_model = JsonCharModel
class OrderedJsonModel(models.Model):
json = JSONField(load_kwargs={'object_pairs_hook': OrderedDict})
class OrderedDictSerializationTest(TestCase):
def setUp(self):
self.ordered_dict = OrderedDict([
('number', [1, 2, 3, 4]),
('notes', True),
('alpha', True),
('romeo', True),
('juliet', True),
('bravo', True),
])
self.expected_key_order = ['number', 'notes', 'alpha', 'romeo', 'juliet', 'bravo']
def test_ordered_dict_differs_from_normal_dict(self):
self.assertEqual(list(self.ordered_dict.keys()), self.expected_key_order)
self.assertNotEqual(dict(self.ordered_dict).keys(), self.expected_key_order)
def test_default_behaviour_loses_sort_order(self):
mod = JsonModel.objects.create(json=self.ordered_dict)
self.assertEqual(list(mod.json.keys()), self.expected_key_order)
mod_from_db = JsonModel.objects.get(id=mod.id)
# mod_from_db lost ordering information during json.loads()
self.assertNotEqual(mod_from_db.json.keys(), self.expected_key_order)
def test_load_kwargs_hook_does_not_lose_sort_order(self):
mod = OrderedJsonModel.objects.create(json=self.ordered_dict)
self.assertEqual(list(mod.json.keys()), self.expected_key_order)
mod_from_db = OrderedJsonModel.objects.get(id=mod.id)
self.assertEqual(list(mod_from_db.json.keys()), self.expected_key_order)
class JsonNotRequiredModel(models.Model):
json = JSONField(blank=True, null=True)
class JsonNotRequiredForm(forms.ModelForm):
class Meta:
model = JsonNotRequiredModel
fields = '__all__'
class JsonModelFormTest(TestCase):
def test_blank_form(self):
form = JsonNotRequiredForm(data={'json': ''})
self.assertFalse(form.has_changed())
def test_form_with_data(self):
form = JsonNotRequiredForm(data={'json': '{}'})
self.assertTrue(form.has_changed())
class TestFieldAPIMethods(TestCase):
def test_get_db_prep_value_method_with_null(self):
json_field_instance = JSONField(null=True)
value = {'a': 1}
prepared_value = json_field_instance.get_db_prep_value(
value, connection=None, prepared=False)
self.assertIsInstance(prepared_value, string_types)
self.assertDictEqual(value, json.loads(prepared_value))
self.assertIs(json_field_instance.get_db_prep_value(
None, connection=None, prepared=True), None)
self.assertIs(json_field_instance.get_db_prep_value(
None, connection=None, prepared=False), None)
def test_get_db_prep_value_method_with_not_null(self):
json_field_instance = JSONField(null=False)
value = {'a': 1}
prepared_value = json_field_instance.get_db_prep_value(
value, connection=None, prepared=False)
self.assertIsInstance(prepared_value, string_types)
self.assertDictEqual(value, json.loads(prepared_value))
self.assertIs(json_field_instance.get_db_prep_value(
None, connection=None, prepared=True), None)
self.assertEqual(json_field_instance.get_db_prep_value(
None, connection=None, prepared=False), 'null')
def test_get_db_prep_value_method_skips_prepared_values(self):
json_field_instance = JSONField(null=False)
value = {'a': 1}
prepared_value = json_field_instance.get_db_prep_value(
value, connection=None, prepared=True)
self.assertIs(prepared_value, value)
def test_get_prep_value_always_json_dumps_if_not_null(self):
json_field_instance = JSONField(null=False)
value = {'a': 1}
prepared_value = json_field_instance.get_prep_value(value)
self.assertIsInstance(prepared_value, string_types)
self.assertDictEqual(value, json.loads(prepared_value))
already_json = json.dumps(value)
double_prepared_value = json_field_instance.get_prep_value(
already_json)
self.assertDictEqual(value,
json.loads(json.loads(double_prepared_value)))
self.assertEqual(json_field_instance.get_prep_value(None), 'null')
def test_get_prep_value_can_return_none_if_null(self):
json_field_instance = JSONField(null=True)
value = {'a': 1}
prepared_value = json_field_instance.get_prep_value(value)
self.assertIsInstance(prepared_value, string_types)
self.assertDictEqual(value, json.loads(prepared_value))
already_json = json.dumps(value)
double_prepared_value = json_field_instance.get_prep_value(
already_json)
self.assertDictEqual(value,
json.loads(json.loads(double_prepared_value)))
self.assertIs(json_field_instance.get_prep_value(None), None)

@ -0,0 +1,5 @@
VERSION = (3, 1, 0)
from .backends import EmailBackend
default_app_config = 'post_office.apps.PostOfficeConfig'

@ -0,0 +1,153 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django import forms
from django.db import models
from django.contrib import admin
from django.conf import settings
from django.forms.widgets import TextInput
from django.utils import six
from django.utils.text import Truncator
from django.utils.translation import ugettext_lazy as _
from .fields import CommaSeparatedEmailField
from .models import Attachment, Log, Email, EmailTemplate, STATUS
def get_message_preview(instance):
return (u'{0}...'.format(instance.message[:25]) if len(instance.message) > 25
else instance.message)
get_message_preview.short_description = 'Message'
class LogInline(admin.StackedInline):
model = Log
extra = 0
class CommaSeparatedEmailWidget(TextInput):
def __init__(self, *args, **kwargs):
super(CommaSeparatedEmailWidget, self).__init__(*args, **kwargs)
self.attrs.update({'class': 'vTextField'})
def _format_value(self, value):
# If the value is a string wrap it in a list so it does not get sliced.
if not value:
return ''
if isinstance(value, six.string_types):
value = [value, ]
return ','.join([item for item in value])
def requeue(modeladmin, request, queryset):
"""An admin action to requeue emails."""
queryset.update(status=STATUS.queued)
requeue.short_description = 'Requeue selected emails'
class EmailAdmin(admin.ModelAdmin):
list_display = ('id', 'to_display', 'subject', 'template',
'status', 'last_updated')
search_fields = ['to', 'subject']
date_hierarchy = 'last_updated'
inlines = [LogInline]
list_filter = ['status']
formfield_overrides = {
CommaSeparatedEmailField: {'widget': CommaSeparatedEmailWidget}
}
actions = [requeue]
def get_queryset(self, request):
return super(EmailAdmin, self).get_queryset(request).select_related('template')
def to_display(self, instance):
return ', '.join(instance.to)
to_display.short_description = 'to'
to_display.admin_order_field = 'to'
class LogAdmin(admin.ModelAdmin):
list_display = ('date', 'email', 'status', get_message_preview)
class SubjectField(TextInput):
def __init__(self, *args, **kwargs):
super(SubjectField, self).__init__(*args, **kwargs)
self.attrs.update({'style': 'width: 610px;'})
class EmailTemplateAdminForm(forms.ModelForm):
language = forms.ChoiceField(choices=settings.LANGUAGES, required=False,
help_text=_("Render template in alternative language"),
label=_("Language"))
class Meta:
model = EmailTemplate
fields = ('name', 'description', 'subject',
'content', 'html_content', 'language', 'default_template')
class EmailTemplateInline(admin.StackedInline):
form = EmailTemplateAdminForm
model = EmailTemplate
extra = 0
fields = ('language', 'subject', 'content', 'html_content',)
formfield_overrides = {
models.CharField: {'widget': SubjectField}
}
def get_max_num(self, request, obj=None, **kwargs):
return len(settings.LANGUAGES)
class EmailTemplateAdmin(admin.ModelAdmin):
form = EmailTemplateAdminForm
list_display = ('name', 'description_shortened', 'subject', 'languages_compact', 'created')
search_fields = ('name', 'description', 'subject')
fieldsets = [
(None, {
'fields': ('name', 'description'),
}),
(_("Default Content"), {
'fields': ('subject', 'content', 'html_content'),
}),
]
inlines = (EmailTemplateInline,) if settings.USE_I18N else ()
formfield_overrides = {
models.CharField: {'widget': SubjectField}
}
def get_queryset(self, request):
return self.model.objects.filter(default_template__isnull=True)
def description_shortened(self, instance):
return Truncator(instance.description.split('\n')[0]).chars(200)
description_shortened.short_description = _("Description")
description_shortened.admin_order_field = 'description'
def languages_compact(self, instance):
languages = [tt.language for tt in instance.translated_templates.order_by('language')]
return ', '.join(languages)
languages_compact.short_description = _("Languages")
def save_model(self, request, obj, form, change):
obj.save()
# if the name got changed, also change the translated templates to match again
if 'name' in form.changed_data:
obj.translated_templates.update(name=obj.name)
class AttachmentAdmin(admin.ModelAdmin):
list_display = ('name', 'file', )
admin.site.register(Email, EmailAdmin)
admin.site.register(Log, LogAdmin)
admin.site.register(EmailTemplate, EmailTemplateAdmin)
admin.site.register(Attachment, AttachmentAdmin)

@ -0,0 +1,7 @@
from django.apps import AppConfig
from django.utils.translation import ugettext_lazy as _
class PostOfficeConfig(AppConfig):
name = 'post_office'
verbose_name = _("Post Office")

@ -0,0 +1,56 @@
from django.core.files.base import ContentFile
from django.core.mail.backends.base import BaseEmailBackend
from .settings import get_default_priority
class EmailBackend(BaseEmailBackend):
def open(self):
pass
def close(self):
pass
def send_messages(self, email_messages):
"""
Queue one or more EmailMessage objects and returns the number of
email messages sent.
"""
from .mail import create
from .utils import create_attachments
if not email_messages:
return
for email_message in email_messages:
subject = email_message.subject
from_email = email_message.from_email
message = email_message.body
headers = email_message.extra_headers
# Check whether email has 'text/html' alternative
alternatives = getattr(email_message, 'alternatives', ())
for alternative in alternatives:
if alternative[1].startswith('text/html'):
html_message = alternative[0]
break
else:
html_message = ''
attachment_files = dict([(name, ContentFile(content))
for name, content, _ in email_message.attachments])
email = create(sender=from_email,
recipients=email_message.to, cc=email_message.cc,
bcc=email_message.bcc, subject=subject,
message=message, html_message=html_message,
headers=headers)
if attachment_files:
attachments = create_attachments(attachment_files)
email.attachments.add(*attachments)
if get_default_priority() == 'now':
email.dispatch()

@ -0,0 +1,26 @@
from django.template.defaultfilters import slugify
from .settings import get_cache_backend
# Stripped down version of caching functions from django-dbtemplates
# https://github.com/jezdez/django-dbtemplates/blob/develop/dbtemplates/utils/cache.py
cache_backend = get_cache_backend()
def get_cache_key(name):
"""
Prefixes and slugify the key name
"""
return 'post_office:template:%s' % (slugify(name))
def set(name, content):
return cache_backend.set(get_cache_key(name), content)
def get(name):
return cache_backend.get(get_cache_key(name))
def delete(name):
return cache_backend.delete(get_cache_key(name))

@ -0,0 +1,46 @@
try:
import importlib
except ImportError:
from django.utils import importlib
try:
from logging.config import dictConfig # Python >= 2.7
except ImportError:
from django.utils.log import dictConfig # Django <= 1.9
import sys
PY2 = sys.version_info[0] == 2
PY3 = sys.version_info[0] == 3
if PY3:
string_types = str
text_type = str
else:
string_types = basestring
text_type = unicode
try:
from django.core.cache import caches # Django >= 1.7
def get_cache(name):
return caches[name]
except ImportError:
from django.core.cache import get_cache
try:
from django.utils.encoding import smart_text # For Django >= 1.5
except ImportError:
from django.utils.encoding import smart_unicode as smart_text
# Django 1.4 doesn't have ``import_string`` or ``import_by_path``
def import_attribute(name):
"""Return an attribute from a dotted path name (e.g. "path.to.func")."""
module_name, attribute = name.rsplit('.', 1)
module = importlib.import_module(module_name)
return getattr(module, attribute)

@ -0,0 +1,44 @@
from threading import local
from django.core.mail import get_connection
from .settings import get_backend
# Copied from Django 1.8's django.core.cache.CacheHandler
class ConnectionHandler(object):
"""
A Cache Handler to manage access to Cache instances.
Ensures only one instance of each alias exists per thread.
"""
def __init__(self):
self._connections = local()
def __getitem__(self, alias):
try:
return self._connections.connections[alias]
except AttributeError:
self._connections.connections = {}
except KeyError:
pass
try:
backend = get_backend(alias)
except KeyError:
raise KeyError('%s is not a valid backend alias' % alias)
connection = get_connection(backend)
connection.open()
self._connections.connections[alias] = connection
return connection
def all(self):
return getattr(self._connections, 'connections', {}).values()
def close(self):
for connection in self.all():
connection.close()
connections = ConnectionHandler()

@ -0,0 +1,58 @@
from django.db.models import TextField
from django.utils import six
from django.utils.translation import ugettext_lazy as _
from .validators import validate_comma_separated_emails
class CommaSeparatedEmailField(TextField):
default_validators = [validate_comma_separated_emails]
description = _("Comma-separated emails")
def __init__(self, *args, **kwargs):
kwargs['blank'] = True
super(CommaSeparatedEmailField, self).__init__(*args, **kwargs)
def formfield(self, **kwargs):
defaults = {
'error_messages': {
'invalid': _('Only comma separated emails are allowed.'),
}
}
defaults.update(kwargs)
return super(CommaSeparatedEmailField, self).formfield(**defaults)
def from_db_value(self, value, expression, connection, context):
return self.to_python(value)
def get_prep_value(self, value):
"""
We need to accomodate queries where a single email,
or list of email addresses is supplied as arguments. For example:
- Email.objects.filter(to='mail@example.com')
- Email.objects.filter(to=['one@example.com', 'two@example.com'])
"""
if isinstance(value, six.string_types):
return value
else:
return ', '.join(map(lambda s: s.strip(), value))
def to_python(self, value):
if isinstance(value, six.string_types):
if value == '':
return []
else:
return [s.strip() for s in value.split(',')]
else:
return value
def south_field_triple(self):
"""
Return a suitable description of this field for South.
Taken from smiley chris' easy_thumbnails
"""
from south.modelsinspector import introspector
field_class = 'django.db.models.fields.TextField'
args, kwargs = introspector(self)
return (field_class, args, kwargs)

@ -0,0 +1,130 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-07-06 07:47+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: admin.py:97
#, fuzzy
msgid "Default Content"
msgstr "Inhalt"
#: admin.py:111
msgid "Description"
msgstr "Beschreibung"
#: admin.py:117
msgid "Languages"
msgstr "Sprachen"
#: fields.py:11
msgid "Comma-separated emails"
msgstr "Durch Kommas getrennte Emails"
#: fields.py:20
msgid "Only comma separated emails are allowed."
msgstr "Nur durch Kommas getrennte Emails sind erlaubt"
#: models.py:38
msgid "low"
msgstr "niedrig"
#: models.py:38
msgid "medium"
msgstr "mittel"
#: models.py:39
msgid "high"
msgstr "hoch"
#: models.py:39
msgid "now"
msgstr "sofort"
#: models.py:40 models.py:169
msgid "sent"
msgstr "gesendet"
#: models.py:40 models.py:169
msgid "failed"
msgstr "fehlgeschlagen"
#: models.py:41
msgid "queued"
msgstr "in der Warteschleife"
#: models.py:44
msgid "Email From"
msgstr "Email Von"
#: models.py:45
msgid "Email To"
msgstr "Email An"
#: models.py:46
msgid "Cc"
msgstr "Kopie"
#: models.py:47
msgid "Bcc"
msgstr "Blinde Kopie"
#: models.py:48 models.py:194
msgid "Subject"
msgstr "Betreff"
#: models.py:49
msgid "Message"
msgstr "Nachricht"
#: models.py:50
msgid "HTML Message"
msgstr "HTML Nachricht"
#: models.py:188
msgid "e.g: 'welcome_email'"
msgstr "z.B. 'welcome_email'"
#: models.py:190
msgid "Description of this template."
msgstr "Beschreibung dieser Vorlage"
#: models.py:196
msgid "Content"
msgstr "Inhalt"
#: models.py:198
msgid "HTML content"
msgstr "HTML Inhalt"
#: models.py:200
msgid "Render template in alternative language"
msgstr "Vorlage in alternativer Sprache rendern"
#: models.py:208
#, fuzzy
msgid "Email Template"
msgstr "Email Vorlage"
#: models.py:209
#, fuzzy
msgid "Email Templates"
msgstr "Email Vorlagen"
#: models.py:236
msgid "The original filename"
msgstr "Ursprünglicher Dateiname"

@ -0,0 +1,130 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-07-06 07:47+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: admin.py:97
#, fuzzy
msgid "Default Content"
msgstr "Contenuto"
#: admin.py:111
msgid "Description"
msgstr "Descrizione"
#: admin.py:117
msgid "Languages"
msgstr "Lingue"
#: fields.py:11
msgid "Comma-separated emails"
msgstr "Emails separati da virgole"
#: fields.py:20
msgid "Only comma separated emails are allowed."
msgstr "Sono consentiti soltanto emails separati da virgole"
#: models.py:38
msgid "low"
msgstr "bassa"
#: models.py:38
msgid "medium"
msgstr "media"
#: models.py:39
msgid "high"
msgstr "alta"
#: models.py:39
msgid "now"
msgstr "immediata"
#: models.py:40 models.py:169
msgid "sent"
msgstr "inviato"
#: models.py:40 models.py:169
msgid "failed"
msgstr "fallito"
#: models.py:41
msgid "queued"
msgstr "in attesa"
#: models.py:44
msgid "Email From"
msgstr "Email da"
#: models.py:45
msgid "Email To"
msgstr "Email per"
#: models.py:46
msgid "Cc"
msgstr "Copia"
#: models.py:47
msgid "Bcc"
msgstr "Bcc"
#: models.py:48 models.py:194
msgid "Subject"
msgstr "Soggetto"
#: models.py:49
msgid "Message"
msgstr "Messaggio"
#: models.py:50
msgid "HTML Message"
msgstr "HTML Messaggio"
#: models.py:188
msgid "e.g: 'welcome_email'"
msgstr "z.B. 'welcome_email'"
#: models.py:190
msgid "Description of this template."
msgstr "Descrizione di questa template."
#: models.py:196
msgid "Content"
msgstr "Contenuto"
#: models.py:198
msgid "HTML content"
msgstr "Contenuto in HTML"
#: models.py:200
msgid "Render template in alternative language"
msgstr "Rendere template in un altra lingua"
#: models.py:208
#, fuzzy
msgid "Email Template"
msgstr "Email Template"
#: models.py:209
#, fuzzy
msgid "Email Templates"
msgstr "Email Templates"
#: models.py:236
msgid "The original filename"
msgstr "Nome del file originale"

@ -0,0 +1,206 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2016-06-09 13:58+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 "
"|| n%100>=20) ? 1 : 2);\n"
#: post_office/admin.py:81 post_office/models.py:200
msgid "Render template in alternative language"
msgstr "Wygeneruj szablon w alternatywnym języku"
#: post_office/admin.py:82 post_office/models.py:199
msgid "Language"
msgstr "Język"
#: post_office/admin.py:111
msgid "Default Content"
msgstr "Domyślna zawartość"
#: post_office/admin.py:125 post_office/models.py:188
msgid "Description"
msgstr "Opis"
#: post_office/admin.py:131
msgid "Languages"
msgstr "Języki"
#: post_office/fields.py:11
msgid "Comma-separated emails"
msgstr "Oddzielone przecinkami emaile"
#: post_office/fields.py:20
msgid "Only comma separated emails are allowed."
msgstr "Tylko oddzielone przecinkami emaile są dozwolone."
#: post_office/models.py:36
msgid "low"
msgstr "niski"
#: post_office/models.py:36
msgid "medium"
msgstr "średni"
#: post_office/models.py:37
msgid "high"
msgstr "wysoki"
#: post_office/models.py:37
msgid "now"
msgstr "natychmiastowy"
#: post_office/models.py:38 post_office/models.py:164
msgid "sent"
msgstr "wysłany"
#: post_office/models.py:38 post_office/models.py:164
msgid "failed"
msgstr "odrzucony"
#: post_office/models.py:39
msgid "queued"
msgstr "w kolejce"
#: post_office/models.py:41
msgid "Email From"
msgstr "Email od"
#: post_office/models.py:43
msgid "Email To"
msgstr "Email do"
#: post_office/models.py:44
msgid "Cc"
msgstr "Cc"
#: post_office/models.py:46 post_office/models.py:193
msgid "Subject"
msgstr "Temat"
#: post_office/models.py:47 post_office/models.py:171
msgid "Message"
msgstr "Wiadomość"
#: post_office/models.py:48
msgid "HTML Message"
msgstr "Wiadomość w HTML"
#: post_office/models.py:55 post_office/models.py:169
msgid "Status"
msgstr "Status"
#: post_office/models.py:58
msgid "Priority"
msgstr "Priorytet"
#: post_office/models.py:63
msgid "The scheduled sending time"
msgstr "Zaplanowany czas wysłania"
#: post_office/models.py:65
msgid "Headers"
msgstr "Nagłówki"
#: post_office/models.py:67
msgid "Email template"
msgstr "Szablon emaila"
#: post_office/models.py:68
msgid "Context"
msgstr "Kontekst"
#: post_office/models.py:69
msgid "Backend alias"
msgstr "Backend alias"
#: post_office/models.py:74
msgctxt "Email address"
msgid "Email"
msgstr "Email"
#: post_office/models.py:75
msgctxt "Email addresses"
msgid "Emails"
msgstr "Emaile"
#: post_office/models.py:167
msgid "Email address"
msgstr "Adres email"
#: post_office/models.py:170
msgid "Exception type"
msgstr "Typ wyjątku"
#: post_office/models.py:175
msgid "Log"
msgstr "Log"
#: post_office/models.py:176
msgid "Logs"
msgstr "Logi"
#: post_office/models.py:187 post_office/models.py:241
msgid "Name"
msgstr "Nazwa"
#: post_office/models.py:187
msgid "e.g: 'welcome_email'"
msgstr "np: 'powitalny_email'"
#: post_office/models.py:189
msgid "Description of this template."
msgstr "Opis tego szablonu"
#: post_office/models.py:195
msgid "Content"
msgstr "Zawartość"
#: post_office/models.py:197
msgid "HTML content"
msgstr "Zawartość w HTML"
#: post_office/models.py:203
msgid "Default template"
msgstr "Domyślna zawartość"
#: post_office/models.py:208
msgid "Email Template"
msgstr "Szablon emaila"
#: post_office/models.py:209
msgid "Email Templates"
msgstr "Szablony emaili"
#: post_office/models.py:240
msgid "File"
msgstr "Plik"
#: post_office/models.py:241
msgid "The original filename"
msgstr "Oryginalna nazwa pliku"
#: post_office/models.py:243
msgid "Email addresses"
msgstr "Adresy email"
#: post_office/models.py:247
msgid "Attachment"
msgstr "Załącznik"
#: post_office/models.py:248
msgid "Attachments"
msgstr "Załączniki"

@ -0,0 +1,211 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2017-08-07 16:47+0300\n"
"PO-Revision-Date: 2017-08-07 16:49+0300\n"
"Language: ru_RU\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n"
"%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || (n"
"%100>=11 && n%100<=14)? 2 : 3);\n"
"Last-Translator: \n"
"Language-Team: \n"
"X-Generator: Poedit 1.8.11\n"
#: post_office/admin.py:83 post_office/models.py:217
msgid "Render template in alternative language"
msgstr "Отправить письмо на другом языке"
#: post_office/admin.py:84 post_office/models.py:216
msgid "Language"
msgstr "Язык"
#: post_office/admin.py:113
msgid "Default Content"
msgstr "Содержимое по умолчанию"
#: post_office/admin.py:127 post_office/models.py:205
msgid "Description"
msgstr "Описание"
#: post_office/admin.py:133
msgid "Languages"
msgstr "Языки"
#: post_office/apps.py:7
msgid "Post Office"
msgstr "Менеджер почты"
#: post_office/fields.py:10
msgid "Comma-separated emails"
msgstr "Список адресов, разделенных запятыми"
#: post_office/fields.py:19
msgid "Only comma separated emails are allowed."
msgstr "Разрешен только разделенный запятыми список адресов."
#: post_office/models.py:34
msgid "low"
msgstr "низкий"
#: post_office/models.py:34
msgid "medium"
msgstr "средний"
#: post_office/models.py:35
msgid "high"
msgstr "высокий"
#: post_office/models.py:35
msgid "now"
msgstr "сейчас"
#: post_office/models.py:36 post_office/models.py:181
msgid "sent"
msgstr "отправлен"
#: post_office/models.py:36 post_office/models.py:181
msgid "failed"
msgstr "ошибка"
#: post_office/models.py:37
msgid "queued"
msgstr "в очереди"
#: post_office/models.py:39
msgid "Email From"
msgstr "Отправитель"
#: post_office/models.py:41
msgid "Email To"
msgstr "Получатель"
#: post_office/models.py:42
msgid "Cc"
msgstr "Копия"
#: post_office/models.py:44 post_office/models.py:210
msgid "Subject"
msgstr "Тема"
#: post_office/models.py:45 post_office/models.py:188
msgid "Message"
msgstr "Сообщение"
#: post_office/models.py:46
msgid "HTML Message"
msgstr "HTML-сообщение"
#: post_office/models.py:53 post_office/models.py:186
msgid "Status"
msgstr "Статус"
#: post_office/models.py:56
msgid "Priority"
msgstr "Приоритет"
#: post_office/models.py:61
msgid "The scheduled sending time"
msgstr "Запланированное время отправки"
#: post_office/models.py:63
msgid "Headers"
msgstr "Заголовки"
#: post_office/models.py:65
msgid "Email template"
msgstr "Шаблон письма"
#: post_office/models.py:67
msgid "Context"
msgstr "Контекст"
#: post_office/models.py:68
msgid "Backend alias"
msgstr "Имя бекенда"
#: post_office/models.py:73
msgctxt "Email address"
msgid "Email"
msgstr "Письмо"
#: post_office/models.py:74
msgctxt "Email addresses"
msgid "Emails"
msgstr "Письма"
#: post_office/models.py:184
msgid "Email address"
msgstr "Email-адрес"
#: post_office/models.py:187
msgid "Exception type"
msgstr "Тип исключения"
#: post_office/models.py:192
msgid "Log"
msgstr "Лог"
#: post_office/models.py:193
msgid "Logs"
msgstr "Логи"
#: post_office/models.py:204 post_office/models.py:258
msgid "Name"
msgstr "Имя"
#: post_office/models.py:204
msgid "e.g: 'welcome_email'"
msgstr "например: 'welcome_email'"
#: post_office/models.py:206
msgid "Description of this template."
msgstr "Описание шаблона."
#: post_office/models.py:212
msgid "Content"
msgstr "Содержимое"
#: post_office/models.py:214
msgid "HTML content"
msgstr "HTML-содержимое"
#: post_office/models.py:220
msgid "Default template"
msgstr "Шаблон по умолчанию"
#: post_office/models.py:225
msgid "Email Template"
msgstr "Шаблон письма"
#: post_office/models.py:226
msgid "Email Templates"
msgstr "Шаблоны писем"
#: post_office/models.py:257
msgid "File"
msgstr "Файл"
#: post_office/models.py:258
msgid "The original filename"
msgstr "Исходное имя файла"
#: post_office/models.py:260
msgid "Email addresses"
msgstr "Email адреса"
#: post_office/models.py:265
msgid "Attachment"
msgstr "Вложение"
#: post_office/models.py:266
msgid "Attachments"
msgstr "Вложения"

@ -0,0 +1,148 @@
# This module is taken from https://gist.github.com/ionrock/3015700
# A file lock implementation that tries to avoid platform specific
# issues. It is inspired by a whole bunch of different implementations
# listed below.
# - https://bitbucket.org/jaraco/yg.lockfile/src/6c448dcbf6e5/yg/lockfile/__init__.py
# - http://svn.zope.org/zc.lockfile/trunk/src/zc/lockfile/__init__.py?rev=121133&view=markup
# - http://stackoverflow.com/questions/489861/locking-a-file-in-python
# - http://www.evanfosmark.com/2009/01/cross-platform-file-locking-support-in-python/
# - http://packages.python.org/lockfile/lockfile.html
# There are some tests below and a blog posting conceptually the
# problems I wanted to try and solve. The tests reflect these ideas.
# - http://ionrock.wordpress.com/2012/06/28/file-locking-in-python/
# I'm not advocating using this package. But if you do happen to try it
# out and have suggestions please let me know.
import os
import time
class FileLocked(Exception):
pass
class FileLock(object):
def __init__(self, lock_filename, timeout=None, force=False):
self.lock_filename = '%s.lock' % lock_filename
self.timeout = timeout
self.force = force
self._pid = str(os.getpid())
# Store pid in a file in the same directory as desired lockname
self.pid_filename = os.path.join(
os.path.dirname(self.lock_filename),
self._pid,
) + '.lock'
def get_lock_pid(self):
try:
return int(open(self.lock_filename).read())
except IOError:
# If we can't read symbolic link, there are two possibilities:
# 1. The symbolic link is dead (point to non existing file)
# 2. Symbolic link is not there
# In either case, we can safely release the lock
self.release()
def valid_lock(self):
"""
See if the lock exists and is left over from an old process.
"""
lock_pid = self.get_lock_pid()
# If we're unable to get lock_pid
if lock_pid is None:
return False
# this is our process
if self._pid == lock_pid:
return True
# it is/was another process
# see if it is running
try:
os.kill(lock_pid, 0)
except OSError:
self.release()
return False
# it is running
return True
def is_locked(self, force=False):
# We aren't locked
if not self.valid_lock():
return False
# We are locked, but we want to force it without waiting
if not self.timeout:
if self.force:
self.release()
return False
else:
# We're not waiting or forcing the lock
raise FileLocked()
# Locked, but want to wait for an unlock
interval = .1
intervals = int(self.timeout / interval)
while intervals:
if self.valid_lock():
intervals -= 1
time.sleep(interval)
#print('stopping %s' % intervals)
else:
return True
# check one last time
if self.valid_lock():
if self.force:
self.release()
else:
# still locked :(
raise FileLocked()
def acquire(self):
"""Create a pid filename and create a symlink (the actual lock file)
across platforms that points to it. Symlink is used because it's an
atomic operation across platforms.
"""
pid_file = os.open(self.pid_filename, os.O_CREAT | os.O_EXCL | os.O_RDWR)
os.write(pid_file, str(os.getpid()).encode('utf-8'))
os.close(pid_file)
if hasattr(os, 'symlink'):
os.symlink(self.pid_filename, self.lock_filename)
else:
# Windows platforms doesn't support symlinks, at least not through the os API
self.lock_filename = self.pid_filename
def release(self):
"""Try to delete the lock files. Doesn't matter if we fail"""
if self.lock_filename != self.pid_filename:
try:
os.unlink(self.lock_filename)
except OSError:
pass
try:
os.remove(self.pid_filename)
except OSError:
pass
def __enter__(self):
if not self.is_locked():
self.acquire()
return self
def __exit__(self, type, value, traceback):
self.release()

@ -0,0 +1,37 @@
import logging
from .compat import dictConfig
# Taken from https://github.com/nvie/rq/blob/master/rq/logutils.py
def setup_loghandlers(level=None):
# Setup logging for post_office if not already configured
logger = logging.getLogger('post_office')
if not logger.handlers:
dictConfig({
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"post_office": {
"format": "[%(levelname)s]%(asctime)s PID %(process)d: %(message)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
},
},
"handlers": {
"post_office": {
"level": "DEBUG",
"class": "logging.StreamHandler",
"formatter": "post_office"
},
},
"loggers": {
"post_office": {
"handlers": ["post_office"],
"level": level or "DEBUG"
}
}
})
return logger

@ -0,0 +1,305 @@
from multiprocessing import Pool
from multiprocessing.dummy import Pool as ThreadPool
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import connection as db_connection
from django.db.models import Q
from django.template import Context, Template
from django.utils.timezone import now
from .connections import connections
from .models import Email, EmailTemplate, Log, PRIORITY, STATUS
from .settings import (get_available_backends, get_batch_size,
get_log_level, get_sending_order, get_threads_per_process)
from .utils import (get_email_template, parse_emails, parse_priority,
split_emails, create_attachments)
from .logutils import setup_loghandlers
logger = setup_loghandlers("INFO")
def create(sender, recipients=None, cc=None, bcc=None, subject='', message='',
html_message='', context=None, scheduled_time=None, headers=None,
template=None, priority=None, render_on_delivery=False, commit=True,
backend=''):
"""
Creates an email from supplied keyword arguments. If template is
specified, email subject and content will be rendered during delivery.
"""
priority = parse_priority(priority)
status = None if priority == PRIORITY.now else STATUS.queued
if recipients is None:
recipients = []
if cc is None:
cc = []
if bcc is None:
bcc = []
if context is None:
context = ''
# If email is to be rendered during delivery, save all necessary
# information
if render_on_delivery:
email = Email(
from_email=sender,
to=recipients,
cc=cc,
bcc=bcc,
scheduled_time=scheduled_time,
headers=headers, priority=priority, status=status,
context=context, template=template, backend_alias=backend
)
else:
if template:
subject = template.subject
message = template.content
html_message = template.html_content
_context = Context(context or {})
subject = Template(subject).render(_context)
message = Template(message).render(_context)
html_message = Template(html_message).render(_context)
email = Email(
from_email=sender,
to=recipients,
cc=cc,
bcc=bcc,
subject=subject,
message=message,
html_message=html_message,
scheduled_time=scheduled_time,
headers=headers, priority=priority, status=status,
backend_alias=backend
)
if commit:
email.save()
return email
def send(recipients=None, sender=None, template=None, context=None, subject='',
message='', html_message='', scheduled_time=None, headers=None,
priority=None, attachments=None, render_on_delivery=False,
log_level=None, commit=True, cc=None, bcc=None, language='',
backend=''):
try:
recipients = parse_emails(recipients)
except ValidationError as e:
raise ValidationError('recipients: %s' % e.message)
try:
cc = parse_emails(cc)
except ValidationError as e:
raise ValidationError('c: %s' % e.message)
try:
bcc = parse_emails(bcc)
except ValidationError as e:
raise ValidationError('bcc: %s' % e.message)
if sender is None:
sender = settings.DEFAULT_FROM_EMAIL
priority = parse_priority(priority)
if log_level is None:
log_level = get_log_level()
if not commit:
if priority == PRIORITY.now:
raise ValueError("send_many() can't be used with priority = 'now'")
if attachments:
raise ValueError("Can't add attachments with send_many()")
if template:
if subject:
raise ValueError('You can\'t specify both "template" and "subject" arguments')
if message:
raise ValueError('You can\'t specify both "template" and "message" arguments')
if html_message:
raise ValueError('You can\'t specify both "template" and "html_message" arguments')
# template can be an EmailTemplate instance or name
if isinstance(template, EmailTemplate):
template = template
# If language is specified, ensure template uses the right language
if language:
if template.language != language:
template = template.translated_templates.get(language=language)
else:
template = get_email_template(template, language)
if backend and backend not in get_available_backends().keys():
raise ValueError('%s is not a valid backend alias' % backend)
email = create(sender, recipients, cc, bcc, subject, message, html_message,
context, scheduled_time, headers, template, priority,
render_on_delivery, commit=commit, backend=backend)
if attachments:
attachments = create_attachments(attachments)
email.attachments.add(*attachments)
if priority == PRIORITY.now:
email.dispatch(log_level=log_level)
return email
def send_many(kwargs_list):
"""
Similar to mail.send(), but this function accepts a list of kwargs.
Internally, it uses Django's bulk_create command for efficiency reasons.
Currently send_many() can't be used to send emails with priority = 'now'.
"""
emails = []
for kwargs in kwargs_list:
emails.append(send(commit=False, **kwargs))
Email.objects.bulk_create(emails)
def get_queued():
"""
Returns a list of emails that should be sent:
- Status is queued
- Has scheduled_time lower than the current time or None
"""
return Email.objects.filter(status=STATUS.queued) \
.select_related('template') \
.filter(Q(scheduled_time__lte=now()) | Q(scheduled_time=None)) \
.order_by(*get_sending_order()).prefetch_related('attachments')[:get_batch_size()]
def send_queued(processes=1, log_level=None):
"""
Sends out all queued mails that has scheduled_time less than now or None
"""
queued_emails = get_queued()
total_sent, total_failed = 0, 0
total_email = len(queued_emails)
logger.info('Started sending %s emails with %s processes.' %
(total_email, processes))
if log_level is None:
log_level = get_log_level()
if queued_emails:
# Don't use more processes than number of emails
if total_email < processes:
processes = total_email
if processes == 1:
total_sent, total_failed = _send_bulk(queued_emails,
uses_multiprocessing=False,
log_level=log_level)
else:
email_lists = split_emails(queued_emails, processes)
pool = Pool(processes)
results = pool.map(_send_bulk, email_lists)
pool.terminate()
total_sent = sum([result[0] for result in results])
total_failed = sum([result[1] for result in results])
message = '%s emails attempted, %s sent, %s failed' % (
total_email,
total_sent,
total_failed
)
logger.info(message)
return (total_sent, total_failed)
def _send_bulk(emails, uses_multiprocessing=True, log_level=None):
# Multiprocessing does not play well with database connection
# Fix: Close connections on forking process
# https://groups.google.com/forum/#!topic/django-users/eCAIY9DAfG0
if uses_multiprocessing:
db_connection.close()
if log_level is None:
log_level = get_log_level()
sent_emails = []
failed_emails = [] # This is a list of two tuples (email, exception)
email_count = len(emails)
logger.info('Process started, sending %s emails' % email_count)
def send(email):
try:
email.dispatch(log_level=log_level, commit=False,
disconnect_after_delivery=False)
sent_emails.append(email)
logger.debug('Successfully sent email #%d' % email.id)
except Exception as e:
logger.debug('Failed to send email #%d' % email.id)
failed_emails.append((email, e))
# Prepare emails before we send these to threads for sending
# So we don't need to access the DB from within threads
for email in emails:
# Sometimes this can fail, for example when trying to render
# email from a faulty Django template
try:
email.prepare_email_message()
except Exception as e:
failed_emails.append((email, e))
number_of_threads = min(get_threads_per_process(), email_count)
pool = ThreadPool(number_of_threads)
pool.map(send, emails)
pool.close()
pool.join()
connections.close()
# Update statuses of sent and failed emails
email_ids = [email.id for email in sent_emails]
Email.objects.filter(id__in=email_ids).update(status=STATUS.sent)
email_ids = [email.id for (email, e) in failed_emails]
Email.objects.filter(id__in=email_ids).update(status=STATUS.failed)
# If log level is 0, log nothing, 1 logs only sending failures
# and 2 means log both successes and failures
if log_level >= 1:
logs = []
for (email, exception) in failed_emails:
logs.append(
Log(email=email, status=STATUS.failed,
message=str(exception),
exception_type=type(exception).__name__)
)
if logs:
Log.objects.bulk_create(logs)
if log_level == 2:
logs = []
for email in sent_emails:
logs.append(Log(email=email, status=STATUS.sent))
if logs:
Log.objects.bulk_create(logs)
logger.info(
'Process finished, %s attempted, %s sent, %s failed' % (
email_count, len(sent_emails), len(failed_emails)
)
)
return len(sent_emails), len(failed_emails)

@ -0,0 +1,35 @@
import datetime
from django.core.management.base import BaseCommand
from django.utils.timezone import now
from ...models import Attachment, Email
class Command(BaseCommand):
help = 'Place deferred messages back in the queue.'
def add_arguments(self, parser):
parser.add_argument('-d', '--days',
type=int, default=90,
help="Cleanup mails older than this many days, defaults to 90.")
parser.add_argument('-da', '--delete-attachments', action='store_true',
help="Delete orphaned attachments.")
def handle(self, verbosity, days, delete_attachments, **options):
# Delete mails and their related logs and queued created before X days
cutoff_date = now() - datetime.timedelta(days)
count = Email.objects.filter(created__lt=cutoff_date).count()
Email.objects.only('id').filter(created__lt=cutoff_date).delete()
print("Deleted {0} mails created before {1} ".format(count, cutoff_date))
if delete_attachments:
attachments = Attachment.objects.filter(emails=None)
attachments_count = len(attachments)
for attachment in attachments:
# Delete the actual file
attachment.file.delete()
attachments.delete()
print("Deleted {0} attachments".format(attachments_count))

@ -0,0 +1,60 @@
import tempfile
import sys
from django.core.management.base import BaseCommand
from django.db import connection
from django.db.models import Q
from django.utils.timezone import now
from ...lockfile import FileLock, FileLocked
from ...mail import send_queued
from ...models import Email, STATUS
from ...logutils import setup_loghandlers
logger = setup_loghandlers()
default_lockfile = tempfile.gettempdir() + "/post_office"
class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument(
'-p', '--processes',
type=int,
default=1,
help='Number of processes used to send emails',
)
parser.add_argument(
'-L', '--lockfile',
default=default_lockfile,
help='Absolute path of lockfile to acquire',
)
parser.add_argument(
'-l', '--log-level',
type=int,
help='"0" to log nothing, "1" to only log errors',
)
def handle(self, *args, **options):
logger.info('Acquiring lock for sending queued emails at %s.lock' %
options['lockfile'])
try:
with FileLock(options['lockfile']):
while 1:
try:
send_queued(options['processes'],
options.get('log_level'))
except Exception as e:
logger.error(e, exc_info=sys.exc_info(),
extra={'status_code': 500})
raise
# Close DB connection to avoid multiprocessing errors
connection.close()
if not Email.objects.filter(status=STATUS.queued) \
.filter(Q(scheduled_time__lte=now()) | Q(scheduled_time=None)).exists():
break
except FileLocked:
logger.info('Failed to acquire lock, terminating now.')

@ -0,0 +1,93 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
import jsonfield.fields
import post_office.fields
import post_office.validators
import post_office.models
class Migration(migrations.Migration):
dependencies = [
]
operations = [
migrations.CreateModel(
name='Attachment',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('file', models.FileField(upload_to=post_office.models.get_upload_path)),
('name', models.CharField(help_text='The original filename', max_length=255)),
],
options={
},
bases=(models.Model,),
),
migrations.CreateModel(
name='Email',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('from_email', models.CharField(max_length=254, validators=[post_office.validators.validate_email_with_name])),
('to', post_office.fields.CommaSeparatedEmailField(blank=True)),
('cc', post_office.fields.CommaSeparatedEmailField(blank=True)),
('bcc', post_office.fields.CommaSeparatedEmailField(blank=True)),
('subject', models.CharField(max_length=255, blank=True)),
('message', models.TextField(blank=True)),
('html_message', models.TextField(blank=True)),
('status', models.PositiveSmallIntegerField(blank=True, null=True, db_index=True, choices=[(0, 'sent'), (1, 'failed'), (2, 'queued')])),
('priority', models.PositiveSmallIntegerField(blank=True, null=True, choices=[(0, 'low'), (1, 'medium'), (2, 'high'), (3, 'now')])),
('created', models.DateTimeField(auto_now_add=True, db_index=True)),
('last_updated', models.DateTimeField(auto_now=True, db_index=True)),
('scheduled_time', models.DateTimeField(db_index=True, null=True, blank=True)),
('headers', jsonfield.fields.JSONField(null=True, blank=True)),
('context', jsonfield.fields.JSONField(null=True, blank=True)),
],
options={
},
bases=(models.Model,),
),
migrations.CreateModel(
name='EmailTemplate',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('name', models.CharField(help_text=b"e.g: 'welcome_email'", max_length=255)),
('description', models.TextField(help_text='Description of this template.', blank=True)),
('subject', models.CharField(blank=True, max_length=255, validators=[post_office.validators.validate_template_syntax])),
('content', models.TextField(blank=True, validators=[post_office.validators.validate_template_syntax])),
('html_content', models.TextField(blank=True, validators=[post_office.validators.validate_template_syntax])),
('created', models.DateTimeField(auto_now_add=True)),
('last_updated', models.DateTimeField(auto_now=True)),
],
options={
},
bases=(models.Model,),
),
migrations.CreateModel(
name='Log',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('date', models.DateTimeField(auto_now_add=True)),
('status', models.PositiveSmallIntegerField(choices=[(0, 'sent'), (1, 'failed')])),
('exception_type', models.CharField(max_length=255, blank=True)),
('message', models.TextField()),
('email', models.ForeignKey(related_name='logs', editable=False, on_delete=models.deletion.CASCADE, to='post_office.Email', )),
],
options={
},
bases=(models.Model,),
),
migrations.AddField(
model_name='email',
name='template',
field=models.ForeignKey(blank=True, on_delete=models.deletion.SET_NULL, to='post_office.EmailTemplate', null=True),
preserve_default=True,
),
migrations.AddField(
model_name='attachment',
name='emails',
field=models.ManyToManyField(related_name='attachments', to='post_office.Email'),
preserve_default=True,
),
]

@ -0,0 +1,89 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
import post_office.validators
import post_office.fields
class Migration(migrations.Migration):
dependencies = [
('post_office', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='emailtemplate',
options={'verbose_name': 'Email Template', 'verbose_name_plural': 'Email Templates'},
),
migrations.AddField(
model_name='email',
name='backend_alias',
field=models.CharField(default='', max_length=64, blank=True),
),
migrations.AddField(
model_name='emailtemplate',
name='default_template',
field=models.ForeignKey(related_name='translated_templates', default=None, to='post_office.EmailTemplate', null=True, on_delete=models.deletion.SET_NULL),
),
migrations.AddField(
model_name='emailtemplate',
name='language',
field=models.CharField(default='', help_text='Render template in alternative language', max_length=12, blank=True, choices=[(b'af', b'Afrikaans'), (b'ar', b'Arabic'), (b'ast', b'Asturian'), (b'az', b'Azerbaijani'), (b'bg', b'Bulgarian'), (b'be', b'Belarusian'), (b'bn', b'Bengali'), (b'br', b'Breton'), (b'bs', b'Bosnian'), (b'ca', b'Catalan'), (b'cs', b'Czech'), (b'cy', b'Welsh'), (b'da', b'Danish'), (b'de', b'German'), (b'el', b'Greek'), (b'en', b'English'), (b'en-au', b'Australian English'), (b'en-gb', b'British English'), (b'eo', b'Esperanto'), (b'es', b'Spanish'), (b'es-ar', b'Argentinian Spanish'), (b'es-mx', b'Mexican Spanish'), (b'es-ni', b'Nicaraguan Spanish'), (b'es-ve', b'Venezuelan Spanish'), (b'et', b'Estonian'), (b'eu', b'Basque'), (b'fa', b'Persian'), (b'fi', b'Finnish'), (b'fr', b'French'), (b'fy', b'Frisian'), (b'ga', b'Irish'), (b'gl', b'Galician'), (b'he', b'Hebrew'), (b'hi', b'Hindi'), (b'hr', b'Croatian'), (b'hu', b'Hungarian'), (b'ia', b'Interlingua'), (b'id', b'Indonesian'), (b'io', b'Ido'), (b'is', b'Icelandic'), (b'it', b'Italian'), (b'ja', b'Japanese'), (b'ka', b'Georgian'), (b'kk', b'Kazakh'), (b'km', b'Khmer'), (b'kn', b'Kannada'), (b'ko', b'Korean'), (b'lb', b'Luxembourgish'), (b'lt', b'Lithuanian'), (b'lv', b'Latvian'), (b'mk', b'Macedonian'), (b'ml', b'Malayalam'), (b'mn', b'Mongolian'), (b'mr', b'Marathi'), (b'my', b'Burmese'), (b'nb', b'Norwegian Bokmal'), (b'ne', b'Nepali'), (b'nl', b'Dutch'), (b'nn', b'Norwegian Nynorsk'), (b'os', b'Ossetic'), (b'pa', b'Punjabi'), (b'pl', b'Polish'), (b'pt', b'Portuguese'), (b'pt-br', b'Brazilian Portuguese'), (b'ro', b'Romanian'), (b'ru', b'Russian'), (b'sk', b'Slovak'), (b'sl', b'Slovenian'), (b'sq', b'Albanian'), (b'sr', b'Serbian'), (b'sr-latn', b'Serbian Latin'), (b'sv', b'Swedish'), (b'sw', b'Swahili'), (b'ta', b'Tamil'), (b'te', b'Telugu'), (b'th', b'Thai'), (b'tr', b'Turkish'), (b'tt', b'Tatar'), (b'udm', b'Udmurt'), (b'uk', b'Ukrainian'), (b'ur', b'Urdu'), (b'vi', b'Vietnamese'), (b'zh-cn', b'Simplified Chinese'), (b'zh-hans', b'Simplified Chinese'), (b'zh-hant', b'Traditional Chinese'), (b'zh-tw', b'Traditional Chinese')]),
),
migrations.AlterField(
model_name='email',
name='bcc',
field=post_office.fields.CommaSeparatedEmailField(verbose_name='Bcc', blank=True),
),
migrations.AlterField(
model_name='email',
name='cc',
field=post_office.fields.CommaSeparatedEmailField(verbose_name='Cc', blank=True),
),
migrations.AlterField(
model_name='email',
name='from_email',
field=models.CharField(max_length=254, verbose_name='Email From', validators=[post_office.validators.validate_email_with_name]),
),
migrations.AlterField(
model_name='email',
name='html_message',
field=models.TextField(verbose_name='HTML Message', blank=True),
),
migrations.AlterField(
model_name='email',
name='message',
field=models.TextField(verbose_name='Message', blank=True),
),
migrations.AlterField(
model_name='email',
name='subject',
field=models.CharField(max_length=255, verbose_name='Subject', blank=True),
),
migrations.AlterField(
model_name='email',
name='to',
field=post_office.fields.CommaSeparatedEmailField(verbose_name='Email To', blank=True),
),
migrations.AlterField(
model_name='emailtemplate',
name='content',
field=models.TextField(blank=True, verbose_name='Content', validators=[post_office.validators.validate_template_syntax]),
),
migrations.AlterField(
model_name='emailtemplate',
name='html_content',
field=models.TextField(blank=True, verbose_name='HTML content', validators=[post_office.validators.validate_template_syntax]),
),
migrations.AlterField(
model_name='emailtemplate',
name='subject',
field=models.CharField(blank=True, max_length=255, verbose_name='Subject', validators=[post_office.validators.validate_template_syntax]),
),
migrations.AlterUniqueTogether(
name='emailtemplate',
unique_together=set([('language', 'default_template')]),
),
]

@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9 on 2016-02-04 08:08
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('post_office', '0002_add_i18n_and_backend_alias'),
]
operations = [
migrations.AlterField(
model_name='email',
name='subject',
field=models.CharField(blank=True, max_length=989, verbose_name='Subject'),
),
migrations.AlterField(
model_name='emailtemplate',
name='language',
field=models.CharField(blank=True, choices=[(b'af', b'Afrikaans'), (b'ar', b'Arabic'), (b'ast', b'Asturian'), (b'az', b'Azerbaijani'), (b'bg', b'Bulgarian'), (b'be', b'Belarusian'), (b'bn', b'Bengali'), (b'br', b'Breton'), (b'bs', b'Bosnian'), (b'ca', b'Catalan'), (b'cs', b'Czech'), (b'cy', b'Welsh'), (b'da', b'Danish'), (b'de', b'German'), (b'el', b'Greek'), (b'en', b'English'), (b'en-au', b'Australian English'), (b'en-gb', b'British English'), (b'eo', b'Esperanto'), (b'es', b'Spanish'), (b'es-ar', b'Argentinian Spanish'), (b'es-co', b'Colombian Spanish'), (b'es-mx', b'Mexican Spanish'), (b'es-ni', b'Nicaraguan Spanish'), (b'es-ve', b'Venezuelan Spanish'), (b'et', b'Estonian'), (b'eu', b'Basque'), (b'fa', b'Persian'), (b'fi', b'Finnish'), (b'fr', b'French'), (b'fy', b'Frisian'), (b'ga', b'Irish'), (b'gd', b'Scottish Gaelic'), (b'gl', b'Galician'), (b'he', b'Hebrew'), (b'hi', b'Hindi'), (b'hr', b'Croatian'), (b'hu', b'Hungarian'), (b'ia', b'Interlingua'), (b'id', b'Indonesian'), (b'io', b'Ido'), (b'is', b'Icelandic'), (b'it', b'Italian'), (b'ja', b'Japanese'), (b'ka', b'Georgian'), (b'kk', b'Kazakh'), (b'km', b'Khmer'), (b'kn', b'Kannada'), (b'ko', b'Korean'), (b'lb', b'Luxembourgish'), (b'lt', b'Lithuanian'), (b'lv', b'Latvian'), (b'mk', b'Macedonian'), (b'ml', b'Malayalam'), (b'mn', b'Mongolian'), (b'mr', b'Marathi'), (b'my', b'Burmese'), (b'nb', b'Norwegian Bokmal'), (b'ne', b'Nepali'), (b'nl', b'Dutch'), (b'nn', b'Norwegian Nynorsk'), (b'os', b'Ossetic'), (b'pa', b'Punjabi'), (b'pl', b'Polish'), (b'pt', b'Portuguese'), (b'pt-br', b'Brazilian Portuguese'), (b'ro', b'Romanian'), (b'ru', b'Russian'), (b'sk', b'Slovak'), (b'sl', b'Slovenian'), (b'sq', b'Albanian'), (b'sr', b'Serbian'), (b'sr-latn', b'Serbian Latin'), (b'sv', b'Swedish'), (b'sw', b'Swahili'), (b'ta', b'Tamil'), (b'te', b'Telugu'), (b'th', b'Thai'), (b'tr', b'Turkish'), (b'tt', b'Tatar'), (b'udm', b'Udmurt'), (b'uk', b'Ukrainian'), (b'ur', b'Urdu'), (b'vi', b'Vietnamese'), (b'zh-hans', b'Simplified Chinese'), (b'zh-hant', b'Traditional Chinese')], default='', help_text='Render template in alternative language', max_length=12),
),
]

@ -0,0 +1,120 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.6 on 2016-06-07 07:01
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
import jsonfield.fields
import post_office.models
class Migration(migrations.Migration):
dependencies = [
('post_office', '0003_longer_subject'),
]
operations = [
migrations.AlterModelOptions(
name='attachment',
options={'verbose_name': 'Attachment', 'verbose_name_plural': 'Attachments'},
),
migrations.AlterModelOptions(
name='email',
options={'verbose_name': 'Email', 'verbose_name_plural': 'Emails'},
),
migrations.AlterModelOptions(
name='log',
options={'verbose_name': 'Log', 'verbose_name_plural': 'Logs'},
),
migrations.AlterField(
model_name='attachment',
name='emails',
field=models.ManyToManyField(related_name='attachments', to='post_office.Email', verbose_name='Email addresses'),
),
migrations.AlterField(
model_name='attachment',
name='file',
field=models.FileField(upload_to=post_office.models.get_upload_path, verbose_name='File'),
),
migrations.AlterField(
model_name='attachment',
name='name',
field=models.CharField(help_text='The original filename', max_length=255, verbose_name='Name'),
),
migrations.AlterField(
model_name='email',
name='backend_alias',
field=models.CharField(blank=True, default='', max_length=64, verbose_name='Backend alias'),
),
migrations.AlterField(
model_name='email',
name='context',
field=jsonfield.fields.JSONField(blank=True, null=True, verbose_name='Context'),
),
migrations.AlterField(
model_name='email',
name='headers',
field=jsonfield.fields.JSONField(blank=True, null=True, verbose_name='Headers'),
),
migrations.AlterField(
model_name='email',
name='priority',
field=models.PositiveSmallIntegerField(blank=True, choices=[(0, 'low'), (1, 'medium'), (2, 'high'), (3, 'now')], null=True, verbose_name='Priority'),
),
migrations.AlterField(
model_name='email',
name='scheduled_time',
field=models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='The scheduled sending time'),
),
migrations.AlterField(
model_name='email',
name='status',
field=models.PositiveSmallIntegerField(blank=True, choices=[(0, 'sent'), (1, 'failed'), (2, 'queued')], db_index=True, null=True, verbose_name='Status'),
),
migrations.AlterField(
model_name='email',
name='template',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='post_office.EmailTemplate', verbose_name='Email template'),
),
migrations.AlterField(
model_name='emailtemplate',
name='default_template',
field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='translated_templates', to='post_office.EmailTemplate', verbose_name='Default template'),
),
migrations.AlterField(
model_name='emailtemplate',
name='description',
field=models.TextField(blank=True, help_text='Description of this template.', verbose_name='Description'),
),
migrations.AlterField(
model_name='emailtemplate',
name='language',
field=models.CharField(blank=True, default='', help_text='Render template in alternative language', max_length=12, verbose_name='Language'),
),
migrations.AlterField(
model_name='emailtemplate',
name='name',
field=models.CharField(help_text="e.g: 'welcome_email'", max_length=255, verbose_name='Name'),
),
migrations.AlterField(
model_name='log',
name='email',
field=models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='logs', to='post_office.Email', verbose_name='Email address'),
),
migrations.AlterField(
model_name='log',
name='exception_type',
field=models.CharField(blank=True, max_length=255, verbose_name='Exception type'),
),
migrations.AlterField(
model_name='log',
name='message',
field=models.TextField(verbose_name='Message'),
),
migrations.AlterField(
model_name='log',
name='status',
field=models.PositiveSmallIntegerField(choices=[(0, 'sent'), (1, 'failed')], verbose_name='Status'),
),
]

@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.1 on 2017-05-15 00:13
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('post_office', '0004_auto_20160607_0901'),
]
operations = [
migrations.AlterUniqueTogether(
name='emailtemplate',
unique_together=set([('name', 'language', 'default_template')]),
),
]

@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
('post_office', '0005_auto_20170515_0013'),
]
operations = [
migrations.AddField(
model_name='attachment',
name='mimetype',
field=models.CharField(default='', max_length=255, blank=True),
),
]

@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.3 on 2017-07-31 11:42
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('post_office', '0006_attachment_mimetype'),
]
operations = [
migrations.AlterModelOptions(
name='emailtemplate',
options={'ordering': ['name'], 'verbose_name': 'Email Template', 'verbose_name_plural': 'Email Templates'},
),
]

@ -0,0 +1,284 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import os
from collections import namedtuple
from uuid import uuid4
from django.core.mail import EmailMessage, EmailMultiAlternatives
from django.db import models
from django.template import Context, Template
from django.utils.encoding import python_2_unicode_compatible
from django.utils.translation import pgettext_lazy
from django.utils.translation import ugettext_lazy as _
from django.utils import timezone
from jsonfield import JSONField
from post_office import cache
from post_office.fields import CommaSeparatedEmailField
from .compat import text_type, smart_text
from .connections import connections
from .settings import context_field_class, get_log_level
from .validators import validate_email_with_name, validate_template_syntax
PRIORITY = namedtuple('PRIORITY', 'low medium high now')._make(range(4))
STATUS = namedtuple('STATUS', 'sent failed queued')._make(range(3))
@python_2_unicode_compatible
class Email(models.Model):
"""
A model to hold email information.
"""
PRIORITY_CHOICES = [(PRIORITY.low, _("low")), (PRIORITY.medium, _("medium")),
(PRIORITY.high, _("high")), (PRIORITY.now, _("now"))]
STATUS_CHOICES = [(STATUS.sent, _("sent")), (STATUS.failed, _("failed")),
(STATUS.queued, _("queued"))]
from_email = models.CharField(_("Email From"), max_length=254,
validators=[validate_email_with_name])
to = CommaSeparatedEmailField(_("Email To"))
cc = CommaSeparatedEmailField(_("Cc"))
bcc = CommaSeparatedEmailField(_("Bcc"))
subject = models.CharField(_("Subject"), max_length=989, blank=True)
message = models.TextField(_("Message"), blank=True)
html_message = models.TextField(_("HTML Message"), blank=True)
"""
Emails with 'queued' status will get processed by ``send_queued`` command.
Status field will then be set to ``failed`` or ``sent`` depending on
whether it's successfully delivered.
"""
status = models.PositiveSmallIntegerField(
_("Status"),
choices=STATUS_CHOICES, db_index=True,
blank=True, null=True)
priority = models.PositiveSmallIntegerField(_("Priority"),
choices=PRIORITY_CHOICES,
blank=True, null=True)
created = models.DateTimeField(auto_now_add=True, db_index=True)
last_updated = models.DateTimeField(db_index=True, auto_now=True)
scheduled_time = models.DateTimeField(_('The scheduled sending time'),
blank=True, null=True, db_index=True)
headers = JSONField(_('Headers'), blank=True, null=True)
template = models.ForeignKey('post_office.EmailTemplate', blank=True,
null=True, verbose_name=_('Email template'),
on_delete=models.CASCADE)
context = context_field_class(_('Context'), blank=True, null=True)
backend_alias = models.CharField(_('Backend alias'), blank=True, default='',
max_length=64)
class Meta:
app_label = 'post_office'
verbose_name = pgettext_lazy("Email address", "Email")
verbose_name_plural = pgettext_lazy("Email addresses", "Emails")
def __init__(self, *args, **kwargs):
super(Email, self).__init__(*args, **kwargs)
self._cached_email_message = None
def __str__(self):
return u'%s' % self.to
def email_message(self):
"""
Returns Django EmailMessage object for sending.
"""
if self._cached_email_message:
return self._cached_email_message
return self.prepare_email_message()
def prepare_email_message(self):
"""
Returns a django ``EmailMessage`` or ``EmailMultiAlternatives`` object,
depending on whether html_message is empty.
"""
subject = smart_text(self.subject)
if self.template is not None:
_context = Context(self.context)
subject = Template(self.template.subject).render(_context)
message = Template(self.template.content).render(_context)
html_message = Template(self.template.html_content).render(_context)
else:
subject = self.subject
message = self.message
html_message = self.html_message
connection = connections[self.backend_alias or 'default']
if html_message:
msg = EmailMultiAlternatives(
subject=subject, body=message, from_email=self.from_email,
to=self.to, bcc=self.bcc, cc=self.cc,
headers=self.headers, connection=connection)
msg.attach_alternative(html_message, "text/html")
else:
msg = EmailMessage(
subject=subject, body=message, from_email=self.from_email,
to=self.to, bcc=self.bcc, cc=self.cc,
headers=self.headers, connection=connection)
for attachment in self.attachments.all():
msg.attach(attachment.name, attachment.file.read(), mimetype=attachment.mimetype or None)
attachment.file.close()
self._cached_email_message = msg
return msg
def dispatch(self, log_level=None,
disconnect_after_delivery=True, commit=True):
"""
Sends email and log the result.
"""
try:
self.email_message().send()
status = STATUS.sent
message = ''
exception_type = ''
except Exception as e:
status = STATUS.failed
message = str(e)
exception_type = type(e).__name__
# If run in a bulk sending mode, reraise and let the outer
# layer handle the exception
if not commit:
raise
if commit:
self.status = status
self.save(update_fields=['status'])
if log_level is None:
log_level = get_log_level()
# If log level is 0, log nothing, 1 logs only sending failures
# and 2 means log both successes and failures
if log_level == 1:
if status == STATUS.failed:
self.logs.create(status=status, message=message,
exception_type=exception_type)
elif log_level == 2:
self.logs.create(status=status, message=message,
exception_type=exception_type)
return status
def save(self, *args, **kwargs):
self.full_clean()
return super(Email, self).save(*args, **kwargs)
@python_2_unicode_compatible
class Log(models.Model):
"""
A model to record sending email sending activities.
"""
STATUS_CHOICES = [(STATUS.sent, _("sent")), (STATUS.failed, _("failed"))]
email = models.ForeignKey(Email, editable=False, related_name='logs',
verbose_name=_('Email address'), on_delete=models.CASCADE)
date = models.DateTimeField(auto_now_add=True)
status = models.PositiveSmallIntegerField(_('Status'), choices=STATUS_CHOICES)
exception_type = models.CharField(_('Exception type'), max_length=255, blank=True)
message = models.TextField(_('Message'))
class Meta:
app_label = 'post_office'
verbose_name = _("Log")
verbose_name_plural = _("Logs")
def __str__(self):
return text_type(self.date)
class EmailTemplateManager(models.Manager):
def get_by_natural_key(self, name, language, default_template):
return self.get(name=name, language=language, default_template=default_template)
@python_2_unicode_compatible
class EmailTemplate(models.Model):
"""
Model to hold template information from db
"""
name = models.CharField(_('Name'), max_length=255, help_text=_("e.g: 'welcome_email'"))
description = models.TextField(_('Description'), blank=True,
help_text=_("Description of this template."))
created = models.DateTimeField(auto_now_add=True)
last_updated = models.DateTimeField(auto_now=True)
subject = models.CharField(max_length=255, blank=True,
verbose_name=_("Subject"), validators=[validate_template_syntax])
content = models.TextField(blank=True,
verbose_name=_("Content"), validators=[validate_template_syntax])
html_content = models.TextField(blank=True,
verbose_name=_("HTML content"), validators=[validate_template_syntax])
language = models.CharField(max_length=12,
verbose_name=_("Language"),
help_text=_("Render template in alternative language"),
default='', blank=True)
default_template = models.ForeignKey('self', related_name='translated_templates',
null=True, default=None, verbose_name=_('Default template'), on_delete=models.CASCADE)
objects = EmailTemplateManager()
class Meta:
app_label = 'post_office'
unique_together = ('name', 'language', 'default_template')
verbose_name = _("Email Template")
verbose_name_plural = _("Email Templates")
ordering = ['name']
def __str__(self):
return u'%s %s' % (self.name, self.language)
def natural_key(self):
return (self.name, self.language, self.default_template)
def save(self, *args, **kwargs):
# If template is a translation, use default template's name
if self.default_template and not self.name:
self.name = self.default_template.name
template = super(EmailTemplate, self).save(*args, **kwargs)
cache.delete(self.name)
return template
def get_upload_path(instance, filename):
"""Overriding to store the original filename"""
if not instance.name:
instance.name = filename # set original filename
date = timezone.now().date()
filename = '{name}.{ext}'.format(name=uuid4().hex,
ext=filename.split('.')[-1])
return os.path.join('post_office_attachments', str(date.year),
str(date.month), str(date.day), filename)
@python_2_unicode_compatible
class Attachment(models.Model):
"""
A model describing an email attachment.
"""
file = models.FileField(_('File'), upload_to=get_upload_path)
name = models.CharField(_('Name'), max_length=255, help_text=_("The original filename"))
emails = models.ManyToManyField(Email, related_name='attachments',
verbose_name=_('Email addresses'))
mimetype = models.CharField(max_length=255, default='', blank=True)
class Meta:
app_label = 'post_office'
verbose_name = _("Attachment")
verbose_name_plural = _("Attachments")
def __str__(self):
return self.name

@ -0,0 +1,95 @@
import warnings
from django.conf import settings
from django.core.cache.backends.base import InvalidCacheBackendError
from .compat import import_attribute, get_cache
def get_backend(alias='default'):
return get_available_backends()[alias]
def get_available_backends():
""" Returns a dictionary of defined backend classes. For example:
{
'default': 'django.core.mail.backends.smtp.EmailBackend',
'locmem': 'django.core.mail.backends.locmem.EmailBackend',
}
"""
backends = get_config().get('BACKENDS', {})
if backends:
return backends
# Try to get backend settings from old style
# POST_OFFICE = {
# 'EMAIL_BACKEND': 'mybackend'
# }
backend = get_config().get('EMAIL_BACKEND')
if backend:
warnings.warn('Please use the new POST_OFFICE["BACKENDS"] settings',
DeprecationWarning)
backends['default'] = backend
return backends
# Fall back to Django's EMAIL_BACKEND definition
backends['default'] = getattr(
settings, 'EMAIL_BACKEND',
'django.core.mail.backends.smtp.EmailBackend')
# If EMAIL_BACKEND is set to use PostOfficeBackend
# and POST_OFFICE_BACKEND is not set, fall back to SMTP
if 'post_office.EmailBackend' in backends['default']:
backends['default'] = 'django.core.mail.backends.smtp.EmailBackend'
return backends
def get_cache_backend():
if hasattr(settings, 'CACHES'):
if "post_office" in settings.CACHES:
return get_cache("post_office")
else:
# Sometimes this raises InvalidCacheBackendError, which is ok too
try:
return get_cache("default")
except InvalidCacheBackendError:
pass
return None
def get_config():
"""
Returns Post Office's configuration in dictionary format. e.g:
POST_OFFICE = {
'BATCH_SIZE': 1000
}
"""
return getattr(settings, 'POST_OFFICE', {})
def get_batch_size():
return get_config().get('BATCH_SIZE', 100)
def get_threads_per_process():
return get_config().get('THREADS_PER_PROCESS', 5)
def get_default_priority():
return get_config().get('DEFAULT_PRIORITY', 'medium')
def get_log_level():
return get_config().get('LOG_LEVEL', 2)
def get_sending_order():
return get_config().get('SENDING_ORDER', ['-priority'])
CONTEXT_FIELD_CLASS = get_config().get('CONTEXT_FIELD_CLASS',
'jsonfield.JSONField')
context_field_class = import_attribute(CONTEXT_FIELD_CLASS)

@ -0,0 +1,84 @@
# -*- coding: utf-8 -*-
import django
from distutils.version import StrictVersion
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
},
}
# Default values: True
# POST_OFFICE_CACHE = True
# POST_OFFICE_TEMPLATE_CACHE = True
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'TIMEOUT': 36000,
'KEY_PREFIX': 'post-office',
},
'post_office': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'TIMEOUT': 36000,
'KEY_PREFIX': 'post-office',
}
}
POST_OFFICE = {
'BACKENDS': {
'default': 'django.core.mail.backends.dummy.EmailBackend',
'locmem': 'django.core.mail.backends.locmem.EmailBackend',
'error': 'post_office.tests.test_backends.ErrorRaisingBackend',
'smtp': 'django.core.mail.backends.smtp.EmailBackend',
'connection_tester': 'post_office.tests.test_mail.ConnectionTestingBackend',
}
}
INSTALLED_APPS = (
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'post_office',
)
SECRET_KEY = 'a'
ROOT_URLCONF = 'post_office.test_urls'
DEFAULT_FROM_EMAIL = 'webmaster@example.com'
if StrictVersion(str(django.get_version())) < '1.10':
MIDDLEWARE_CLASSES = (
'django.contrib.sessions.middleware.SessionMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
)
else:
MIDDLEWARE = [
'django.contrib.sessions.middleware.SessionMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
]
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.contrib.auth.context_processors.auth',
'django.template.context_processors.debug',
'django.template.context_processors.i18n',
'django.template.context_processors.media',
'django.template.context_processors.static',
'django.template.context_processors.tz',
'django.contrib.messages.context_processors.messages',
],
},
},
]

@ -0,0 +1,6 @@
from django.conf.urls import url
from django.contrib import admin
urlpatterns = [
url(r'^admin/', admin.site.urls),
]

@ -0,0 +1,8 @@
from .test_backends import BackendTest
from .test_commands import CommandTest
from .test_lockfile import LockTest
from .test_mail import MailTest
from .test_models import ModelTest
from .test_utils import UtilsTest
from .test_cache import CacheTest
from .test_views import AdminViewTest

@ -0,0 +1,113 @@
from django.conf import settings
from django.core.mail import EmailMultiAlternatives, send_mail, EmailMessage
from django.core.mail.backends.base import BaseEmailBackend
from django.test import TestCase
from django.test.utils import override_settings
from ..models import Email, STATUS, PRIORITY
from ..settings import get_backend
class ErrorRaisingBackend(BaseEmailBackend):
"""
An EmailBackend that always raises an error during sending
to test if django_mailer handles sending error correctly
"""
def send_messages(self, email_messages):
raise Exception('Fake Error')
class BackendTest(TestCase):
@override_settings(EMAIL_BACKEND='post_office.EmailBackend')
def test_email_backend(self):
"""
Ensure that email backend properly queue email messages.
"""
send_mail('Test', 'Message', 'from@example.com', ['to@example.com'])
email = Email.objects.latest('id')
self.assertEqual(email.subject, 'Test')
self.assertEqual(email.status, STATUS.queued)
self.assertEqual(email.priority, PRIORITY.medium)
def test_email_backend_setting(self):
"""
"""
old_email_backend = getattr(settings, 'EMAIL_BACKEND', None)
old_post_office_backend = getattr(settings, 'POST_OFFICE_BACKEND', None)
if hasattr(settings, 'EMAIL_BACKEND'):
delattr(settings, 'EMAIL_BACKEND')
if hasattr(settings, 'POST_OFFICE_BACKEND'):
delattr(settings, 'POST_OFFICE_BACKEND')
previous_settings = settings.POST_OFFICE
delattr(settings, 'POST_OFFICE')
# If no email backend is set, backend should default to SMTP
self.assertEqual(get_backend(), 'django.core.mail.backends.smtp.EmailBackend')
# If EMAIL_BACKEND is set to PostOfficeBackend, use SMTP to send by default
setattr(settings, 'EMAIL_BACKEND', 'post_office.EmailBackend')
self.assertEqual(get_backend(), 'django.core.mail.backends.smtp.EmailBackend')
# If EMAIL_BACKEND is set on new dictionary-styled settings, use that
setattr(settings, 'POST_OFFICE', {'EMAIL_BACKEND': 'test'})
self.assertEqual(get_backend(), 'test')
delattr(settings, 'POST_OFFICE')
if old_email_backend:
setattr(settings, 'EMAIL_BACKEND', old_email_backend)
else:
delattr(settings, 'EMAIL_BACKEND')
setattr(settings, 'POST_OFFICE', previous_settings)
@override_settings(EMAIL_BACKEND='post_office.EmailBackend')
def test_sending_html_email(self):
"""
"text/html" attachments to Email should be persisted into the database
"""
message = EmailMultiAlternatives('subject', 'body', 'from@example.com',
['recipient@example.com'])
message.attach_alternative('html', "text/html")
message.send()
email = Email.objects.latest('id')
self.assertEqual(email.html_message, 'html')
@override_settings(EMAIL_BACKEND='post_office.EmailBackend')
def test_headers_sent(self):
"""
Test that headers are correctly set on the outgoing emails.
"""
message = EmailMessage('subject', 'body', 'from@example.com',
['recipient@example.com'],
headers={'Reply-To': 'reply@example.com'})
message.send()
email = Email.objects.latest('id')
self.assertEqual(email.headers, {'Reply-To': 'reply@example.com'})
@override_settings(EMAIL_BACKEND='post_office.EmailBackend')
def test_backend_attachments(self):
message = EmailMessage('subject', 'body', 'from@example.com',
['recipient@example.com'])
message.attach('attachment.txt', 'attachment content')
message.send()
email = Email.objects.latest('id')
self.assertEqual(email.attachments.count(), 1)
self.assertEqual(email.attachments.all()[0].name, 'attachment.txt')
self.assertEqual(email.attachments.all()[0].file.read(), b'attachment content')
@override_settings(
EMAIL_BACKEND='post_office.EmailBackend',
POST_OFFICE={
'DEFAULT_PRIORITY': 'now',
'BACKENDS': {'default': 'django.core.mail.backends.dummy.EmailBackend'}
}
)
def test_default_priority_now(self):
# If DEFAULT_PRIORITY is "now", mails should be sent right away
send_mail('Test', 'Message', 'from1@example.com', ['to@example.com'])
email = Email.objects.latest('id')
self.assertEqual(email.status, STATUS.sent)

@ -0,0 +1,41 @@
from django.conf import settings
from django.test import TestCase
from post_office import cache
from ..settings import get_cache_backend
class CacheTest(TestCase):
def test_get_backend_settings(self):
"""Test basic get backend function and its settings"""
# Sanity check
self.assertTrue('post_office' in settings.CACHES)
self.assertTrue(get_cache_backend())
# If no post office key is defined, it should return default
del(settings.CACHES['post_office'])
self.assertTrue(get_cache_backend())
# If no caches key in settings, it should return None
delattr(settings, 'CACHES')
self.assertEqual(None, get_cache_backend())
def test_get_cache_key(self):
"""
Test for converting names to cache key
"""
self.assertEqual('post_office:template:test', cache.get_cache_key('test'))
self.assertEqual('post_office:template:test-slugify', cache.get_cache_key('test slugify'))
def test_basic_cache_operations(self):
"""
Test basic cache operations
"""
# clean test cache
cache.cache_backend.clear()
self.assertEqual(None, cache.get('test-cache'))
cache.set('test-cache', 'awesome content')
self.assertTrue('awesome content', cache.get('test-cache'))
cache.delete('test-cache')
self.assertEqual(None, cache.get('test-cache'))

@ -0,0 +1,150 @@
import datetime
import os
from django.core.files.base import ContentFile
from django.core.management import call_command
from django.test import TestCase
from django.test.utils import override_settings
from django.utils.timezone import now
from ..models import Attachment, Email, STATUS
class CommandTest(TestCase):
def test_cleanup_mail_with_orphaned_attachments(self):
self.assertEqual(Email.objects.count(), 0)
email = Email.objects.create(to=['to@example.com'],
from_email='from@example.com',
subject='Subject')
email.created = now() - datetime.timedelta(31)
email.save()
attachment = Attachment()
attachment.file.save(
'test.txt', content=ContentFile('test file content'), save=True
)
email.attachments.add(attachment)
attachment_path = attachment.file.name
# We have orphaned attachment now
call_command('cleanup_mail', days=30)
self.assertEqual(Email.objects.count(), 0)
self.assertEqual(Attachment.objects.count(), 1)
# Actually cleanup orphaned attachments
call_command('cleanup_mail', '-da', days=30)
self.assertEqual(Email.objects.count(), 0)
self.assertEqual(Attachment.objects.count(), 0)
# Check that the actual file has been deleted as well
self.assertFalse(os.path.exists(attachment_path))
# Check if the email attachment's actual file have been deleted
Email.objects.all().delete()
email = Email.objects.create(to=['to@example.com'],
from_email='from@example.com',
subject='Subject')
email.created = now() - datetime.timedelta(31)
email.save()
attachment = Attachment()
attachment.file.save(
'test.txt', content=ContentFile('test file content'), save=True
)
email.attachments.add(attachment)
attachment_path = attachment.file.name
# Simulate that the files have been deleted by accidents
os.remove(attachment_path)
# No exceptions should break the cleanup
call_command('cleanup_mail', '-da', days=30)
self.assertEqual(Email.objects.count(), 0)
self.assertEqual(Attachment.objects.count(), 0)
def test_cleanup_mail(self):
"""
The ``cleanup_mail`` command deletes mails older than a specified
amount of days
"""
self.assertEqual(Email.objects.count(), 0)
# The command shouldn't delete today's email
email = Email.objects.create(from_email='from@example.com',
to=['to@example.com'])
call_command('cleanup_mail', days=30)
self.assertEqual(Email.objects.count(), 1)
# Email older than 30 days should be deleted
email.created = now() - datetime.timedelta(31)
email.save()
call_command('cleanup_mail', days=30)
self.assertEqual(Email.objects.count(), 0)
TEST_SETTINGS = {
'BACKENDS': {
'default': 'django.core.mail.backends.dummy.EmailBackend',
},
'BATCH_SIZE': 1
}
@override_settings(POST_OFFICE=TEST_SETTINGS)
def test_send_queued_mail(self):
"""
Ensure that ``send_queued_mail`` behaves properly and sends all queued
emails in two batches.
"""
# Make sure that send_queued_mail with empty queue does not raise error
call_command('send_queued_mail', processes=1)
Email.objects.create(from_email='from@example.com',
to=['to@example.com'], status=STATUS.queued)
Email.objects.create(from_email='from@example.com',
to=['to@example.com'], status=STATUS.queued)
call_command('send_queued_mail', processes=1)
self.assertEqual(Email.objects.filter(status=STATUS.sent).count(), 2)
self.assertEqual(Email.objects.filter(status=STATUS.queued).count(), 0)
def test_successful_deliveries_logging(self):
"""
Successful deliveries are only logged when log_level is 2.
"""
email = Email.objects.create(from_email='from@example.com',
to=['to@example.com'], status=STATUS.queued)
call_command('send_queued_mail', log_level=0)
self.assertEqual(email.logs.count(), 0)
email = Email.objects.create(from_email='from@example.com',
to=['to@example.com'], status=STATUS.queued)
call_command('send_queued_mail', log_level=1)
self.assertEqual(email.logs.count(), 0)
email = Email.objects.create(from_email='from@example.com',
to=['to@example.com'], status=STATUS.queued)
call_command('send_queued_mail', log_level=2)
self.assertEqual(email.logs.count(), 1)
def test_failed_deliveries_logging(self):
"""
Failed deliveries are logged when log_level is 1 and 2.
"""
email = Email.objects.create(from_email='from@example.com',
to=['to@example.com'], status=STATUS.queued,
backend_alias='error')
call_command('send_queued_mail', log_level=0)
self.assertEqual(email.logs.count(), 0)
email = Email.objects.create(from_email='from@example.com',
to=['to@example.com'], status=STATUS.queued,
backend_alias='error')
call_command('send_queued_mail', log_level=1)
self.assertEqual(email.logs.count(), 1)
email = Email.objects.create(from_email='from@example.com',
to=['to@example.com'], status=STATUS.queued,
backend_alias='error')
call_command('send_queued_mail', log_level=2)
self.assertEqual(email.logs.count(), 1)

@ -0,0 +1,13 @@
from django.core.mail import backends
from django.test import TestCase
from .test_backends import ErrorRaisingBackend
from ..connections import connections
class ConnectionTest(TestCase):
def test_get_connection(self):
# Ensure ConnectionHandler returns the right connection
self.assertTrue(isinstance(connections['error'], ErrorRaisingBackend))
self.assertTrue(isinstance(connections['locmem'], backends.locmem.EmailBackend))

@ -0,0 +1,75 @@
import time
import os
from django.test import TestCase
from ..lockfile import FileLock, FileLocked
def setup_fake_lock(lock_file_name):
pid = os.getpid()
lockfile = '%s.lock' % pid
try:
os.remove(lock_file_name)
except OSError:
pass
os.symlink(lockfile, lock_file_name)
class LockTest(TestCase):
def test_process_killed_force_unlock(self):
pid = os.getpid()
lockfile = '%s.lock' % pid
setup_fake_lock('test.lock')
with open(lockfile, 'w+') as f:
f.write('9999999')
assert os.path.exists(lockfile)
with FileLock('test'):
assert True
def test_force_unlock_in_same_process(self):
pid = os.getpid()
lockfile = '%s.lock' % pid
os.symlink(lockfile, 'test.lock')
with open(lockfile, 'w+') as f:
f.write(str(os.getpid()))
with FileLock('test', force=True):
assert True
def test_exception_after_timeout(self):
pid = os.getpid()
lockfile = '%s.lock' % pid
setup_fake_lock('test.lock')
with open(lockfile, 'w+') as f:
f.write(str(os.getpid()))
try:
with FileLock('test', timeout=1):
assert False
except FileLocked:
assert True
def test_force_after_timeout(self):
pid = os.getpid()
lockfile = '%s.lock' % pid
setup_fake_lock('test.lock')
with open(lockfile, 'w+') as f:
f.write(str(os.getpid()))
timeout = 1
start = time.time()
with FileLock('test', timeout=timeout, force=True):
assert True
end = time.time()
assert end - start > timeout
def test_get_lock_pid(self):
"""Ensure get_lock_pid() works properly"""
with FileLock('test', timeout=1, force=True) as lock:
self.assertEqual(lock.get_lock_pid(), int(os.getpid()))

@ -0,0 +1,400 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from datetime import date, datetime
from django.core import mail
from django.core.files.base import ContentFile
from django.conf import settings
from django.test import TestCase
from django.test.utils import override_settings
from ..settings import get_batch_size, get_log_level, get_threads_per_process
from ..models import Email, EmailTemplate, Attachment, PRIORITY, STATUS
from ..mail import (create, get_queued,
send, send_many, send_queued, _send_bulk)
connection_counter = 0
class ConnectionTestingBackend(mail.backends.base.BaseEmailBackend):
'''
An EmailBackend that increments a global counter when connection is opened
'''
def open(self):
global connection_counter
connection_counter += 1
def send_messages(self, email_messages):
pass
class MailTest(TestCase):
@override_settings(EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend')
def test_send_queued_mail(self):
"""
Check that only queued messages are sent.
"""
kwargs = {
'to': ['to@example.com'],
'from_email': 'bob@example.com',
'subject': 'Test',
'message': 'Message',
}
failed_mail = Email.objects.create(status=STATUS.failed, **kwargs)
none_mail = Email.objects.create(status=None, **kwargs)
# This should be the only email that gets sent
queued_mail = Email.objects.create(status=STATUS.queued, **kwargs)
send_queued()
self.assertNotEqual(Email.objects.get(id=failed_mail.id).status, STATUS.sent)
self.assertNotEqual(Email.objects.get(id=none_mail.id).status, STATUS.sent)
self.assertEqual(Email.objects.get(id=queued_mail.id).status, STATUS.sent)
@override_settings(EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend')
def test_send_queued_mail_multi_processes(self):
"""
Check that send_queued works well with multiple processes
"""
kwargs = {
'to': ['to@example.com'],
'from_email': 'bob@example.com',
'subject': 'Test',
'message': 'Message',
'status': STATUS.queued
}
# All three emails should be sent
self.assertEqual(Email.objects.filter(status=STATUS.sent).count(), 0)
for i in range(3):
Email.objects.create(**kwargs)
total_sent, total_failed = send_queued(processes=2)
self.assertEqual(total_sent, 3)
def test_send_bulk(self):
"""
Ensure _send_bulk() properly sends out emails.
"""
email = Email.objects.create(
to=['to@example.com'], from_email='bob@example.com',
subject='send bulk', message='Message', status=STATUS.queued,
backend_alias='locmem')
_send_bulk([email], uses_multiprocessing=False)
self.assertEqual(Email.objects.get(id=email.id).status, STATUS.sent)
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].subject, 'send bulk')
@override_settings(EMAIL_BACKEND='post_office.tests.test_mail.ConnectionTestingBackend')
def test_send_bulk_reuses_open_connection(self):
"""
Ensure _send_bulk() only opens connection once to send multiple emails.
"""
global connection_counter
self.assertEqual(connection_counter, 0)
email = Email.objects.create(to=['to@example.com'],
from_email='bob@example.com', subject='',
message='', status=STATUS.queued, backend_alias='connection_tester')
email_2 = Email.objects.create(to=['to@example.com'],
from_email='bob@example.com', subject='',
message='', status=STATUS.queued,
backend_alias='connection_tester')
_send_bulk([email, email_2])
self.assertEqual(connection_counter, 1)
def test_get_queued(self):
"""
Ensure get_queued returns only emails that should be sent
"""
kwargs = {
'to': 'to@example.com',
'from_email': 'bob@example.com',
'subject': 'Test',
'message': 'Message',
}
self.assertEqual(list(get_queued()), [])
# Emails with statuses failed, sent or None shouldn't be returned
Email.objects.create(status=STATUS.failed, **kwargs)
Email.objects.create(status=None, **kwargs)
Email.objects.create(status=STATUS.sent, **kwargs)
self.assertEqual(list(get_queued()), [])
# Email with queued status and None as scheduled_time should be included
queued_email = Email.objects.create(status=STATUS.queued,
scheduled_time=None, **kwargs)
self.assertEqual(list(get_queued()), [queued_email])
# Email scheduled for the future should not be included
Email.objects.create(status=STATUS.queued,
scheduled_time=date(2020, 12, 13), **kwargs)
self.assertEqual(list(get_queued()), [queued_email])
# Email scheduled in the past should be included
past_email = Email.objects.create(status=STATUS.queued,
scheduled_time=date(2010, 12, 13), **kwargs)
self.assertEqual(list(get_queued()), [queued_email, past_email])
def test_get_batch_size(self):
"""
Ensure BATCH_SIZE setting is read correctly.
"""
previous_settings = settings.POST_OFFICE
self.assertEqual(get_batch_size(), 100)
setattr(settings, 'POST_OFFICE', {'BATCH_SIZE': 10})
self.assertEqual(get_batch_size(), 10)
settings.POST_OFFICE = previous_settings
def test_get_threads_per_process(self):
"""
Ensure THREADS_PER_PROCESS setting is read correctly.
"""
previous_settings = settings.POST_OFFICE
self.assertEqual(get_threads_per_process(), 5)
setattr(settings, 'POST_OFFICE', {'THREADS_PER_PROCESS': 10})
self.assertEqual(get_threads_per_process(), 10)
settings.POST_OFFICE = previous_settings
def test_get_log_level(self):
"""
Ensure LOG_LEVEL setting is read correctly.
"""
previous_settings = settings.POST_OFFICE
self.assertEqual(get_log_level(), 2)
setattr(settings, 'POST_OFFICE', {'LOG_LEVEL': 1})
self.assertEqual(get_log_level(), 1)
# Restore ``LOG_LEVEL``
setattr(settings, 'POST_OFFICE', {'LOG_LEVEL': 2})
settings.POST_OFFICE = previous_settings
def test_create(self):
"""
Test basic email creation
"""
# Test that email is persisted only when commit=True
email = create(
sender='from@example.com', recipients=['to@example.com'],
commit=False
)
self.assertEqual(email.id, None)
email = create(
sender='from@example.com', recipients=['to@example.com'],
commit=True
)
self.assertNotEqual(email.id, None)
# Test that email is created with the right status
email = create(
sender='from@example.com', recipients=['to@example.com'],
priority=PRIORITY.now
)
self.assertEqual(email.status, None)
email = create(
sender='from@example.com', recipients=['to@example.com'],
priority=PRIORITY.high
)
self.assertEqual(email.status, STATUS.queued)
# Test that email is created with the right content
context = {
'subject': 'My subject',
'message': 'My message',
'html': 'My html',
}
now = datetime.now()
email = create(
sender='from@example.com', recipients=['to@example.com'],
subject='Test {{ subject }}', message='Test {{ message }}',
html_message='Test {{ html }}', context=context,
scheduled_time=now, headers={'header': 'Test header'},
)
self.assertEqual(email.from_email, 'from@example.com')
self.assertEqual(email.to, ['to@example.com'])
self.assertEqual(email.subject, 'Test My subject')
self.assertEqual(email.message, 'Test My message')
self.assertEqual(email.html_message, 'Test My html')
self.assertEqual(email.scheduled_time, now)
self.assertEqual(email.headers, {'header': 'Test header'})
def test_send_many(self):
"""Test send_many creates the right emails """
kwargs_list = [
{'sender': 'from@example.com', 'recipients': ['a@example.com']},
{'sender': 'from@example.com', 'recipients': ['b@example.com']},
]
send_many(kwargs_list)
self.assertEqual(Email.objects.filter(to=['a@example.com']).count(), 1)
def test_send_with_attachments(self):
attachments = {
'attachment_file1.txt': ContentFile('content'),
'attachment_file2.txt': ContentFile('content'),
}
email = send(recipients=['a@example.com', 'b@example.com'],
sender='from@example.com', message='message',
subject='subject', attachments=attachments)
self.assertTrue(email.pk)
self.assertEqual(email.attachments.count(), 2)
def test_send_with_render_on_delivery(self):
"""
Ensure that mail.send() create email instances with appropriate
fields being saved
"""
template = EmailTemplate.objects.create(
subject='Subject {{ name }}',
content='Content {{ name }}',
html_content='HTML {{ name }}'
)
context = {'name': 'test'}
email = send(recipients=['a@example.com', 'b@example.com'],
template=template, context=context,
render_on_delivery=True)
self.assertEqual(email.subject, '')
self.assertEqual(email.message, '')
self.assertEqual(email.html_message, '')
self.assertEqual(email.template, template)
# context shouldn't be persisted when render_on_delivery = False
email = send(recipients=['a@example.com'],
template=template, context=context,
render_on_delivery=False)
self.assertEqual(email.context, None)
def test_send_with_attachments_multiple_recipients(self):
"""Test reusing the same attachment objects for several email objects"""
attachments = {
'attachment_file1.txt': ContentFile('content'),
'attachment_file2.txt': ContentFile('content'),
}
email = send(recipients=['a@example.com', 'b@example.com'],
sender='from@example.com', message='message',
subject='subject', attachments=attachments)
self.assertEqual(email.attachments.count(), 2)
self.assertEqual(Attachment.objects.count(), 2)
def test_create_with_template(self):
"""If render_on_delivery is True, subject and content
won't be rendered, context also won't be saved."""
template = EmailTemplate.objects.create(
subject='Subject {{ name }}',
content='Content {{ name }}',
html_content='HTML {{ name }}'
)
context = {'name': 'test'}
email = create(
sender='from@example.com', recipients=['to@example.com'],
template=template, context=context, render_on_delivery=True
)
self.assertEqual(email.subject, '')
self.assertEqual(email.message, '')
self.assertEqual(email.html_message, '')
self.assertEqual(email.context, context)
self.assertEqual(email.template, template)
def test_create_with_template_and_empty_context(self):
"""If render_on_delivery is False, subject and content
will be rendered, context won't be saved."""
template = EmailTemplate.objects.create(
subject='Subject {% now "Y" %}',
content='Content {% now "Y" %}',
html_content='HTML {% now "Y" %}'
)
context = None
email = create(
sender='from@example.com', recipients=['to@example.com'],
template=template, context=context
)
today = date.today()
current_year = today.year
self.assertEqual(email.subject, 'Subject %d' % current_year)
self.assertEqual(email.message, 'Content %d' % current_year)
self.assertEqual(email.html_message, 'HTML %d' % current_year)
self.assertEqual(email.context, None)
self.assertEqual(email.template, None)
def test_backend_alias(self):
"""Test backend_alias field is properly set."""
email = send(recipients=['a@example.com'],
sender='from@example.com', message='message',
subject='subject')
self.assertEqual(email.backend_alias, '')
email = send(recipients=['a@example.com'],
sender='from@example.com', message='message',
subject='subject', backend='locmem')
self.assertEqual(email.backend_alias, 'locmem')
with self.assertRaises(ValueError):
send(recipients=['a@example.com'], sender='from@example.com',
message='message', subject='subject', backend='foo')
@override_settings(LANGUAGES=(('en', 'English'), ('ru', 'Russian')))
def test_send_with_template(self):
"""If render_on_delivery is False, subject and content
will be rendered, context won't be saved."""
template = EmailTemplate.objects.create(
subject='Subject {{ name }}',
content='Content {{ name }}',
html_content='HTML {{ name }}'
)
russian_template = EmailTemplate(
default_template=template,
language='ru',
subject='предмет {{ name }}',
content='содержание {{ name }}',
html_content='HTML {{ name }}'
)
russian_template.save()
context = {'name': 'test'}
email = send(recipients=['to@example.com'], sender='from@example.com',
template=template, context=context)
email = Email.objects.get(id=email.id)
self.assertEqual(email.subject, 'Subject test')
self.assertEqual(email.message, 'Content test')
self.assertEqual(email.html_message, 'HTML test')
self.assertEqual(email.context, None)
self.assertEqual(email.template, None)
# check, if we use the Russian version
email = send(recipients=['to@example.com'], sender='from@example.com',
template=russian_template, context=context)
email = Email.objects.get(id=email.id)
self.assertEqual(email.subject, 'предмет test')
self.assertEqual(email.message, 'содержание test')
self.assertEqual(email.html_message, 'HTML test')
self.assertEqual(email.context, None)
self.assertEqual(email.template, None)
# Check that send picks template with the right language
email = send(recipients=['to@example.com'], sender='from@example.com',
template=template, context=context, language='ru')
email = Email.objects.get(id=email.id)
self.assertEqual(email.subject, 'предмет test')
email = send(recipients=['to@example.com'], sender='from@example.com',
template=template, context=context, language='ru',
render_on_delivery=True)
self.assertEqual(email.template.language, 'ru')
def test_send_bulk_with_faulty_template(self):
template = EmailTemplate.objects.create(
subject='{% if foo %}Subject {{ name }}',
content='Content {{ name }}',
html_content='HTML {{ name }}'
)
email = Email.objects.create(to='to@example.com', from_email='from@example.com',
template=template, status=STATUS.queued)
_send_bulk([email], uses_multiprocessing=False)
email = Email.objects.get(id=email.id)
self.assertEqual(email.status, STATUS.failed)

@ -0,0 +1,332 @@
import django
import json
import os
from datetime import datetime, timedelta
from django.conf import settings as django_settings
from django.core import mail
from django.core import serializers
from django.core.files.base import ContentFile
from django.core.mail import EmailMessage, EmailMultiAlternatives
from django.forms.models import modelform_factory
from django.test import TestCase
from django.utils import timezone
from ..models import Email, Log, PRIORITY, STATUS, EmailTemplate, Attachment
from ..mail import send
class ModelTest(TestCase):
def test_email_message(self):
"""
Test to make sure that model's "email_message" method
returns proper email classes.
"""
# If ``html_message`` is set, ``EmailMultiAlternatives`` is expected
email = Email.objects.create(to=['to@example.com'],
from_email='from@example.com', subject='Subject',
message='Message', html_message='<p>HTML</p>')
message = email.email_message()
self.assertEqual(type(message), EmailMultiAlternatives)
self.assertEqual(message.from_email, 'from@example.com')
self.assertEqual(message.to, ['to@example.com'])
self.assertEqual(message.subject, 'Subject')
self.assertEqual(message.body, 'Message')
self.assertEqual(message.alternatives, [('<p>HTML</p>', 'text/html')])
# Without ``html_message``, ``EmailMessage`` class is expected
email = Email.objects.create(to=['to@example.com'],
from_email='from@example.com', subject='Subject',
message='Message')
message = email.email_message()
self.assertEqual(type(message), EmailMessage)
self.assertEqual(message.from_email, 'from@example.com')
self.assertEqual(message.to, ['to@example.com'])
self.assertEqual(message.subject, 'Subject')
self.assertEqual(message.body, 'Message')
def test_email_message_render(self):
"""
Ensure Email instance with template is properly rendered.
"""
template = EmailTemplate.objects.create(
subject='Subject {{ name }}',
content='Content {{ name }}',
html_content='HTML {{ name }}'
)
context = {'name': 'test'}
email = Email.objects.create(to=['to@example.com'], template=template,
from_email='from@e.com', context=context)
message = email.email_message()
self.assertEqual(message.subject, 'Subject test')
self.assertEqual(message.body, 'Content test')
self.assertEqual(message.alternatives[0][0], 'HTML test')
def test_dispatch(self):
"""
Ensure that email.dispatch() actually sends out the email
"""
email = Email.objects.create(to=['to@example.com'], from_email='from@example.com',
subject='Test dispatch', message='Message', backend_alias='locmem')
email.dispatch()
self.assertEqual(mail.outbox[0].subject, 'Test dispatch')
def test_status_and_log(self):
"""
Ensure that status and log are set properly on successful sending
"""
email = Email.objects.create(to=['to@example.com'], from_email='from@example.com',
subject='Test', message='Message', backend_alias='locmem', id=333)
# Ensure that after dispatch status and logs are correctly set
email.dispatch()
log = Log.objects.latest('id')
self.assertEqual(email.status, STATUS.sent)
self.assertEqual(log.email, email)
def test_status_and_log_on_error(self):
"""
Ensure that status and log are set properly on sending failure
"""
email = Email.objects.create(to=['to@example.com'], from_email='from@example.com',
subject='Test', message='Message',
backend_alias='error')
# Ensure that after dispatch status and logs are correctly set
email.dispatch()
log = Log.objects.latest('id')
self.assertEqual(email.status, STATUS.failed)
self.assertEqual(log.email, email)
self.assertEqual(log.status, STATUS.failed)
self.assertEqual(log.message, 'Fake Error')
self.assertEqual(log.exception_type, 'Exception')
def test_errors_while_getting_connection_are_logged(self):
"""
Ensure that status and log are set properly on sending failure
"""
email = Email.objects.create(to=['to@example.com'], subject='Test',
from_email='from@example.com',
message='Message', backend_alias='random')
# Ensure that after dispatch status and logs are correctly set
email.dispatch()
log = Log.objects.latest('id')
self.assertEqual(email.status, STATUS.failed)
self.assertEqual(log.email, email)
self.assertEqual(log.status, STATUS.failed)
self.assertIn('is not a valid', log.message)
def test_default_sender(self):
email = send(['to@example.com'], subject='foo')
self.assertEqual(email.from_email,
django_settings.DEFAULT_FROM_EMAIL)
def test_send_argument_checking(self):
"""
mail.send() should raise an Exception if:
- "template" is used with "subject", "message" or "html_message"
- recipients is not in tuple or list format
"""
self.assertRaises(ValueError, send, ['to@example.com'], 'from@a.com',
template='foo', subject='bar')
self.assertRaises(ValueError, send, ['to@example.com'], 'from@a.com',
template='foo', message='bar')
self.assertRaises(ValueError, send, ['to@example.com'], 'from@a.com',
template='foo', html_message='bar')
self.assertRaises(ValueError, send, 'to@example.com', 'from@a.com',
template='foo', html_message='bar')
self.assertRaises(ValueError, send, cc='cc@example.com', sender='from@a.com',
template='foo', html_message='bar')
self.assertRaises(ValueError, send, bcc='bcc@example.com', sender='from@a.com',
template='foo', html_message='bar')
def test_send_with_template(self):
"""
Ensure mail.send correctly creates templated emails to recipients
"""
Email.objects.all().delete()
headers = {'Reply-to': 'reply@email.com'}
email_template = EmailTemplate.objects.create(name='foo', subject='bar',
content='baz')
scheduled_time = datetime.now() + timedelta(days=1)
email = send(recipients=['to1@example.com', 'to2@example.com'], sender='from@a.com',
headers=headers, template=email_template,
scheduled_time=scheduled_time)
self.assertEqual(email.to, ['to1@example.com', 'to2@example.com'])
self.assertEqual(email.headers, headers)
self.assertEqual(email.scheduled_time, scheduled_time)
# Test without header
Email.objects.all().delete()
email = send(recipients=['to1@example.com', 'to2@example.com'], sender='from@a.com',
template=email_template)
self.assertEqual(email.to, ['to1@example.com', 'to2@example.com'])
self.assertEqual(email.headers, None)
def test_send_without_template(self):
headers = {'Reply-to': 'reply@email.com'}
scheduled_time = datetime.now() + timedelta(days=1)
email = send(sender='from@a.com',
recipients=['to1@example.com', 'to2@example.com'],
cc=['cc1@example.com', 'cc2@example.com'],
bcc=['bcc1@example.com', 'bcc2@example.com'],
subject='foo', message='bar', html_message='baz',
context={'name': 'Alice'}, headers=headers,
scheduled_time=scheduled_time, priority=PRIORITY.low)
self.assertEqual(email.to, ['to1@example.com', 'to2@example.com'])
self.assertEqual(email.cc, ['cc1@example.com', 'cc2@example.com'])
self.assertEqual(email.bcc, ['bcc1@example.com', 'bcc2@example.com'])
self.assertEqual(email.subject, 'foo')
self.assertEqual(email.message, 'bar')
self.assertEqual(email.html_message, 'baz')
self.assertEqual(email.headers, headers)
self.assertEqual(email.priority, PRIORITY.low)
self.assertEqual(email.scheduled_time, scheduled_time)
# Same thing, but now with context
email = send(['to1@example.com'], 'from@a.com',
subject='Hi {{ name }}', message='Message {{ name }}',
html_message='<b>{{ name }}</b>',
context={'name': 'Bob'}, headers=headers)
self.assertEqual(email.to, ['to1@example.com'])
self.assertEqual(email.subject, 'Hi Bob')
self.assertEqual(email.message, 'Message Bob')
self.assertEqual(email.html_message, '<b>Bob</b>')
self.assertEqual(email.headers, headers)
def test_invalid_syntax(self):
"""
Ensures that invalid template syntax will result in validation errors
when saving a ModelForm of an EmailTemplate.
"""
data = dict(
name='cost',
subject='Hi there!{{ }}',
content='Welcome {{ name|titl }} to the site.',
html_content='{% block content %}<h1>Welcome to the site</h1>'
)
EmailTemplateForm = modelform_factory(EmailTemplate,
exclude=['template'])
form = EmailTemplateForm(data)
self.assertFalse(form.is_valid())
self.assertEqual(form.errors['default_template'], [u'This field is required.'])
self.assertEqual(form.errors['content'], [u"Invalid filter: 'titl'"])
self.assertIn(form.errors['html_content'],
[[u'Unclosed tags: endblock '],
[u"Unclosed tag on line 1: 'block'. Looking for one of: endblock."]])
self.assertIn(form.errors['subject'],
[[u'Empty variable tag'], [u'Empty variable tag on line 1']])
def test_string_priority(self):
"""
Regression test for:
https://github.com/ui/django-post_office/issues/23
"""
email = send(['to1@example.com'], 'from@a.com', priority='low')
self.assertEqual(email.priority, PRIORITY.low)
def test_default_priority(self):
email = send(recipients=['to1@example.com'], sender='from@a.com')
self.assertEqual(email.priority, PRIORITY.medium)
def test_string_priority_exception(self):
invalid_priority_send = lambda: send(['to1@example.com'], 'from@a.com', priority='hgh')
with self.assertRaises(ValueError) as context:
invalid_priority_send()
self.assertEqual(
str(context.exception),
'Invalid priority, must be one of: low, medium, high, now'
)
def test_send_recipient_display_name(self):
"""
Regression test for:
https://github.com/ui/django-post_office/issues/73
"""
email = send(recipients=['Alice Bob <email@example.com>'], sender='from@a.com')
self.assertTrue(email.to)
def test_attachment_filename(self):
attachment = Attachment()
attachment.file.save(
'test.txt',
content=ContentFile('test file content'),
save=True
)
self.assertEqual(attachment.name, 'test.txt')
# Test that it is saved to the correct subdirectory
date = timezone.now().date()
expected_path = os.path.join('post_office_attachments', str(date.year),
str(date.month), str(date.day))
self.assertTrue(expected_path in attachment.file.name)
def test_attachments_email_message(self):
email = Email.objects.create(to=['to@example.com'],
from_email='from@example.com',
subject='Subject')
attachment = Attachment()
attachment.file.save(
'test.txt', content=ContentFile('test file content'), save=True
)
email.attachments.add(attachment)
message = email.email_message()
# https://docs.djangoproject.com/en/1.11/releases/1.11/#email
if django.VERSION >= (1, 11,):
self.assertEqual(message.attachments,
[('test.txt', 'test file content', 'text/plain')])
else:
self.assertEqual(message.attachments,
[('test.txt', b'test file content', None)])
def test_attachments_email_message_with_mimetype(self):
email = Email.objects.create(to=['to@example.com'],
from_email='from@example.com',
subject='Subject')
attachment = Attachment()
attachment.file.save(
'test.txt', content=ContentFile('test file content'), save=True
)
attachment.mimetype = 'text/plain'
attachment.save()
email.attachments.add(attachment)
message = email.email_message()
if django.VERSION >= (1, 11,):
self.assertEqual(message.attachments,
[('test.txt', 'test file content', 'text/plain')])
else:
self.assertEqual(message.attachments,
[('test.txt', b'test file content', 'text/plain')])
def test_translated_template_uses_default_templates_name(self):
template = EmailTemplate.objects.create(name='name')
id_template = template.translated_templates.create(language='id')
self.assertEqual(id_template.name, template.name)
def test_models_repr(self):
self.assertEqual(repr(EmailTemplate(name='test', language='en')),
'<EmailTemplate: test en>')
self.assertEqual(repr(Email(to=['test@example.com'])),
"<Email: ['test@example.com']>")
def test_natural_key(self):
template = EmailTemplate.objects.create(name='name')
self.assertEqual(template, EmailTemplate.objects.get_by_natural_key(*template.natural_key()))
data = serializers.serialize('json', [template], use_natural_primary_keys=True)
self.assertNotIn('pk', json.loads(data)[0])
deserialized_objects = serializers.deserialize('json', data, use_natural_primary_keys=True)
list(deserialized_objects)[0].save()
self.assertEqual(EmailTemplate.objects.count(), 1)

@ -0,0 +1,203 @@
from django.core.files.base import ContentFile
from django.core.exceptions import ValidationError
from django.test import TestCase
from django.test.utils import override_settings
from ..models import Email, STATUS, PRIORITY, EmailTemplate, Attachment
from ..utils import (create_attachments, get_email_template, parse_emails,
parse_priority, send_mail, split_emails)
from ..validators import validate_email_with_name, validate_comma_separated_emails
@override_settings(EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend')
class UtilsTest(TestCase):
def test_mail_status(self):
"""
Check that send_mail assigns the right status field to Email instances
"""
send_mail('subject', 'message', 'from@example.com', ['to@example.com'],
priority=PRIORITY.medium)
email = Email.objects.latest('id')
self.assertEqual(email.status, STATUS.queued)
# Emails sent with "now" priority is sent right away
send_mail('subject', 'message', 'from@example.com', ['to@example.com'],
priority=PRIORITY.now)
email = Email.objects.latest('id')
self.assertEqual(email.status, STATUS.sent)
def test_email_validator(self):
# These should validate
validate_email_with_name('email@example.com')
validate_email_with_name('Alice Bob <email@example.com>')
Email.objects.create(to=['to@example.com'], from_email='Alice <from@example.com>',
subject='Test', message='Message', status=STATUS.sent)
# Should also support international domains
validate_email_with_name('Alice Bob <email@example.co.id>')
# These should raise ValidationError
self.assertRaises(ValidationError, validate_email_with_name, 'invalid')
self.assertRaises(ValidationError, validate_email_with_name, 'Al <ab>')
def test_comma_separated_email_list_validator(self):
# These should validate
validate_comma_separated_emails(['email@example.com'])
validate_comma_separated_emails(
['email@example.com', 'email2@example.com', 'email3@example.com']
)
validate_comma_separated_emails(['Alice Bob <email@example.com>'])
# Should also support international domains
validate_comma_separated_emails(['email@example.co.id'])
# These should raise ValidationError
self.assertRaises(ValidationError, validate_comma_separated_emails,
['email@example.com', 'invalid_mail', 'email@example.com'])
def test_get_template_email(self):
# Sanity Check
name = 'customer/happy-holidays'
self.assertRaises(EmailTemplate.DoesNotExist, get_email_template, name)
template = EmailTemplate.objects.create(name=name, content='test')
# First query should hit database
self.assertNumQueries(1, lambda: get_email_template(name))
# Second query should hit cache instead
self.assertNumQueries(0, lambda: get_email_template(name))
# It should return the correct template
self.assertEqual(template, get_email_template(name))
# Repeat with language support
template = EmailTemplate.objects.create(name=name, content='test',
language='en')
# First query should hit database
self.assertNumQueries(1, lambda: get_email_template(name, 'en'))
# Second query should hit cache instead
self.assertNumQueries(0, lambda: get_email_template(name, 'en'))
# It should return the correct template
self.assertEqual(template, get_email_template(name, 'en'))
def test_template_caching_settings(self):
"""Check if POST_OFFICE_CACHE and POST_OFFICE_TEMPLATE_CACHE understood
correctly
"""
def is_cache_used(suffix='', desired_cache=False):
"""Raise exception if real cache usage not equal to desired_cache value
"""
# to avoid cache cleaning - just create new template
name = 'can_i/suport_cache_settings%s' % suffix
self.assertRaises(
EmailTemplate.DoesNotExist, get_email_template, name
)
EmailTemplate.objects.create(name=name, content='test')
# First query should hit database anyway
self.assertNumQueries(1, lambda: get_email_template(name))
# Second query should hit cache instead only if we want it
self.assertNumQueries(
0 if desired_cache else 1,
lambda: get_email_template(name)
)
return
# default - use cache
is_cache_used(suffix='with_default_cache', desired_cache=True)
# disable cache
with self.settings(POST_OFFICE_CACHE=False):
is_cache_used(suffix='cache_disabled_global', desired_cache=False)
with self.settings(POST_OFFICE_TEMPLATE_CACHE=False):
is_cache_used(
suffix='cache_disabled_for_templates', desired_cache=False
)
with self.settings(POST_OFFICE_CACHE=True, POST_OFFICE_TEMPLATE_CACHE=False):
is_cache_used(
suffix='cache_disabled_for_templates_but_enabled_global',
desired_cache=False
)
return
def test_split_emails(self):
"""
Check that split emails correctly divide email lists for multiprocessing
"""
for i in range(225):
Email.objects.create(from_email='from@example.com', to=['to@example.com'])
expected_size = [57, 56, 56, 56]
email_list = split_emails(Email.objects.all(), 4)
self.assertEqual(expected_size, [len(emails) for emails in email_list])
def test_create_attachments(self):
attachments = create_attachments({
'attachment_file1.txt': ContentFile('content'),
'attachment_file2.txt': ContentFile('content'),
})
self.assertEqual(len(attachments), 2)
self.assertIsInstance(attachments[0], Attachment)
self.assertTrue(attachments[0].pk)
self.assertEqual(attachments[0].file.read(), b'content')
self.assertTrue(attachments[0].name.startswith('attachment_file'))
self.assertEquals(attachments[0].mimetype, u'')
def test_create_attachments_with_mimetype(self):
attachments = create_attachments({
'attachment_file1.txt': {
'file': ContentFile('content'),
'mimetype': 'text/plain'
},
'attachment_file2.jpg': {
'file': ContentFile('content'),
'mimetype': 'text/plain'
}
})
self.assertEqual(len(attachments), 2)
self.assertIsInstance(attachments[0], Attachment)
self.assertTrue(attachments[0].pk)
self.assertEquals(attachments[0].file.read(), b'content')
self.assertTrue(attachments[0].name.startswith('attachment_file'))
self.assertEquals(attachments[0].mimetype, 'text/plain')
def test_create_attachments_open_file(self):
attachments = create_attachments({
'attachment_file.py': __file__,
})
self.assertEqual(len(attachments), 1)
self.assertIsInstance(attachments[0], Attachment)
self.assertTrue(attachments[0].pk)
self.assertTrue(attachments[0].file.read())
self.assertEquals(attachments[0].name, 'attachment_file.py')
self.assertEquals(attachments[0].mimetype, u'')
def test_parse_priority(self):
self.assertEqual(parse_priority('now'), PRIORITY.now)
self.assertEqual(parse_priority('high'), PRIORITY.high)
self.assertEqual(parse_priority('medium'), PRIORITY.medium)
self.assertEqual(parse_priority('low'), PRIORITY.low)
def test_parse_emails(self):
# Converts a single email to list of email
self.assertEqual(
parse_emails('test@example.com'),
['test@example.com']
)
# None is converted into an empty list
self.assertEqual(parse_emails(None), [])
# Raises ValidationError if email is invalid
self.assertRaises(
ValidationError,
parse_emails, 'invalid_email'
)
self.assertRaises(
ValidationError,
parse_emails, ['invalid_email', 'test@example.com']
)

@ -0,0 +1,35 @@
from django.contrib.auth.models import User
from django.test.client import Client
from django.test import TestCase
try:
from django.urls import reverse
except ImportError:
from django.core.urlresolvers import reverse
from post_office import mail
from post_office.models import Email
admin_username = 'real_test_admin'
admin_email = 'read@admin.com'
admin_pass = 'admin_pass'
class AdminViewTest(TestCase):
def setUp(self):
user = User.objects.create_superuser(admin_username, admin_email, admin_pass)
self.client = Client()
self.client.login(username=user.username, password=admin_pass)
# Small test to make sure the admin interface is loaded
def test_admin_interface(self):
response = self.client.get(reverse('admin:index'))
self.assertEqual(response.status_code, 200)
def test_admin_change_page(self):
"""Ensure that changing an email object in admin works."""
mail.send(recipients=['test@example.com'], headers={'foo': 'bar'})
email = Email.objects.latest('id')
response = self.client.get(reverse('admin:post_office_email_change', args=[email.id]))
self.assertEqual(response.status_code, 200)

@ -0,0 +1,138 @@
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.files import File
from django.utils.encoding import force_text
from post_office import cache
from .compat import string_types
from .models import Email, PRIORITY, STATUS, EmailTemplate, Attachment
from .settings import get_default_priority
from .validators import validate_email_with_name
def send_mail(subject, message, from_email, recipient_list, html_message='',
scheduled_time=None, headers=None, priority=PRIORITY.medium):
"""
Add a new message to the mail queue. This is a replacement for Django's
``send_mail`` core email method.
"""
subject = force_text(subject)
status = None if priority == PRIORITY.now else STATUS.queued
emails = []
for address in recipient_list:
emails.append(
Email.objects.create(
from_email=from_email, to=address, subject=subject,
message=message, html_message=html_message, status=status,
headers=headers, priority=priority, scheduled_time=scheduled_time
)
)
if priority == PRIORITY.now:
for email in emails:
email.dispatch()
return emails
def get_email_template(name, language=''):
"""
Function that returns an email template instance, from cache or DB.
"""
use_cache = getattr(settings, 'POST_OFFICE_CACHE', True)
if use_cache:
use_cache = getattr(settings, 'POST_OFFICE_TEMPLATE_CACHE', True)
if not use_cache:
return EmailTemplate.objects.get(name=name, language=language)
else:
composite_name = '%s:%s' % (name, language)
email_template = cache.get(composite_name)
if email_template is not None:
return email_template
else:
email_template = EmailTemplate.objects.get(name=name,
language=language)
cache.set(composite_name, email_template)
return email_template
def split_emails(emails, split_count=1):
# Group emails into X sublists
# taken from http://www.garyrobinson.net/2008/04/splitting-a-pyt.html
# Strange bug, only return 100 email if we do not evaluate the list
if list(emails):
return [emails[i::split_count] for i in range(split_count)]
def create_attachments(attachment_files):
"""
Create Attachment instances from files
attachment_files is a dict of:
* Key - the filename to be used for the attachment.
* Value - file-like object, or a filename to open OR a dict of {'file': file-like-object, 'mimetype': string}
Returns a list of Attachment objects
"""
attachments = []
for filename, filedata in attachment_files.items():
if isinstance(filedata, dict):
content = filedata.get('file', None)
mimetype = filedata.get('mimetype', None)
else:
content = filedata
mimetype = None
opened_file = None
if isinstance(content, string_types):
# `content` is a filename - try to open the file
opened_file = open(content, 'rb')
content = File(opened_file)
attachment = Attachment()
if mimetype:
attachment.mimetype = mimetype
attachment.file.save(filename, content=content, save=True)
attachments.append(attachment)
if opened_file is not None:
opened_file.close()
return attachments
def parse_priority(priority):
if priority is None:
priority = get_default_priority()
# If priority is given as a string, returns the enum representation
if isinstance(priority, string_types):
priority = getattr(PRIORITY, priority, None)
if priority is None:
raise ValueError('Invalid priority, must be one of: %s' %
', '.join(PRIORITY._fields))
return priority
def parse_emails(emails):
"""
A function that returns a list of valid email addresses.
This function will also convert a single email address into
a list of email addresses.
None value is also converted into an empty list.
"""
if isinstance(emails, string_types):
emails = [emails]
elif emails is None:
emails = []
for email in emails:
try:
validate_email_with_name(email)
except ValidationError:
raise ValidationError('%s is not a valid email address' % email)
return emails

@ -0,0 +1,50 @@
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.template import Template, TemplateSyntaxError, TemplateDoesNotExist
from django.utils.encoding import force_text
from .compat import text_type
def validate_email_with_name(value):
"""
Validate email address.
Both "Recipient Name <email@example.com>" and "email@example.com" are valid.
"""
value = force_text(value)
if '<' and '>' in value:
start = value.find('<') + 1
end = value.find('>')
if start < end:
recipient = value[start:end]
else:
recipient = value
validate_email(recipient)
def validate_comma_separated_emails(value):
"""
Validate every email address in a comma separated list of emails.
"""
if not isinstance(value, (tuple, list)):
raise ValidationError('Email list must be a list/tuple.')
for email in value:
try:
validate_email_with_name(email)
except ValidationError:
raise ValidationError('Invalid email: %s' % email, code='invalid')
def validate_template_syntax(source):
"""
Basic Django Template syntax validation. This allows for robuster template
authoring.
"""
try:
Template(source)
except (TemplateSyntaxError, TemplateDoesNotExist) as err:
raise ValidationError(text_type(err))

@ -0,0 +1 @@
# Create your views here.