Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 100 additions & 26 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,25 @@ local web browser during development is also provided.
Dependencies
============

* `python-gnupg <https://bitbucket.org/vinay.sajip/python-gnupg>`_ is
required for sending PGP encrypted email.
* `python-gnupg <https://bitbucket.org/vinay.sajip/python-gnupg>`_ is
required for sending PGP encrypted email.


Installation
============

The easiest way to install django-email-extras is directly from PyPi
using `pip <https://pip.pypa.io/en/stable/>`_ by running the command
below::
below:

.. code-block:: bash

$ pip install -U django-email-extras

Otherwise you can download django-email-extras and install it directly
from source::
from source:

.. code-block:: bash

$ python setup.py install

Expand All @@ -43,8 +47,8 @@ Once installed, first add ``email_extras`` to your ``INSTALLED_APPS``
setting and run the migrations. Then there are two functions for sending email
in the ``email_extras.utils`` module:

* ``send_mail``
* ``send_mail_template``
* ``send_mail``
* ``send_mail_template``

The former mimics the signature of ``django.core.mail.send_mail``
while the latter provides the ability to send multipart emails
Expand Down Expand Up @@ -75,15 +79,83 @@ When an ``Address`` is deleted via the Django Admin, the key is
removed from the key ring on the server.


Sending PGP Signed Email
========================

Adding a private/public signing keypair is different than importing a
public encryption key, since the private key will be stored on the
server.

This project ships with a Django management command to generate and
export private signing keys: ``email_signing_key``
management command.

You first need to set the ``EMAIL_EXTRAS_SIGNING_KEY_DATA`` option in your project's
``settings.py``. This is a dictionary that is passed as keyword arguments
directly to ``GPG.gen_key()``, so please read and understand all of the
available `options in their documentation <https://pythonhosted.org/python-gnupg/#generating-keys>`_. The default settings are:

.. code-block:: python

EMAIL_EXTRAS_SIGNING_KEY_DATA = {
'key_type': "RSA",
'key_length': 4096,
'name_real': settings.SITE_NAME,
'name_comment': "Outgoing email server",
'name_email': settings.DEFAULT_FROM_EMAIL,
'expire_date': '2y',
}

You may wish to change the ``key_type`` to a signing-only type of key,
such as DSA, or the expire date.

Once you are content with the signing key settings, generate a new
signing key with the ``--generate`` option:

.. code-block:: bash

python manage.py email_signing_key --generate

To work with specific keys, identify them by their fingerprint

.. code-block:: bash

python manage.py email_signing_key 7AB59FE794A7AC12EBA87507EF33F601153CFE28

You can print the private key to your terminal/console with:

.. code-block:: bash

python manage.py email_signing_key 7AB59FE794A7AC12EBA87507EF33F601153CFE28 --print-private-key

And you can upload the public signing key to one or more specified
keyservers by passing the key server hostnames with the ``-k`` or
``--keyserver`` options:

.. code-block:: bash

python manage.py email_signing_key 7AB59FE794A7AC12EBA87507EF33F601153CFE28 -k keys.ubuntu.com keys.redhat.com -k pgp.mit.edu

You can also perform all tasks with one command:

.. code-block:: bash

python manage.py email_signing_key --generate --keyserver pgp.mit.edu --print-private-key

Use the ``--help`` option to see the complete help text for the command.


Sending Multipart Email with Django Templates
=============================================

As mentioned above, the following function is provided in
the ``email_extras.utils`` module::
the ``email_extras.utils`` module:

.. code-block:: python

send_mail_template(subject, template, addr_from, addr_to,
fail_silently=False, attachments=None, context=None,
headers=None)
send_mail_template(subject, template, addr_from, addr_to,
fail_silently=False, attachments=None, context=None,
headers=None)

The arguments that differ from ``django.core.mail.send_mail`` are
``template`` and ``context``. The ``template`` argument is simply
Expand All @@ -95,8 +167,8 @@ the ``email_extras`` directory where your templates are stored,
therefore if the name ``contact_form`` was given for the ``template``
argument, the two template files for the email would be:

* ``templates/email_extras/contact_form.html``
* ``templates/email_extras/contact_form.txt``
* ``templates/email_extras/contact_form.html``
* ``templates/email_extras/contact_form.txt``

The ``attachments`` argument is a list of files to attach to the email.
Each attachment can be the full filesystem path to the file, or a
Expand All @@ -117,18 +189,18 @@ Configuration
There are two settings you can configure in your project's
``settings.py`` module:

