From 6d29d4b21ec151a1e42ef675cc0fdf930376c9b9 Mon Sep 17 00:00:00 2001
From: Drew Hubl <drew.hubl@gmail.com>
Date: Mon, 20 Mar 2017 19:53:48 -0600
Subject: [PATCH 01/11] Add encrypting backend mixin and mix it in with
 Django's built-in backends

---
 email_extras/backends.py | 175 +++++++++++++++++++++++++++++++++++++++
 email_extras/settings.py |   6 ++
 email_extras/utils.py    |  12 ++-
 3 files changed, 189 insertions(+), 4 deletions(-)

diff --git a/email_extras/backends.py b/email_extras/backends.py
index c5f618b..cbfc97d 100644
--- a/email_extras/backends.py
+++ b/email_extras/backends.py
@@ -1,9 +1,20 @@
+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 .settings import (GNUPG_HOME, GNUPG_ENCODING, USE_GNUPG)
+from .utils import (EncryptionFailedError, encrypt_kwargs)
 
 
 class BrowsableEmailBackend(BaseEmailBackend):
@@ -26,3 +37,167 @@ def open(self, body):
             temp.write(body.encode('utf-8'))
 
         webbrowser.open("file://" + temp.name)
+
+
+class AttachmentEncryptionFailedError(EncryptionFailedError):
+    pass
+
+
+class AlternativeEncryptionFailedError(EncryptionFailedError):
+    pass
+
+
+if USE_GNUPG:
+    from gnupg import GPG
+
+    from .models import Address
+
+    # Create the GPG object
+    gpg = GPG(gnupghome=GNUPG_HOME)
+    if GNUPG_ENCODING is not None:
+        gpg.encoding = GNUPG_ENCODING
+
+    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:
+            # SECURITY: We could include a piece of the content here, but that
+            # would leak information in logs and to the admins. So instead, we
+            # only try to include the filename.
+            raise AttachmentEncryptionFailedError(
+                "Encrypting attachment to %s failed: %s (%s)", address,
+                filename, e.msg)
+        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
+                new_msg.body = encrypt(new_msg.body, address)
+
+                # 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:
+                        raise AlternativeEncryptionFailedError(
+                            "Encrypting alternative to %s failed: %s (%s)",
+                            address, alt, e.msg)
+                    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
diff --git a/email_extras/settings.py b/email_extras/settings.py
index fe17e4d..043c438 100644
--- a/email_extras/settings.py
+++ b/email_extras/settings.py
@@ -8,6 +8,12 @@
 ALWAYS_TRUST = getattr(settings, "EMAIL_EXTRAS_ALWAYS_TRUST_KEYS", False)
 GNUPG_ENCODING = getattr(settings, "EMAIL_EXTRAS_GNUPG_ENCODING", None)
 
+# Used internally
+encrypt_kwargs = {
+    'always_trust': ALWAYS_TRUST,
+}
+
+
 if USE_GNUPG:
     try:
         import gnupg  # noqa: F401
diff --git a/email_extras/utils.py b/email_extras/utils.py
index ebb2352..a24a07c 100644
--- a/email_extras/utils.py
+++ b/email_extras/utils.py
@@ -7,13 +7,18 @@
 from django.utils import six
 from django.utils.encoding import smart_text
 
-from email_extras.settings import (USE_GNUPG, GNUPG_HOME, ALWAYS_TRUST,
-                                   GNUPG_ENCODING)
+from email_extras.settings import (ALWAYS_TRUST, GNUPG_ENCODING, GNUPG_HOME,
+                                   USE_GNUPG)
 
 
 if USE_GNUPG:
     from gnupg import GPG
 
+# Used internally
+encrypt_kwargs = {
+    'always_trust': ALWAYS_TRUST,
+}
+
 
 class EncryptionFailedError(Exception):
     pass
@@ -80,8 +85,7 @@ def has_pgp_key(addr):
     # Encrypts body if recipient has a gpg key installed.
     def encrypt_if_key(body, addr_list):
         if has_pgp_key(addr_list[0]):
-            encrypted = gpg.encrypt(body, addr_list[0],
-                                    always_trust=ALWAYS_TRUST)
+            encrypted = gpg.encrypt(body, addr_list[0], **encrypt_kwargs)
             if encrypted == "" and body != "":  # encryption failed
                 raise EncryptionFailedError("Encrypting mail to %s failed.",
                                             addr_list[0])

From 620a2dbbcd140538a97e7c96907ea3a8d30a7436 Mon Sep 17 00:00:00 2001
From: Drew Hubl <drew.hubl@gmail.com>
Date: Fri, 24 Mar 2017 18:22:21 -0600
Subject: [PATCH 02/11] Add management command to generate new signing key and
 upload it to keyservers

---
 email_extras/admin.py                         |   1 -
 email_extras/management/__init__.py           |   0
 email_extras/management/commands/__init__.py  |   0
 .../management/commands/email_signing_key.py  | 104 ++++++++++++++++++
 email_extras/settings.py                      |  10 ++
 5 files changed, 114 insertions(+), 1 deletion(-)
 create mode 100644 email_extras/management/__init__.py
 create mode 100644 email_extras/management/commands/__init__.py
 create mode 100644 email_extras/management/commands/email_signing_key.py

diff --git a/email_extras/admin.py b/email_extras/admin.py
index 82bea64..0cb1030 100644
--- a/email_extras/admin.py
+++ b/email_extras/admin.py
@@ -1,4 +1,3 @@
-
 from email_extras.settings import USE_GNUPG
 
 
diff --git a/email_extras/management/__init__.py b/email_extras/management/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/email_extras/management/commands/__init__.py b/email_extras/management/commands/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/email_extras/management/commands/email_signing_key.py b/email_extras/management/commands/email_signing_key.py
new file mode 100644
index 0000000..323ed56
--- /dev/null
+++ b/email_extras/management/commands/email_signing_key.py
@@ -0,0 +1,104 @@
+"""
+Script to generate and upload a signing key to keyservers
+"""
+from __future__ import print_function
+
+import argparse
+import sys
+
+from gnupg import GPG
+
+from django.core.management.base import LabelCommand
+from django.utils.translation import ugettext as _
+
+from email_extras.models import Key
+from email_extras.settings import (GNUPG_HOME, SIGNING_KEY_DATA)
+
+
+gpg = GPG(gnupghome=GNUPG_HOME)
+
+
+# Create an action that *extends* a list, instead of *appending* to it
+class ExtendAction(argparse.Action):
+    def __call__(self, parser, namespace, values, option_string=None):
+        items = getattr(namespace, self.dest) or []
+        items.extend(values)
+        setattr(namespace, self.dest, items)
+
+
+class Command(LabelCommand):
+    label = "FINGERPRINT"
+    missing_args_message = ("Enter at least one fingerprint or use the "
+                            "--generate option.")
+
+    def add_arguments(self, parser):
+        # Register our extending action
+        parser.register('action', 'extend', ExtendAction)
+
+        parser.add_argument('args', metavar=self.label, nargs='*')
+        parser.add_argument(
+            '--generate',
+            action='store_true',
+            default=False,
+            help=_("Generate a new signing key"))
+        parser.add_argument(
+            '--print-private-key',
+            action='store_true',
+            default=False,
+            dest='print_private_key',
+            help=_("Print the private signing key"))
+        parser.add_argument(
+            '-k', '--keyserver',
+            # We want multiple uses of -k server1 server 2 -k server3 server4
+            # to be interpreted as [server1, server2, server3, server4], so we
+            # need to use the custom ExtendAction we defiend before
+            action='extend',
+            nargs='+',
+            dest='keyservers',
+            help=_("Upload (the most recently generated) public signing key "
+                   "to the specified keyservers"))
+
+    def handle(self, *labels, **options):
+        # EITHER specify the key fingerprints OR generate a key
+        if options.get('generate') and labels:
+            print("You cannot specify fingerprints and --generate when "
+                  "running this command")
+            sys.exit(-1)
+
+        if options.get('generate'):
+            signing_key_cmd = gpg.gen_key_input(**SIGNING_KEY_DATA)
+            new_signing_key = gpg.gen_key(signing_key_cmd)
+
+            exported_signing_key = gpg.export_keys(
+                new_signing_key.fingerprint)
+
+            self.key = Key.objects.create(key=exported_signing_key,
+                                          use_asc=False)
+            labels = [self.key.fingerprint]
+
+        return super(Command, self).handle(*labels, **options)
+
+    def handle_label(self, label, **options):
+        try:
+            self.key = Key.objects.get(fingerprint=label)
+        except Key.DoesNotExist:
+            print("Key matching fingerprint '%(fp)s' not found." % {
+                'fp': label,
+            })
+            sys.exit(-1)
+
+        for ks in set(options.get('keyservers')):
+            gpg.send_keys(ks, self.key.fingerprint)
+
+        output = ''
+
+        if options.get('print_private_key'):
+            output += gpg.export_keys([self.key.fingerprint], True)
+
+        # If we havne't been told to do anything else, print out the public
+        # signing key
+        if not options.get('keyservers') and \
+           not options.get('print_private_key'):
+            output += gpg.export_keys([self.key.fingerprint])
+
+        return output
diff --git a/email_extras/settings.py b/email_extras/settings.py
index 043c438..8306ac7 100644
--- a/email_extras/settings.py
+++ b/email_extras/settings.py
@@ -5,8 +5,18 @@
 
 GNUPG_HOME = getattr(settings, "EMAIL_EXTRAS_GNUPG_HOME", None)
 USE_GNUPG = getattr(settings, "EMAIL_EXTRAS_USE_GNUPG", GNUPG_HOME is not None)
+
 ALWAYS_TRUST = getattr(settings, "EMAIL_EXTRAS_ALWAYS_TRUST_KEYS", False)
 GNUPG_ENCODING = getattr(settings, "EMAIL_EXTRAS_GNUPG_ENCODING", None)
+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',
+}
+SIGNING_KEY_DATA.update(getattr(settings, "EMAIL_EXTRAS_SIGNING_KEY_DATA", {}))
 
 # Used internally
 encrypt_kwargs = {

From f7fc4afc55ebd4483bea7811cc6663ba21ed0008 Mon Sep 17 00:00:00 2001
From: Drew Hubl <drew.hubl@gmail.com>
Date: Fri, 24 Mar 2017 18:23:16 -0600
Subject: [PATCH 03/11] Add option to sign messages

---
 README.rst               | 126 +++++++++++++++++++++++++++++++--------
 email_extras/apps.py     |  19 ++++++
 email_extras/settings.py |   8 +--
 email_extras/utils.py    |   1 +
 4 files changed, 122 insertions(+), 32 deletions(-)

diff --git a/README.rst b/README.rst
index 8b70af8..ac59547 100644
--- a/README.rst
+++ b/README.rst
@@ -17,8 +17,8 @@ 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
@@ -26,12 +26,16 @@ 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
 
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
diff --git a/email_extras/apps.py b/email_extras/apps.py
index 4a810d5..9a8d18e 100644
--- a/email_extras/apps.py
+++ b/email_extras/apps.py
@@ -1,6 +1,25 @@
 from django.apps import AppConfig
 
+from gnupg import GPG
+
+from email_extras.settings import USE_GNUPG, SIGNING_KEY_FINGERPRINT
+
 
 class EmailExtrasConfig(AppConfig):
     name = 'email_extras'
     verbose_name = 'Email Extras'
+
+    def ready(self):
+        # Fail early and loudly if the signing key fingerprint is misconfigured
+        if USE_GNUPG and SIGNING_KEY_FINGERPRINT is not None:
+            gpg = GPG()
+            try:
+                gpg.list_keys().key_map[SIGNING_KEY_FINGERPRINT]
+            except KeyError:
+                raise Exception(
+                    "The key specified by the "
+                    "EMAIL_EXTRAS_SIGNING_KEY_FINGERPRINT setting"
+                    "({fp}) does not exist in the GPG keyring. Adjust the"
+                    "EMAIL_EXTRAS_GNUPG_HOME setting, correct the key "
+                    "fingerprint, or generate a new key by running "
+                    "python manage.py email_signing_key --generate to fix.")
diff --git a/email_extras/settings.py b/email_extras/settings.py
index 8306ac7..d458c03 100644
--- a/email_extras/settings.py
+++ b/email_extras/settings.py
@@ -17,12 +17,8 @@
     'expire_date': '2y',
 }
 SIGNING_KEY_DATA.update(getattr(settings, "EMAIL_EXTRAS_SIGNING_KEY_DATA", {}))
-
-# Used internally
-encrypt_kwargs = {
-    'always_trust': ALWAYS_TRUST,
-}
-
+SIGNING_KEY_FINGERPRINT = getattr(
+    settings, "EMAIL_EXTRAS_SIGNING_KEY_FINGERPRINT", None)
 
 if USE_GNUPG:
     try:
diff --git a/email_extras/utils.py b/email_extras/utils.py
index a24a07c..f007563 100644
--- a/email_extras/utils.py
+++ b/email_extras/utils.py
@@ -17,6 +17,7 @@
 # Used internally
 encrypt_kwargs = {
     'always_trust': ALWAYS_TRUST,
+    'sign': SIGNING_KEY_FINGERPRINT,
 }
 
 

From 9472724ceaf11c66f9c340289a4ea70021e3f680 Mon Sep 17 00:00:00 2001
From: Drew Hubl <drew.hubl@gmail.com>
Date: Mon, 27 Mar 2017 16:20:49 -0600
Subject: [PATCH 04/11] Add exception handlers for failed encryption only for
 the encrypting mail backend

---
 email_extras/backends.py |  42 ++++++++-------
 email_extras/handlers.py | 109 +++++++++++++++++++++++++++++++++++++++
 email_extras/settings.py |   7 ++-
 3 files changed, 139 insertions(+), 19 deletions(-)
 create mode 100644 email_extras/handlers.py

diff --git a/email_extras/backends.py b/email_extras/backends.py
index cbfc97d..22d3614 100644
--- a/email_extras/backends.py
+++ b/email_extras/backends.py
@@ -13,6 +13,9 @@
 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 (GNUPG_HOME, GNUPG_ENCODING, USE_GNUPG)
 from .utils import (EncryptionFailedError, encrypt_kwargs)
 
@@ -39,14 +42,6 @@ def open(self, body):
         webbrowser.open("file://" + temp.name)
 
 
-class AttachmentEncryptionFailedError(EncryptionFailedError):
-    pass
-
-
-class AlternativeEncryptionFailedError(EncryptionFailedError):
-    pass
-
-
 if USE_GNUPG:
     from gnupg import GPG
 
@@ -102,12 +97,22 @@ def encrypt_attachment(address, attachment, use_asc):
         try:
             encrypted_content = encrypt(content, address)
         except EncryptionFailedError as e:
-            # SECURITY: We could include a piece of the content here, but that
-            # would leak information in logs and to the admins. So instead, we
-            # only try to include the filename.
-            raise AttachmentEncryptionFailedError(
-                "Encrypting attachment to %s failed: %s (%s)", address,
-                filename, e.msg)
+            # 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"
@@ -145,7 +150,10 @@ def encrypt_messages(email_messages):
                     continue
 
                 # Replace the message body with the encrypted message body
-                new_msg.body = encrypt(new_msg.body, address)
+                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 = []
@@ -158,9 +166,7 @@ def encrypt_messages(email_messages):
                     try:
                         encrypted_alternative = encrypt(alt, address)
                     except EncryptionFailedError as e:
-                        raise AlternativeEncryptionFailedError(
-                            "Encrypting alternative to %s failed: %s (%s)",
-                            address, alt, e.msg)
+                        handle_failed_alternative_encryption(e)
                     else:
                         alternatives.append((encrypted_alternative,
                                              "application/gpg-encrypted"))
