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' diff --git a/README.rst b/README.rst index 8b70af8..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 @@ -17,8 +24,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 +33,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 +54,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 +86,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 +174,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 +196,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 +217,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/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/apps.py b/email_extras/apps.py index 4a810d5..d75963d 100644 --- a/email_extras/apps.py +++ b/email_extras/apps.py @@ -1,6 +1,13 @@ from django.apps import AppConfig +from email_extras.utils import check_signing_key + class EmailExtrasConfig(AppConfig): name = 'email_extras' verbose_name = 'Email Extras' + + # AFAICT, this is impossible to test + def ready(self): # pragma: noqa + # Fail early and loudly if the signing key fingerprint is misconfigured + check_signing_key() diff --git a/email_extras/backends.py b/email_extras/backends.py index c5f618b..3738e9e 100644 --- a/email_extras/backends.py +++ b/email_extras/backends.py @@ -1,9 +1,24 @@ +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 django.utils import six + +from .handlers import (handle_failed_message_encryption, + handle_failed_alternative_encryption, + handle_failed_attachment_encryption) +from .settings import USE_GNUPG +from .utils import (EncryptionFailedError, encrypt_kwargs, get_gpg) class BrowsableEmailBackend(BaseEmailBackend): @@ -26,3 +41,166 @@ def open(self, body): temp.write(body.encode('utf-8')) webbrowser.open("file://" + temp.name) + + +if USE_GNUPG: + from .models import Address + + # Create the GPG object + gpg = get_gpg() + + def copy_message(msg): + return EmailMultiAlternatives( + to=msg.to, + cc=msg.cc, + bcc=msg.bcc, + reply_to=msg.reply_to, + from_email=msg.from_email, + subject=msg.subject, + body=msg.body, + 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 or (smart_text(encryption_result) == "" + and text != ""): + raise EncryptionFailedError("Encrypting mail to %s failed: '%s'", + addr, encryption_result.status) + 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 isinstance(attachment, six.string_types): + filename = basename(attachment) + mimetype = None + + # If the attachment is just a filename, open the file, + # encrypt it, and attach it + with open(attachment, "rb") as f: + content = f.read() + else: + # Unpack attachment tuple + filename, content, mimetype = attachment + + # Ignore attachments if they're already encrypted + if mimetype == "application/gpg-encrypted": + return attachment + + try: + encrypted_content = encrypt(content, address) + except EncryptionFailedError as e: + # This function will need to decide what to do. Possibilities + # include one or more of: + # + # * Mail admins (possibly without encrypting the message to them) + # * Remove the offending key automatically + # * Set the body to a blank string + # * Set the body to the cleartext + # * Set the body to the cleartext, with a warning message prepended + # * Set the body to a custom error string + # * Reraise the exception + # + # However, the behavior will be very site-specific, because each + # site will have different attackers, different threat profiles, + # different compliance requirements, and different policies. + # + handle_failed_attachment_encryption(e) + else: + if use_asc and filename is not None: + filename += ".asc" + + return (filename, encrypted_content, "application/gpg-encrypted") + + def encrypt_messages(email_messages): + unencrypted_messages = [] + encrypted_messages = [] + for msg in email_messages: + # Copied out of utils.py + # Obtain a list of the recipients that have GPG keys installed + key_addrs = dict(Address.objects.filter(address__in=msg.to) + .values_list('address', 'use_asc')) + + # Encrypt emails - encrypted emails need to be sent individually, + # while non-encrypted emails can be sent in one send. So we split + # up each message into 1 or more parts: the unencrypted message + # that is addressed to everybody who doesn't have a key, and a + # separate message for people who do have keys. + unencrypted_msg = copy_message(msg) + unencrypted_msg.to = [addr for addr in msg.to + if addr not in key_addrs] + if unencrypted_msg.to: + unencrypted_messages.append(unencrypted_msg) + + # Make a new message object for each recipient with a key + new_msg = copy_message(msg) + new_msg.to = list(key_addrs.keys()) + + # Encrypt the message body and all attachments for all addresses + # we have keys for + for address, use_asc in key_addrs.items(): + if getattr(msg, 'do_not_encrypt_this_message', False): + unencrypted_messages.append(new_msg) + continue + + # Replace the message body with the encrypted message body + try: + new_msg.body = encrypt(new_msg.body, address) + except EncryptionFailedError as e: + handle_failed_message_encryption(e) + + # If the message has alternatives, encrypt them all + alternatives = [] + for alt, mimetype in getattr(new_msg, 'alternatives', []): + # Ignore alternatives if they're already encrypted + if mimetype == "application/gpg-encrypted": + alternatives.append((alt, mimetype)) + continue + + try: + encrypted_alternative = encrypt(alt, address) + except EncryptionFailedError as e: + handle_failed_alternative_encryption(e) + else: + alternatives.append((encrypted_alternative, + "application/gpg-encrypted")) + # Replace all of the alternatives + new_msg.alternatives = alternatives + + # Replace all unencrypted attachments with their encrypted + # versions + attachments = [] + for attachment in new_msg.attachments: + attachments.append( + encrypt_attachment(address, attachment, use_asc)) + new_msg.attachments = attachments + + encrypted_messages.append(new_msg) + + return unencrypted_messages + encrypted_messages + + class EncryptingEmailBackendMixin(object): + def send_messages(self, email_messages): + if USE_GNUPG: + email_messages = encrypt_messages(email_messages) + super(EncryptingEmailBackendMixin, self)\ + .send_messages(email_messages) + + class EncryptingConsoleEmailBackend(EncryptingEmailBackendMixin, + ConsoleBackend): + pass + + class EncryptingLocmemEmailBackend(EncryptingEmailBackendMixin, + LocmemBackend): + pass + + class EncryptingFilebasedEmailBackend(EncryptingEmailBackendMixin, + FileBackend): + pass + + class EncryptingSmtpEmailBackend(EncryptingEmailBackendMixin, + SmtpBackend): + pass diff --git a/email_extras/forms.py b/email_extras/forms.py index 3be4453..2628dda 100644 --- a/email_extras/forms.py +++ b/email_extras/forms.py @@ -2,20 +2,21 @@ 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.models import Key +from email_extras.utils import get_gpg class KeyForm(forms.ModelForm): + class Meta: + model = Key + fields = ('key', 'use_asc') 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/handlers.py b/email_extras/handlers.py new file mode 100644 index 0000000..f9b379c --- /dev/null +++ b/email_extras/handlers.py @@ -0,0 +1,103 @@ +from importlib import import_module +from inspect import trace + +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(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('.') + 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/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..bb84e0b --- /dev/null +++ b/email_extras/management/commands/email_signing_key.py @@ -0,0 +1,112 @@ +""" +Script to generate and upload a signing key to keyservers +""" +from __future__ import print_function + +import argparse + +from django.core.management.base import LabelCommand, CommandError +from django.utils.translation import ugettext as _ + +from email_extras.models import Key +from email_extras.settings import SIGNING_KEY_DATA +from email_extras.utils import get_gpg + + +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): + 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, + dest='generate', + 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', + default=[], + dest='keyservers', + help=_("Upload (the most recently generated) public signing key " + "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: + 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) + 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] + + 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: + raise CommandError("Key matching fingerprint '%(fp)s' not found." % + {'fp': label}) + + for ks in set(options.get('keyservers')): + upload_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 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/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..229befb 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): """ @@ -23,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.")) @@ -36,7 +35,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 = [] @@ -47,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() @@ -73,7 +74,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/settings.py b/email_extras/settings.py index fe17e4d..ffa18f0 100644 --- a/email_extras/settings.py +++ b/email_extras/settings.py @@ -1,15 +1,32 @@ - from django.conf import settings from django.core.exceptions import ImproperlyConfigured 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) +FAILURE_HANDLERS = { + 'message': 'email_extras.handlers.default_handle_failed_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) +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", {})) +SIGNING_KEY_FINGERPRINT = getattr( + settings, "EMAIL_EXTRAS_SIGNING_KEY_FINGERPRINT", None) 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 ebb2352..d22dbbf 100644 --- a/email_extras/utils.py +++ b/email_extras/utils.py @@ -1,35 +1,71 @@ from __future__ import with_statement + from os.path import basename 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 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, 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 + 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, + 'sign': SIGNING_KEY_FINGERPRINT, +} + 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: + 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. """ - 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, @@ -69,9 +105,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): @@ -80,11 +114,11 @@ 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) - if encrypted == "" and body != "": # encryption failed - raise EncryptionFailedError("Encrypting mail to %s failed.", - addr_list[0]) + encrypted = gpg.encrypt(body, addr_list[0], **encrypt_kwargs) + 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 @@ -93,7 +127,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: @@ -119,11 +153,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) diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..7855641 --- /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 # noqa: F401 + 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..00e1f09 --- /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', # noqa: E501 + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', # noqa: E501 + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', # noqa: E501 + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', # noqa: E501 + }, +] + + +# 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..052ceef --- /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 # noqa: F841 + raise Exception() + + def parent(): + parent_var = 1 # noqa: F841 + + 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 # noqa: F841 + raise Exception() + + def parent(): + parent_var = 1 # noqa: F841 + + 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 # noqa: F841 + raise Exception() + + def parent(): + parent_var = 1 # noqa: F841 + + 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..ccd53b5 --- /dev/null +++ b/tests/test_send_mail.py @@ -0,0 +1,90 @@ +from django.conf import settings +from django.core import mail +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 + + +@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)) + + 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)) + + 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({})) + + self.assertEquals(message.alternatives[0][1], 'text/html') + self.assertEquals( + message.alternatives[0][0], + loader.get_template("email_extras/%s.%s" % (template, 'html')) + .render({})) 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..9c0cb32 --- /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): # noqa: E501 + 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()