* ``EMAIL_EXTRAS_USE_GNUPG`` - Boolean that controls whether the PGP
encryption features are used. Defaults to ``True`` if
``EMAIL_EXTRAS_GNUPG_HOME`` is specified, otherwise ``False``.
* ``EMAIL_EXTRAS_GNUPG_HOME`` - String representing a custom location
for the GNUPG keyring.
* ``EMAIL_EXTRAS_GNUPG_ENCODING`` - String representing a gnupg encoding.
Defaults to GNUPG ``latin-1`` and could be changed to e.g. ``utf-8``
if needed. Check out
`python-gnupg docs <https://pythonhosted.org/python-gnupg/#getting-started>`_
for more info.
* ``EMAIL_EXTRAS_ALWAYS_TRUST_KEYS`` - Skip key validation and assume
that used keys are always fully trusted.
* ``EMAIL_EXTRAS_USE_GNUPG`` - Boolean that controls whether the PGP
encryption features are used. Defaults to ``True`` if
``EMAIL_EXTRAS_GNUPG_HOME`` is specified, otherwise ``False``.
* ``EMAIL_EXTRAS_GNUPG_HOME`` - String representing a custom location
for the GNUPG keyring.
* ``EMAIL_EXTRAS_GNUPG_ENCODING`` - String representing a gnupg encoding.
Defaults to GNUPG ``latin-1`` and could be changed to e.g. ``utf-8``
if needed. Check out
`python-gnupg docs <https://pythonhosted.org/python-gnupg/#getting-started>`_
for more info.
* ``EMAIL_EXTRAS_ALWAYS_TRUST_KEYS`` - Skip key validation and assume
that used keys are always fully trusted.


Local Browser Testing
Expand All @@ -138,9 +210,11 @@ When sending multipart emails during development, it can be useful
to view the HTML part of the email in a web browser, without having
to actually send emails and open them in a mail client. To use
this feature during development, simply set your email backend as follows
in your development ``settings.py`` module::
in your development ``settings.py`` module:

.. code-block:: python

EMAIL_BACKEND = 'email_extras.backends.BrowsableEmailBackend'
EMAIL_BACKEND = 'email_extras.backends.BrowsableEmailBackend'

With this configured, each time a multipart email is sent, it will
be written to a temporary file, which is then automatically opened
Expand Down
1 change: 0 additions & 1 deletion email_extras/admin.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

from email_extras.settings import USE_GNUPG


Expand Down
7 changes: 7 additions & 0 deletions email_extras/apps.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
from django.apps import AppConfig

from email_extras.utils import check_signing_key


class EmailExtrasConfig(AppConfig):
name = 'email_extras'
verbose_name = 'Email Extras'

# AFAICT, this is impossible to test
def ready(self): # pragma: noqa
# Fail early and loudly if the signing key fingerprint is misconfigured
check_signing_key()
177 changes: 177 additions & 0 deletions email_extras/backends.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
from __future__ import with_statement

from os.path import basename
from tempfile import NamedTemporaryFile
import webbrowser

from django.conf import settings
from django.core.mail.backends.base import BaseEmailBackend
from django.core.mail.backends.console import EmailBackend as ConsoleBackend
from django.core.mail.backends.locmem import EmailBackend as LocmemBackend
from django.core.mail.backends.filebased import EmailBackend as FileBackend
from django.core.mail.backends.smtp import EmailBackend as SmtpBackend
from django.core.mail.message import EmailMultiAlternatives
from django.utils.encoding import smart_text

from .handlers import (handle_failed_message_encryption,
handle_failed_alternative_encryption,
handle_failed_attachment_encryption)
from .settings import USE_GNUPG
from .utils import (EncryptionFailedError, encrypt_kwargs, get_gpg)


class BrowsableEmailBackend(BaseEmailBackend):
Expand All @@ -26,3 +40,166 @@ def open(self, body):
temp.write(body.encode('utf-8'))

webbrowser.open("file://" + temp.name)


if USE_GNUPG:
from .models import Address

# Create the GPG object
gpg = get_gpg()

def copy_message(msg):
return EmailMultiAlternatives(
to=msg.to,
cc=msg.cc,
bcc=msg.bcc,
reply_to=msg.reply_to,
from_email=msg.from_email,
subject=msg.subject,
body=msg.body,
attachments=msg.attachments,
headers=msg.extra_headers,
connection=msg.connection)

