Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

v2.1.0 #10

Merged
merged 10 commits into from
May 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -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`
Expand Down
2 changes: 1 addition & 1 deletion django_pony_express/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Class-based emails including a test suite for Django"""

__version__ = "2.0.0"
__version__ = "2.1.0"
34 changes: 27 additions & 7 deletions django_pony_express/locale/de/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
Expand Up @@ -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 <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
Expand All @@ -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"
Empty file.
19 changes: 19 additions & 0 deletions django_pony_express/services/asynchronous/thread.py
Original file line number Diff line number Diff line change
@@ -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()
34 changes: 32 additions & 2 deletions django_pony_express/services/base.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import logging
from typing import Union

import html2text
Expand All @@ -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:
Expand Down Expand Up @@ -111,6 +113,7 @@ class BaseEmailService:
REPLY_TO_ADDRESS = []

_errors = []
_logger: logging.Logger = None

subject = None
template_name = None
Expand All @@ -133,6 +136,7 @@ def __init__(
"""
# Empty error list on initialisation
self._errors = []
self._logger = self._get_logger()

super().__init__()

Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
6 changes: 4 additions & 2 deletions django_pony_express/services/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from typing import List

from django.core import mail
from django.core.mail import EmailMultiAlternatives
from django.test import TestCase


Expand Down Expand Up @@ -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):
"""
Expand Down
4 changes: 4 additions & 0 deletions django_pony_express/settings.py
Original file line number Diff line number Diff line change
@@ -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)
48 changes: 48 additions & 0 deletions docs/features/configuration.md
Original file line number Diff line number Diff line change
@@ -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
```
65 changes: 65 additions & 0 deletions docs/features/factories.md
Original file line number Diff line number Diff line change
@@ -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.
55 changes: 55 additions & 0 deletions docs/features/internal_api.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading