Repository: openwisp/openwisp-utils (language: python)

Issue:
[feature] Reusable HTML email template
Would it be possible to move the email HTML template to openwisp-utils and provide utilities to easily re-use it across different modules?

I am talking about:

- https://github.com/openwisp/openwisp-notifications/blob/0474539f30fb6dec96e776b026ff7d4dc311ad6b/openwisp_notifications/templates/openwisp_notifications/email_template.html
- https://github.com/openwisp/openwisp-notifications/blob/015bd4685d883d01c5fb2640bc1beac83b9cef2e/openwisp_notifications/settings.py#L6-L17
- https://github.com/openwisp/openwisp-notifications/blob/ba96a447028fb33c266cd1277ab0b41d0970f500/openwisp_notifications/handlers.py#L205-L212

This way we could send emails from different modules (openwisp-users, openwisp-radius, etc.) but with a consistent design.


Discussion / hints:

Fix patch:
diff --git a/README.rst b/README.rst
index f5aa4e4b..dbac1bbf 100644
--- a/README.rst
+++ b/README.rst
@@ -51,6 +51,7 @@ Current features
 * `Storage utilities <#storage-utilities>`_
 * `Admin utilities <#admin-utilities>`_
 * `Code utilities <#code-utilities>`_
+* `Admin Theme utilities <#admin-theme-utilities>`_
 * `REST API utilities <#rest-api-utilities>`_
 * `Test utilities <#test-utilities>`_
 * `Quality assurance checks <#quality-assurance-checks>`_
@@ -943,6 +944,45 @@ To use point ``STATICFILES_STORAGE`` to ``openwisp_utils.storage.CompressStaticF
 
     STATICFILES_STORAGE = 'openwisp_utils.storage.CompressStaticFilesStorage'
 
+Admin Theme utilities
+---------------------
+
+``openwisp_utils.admin_theme.email.send_email``
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+This function allows sending email in both plain text and HTML version (using the template
+and logo that can be customised using `OPENWISP_EMAIL_TEMPLATE <#openwisp_email_template>`_
+and `OPENWISP_EMAIL_LOGO <#openwisp_email_logo>`_ respectively).
+
+In case the HTML version if not needed it may be disabled by
+setting `OPENWISP_HTML_EMAIL <#openwisp_html_email>`_ to ``False``.
+
+**Syntax:**
+
+.. code-block:: python
+
+    send_email(subject, body_text, body_html, recipients)
+
++--------------------+------------------------------------------------------------------------+
+| **Parameter**      | **Description**                                                        |
++--------------------+------------------------------------------------------------------------+
+| ``subject``        | (``str``) The subject of the email template.                           |
++--------------------+------------------------------------------------------------------------+
+| ``body_text``      | (``str``) The body of the text message to be emailed.                  |
++--------------------+------------------------------------------------------------------------+
+| ``body_html``      | (``str``) The body of the html template to be emailed.                 |
++--------------------+------------------------------------------------------------------------+
+| ``recipients``     | (``list``) The list of recipients to send the mail to.                 |
++--------------------+------------------------------------------------------------------------+
+| ``extra_context``  | **optional** (``dict``) Extra context which is passed to the template. |
+|                    | The dictionary keys ``call_to_action_text`` and ``call_to_action_url`` |
+|                    | can be passed to show a call to action button.                         |
+|                    | Similarly, ``footer`` can be passed to add a footer.                   |
++--------------------+------------------------------------------------------------------------+
+
+
+**Note**: Data passed in body should be validated and user supplied data should not be sent directly to the function.
+
 REST API utilities
 ------------------
 
@@ -1422,6 +1462,72 @@ Example usage:
         '*png',
     ]
 
