From 6d29d4b21ec151a1e42ef675cc0fdf930376c9b9 Mon Sep 17 00:00:00 2001 From: Drew Hubl 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 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 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 `_ is - required for sending PGP encrypted email. +* `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 `_ 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 `_. 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 `_ - 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 `_ + 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 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 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 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 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 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 @@ +One fish, +two fish, +red fish, +{{ last_fish }}. 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 = "Hello World 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, + 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 = "Hello World 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, + 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 = "Hello World 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, + 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 = "Hello World Text" + + # 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 = "Hello World Text" + + # 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 = "Hello World Text" + + 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 = "Hello World Text" + + 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 = "Hello World Text" + + 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 = "Hello World Text" + + 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 = "Hello World Text" + + 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 = "Hello World Text" + + 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 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 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 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 `_ Introduction