diff --git a/email_extras/handlers.py b/email_extras/handlers.py
new file mode 100644
index 0000000..eefd06e
--- /dev/null
+++ b/email_extras/handlers.py
@@ -0,0 +1,109 @@
+import inspect
+
+from django.conf import settings
+from django.core.mail import mail_admins
+
+from .models import Address
+from .settings import FAILURE_HANDLERS
+
+
+ADMIN_ADDRESSES = [admin[1] for admin in settings.ADMINS]
+
+
+def get_variable_from_exception(exception, variable_name):
+    """
+    Grab the variable from closest frame in the stack
+    """
+    for frame in reversed(inspect.trace()):
+        try:
+            # From http://stackoverflow.com/a/9059407/6461688
+            frame_variable = frame[0].f_locals[variable_name]
+        except KeyError:
+            pass
+        else:
+            return frame_variable
+    else:
+        raise KeyError("Variable '%s' not in any stack frames", variable_name)
+
+
+def default_handle_failed_encryption(exception):
+    """
+    Handle failures when trying to encrypt alternative content for messages
+    """
+    raise exception
+
+
+def default_handle_failed_alternative_encryption(exception):
+    """
+    Handle failures when trying to encrypt alternative content for messages
+    """
+    raise exception
+
+
+def default_handle_failed_attachment_encryption(exception):
+    """
+    Handle failures when trying to encrypt alternative content for messages
+    """
+    raise exception
+
+
+def force_mail_admins(unencrypted_message, address):
+    """
+    Mail admins when encryption fails, and send the message unencrypted if
+    the recipient is an admin
+    """
+
+    if address in ADMIN_ADDRESSES:
+        # We assume that it is more important to mail the admin *without*
+        # encrypting the message
+        force_send_message(unencrypted_message)
+    else:
+        mail_admins(
+            "Failed encryption attempt",
+            """
+            There was a problem encrypting an email message.
+
+            Subject: "{subject}"
+            Address: "{address}"
+            """)
+
+
+def force_delete_key(address):
+    """
+    Delete the key from the keyring and the Key and Address objects from the
+    database
+    """
+    address_object = Address.objects.get(address=address)
+    address_object.key.delete()
+    address_object.delete()
+
+
+def force_send_message(unencrypted_message):
+    """
+    Send the message unencrypted
+    """
+    unencrypted_message.do_not_encrypt_this_message = True
+    unencrypted_message.send()
+
+
+def import_function(key):
+    mod, _, function = FAILURE_HANDLERS[key].rpartition('.')
+    try:
+        # Python 3.4+
+        from importlib import import_module
+    except ImportError:
+        # Python < 3.4
+        # From http://stackoverflow.com/a/8255024/6461688
+        mod = __import__(mod, globals(), locals(), [function])
+    else:
+        mod = import_module(mod)
+    return getattr(mod, function)
+
+exception_handlers = {
+    'message': 'handle_failed_message_encryption',
+    'alternative': 'handle_failed_alternative_encryption',
+    'attachment': 'handle_failed_attachment_encryption',
+}
+
+for key, value in exception_handlers.items():
+    locals()[value] = import_function(key)
diff --git a/email_extras/settings.py b/email_extras/settings.py
index d458c03..ffc0f05 100644
--- a/email_extras/settings.py
+++ b/email_extras/settings.py
@@ -1,4 +1,3 @@
-
 from django.conf import settings
 from django.core.exceptions import ImproperlyConfigured
 
@@ -7,6 +6,12 @@
 USE_GNUPG = getattr(settings, "EMAIL_EXTRAS_USE_GNUPG", GNUPG_HOME is not None)
 
 ALWAYS_TRUST = getattr(settings, "EMAIL_EXTRAS_ALWAYS_TRUST_KEYS", False)
+FAILURE_HANDLERS = {
+    'message': 'email_extras.handlers.default_handle_failed_encryption',
+    'alternative': 'email_extras.handlers.default_handle_failed_alternative_encryption',
+    'attachment': 'email_extras.handlers.default_handle_failed_attachment_encryption',
+}
+FAILURE_HANDLERS.update(getattr(settings, "EMAIL_EXTRAS_FAILURE_HANDLERS", {}))
 GNUPG_ENCODING = getattr(settings, "EMAIL_EXTRAS_GNUPG_ENCODING", None)
 SIGNING_KEY_DATA = {
     'key_type': "RSA",

From e3d799c94396bd6a535472c8182015e351112c3c Mon Sep 17 00:00:00 2001
From: Drew Hubl <drew.hubl@gmail.com>
Date: Sat, 1 Apr 2017 21:21:53 -0600
Subject: [PATCH 05/11] Add a get_gpg function to utils and use it

---
 email_extras/apps.py                                  |  5 ++---
 email_extras/backends.py                              | 10 +++-------
 email_extras/forms.py                                 |  7 ++-----
 email_extras/management/commands/email_signing_key.py |  7 +++----
 email_extras/migrations/0003_auto_20161103_0315.py    |  5 ++---
 email_extras/models.py                                | 10 ++++------
 email_extras/utils.py                                 | 10 +++++++---
 7 files changed, 23 insertions(+), 31 deletions(-)

diff --git a/email_extras/apps.py b/email_extras/apps.py
index 9a8d18e..5390717 100644
--- a/email_extras/apps.py
+++ b/email_extras/apps.py
@@ -1,8 +1,7 @@
 from django.apps import AppConfig
 
-from gnupg import GPG
-
 from email_extras.settings import USE_GNUPG, SIGNING_KEY_FINGERPRINT
+from email_extras.utils import get_gpg
 
 
 class EmailExtrasConfig(AppConfig):
@@ -12,7 +11,7 @@ class EmailExtrasConfig(AppConfig):
     def ready(self):
         # Fail early and loudly if the signing key fingerprint is misconfigured
         if USE_GNUPG and SIGNING_KEY_FINGERPRINT is not None:
-            gpg = GPG()
+            gpg = get_gpg()
             try:
                 gpg.list_keys().key_map[SIGNING_KEY_FINGERPRINT]
             except KeyError:
diff --git a/email_extras/backends.py b/email_extras/backends.py
index 22d3614..9980c35 100644
--- a/email_extras/backends.py
+++ b/email_extras/backends.py
@@ -16,8 +16,8 @@
 from .handlers import (handle_failed_message_encryption,
                        handle_failed_alternative_encryption,
                        handle_failed_attachment_encryption)
-from .settings import (GNUPG_HOME, GNUPG_ENCODING, USE_GNUPG)
-from .utils import (EncryptionFailedError, encrypt_kwargs)
+from .settings import USE_GNUPG
+from .utils import (EncryptionFailedError, encrypt_kwargs, get_gpg)
 
 
 class BrowsableEmailBackend(BaseEmailBackend):
@@ -43,14 +43,10 @@ def open(self, body):
 
 
 if USE_GNUPG:
-    from gnupg import GPG
-
     from .models import Address
 
     # Create the GPG object
-    gpg = GPG(gnupghome=GNUPG_HOME)
-    if GNUPG_ENCODING is not None:
-        gpg.encoding = GNUPG_ENCODING
+    gpg = get_gpg()
 
     def copy_message(msg):
         return EmailMultiAlternatives(
diff --git a/email_extras/forms.py b/email_extras/forms.py
index 3be4453..ad7c46c 100644
--- a/email_extras/forms.py
+++ b/email_extras/forms.py
@@ -2,10 +2,7 @@
 from django import forms
 from django.utils.translation import ugettext_lazy as _
 
-from email_extras.settings import USE_GNUPG, GNUPG_HOME
-
-if USE_GNUPG:
-    from gnupg import GPG
+from email_extras.utils import get_gpg
 
 
 class KeyForm(forms.ModelForm):
@@ -15,7 +12,7 @@ def clean_key(self):
         Validate the key contains an email address.
         """
         key = self.cleaned_data["key"]
-        gpg = GPG(gnupghome=GNUPG_HOME)
+        gpg = get_gpg()
         result = gpg.import_keys(key)
         if result.count == 0:
             raise forms.ValidationError(_("Invalid Key"))
diff --git a/email_extras/management/commands/email_signing_key.py b/email_extras/management/commands/email_signing_key.py
index 323ed56..b457bf6 100644
--- a/email_extras/management/commands/email_signing_key.py
+++ b/email_extras/management/commands/email_signing_key.py
@@ -6,16 +6,15 @@
 import argparse
 import sys
 
-from gnupg import GPG
-
 from django.core.management.base import LabelCommand
 from django.utils.translation import ugettext as _
 
 from email_extras.models import Key
-from email_extras.settings import (GNUPG_HOME, SIGNING_KEY_DATA)
+from email_extras.settings import SIGNING_KEY_DATA
+from email_extras.utils import get_gpg
 
 
-gpg = GPG(gnupghome=GNUPG_HOME)
+gpg = get_gpg()
 
 
 # Create an action that *extends* a list, instead of *appending* to it
diff --git a/email_extras/migrations/0003_auto_20161103_0315.py b/email_extras/migrations/0003_auto_20161103_0315.py
index a8085dc..a6dbc12 100644
--- a/email_extras/migrations/0003_auto_20161103_0315.py
+++ b/email_extras/migrations/0003_auto_20161103_0315.py
@@ -3,9 +3,8 @@
 
 from django.db import migrations, models
 import django.db.models.deletion
-from gnupg import GPG
 
-from email_extras.settings import GNUPG_HOME
+from email_extras.utils import get_gpg
 
 
 def forward_change(apps, schema_editor):
@@ -16,7 +15,7 @@ def forward_change(apps, schema_editor):
         addresses = Address.objects.filter(address__in=key.addresses.split(','))
         addresses.update(key=key)
 
-        gpg = GPG(gnupghome=GNUPG_HOME)
+        gpg = get_gpg()
         result = gpg.import_keys(key.key)
         key.fingerprint = result.fingerprints[0]
         key.save()
diff --git a/email_extras/models.py b/email_extras/models.py
index c2ef146..913776a 100644
--- a/email_extras/models.py
+++ b/email_extras/models.py
@@ -4,13 +4,11 @@
 from django.utils.encoding import python_2_unicode_compatible
 from django.utils.translation import ugettext_lazy as _
 
-from email_extras.settings import USE_GNUPG, GNUPG_HOME
-from email_extras.utils import addresses_for_key
+from email_extras.settings import USE_GNUPG
+from email_extras.utils import addresses_for_key, get_gpg
 
 
 if USE_GNUPG:
-    from gnupg import GPG
-
     @python_2_unicode_compatible
     class Key(models.Model):
         """
@@ -36,7 +34,7 @@ def email_addresses(self):
             return ",".join(str(address) for address in self.address_set.all())
 
         def save(self, *args, **kwargs):
-            gpg = GPG(gnupghome=GNUPG_HOME)
+            gpg = get_gpg()
             result = gpg.import_keys(self.key)
 
             addresses = []
@@ -73,7 +71,7 @@ def delete(self):
             """
             Remove any keys for this address.
             """
-            gpg = GPG(gnupghome=GNUPG_HOME)
+            gpg = get_gpg()
             for key in gpg.list_keys():
                 if self.address in addresses_for_key(gpg, key):
                     gpg.delete_keys(key["fingerprint"], True)
diff --git a/email_extras/utils.py b/email_extras/utils.py
index f007563..0002b17 100644
--- a/email_extras/utils.py
+++ b/email_extras/utils.py
@@ -14,6 +14,12 @@
 if USE_GNUPG:
     from gnupg import GPG
 
+    def get_gpg():
+        gpg = GPG(gnupghome=GNUPG_HOME)
+        if GNUPG_ENCODING is not None:
+            gpg.encoding = GNUPG_ENCODING
+        return gpg
+
 # Used internally
 encrypt_kwargs = {
     'always_trust': ALWAYS_TRUST,
@@ -75,9 +81,7 @@ def send_mail(subject, body_text, addr_from, recipient_list,
                                             .values_list('address', 'use_asc'))
         # Create the gpg object.
         if key_addresses:
-            gpg = GPG(gnupghome=GNUPG_HOME)
-            if GNUPG_ENCODING is not None:
-                gpg.encoding = GNUPG_ENCODING
+            gpg = get_gpg()
 
     # Check if recipient has a gpg key installed
     def has_pgp_key(addr):

From d19d9bbc4493f2afb4e81a2a51fc5cd372b1ee57 Mon Sep 17 00:00:00 2001
From: Drew Hubl <drew.hubl@gmail.com>
Date: Sat, 1 Apr 2017 23:42:25 -0600
Subject: [PATCH 06/11] Move check_signing_key into utils

---
 email_extras/apps.py  | 19 ++++---------------
 email_extras/utils.py | 27 ++++++++++++++++++++++++++-
 2 files changed, 30 insertions(+), 16 deletions(-)

diff --git a/email_extras/apps.py b/email_extras/apps.py
index 5390717..d75963d 100644
--- a/email_extras/apps.py
+++ b/email_extras/apps.py
@@ -1,24 +1,13 @@
 from django.apps import AppConfig
 
-from email_extras.settings import USE_GNUPG, SIGNING_KEY_FINGERPRINT
-from email_extras.utils import get_gpg
+from email_extras.utils import check_signing_key
 
 
 class EmailExtrasConfig(AppConfig):
     name = 'email_extras'
     verbose_name = 'Email Extras'
 
-    def ready(self):
+    # AFAICT, this is impossible to test
+    def ready(self):  # pragma: noqa
         # Fail early and loudly if the signing key fingerprint is misconfigured
-        if USE_GNUPG and SIGNING_KEY_FINGERPRINT is not None:
-            gpg = get_gpg()
-            try:
-                gpg.list_keys().key_map[SIGNING_KEY_FINGERPRINT]
-            except KeyError:
-                raise Exception(
-                    "The key specified by the "
-                    "EMAIL_EXTRAS_SIGNING_KEY_FINGERPRINT setting"
-                    "({fp}) does not exist in the GPG keyring. Adjust the"
-                    "EMAIL_EXTRAS_GNUPG_HOME setting, correct the key "
-                    "fingerprint, or generate a new key by running "
-                    "python manage.py email_signing_key --generate to fix.")
+        check_signing_key()
diff --git a/email_extras/utils.py b/email_extras/utils.py
index 0002b17..5f1d0eb 100644
--- a/email_extras/utils.py
+++ b/email_extras/utils.py
@@ -1,4 +1,5 @@
 from __future__ import with_statement
+
 from os.path import basename
 from warnings import warn
 
@@ -7,8 +8,10 @@
 from django.utils import six
 from django.utils.encoding import smart_text
 
+from gnupg import GPG
+
 from email_extras.settings import (ALWAYS_TRUST, GNUPG_ENCODING, GNUPG_HOME,
-                                   USE_GNUPG)
+                                   USE_GNUPG, SIGNING_KEY_FINGERPRINT)
 
 
 if USE_GNUPG:
@@ -31,6 +34,28 @@ class EncryptionFailedError(Exception):
     pass
 
 
+class BadSigningKeyError(KeyError):
+    pass
+
+
+def check_signing_key():
+    if USE_GNUPG and SIGNING_KEY_FINGERPRINT is not None:
+        gpg = get_gpg()
+        try:
+            gpg.list_keys(True).key_map[SIGNING_KEY_FINGERPRINT]
+        except KeyError as e:
+            raise BadSigningKeyError(
+                "The key specified by the "
+                "EMAIL_EXTRAS_SIGNING_KEY_FINGERPRINT setting "
+                "({fp}) does not exist in the GPG keyring. Adjust the "
+                "EMAIL_EXTRAS_GNUPG_HOME setting (currently set to "
+                "{gnupg_home}, correct the key fingerprint, or generate a new "
+                "key by running python manage.py email_signing_key --generate "
+                "to fix.".format(
+                    fp=SIGNING_KEY_FINGERPRINT,
+                    gnupg_home=GNUPG_HOME))
+
+
 def addresses_for_key(gpg, key):
     """
     Takes a key and extracts the email addresses for it.

From e00795ad6257599597661ed50c9796c5d9bd6561 Mon Sep 17 00:00:00 2001
From: Drew Hubl <drew.hubl@gmail.com>
Date: Thu, 30 Mar 2017 03:37:36 -0600
Subject: [PATCH 07/11] A few fixups exposed by tests (see next commit)

---
 email_extras/backends.py                      | 11 ++---
 email_extras/forms.py                         |  5 +++
 email_extras/handlers.py                      | 15 ++-----
 .../management/commands/email_signing_key.py  | 39 +++++++++-------
 email_extras/utils.py                         | 45 +++++++++++++------
 5 files changed, 70 insertions(+), 45 deletions(-)

diff --git a/email_extras/backends.py b/email_extras/backends.py
index 9980c35..4fa6a42 100644
--- a/email_extras/backends.py
+++ b/email_extras/backends.py
@@ -12,6 +12,7 @@
 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 django.utils import six
 
 from .handlers import (handle_failed_message_encryption,
                        handle_failed_alternative_encryption,
@@ -57,24 +58,23 @@ def copy_message(msg):
             from_email=msg.from_email,
             subject=msg.subject,
             body=msg.body,
+            alternatives=getattr(msg, 'alternatives', []),
             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:
+        if not encryption_result.ok or (smart_text(encryption_result) == ""
+                                        and text != ""):
             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__"):
+        if isinstance(attachment, six.string_types):
             filename = basename(attachment)
             mimetype = None
 
@@ -137,6 +137,7 @@ def encrypt_messages(email_messages):
 
             # Make a new message object for each recipient with a key
             new_msg = copy_message(msg)
+            new_msg.to = list(key_addrs.keys())
 
             # Encrypt the message body and all attachments for all addresses
             # we have keys for
diff --git a/email_extras/forms.py b/email_extras/forms.py
index ad7c46c..952add5 100644
--- a/email_extras/forms.py
+++ b/email_extras/forms.py
@@ -2,10 +2,15 @@
 from django import forms
 from django.utils.translation import ugettext_lazy as _
 
+from email_extras.models import Key
+from email_extras.settings import USE_GNUPG, GNUPG_HOME
 from email_extras.utils import get_gpg
 
 
 class KeyForm(forms.ModelForm):
+    class Meta:
+        model = Key
+        fields = ('key', 'use_asc')
 
     def clean_key(self):
         """
diff --git a/email_extras/handlers.py b/email_extras/handlers.py
index eefd06e..36927ff 100644
--- a/email_extras/handlers.py
+++ b/email_extras/handlers.py
@@ -1,4 +1,5 @@
-import inspect
+from importlib import import_module
+from inspect import trace
 
 from django.conf import settings
 from django.core.mail import mail_admins
@@ -14,7 +15,7 @@ def get_variable_from_exception(exception, variable_name):
     """
     Grab the variable from closest frame in the stack
     """
-    for frame in reversed(inspect.trace()):
+    for frame in reversed(trace()):
         try:
             # From http://stackoverflow.com/a/9059407/6461688
             frame_variable = frame[0].f_locals[variable_name]
@@ -88,15 +89,7 @@ def force_send_message(unencrypted_message):
 
 def import_function(key):
     mod, _, function = FAILURE_HANDLERS[key].rpartition('.')
-    try:
-        # Python 3.4+
-        from importlib import import_module
-    except ImportError:
-        # Python < 3.4
-        # From http://stackoverflow.com/a/8255024/6461688
-        mod = __import__(mod, globals(), locals(), [function])
-    else:
-        mod = import_module(mod)
+    mod = import_module(mod)
     return getattr(mod, function)
 
 exception_handlers = {
diff --git a/email_extras/management/commands/email_signing_key.py b/email_extras/management/commands/email_signing_key.py
index b457bf6..bb84e0b 100644
--- a/email_extras/management/commands/email_signing_key.py
+++ b/email_extras/management/commands/email_signing_key.py
@@ -4,9 +4,8 @@
 from __future__ import print_function
 
 import argparse
-import sys
 
-from django.core.management.base import LabelCommand
+from django.core.management.base import LabelCommand, CommandError
 from django.utils.translation import ugettext as _
 
 from email_extras.models import Key
@@ -17,6 +16,11 @@
 gpg = get_gpg()
 
 
+# This is split out so we can mock it for tests
+def upload_keys(keyservers, fingerprint):
+    gpg.send_keys(keyservers, fingerprint)  # pragma: nocover
+
+
 # Create an action that *extends* a list, instead of *appending* to it
 class ExtendAction(argparse.Action):
     def __call__(self, parser, namespace, values, option_string=None):
@@ -39,6 +43,7 @@ def add_arguments(self, parser):
             '--generate',
             action='store_true',
             default=False,
+            dest='generate',
             help=_("Generate a new signing key"))
         parser.add_argument(
             '--print-private-key',
@@ -52,17 +57,19 @@ def add_arguments(self, parser):
             # to be interpreted as [server1, server2, server3, server4], so we
             # need to use the custom ExtendAction we defiend before
             action='extend',
-            nargs='+',
+            default=[],
             dest='keyservers',
             help=_("Upload (the most recently generated) public signing key "
-                   "to the specified keyservers"))
+                   "to the specified keyservers"),
+            nargs='+')
 
     def handle(self, *labels, **options):