+``OPENWISP_HTML_EMAIL``
+^^^^^^^^^^^^^^^^^^^^^^^
+
++---------+----------+
+| type    | ``bool`` |
++---------+----------+
+| default | ``True`` |
++---------+----------+
+
+If ``True``, an HTML themed version of the email can be sent using
+the `send_email <#openwisp_utilsadmin_themeemailsend_email>`_ function.
+
+``OPENWISP_EMAIL_TEMPLATE``
+^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
++---------+----------------------------------------+
+| type    | ``str``                                |
++---------+----------------------------------------+
+| default | ``openwisp_utils/email_template.html`` |
++---------+----------------------------------------+
+
+This setting allows to change the django template used for sending emails with
+the `send_email <#openwisp_utilsadmin_themeemailsend_email>`_ function.
+It is recommended to extend the default email template as in the example below.
+
+.. code-block:: django
+
+    {% extends 'openwisp_utils/email_template.html' %}
+    {% block styles %}
+    {{ block.super }}
+    <style>
+      .background {
+        height: 100%;
+        background: linear-gradient(to bottom, #8ccbbe 50%, #3797a4 50%);
+        background-repeat: no-repeat;
+        background-attachment: fixed;
+        padding: 50px;
+      }
+
+      .mail-header {
+        background-color: #3797a4;
+        color: white;
+      }
+    </style>
+    {% endblock styles %}
+
+Similarly, you can customize the HTML of the template by overriding the ``body`` block.
+See `email_template.html <https://github.com/openwisp/openwisp-utils/blob/
+master/openwisp_utils/admin_theme/templates/openwisp_utils/email_template.html>`_
+for reference implementation.
+
+``OPENWISP_EMAIL_LOGO``
+^^^^^^^^^^^^^^^^^^^^^^^
+
++---------+-------------------------------------------------------------------------------------+
+| type    | ``str``                                                                             |
++---------+-------------------------------------------------------------------------------------+
+| default | `OpenWISP logo <https://raw.githubusercontent.com/openwisp/openwisp-utils/master/ \ |
+|         | openwisp_utils/static/openwisp-utils/images/openwisp-logo.png>`_                    |
++---------+-------------------------------------------------------------------------------------+
+
+This setting allows to change the logo which is displayed in HTML version of the email.
+
+**Note**: Provide a URL which points to the logo on your own web server. Ensure that the URL provided is
+publicly accessible from the internet. Otherwise, the logo may not be displayed in the email.
+
 Installing for development
 --------------------------
 
diff --git a/openwisp_utils/admin_theme/email.py b/openwisp_utils/admin_theme/email.py
new file mode 100644
index 00000000..d0ee464f
--- /dev/null
+++ b/openwisp_utils/admin_theme/email.py
@@ -0,0 +1,31 @@
+from django.conf import settings
+from django.core.mail import EmailMultiAlternatives
+from django.template.loader import render_to_string
+from django.utils.html import strip_tags
+
+from . import settings as app_settings
+
+
+def send_email(subject, body_text, body_html, recipients, extra_context={}):
+
+    mail = EmailMultiAlternatives(
+        subject=subject,
+        body=strip_tags(body_text),
+        from_email=settings.DEFAULT_FROM_EMAIL,
+        to=recipients,
+    )
+
+    if app_settings.OPENWISP_HTML_EMAIL and body_html:
+        context = dict(
+            subject=subject,
+            message=body_html,
+            logo_url=app_settings.OPENWISP_EMAIL_LOGO,
+        )
+        context.update(extra_context)
+
+        html_message = render_to_string(
+            app_settings.OPENWISP_EMAIL_TEMPLATE,
+            context=context,
+        )
+        mail.attach_alternative(html_message, 'text/html')
+    mail.send()
diff --git a/openwisp_utils/admin_theme/settings.py b/openwisp_utils/admin_theme/settings.py
index 7898a285..bd079c77 100644
--- a/openwisp_utils/admin_theme/settings.py
+++ b/openwisp_utils/admin_theme/settings.py
@@ -9,3 +9,18 @@
 OPENWISP_ADMIN_THEME_LINKS = getattr(settings, 'OPENWISP_ADMIN_THEME_LINKS', [])
 OPENWISP_ADMIN_THEME_JS = getattr(settings, 'OPENWISP_ADMIN_THEME_JS', [])
 ADMIN_DASHBOARD_ENABLED = getattr(settings, 'OPENWISP_ADMIN_DASHBOARD_ENABLED', True)
+
+OPENWISP_EMAIL_TEMPLATE = getattr(
+    settings,
+    'OPENWISP_EMAIL_TEMPLATE',
+    'openwisp_utils/email_template.html',
+)
+
+OPENWISP_EMAIL_LOGO = getattr(
+    settings,
+    'OPENWISP_EMAIL_LOGO',
+    'https://raw.githubusercontent.com/openwisp/openwisp-utils/master/openwisp_utils/'
+    'static/openwisp-utils/images/openwisp-logo.png',
+)
+
+OPENWISP_HTML_EMAIL = getattr(settings, 'OPENWISP_HTML_EMAIL', True)
diff --git a/openwisp_utils/admin_theme/templates/openwisp_utils/email_template.html b/openwisp_utils/admin_theme/templates/openwisp_utils/email_template.html
new file mode 100644
index 00000000..be8d4e51
--- /dev/null
+++ b/openwisp_utils/admin_theme/templates/openwisp_utils/email_template.html
@@ -0,0 +1,106 @@
+{% load i18n %}
+{% load l10n %}
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  {% block styles %}
+  <style type="text/css">
+    body {
+      min-width: 250px;
+      background: #ffe5e5;
+      margin: 0;
+      padding: 0;
+    }
+    .container{
+      background: #ffe5e5;
+      height: 100%;
+      min-height: 500px;
+      display: flex;
+      justify-content: center;
+      align-items: center;
+    }
+    .box{
+      width: auto;
+      padding: 60px 40px;
+      background-color: #fcfcfc;
+      box-sizing: border-box;
+      box-shadow: 0 15px 25px rgba(256,256, 256,.5);
+      border-radius: 10px;
+      margin: auto;
+    }
+    .logo-container{
+      text-align: center;
+      margin-bottom: 25px;
+    }
+    .logo {
+      max-width: 230px;
+    }
+    .call-to-action{
+      text-align: center;
+      margin-top: 30px;
+    }
+    .subject{
+      font-size: medium;
+      font-weight: 700;
+      margin: 20px 0;
+    }
+    .msg{
+      color: #1b1818;
+      margin: 20px 0;
+      font-size: medium;
+    }
+    .btn{
+      appearance: button;
+      text-decoration: none;
+      color: #fff!important;
+      background: #333;
+      border-radius: 4px;
+      border: 0;
+      padding: 10px 15px;
+      font-weight: bold;
+      font-size: medium;
+    }
+    .btn:hover, .btn:focus {
+      background: #000;
+      outline: 0 none;
+      background: rgba(51, 51, 51, 0.7);
+    }
+    a:link:hover {
+      text-decoration: none;
+    }
+  </style>
+  {% endblock styles %}
+</head>
+<body>
+  {% block body %}
+  <div class="container">
+    <div class="box">
+      {% block mail_header %}
+      <div class="logo-container">
+        <img src="{{ logo_url }}" alt="Logo" class="logo">
+      </div>
+      {% if subject %}
+      <div class="subject">{{ subject }}</div>
+      {% endif %}
+      {% endblock mail_header %}
+      {% block mail_body %}
+      <div class="msg">{{ message | safe }}</div>
+      {% endblock mail_body %}
+      {% block call_to_action %}
+      {% if call_to_action_url %}
+      <div class="call-to-action">
+        <a href="{{ call_to_action_url }}" class="btn">{{ call_to_action_text }}</a>
+      </div>
+      {% endif %}
+      {% endblock call_to_action %}
+      {% block mail_footer %}
+        {% if footer %}
+          {{ footer }}
+        {% endif %}
+      {% endblock mail_footer %}
+    </div>
+  </div>
+  {% endblock body %}
+</body>
+</html>
diff --git a/openwisp_utils/static/openwisp-utils/images/openwisp-logo.png b/openwisp_utils/static/openwisp-utils/images/openwisp-logo.png
new file mode 100644
index 00000000..407e2cfd
Binary files /dev/null and b/openwisp_utils/static/openwisp-utils/images/openwisp-logo.png differ


Test patch:
diff --git a/tests/test_project/tests/test_email.py b/tests/test_project/tests/test_email.py
new file mode 100644
index 00000000..97ac80e2
--- /dev/null
+++ b/tests/test_project/tests/test_email.py
@@ -0,0 +1,75 @@
+from unittest.mock import patch
+
+from django.core import mail
+from django.test import TestCase, override_settings
+from openwisp_utils.admin_theme import settings as app_settings
+from openwisp_utils.admin_theme.email import send_email
+
+
+class TestEmail(TestCase):
+    @override_settings(DEFAULT_FROM_EMAIL='test@openwisp.io')
+    def test_email(self):
+        send_email(
+            'Test mail',
+            '',
+            'This is a test email',
+            ['devkapilbansal@gmail.com'],
+        )
+        self.assertEqual(len(mail.outbox), 1)
+        email = mail.outbox.pop()
+        # test from email
+        self.assertEqual(email.from_email, 'test@openwisp.io')
+        # test image logo
+        self.assertIn(
+            'https://raw.githubusercontent.com/openwisp/openwisp-utils/master/'
+            'openwisp_utils/static/openwisp-utils/images/openwisp-logo.png',
+            email.alternatives[0][0],
+        )
+        # test email doesn't contain link
+        self.assertNotIn('<a href', email.alternatives[0][0])
+
+    def test_email_action_text_and_url(self):
+        send_email(
+            'Test mail',
+            '',
+            'This is a test email',
+            ['devkapilbansal@gmail.com', 'test123@openwisp.io'],
+            extra_context={
+                'call_to_action_text': 'Click me',
+                'call_to_action_url': 'https://openwisp.io/docs/index.html',
+            },
+        )
+        self.assertEqual(len(mail.outbox), 1)
+        email = mail.outbox.pop()
+        self.assertIn('Click me</a>\n', email.alternatives[0][0])
+        self.assertIn(
+            '<a href="https://openwisp.io/docs/index.html" class="btn">',
+            email.alternatives[0][0],
+        )
+
+    @patch.object(app_settings, 'OPENWISP_HTML_EMAIL', False)
+    def test_no_html_email(self):
+        send_email(
+            'Test mail',
+            'This is a test email',
+            'Email body in html message',
+            ['devkapilbansal@gmail.com', 'test123@openwisp.io'],
+        )
+        self.assertEqual(len(mail.outbox), 1)
+        email = mail.outbox.pop()
+        self.assertEqual(email.subject, 'Test mail')
+        self.assertEqual(email.body, 'This is a test email')
+        self.assertFalse(email.alternatives)
+
+    def test_blank_html_body(self):
+        send_email(
+            'Test mail',
+            'This is a test email',
+            '',
+            ['devkapilbansal@gmail.com', 'test123@openwisp.io'],
+        )
+        self.assertEqual(len(mail.outbox), 1)
+        email = mail.outbox.pop()
+        self.assertEqual(email.subject, 'Test mail')
+        self.assertEqual(email.body, 'This is a test email')
+        self.assertFalse(email.alternatives)