def encrypt(text, addr):
encryption_result = gpg.encrypt(text, addr, **encrypt_kwargs)
if not encryption_result.ok:
raise EncryptionFailedError("Encrypting mail to %s failed: '%s'",
addr, encryption_result.status)
if smart_text(encryption_result) == "" and text != "":
raise EncryptionFailedError("Encrypting mail to %s failed.",
addr)
return smart_text(encryption_result)

def encrypt_attachment(address, attachment, use_asc):
# Attachments can either just be filenames or a
# (filename, content, mimetype) triple
if not hasattr(attachment, "__iter__"):
filename = basename(attachment)
mimetype = None

# If the attachment is just a filename, open the file,
# encrypt it, and attach it
with open(attachment, "rb") as f:
content = f.read()
else:
# Unpack attachment tuple
filename, content, mimetype = attachment

# Ignore attachments if they're already encrypted
if mimetype == "application/gpg-encrypted":
return attachment

try:
encrypted_content = encrypt(content, address)
except EncryptionFailedError as e:
# This function will need to decide what to do. Possibilities include
# one or more of:
#
# * Mail admins (possibly without encrypting the message to them)
# * Remove the offending key automatically
# * Set the body to a blank string
# * Set the body to the cleartext
# * Set the body to the cleartext, with a warning message prepended
# * Set the body to a custom error string
# * Reraise the exception
#
# However, the behavior will be very site-specific, because each site
# will have different attackers, different threat profiles, different
# compliance requirements, and different policies.
#
handle_failed_attachment_encryption(e)
else:
if use_asc and filename is not None:
filename += ".asc"

return (filename, encrypted_content, "application/gpg-encrypted")

def encrypt_messages(email_messages):
unencrypted_messages = []
encrypted_messages = []
for msg in email_messages:
# Copied out of utils.py
# Obtain a list of the recipients that have GPG keys installed
key_addrs = dict(Address.objects.filter(address__in=msg.to)
.values_list('address', 'use_asc'))

# Encrypt emails - encrypted emails need to be sent individually,
# while non-encrypted emails can be sent in one send. So we split
# up each message into 1 or more parts: the unencrypted message
# that is addressed to everybody who doesn't have a key, and a
# separate message for people who do have keys.
unencrypted_msg = copy_message(msg)
unencrypted_msg.to = [addr for addr in msg.to
if addr not in key_addrs]
if unencrypted_msg.to:
unencrypted_messages.append(unencrypted_msg)

# Make a new message object for each recipient with a key
new_msg = copy_message(msg)

# Encrypt the message body and all attachments for all addresses
# we have keys for
for address, use_asc in key_addrs.items():
if getattr(msg, 'do_not_encrypt_this_message', False):
unencrypted_messages.append(new_msg)
continue

# Replace the message body with the encrypted message body
try:
new_msg.body = encrypt(new_msg.body, address)
except EncryptionFailedError as e:
handle_failed_message_encryption(e)

# If the message has alternatives, encrypt them all
alternatives = []
for alt, mimetype in getattr(new_msg, 'alternatives', []):
# Ignore alternatives if they're already encrypted
if mimetype == "application/gpg-encrypted":
alternatives.append((alt, mimetype))
continue

try:
encrypted_alternative = encrypt(alt, address)
except EncryptionFailedError as e:
handle_failed_alternative_encryption(e)
else:
alternatives.append((encrypted_alternative,
"application/gpg-encrypted"))
# Replace all of the alternatives
new_msg.alternatives = alternatives

# Replace all unencrypted attachments with their encrypted
# versions
attachments = []
for attachment in new_msg.attachments:
attachments.append(
encrypt_attachment(address, attachment, use_asc))
new_msg.attachments = attachments

encrypted_messages.append(new_msg)

return unencrypted_messages + encrypted_messages

class EncryptingEmailBackendMixin(object):
def send_messages(self, email_messages):
if USE_GNUPG:
email_messages = encrypt_messages(email_messages)
super(EncryptingEmailBackendMixin, self)\
.send_messages(email_messages)

class EncryptingConsoleEmailBackend(EncryptingEmailBackendMixin,
ConsoleBackend):
pass

class EncryptingLocmemEmailBackend(EncryptingEmailBackendMixin,
LocmemBackend):
pass

class EncryptingFilebasedEmailBackend(EncryptingEmailBackendMixin,
FileBackend):
pass

class EncryptingSmtpEmailBackend(EncryptingEmailBackendMixin,
SmtpBackend):
pass
Loading