diff --git a/CHANGES.md b/CHANGES.md index a3dd7fd..5c0ac1c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,12 @@ # Changelog +* *2.1.0* (2024-05-27) + * Added `ThreadEmailService` for simple async sending of emails + * Added basic logging with privacy configuration to mail class + * Restructured documentation + * Restructured unit-tests + * Minor test improvements + * *2.0.0* (2024-04-11) * Dropped Django 3.2 & 4.1 support (via `ambient-package-update`) * Internal updates via `ambient-package-update` diff --git a/django_pony_express/__init__.py b/django_pony_express/__init__.py index 7cdfde3..b4f9b5e 100644 --- a/django_pony_express/__init__.py +++ b/django_pony_express/__init__.py @@ -1,3 +1,3 @@ """Class-based emails including a test suite for Django""" -__version__ = "2.0.0" +__version__ = "2.1.0" diff --git a/django_pony_express/locale/de/LC_MESSAGES/django.po b/django_pony_express/locale/de/LC_MESSAGES/django.po index 1f100b8..864f089 100644 --- a/django_pony_express/locale/de/LC_MESSAGES/django.po +++ b/django_pony_express/locale/de/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-05-03 15:32+0200\n" +"POT-Creation-Date: 2024-05-27 17:11+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,26 +18,46 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: .\django_pony_express\services\base.py:42 +#: .\django_pony_express\services\base.py:44 msgid "Email factory requires a mail service class." msgstr "E-Mailfactory benötigt eine Mailservice-Klasse." -#: .\django_pony_express\services\base.py:44 +#: .\django_pony_express\services\base.py:46 msgid "Email factory requires a target mail address." msgstr "E-Mailfactory benötigt eine Ziel-Mailadresse." -#: .\django_pony_express\services\base.py:212 +#: .\django_pony_express\services\base.py:225 msgid "Missing or mislabeled data provided for email attachment." msgstr "Fehlende oder falsch deklarierte Daten für E-Mailanhänge übergeben." -#: .\django_pony_express\services\base.py:269 +#: .\django_pony_express\services\base.py:282 msgid "Email service requires a subject." msgstr "E-Mailservice benötigt einen Betreff." -#: .\django_pony_express\services\base.py:271 +#: .\django_pony_express\services\base.py:284 msgid "Email service requires a template." msgstr "E-Mailservice benötigt ein Template." -#: .\django_pony_express\services\base.py:273 +#: .\django_pony_express\services\base.py:286 msgid "Email service requires a target mail address." msgstr "E-Mailservice benötigt eine Ziel-Mailadresse." + +#: .\django_pony_express\services\base.py:316 +#, python-format +msgid "Email \"%s\" successfully sent to %s." +msgstr "E-Mail \"%s\" erfolgreich versendet an %s." + +#: .\django_pony_express\services\base.py:318 +#, python-format +msgid "Email \"%s\" successfully sent." +msgstr "E-Mail \"%s\" erfolgreich versendet." + +#: .\django_pony_express\services\base.py:322 +#, python-format +msgid "An error occurred sending email \"%s\" to %s: %s" +msgstr "Beim Versenden der E-Mail \"%s\" an %s ist ein Fehler aufgetreten: %s" + +#: .\django_pony_express\services\base.py:325 +#, python-format +msgid "An error occurred sending email \"%s\": %s" +msgstr "Beim Versenden der E-Mail \"%s\" ist ein Fehler aufgetreten: %s" diff --git a/django_pony_express/services/asynchronous/__init__.py b/django_pony_express/services/asynchronous/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_pony_express/services/asynchronous/thread.py b/django_pony_express/services/asynchronous/thread.py new file mode 100644 index 0000000..201a16e --- /dev/null +++ b/django_pony_express/services/asynchronous/thread.py @@ -0,0 +1,19 @@ +import threading + +from django_pony_express.services.base import BaseEmailService + + +class ThreadEmailService(BaseEmailService): + """ + Service to send emails using Python threads to avoid blocking the main thread while talking to an external API + """ + + def process(self, raise_exception: bool = True) -> None: + """ + Public method which is called to actually send an email. + Calls validation first and returns the result of "msg.send()" + """ + if self.is_valid(raise_exception=raise_exception): + msg = self._build_mail_object() + email_thread = threading.Thread(target=self._send_and_log_email, args=(msg,)) + email_thread.start() diff --git a/django_pony_express/services/base.py b/django_pony_express/services/base.py index 48d8016..6307a34 100644 --- a/django_pony_express/services/base.py +++ b/django_pony_express/services/base.py @@ -1,3 +1,4 @@ +import logging from typing import Union import html2text @@ -8,7 +9,8 @@ from django.utils import translation from django.utils.translation import gettext_lazy as _ -from ..errors import EmailServiceAttachmentError, EmailServiceConfigError +from django_pony_express.errors import EmailServiceAttachmentError, EmailServiceConfigError +from django_pony_express.settings import PONY_LOG_RECIPIENTS, PONY_LOGGER_NAME class BaseEmailServiceFactory: @@ -111,6 +113,7 @@ class BaseEmailService: REPLY_TO_ADDRESS = [] _errors = [] + _logger: logging.Logger = None subject = None template_name = None @@ -133,6 +136,7 @@ def __init__( """ # Empty error list on initialisation self._errors = [] + self._logger = self._get_logger() super().__init__() @@ -144,6 +148,10 @@ def __init__( self.context_data = context_data if context_data else {} self.attachment_list = attachment_list if attachment_list else [] + def _get_logger(self) -> logging.Logger: + self._logger = logging.getLogger(PONY_LOGGER_NAME) if self._logger is None else self._logger + return self._logger + def get_context_data(self) -> dict: """ This method provides the required variables for the base email template. If more variables are required, @@ -296,6 +304,28 @@ def errors(self) -> list: """ return self._errors + def _send_and_log_email(self, msg: EmailMultiAlternatives) -> bool: + """ + Method to be called by the thread. Enables logging since we won't have any sync return values. + """ + result = False + recipients_as_string = " ".join(self.recipient_email_list) + try: + result = msg.send() + if PONY_LOG_RECIPIENTS: + self._logger.debug(_('Email "%s" successfully sent to %s.') % (msg.subject, recipients_as_string)) + else: + self._logger.debug(_('Email "%s" successfully sent.') % msg.subject) + except Exception as e: + if PONY_LOG_RECIPIENTS: + self._logger.error( + _('An error occurred sending email "%s" to %s: %s') % (msg.subject, recipients_as_string, str(e)) + ) + else: + self._logger.error(_('An error occurred sending email "%s": %s') % (msg.subject, str(e))) + + return result + def process(self, raise_exception: bool = True) -> bool: """ Public method which is called to actually send an email. Calls validation first and returns the result of @@ -304,6 +334,6 @@ def process(self, raise_exception: bool = True) -> bool: result = False if self.is_valid(raise_exception=raise_exception): msg = self._build_mail_object() - result = msg.send() + result = self._send_and_log_email(msg=msg) return result diff --git a/django_pony_express/services/tests.py b/django_pony_express/services/tests.py index 10c9fa8..267cbca 100644 --- a/django_pony_express/services/tests.py +++ b/django_pony_express/services/tests.py @@ -3,6 +3,7 @@ from typing import List from django.core import mail +from django.core.mail import EmailMultiAlternatives from django.test import TestCase @@ -156,9 +157,10 @@ def __init__(self, matching_list=None): super().__init__() self._match_list = matching_list for email in self._match_list or []: - # Change the class of every EmailMutliAlternative instance, so that it points to + # Change the class of every EmailMultiAlternative instance, so that it points to # our subclass, which has some additional assertion-methods. - email.__class__ = EmailTestServiceMail + if isinstance(email, EmailMultiAlternatives): + email.__class__ = EmailTestServiceMail def _get_html_content(self): """ diff --git a/django_pony_express/settings.py b/django_pony_express/settings.py new file mode 100644 index 0000000..ec5843d --- /dev/null +++ b/django_pony_express/settings.py @@ -0,0 +1,4 @@ +from django.conf import settings + +PONY_LOGGER_NAME: str = getattr(settings, "DJANGO_PONY_EXPRESS_LOGGER_NAME", "django_pony_express") +PONY_LOG_RECIPIENTS: bool = getattr(settings, "DJANGO_PONY_EXPRESS_LOG_RECIPIENTS", False) diff --git a/docs/features/configuration.md b/docs/features/configuration.md new file mode 100644 index 0000000..ab7766f --- /dev/null +++ b/docs/features/configuration.md @@ -0,0 +1,48 @@ +# Configuration + +## Default "FROM" + +You can set a subject prefix, so that all your emails look more similar when setting the constant ``SUBJECT_PREFIX``. + +If you wish to define a custom "from" email, you can do so via the ``FROM_EMAIL`` constant. Take care: +If you do not set it, the ``DEFAULT_FROM_EMAIL`` variable from the django settings is used. + +## Logging + +To enable basic logging, add the following block to your Django settings. + +Note that in this example, we are only logging to the console. + +```python +LOGGING = { + "loggers": { + "django_pony_express": { + "handlers": ["console"], + "level": "INFO", + "propagate": True, + }, + ... + }, +} +``` + +If you want to customise the logger name, you can do so with this variable in your global Django settings file. + +```python +DJANGO_PONY_EXPRESS_LOGGER_NAME = "my_email_logger" +``` + +## Privacy configuration + +When debugging email problem, it's incredibly helpful to know the recipient. But logging sensitive personal data - often +unknowingly - is at least a bad practice and contradicts the "privacy by design" pattern. + +Since it's very helpful and might be required to find bugs, it's possible to activate logging the recipient email +addresses. + +The authors feel that if you consciously activate this flag, you know what you are doing and think about the +consequences. + +```python +DJANGO_PONY_EXPRESS_LOG_RECIPIENTS = True +``` diff --git a/docs/features/factories.md b/docs/features/factories.md new file mode 100644 index 0000000..4bb93e6 --- /dev/null +++ b/docs/features/factories.md @@ -0,0 +1,65 @@ +# Factories + +## Create multiple similar emails + +Imagine you want to inform a couple of users about something that happened in your system, for example a successful +transaction or a report generated by your application. You would have a very similar mail body except for the salutation +or one or two minor differences. Handling this with the single email class from above feels a little off. + +That is why this package provides a mail factory! This factory is a wrapper for creating similar emails and providing +the email class shown above with recipient-specific content. + +Look at this example: + +`````` +class MyFancyMail(BaseEmailService): + subject = _('I am a great email!') + template_name = 'email/my_fancy_email.html' + + +class MyFancyMailFactory(BaseEmailServiceFactory): + service_class = MyFancyMail + + def __init__(self, action_id: int, recipient_email_list: list = None): + super().__init__(recipient_email_list) + self.action = Action.objects.filter(id=action_id).first() + + def get_recipient_list(self) -> list: + return self.action.fetch_recipients_for_this_action() + + def get_email_from_recipient(self, recipient) -> str: + if isinstance(recipient, User): + return recipient.email + return recipient + + def is_valid(self): + if not self.action: + raise EmailServiceConfigError('No action provided.') + + return super().is_valid() + + def get_context_data(self): + context = super().get_context_data() + context.update({ + 'action': self.action, + }) + return context +`````` + +This is only one of many (!) possibilities to handle a case like described above. We pass a custom action id to the +factory and fetch the given action (this might be a project, a report, a record...) and set it to a class attribute. + +The ``get_recipient_list()`` method fetches the recipients based on the action we are looking at right now. + +Because we might get mixed results (mostly not, but just to show what is possible), we overwrite the method +``get_email_from_recipient()`` to be able to handle simple email addresses as a string or user objects. If you pass only +strings, overwriting this method can be omitted. + +We added a sanity check in the ``is_valid()`` method to be sure that nobody tries to create emails like this without an +action being set. + +Finally, we add the action to the context data ``get_context_data()`` so the `MyFancyMail()` class can use it. + +Now for every recipient an instance of ``MyFancyMail()`` will be created. Now it is no problem, handling the salutation +or any other recipient-specific content within the "real" mail class. Only make sure that the factory provides all the +required data. diff --git a/docs/features/internal_api.md b/docs/features/internal_api.md new file mode 100644 index 0000000..eedd28d --- /dev/null +++ b/docs/features/internal_api.md @@ -0,0 +1,55 @@ +# Public method overview + +* `__init__(recipient_email_list: Union[list, str] = None, context_data: dict = None)` + Takes a list of recipient email addresses or just a single email address as a string and optionally some context data. + `recipient_email_list` might be none, because you could set the variable statically in the class definition. + + +* ``get_context_data()`` + Similar to django CBVs known method, you can extend here the ``context_data`` provided in the `__init__()`. + + +* ``get_subject()`` + This method combines the constant ``SUBJECT_PREFIX`` with the variable `subject`. Can be overwritten, if required. + + +* ``get_from_email()`` + Returns the email address the mail should be sent from. Will take the django settings variable ``DEFAULT_FROM_EMAIL`` + if the constant ``FROM_EMAIL`` in the class is not set. + + +* ``get_reply_to_email()`` + Returns the content of constant ``REPLY_TO_ADDRESS``. If this constant is not set, there will be no "reply-to" data in + the email. + + +* ``get_cc_to_email()`` + Returns the content of class variable ``cc_email_list_``. Any email address returned by this method will be used in + the "CC" field of the generated email. This variable can be set anywhere within the class + (preferably in the `__init__` method) or this method can be overwritten for custom behaviour. + + +* ``get_bcc_to_email()`` + Returns the content of class variable ``bcc_email_list_``. Any email address returned by this method will be used in + the "CC" field of the generated email. This variable can be set anywhere within the class + (preferably in the `__init__` method) or this method can be overwritten for custom behaviour. + + +* ``get_translation()`` + Tries to parse the language from the django settings variable ``LANGUAGE_CODE``. Can be overwritten to set a language + manually. Needs to return either `None` or a two-character language code like `en` or `de`. If this method returns + `None`, translation will be deactivated. Translations are needed for localised values like getting the current month + from a date (in the correct language). + +* ``get_attachments()`` + This method returns a list of paths to a locally-stored file. Can automatically be filled by passing the kwarg + `attachment_list` in the constructor. Each file of the given list will be attached to the newly created email. + +* ``has_errors()`` + If ``is_valid()`` is called with the keyword argument `raise_exception=False`, the configuration errors are not raised + but stored internally. This method checks if any errors occurred. If you need the explicit errors, you can fetch them + via the ``errors`` property. + + +* ``process()`` + Executes the actual sending. Not recommended to change. diff --git a/docs/features/introduction.md b/docs/features/introduction.md new file mode 100644 index 0000000..ccccd6b --- /dev/null +++ b/docs/features/introduction.md @@ -0,0 +1,111 @@ +# Class-based Emails + +This package contains an approach called "class-based emails". Similar to class-based views in django (in comparison to +function-based ones), you can now implement your email as a class and use all the benefits of object-orientated +programming like fiddling around with functions and all the non-DRY repetition of code you'll usually end up with. + +USPs: + +* Better structure and advantages of object-orientated programming over handling multiple functions! +* Quick and fast creation of new emails — only worry about the things you have to think about! +* No code redundancy for setting up the default mail configuration — all wrapped in the class! +* The text part of the email can be automatically rendered from the HTML part — no redundant templates anymore! +* Built-in (and extendable) sanity checks as a validation for forgotten variables! +* Clean and easy solution for email attachments + +There are two scenarios covered by this package: + +* **Single mail**: Create a single email through a class to utilise benefits of object-orientation. +* **Similar mails**: Create a bunch of similar emails with a factory class, for example if you want to send the same + content but with a personal salutation to a number of people. + +## Create a single email + +Imagine you want to send a single email to a given user or to the system admins. Instead of having to deal with how to +end an email and worry about if the ``to`` field requires a list or string of emails... look at this example: + +````python +from django_pony_express.services.base import BaseEmailService + +class MyFancyClassBasedMail(BaseEmailService): + """ + Send an email to my admins to inform them about something important. + """ + subject = _('Heads up, admins!') + template_name = 'email/my_fancy_class_based_email.html' + + def get_context_data(self): + data = super().get_context_data() + data['my_variable'] = ... + return data +```` + +This is a simple example of how to create an email. We pass or set the recipients in the `__init__()` method and can add +more data in the `get_context_data()` - or just provide the context on creation as a parameter. + +One big advantage is that you can create your own base class which handles all the context data you need to have for +your base email template. Imagine you have an unsubscribe link or a logo in the base template. In the "old world" +you have to think to pass these variables every time. Now, just wrap it up in a base class and that's it! + +And that is how you would send the email: + +````python +from django.conf import settings + +email_service = MyFancyClassBasedMail(settings.MY_ADMIN_EMAIL_ADDRESS) +email_service.process() +```` + +Optionally you can set the class attribute ``template_txt_name`` to define a plain text template. If not set, the HTML +part will be used to render the plain text body. + +## Attachments + +If you want to attach a number of files to your emails, you can do this in two ways. + +The simple way is passing an absolute file path to the constructor of the service: + +````python +email_service = MyMailService( + ... + attachment_list=[my_file_1, my_file_2] +) +```` + +If you want to customise the filename or even pass a mimetype, you can do as follows: + +````python +email_service = MyMailService( + ... + attachment_list=[{'filename': 'my_fancy_file.json', 'file': file_content, 'mimetype': 'application/json'}] +) +```` + +Please note that here the file content, not the file path, needs to be passed to the attachment list. If anything goes +sideways, the service will throw an `EmailServiceAttachmentError` exception. + +## Async dispatching + +A general rule about external APIs is that you shouldn't talk to them in your main thread. You don't have any control +over it, and it might be blocking your application. Therefore, it's wise to use some kind of asynchronous method to send +your emails. + +The base class in this package can be used in an async way very simply. Call the process method of your email +inheriting from `BaseEmailService` in, for example, a thread or celery task. + +### Python Threads + +If you don't want to do this, you can use the `ThreadEmailService` class, which will wrap the sending of your emails in +a simple Python thread. + +````python +from django_pony_express.services.asynchronous.thread import ThreadEmailService + + +class MyThreadBasedEmail(ThreadEmailService): + pass +```` + +### Other methods + +In the future, we'll add a base class for Celery and maybe django-q / django-q2. diff --git a/docs/features/mail.md b/docs/features/mail.md deleted file mode 100644 index 835cbef..0000000 --- a/docs/features/mail.md +++ /dev/null @@ -1,218 +0,0 @@ -# Mailing - -## Class-based Emails - -This package contains an approach called "class-based emails". Similar to class-based views in django (in comparison to -function-based ones), you can now implement your email as a class and use all the benefits of object-orientated -programming like fiddling around with functions and all the non-DRY repetition of code you'll usually end up with. - -USPs: - -* Better structure and advantages of object-orientated programming over handling multiple functions! -* Quick and fast creation of new emails - only worry about the things you have to think about! -* No code redundancy for setting up the default mail configuration - all wrapped in the class! -* The text part of the email can be automatically rendered from the HTML part - no redundant templates anymore! -* Built-in (and extendable) sanity checks as a validation for forgotten variables! -* Clean and easy solution for email attachments - -There are two scenarios covered by this package: - -* **Single mail**: Create a single email through a class to utilise benefits of object-orientation. -* **Similar mails**: Create a bunch of similar emails with a factory class, for example if you want to send the same - content but with a personal salutation to a number of people. - -### Create a single email - -Imagine you want to send a single email to a given user or to the system admins. Instead of having to deal with how to -end an email and worry about if the ``to`` field requires a list or string of emails... look at this example: - -```` -from django_pony_express.services.base import BaseEmailService - -class MyFancyClassBasedMail(BaseEmailService): - """ - Send an email to my admins to inform them about something important. - """ - subject = _('Heads up, admins!') - template_name = 'email/my_fancy_class_based_email.html' - - def get_context_data(self): - data = super().get_context_data() - data['my_variable'] = ... - return data -```` - -This is a simple example of how to create an email. We pass or set the recipients in the `__init__()` method and can add -more data in the `get_context_data()` - or just provide the context on creation as a parameter. - -One big advantage is that you can create your own base class which handles all the context data you need to have for -your base email template. Imagine you have an unsubscribe link or a logo in the base template. In the "old world" -you have to think to pass these variables every time. Now, just wrap it up in a base class and that's it! - -And that is how you would send the email: - -```` -from django.conf import settings - -email_service = MyFancyClassBasedMail(settings.MY_ADMIN_EMAIL_ADDRESS) -email_service.process() -```` - -Optionally you can set the class attribute ``template_txt_name`` to define a plain text template. If not set, the HTML -part will be used to render the plain text body. - -#### Configuration - -You can set a subject prefix, so that all your emails look more similar when setting the constant ``SUBJECT_PREFIX``. - -If you wish to define a custom "from" email, you can do so via the ``FROM_EMAIL`` constant. Take care: -If you do not set it, the ``DEFAULT_FROM_EMAIL`` variable from the django settings is used. - -#### Public method overview - -* `__init__(recipient_email_list: Union[list, str] = None, context_data: dict = None)` - Takes a list of recipient email addresses or just a single email address as a string and optionally some context data. - `recipient_email_list` might be none, because you could set the variable statically in the class definition. - - -* ``get_context_data()`` - Similar to django CBVs known method, you can extend here the ``context_data`` provided in the `__init__()`. - - -* ``get_subject()`` - This method combines the constant ``SUBJECT_PREFIX`` with the variable `subject`. Can be overwritten, if required. - - -* ``get_from_email()`` - Returns the email address the mail should be sent from. Will take the django settings variable ``DEFAULT_FROM_EMAIL`` - if the constant ``FROM_EMAIL`` in the class is not set. - - -* ``get_reply_to_email()`` - Returns the content of constant ``REPLY_TO_ADDRESS``. If this constant is not set, there will be no "reply-to" data in - the email. - - -* ``get_cc_to_email()`` - Returns the content of class variable ``cc_email_list_``. Any email address returned by this method will be used in - the "CC" field of the generated email. This variable can be set anywhere within the class - (preferably in the `__init__` method) or this method can be overwritten for custom behaviour. - - -* ``get_bcc_to_email()`` - Returns the content of class variable ``bcc_email_list_``. Any email address returned by this method will be used in - the "CC" field of the generated email. This variable can be set anywhere within the class - (preferably in the `__init__` method) or this method can be overwritten for custom behaviour. - - -* ``get_translation()`` - Tries to parse the language from the django settings variable ``LANGUAGE_CODE``. Can be overwritten to set a language - manually. Needs to return either `None` or a two-character language code like `en` or `de`. If this method returns - `None`, translation will be deactivated. Translations are needed for localised values like getting the current month - from a date (in the correct language). - -* ``get_attachments()`` - This method returns a list of paths to a locally-stored file. Can automatically be filled by passing the kwarg - `attachment_list` in the constructor. Each file of the given list will be attached to the newly created email. - -* ``has_errors()`` - If ``is_valid()`` is called with the keyword argument `raise_exception=False`, the configuration errors are not raised - but stored internally. This method checks if any errors occurred. If you need the explicit errors, you can fetch them - via the ``errors`` property. - - -* ``process()`` - Executes the actual sending. Not recommended to change. - -### Create multiple similar emails - -Imagine you want to inform a couple of users about something that happened in your system, for example a successful -transaction or a report generated by your application. You would have a very similar mail body except for the salutation -or one or two minor differences. Handling this with the single email class from above feels a little off. - -That is why this package provides a mail factory! This factory is a wrapper for creating similar emails and providing -the email class shown above with recipient-specific content. - -Look at this example: - -`````` -class MyFancyMail(BaseEmailService): - subject = _('I am a great email!') - template_name = 'email/my_fancy_email.html' - - -class MyFancyMailFactory(BaseEmailServiceFactory): - service_class = MyFancyMail - - def __init__(self, action_id: int, recipient_email_list: list = None): - super().__init__(recipient_email_list) - self.action = Action.objects.filter(id=action_id).first() - - def get_recipient_list(self) -> list: - return self.action.fetch_recipients_for_this_action() - - def get_email_from_recipient(self, recipient) -> str: - if isinstance(recipient, User): - return recipient.email - return recipient - - def is_valid(self): - if not self.action: - raise EmailServiceConfigError('No action provided.') - - return super().is_valid() - - def get_context_data(self): - context = super().get_context_data() - context.update({ - 'action': self.action, - }) - return context -`````` - -This is only one of many (!) possibilities to handle a case like described above. We pass a custom action id to the -factory and fetch the given action (this might be a project, a report, a record...) and set it to a class attribute. - -The ``get_recipient_list()`` method fetches the recipients based on the action we are looking at right now. - -Because we might get mixed results (mostly not, but just to show what is possible), we overwrite the method -``get_email_from_recipient()`` to be able to handle simple email addresses as a string or user objects. If you pass only -strings, overwriting this method can be omitted. - -We add a sanity check in the ``is_valid()`` method to be sure that nobody tries to create emails like this without an -action being set. - -Finally, we add the action to the context data ``get_context_data()`` so the `MyFancyMail()` class can use it. - -Now for every recipient an instance of ``MyFancyMail()`` will be created. Now it is no problem, handling the salutation -or any other recipient-specific content within the "real" mail class. Only make sure that the factory provides all the -required data. - -### Attachments - -If you want to attach a number of files to your emails, you can do this in two ways. - -The simple way is passing an absolute file path to the constructor of the service: - -```` -email_service = MyMailService( - ... - attachment_list=[my_file_1, my_file_2] -) -```` - -If you want to customise the filename or even pass a mimetype, you can do as follows: - -```` -email_service = MyMailService( - ... - attachment_list=[{'filename': 'my_fancy_file.json', 'file': file_content, 'mimetype': 'application/json'}] -) -```` - -Please note that here the file content, not the file path, needs to be passed to the attachment list. If anything goes -sideways, the service will throw an `EmailServiceAttachmentError` exception. - -## Testing emails - -If you are curious about how to properly test your emails, have a look at the testing section. diff --git a/docs/index.rst b/docs/index.rst index e33b149..da55a77 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -7,7 +7,10 @@ django-pony-express :maxdepth: 1 :caption: Contents: - features/mail.md + features/introduction.md + features/configuration.md + features/factories.md + features/internal_api.md features/tests.md features/changelog.rst diff --git a/settings.py b/settings.py index d6eac2c..638c9d4 100644 --- a/settings.py +++ b/settings.py @@ -57,6 +57,11 @@ "django.middleware.clickjacking.XFrameOptionsMiddleware", ) +USE_TZ = True TIME_ZONE = "UTC" LOCALE_PATHS = [str(BASE_PATH) + "/django_pony_express/locale"] + + +# Pony express +DJANGO_PONY_EXPRESS_LOG_RECIPIENTS = False diff --git a/tests/services/__init__.py b/tests/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/services/asynchronous/__init__.py b/tests/services/asynchronous/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/services/asynchronous/test_thread_email.py b/tests/services/asynchronous/test_thread_email.py new file mode 100644 index 0000000..abdf57f --- /dev/null +++ b/tests/services/asynchronous/test_thread_email.py @@ -0,0 +1,19 @@ +from threading import Thread +from unittest import mock + +from django.test import TestCase + +from django_pony_express.services.asynchronous.thread import ThreadEmailService + + +class ThreadEmailServiceTest(TestCase): + @mock.patch.object(Thread, "start") + def test_process_regular(self, mocked_start): + email = "albertus.magnus@example.com" + subject = "Test email" + service = ThreadEmailService(recipient_email_list=[email]) + service.subject = subject + service.template_name = "testapp/test_email.html" + + self.assertIsNone(service.process()) + mocked_start.assert_called_once() diff --git a/tests/services/base/__init__.py b/tests/services/base/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/services/base/test_base_mail_factory.py b/tests/services/base/test_base_mail_factory.py new file mode 100644 index 0000000..125cdd7 --- /dev/null +++ b/tests/services/base/test_base_mail_factory.py @@ -0,0 +1,82 @@ +from django.test import TestCase + +from django_pony_express.errors import EmailServiceConfigError +from django_pony_express.services.base import BaseEmailService, BaseEmailServiceFactory + + +class BaseEmailServiceFactoryTest(TestCase): + class TestMailService(BaseEmailService): + subject = "My subject" + template_name = "testapp/test_email.html" + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + + def test_init_recipient_list_is_set(self): + email = "albertus.magnus@example.com" + factory = BaseEmailServiceFactory([email]) + self.assertEqual(factory.recipient_email_list, [email]) + + def test_is_valid_positive_case(self): + email = "albertus.magnus@example.com" + factory = BaseEmailServiceFactory(recipient_email_list=[email]) + factory.service_class = BaseEmailService + self.assertTrue(factory.is_valid()) + + def test_is_valid_no_recipients(self): + factory = BaseEmailServiceFactory() + factory.service_class = BaseEmailService + with self.assertRaises(EmailServiceConfigError): + factory.is_valid() + + def test_is_valid_no_service_class(self): + email = "albertus.magnus@example.com" + factory = BaseEmailServiceFactory(recipient_email_list=[email]) + with self.assertRaises(EmailServiceConfigError): + factory.is_valid() + + def test_is_valid_no_exception_raised(self): + factory = BaseEmailServiceFactory() + factory.is_valid(raise_exception=False) + self.assertEqual(len(factory.errors), 2) + + def test_has_errors_positive_case(self): + factory = BaseEmailServiceFactory() + factory.is_valid(raise_exception=False) + self.assertTrue(factory.has_errors()) + + def test_has_errors_negative_case(self): + email = "albertus.magnus@example.com" + factory = BaseEmailServiceFactory(recipient_email_list=[email]) + factory.service_class = BaseEmailService + self.assertFalse(factory.has_errors()) + + def test_get_recipient_list_regular(self): + email_1 = "albertus.magnus@example.com" + email_2 = "thomas.von.aquin@example.com" + factory = BaseEmailServiceFactory(recipient_email_list=[email_1, email_2]) + self.assertEqual(factory.get_recipient_list(), [email_1, email_2]) + + def test_get_email_from_recipient_regular(self): + email_1 = "albertus.magnus@example.com" + email_2 = "thomas.von.aquin@example.com" + factory = BaseEmailServiceFactory(recipient_email_list=[email_1, email_2]) + self.assertEqual(factory.get_email_from_recipient(factory.get_recipient_list()[1]), email_2) + + def test_get_context_data_regular(self): + factory = BaseEmailServiceFactory() + self.assertEqual(factory.get_context_data(), {}) + + def test_process_regular(self): + email_1 = "albertus.magnus@example.com" + email_2 = "thomas.von.aquin@example.com" + factory = BaseEmailServiceFactory(recipient_email_list=[email_1, email_2]) + factory.service_class = self.TestMailService + self.assertEqual(factory.process(), 2) + + def test_process_with_exception(self): + factory = BaseEmailServiceFactory() + factory.service_class = self.TestMailService + with self.assertRaises(EmailServiceConfigError): + factory.process() diff --git a/tests/test_mail_services.py b/tests/services/base/test_base_mail_service.py similarity index 78% rename from tests/test_mail_services.py rename to tests/services/base/test_base_mail_service.py index a32a364..c5dee25 100644 --- a/tests/test_mail_services.py +++ b/tests/services/base/test_base_mail_service.py @@ -1,4 +1,6 @@ +import logging from os.path import basename +from unittest import mock from django.conf import settings from django.core.mail import EmailMultiAlternatives @@ -6,85 +8,7 @@ from freezegun import freeze_time from django_pony_express.errors import EmailServiceAttachmentError, EmailServiceConfigError -from django_pony_express.services.base import BaseEmailService, BaseEmailServiceFactory - - -class BaseEmailServiceFactoryTest(TestCase): - class TestMailService(BaseEmailService): - subject = "My subject" - template_name = "testapp/test_email.html" - - @classmethod - def setUpTestData(cls): - super().setUpTestData() - - def test_init_recipient_list_is_set(self): - email = "albertus.magnus@example.com" - factory = BaseEmailServiceFactory([email]) - self.assertEqual(factory.recipient_email_list, [email]) - - def test_is_valid_positive_case(self): - email = "albertus.magnus@example.com" - factory = BaseEmailServiceFactory(recipient_email_list=[email]) - factory.service_class = BaseEmailService - self.assertTrue(factory.is_valid()) - - def test_is_valid_no_recipients(self): - factory = BaseEmailServiceFactory() - factory.service_class = BaseEmailService - with self.assertRaises(EmailServiceConfigError): - factory.is_valid() - - def test_is_valid_no_service_class(self): - email = "albertus.magnus@example.com" - factory = BaseEmailServiceFactory(recipient_email_list=[email]) - with self.assertRaises(EmailServiceConfigError): - factory.is_valid() - - def test_is_valid_no_exception_raised(self): - factory = BaseEmailServiceFactory() - factory.is_valid(raise_exception=False) - self.assertEqual(len(factory.errors), 2) - - def test_has_errors_positive_case(self): - factory = BaseEmailServiceFactory() - factory.is_valid(raise_exception=False) - self.assertTrue(factory.has_errors()) - - def test_has_errors_negative_case(self): - email = "albertus.magnus@example.com" - factory = BaseEmailServiceFactory(recipient_email_list=[email]) - factory.service_class = BaseEmailService - self.assertFalse(factory.has_errors()) - - def test_get_recipient_list_regular(self): - email_1 = "albertus.magnus@example.com" - email_2 = "thomas.von.aquin@example.com" - factory = BaseEmailServiceFactory(recipient_email_list=[email_1, email_2]) - self.assertEqual(factory.get_recipient_list(), [email_1, email_2]) - - def test_get_email_from_recipient_regular(self): - email_1 = "albertus.magnus@example.com" - email_2 = "thomas.von.aquin@example.com" - factory = BaseEmailServiceFactory(recipient_email_list=[email_1, email_2]) - self.assertEqual(factory.get_email_from_recipient(factory.get_recipient_list()[1]), email_2) - - def test_get_context_data_regular(self): - factory = BaseEmailServiceFactory() - self.assertEqual(factory.get_context_data(), {}) - - def test_process_regular(self): - email_1 = "albertus.magnus@example.com" - email_2 = "thomas.von.aquin@example.com" - factory = BaseEmailServiceFactory(recipient_email_list=[email_1, email_2]) - factory.service_class = self.TestMailService - self.assertEqual(factory.process(), 2) - - def test_process_with_excepetion(self): - factory = BaseEmailServiceFactory() - factory.service_class = self.TestMailService - with self.assertRaises(EmailServiceConfigError): - factory.process() +from django_pony_express.services.base import BaseEmailService class BaseEmailServiceTest(TestCase): @@ -102,6 +26,17 @@ def test_init_recipient_and_context_are_initialised_empty(self): self.assertEqual(service.recipient_email_list, []) self.assertEqual(service.context_data, {}) + def test_get_logger_logger_not_set(self): + service = BaseEmailService() + email_logger = service._get_logger() + self.assertEqual(service._logger, email_logger) + + def test_get_logger_logger_set(self): + service = BaseEmailService() + service._logger = logging.getLogger("my_logger") + email_logger = service._get_logger() + self.assertEqual(service._logger, email_logger) + def test_get_context_data_regular(self): data = {"city": "Cologne"} service = BaseEmailService(context_data=data) @@ -114,7 +49,7 @@ def test_get_subject_no_prefix(self): self.assertEqual(service.get_subject(), subject) def test_get_subject_with_prefix(self): - prefix = "Ai: Core" + prefix = "Pony Express" subject = "I am a subject!" service = BaseEmailService() service.SUBJECT_PREFIX = prefix @@ -373,6 +308,55 @@ def test_has_errors_negative_case(self): service.template_name = "testapp/test_email.html" self.assertFalse(service.has_errors()) + @mock.patch("django_pony_express.services.base.BaseEmailService._logger") + def test_send_and_log_email_success_privacy_active(self, mock_logger): + service = BaseEmailService(recipient_email_list=["thomas.aquin@example.com"]) + result = service._send_and_log_email( + msg=EmailMultiAlternatives(subject="The Pony Express", to=["thomas.aquin@example.com"]) + ) + + mock_logger.debug.assert_called_with('Email "The Pony Express" successfully sent.') + self.assertEqual(result, 1) + + @mock.patch("django_pony_express.services.base.BaseEmailService._logger") + @mock.patch("django_pony_express.services.base.PONY_LOG_RECIPIENTS", True) + def test_send_and_log_success_privacy_inactive(self, mock_logger): + service = BaseEmailService(recipient_email_list=["thomas.aquin@example.com"]) + result = service._send_and_log_email( + msg=EmailMultiAlternatives(subject="The Pony Express", to=["thomas.aquin@example.com"]) + ) + + mock_logger.debug.assert_called_with('Email "The Pony Express" successfully sent to thomas.aquin@example.com.') + self.assertEqual(result, 1) + + @mock.patch.object(EmailMultiAlternatives, "send", side_effect=Exception("Broken pony")) + @mock.patch("django_pony_express.services.base.BaseEmailService._logger") + def test_send_and_log_email_failure_privacy_active(self, mock_logger, *args): + service = BaseEmailService(recipient_email_list=["thomas.aquin@example.com"]) + result = service._send_and_log_email( + msg=EmailMultiAlternatives(subject="The Pony Express", to=["thomas.aquin@example.com"]) + ) + + mock_logger.error('An error occurred sending email "%s": %s', "The Pony Express", "Broken pony") + self.assertFalse(result) + + @mock.patch.object(EmailMultiAlternatives, "send", side_effect=Exception("Broken pony")) + @mock.patch("django_pony_express.services.base.BaseEmailService._logger") + @mock.patch("django_pony_express.services.base.PONY_LOG_RECIPIENTS", True) + def test_send_and_log_failure_privacy_inactive(self, mock_logger, *args): + service = BaseEmailService(recipient_email_list=["thomas.aquin@example.com"]) + result = service._send_and_log_email( + msg=EmailMultiAlternatives(subject="The Pony Express", to=["thomas.aquin@example.com"]) + ) + + mock_logger.error( + 'An error occurred sending email "%s" to %s: %s', + "The Pony Express", + "thomas.aquin@example.com", + "Broken pony", + ) + self.assertFalse(result) + def test_process_regular(self): email = "albertus.magnus@example.com" subject = "Test email" diff --git a/tests/services/tests/__init__.py b/tests/services/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_email_test_service.py b/tests/services/tests/test_email_test_service.py similarity index 93% rename from tests/test_email_test_service.py rename to tests/services/tests/test_email_test_service.py index a52a151..1e9244d 100644 --- a/tests/test_email_test_service.py +++ b/tests/services/tests/test_email_test_service.py @@ -200,32 +200,32 @@ def test_assert_quantity_false(self): self.ets.filter(subject=self.subject).assert_quantity(0) def test_assert_subject_true(self): - self.ets.filter(subject=self.subject).assert_subject(self.subject) + self.ets.filter(subject=self.subject)[0].assert_subject(self.subject) def test_assert_subject_false(self): with self.assertRaises(AssertionError): - self.ets.filter(subject=self.subject).assert_subject(self.other_mail_subject) + self.ets.filter(subject=self.subject)[0].assert_subject(self.other_mail_subject) def test_assert_body_contains_true(self): - self.ets.filter(subject=self.subject).assert_body_contains(self.content_part) + self.ets.filter(subject=self.subject)[0].assert_body_contains(self.content_part) def test_assert_body_contains_false(self): with self.assertRaises(AssertionError): - self.ets.filter(subject=self.subject).assert_body_contains("Not in here!") + self.ets.filter(subject=self.subject)[0].assert_body_contains("Not in here!") def test_assert_body_contains_not_true(self): - self.ets.filter(subject=self.subject).assert_body_contains_not("Not in here!") + self.ets.filter(subject=self.subject)[0].assert_body_contains_not("Not in here!") def test_assert_body_contains_not_false(self): with self.assertRaises(AssertionError): - self.ets.filter(subject=self.subject).assert_body_contains_not(self.content_part) + self.ets.filter(subject=self.subject)[0].assert_body_contains_not(self.content_part) def test_assert_body_contains_no_html_part(self): subject = "No html email" email = EmailMultiAlternatives(subject, self.text_content, to=[self.to], cc=[self.cc], bcc=[self.bcc]) mail.outbox.append(email) - self.ets.filter(subject=subject).assert_body_contains(self.content_part) + self.ets.filter(subject=subject)[0].assert_body_contains(self.content_part) def test_can_get_mail_via_item(self): mail_qs = self.ets.all()