+        output = ''
+
         # EITHER specify the key fingerprints OR generate a key
         if options.get('generate') and labels:
-            print("You cannot specify fingerprints and --generate when "
-                  "running this command")
-            sys.exit(-1)
+            raise CommandError("You cannot specify fingerprints and "
+                               "--generate when running this command")
 
         if options.get('generate'):
             signing_key_cmd = gpg.gen_key_input(**SIGNING_KEY_DATA)
@@ -75,19 +82,21 @@ def handle(self, *labels, **options):
                                           use_asc=False)
             labels = [self.key.fingerprint]
 
-        return super(Command, self).handle(*labels, **options)
+            output += "{fp}\n".format(fp=self.key.fingerprint)
+
+        output += super(Command, self).handle(*labels, **options)
+
+        return output
 
     def handle_label(self, label, **options):
         try:
             self.key = Key.objects.get(fingerprint=label)
         except Key.DoesNotExist:
-            print("Key matching fingerprint '%(fp)s' not found." % {
-                'fp': label,
-            })
-            sys.exit(-1)
+            raise CommandError("Key matching fingerprint '%(fp)s' not found." %
+                               {'fp': label})
 
         for ks in set(options.get('keyservers')):
-            gpg.send_keys(ks, self.key.fingerprint)
+            upload_keys(ks, self.key.fingerprint)
 
         output = ''
 
@@ -96,8 +105,8 @@ def handle_label(self, label, **options):
 
         # If we havne't been told to do anything else, print out the public
         # signing key
-        if not options.get('keyservers') and \
-           not options.get('print_private_key'):
+        if options.get('generate') or (not options.get('keyservers') and
+                                       not options.get('print_private_key')):
             output += gpg.export_keys([self.key.fingerprint])
 
         return output
diff --git a/email_extras/utils.py b/email_extras/utils.py
index 5f1d0eb..0fa7d28 100644
--- a/email_extras/utils.py
+++ b/email_extras/utils.py
@@ -1,6 +1,7 @@
 from __future__ import with_statement
 
 from os.path import basename
+from six import string_types
 from warnings import warn
 
 from django.template import loader, Context
@@ -13,6 +14,9 @@
 from email_extras.settings import (ALWAYS_TRUST, GNUPG_ENCODING, GNUPG_HOME,
                                    USE_GNUPG, SIGNING_KEY_FINGERPRINT)
 
+# Contexts are just vanilla Python dictionaries in Django 1.9+
+if VERSION >= (1, 9):
+    Context = dict  # noqa: F811
 
 if USE_GNUPG:
     from gnupg import GPG
@@ -60,13 +64,9 @@ def addresses_for_key(gpg, key):
     """
     Takes a key and extracts the email addresses for it.
     """
-    fingerprint = key["fingerprint"]
-    addresses = []
-    for key in gpg.list_keys():
-        if key["fingerprint"] == fingerprint:
-            addresses.extend([address.split("<")[-1].strip(">")
-                              for address in key["uids"] if address])
-    return addresses
+    return [address.split("<")[-1].strip(">")
+            for address in gpg.list_keys().key_map[key['fingerprint']]["uids"]
+            if address]
 
 
 def send_mail(subject, body_text, addr_from, recipient_list,
@@ -116,9 +116,10 @@ def has_pgp_key(addr):
     def encrypt_if_key(body, addr_list):
         if has_pgp_key(addr_list[0]):
             encrypted = gpg.encrypt(body, addr_list[0], **encrypt_kwargs)
-            if encrypted == "" and body != "":  # encryption failed
-                raise EncryptionFailedError("Encrypting mail to %s failed.",
-                                            addr_list[0])
+            if not encrypted.ok or str(encrypted) == "" and body != "":
+                # encryption failed
+                raise EncryptionFailedError("Encrypting mail to %s failed: %s",
+                                            addr_list[0], encrypted.stderr)
             return smart_text(encrypted)
         return body
 
@@ -127,7 +128,7 @@ def encrypt_if_key(body, addr_list):
     if attachments is not None:
         for attachment in attachments:
             # Attachments can be pairs of name/data, or filesystem paths.
-            if not hasattr(attachment, "__iter__"):
+            if isinstance(attachment, six.string_types):
                 with open(attachment, "rb") as f:
                     attachments_parts.append((basename(attachment), f.read()))
             else:
@@ -153,11 +154,27 @@ def encrypt_if_key(body, addr_list):
                 mimetype = "text/html"
             msg.attach_alternative(encrypt_if_key(html_message, addr_list),
                                    mimetype)
+
         for parts in attachments_parts:
             name = parts[0]
-            if key_addresses.get(addr_list[0]):
-                name += ".asc"
-            msg.attach(name, encrypt_if_key(parts[1], addr_list))
+
+            # Don't encrypt attachments twice
+            if len(parts) > 2 and parts[2] == "application/gpg-encrypted":
+                msg.attach(name, parts[1], parts[2])
+                continue
+
+            if has_pgp_key(addr_list[0]):
+                # Name might be none if content was simply directly attached
+                if key_addresses.get(addr_list[0]) and name is not None:
+                    name += ".asc"
+                mimetype = "application/gpg-encrypted"
+            else:
+                # If we aren't encrypting the message, then leave the mimetype
+                # alone
+                mimetype = parts[2] if len(parts) > 2 else None
+
+            msg.attach(name, encrypt_if_key(parts[1], addr_list), mimetype)
+
         msg.send(fail_silently=fail_silently)
 
 

From e8fd346f8bd44cb6979403e6cda57208e7c9d202 Mon Sep 17 00:00:00 2001
From: Drew Hubl <drew.hubl@gmail.com>
Date: Thu, 30 Mar 2017 03:40:01 -0600
Subject: [PATCH 08/11] Add tests for 100% coverage

---
 email_extras/settings.py                   |   2 +-
 email_extras/utils.py                      |   4 +-
 manage.py                                  |  22 +
 tests/__init__.py                          |   0
 tests/settings.py                          | 139 +++++
 tests/templates/email_extras/dr_suess.html |   4 +
 tests/templates/email_extras/dr_suess.txt  |   4 +
 tests/test_admin.py                        |  31 +
 tests/test_apps.py                         |  65 ++
 tests/test_backends.py                     | 226 +++++++
 tests/test_command.py                      | 155 +++++
 tests/test_forms.py                        |  41 ++
 tests/test_handlers.py                     | 141 +++++
 tests/test_models.py                       |  45 ++
 tests/test_send_mail.py                    |  91 +++
 tests/urls.py                              |  21 +
 tests/utils.py                             | 654 +++++++++++++++++++++
 tests/write_mail.sh                        |   5 +
 tests/wsgi.py                              |  16 +
 19 files changed, 1662 insertions(+), 4 deletions(-)
 create mode 100755 manage.py
 create mode 100644 tests/__init__.py
 create mode 100644 tests/settings.py
 create mode 100644 tests/templates/email_extras/dr_suess.html
 create mode 100644 tests/templates/email_extras/dr_suess.txt
 create mode 100644 tests/test_admin.py
 create mode 100644 tests/test_apps.py
 create mode 100644 tests/test_backends.py
 create mode 100644 tests/test_command.py
 create mode 100644 tests/test_forms.py
 create mode 100644 tests/test_handlers.py
 create mode 100644 tests/test_models.py
 create mode 100644 tests/test_send_mail.py
 create mode 100644 tests/urls.py
 create mode 100644 tests/utils.py
 create mode 100755 tests/write_mail.sh
 create mode 100644 tests/wsgi.py

diff --git a/email_extras/settings.py b/email_extras/settings.py
index ffc0f05..13b7866 100644
--- a/email_extras/settings.py
+++ b/email_extras/settings.py
@@ -28,5 +28,5 @@
 if USE_GNUPG:
     try:
         import gnupg  # noqa: F401
-    except ImportError:
+    except ImportError:  # pragma: no cover
         raise ImproperlyConfigured("Could not import gnupg")
diff --git a/email_extras/utils.py b/email_extras/utils.py
index 0fa7d28..12687ae 100644
--- a/email_extras/utils.py
+++ b/email_extras/utils.py
@@ -9,8 +9,6 @@
 from django.utils import six
 from django.utils.encoding import smart_text
 
-from gnupg import GPG
-
 from email_extras.settings import (ALWAYS_TRUST, GNUPG_ENCODING, GNUPG_HOME,
                                    USE_GNUPG, SIGNING_KEY_FINGERPRINT)
 
@@ -47,7 +45,7 @@ def check_signing_key():
         gpg = get_gpg()
         try:
             gpg.list_keys(True).key_map[SIGNING_KEY_FINGERPRINT]
-        except KeyError as e:
+        except KeyError:
             raise BadSigningKeyError(
                 "The key specified by the "
                 "EMAIL_EXTRAS_SIGNING_KEY_FINGERPRINT setting "
diff --git a/manage.py b/manage.py
new file mode 100755
index 0000000..38a919f
--- /dev/null
+++ b/manage.py
@@ -0,0 +1,22 @@
+#!/usr/bin/env python
+import os
+import sys
+
+if __name__ == "__main__":
+    os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings")
+    try:
+        from django.core.management import execute_from_command_line
+    except ImportError:
+        # The above import may fail for some other reason. Ensure that the
+        # issue is really that Django is missing to avoid masking other
+        # exceptions on Python 2.
+        try:
+            import django
+        except ImportError:
+            raise ImportError(
+                "Couldn't import Django. Are you sure it's installed and "
+                "available on your PYTHONPATH environment variable? Did you "
+                "forget to activate a virtual environment?"
+            )
+        raise
+    execute_from_command_line(sys.argv)
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/settings.py b/tests/settings.py
new file mode 100644
index 0000000..5e87732
--- /dev/null
+++ b/tests/settings.py
@@ -0,0 +1,139 @@
+"""
+Django settings for tests project.
+
+Generated by 'django-admin startproject' using Django 1.10.5.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/1.10/topics/settings/
+
+For the full list of settings and their values, see
+https://docs.djangoproject.com/en/1.10/ref/settings/
+"""
+
+import os
+
+SITE_NAME = 'django-email-extras Test Project'
+DEFAULT_FROM_EMAIL = 'noreply@example.com'
+ADMINS = [
+    ('Admin', 'admin@example.com'),
+]
+
+# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
+BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+
+
+# Quick-start development settings - unsuitable for production
+# See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/
+
+# SECURITY WARNING: keep the secret key used in production secret!
+SECRET_KEY = 'm$gjxnwu+5ep%v&ms+2g#w$z*j3gu#s4ewpcyp750)7u-767*0'
+
+# SECURITY WARNING: don't run with debug turned on in production!
+DEBUG = True
+
+ALLOWED_HOSTS = []
+
+
+# Application definition
+
+INSTALLED_APPS = [
+    'django.contrib.admin',
+    'django.contrib.auth',
+    'django.contrib.contenttypes',
+    'django.contrib.sessions',
+    'django.contrib.messages',
+    'django.contrib.staticfiles',
+
+    'email_extras',
+]
+
+MIDDLEWARE = [
+    'django.middleware.security.SecurityMiddleware',
+    'django.contrib.sessions.middleware.SessionMiddleware',
+    'django.middleware.common.CommonMiddleware',
+    'django.middleware.csrf.CsrfViewMiddleware',
+    'django.contrib.auth.middleware.AuthenticationMiddleware',
+    'django.contrib.messages.middleware.MessageMiddleware',
+    'django.middleware.clickjacking.XFrameOptionsMiddleware',
+]
+
+MIDDLEWARE_CLASSES = MIDDLEWARE
+
+ROOT_URLCONF = 'tests.urls'
+
+TEMPLATES = [
+    {
+        'BACKEND': 'django.template.backends.django.DjangoTemplates',
+        'DIRS': [
+            'tests/templates',
+        ],
+        'APP_DIRS': True,
+        'OPTIONS': {
+            'context_processors': [
+                'django.template.context_processors.debug',
+                'django.template.context_processors.request',
+                'django.contrib.auth.context_processors.auth',
+                'django.contrib.messages.context_processors.messages',
+            ],
+        },
+    },
+]
+
+WSGI_APPLICATION = 'tests.wsgi.application'
+
+
+# Database
+# https://docs.djangoproject.com/en/1.10/ref/settings/#databases
+
+DATABASES = {
+    'default': {
+        'ENGINE': 'django.db.backends.sqlite3',
+        'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
+    }
+}
+
+
+# Password validation
+# https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators
+
+AUTH_PASSWORD_VALIDATORS = [
+    {
+        'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
+    },
+    {
+        'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
+    },
+    {
+        'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
+    },
+    {
+        'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
+    },
+]
+
+
+# Internationalization
+# https://docs.djangoproject.com/en/1.10/topics/i18n/
+
+LANGUAGE_CODE = 'en-us'
+
+TIME_ZONE = 'UTC'
+
+USE_I18N = True
+
+USE_L10N = True
+
+USE_TZ = True
+
+
+# Static files (CSS, JavaScript, Images)
+# https://docs.djangoproject.com/en/1.10/howto/static-files/
+
+STATIC_URL = '/static/'
+
+EMAIL_EXTRAS_GNUPG_HOME = 'gpg_keyring'
+EMAIL_EXTRAS_ALWAYS_TRUST_KEYS = True
+EMAIL_EXTRAS_GNUPG_ENCODING = 'utf-8'
+
+os.environ['PATH'] += ':./tests'
+os.environ['BROWSER'] = 'write_mail.sh'
diff --git a/tests/templates/email_extras/dr_suess.html b/tests/templates/email_extras/dr_suess.html
new file mode 100644
index 0000000..df5053f
--- /dev/null
+++ b/tests/templates/email_extras/dr_suess.html
@@ -0,0 +1,4 @@
+<strong>One fish</strong>,
+<emph>two fish</emph>,
+<span style="color: red;">red fish</span>,
+<span style="color: blue;">{{ last_fish }}</span>.
diff --git a/tests/templates/email_extras/dr_suess.txt b/tests/templates/email_extras/dr_suess.txt
new file mode 100644
index 0000000..1db3921
--- /dev/null
+++ b/tests/templates/email_extras/dr_suess.txt
@@ -0,0 +1,4 @@
+One fish,
+two fish,
+red fish,
+{{ last_fish }}.
diff --git a/tests/test_admin.py b/tests/test_admin.py
new file mode 100644
index 0000000..8f7cc61
--- /dev/null
+++ b/tests/test_admin.py
@@ -0,0 +1,31 @@
+from django.contrib.auth import get_user_model
+from django.core.urlresolvers import resolve
+from django.test import TestCase
+from django.test.client import RequestFactory
+try:
+    from django.urls import reverse
+except ImportError:
+    from django.core.urlresolvers import reverse
+
+
+class AdminTestCase(TestCase):
+    def setUp(self):
+        self.user_password = 'pw'
+        self.user = get_user_model().objects.create_user(
+            'user', email='user@example.com', password=self.user_password)
+        self.user.is_staff = True
+        self.user.is_superuser = True
+        self.user.save()
+
+        self.factory = RequestFactory()
+
+    def tearDown(self):
+        self.user.delete()
+
+    def test_has_add_permission(self):
+        self.client.login(username=self.user, password=self.user_password)
+
+        url = reverse('admin:email_extras_address_changelist')
+        response = self.client.get(url)
+
+        self.assertFalse(response.context['has_add_permission'])
diff --git a/tests/test_apps.py b/tests/test_apps.py
new file mode 100644
index 0000000..9fa914b
--- /dev/null
+++ b/tests/test_apps.py
@@ -0,0 +1,65 @@
+from django.test import TestCase, override_settings
+
+from email_extras.utils import BadSigningKeyError, check_signing_key
+
+from tests.utils import (
+    GPGMixin, TEST_PRIVATE_KEY, TEST_KEY_FINGERPRINT,
+)
+
+
+MODIFIED_FINGERPRINT = "{}{}".format(
+    TEST_KEY_FINGERPRINT[:-1],
+    "0" if TEST_KEY_FINGERPRINT[-1] != "0" else "1")
+
+
+@override_settings(
+    EMAIL_EXTRAS_SIGNING_KEY_FINGERPRINT=TEST_KEY_FINGERPRINT)
+class NoBadSigningKeyErrorTestCase(GPGMixin, TestCase):
+    use_asc = False
+    maxDiff = 10000
+    send_mail_function = 'email_extras.utils.send_mail'
+
+    def test_no_exception(self):
+        from email_extras import utils
+        try:
+            self.gpg.import_keys(TEST_PRIVATE_KEY)
+            previous_value = utils.SIGNING_KEY_FINGERPRINT
+            utils.SIGNING_KEY_FINGERPRINT = TEST_KEY_FINGERPRINT
+            check_signing_key()
+        except (BadSigningKeyError, KeyError):
+            error_raised = True
+        else:
+            error_raised = False
+        finally:
+            self.assertFalse(error_raised, "BadSigningKeyError was raised")
+            utils.SIGNING_KEY_FINGERPRINT = previous_value
+            self.gpg.delete_keys([TEST_KEY_FINGERPRINT], True)
+
+
+@override_settings(
+    EMAIL_EXTRAS_SIGNING_KEY_FINGERPRINT=MODIFIED_FINGERPRINT)
+class BadSigningKeyErrorTestCase(GPGMixin, TestCase):
+    use_asc = False
+    maxDiff = 10000
+    send_mail_function = 'email_extras.utils.send_mail'
+
+    @classmethod
+    def setUpClass(cls):
+        super(BadSigningKeyErrorTestCase, cls).setUpClass()
+
+    @classmethod
+    def tearDownClass(cls):
+        super(BadSigningKeyErrorTestCase, cls).tearDownClass()
+
+    def test_exception(self):
+        from email_extras import utils
+        try:
+            previous_value = utils.SIGNING_KEY_FINGERPRINT
+            utils.SIGNING_KEY_FINGERPRINT = MODIFIED_FINGERPRINT
+            check_signing_key()
+        except BadSigningKeyError:
+            self.assertTrue(True, "BadSigningKeyError was raised")
+        else:
+            self.assertFalse(True, "No BadSigningKeyError was raised")
+        finally:
+            utils.SIGNING_KEY_FINGERPRINT = previous_value
diff --git a/tests/test_backends.py b/tests/test_backends.py
new file mode 100644
index 0000000..24b6662
--- /dev/null
+++ b/tests/test_backends.py
@@ -0,0 +1,226 @@
+import os
+
+from django.conf import settings
+from django.core import mail
+from django.test import TestCase, override_settings
+from django.utils.safestring import mark_safe
+
+from email_extras.utils import EncryptionFailedError
+
+from tests.utils import (SendMailFunctionMixin, SendMailMixin)
+
+
+@override_settings(
+    EMAIL_BACKEND='email_extras.backends.BrowsableEmailBackend',
+    DEBUG=True)
+class BrowsableEmailBackendTestCase(SendMailFunctionMixin, TestCase):
+    mail_file = 'tests/mail.txt'
+    send_mail_function = 'tests.utils.send_mail_with_backend'
+
+    def _remove_mail_file(self):
+        if os.path.exists(self.mail_file):
+            os.remove(self.mail_file)
+
+    def setUp(self):
+        self._remove_mail_file()
+
+    def tearDown(self):
+        self._remove_mail_file()
+
+    @override_settings(DEBUG=False)
+    def test_with_debug_false(self):
+        msg_subject = "Test Subject"
+        to = ['django-email-extras@example.com']
+        from_email = settings.DEFAULT_FROM_EMAIL
+        msg_text = "Test Body Text"
+        msg_html = "<html><body><b>Hello</b> World <i>Text</i>"
+
+        # Make sure the file doesn't exist yet
+        self.assertFalse(os.path.exists(self.mail_file))
+
+        self.send_mail(
+            msg_subject, msg_text, from_email, to,
+            html_message=mark_safe(msg_html))
+
+        # The backend should bail when DEBUG = False
+        self.assertFalse(os.path.exists(self.mail_file))
+
+    def test_with_txt_mail(self):
+        msg_subject = "Test Subject"
+        to = ['django-email-extras@example.com']
+        from_email = settings.DEFAULT_FROM_EMAIL
+        msg_text = "Test Body Text"
+
+        # Make sure the file doesn't exist yet
+        self.assertFalse(os.path.exists(self.mail_file))
+
+        self.send_mail(
+            msg_subject, msg_text, from_email, to)
+
+        # Since there isn't an HTML alternative, the backend shouldn't fire
+        self.assertFalse(os.path.exists(self.mail_file))
+
+    def test_with_non_html_alternative(self):
+        msg_subject = "Test Subject"
+        to = ['django-email-extras@example.com']
+        from_email = settings.DEFAULT_FROM_EMAIL
+        msg_text = "Test Body Text"
+        msg_html = "<html><body><b>Hello</b> World <i>Text</i>"
+
+        # Make sure the file doesn't exist yet
+        self.assertFalse(os.path.exists(self.mail_file))
+
+        self.send_mail(
+            msg_subject, msg_text, from_email, to,
+            alternatives=[(mark_safe(msg_html), 'application/gpg-encrypted')])
+
+        # The backend should skip any non-HTML alternative
+        self.assertFalse(os.path.exists(self.mail_file))
+
+    def test_with_html_mail(self):
+        msg_subject = "Test Subject"
+        to = ['django-email-extras@example.com']
+        from_email = settings.DEFAULT_FROM_EMAIL
+        msg_text = "Test Body Text"
+        msg_html = "<html><body><b>Hello</b> World <i>Text</i>"
+
+        # Make sure the file doesn't exist yet
+        self.assertFalse(os.path.exists(self.mail_file))
+
+        self.send_mail(
+            msg_subject, msg_text, from_email, to,
+            html_message=mark_safe(msg_html))
+
+        # Make sure the file exists
+        self.assertTrue(os.path.exists(self.mail_file))
+
+        # Make sure the contents are expected
+        with open(self.mail_file, 'r') as f:
+            self.assertEquals(f.read().strip(), msg_html)
+
+        # Try to remove it
+        self._remove_mail_file()
+
+        # Make sure the file doesn't exist
+        self.assertFalse(os.path.exists(self.mail_file))
+
+
+@override_settings(
+    EMAIL_BACKEND='email_extras.backends.EncryptingLocmemEmailBackend')
+class SendEncryptedMailBackendNoASCTestCase(SendMailMixin, TestCase):
+    use_asc = False
+    maxDiff = 10000
+    send_mail_function = 'tests.utils.send_mail_with_backend'
+
+    def test_send_mail_function_html_message_encrypted_alternative(self):
+        msg_subject = "Test Subject"
+        to = ['django-email-extras@example.com']
+        from_email = settings.DEFAULT_FROM_EMAIL
+        msg_text = "Test Body Text"
+        with open('tests/templates/email_extras/dr_suess.txt', 'r') as f:
+            alt = f.read()
+
+        self.send_mail(
+            msg_subject, msg_text, from_email, to,
+            alternatives=[(alt, 'application/gpg-encrypted')])
+
+        message = mail.outbox[0]
+
+        # Decrypt and test the alternatives later, just ensure we have
+        # any alternatives at all so we fail quickly
+        self.assertNotEquals(message.alternatives, [])
+        self.assertEquals(message.attachments, [])
+
+        # We should only have one alternative - the txt message
+        self.assertEquals(len(message.alternatives), 1)
+
+        # Check the alternative to make sure it wasn't encrypted
+        content, mimetype = message.alternatives[0]
+        self.assertEquals(mimetype, "application/gpg-encrypted")
+        self.assertEquals(content, alt)
+
+    def test_handle_failed_alternative_encryption(self):
+        msg_subject = "Test Subject"
+        to = ['django-email-extras@example.com']
+        from_email = settings.DEFAULT_FROM_EMAIL
+        msg_text = "Test Body Text"
+        msg_html = "<html><body><b>Hello</b> World <i>Text</i>"
+
+        # Make sending the mail fail
+        from email_extras import utils
+        previous_value = utils.encrypt_kwargs['always_trust']
+        utils.encrypt_kwargs['always_trust'] = False
+        # Tweak the failed content handler to simply pass
+        from email_extras import backends
+        previous_content_handler = backends.handle_failed_message_encryption
+        backends.handle_failed_message_encryption = lambda e: None
+        with self.assertRaises(EncryptionFailedError):
+            self.send_mail(
+                msg_subject, msg_text, from_email, to,
+                html_message=mark_safe(msg_html))
+        backends.handle_failed_message_encryption = previous_content_handler
+        utils.encrypt_kwargs['always_trust'] = previous_value
+
+    def test_handle_failed_attachment_encryption(self):
+        msg_subject = "Test Subject"
+        to = ['django-email-extras@example.com']
+        from_email = settings.DEFAULT_FROM_EMAIL
+        msg_text = "Test Body Text"
+        msg_html = "<html><body><b>Hello</b> World <i>Text</i>"
+
+        # Make sending the mail fail
+        from email_extras import utils
+        previous_value = utils.encrypt_kwargs['always_trust']
+        utils.encrypt_kwargs['always_trust'] = False
+        # Tweak the failed content handler to simply pass
+        from email_extras import backends
+        previous_content_handler = backends.handle_failed_message_encryption
+        alt_handler = backends.handle_failed_alternative_encryption
+        previous_alt_handler = alt_handler
+        backends.handle_failed_message_encryption = lambda e: None
+        backends.handle_failed_alternative_encryption = lambda e: None
+        with self.assertRaises(EncryptionFailedError):
+            self.send_mail(
+                msg_subject, msg_text, from_email, to,
+                attachments=[('file.txt', msg_html, 'text/html')])
+        backends.handle_failed_alternative_encryption = previous_alt_handler
+        backends.handle_failed_message_encryption = previous_content_handler
+        utils.encrypt_kwargs['always_trust'] = previous_value
+
+
+@override_settings(
+    EMAIL_BACKEND='email_extras.backends.EncryptingLocmemEmailBackend')
+class SendEncryptedMailBackendWithASCTestCase(SendMailMixin, TestCase):
+    use_asc = True
+    send_mail_function = 'tests.utils.send_mail_with_backend'
+
+
+@override_settings(
+    EMAIL_BACKEND='email_extras.backends.EncryptingLocmemEmailBackend')
+class SendDoNotEncryptMailBackendTestCase(SendMailMixin, TestCase):
+    use_asc = True
+    send_mail_function = 'tests.utils.send_mail_with_backend'
+
+    def test_send_mail_function_txt_message(self):
+        msg_subject = "Test Subject"
+        to = ['django-email-extras@example.com']
+        from_email = settings.DEFAULT_FROM_EMAIL
+        msg_text = "Test Body Text"
+
+        self.send_mail(msg_subject, msg_text, from_email, to,
+                       do_not_encrypt_this_message=True)
+
+        message = mail.outbox[0]
+
+        self.assertEquals(message.subject, msg_subject)
+        # We decrypt and test the message body below, these just ensure the
+        # message body is not cleartext
+        self.assertEquals(message.body, msg_text)
+        self.assertEquals(message.to, to)
+        self.assertEquals(message.cc, [])
+        self.assertEquals(message.bcc, [])
+        self.assertEquals(message.reply_to, [])
+        self.assertEquals(message.from_email, from_email)
+        self.assertEquals(message.extra_headers, {})
+        self.assertEquals(message.alternatives, [])
+        self.assertEquals(message.attachments, [])
diff --git a/tests/test_command.py b/tests/test_command.py
new file mode 100644
index 0000000..dcf1c7d
--- /dev/null
+++ b/tests/test_command.py
@@ -0,0 +1,155 @@
+import re
+import sys
+try:
+    from io import StringIO
+except ImportError:
+    from cStringIO import StringIO
+from unittest import skipIf
+
+from django.core.management import call_command, CommandError
+from django.test import TestCase
+
+from email_extras.models import Key
+
+from tests.utils import TEST_KEY_FINGERPRINT
+
+
+@skipIf(sys.version_info < (3,), "Test uses assertRaisesRegex")
+class TestEmailSigningKeyCommandTestCase(TestCase):
+    def _generate_signing_key(self):
+        out = StringIO()
+        err = StringIO()
+
+        self.assertEquals(Key.objects.count(), 0)
+
+        call_command('email_signing_key', '--generate',
+                     stdout=out, stderr=err)
+
+        key_data = out.getvalue().strip().split('\n')
+
+        # For Python 3 we can jsut do fp, header, *blocks, footer = key_data
+        fp, header = key_data[0:2]
+        blocks = key_data[2:-1]
+        footer = key_data[-1]
+
+        self.assertRegex(fp, r'^[0-9A-F]{40}$')
+        self.assertEquals(header, "-----BEGIN PGP PUBLIC KEY BLOCK-----")
+        self.assertEquals(footer, "-----END PGP PUBLIC KEY BLOCK-----")
+
+        self.assertEquals(err.getvalue(), '')
+
+        self.assertEquals(Key.objects.count(), 1)
+
+        key = Key.objects.get()
+
+        # For Python 3.5+ we can just do key_data = [header, *blocks, footer]
+        key_data = [header]
+        key_data.extend(blocks)
+        key_data.append(footer)
+
+        self.assertEquals(key.key.strip(), '\n'.join(key_data))
+
+        self.fp = fp
+
+    def _delete(self, key):
+        for address in key.address_set.all():
+            address.delete()
+
+        key.delete()
+
+        self.assertEquals(Key.objects.count(), 0)
+
+    def test_generated_signing_key(self):
+        self._generate_signing_key()
+
+        self._delete(Key.objects.get())
+
+    def test_print_private_key(self):
+        self._generate_signing_key()
+
+        print_out = StringIO()
+        print_err = StringIO()
+
+        call_command('email_signing_key', self.fp, '--print-private-key',
+                     stdout=print_out, stderr=print_err)
+
+        print_private_key_data = print_out.getvalue().strip().split('\n')
+        # In Python 3 we can just do:
+        # header, version, *_, footer = print_private_key_data
+        header, version = print_private_key_data[0:2]
+        footer = print_private_key_data[-1]
+
+        self.assertRegex(version, r'^Version: .*$')
+        self.assertEquals(header, "-----BEGIN PGP PRIVATE KEY BLOCK-----")
+        self.assertEquals(footer, "-----END PGP PRIVATE KEY BLOCK-----")
+
+        self.assertEquals(print_err.getvalue(), '')
+
+        self.assertEquals(Key.objects.count(), 1)
+
+        self._delete(Key.objects.get())
+
+    def test_upload_to_keyservers(self):
+        self._generate_signing_key()
+
+        data = {
+            'keyservers': [],
+            'fingerprint': '',
+        }
+
+        def fake_upload_keys(keyservers, fingerprint):
+            data['keyservers'] = keyservers
+            data['fingerprint'] = fingerprint
+
+        upload_out = StringIO()
+        upload_err = StringIO()
+
+        from email_extras.management.commands import email_signing_key
+        previous_value = email_signing_key.upload_keys
+        email_signing_key.upload_keys = fake_upload_keys
+
+        call_command('email_signing_key', self.fp, '--keyserver', 'localhost',
+                     stdout=upload_out, stderr=upload_err)
+
+        self.assertEquals(data['keyservers'], 'localhost')
+        self.assertEquals(data['fingerprint'], self.fp)
+
+        self.assertEquals(upload_out.getvalue(), '')
+        self.assertEquals(upload_err.getvalue(), '')
+
+        email_signing_key.upload_keys = previous_value
+
+        self._delete(Key.objects.get())
+
+    def test_fingerprint_and_generate_flag_raises_error(self):
+        out = StringIO()
+        err = StringIO()
+
+        rgx = re.compile(r'^You cannot specify fingerprints and --generate '
+                         r'when running this command$')
+
+        self.assertEquals(Key.objects.count(), 0)
+
+        with self.assertRaisesRegex(CommandError, rgx):
+            call_command('email_signing_key', TEST_KEY_FINGERPRINT,
+                         generate=True, stdout=out, stderr=err)
+
+        self.assertEquals(out.getvalue(), '')
+        self.assertEquals(err.getvalue(), '')
+
+    def test_no_matching_fingerprint_raises_error(self):
+        out = StringIO()
+        err = StringIO()
+
+        missing_fingerprint = '01234567890ABCDEF01234567890ABCDEF01234567'
+        rgx = re.compile(r'''^Key matching fingerprint '{fp}' not '''
+                         r'''found.$'''.format(fp=missing_fingerprint))
+
+        self.assertEquals(Key.objects.count(), 0)
+
+        with self.assertRaisesRegex(CommandError, rgx):
+            call_command('email_signing_key', missing_fingerprint,
+                         stdout=out, stderr=err)
+
+        self.assertEquals(out.getvalue(), '')
+        self.assertEquals(err.getvalue(), '')
diff --git a/tests/test_forms.py b/tests/test_forms.py
new file mode 100644
index 0000000..974615f
--- /dev/null
+++ b/tests/test_forms.py
@@ -0,0 +1,41 @@
+from django.forms import forms
+from django.test import TestCase
+
+from email_extras.forms import KeyForm
+
+from tests.utils import (
+    TEST_PUBLIC_KEY, TEST_KEY_FINGERPRINT, GPGMixin
+)
+
+
+class KeyFormTestCase(GPGMixin, TestCase):
+    maxDiff = 10000
+
+    def setUp(self):
+        if TEST_KEY_FINGERPRINT in self.gpg.list_keys().key_map:
+            self.gpg.delete_keys([TEST_KEY_FINGERPRINT])
+
+    def tearDown(self):
+        if TEST_KEY_FINGERPRINT in self.gpg.list_keys().key_map:
+            self.gpg.delete_keys([TEST_KEY_FINGERPRINT])
+
+    def test_valid_key_data(self):
+        form = KeyForm(data={
+            'key': TEST_PUBLIC_KEY,
+            'use_asc': False,
+        })
+        self.assertTrue(form.is_valid())
+        self.assertEquals(form.cleaned_data['key'].strip(),
+                          TEST_PUBLIC_KEY.strip())
+        self.assertEquals(form.cleaned_data['use_asc'], False)
+
+    def test_invalid_key_data(self):
+        form = KeyForm(data={
+            'key': "The cat in the hat didn't come back after that",
+            'use_asc': False,
+        })
+        self.assertFalse(form.is_valid())
+
+        form.cleaned_data = form.data
+        with self.assertRaises(forms.ValidationError):
+            form.clean_key()
diff --git a/tests/test_handlers.py b/tests/test_handlers.py
new file mode 100644
index 0000000..0960118
--- /dev/null
+++ b/tests/test_handlers.py
@@ -0,0 +1,141 @@
+from django.conf import settings
+from django.core.mail import EmailMultiAlternatives
+from django.test import TestCase
+
+from email_extras.handlers import (
+    force_delete_key, force_mail_admins, force_send_message,
+    get_variable_from_exception,
+)
+
+from tests.utils import KeyMixin
+
+
+class GetVariableFromExceptionTestCase(TestCase):
+    def test_get_variable_from_parent(self):
+        def child():
+            child_var = 2
+            raise Exception()
+
+        def parent():
+            parent_var = 1
+
+            child()
+
+        try:
+            parent()
+        except Exception as e:
+            self.assertEquals(get_variable_from_exception(e, 'parent_var'), 1)
+        else:
+            self.assertTrue(False, "handler() didn't raise an exception")
+
+    def test_get_variable_from_child(self):
+        def child():
+            child_var = 2
+            raise Exception()
+
+        def parent():
+            parent_var = 1
+
+            child()
+
+        try:
+            parent()
+        except Exception as e:
+            self.assertEquals(get_variable_from_exception(e, 'child_var'), 2)
+        else:
+            self.assertTrue(False, "handler() didn't raise an exception")
+
+    def test_raise_key_error(self):
+        def child():
+            child_var = 2
+            raise Exception()
+
+        def parent():
+            parent_var = 1
+
+            child()
+
+        with self.assertRaises(KeyError):
+            try:
+                parent()
+            except Exception as e:
+                get_variable_from_exception(e, 'grandchild_var')
+            else:
+                self.assertTrue(False, "handler() didn't raise an exception")
+
+
+class ForceDeleteKeyTestCase(KeyMixin, TestCase):
+    use_asc = False
+
+    def test_key_deletion(self):
+        self.assertGreater(len(self.gpg.list_keys()), 0)
+
+        force_delete_key(self.address)
+
+        self.assertEquals(len(self.gpg.list_keys()), 0)
+
+
+class ForceMailAdminsTestCase(TestCase):
+    def test_force_mail_admins_from_trying_to_mail_admin(self):
+        sent = {'sent': False}
+
+        message = EmailMultiAlternatives(
+            "Subject", "Body", "sender@example.com",
+            [admin[1] for admin in settings.ADMINS])
+
+        def fake_send():
+            # It's eaiser to use nonlocal here, but we support Python 2.7
+            # nonlocal sent
+            sent['sent'] = True
+
+        setattr(message, 'send', fake_send)
+
+        self.assertFalse(sent['sent'])
+
+        force_mail_admins(message, settings.ADMINS[0][1])
+
+        self.assertTrue(sent['sent'])
+
+    def test_force_mail_admins_from_trying_to_mail_nonadmins(self):
+        sent = {'sent': False}
+
+        message = EmailMultiAlternatives(
+            "Subject", "Body", "sender@example.com", ["recipient@example.com"])
+
+        def fake_mail_admins(subject, body):
+            # It's eaiser to use nonlocal here, but we support Python 2.7
+            # nonlocal sent
+            sent['sent'] = True
+
+        from email_extras import handlers
+        previous_mail_admins = handlers.mail_admins
+        handlers.mail_admins = fake_mail_admins
+
+        self.assertFalse(sent['sent'])
+
+        force_mail_admins(message, "recipient@example.com")
+
+        self.assertTrue(sent['sent'])
+
+        handlers.mail_admins = previous_mail_admins
+
+
+class ForceSendMessageTestCase(TestCase):
+    def test_sent_message(self):
+        sent = {'sent': False}
+
+        message = EmailMultiAlternatives(
+            "Subject", "Body", "sender@example.com", ["recipient@example.com"])
+
+        def fake_send():
+            # It's eaiser to use nonlocal here, but we support Python 2.7
+            # nonlocal sent
+            sent['sent'] = True
+
+        setattr(message, 'send', fake_send)
+
+        self.assertFalse(sent['sent'])
+
+        force_send_message(message)
+
+        self.assertTrue(sent['sent'])
diff --git a/tests/test_models.py b/tests/test_models.py
new file mode 100644
index 0000000..25ac7e6
--- /dev/null
+++ b/tests/test_models.py
@@ -0,0 +1,45 @@
+from django.test import TestCase
+
+from email_extras.models import Address, Key
+
+from tests.utils import (
+    TEST_KEY_FINGERPRINT, TEST_PUBLIC_KEY, GPGMixin,
+)
+
+
+class ModelFunctionTestCase(GPGMixin, TestCase):
+    # This isn't too complex yet, but there are a few things left to do:
+    #
+    # * Implement queryset functions (create, update, delete)
+    # * Implement tests for queryset functions
+    # * Refactor functionality in the models' .save() function into signal
+    #   handlers and connect them up in email_extras/apps.py
+    #
+    # Once we implement that this will get "filled in" a bit more
+    #
+    def test_key_model_functions(self):
+        key = Key(key=TEST_PUBLIC_KEY, use_asc=False)
+        key.save()
+
+        # Test Key.__str__()
+        self.assertEquals(str(key), TEST_KEY_FINGERPRINT)
+
+        # Test Key.email_addresses property
+        self.assertEquals(key.email_addresses,
+                          'django-email-extras@example.com')
+
+        address = Address.objects.get(key=key)
+
+        # Test Address.__str__()
+        self.assertEquals(str(address), 'django-email-extras@example.com')
+
+        self.assertEquals(address.address, 'django-email-extras@example.com')
+
+        fp = key.fingerprint
+        self.assertEquals(fp, TEST_KEY_FINGERPRINT)
+
+        address.delete()
+        key.delete()
+
+        self.assertEquals(Address.objects.count(), 0)
+        self.assertEquals(Key.objects.count(), 0)
diff --git a/tests/test_send_mail.py b/tests/test_send_mail.py
new file mode 100644
index 0000000..47ad79a
--- /dev/null
+++ b/tests/test_send_mail.py
@@ -0,0 +1,91 @@
+from django.conf import settings
+from django.core import mail
+from django.template import loader, Context
+from django.test import TestCase, override_settings
+
+
+from email_extras.utils import send_mail_template
+
+from tests.utils import SendMailMixin
+
+
+@override_settings(
+    EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend')
+class SendMailFunctionNoASCTestCase(SendMailMixin, TestCase):
+    use_asc = False
+    maxDiff = 10000
+    send_mail_function = 'email_extras.utils.send_mail'
+
+
+@override_settings(
+    EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend')
+class SendMailFunctionWithASCTestCase(SendMailMixin, TestCase):
+    use_asc = True
+    maxDiff = 10000
+    send_mail_function = 'email_extras.utils.send_mail'
+
+    def test_send_mail_function_single_recipient(self):
+        msg_subject = "Test Subject"
+        to = 'django-email-extras@example.com'
+        from_email = settings.DEFAULT_FROM_EMAIL
+        msg_text = "Test Body Text"
+
+        self.send_mail(msg_subject, msg_text, from_email, to)
+
+        message = mail.outbox[0]
+
+        self.assertEquals(message.to, [to])
+
+
+@override_settings(
+    EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend')
+class SendMailTemplateTestCase(TestCase):
+    # We don't need to test our send_mail function here
+    send_mail_function = 'django.core.mail.send_mail'
+
+    def test_with_context(self):
+        subject = "Dr. Suess Says"
+        template = "dr_suess"
+        from_email = settings.DEFAULT_FROM_EMAIL
+        to = ['unencrypted@example.com']
+        context = {
+            'last_fish': 'blue fish',
+        }
+
+        send_mail_template(subject, template, from_email, to, context=context)
+
+        message = mail.outbox[0]
+
+        self.assertEquals(message.subject, subject)
+        self.assertEquals(
+            message.body,
+            loader.get_template("email_extras/%s.%s" % (template, 'txt'))
+            .render(Context(context)))
+
+        self.assertEquals(message.alternatives[0][1], 'text/html')
+        self.assertEquals(
+            message.alternatives[0][0],
+            loader.get_template("email_extras/%s.%s" % (template, 'html'))
+            .render(Context(context)))
+
+    def test_without_context(self):
+        subject = "Dr. Suess Says"
+        template = "dr_suess"
+        from_email = settings.DEFAULT_FROM_EMAIL
+        to = ['unencrypted@example.com']
+
+        send_mail_template(subject, template, from_email, to)
+
+        message = mail.outbox[0]
+
+        self.assertEquals(message.subject, subject)
+        self.assertEquals(
+            message.body,
+            loader.get_template("email_extras/%s.%s" % (template, 'txt'))
+            .render(Context({})))
+
+        self.assertEquals(message.alternatives[0][1], 'text/html')
+        self.assertEquals(
+            message.alternatives[0][0],
+            loader.get_template("email_extras/%s.%s" % (template, 'html'))
+            .render(Context({})))
diff --git a/tests/urls.py b/tests/urls.py
new file mode 100644
index 0000000..e434eb9
--- /dev/null
+++ b/tests/urls.py
@@ -0,0 +1,21 @@
+"""test_project URL Configuration
+
+The `urlpatterns` list routes URLs to views. For more information please see:
+    https://docs.djangoproject.com/en/1.10/topics/http/urls/
+Examples:
+Function views
+    1. Add an import:  from my_app import views
+    2. Add a URL to urlpatterns:  url(r'^$', views.home, name='home')
+Class-based views
+    1. Add an import:  from other_app.views import Home
+    2. Add a URL to urlpatterns:  url(r'^$', Home.as_view(), name='home')
+Including another URLconf
+    1. Import the include() function: from django.conf.urls import url, include
+    2. Add a URL to urlpatterns:  url(r'^blog/', include('blog.urls'))
+"""
+from django.conf.urls import url
+from django.contrib import admin
+
+urlpatterns = [
+    url(r'^admin/', admin.site.urls),
+]
diff --git a/tests/utils.py b/tests/utils.py
new file mode 100644
index 0000000..53f58ab
--- /dev/null
+++ b/tests/utils.py
@@ -0,0 +1,654 @@
+from __future__ import print_function
+
+from django.conf import settings
+from django.core import mail
+from django.utils.safestring import mark_safe
+
+from email_extras.models import Key
+
+from email_extras.utils import get_gpg, EncryptionFailedError
+
+# Generated with:
+#
+# key_data = {
+#     'key_type': "RSA",
+#     'key_length': 4096,
+#     'name_real': 'django-email-extras test project',
+#     # 'name_comment': "Test address and key for django-email-extras",
+#     'name_email': 'django-email-extras@example.com',
+#     'expire_date': 0,
+# }
+
+# key = gpg.gen_key(gpg.gen_key_input(**key_data))
+# public_fp = key.fingerprint
+# private_key = gpg.export_keys(key.fingerprint, True, armor=True)
+# public_key = gpg.export_keys(key.fingerprint, armor=True)
+# gpg.delete_keys([private_fp], True)
+# gpg.delete_keys([public_fp])
+# print('TEST_KEY_FINGERPRINT = "{}"'.format(public_fp))
+# print('TEST_PRIVATE_KEY = """\n{}"""'.format(private_key))
+# print('TEST_PUBLIC_KEY = """\n{}"""'.format(public_key))
+#
+TEST_KEY_FINGERPRINT = "5C5C560DA52021E167B5D713C9EA85FD5D576B8D"
+TEST_PRIVATE_KEY = """
+-----BEGIN PGP PRIVATE KEY BLOCK-----
+Version: GnuPG/MacGPG2 v2
+
+lQcXBFjrQhQBEADknfZRSqDxWY7o/yiiiXX1peUhKmMxdgHmIPdT4VL7P//DRRmK
+OBuUan22dVduA9h1tdOpEviejJmw63rJLPmFaR3knhHcPkhhlx2AoHaSNzZNZk9M
+r0c23BRILckeKGenrzhxzdWi1Yp+XWzsEzUuf0X3X8zLJ6Kf3P79d3uEA7hxupqs
+Q18NzfZx3cyewL3dL00Z54LFMf+QLyj/Rn2YKjj2XgBJ6e4cOgv3CJJCDueLDjSO
+XL4Q/yoXGahEeQlnWL0n0dya2G3zOgYfIQDDSkhSCDF5n9yiJldEVMrdckIoWtkp
+JeTJBC2lgty0vl2XuWPo8gyGJ0KDYCx/hwjoR44BsQbAx//3EmU4DWp2TqvuuFWB
+WVnFghXFRMp/SMBflQPXcLb5Qy1ea9xo64MGBsw+ajOFjf58aViUAd1YZm4ejIic
+MRYOpkSYUWR3kuZL2VZvVKC1bC3M0fkTV9lciwir4n7CivfCqxRr19tx+2Pfep13
+1gf6jtrNTRlJyvpaOPGXqJGJJS2BLuWZK+G6tHB3OnOQBGlXCraJQoTjK8V7I9ng
+LQLAOdssefV40ijznD2w7YOTI7CwSsqGamwX5diJ5bePU2Fh/paXkVSYQAScje1/
+JAbuHGp8Cewnk81VdXqav3XzHbGWuqx073881P2WYYoio2bJwMYpk2mjxQARAQAB
+AA/4iPWyxd6Av/QjB2HVdjT0wuioZLYZttS/1wSknqRWCbferEfj/IxcPbh9ZHPF
+sU2cebUnqZGaYFC49ZFyfc7ivmfdIt1toozgvuuvhUXxwusdlIU2Yxhad/Icmtz1
+QfFIJ/FRCg/oOYuBUzLYWvwVxpqsFBfykAbh5koyVqcCPpRiazdGC/5yptsJwNg5
+X9D2Mh096NgE7rfar9MqkcQpJH/iwmBsITEdfZhZ6AKpelANIrBGVJzlp1olTAbX
+NrbDwMhPNINJjIulDsUA9ulwx0n053RWc8JrUcod5B6j8qAi8SxIubvgS535dmor
+NS3nn9MkED9YY47MQZMPe9+DjwsLSYxHWqPLLFGqOFWaOE5n89MY1huoYxZa0Qaq
+IUHJkc83/2iI8Hc002h0gFPSf5HTXdWjXVAHUEaYlSQ9fL8OVwEL97RNpxlaDRVN
+iL6HDUf+d1VGSs1Ki5MuYSyFwzuAws8XNk1B6TLcdS9J7bjM8iSFpIHRXi55DHEP
+SZLotg8NTH0CfJJ1SO80rp3E+e04m2lfdhA4Eyt+EykKKjgKXGjSpImXmJ1c4zFi
+JWRFO1Jl5Fys4MWC9IT+3nvNdM3kemOUPy2GDsL9n46kQ89RdzSQKvwbMqCBm0uu
+TI2KYBXClifqEIIJedeiagufkY44d5dxFy3wwP5GZace8QgA8b1JJqfrF1/ovAqT
+7SIN/NDCz/EZ7BElYj/gMVRVy9xLLkJB3x+VXcYryI7EP/rMyB8XnRZpH7Mu9WAJ
+Npb0Ldn2TWF9/5KWrUFnbuWx4gO+1E4JiZy/7lCbiv+Moax0osDZjbob2UZFyWUM
+ePRdNpSOLKPScfvYL1iI+iJwAO+q8p5MWZ+QPVJf8dojeK6qQ2QX931gKE1s9dZr
+RyTa1QxhV30YkRMAzEoIB5cfprRV/52UGNJAkOPg8GKLvWoOLcZtYIj491WqB1en
+42NM4M2rl8fTiGinhwg25hMaQGCQa1ez6XRXrSG+zqDoYlOxakZRlWwGoGASSSOP
+cy5HGQgA8hqBIuGHps7+oC47FCvifag37jUeleknwVgyjyAa0YvYibe5WxC3oitX
+5IucWmzcgaCEO1QyRK/bKTrvRd4hJEDjGNbSKNObPpxXDRS/tucCLtpqXaudrNob
+9897jBum8HYgCljJF57+fCICGNRrYfHBytRWDhgG/8upV5/H++a15NmRoDifE8rm
+KUCfyF4ynHOx8Zf6YoNJE99uLrTlsD0rExumPFggyWLKYjnWyoDDA8nDiiJCv8ZP
+qbhJcPbJqngEkf67mVrV0DePrQ4igHW+gyWMUFMilafDm1P4SxGHSsleYTdgiQqA
+RI0Yk1bPKoZ9eWvskjEriCrvtaazjQgAkGT/z8CQ2TZ7LADqhFybPMUT3f2uzy3D
+Ifwsuczow4r6YIPSSPRPS+x2kDL+i6XllB3Haye+49e0pDGjIwbgUkoaBpWuB5cs
+Xvqu4CHpD+DGYXrpAgo0EZ4+457n71pt2+bKErlM4osVk7fMsJwcXer/Q5wW5r29
+GjaFRBqZppIjX8fli2frWUb56r38oBfTYHfPAyhcJ+b8gDqLKYPWEUoOopiCoP45
+/XBJzSDG0jiFDKg8NeGoiMCgM0WR55z3lAjZhXuhVeMCRFeqxoPwZd1j8mQofQyq
+u89qnI6dEVx9prG/bVwDLybCiOwyPefTbalGFdpGYeRjHyCUYVQ4ZYFhtEJkamFu
+Z28tZW1haWwtZXh0cmFzIHRlc3QgcHJvamVjdCA8ZGphbmdvLWVtYWlsLWV4dHJh
+c0BleGFtcGxlLmNvbT6JAjkEEwEIACMFAljrQhQCGy8HCwkIBwMCAQYVCAIJCgsE
+FgIDAQIeAQIXgAAKCRDJ6oX9XVdrjTtPD/90ygOHzgqOEYowq9XpUcye3VqL/jk0
+zichZt98qtc7x0FejPTnnzDcdEpNFH881L0lg1QxcCqjiyLqxQRfQaUFSBlwn73D
+rTxz5Ky6hyrhbpBUMt5Fd0T3M+nbBJkop0XXFTVVXwhrfd8rKKhER9vHxy2mIRYy
+CegRCGcyieazveqS7vw4SHy+fEOzbrp8PCLOJoT1HJc5qH4SdXraYdyJn7QfWs2s
+iaVMWpwNebxqtkgofdSsxWNqpfrfj1FTs506kAgI+q9x9s/jT5mXdiZFd24deiiF
+DMG2JUGVPTJdiIXQ+sbYNDwyf+EU8XNelHIcXEq84YYPDZ4D/yjwnpi57cTcmdXh
+ZrMUEKKjdvuogLZ37U7AX6yyD5K8i4MJ+VHWGNKdg+cNzJaSmfFXjuDc/gSCTy/D
+R97s5p1BrVr4ypDra3ZstbjTh8QPDD5EfecJ0GJchRrCIVyGP8UTX7IqU+3ycDLF
+I7X9+JHpkPN+AEyknaQ4TMmzFzF97VbVUC69j34sj08Sff6dov6xtnLCDiQC+u2f
+jOPjp4I4hQqip2+/3pUCFKNCJbO4jZnSmUln2xAQTqsdDhHjZqA8AdRJ1e64hjzk
+VKDzIsVh0eDF64dGJDAK+J2hpC2xZ5f9PBEqjaxGNnV3TesB3PwunjPSH0DZwqtp
+ayBef79l2Ir9GA==
+=B5JT
+-----END PGP PRIVATE KEY BLOCK-----
+"""
+TEST_PUBLIC_KEY = """
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+Version: GnuPG/MacGPG2 v2
+
+mQINBFjrQhQBEADknfZRSqDxWY7o/yiiiXX1peUhKmMxdgHmIPdT4VL7P//DRRmK
+OBuUan22dVduA9h1tdOpEviejJmw63rJLPmFaR3knhHcPkhhlx2AoHaSNzZNZk9M
+r0c23BRILckeKGenrzhxzdWi1Yp+XWzsEzUuf0X3X8zLJ6Kf3P79d3uEA7hxupqs
+Q18NzfZx3cyewL3dL00Z54LFMf+QLyj/Rn2YKjj2XgBJ6e4cOgv3CJJCDueLDjSO
+XL4Q/yoXGahEeQlnWL0n0dya2G3zOgYfIQDDSkhSCDF5n9yiJldEVMrdckIoWtkp
+JeTJBC2lgty0vl2XuWPo8gyGJ0KDYCx/hwjoR44BsQbAx//3EmU4DWp2TqvuuFWB
+WVnFghXFRMp/SMBflQPXcLb5Qy1ea9xo64MGBsw+ajOFjf58aViUAd1YZm4ejIic
+MRYOpkSYUWR3kuZL2VZvVKC1bC3M0fkTV9lciwir4n7CivfCqxRr19tx+2Pfep13
+1gf6jtrNTRlJyvpaOPGXqJGJJS2BLuWZK+G6tHB3OnOQBGlXCraJQoTjK8V7I9ng
+LQLAOdssefV40ijznD2w7YOTI7CwSsqGamwX5diJ5bePU2Fh/paXkVSYQAScje1/
+JAbuHGp8Cewnk81VdXqav3XzHbGWuqx073881P2WYYoio2bJwMYpk2mjxQARAQAB
+tEJkamFuZ28tZW1haWwtZXh0cmFzIHRlc3QgcHJvamVjdCA8ZGphbmdvLWVtYWls
+LWV4dHJhc0BleGFtcGxlLmNvbT6JAjkEEwEIACMFAljrQhQCGy8HCwkIBwMCAQYV
+CAIJCgsEFgIDAQIeAQIXgAAKCRDJ6oX9XVdrjTtPD/90ygOHzgqOEYowq9XpUcye
+3VqL/jk0zichZt98qtc7x0FejPTnnzDcdEpNFH881L0lg1QxcCqjiyLqxQRfQaUF
+SBlwn73DrTxz5Ky6hyrhbpBUMt5Fd0T3M+nbBJkop0XXFTVVXwhrfd8rKKhER9vH
+xy2mIRYyCegRCGcyieazveqS7vw4SHy+fEOzbrp8PCLOJoT1HJc5qH4SdXraYdyJ
+n7QfWs2siaVMWpwNebxqtkgofdSsxWNqpfrfj1FTs506kAgI+q9x9s/jT5mXdiZF
+d24deiiFDMG2JUGVPTJdiIXQ+sbYNDwyf+EU8XNelHIcXEq84YYPDZ4D/yjwnpi5
+7cTcmdXhZrMUEKKjdvuogLZ37U7AX6yyD5K8i4MJ+VHWGNKdg+cNzJaSmfFXjuDc
+/gSCTy/DR97s5p1BrVr4ypDra3ZstbjTh8QPDD5EfecJ0GJchRrCIVyGP8UTX7Iq
+U+3ycDLFI7X9+JHpkPN+AEyknaQ4TMmzFzF97VbVUC69j34sj08Sff6dov6xtnLC
+DiQC+u2fjOPjp4I4hQqip2+/3pUCFKNCJbO4jZnSmUln2xAQTqsdDhHjZqA8AdRJ
+1e64hjzkVKDzIsVh0eDF64dGJDAK+J2hpC2xZ5f9PBEqjaxGNnV3TesB3PwunjPS
+H0DZwqtpayBef79l2Ir9GA==
+=X9C8
+-----END PGP PUBLIC KEY BLOCK-----
+"""
+
+
+def send_mail_with_backend(
+        subject, body, from_email, recipient_list, html_message=None,
+        fail_silently=False, auth_user=None, auth_password=None,
+        attachments=None, alternatives=None, connection=None, headers=None,
+        do_not_encrypt_this_message=False):
+    connection = connection or mail.get_connection(
+        username=auth_user, password=auth_password,
+        fail_silently=fail_silently,
+    )
+    message = mail.EmailMultiAlternatives(
+        subject, body, from_email, recipient_list, attachments=attachments,
+        connection=connection, headers=headers)
+
+    if html_message:
+        message.attach_alternative(html_message, 'text/html')
+
+    for alternative, mimetype in alternatives or []:
+        message.attach_alternative(alternative, mimetype)
+
+    if do_not_encrypt_this_message:
+        message.do_not_encrypt_this_message = True
+
+    return message.send()
+
+
+class GPGMixin(object):
+    @classmethod
+    def setUpClass(cls):
+        cls.gpg = get_gpg()
+        super(GPGMixin, cls).setUpClass()
+
+
+class KeyMixin(GPGMixin):
+    @classmethod
+    def setUpClass(cls):
+        super(KeyMixin, cls).setUpClass()
+        # Import the public key through the Key model
+        cls.key = Key.objects.create(key=TEST_PUBLIC_KEY,
+                                     use_asc=cls.use_asc)
+        cls.address = cls.key.address_set.first()
+
+    @classmethod
+    def tearDownClass(cls):
+        for address in cls.key.address_set.all():
+            address.delete()
+        cls.key.delete()
+        super(KeyMixin, cls).tearDownClass()
+
+
+class DeleteAllKeysMixin(GPGMixin):
+    def delete_all_keys(self):
+        self.gpg.delete_keys([k['fingerprint'] for k in self.gpg.list_keys()],
+                             True)
+        self.gpg.delete_keys([k['fingerprint'] for k in self.gpg.list_keys()])
+
+
+class SendMailFunctionMixin(GPGMixin):
+    send_mail_function = None
+
+    def send_mail(self, *args, **kwargs):
+        if hasattr(self.send_mail_function, '__call__'):
+            # Allow functions assigned directly
+            send_mail_actual_function = self.send_mail_function
+        else:
+            # Import a function from its dotted path
+            mod, _, function = self.send_mail_function.rpartition('.')
+            try:
+                # Python 3.4+
+                from importlib import import_module
+            except ImportError:
+                # Python < 3.4
+                # From http://stackoverflow.com/a/8255024/6461688
+                mod = __import__(mod, globals(), locals(), [function])
+            else:
+                mod = import_module(mod)
+            send_mail_actual_function = getattr(mod, function)
+
+        return send_mail_actual_function(*args, **kwargs)
+
+
+class SendMailMixin(KeyMixin, SendMailFunctionMixin):
+    def test_send_mail_key_validation_fail_raises_exception(self):
+        msg_subject = "Test Subject"
+        to = ['django-email-extras@example.com']
+        from_email = settings.DEFAULT_FROM_EMAIL
+        msg_text = "Test Body Text"
+        msg_html = "<html><body><b>Hello</b> World <i>Text</i>"
+
+        from email_extras import utils
+        previous_value = utils.encrypt_kwargs['always_trust']
+        utils.encrypt_kwargs['always_trust'] = False
+        with self.assertRaises(EncryptionFailedError):
+            self.send_mail(
+                msg_subject, msg_text, from_email, to,
+                html_message=mark_safe(msg_html))
+        utils.encrypt_kwargs['always_trust'] = previous_value
+
+    def test_send_mail_function_txt_message(self):
+        msg_subject = "Test Subject"
+        to = ['django-email-extras@example.com']
+        from_email = settings.DEFAULT_FROM_EMAIL
+        msg_text = "Test Body Text"
+
+        self.send_mail(msg_subject, msg_text, from_email, to)
+
+        message = mail.outbox[0]
+
+        self.assertEquals(message.subject, msg_subject)
+        # We decrypt and test the message body below, these just ensure the
+        # message body is not cleartext
+        self.assertNotEquals(message.body, "")
+        self.assertNotEquals(message.body, msg_text)
+        self.assertEquals(message.to, to)
+        self.assertEquals(message.from_email, from_email)
+        self.assertEquals(message.alternatives, [])
+        self.assertEquals(message.attachments, [])
+
+        # Import the private key so we can decrypt the message body to test it
+        import_result = self.gpg.import_keys(TEST_PRIVATE_KEY)
+
+        self.assertTrue(all([result.get('ok', False)
+                             for result in import_result.results]))
+
+        keys = self.gpg.list_keys()
+        imported_key = keys.key_map[TEST_KEY_FINGERPRINT]
+        fp = imported_key['fingerprint']
+
+        self.assertEquals(fp, TEST_KEY_FINGERPRINT)
+
+        # Decrypt and test it against the cleartext
+        self.assertEquals(str(self.gpg.decrypt(message.body)),
+                          msg_text)
+
+        # Clean up the private key we imported here, leave the public key to be
+        # cleaned up by tearDownClass
+        delete_result = self.gpg.delete_keys(
+            TEST_KEY_FINGERPRINT, True)
+
+        self.assertEquals(str(delete_result), 'ok')
+
+    def test_send_mail_function_txt_message_with_unencrypted_recipients(self):
+        self.maxDiff = 10000
+        msg_subject = "Test Subject"
+        to = ['django-email-extras@example.com', 'unencrypted@example.com']
+        from_email = settings.DEFAULT_FROM_EMAIL
+        msg_text = "Test Body Text"
+
+        self.send_mail(msg_subject, msg_text, from_email, to)
+
+        # Grab the unencrypted message
+        message = next((msg for msg in mail.outbox if to[1] in msg.to), None)
+
+        self.assertEquals(message.subject, msg_subject)
+        self.assertEquals(message.body, msg_text)
+        self.assertEquals(message.to, [to[1]])
+        self.assertEquals(message.from_email, from_email)
+        self.assertEquals(message.alternatives, [])
+        self.assertEquals(message.attachments, [])
+
+        # Grab the encrypted message
+        message = next((msg for msg in mail.outbox if to[0] in msg.to), None)
+
+        self.assertEquals(message.subject, msg_subject)
+        # We decrypt and test the message body below, these just ensure the
+        # message body is not cleartext
+        self.assertNotEquals(message.body, "")
+        self.assertNotEquals(message.body, msg_text)
+        self.assertEquals(message.to, [to[0]])
+        self.assertEquals(message.from_email, from_email)
+        self.assertEquals(message.alternatives, [])
+        self.assertEquals(message.attachments, [])
+
+        # Import the private key so we can decrypt the message body to test it
+        import_result = self.gpg.import_keys(TEST_PRIVATE_KEY)
+
+        self.assertTrue(all([result.get('ok', False)
+                             for result in import_result.results]))
+
+        keys = self.gpg.list_keys()
+        imported_key = keys.key_map[TEST_KEY_FINGERPRINT]
+        fp = imported_key['fingerprint']
+
+        self.assertEquals(fp, TEST_KEY_FINGERPRINT)
+
+        # Decrypt and test it against the cleartext
+        self.assertEquals(str(self.gpg.decrypt(message.body)),
+                          msg_text)
+
+        # Clean up the private key we imported here, leave the public key to be
+        # cleaned up by tearDownClass
+        delete_result = self.gpg.delete_keys(
+            TEST_KEY_FINGERPRINT, True)
+
+        self.assertEquals(str(delete_result), 'ok')
+
+    def test_send_mail_function_txt_message_with_unencrypted_recipients_with_attachment_from_filename(self):
+        self.maxDiff = 10000
+        msg_subject = "Test Subject"
+        to = ['django-email-extras@example.com', 'unencrypted@example.com']
+        from_email = settings.DEFAULT_FROM_EMAIL
+        msg_text = "Test Body Text"
+        msg_html = "<html><body><b>Hello</b> World <i>Text</i>"
+
+        self.send_mail(
+            msg_subject, msg_text, from_email, to,
+            attachments=[('file.txt', msg_html, 'text/html')])
+
+        # Grab the unencrypted message
+        message = next((msg for msg in mail.outbox if to[1] in msg.to), None)
+
+        self.assertEquals(message.subject, msg_subject)
+        self.assertEquals(message.body, msg_text)
+        self.assertEquals(message.to, [to[1]])
+        self.assertEquals(message.from_email, from_email)
+        self.assertEquals(message.alternatives, [])
+        self.assertNotEquals(message.attachments, [])
+
+        # We should only have one attachment - the HTML message
+        self.assertEquals(len(message.attachments), 1)
+
+        # Check the mimetype, then decrypt the contents and compare it to the
+        # cleartext
+        filename, content, mimetype = message.attachments[0]
+        self.assertEquals(filename, 'file.txt')
+        self.assertEquals(mimetype, "text/html")
+        self.assertEquals(content, msg_html)
+
+        # Grab the encrypted message
+        message = next((msg for msg in mail.outbox if to[0] in msg.to), None)
+
+        self.assertEquals(message.subject, msg_subject)
+        # We decrypt and test the message body below, these just ensure the
+        # message body is not cleartext
+        self.assertNotEquals(message.body, "")
+        self.assertNotEquals(message.body, msg_text)
+        self.assertEquals(message.to, [to[0]])
+        self.assertEquals(message.from_email, from_email)
+        self.assertEquals(message.alternatives, [])
+        self.assertNotEquals(message.attachments, [])
+
+        # Import the private key so we can decrypt the message body to test it
+        import_result = self.gpg.import_keys(TEST_PRIVATE_KEY)
+
+        self.assertTrue(all([result.get('ok', False)
+                             for result in import_result.results]))
+
+        keys = self.gpg.list_keys()
+        imported_key = keys.key_map[TEST_KEY_FINGERPRINT]
+        fp = imported_key['fingerprint']
+
+        self.assertEquals(fp, TEST_KEY_FINGERPRINT)
+
+        # Decrypt and test it against the cleartext
+        self.assertEquals(str(self.gpg.decrypt(message.body)),
+                          msg_text)
+
+        # We should only have one attachment - the HTML message
+        self.assertEquals(len(message.attachments), 1)
+
+        # Check the mimetype, then decrypt the contents and compare it to the
+        # cleartext
+        filename, content, mimetype = message.attachments[0]
+        self.assertEquals(
+            filename, 'file.txt{}'.format('.asc' if self.use_asc else ''))
+        self.assertEquals(mimetype, "application/gpg-encrypted")
+        self.assertEquals(str(self.gpg.decrypt(content)), msg_html)
+
+        # Clean up the private key we imported here, leave the public key to be
+        # cleaned up by tearDownClass
+        delete_result = self.gpg.delete_keys(
+            TEST_KEY_FINGERPRINT, True)
+
+        self.assertEquals(str(delete_result), 'ok')
+
+    def test_send_mail_function_html_message(self):
+        self.maxDiff = 10000
+        msg_subject = "Test Subject"
+        to = ['django-email-extras@example.com']
+        from_email = settings.DEFAULT_FROM_EMAIL
+        msg_text = "Test Body Text"
+        msg_html = "<html><body><b>Hello</b> World <i>Text</i>"
+
+        self.send_mail(
+            msg_subject, msg_text, from_email, to,
+            html_message=mark_safe(msg_html))
+
+        message = mail.outbox[0]
+
+        self.assertEquals(message.subject, msg_subject)
+        # We decrypt and test the message body below, these just ensure the
+        # message body is not cleartext so we fail quickly
+        self.assertNotEquals(message.body, "")
+        self.assertNotEquals(message.body, msg_text)
+        self.assertEquals(message.to, to)
+        self.assertEquals(message.from_email, from_email)
+        # Decrypt and test the alternatives later, just ensure we have
+        # any alternatives at all so we fail quickly
+        self.assertNotEquals(message.alternatives, [])
+        self.assertEquals(message.attachments, [])
+
+        # Import the private key so we can decrypt the message body to test it
+        import_result = self.gpg.import_keys(TEST_PRIVATE_KEY)
+
+        self.assertTrue(all([result.get('ok', False)
+                             for result in import_result.results]))
+
+        keys = self.gpg.list_keys()
+        imported_key = keys.key_map[TEST_KEY_FINGERPRINT]
+        fp = imported_key['fingerprint']
+
+        self.assertEquals(fp, TEST_KEY_FINGERPRINT)
+
+        # Decrypt and test the message body against the cleartext
+        self.assertEquals(str(self.gpg.decrypt(message.body)), msg_text)
+
+        # We should only have one alternative - the HTML message
+        self.assertEquals(len(message.alternatives), 1)
+
+        # Check the mimetype, then decrypt the contents and compare it to the
+        # cleartext
+        alt, mimetype = message.alternatives[0]
+        self.assertEquals(mimetype, "application/gpg-encrypted")
+        self.assertEquals(str(self.gpg.decrypt(alt)), msg_html)
+
+        # Clean up the private key we imported here, leave the public key to be
+        # cleaned up by tearDownClass
+        delete_result = self.gpg.delete_keys(
+            TEST_KEY_FINGERPRINT, True)
+
+        self.assertEquals(str(delete_result), 'ok')
+
+    def test_send_mail_function_html_message_attachment(self):
+        self.maxDiff = 10000
+        msg_subject = "Test Subject"
+        to = ['django-email-extras@example.com']
+        from_email = settings.DEFAULT_FROM_EMAIL
+        msg_text = "Test Body Text"
+        msg_html = "<html><body><b>Hello</b> World <i>Text</i>"
+
+        self.send_mail(
+            msg_subject, msg_text, from_email, to,
+            attachments=[(None, msg_html, 'text/html')])
+
+        message = mail.outbox[0]
+
+        self.assertEquals(message.subject, msg_subject)
+        # We decrypt and test the message body below, these just ensure the
+        # message body is not cleartext so we fail quickly
+        self.assertNotEquals(message.body, "")
+        self.assertNotEquals(message.body, msg_text)
+        self.assertEquals(message.to, to)
+        self.assertEquals(message.from_email, from_email)
+        # Decrypt and test the alternatives later, just ensure we have
+        # any alternatives at all so we fail quickly
+        self.assertEquals(message.alternatives, [])
+        self.assertNotEquals(message.attachments, [])
+
+        # Import the private key so we can decrypt the message body to test it
+        import_result = self.gpg.import_keys(TEST_PRIVATE_KEY)
+
+        self.assertTrue(all([result.get('ok', False)
+                             for result in import_result.results]))
+
+        keys = self.gpg.list_keys()
+        imported_key = keys.key_map[TEST_KEY_FINGERPRINT]
+        fp = imported_key['fingerprint']
+
+        self.assertEquals(fp, TEST_KEY_FINGERPRINT)
+
+        # Decrypt and test the message body against the cleartext
+        self.assertEquals(str(self.gpg.decrypt(message.body)), msg_text)
+
+        # We should only have one attachment - the HTML message
+        self.assertEquals(len(message.attachments), 1)
+
+        # Check the mimetype, then decrypt the contents and compare it to the
+        # cleartext
+        filename, content, mimetype = message.attachments[0]
+        self.assertEquals(filename, None)
+        self.assertEquals(mimetype, "application/gpg-encrypted")
+        self.assertEquals(str(self.gpg.decrypt(content)), msg_html)
+
+        # Clean up the private key we imported here, leave the public key to be
+        # cleaned up by tearDownClass
+        delete_result = self.gpg.delete_keys(
+            TEST_KEY_FINGERPRINT, True)
+
+        self.assertEquals(str(delete_result), 'ok')
+
+    def test_send_mail_function_html_message_attachment_from_filename(self):
+        self.maxDiff = 10000
+        msg_subject = "Test Subject"
+        to = ['django-email-extras@example.com']
+        from_email = settings.DEFAULT_FROM_EMAIL
+        msg_text = "Test Body Text"
+        msg_html = "<html><body><b>Hello</b> World <i>Text</i>"
+
+        self.send_mail(
+            msg_subject, msg_text, from_email, to,
+            attachments=[('file.txt', msg_html, 'text/html')])
+
+        message = mail.outbox[0]
+
+        self.assertEquals(message.subject, msg_subject)
+        # We decrypt and test the message body below, these just ensure the
+        # message body is not cleartext so we fail quickly
+        self.assertNotEquals(message.body, "")
+        self.assertNotEquals(message.body, msg_text)
+        self.assertEquals(message.to, to)
+        self.assertEquals(message.from_email, from_email)
+        # Decrypt and test the alternatives later, just ensure we have
+        # any alternatives at all so we fail quickly
+        self.assertEquals(message.alternatives, [])
+        self.assertNotEquals(message.attachments, [])
+
+        # Import the private key so we can decrypt the message body to test it
+        import_result = self.gpg.import_keys(TEST_PRIVATE_KEY)
+
+        self.assertTrue(all([result.get('ok', False)
+                             for result in import_result.results]))
+
+        keys = self.gpg.list_keys()
+        imported_key = keys.key_map[TEST_KEY_FINGERPRINT]
+        fp = imported_key['fingerprint']
+
+        self.assertEquals(fp, TEST_KEY_FINGERPRINT)
+
+        # Decrypt and test the message body against the cleartext
+        self.assertEquals(str(self.gpg.decrypt(message.body)), msg_text)
+
+        # We should only have one attachment - the HTML message
+        self.assertEquals(len(message.attachments), 1)
+
+        # Check the mimetype, then decrypt the contents and compare it to the
+        # cleartext
+        filename, content, mimetype = message.attachments[0]
+        self.assertEquals(
+            filename, 'file.txt{}'.format('.asc' if self.use_asc else ''))
+        self.assertEquals(mimetype, "application/gpg-encrypted")
+        self.assertEquals(str(self.gpg.decrypt(content)), msg_html)
+
+        # Clean up the private key we imported here, leave the public key to be
+        # cleaned up by tearDownClass
+        delete_result = self.gpg.delete_keys(
+            TEST_KEY_FINGERPRINT, True)
+
+        self.assertEquals(str(delete_result), 'ok')
+
+    def test_send_mail_function_html_message_encrypted_attachment(self):
+        self.maxDiff = 10000
+        msg_subject = "Test Subject"
+        to = ['django-email-extras@example.com']
+        from_email = settings.DEFAULT_FROM_EMAIL
+        msg_text = "Test Body Text"
+        msg_html = "<html><body><b>Hello</b> World <i>Text</i>"
+
+        self.send_mail(
+            msg_subject, msg_text, from_email, to,
+            attachments=[(None, msg_html, 'application/gpg-encrypted')])
+
+        message = mail.outbox[0]
+
+        # We should only have one attachment - the HTML message
+        self.assertEquals(len(message.attachments), 1)
+
+        # Check the content to make sure it wasn't encrypted
+        filename, content, mimetype = message.attachments[0]
+        self.assertEquals(filename, None)
+        self.assertEquals(mimetype, "application/gpg-encrypted")
+        self.assertEquals(content, msg_html)
+
+    def test_send_mail_function_html_message_attachment_from_file(self):
+        self.maxDiff = 10000
+        msg_subject = "Test Subject"
+        to = ['django-email-extras@example.com']
+        from_email = settings.DEFAULT_FROM_EMAIL
+        msg_text = "Test Body Text"
+
+        self.send_mail(
+            msg_subject, msg_text, from_email, to,
+            attachments=['tests/templates/email_extras/dr_suess.html'])
+
+        message = mail.outbox[0]
+
+        self.assertEquals(message.subject, msg_subject)
+        # We decrypt and test the message body below, these just ensure the
+        # message body is not cleartext so we fail quickly
+        self.assertNotEquals(message.body, "")
+        self.assertNotEquals(message.body, msg_text)
+        self.assertEquals(message.to, to)
+        self.assertEquals(message.from_email, from_email)
+        # Decrypt and test the alternatives later, just ensure we have
+        # any alternatives at all so we fail quickly
+        self.assertEquals(message.alternatives, [])
+        self.assertNotEquals(message.attachments, [])
+
+        # Import the private key so we can decrypt the message body to test it
+        import_result = self.gpg.import_keys(TEST_PRIVATE_KEY)
+
+        self.assertTrue(all([result.get('ok', False)
+                             for result in import_result.results]))
+
+        keys = self.gpg.list_keys()
+        imported_key = keys.key_map[TEST_KEY_FINGERPRINT]
+        fp = imported_key['fingerprint']
+
+        self.assertEquals(fp, TEST_KEY_FINGERPRINT)
+
+        # Decrypt and test the message body against the cleartext
+        self.assertEquals(str(self.gpg.decrypt(message.body)), msg_text)
+
+        # We should only have one attachment - the HTML message
+        self.assertEquals(len(message.attachments), 1)
+
+        # Check the mimetype, then decrypt the contents and compare it to the
+        # cleartext
+        filename, content, mimetype = message.attachments[0]
+        self.assertEquals(
+            filename, 'dr_suess.html{}'.format('.asc' if self.use_asc else ''))
+        self.assertEquals(mimetype, "application/gpg-encrypted")
+        with open("tests/templates/email_extras/dr_suess.html", 'r') as f:
+            self.assertEquals(str(self.gpg.decrypt(content)), f.read())
+
+        # Clean up the private key we imported here, leave the public key to be
+        # cleaned up by tearDownClass
+        delete_result = self.gpg.delete_keys(
+            TEST_KEY_FINGERPRINT, True)
+
+        self.assertEquals(str(delete_result), 'ok')
diff --git a/tests/write_mail.sh b/tests/write_mail.sh
new file mode 100755
index 0000000..b93eb78
--- /dev/null
+++ b/tests/write_mail.sh
@@ -0,0 +1,5 @@
+#!/bin/bash
+
+MAIL_FILE="tests/mail.txt"
+
+cat $(echo $1 | sed 's|^file://||') > "$MAIL_FILE"
diff --git a/tests/wsgi.py b/tests/wsgi.py
new file mode 100644
index 0000000..0a2bd3d
--- /dev/null
+++ b/tests/wsgi.py
@@ -0,0 +1,16 @@
+"""
+WSGI config for tests project.
+
+It exposes the WSGI callable as a module-level variable named ``application``.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/1.10/howto/deployment/wsgi/
+"""
+
+import os
+
+from django.core.wsgi import get_wsgi_application
+
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings")
+
+application = get_wsgi_application()

From 7859f648dfaba6b666db16431539939ded32669c Mon Sep 17 00:00:00 2001
From: Drew Hubl <drew.hubl@gmail.com>
Date: Thu, 30 Mar 2017 04:10:44 -0600
Subject: [PATCH 09/11] Flake8 fixups

---
 email_extras/backends.py | 10 +++++-----
 email_extras/forms.py    |  1 -
 email_extras/handlers.py |  1 +
 email_extras/models.py   |  7 +++++--
 email_extras/settings.py |  4 ++--
 email_extras/utils.py    |  3 ++-
 manage.py                |  2 +-
 tests/settings.py        |  8 ++++----
 tests/test_handlers.py   | 12 ++++++------
 tests/test_send_mail.py  | 11 +++++------
 tests/utils.py           |  2 +-
 11 files changed, 32 insertions(+), 29 deletions(-)

diff --git a/email_extras/backends.py b/email_extras/backends.py
index 4fa6a42..3738e9e 100644
--- a/email_extras/backends.py
+++ b/email_extras/backends.py
@@ -93,8 +93,8 @@ def encrypt_attachment(address, attachment, use_asc):
         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:
+            # 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
@@ -104,9 +104,9 @@ def encrypt_attachment(address, attachment, use_asc):
             # * 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.
+            # 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:
diff --git a/email_extras/forms.py b/email_extras/forms.py
index 952add5..2628dda 100644
--- a/email_extras/forms.py
+++ b/email_extras/forms.py
@@ -3,7 +3,6 @@
 from django.utils.translation import ugettext_lazy as _
 
 from email_extras.models import Key
-from email_extras.settings import USE_GNUPG, GNUPG_HOME
 from email_extras.utils import get_gpg
 
 
diff --git a/email_extras/handlers.py b/email_extras/handlers.py
index 36927ff..f9b379c 100644
--- a/email_extras/handlers.py
+++ b/email_extras/handlers.py
@@ -92,6 +92,7 @@ def import_function(key):
     mod = import_module(mod)
     return getattr(mod, function)
 
+
 exception_handlers = {
     'message': 'handle_failed_message_encryption',
     'alternative': 'handle_failed_alternative_encryption',
diff --git a/email_extras/models.py b/email_extras/models.py
index 913776a..229befb 100644
--- a/email_extras/models.py
+++ b/email_extras/models.py
@@ -21,7 +21,8 @@ class Meta:
             verbose_name_plural = _("Keys")
 
         key = models.TextField()
-        fingerprint = models.CharField(max_length=200, blank=True, editable=False)
+        fingerprint = models.CharField(max_length=200, blank=True,
+                                       editable=False)
         use_asc = models.BooleanField(default=False, help_text=_(
             "If True, an '.asc' extension will be added to email attachments "
             "sent to the address for this key."))
@@ -45,7 +46,9 @@ def save(self, *args, **kwargs):
 
             super(Key, self).save(*args, **kwargs)
             for address in addresses:
-                address, _ = Address.objects.get_or_create(key=self, address=address)
+                address, _ = Address.objects.get_or_create(
+                    key=self,
+                    address=address)
                 address.use_asc = self.use_asc
                 address.save()
 
diff --git a/email_extras/settings.py b/email_extras/settings.py
index 13b7866..ffa18f0 100644
--- a/email_extras/settings.py
+++ b/email_extras/settings.py
@@ -8,8 +8,8 @@
 ALWAYS_TRUST = getattr(settings, "EMAIL_EXTRAS_ALWAYS_TRUST_KEYS", False)
 FAILURE_HANDLERS = {
     'message': 'email_extras.handlers.default_handle_failed_encryption',
-    'alternative': 'email_extras.handlers.default_handle_failed_alternative_encryption',
-    'attachment': 'email_extras.handlers.default_handle_failed_attachment_encryption',
+    'alternative': 'email_extras.handlers.default_handle_failed_alternative_encryption',  # noqa: E501
+    'attachment': 'email_extras.handlers.default_handle_failed_attachment_encryption',  # noqa: E501
 }
 FAILURE_HANDLERS.update(getattr(settings, "EMAIL_EXTRAS_FAILURE_HANDLERS", {}))
 GNUPG_ENCODING = getattr(settings, "EMAIL_EXTRAS_GNUPG_ENCODING", None)
diff --git a/email_extras/utils.py b/email_extras/utils.py
index 12687ae..d22dbbf 100644
--- a/email_extras/utils.py
+++ b/email_extras/utils.py
@@ -1,9 +1,9 @@
 from __future__ import with_statement
 
 from os.path import basename
-from six import string_types
 from warnings import warn
 
+from django import VERSION
 from django.template import loader, Context
 from django.core.mail import EmailMultiAlternatives, get_connection
 from django.utils import six
@@ -16,6 +16,7 @@
 if VERSION >= (1, 9):
     Context = dict  # noqa: F811
 
+
 if USE_GNUPG:
     from gnupg import GPG
 
diff --git a/manage.py b/manage.py
index 38a919f..7855641 100755
--- a/manage.py
+++ b/manage.py
@@ -11,7 +11,7 @@
         # issue is really that Django is missing to avoid masking other
         # exceptions on Python 2.
         try:
-            import django
+            import django  # noqa: F401
         except ImportError:
             raise ImportError(
                 "Couldn't import Django. Are you sure it's installed and "
diff --git a/tests/settings.py b/tests/settings.py
index 5e87732..00e1f09 100644
--- a/tests/settings.py
+++ b/tests/settings.py
@@ -98,16 +98,16 @@
 
 AUTH_PASSWORD_VALIDATORS = [
     {
-        'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
+        'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',  # noqa: E501
     },
     {
-        'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
+        'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',  # noqa: E501
     },
     {
-        'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
+        'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',  # noqa: E501
     },
     {
-        'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
+        'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',  # noqa: E501
     },
 ]
 
diff --git a/tests/test_handlers.py b/tests/test_handlers.py
index 0960118..052ceef 100644
--- a/tests/test_handlers.py
+++ b/tests/test_handlers.py
@@ -13,11 +13,11 @@
 class GetVariableFromExceptionTestCase(TestCase):
     def test_get_variable_from_parent(self):
         def child():
-            child_var = 2
+            child_var = 2  # noqa: F841
             raise Exception()
 
         def parent():
-            parent_var = 1
+            parent_var = 1  # noqa: F841
 
             child()
 
@@ -30,11 +30,11 @@ def parent():
 
     def test_get_variable_from_child(self):
         def child():
-            child_var = 2
+            child_var = 2  # noqa: F841
             raise Exception()
 
         def parent():
-            parent_var = 1
+            parent_var = 1  # noqa: F841
 
             child()
 
@@ -47,11 +47,11 @@ def parent():
 
     def test_raise_key_error(self):
         def child():
-            child_var = 2
+            child_var = 2  # noqa: F841
             raise Exception()
 
         def parent():
-            parent_var = 1
+            parent_var = 1  # noqa: F841
 
             child()
 
diff --git a/tests/test_send_mail.py b/tests/test_send_mail.py
index 47ad79a..ccd53b5 100644
--- a/tests/test_send_mail.py
+++ b/tests/test_send_mail.py
@@ -1,9 +1,8 @@
 from django.conf import settings
 from django.core import mail
-from django.template import loader, Context
+from django.template import loader
 from django.test import TestCase, override_settings
 
-
 from email_extras.utils import send_mail_template
 
 from tests.utils import SendMailMixin
@@ -60,13 +59,13 @@ def test_with_context(self):
         self.assertEquals(
             message.body,
             loader.get_template("email_extras/%s.%s" % (template, 'txt'))
-            .render(Context(context)))
+            .render(context))
 
         self.assertEquals(message.alternatives[0][1], 'text/html')
         self.assertEquals(
             message.alternatives[0][0],
             loader.get_template("email_extras/%s.%s" % (template, 'html'))
-            .render(Context(context)))
+            .render(context))
 
     def test_without_context(self):
         subject = "Dr. Suess Says"
@@ -82,10 +81,10 @@ def test_without_context(self):
         self.assertEquals(
             message.body,
             loader.get_template("email_extras/%s.%s" % (template, 'txt'))
-            .render(Context({})))
+            .render({}))
 
         self.assertEquals(message.alternatives[0][1], 'text/html')
         self.assertEquals(
             message.alternatives[0][0],
             loader.get_template("email_extras/%s.%s" % (template, 'html'))
-            .render(Context({})))
+            .render({}))
diff --git a/tests/utils.py b/tests/utils.py
index 53f58ab..9c0cb32 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -318,7 +318,7 @@ def test_send_mail_function_txt_message_with_unencrypted_recipients(self):
 
         self.assertEquals(str(delete_result), 'ok')
 
-    def test_send_mail_function_txt_message_with_unencrypted_recipients_with_attachment_from_filename(self):
+    def test_send_mail_function_txt_message_with_unencrypted_recipients_with_attachment_from_filename(self):  # noqa: E501
         self.maxDiff = 10000
         msg_subject = "Test Subject"
         to = ['django-email-extras@example.com', 'unencrypted@example.com']

From 26e1b96e77a3284938cf9b0995390617b0323591 Mon Sep 17 00:00:00 2001
From: Drew Hubl <drew.hubl@gmail.com>
Date: Thu, 30 Mar 2017 04:11:01 -0600
Subject: [PATCH 10/11] Add Travis CI configuration file

---
 .gitignore  |  6 ++++++
 .travis.yml | 36 ++++++++++++++++++++++++++++++++++++
 2 files changed, 42 insertions(+)
 create mode 100644 .travis.yml

diff --git a/.gitignore b/.gitignore
index 0086053..1c8eb8c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,9 @@
 *.pyc
 *.pyo
 *.egg-info
+.coverage
+tests/mail.txt
+
+gpg_keyring/
+htmlcov/
+tests/fixtures/
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 0000000..91d6e02
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,36 @@
+sudo: false
+language: python
+env:
+  - DJANGO_VERSION="Django>=1.8,<1.9"
+  - DJANGO_VERSION="Django>=1.9,<1.10"
+  - DJANGO_VERSION="Django>=1.10,<1.11"
+  - DJANGO_VERSION="Django>=1.11,<2.0"
+  - DJANGO_VERSION='https://github.com/django/django/archive/master.tar.gz'
+python:
+  # None of the currently supported Django versions support Python 2.6
+  # - "2.6"
+  - "2.7"
+  - "3.4"
+  - "3.5"
+  - "3.6"
+# matrix:
+#   exclude:
+#     # Django 2.0 won't support Python 2.x anymore
+#     - python: "2.7"
+#       env: DJANGO_VERSION='https://github.com/django/django/archive/master.tar.gz'
+install:
+  - pip install coverage coveralls flake8 python-gnupg
+  - pip install -q "$DJANGO_VERSION"
+before_script:
+  # Make sure we have gpg installed; this also logs the version of GPG
+  - gpg --version
+script:
+  - flake8 email_extras --exclude=email_extras/migrations
+  - coverage run --include=email_extras/*.py manage.py migrate
+  - coverage run --include=email_extras/*.py --omit=email_extras/migrations/*.py manage.py test tests
+after_script:
+  - coverage combine
+  - coveralls
+matrix:
+  allow_failures:
+    - env: DJANGO_VERSION='https://github.com/django/django/archive/master.tar.gz'

From 574efcb5d86045bec385898c41e5779569411975 Mon Sep 17 00:00:00 2001
From: Drew Hubl <drew.hubl@gmail.com>
Date: Thu, 30 Mar 2017 05:24:30 -0600
Subject: [PATCH 11/11] Add build status and coverage badges to README

---
 README.rst | 7 +++++++
 1 file changed, 7 insertions(+)

diff --git a/README.rst b/README.rst
index ac59547..75e7c2d 100644
--- a/README.rst
+++ b/README.rst
@@ -1,3 +1,10 @@
+.. image:: https://travis-ci.org/stephenmcd/django-email-extras.svg?branch=master
+    :target: https://travis-ci.org/stephenmcd/django-email-extras
+
+.. image:: https://coveralls.io/repos/github/stephenmcd/django-email-extras/badge.svg
+    :target: https://coveralls.io/github/stephenmcd/django-email-extras
+
+
 Created by `Stephen McDonald <http://twitter.com/stephen_mcd>`_
 
 Introduction