├── tests ├── __init__.py ├── urls.py ├── fixtures │ └── templates.json ├── test_helpers.py ├── settings.py ├── test_forms.py ├── test_views.py └── test_models.py ├── appmail ├── __init__.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── truncate_logged_messages.py ├── migrations │ ├── __init__.py │ ├── 0006_emailtemplate_supports_attachments.py │ ├── 0008_add_logged_message_indexes.py │ ├── 0004_emailtemplate_is_active.py │ ├── 0003_emailtemplate_test_context.py │ ├── 0002_add_template_description.py │ ├── 0005_emailtemplate_from_email__reply_to.py │ ├── 0001_initial.py │ └── 0007_loggedemailmessage.py ├── templates │ ├── base.txt │ ├── base.html │ ├── admin │ │ └── appmail │ │ │ ├── emailtemplate │ │ │ └── change_form.html │ │ │ └── loggedmessage │ │ │ ├── change_form.html │ │ │ └── template_name_filter.html │ └── appmail │ │ └── send_test_email.html ├── apps.py ├── settings.py ├── urls.py ├── helpers.py ├── views.py ├── forms.py ├── admin.py └── models.py ├── pytest.ini ├── poetry.toml ├── .coveragerc ├── .gitignore ├── .prettierignore ├── screenshots ├── appmail-test-email-send.png ├── appmail-test-email-action.png ├── appmail-test-email-success.png └── appmail-template-change-form.png ├── .editorconfig ├── manage.py ├── .prettierrc ├── mypy.ini ├── .pre-commit-config.yaml ├── CONTRIBUTING.md ├── LICENSE ├── pyproject.toml ├── tox.ini ├── CHANGELOG.md ├── .github └── workflows │ └── tox.yml └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /appmail/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /appmail/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /appmail/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /appmail/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = tests.settings -------------------------------------------------------------------------------- /appmail/templates/base.txt: -------------------------------------------------------------------------------- 1 | {% block content %} 2 | {% endblock content %} -------------------------------------------------------------------------------- /poetry.toml: -------------------------------------------------------------------------------- 1 | [virtualenvs] 2 | create = true 3 | in-project = true 4 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | tests/* 4 | **/migrations/* 5 | 6 | include = 7 | appmail/* 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .coverage 2 | .tox 3 | *.egg-info 4 | *.pyc 5 | dist 6 | poetry.lock 7 | static 8 | test.db 9 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.gif 2 | *.html 3 | *.ico 4 | *.jpg 5 | *.jpeg 6 | *.svg 7 | *.json 8 | *.png 9 | *.md 10 | vendor 11 | -------------------------------------------------------------------------------- /screenshots/appmail-test-email-send.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yunojuno/django-appmail/HEAD/screenshots/appmail-test-email-send.png -------------------------------------------------------------------------------- /screenshots/appmail-test-email-action.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yunojuno/django-appmail/HEAD/screenshots/appmail-test-email-action.png -------------------------------------------------------------------------------- /screenshots/appmail-test-email-success.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yunojuno/django-appmail/HEAD/screenshots/appmail-test-email-success.png -------------------------------------------------------------------------------- /screenshots/appmail-template-change-form.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yunojuno/django-appmail/HEAD/screenshots/appmail-template-change-form.png -------------------------------------------------------------------------------- /appmail/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | {% block content %}{% endblock content %} 5 | 6 | -------------------------------------------------------------------------------- /appmail/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AppmailConfig(AppConfig): 5 | name = "appmail" 6 | verbose_name = "Application Mail" 7 | default_auto_field = "django.db.models.AutoField" 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.yaml] 13 | indent_size = 2 14 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | if __name__ == "__main__": 5 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") 6 | 7 | from django.core.management import execute_from_command_line 8 | 9 | execute_from_command_line(sys.argv) 10 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | try: 2 | from django.urls import include, re_path 3 | except ImportError: 4 | from django.conf.urls import url as re_path, include 5 | 6 | from django.contrib import admin 7 | 8 | import appmail.urls 9 | 10 | admin.autodiscover() 11 | 12 | urlpatterns = [ 13 | re_path(r"^admin/", admin.site.urls), 14 | re_path(r"^appmail/", include(appmail.urls)), 15 | ] 16 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 4, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": false, 7 | "trailingComma": "none", 8 | "bracketSpacing": true, 9 | "jsxBracketSameLine": false, 10 | "proseWrap": "always", 11 | "endOfLine": "auto", 12 | "overrides": [ 13 | { 14 | "files": ["*.yml", "*.yaml"], 15 | "options": { 16 | "tabWidth": 2 17 | } 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | check_untyped_defs=true 3 | disallow_incomplete_defs=true 4 | disallow_untyped_defs=true 5 | follow_imports=silent 6 | ignore_missing_imports=true 7 | no_implicit_optional=true 8 | strict_optional=true 9 | warn_redundant_casts=true 10 | warn_unreachable=true 11 | warn_unused_ignores=true 12 | 13 | # Disable mypy for migrations 14 | [mypy-*.migrations.*] 15 | ignore_errors=true 16 | 17 | # Disable mypy for settings 18 | [mypy-*.settings.*] 19 | ignore_errors=true 20 | 21 | # Disable mypy for tests 22 | [mypy-tests.*] 23 | ignore_errors=true 24 | -------------------------------------------------------------------------------- /tests/fixtures/templates.json: -------------------------------------------------------------------------------- 1 | [{"model": "appmail.emailtemplate", "pk": 1, "fields": {"name": "Sample email template", "description": "This is a demonstration template", "language": "en-us", "version": 0, "subject": "Welcome to Appmail", "body_text": "Hi {{ first_name }},\r\n\r\nWelcome to the Django Appmail application.", "body_html": "Hi {{ first_name }},
\r\nWelcome to the django-appmail application.
"]
6 | license = "MIT"
7 | readme = "README.md"
8 | homepage = "https://github.com/yunojuno/django-appmail"
9 | repository = "https://github.com/yunojuno/django-appmail"
10 | classifiers = [
11 | "Environment :: Web Environment",
12 | "Framework :: Django :: 4.2",
13 | "Framework :: Django :: 5.0",
14 | "Framework :: Django :: 5.2",
15 | "Operating System :: OS Independent",
16 | "Programming Language :: Python :: 3 :: Only",
17 | "Programming Language :: Python :: 3.9",
18 | "Programming Language :: Python :: 3.10",
19 | "Programming Language :: Python :: 3.11",
20 | "Programming Language :: Python :: 3.12",
21 | ]
22 | packages = [{ include = "appmail" }]
23 |
24 | [tool.poetry.dependencies]
25 | python = "^3.9"
26 | django = "^4.2 || ^5.0"
27 |
28 | [tool.poetry.dev-dependencies]
29 | black = "*"
30 | coverage = "*"
31 | freezegun = "*"
32 | mypy = "*"
33 | pre-commit = "*"
34 | pytest = "*"
35 | pytest-cov = "*"
36 | pytest-django = "*"
37 | ruff = "*"
38 | tox = "*"
39 |
40 | [tool.poetry.group.dev.dependencies]
41 | tox = "^4.26.0"
42 |
43 | [build-system]
44 | requires = ["poetry>=0.12"]
45 | build-backend = "poetry.masonry.api"
46 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | isolated_build = True
3 | envlist =
4 | fmt, lint, mypy,
5 | django-checks,
6 | ; Django versions: 4.2+ (LTS)
7 | django42-py{39,310,311}
8 | django50-py{310,311,312}
9 | django52-py{310,311,312}
10 | djangomain-py{312}
11 |
12 | [testenv]
13 | deps =
14 | coverage
15 | pytest
16 | pytest-cov
17 | pytest-django
18 | django42: Django>=4.2,<4.3
19 | django50: https://github.com/django/django/archive/stable/5.0.x.tar.gz
20 | django52: https://github.com/django/django/archive/stable/5.2.x.tar.gz
21 | djangomain: https://github.com/django/django/archive/main.tar.gz
22 |
23 | commands =
24 | pytest --cov=appmail --verbose tests/
25 |
26 | [testenv:django-checks]
27 | description = Django system checks and missing migrations
28 | deps = Django
29 | commands =
30 | python manage.py check --fail-level WARNING
31 | python manage.py makemigrations --dry-run --check --verbosity 3
32 |
33 | [testenv:fmt]
34 | description = Python source code formatting (black)
35 | deps =
36 | black
37 |
38 | commands =
39 | black --check appmail
40 |
41 | [testenv:lint]
42 | description = Python source code linting (ruff)
43 | deps =
44 | ruff
45 |
46 | commands =
47 | ruff check appmail
48 |
49 | [testenv:mypy]
50 | description = Python source code type hints (mypy)
51 | deps =
52 | mypy
53 |
54 | commands =
55 | mypy appmail
56 |
--------------------------------------------------------------------------------
/appmail/migrations/0002_add_template_description.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Generated by Django 1.10.6 on 2017-03-15 03:26
3 | from __future__ import unicode_literals
4 |
5 | from django.conf import settings
6 | from django.db import migrations, models
7 |
8 |
9 | class Migration(migrations.Migration):
10 | dependencies = [("appmail", "0001_initial")]
11 |
12 | operations = [
13 | migrations.AddField(
14 | model_name="emailtemplate",
15 | name="description",
16 | field=models.CharField(
17 | blank=True,
18 | help_text=(
19 | "Optional description. e.g. used to differentiate variants ('new "
20 | "header')."
21 | ),
22 | max_length=100,
23 | ),
24 | ),
25 | migrations.AlterField(
26 | model_name="emailtemplate",
27 | name="language",
28 | field=models.CharField(
29 | db_index=True,
30 | default=settings.LANGUAGE_CODE,
31 | help_text=(
32 | "Used to support localisation of emails, defaults to "
33 | "`settings.LANGUAGE_CODE`, but can be any string, e.g. 'London', "
34 | "'NYC'."
35 | ),
36 | max_length=20,
37 | verbose_name="Language",
38 | ),
39 | ),
40 | ]
41 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 |
5 | ## v7.0.0
6 |
7 | * Add support for Django 5.2
8 | * Drop support for Django 3.x, 4.0 and 4.1
9 |
10 | ## v6.0.0
11 |
12 | * Add support for Django 5.0
13 | * Add Python 3.12 to classifiers and build matrix
14 |
15 | ## v5.0.0
16 |
17 | * Improve LoggedMessage admin list page performance.
18 | * Add support for Django 4.2
19 | * Drop support for Python 3.8
20 |
21 | ## v4.0.0
22 |
23 | Bump in version due to potential breaking change.
24 |
25 | * [Potential breaking change] New indexes on the LoggedMessage model; take
26 | care when applying this migration as your table may be large in size.
27 | * Fix crash in admin logged messages when a related template has been deleted.
28 | * Fix n+1 query issue in logged messages admin listing.
29 |
30 | ## v3.0.0
31 |
32 | * Add support for Python 3.11.
33 | * Drop support for Django 3.0, 3.1.
34 |
35 | ## v2.3.0
36 |
37 | * Add Django 4.1 to build matrix.
38 |
39 | ## v2.2.0
40 |
41 | * Add management command to truncate logs after a period.
42 |
43 | ## v2.1.0
44 |
45 | * Add Python 3.10 to build matrix.
46 | * Add Django 4.0 to build matrix.
47 | * Update admin template to fix subject line render issue.
48 | * Update `EmailTemplate.clone` to make new template inactive by default.
49 |
50 | ## v2.0.0
51 |
52 | * Drop support for Django 2.x.
53 | * Add abililty to send / log messages from templates.
54 |
--------------------------------------------------------------------------------
/appmail/urls.py:
--------------------------------------------------------------------------------
1 | from django.urls import path
2 |
3 | import appmail.models
4 | import appmail.views
5 |
6 | app_name = "appmail"
7 |
8 | urlpatterns = [
9 | # template paths
10 | path(
11 | "templates//body.txt",
12 | appmail.views.render_template_body,
13 | kwargs={"content_type": "text/plain"},
14 | name="render_template_body_text",
15 | ),
16 | path(
17 | "templates//body.html",
18 | appmail.views.render_template_body,
19 | kwargs={"content_type": "text/html"},
20 | name="render_template_body_html",
21 | ),
22 | path(
23 | "templates//subject.txt",
24 | appmail.views.render_template_subject,
25 | name="render_template_subject",
26 | ),
27 | path("templates/test/", appmail.views.send_test_email, name="send_test_email"),
28 | # LoggedMessage paths
29 | path(
30 | "emails//body.txt",
31 | appmail.views.render_message_body,
32 | kwargs={"content_type": appmail.models.EmailTemplate.CONTENT_TYPE_PLAIN},
33 | name="render_message_body_text",
34 | ),
35 | path(
36 | "emails//body.html",
37 | appmail.views.render_message_body,
38 | kwargs={"content_type": appmail.models.EmailTemplate.CONTENT_TYPE_HTML},
39 | name="render_message_body_html",
40 | ),
41 | path(
42 | "emails/resend//", appmail.views.resend_email, name="resend_email"
43 | ),
44 | ]
45 |
--------------------------------------------------------------------------------
/appmail/migrations/0005_emailtemplate_from_email__reply_to.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Generated by Django 1.10 on 2017-09-11 03:58
3 | from __future__ import unicode_literals
4 |
5 | from django.conf import settings
6 | from django.db import migrations, models
7 |
8 |
9 | class Migration(migrations.Migration):
10 | dependencies = [("appmail", "0004_emailtemplate_is_active")]
11 |
12 | operations = [
13 | migrations.AddField(
14 | model_name="emailtemplate",
15 | name="from_email",
16 | field=models.CharField(
17 | default=settings.DEFAULT_FROM_EMAIL,
18 | help_text=(
19 | "Default sender address if none specified. Verbose form is "
20 | "accepted."
21 | ),
22 | max_length=254,
23 | verbose_name="Sender",
24 | ),
25 | ),
26 | migrations.AddField(
27 | model_name="emailtemplate",
28 | name="reply_to",
29 | field=models.CharField(
30 | default=settings.DEFAULT_FROM_EMAIL,
31 | help_text="Comma separated list of Reply-To recipients.",
32 | max_length=254,
33 | verbose_name="Reply-To",
34 | ),
35 | ),
36 | migrations.AlterField(
37 | model_name="emailtemplate",
38 | name="description",
39 | field=models.CharField(
40 | blank=True,
41 | help_text=(
42 | "Optional description. e.g. used to differentiate variants ('new "
43 | "header')."
44 | ),
45 | max_length=100,
46 | verbose_name="Description",
47 | ),
48 | ),
49 | ]
50 |
--------------------------------------------------------------------------------
/appmail/templates/appmail/send_test_email.html:
--------------------------------------------------------------------------------
1 | {% extends "admin/base_site.html" %}
2 | {% load i18n l10n admin_urls static %}
3 | {% block extrastyle %}{{ block.super }}{% endblock %}
4 | {% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} delete-confirmation delete-selected-confirmation{% endblock %}
5 |
6 | {% block breadcrumbs %}
7 |
13 | {% endblock %}
14 |
15 | {% block content %}
16 | {% trans "Send Test Emails" %}
17 |
18 | {% trans 'Templates to test' %}
19 |
20 | {% for template in templates %}
21 | - {{ template.name }}: "{{ template.subject }}"
22 | {% endfor %}
23 |
24 |
25 |
26 | Test attributes
27 |
47 |
48 | {% endblock %}
49 |
--------------------------------------------------------------------------------
/appmail/management/commands/truncate_logged_messages.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from datetime import date, timedelta
4 | from typing import Any
5 |
6 | from django.core.management.base import BaseCommand, CommandParser
7 | from django.db.models.query import QuerySet
8 | from django.utils.translation import gettext_lazy as _lazy
9 |
10 | from appmail.models import LoggedMessage
11 | from appmail.settings import LOG_RETENTION_PERIOD
12 |
13 |
14 | class Command(BaseCommand):
15 | help = _lazy("Truncate all log records that have passed the LOG_RETENTION_PERIOD.")
16 |
17 | def add_arguments(self, parser: CommandParser) -> None:
18 | super().add_arguments(parser)
19 | parser.add_argument(
20 | "-r",
21 | "--retention",
22 | dest="retention",
23 | type=int,
24 | default=LOG_RETENTION_PERIOD,
25 | help="The number of days to retain logged messages.",
26 | )
27 | parser.add_argument(
28 | "-c",
29 | "--commit",
30 | dest="commit",
31 | action="store_true",
32 | default=False,
33 | help="If not set the transaction will be rolled back (no change).",
34 | )
35 |
36 | def get_logs(self, cutoff: date) -> QuerySet:
37 | """Return the queryset of logs to delete."""
38 | return LoggedMessage.objects.filter(timestamp__lt=cutoff)
39 |
40 | def cutoff(self, retention: int) -> date:
41 | """Return the date before which to truncate logs."""
42 | return date.today() - timedelta(days=retention)
43 |
44 | def handle(self, *args: Any, **options: Any) -> None:
45 | retention = options["retention"]
46 | commit = options["commit"]
47 | cutoff = self.cutoff(retention)
48 | self.stdout.write(f"Truncating records before {cutoff}")
49 | logs = self.get_logs(cutoff)
50 | self.stdout.write(f"Deleting {logs.count()} records")
51 | if not commit:
52 | self.stderr.write("Aborting transaction as --commit is False.")
53 | return
54 | count, _ = logs.delete()
55 | self.stdout.write(f"Deleted {count} records.")
56 | return
57 |
--------------------------------------------------------------------------------
/.github/workflows/tox.yml:
--------------------------------------------------------------------------------
1 | name: Python / Django
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 |
8 | pull_request:
9 | types: [opened, synchronize, reopened]
10 |
11 | jobs:
12 | format:
13 | name: Check formatting
14 | runs-on: ubuntu-latest
15 | strategy:
16 | matrix:
17 | toxenv: [fmt, lint, mypy]
18 | env:
19 | TOXENV: ${{ matrix.toxenv }}
20 |
21 | steps:
22 | - name: Check out the repository
23 | uses: actions/checkout@v4
24 |
25 | - name: Set up Python (3.11)
26 | uses: actions/setup-python@v4
27 | with:
28 | python-version: "3.11"
29 |
30 | - name: Install and run tox
31 | run: |
32 | pip install tox
33 | tox
34 |
35 | checks:
36 | name: Run Django checks
37 | runs-on: ubuntu-latest
38 | strategy:
39 | matrix:
40 | toxenv: ["django-checks"]
41 | env:
42 | TOXENV: ${{ matrix.toxenv }}
43 |
44 | steps:
45 | - name: Check out the repository
46 | uses: actions/checkout@v4
47 |
48 | - name: Set up Python (3.11)
49 | uses: actions/setup-python@v4
50 | with:
51 | python-version: "3.11"
52 |
53 | - name: Install and run tox
54 | run: |
55 | pip install tox
56 | tox
57 |
58 | test:
59 | name: Run tests
60 | runs-on: ubuntu-latest
61 | strategy:
62 | matrix:
63 | python: ["3.9", "3.10", "3.11", "3.12"]
64 | # build LTS versions and main
65 | django: ["42", "50", "52", "main"]
66 | exclude:
67 | - python: "3.9"
68 | django: "50"
69 | - python: "3.9"
70 | django: "52"
71 | - python: "3.9"
72 | django: "main"
73 | - python: "3.10"
74 | django: "main"
75 | - python: "3.11"
76 | django: "main"
77 |
78 | env:
79 | TOXENV: django${{ matrix.django }}-py${{ matrix.python }}
80 |
81 | steps:
82 | - name: Check out the repository
83 | uses: actions/checkout@v4
84 |
85 | - name: Set up Python ${{ matrix.python }}
86 | uses: actions/setup-python@v4
87 | with:
88 | python-version: ${{ matrix.python }}
89 |
90 | - name: Install and run tox
91 | run: |
92 | pip install tox
93 | tox
94 |
--------------------------------------------------------------------------------
/tests/test_helpers.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 |
3 | from appmail import helpers
4 |
5 |
6 | class HelperTests(TestCase):
7 | """appmail.helpers module tests."""
8 |
9 | def test_get_context(self):
10 | self.assertEqual(
11 | helpers.get_context("{{a}} {{b.c}}"), {"a": "A", "b": {"c": "C"}}
12 | )
13 |
14 | def test_extract_vars(self):
15 | """Check extract_vars handles expected input."""
16 | for x, y in (
17 | ("", []),
18 | (None, []),
19 | ("{{foo}}", ["foo"]),
20 | ("{{ foo}}", ["foo"]),
21 | ("{{ foo }}", ["foo"]),
22 | ("{{ foo }", []),
23 | ("{% foo %}", []),
24 | ("{{ foo|time }}", []),
25 | ("{{foo}} {{bar}}", ["foo", "bar"]),
26 | ):
27 | self.assertEqual(set(helpers.extract_vars(x)), set(y))
28 |
29 | def test_expand_list(self):
30 | """Check dot notation expansion."""
31 | self.assertRaises(ValueError, helpers.expand_list, None)
32 | self.assertRaises(ValueError, helpers.expand_list, "")
33 | self.assertEqual(helpers.expand_list(["a", "b.c"]), {"a": {}, "b": {"c": {}}})
34 |
35 | def test_fill_leaf_values(self):
36 | """Check the default func is applied."""
37 | self.assertRaises(ValueError, helpers.fill_leaf_values, None)
38 | self.assertRaises(ValueError, helpers.fill_leaf_values, "")
39 | self.assertEqual(
40 | helpers.fill_leaf_values({"a": {}, "b": {"c": {}}}),
41 | {"a": "A", "b": {"c": "C"}},
42 | )
43 |
44 | def test_merge_dicts(self):
45 | self.assertEqual(helpers.merge_dicts({"foo": 1}), {"foo": 1})
46 | self.assertEqual(
47 | helpers.merge_dicts({"foo": 1}, {"bar": 2}), {"foo": 1, "bar": 2}
48 | )
49 | self.assertEqual(helpers.merge_dicts({"foo": 1}, {"foo": 2}), {"foo": 2})
50 |
51 | def test_patch_context(self):
52 | foo = {"foo": 1}
53 | bar = {"bar": 2}
54 | baz = {"baz": 3}
55 |
56 | def cp1(request):
57 | return bar
58 |
59 | def cp2(request):
60 | return baz
61 |
62 | self.assertEqual(helpers.patch_context(foo, []), foo)
63 |
64 | self.assertEqual(
65 | helpers.patch_context(foo, [cp1]), helpers.merge_dicts(foo, bar)
66 | )
67 |
68 | self.assertEqual(
69 | helpers.patch_context(foo, [cp1, cp2]), helpers.merge_dicts(foo, bar, baz)
70 | )
71 |
--------------------------------------------------------------------------------
/tests/settings.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from django.core.exceptions import ImproperlyConfigured
4 |
5 | DEBUG = True
6 |
7 | USE_TZ = False
8 |
9 | DATABASES = {
10 | "default": {
11 | "ENGINE": "django.db.backends.sqlite3",
12 | "NAME": "test.db",
13 | }
14 | }
15 |
16 | INSTALLED_APPS = (
17 | "django.contrib.admin",
18 | "django.contrib.auth",
19 | "django.contrib.contenttypes",
20 | "django.contrib.sessions",
21 | "django.contrib.messages",
22 | "django.contrib.staticfiles",
23 | "appmail",
24 | "tests",
25 | )
26 |
27 | MIDDLEWARE = [
28 | "django.contrib.sessions.middleware.SessionMiddleware",
29 | "django.middleware.common.CommonMiddleware",
30 | "django.middleware.csrf.CsrfViewMiddleware",
31 | "django.contrib.auth.middleware.AuthenticationMiddleware",
32 | "django.contrib.messages.middleware.MessageMiddleware",
33 | ]
34 |
35 | TEMPLATES = [
36 | {
37 | "BACKEND": "django.template.backends.django.DjangoTemplates",
38 | "DIRS": [
39 | # insert your TEMPLATE_DIRS here
40 | ],
41 | "APP_DIRS": True,
42 | "OPTIONS": {
43 | "context_processors": [
44 | # Insert your TEMPLATE_CONTEXT_PROCESSORS here or use this
45 | # list if you haven't customized them:
46 | "django.contrib.auth.context_processors.auth",
47 | "django.template.context_processors.debug",
48 | "django.template.context_processors.i18n",
49 | "django.template.context_processors.media",
50 | "django.template.context_processors.request",
51 | "django.template.context_processors.static",
52 | "django.template.context_processors.tz",
53 | "django.contrib.messages.context_processors.messages",
54 | ]
55 | },
56 | }
57 | ]
58 |
59 | EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
60 |
61 | # Update storage settings to use the new STORAGES format
62 | STORAGES = {
63 | "default": {
64 | "BACKEND": "django.core.files.storage.FileSystemStorage",
65 | },
66 | "staticfiles": {
67 | "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
68 | },
69 | }
70 |
71 | STATIC_URL = "/static/"
72 | STATIC_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "static"))
73 |
74 | SECRET_KEY = "top secret" # noqa: S105
75 |
76 | ROOT_URLCONF = "tests.urls"
77 |
78 | APPEND_SLASH = True
79 |
80 | if not DEBUG:
81 | raise ImproperlyConfigured("This project is only intended to be used for testing.")
82 |
83 | # ===
84 |
85 | APPMAIL_DEFAULT_SENDER = "test@example.com"
86 |
--------------------------------------------------------------------------------
/appmail/helpers.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import re
4 | from typing import Callable, Iterable
5 |
6 | from django.http import HttpRequest
7 |
8 | # regex for extracting django template {{ variable }}s
9 | TEMPLATE_VARS = re.compile(r"{{([ ._[a-z]*)}}")
10 |
11 |
12 | def get_context(content: str) -> dict:
13 | """
14 | Return a dummary context dict for a content block.
15 |
16 | This function works by taking template content,
17 | extracting the template variables ({{ foo.bar }}),
18 | expanding out the list of variables into a dict
19 | using the '.' separator, and then populating the
20 | value of each leaf node with the node key ('foo': "FOO").
21 |
22 | Used for generating test data.
23 |
24 | """
25 | return fill_leaf_values(expand_list(extract_vars(content)))
26 |
27 |
28 | def extract_vars(content: str) -> list[str]:
29 | """
30 | Extract variables from template content.
31 |
32 | Returns a deduplicated list of all the variable names
33 | found in the content.
34 |
35 | """
36 | content = content or ""
37 | # if I was better at regex I wouldn't need the strip.
38 | return list({s.strip() for s in TEMPLATE_VARS.findall(content)})
39 |
40 |
41 | def expand_list(_list: list[str]) -> dict:
42 | """
43 | Convert list of '.' separated values to a nested dict.
44 |
45 | Taken from SO article which I now can't find, this will take a list
46 | and return a dictionary which contains an empty dict as each leaf
47 | node.
48 |
49 | >>> expand_list(['a', 'b.c'])
50 | {
51 | 'a': {},
52 | 'b': {
53 | 'c': {}
54 | }
55 | }
56 |
57 | """
58 | if not isinstance(_list, list):
59 | raise ValueError("arg must be a list")
60 | tree = {} # type: dict[str, dict]
61 | for item in _list:
62 | t = tree
63 | for part in item.split("."):
64 | t = t.setdefault(part, {})
65 | return tree
66 |
67 |
68 | def fill_leaf_values(tree: dict) -> dict:
69 | """
70 | Recursive function that populates empty dict leaf nodes.
71 |
72 | This function will look for all the leaf nodes in a dictionary
73 | and replace them with a value that looks like the variable
74 | in the template - e.g. {{ foo }}.
75 |
76 | >>> fill_leaf_values({'a': {}, 'b': 'c': {}})
77 | {
78 | 'a': '{{ A }}',
79 | 'b': {
80 | 'c': '{{ C }}'
81 | }
82 | }
83 |
84 | """
85 | if not isinstance(tree, dict):
86 | raise ValueError("arg must be a dictionary")
87 | for k in tree.keys():
88 | if tree[k] == {}:
89 | tree[k] = k.upper()
90 | else:
91 | fill_leaf_values(tree[k])
92 | return tree
93 |
94 |
95 | def merge_dicts(*dicts: dict) -> dict:
96 | """Merge multiple dicts into one."""
97 | context = {}
98 | for d in dicts:
99 | context.update(d)
100 | return context
101 |
102 |
103 | def patch_context(
104 | context: dict,
105 | processors: Iterable[Callable[[HttpRequest], dict]],
106 | request: HttpRequest | None = None,
107 | ) -> dict:
108 | """Add template context_processor content to context."""
109 | cpx = [p(request) for p in processors]
110 | return merge_dicts(context, *cpx)
111 |
--------------------------------------------------------------------------------
/appmail/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Generated by Django 1.10 on 2017-03-11 11:36
3 | from __future__ import unicode_literals
4 |
5 | from django.db import migrations, models
6 |
7 |
8 | class Migration(migrations.Migration):
9 | initial = True
10 |
11 | dependencies = []
12 |
13 | operations = [
14 | migrations.CreateModel(
15 | name="EmailTemplate",
16 | fields=[
17 | (
18 | "id",
19 | models.AutoField(
20 | auto_created=True,
21 | primary_key=True,
22 | serialize=False,
23 | verbose_name="ID",
24 | ),
25 | ),
26 | (
27 | "name",
28 | models.CharField(
29 | db_index=True,
30 | help_text=(
31 | "Template name - must be unique for a given language/"
32 | "version combination."
33 | ),
34 | max_length=100,
35 | verbose_name="Template name",
36 | ),
37 | ),
38 | (
39 | "language",
40 | models.CharField(
41 | db_index=True,
42 | default="en-us",
43 | help_text=(
44 | "Used to support localisation of emails, defaults to "
45 | "settings.LANGUAGE_CODE."
46 | ),
47 | max_length=20,
48 | verbose_name="Language code",
49 | ),
50 | ),
51 | (
52 | "version",
53 | models.IntegerField(
54 | db_index=True,
55 | default=0,
56 | help_text=(
57 | "Integer value - can be used for versioning or A/B testing."
58 | ),
59 | verbose_name="Version (or variant)",
60 | ),
61 | ),
62 | (
63 | "subject",
64 | models.CharField(
65 | help_text=(
66 | "Email subject line (may contain template variables)."
67 | ),
68 | max_length=100,
69 | verbose_name="Subject line template",
70 | ),
71 | ),
72 | (
73 | "body_text",
74 | models.TextField(
75 | help_text=(
76 | "Plain text content (may contain template variables)."
77 | ),
78 | verbose_name="Plain text template",
79 | ),
80 | ),
81 | (
82 | "body_html",
83 | models.TextField(
84 | help_text="HTML content (may contain template variables).",
85 | verbose_name="HTML template",
86 | ),
87 | ),
88 | ],
89 | ),
90 | migrations.AlterUniqueTogether(
91 | name="emailtemplate", unique_together={("name", "language", "version")}
92 | ),
93 | ]
94 |
--------------------------------------------------------------------------------
/appmail/migrations/0007_loggedemailmessage.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.1.5 on 2021-01-04 06:48
2 |
3 | import django.core.serializers.json
4 | import django.db.models.deletion
5 | import django.utils.timezone
6 | from django.conf import settings
7 | from django.db import migrations, models
8 |
9 |
10 | class Migration(migrations.Migration):
11 | dependencies = [
12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL),
13 | ("appmail", "0006_emailtemplate_supports_attachments"),
14 | ]
15 |
16 | operations = [
17 | migrations.CreateModel(
18 | name="LoggedMessage",
19 | fields=[
20 | (
21 | "id",
22 | models.AutoField(
23 | auto_created=True,
24 | primary_key=True,
25 | serialize=False,
26 | verbose_name="ID",
27 | ),
28 | ),
29 | (
30 | "to",
31 | models.EmailField(
32 | help_text="Address to which the the Email was sent.",
33 | max_length=254,
34 | ),
35 | ),
36 | (
37 | "timestamp",
38 | models.DateTimeField(
39 | default=django.utils.timezone.now,
40 | help_text="When the email was sent.",
41 | db_index=True,
42 | ),
43 | ),
44 | (
45 | "subject",
46 | models.TextField(blank=True, help_text="Email subject line."),
47 | ),
48 | (
49 | "body",
50 | models.TextField(
51 | blank=True,
52 | help_text="Plain text content.",
53 | verbose_name="Plain text",
54 | ),
55 | ),
56 | (
57 | "html",
58 | models.TextField(
59 | blank=True, help_text="HTML content.", verbose_name="HTML"
60 | ),
61 | ),
62 | (
63 | "context",
64 | models.JSONField(
65 | default=dict,
66 | encoder=django.core.serializers.json.DjangoJSONEncoder,
67 | help_text="Appmail template context.",
68 | ),
69 | ),
70 | (
71 | "template",
72 | models.ForeignKey(
73 | blank=True,
74 | help_text="The appmail template used.",
75 | null=True,
76 | on_delete=django.db.models.deletion.SET_NULL,
77 | related_name="logged_emails",
78 | to="appmail.emailtemplate",
79 | ),
80 | ),
81 | (
82 | "user",
83 | models.ForeignKey(
84 | blank=True,
85 | null=True,
86 | on_delete=django.db.models.deletion.SET_NULL,
87 | related_name="logged_emails",
88 | to=settings.AUTH_USER_MODEL,
89 | ),
90 | ),
91 | ],
92 | options={
93 | "get_latest_by": "timestamp",
94 | "verbose_name": "Email message",
95 | "verbose_name_plural": "Email messages sent",
96 | },
97 | ),
98 | ]
99 |
--------------------------------------------------------------------------------
/tests/test_forms.py:
--------------------------------------------------------------------------------
1 | # type: ignore
2 | from unittest import mock
3 |
4 | from django.core.exceptions import ValidationError
5 | from django.forms import Textarea
6 | from django.http import HttpRequest
7 | from django.test import TestCase
8 |
9 | from appmail.forms import (
10 | EmailTestForm,
11 | JSONWidget,
12 | MultiEmailField,
13 | MultiEmailTemplateField,
14 | )
15 | from appmail.models import AppmailMessage, EmailTemplate
16 |
17 |
18 | class JSONWidgetTests(TestCase):
19 | def test_format_value(self):
20 | widget = JSONWidget()
21 | self.assertEqual(widget.format_value(None), "{}")
22 | self.assertEqual(widget.format_value(""), "{}")
23 | self.assertEqual(widget.format_value('{"foo": true}'), '{\n "foo": true\n}')
24 | self.assertRaises(TypeError, widget.format_value, {"foo": True})
25 |
26 | def test_render(self):
27 | widget = JSONWidget()
28 | textarea = Textarea()
29 | for val in [None, "", '{"foo": true}']:
30 | self.assertEqual(
31 | widget.render("test", val),
32 | textarea.render(
33 | "test", widget.format_value(val), attrs=widget.DEFAULT_ATTRS
34 | ),
35 | )
36 |
37 |
38 | class MultiEmailFieldTests(TestCase):
39 | def test_to_python(self):
40 | form = MultiEmailField()
41 | self.assertEqual(form.to_python(None), [])
42 | self.assertEqual(form.to_python(""), [])
43 | self.assertEqual(form.to_python("fred@example.com"), ["fred@example.com"])
44 | self.assertEqual(
45 | form.to_python("fred@example.com , ginger@example.com"),
46 | ["fred@example.com", "ginger@example.com"],
47 | )
48 | self.assertEqual(form.to_python(["fred@example.com"]), ["fred@example.com"])
49 |
50 | def test_validate(self):
51 | form = MultiEmailField()
52 | form.validate(["fred@example.com"])
53 | form.validate(form.to_python("fred@example.com, ginger@example.com"))
54 | # single email address fails validation - must be a list
55 | self.assertRaises(ValidationError, form.validate, "fred@example.com")
56 |
57 |
58 | class MultiEmailTemplateFieldTests(TestCase):
59 | @mock.patch.object(EmailTemplate.objects, "filter")
60 | def test_to_python(self, mock_filter):
61 | form = MultiEmailTemplateField()
62 | self.assertEqual(list(form.to_python(None)), list(EmailTemplate.objects.none()))
63 | self.assertEqual(list(form.to_python("")), list(EmailTemplate.objects.none()))
64 | qs = EmailTemplate.objects.none()
65 | self.assertEqual(form.to_python(qs), qs)
66 | form.to_python("1, 2")
67 | mock_filter.assert_called_once_with(pk__in=[1, 2])
68 |
69 |
70 | class EmailTestFormTests(TestCase):
71 | def test_clean_context(self):
72 | form = EmailTestForm()
73 | form.cleaned_data = {"context": "true"}
74 | self.assertEqual(form.clean_context(), True)
75 | form.cleaned_data["context"] = True
76 | self.assertRaises(ValidationError, form.clean_context)
77 |
78 | def test__create_message(self):
79 | form = EmailTestForm()
80 | form.cleaned_data = {
81 | "context": {"foo": "bar"},
82 | "to": ["fred@example.com"],
83 | "cc": [],
84 | "bcc": [],
85 | "from_email": "donotreply@example.com",
86 | }
87 | template = EmailTemplate()
88 | email = form._create_message(template)
89 | self.assertEqual(email.from_email, "donotreply@example.com")
90 | self.assertEqual(email.to, ["fred@example.com"])
91 | self.assertEqual(email.cc, [])
92 | self.assertEqual(email.bcc, [])
93 |
94 | @mock.patch("appmail.forms.messages")
95 | @mock.patch.object(AppmailMessage, "send")
96 | def test_send_emails(self, mock_send, mock_messages):
97 | template = EmailTemplate().save()
98 | form = EmailTestForm()
99 | form.cleaned_data = {
100 | "context": {"foo": "bar"},
101 | "to": ["fred@example.com"],
102 | "cc": [],
103 | "bcc": [],
104 | "from_email": "donotreply@example.com",
105 | "templates": [template],
106 | }
107 | request = HttpRequest()
108 | form.send_emails(request)
109 | mock_send.assert_called_once()
110 | mock_messages.success.assert_called_once()
111 |
112 | # test email failure
113 | mock_send.side_effect = Exception()
114 | form.send_emails(request)
115 | mock_messages.error.assert_called_once()
116 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Django-AppMail
2 |
3 | [](https://pypi.org/project/django-appmail/)
4 |
5 | Django app for managing transactional email templates.
6 |
7 | ## Compatibility
8 |
9 | This project requires Django 4.2+ and Python 3.9+.
10 |
11 | ## Background
12 |
13 | This project arose out of a project to integrate a large transactional Django
14 | application with Mandrill, and the lessons learned. It also owes a minor h/t to
15 | this project from 2011 (https://github.com/hugorodgerbrown/AppMail).
16 |
17 | The core requirement is to provide an easy way to add / edit email templates to
18 | a Django project, in such a way that it doesn't require a developer to make
19 | changes. The easiest way to use templated emails in Django is to rely on the
20 | in-built template structure, but that means that the templates are held in
21 | files, under version control, which makes it very hard for non-developers to
22 | edit.
23 |
24 | This is **not** a WYSIWYG HTML editor, and it doesn't do anything clever. It
25 | doesn't handle the sending of the emails - it simply provides a convenient
26 | mechanism for storing and rendering email content.
27 |
28 | ```python
29 | from appmail.models import EmailTemplate, AppmailMessage
30 |
31 | def send_order_confirmation(order_id):
32 | order = Orders.objects.get(id=order_id)
33 | template = EmailTemplate.objects.current('order_confirmation')
34 | context = { "order": order }
35 | message = AppmailMessage(
36 | template=template,
37 | context=context,
38 | to=[order.recipient.email]
39 | )
40 | message.send()
41 | ```
42 |
43 | The core requirements are:
44 |
45 | 1. List / preview existing templates
46 | 2. Edit subject line, plain text and HTML content
47 | 3. Use standard Django template syntax
48 | 4. Support base templates
49 | 5. Template versioning
50 | 6. Language support
51 | 7. Send test emails
52 | 8. Log emails sent (if desired)
53 |
54 | ### Email logging (v2)
55 |
56 | From v2 on, it is possible to log all emails that are sent via
57 | `AppmailMessage.send`. It records the template, context and the rendered output,
58 | so that the email can be views as sent, and resent. It will attempt to record
59 | the User to whom the email was sent, as well as the email address. This is
60 | dependent on there being a unique 1:1 match from email to User object, but can
61 | prove useful in tracking emails sent to users when they change their email
62 | address.
63 |
64 | ### Template properties
65 |
66 | Individual templates are stored as model objects in the database. The standard
67 | Django admin site is used to view / filter templates. The templates are ordered
68 | by name, language and version. This combination is unique. The language and
69 | version properties have sensible defaults (`version=settings.LANGUAGE_CODE` and
70 | `version=0`) so don't need to set if you don't require it. There is no
71 | inheritance or relationship between different languages and versions - they are
72 | stored as independent objects.
73 |
74 | ```python
75 | # get the default order_summary email (language = settings.LANGUAGE_CODE)
76 | template = EmailTemplate.objects.current('order_summary')
77 | # get the french version
78 | template = EmailTemplate.objects.current('order_summary', language='fr')
79 | # get a specific version
80 | template = EmailTemplate.objects.version('order_summary', 1)
81 | ```
82 |
83 | **Template syntax**
84 |
85 | The templates themselves use standard Django template syntax, including the use
86 | of tags, filters. There is nothing special about them, however there is one
87 | caveat - template inheritance.
88 |
89 | **Template inheritance**
90 |
91 | Although the template content is not stored on disk, without re-engineering the
92 | template rendering methods any parent templates must be. This is annoying, but
93 | there is a valid assumption behind it - if you are changing your base templates
94 | you are probably involving designers and developers already, so having to rely
95 | on a developer to make the changes is acceptable.
96 |
97 | **Sending test emails**
98 |
99 | You can send test emails to an email address through the admin list view.
100 |
101 |
103 |
104 | The custom admin action 'Send test emails' will redirect to an intermediate page
105 | where you can enter the recipient email address and send the email:
106 |
107 |
108 |
109 | There is also a linkon individual template admin pages (top-right, next to the
110 | history link):
111 |
112 |
114 |
115 | ## Tests
116 |
117 | There is a test suite for the app, which is best run through `tox`.
118 |
119 | ## License
120 |
121 | MIT
122 |
123 | ## Contributing
124 |
125 | Usual rules apply:
126 |
127 | 1. Fork to your own account
128 | 2. Fix the issue / add the feature
129 | 3. Submit PR
130 |
131 | Please take care to follow the coding style - and PEP8.
132 |
--------------------------------------------------------------------------------
/appmail/views.py:
--------------------------------------------------------------------------------
1 | """Views supporting template previews in admin site."""
2 |
3 | from __future__ import annotations
4 |
5 | import json
6 | import logging
7 |
8 | from django.conf import settings as django_settings
9 | from django.contrib import messages
10 | from django.contrib.auth.decorators import user_passes_test
11 | from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
12 | from django.http.response import HttpResponseBadRequest
13 | from django.shortcuts import get_object_or_404, render
14 | from django.urls import reverse
15 | from django.utils.translation import gettext as _
16 | from django.views.decorators.clickjacking import xframe_options_sameorigin
17 |
18 | from .forms import EmailTestForm, MultiEmailTemplateField
19 | from .helpers import merge_dicts
20 | from .models import EmailTemplate, LoggedMessage
21 |
22 | logger = logging.getLogger(__name__)
23 |
24 |
25 | @user_passes_test(lambda u: u.is_staff)
26 | @xframe_options_sameorigin
27 | def render_template_subject(request: HttpRequest, template_id: int) -> HttpResponse:
28 | """Render the template subject."""
29 | template = get_object_or_404(EmailTemplate, id=template_id)
30 | html = template.render_subject(template.test_context)
31 | return HttpResponse(html, content_type="text/plain")
32 |
33 |
34 | @user_passes_test(lambda u: u.is_staff)
35 | @xframe_options_sameorigin
36 | def render_template_body(
37 | request: HttpRequest, template_id: int, content_type: str
38 | ) -> HttpResponse:
39 | """Render the template body as plain text or HTML."""
40 | template = get_object_or_404(EmailTemplate, id=template_id)
41 | if content_type in (
42 | EmailTemplate.CONTENT_TYPE_PLAIN,
43 | EmailTemplate.CONTENT_TYPE_HTML,
44 | ):
45 | html = template.render_body(template.test_context, content_type)
46 | return HttpResponse(html, content_type=content_type)
47 | # do not return the content_type to the user, as it is
48 | # user-generated and _could_ be a vulnerability.
49 | return HttpResponse("Invalid content_type specified.", status=400)
50 |
51 |
52 | @user_passes_test(lambda u: u.is_staff)
53 | def send_test_email(request: HttpRequest) -> HttpResponseRedirect:
54 | """Intermediate admin action page for sending a single test email."""
55 | # use the field.to_python here as belt-and-braces - if it works here
56 | # we can be confident that it'll work on the POST.
57 | templates = MultiEmailTemplateField().to_python(request.GET["templates"])
58 |
59 | if request.method == "GET":
60 | contexts = merge_dicts(*[t.test_context for t in templates])
61 | context = json.dumps(contexts, indent=4, sort_keys=True)
62 | initial = {"templates": request.GET["templates"], "context": context}
63 | try:
64 | template = templates.get()
65 | initial["from_email"] = template.from_email
66 | initial["reply_to"] = template.reply_to
67 | except EmailTemplate.MultipleObjectsReturned:
68 | initial["from_email"] = django_settings.DEFAULT_FROM_EMAIL
69 | initial["reply_to"] = django_settings.DEFAULT_FROM_EMAIL
70 | form = EmailTestForm(initial=initial)
71 | return render(
72 | request,
73 | "appmail/send_test_email.html",
74 | {
75 | "form": form,
76 | "templates": templates,
77 | # opts are used for rendering some page furniture - breadcrumbs etc.
78 | "opts": EmailTemplate._meta,
79 | },
80 | )
81 |
82 | if request.method == "POST":
83 | form = EmailTestForm(request.POST)
84 | if form.is_valid():
85 | form.send_emails(request)
86 | return HttpResponseRedirect(
87 | "{}?{}".format(
88 | reverse("appmail:send_test_email"), request.GET.urlencode()
89 | )
90 | )
91 | else:
92 | return render(
93 | request,
94 | "appmail/send_test_email.html",
95 | {
96 | "form": form,
97 | "templates": templates,
98 | # opts are used for rendering some page furniture - breadcrumbs etc.
99 | "opts": EmailTemplate._meta,
100 | },
101 | status=422,
102 | )
103 |
104 |
105 | @user_passes_test(lambda u: u.is_staff)
106 | def resend_email(request: HttpRequest, email_id: int) -> HttpResponseRedirect:
107 | """Resend a specific LoggedMessage."""
108 | email = LoggedMessage.objects.get(id=email_id)
109 | email.resend()
110 | messages.success(request, _("Resent email to {}".format(email.to)))
111 | return HttpResponseRedirect(reverse("admin:appmail_loggedmessage_changelist"))
112 |
113 |
114 | @user_passes_test(lambda u: u.is_staff)
115 | @xframe_options_sameorigin
116 | def render_message_body(
117 | request: HttpRequest, email_id: int, content_type: str
118 | ) -> HttpResponse:
119 | """Render the email body as plain text or HTML."""
120 | email = get_object_or_404(LoggedMessage, id=email_id)
121 | if content_type == EmailTemplate.CONTENT_TYPE_PLAIN:
122 | return HttpResponse(email.body, content_type=content_type)
123 | if content_type == EmailTemplate.CONTENT_TYPE_HTML:
124 | return HttpResponse(email.html, content_type=content_type)
125 | return HttpResponseBadRequest("Invalid content_type specified.")
126 |
--------------------------------------------------------------------------------
/appmail/forms.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import json
4 | import logging
5 | from typing import TYPE_CHECKING
6 |
7 | from django import forms
8 | from django.contrib import messages
9 | from django.core.validators import validate_email
10 | from django.http import HttpRequest
11 | from django.utils.translation import gettext_lazy as _lazy
12 |
13 | from .models import AppmailMessage, EmailTemplate, EmailTemplateQuerySet
14 |
15 | if TYPE_CHECKING:
16 | from typing import Union
17 |
18 |
19 | logger = logging.getLogger(__name__)
20 |
21 |
22 | class JSONWidget(forms.Textarea):
23 | """Pretty print JSON in a text area."""
24 |
25 | DEFAULT_ATTRS: dict[str, Union[str, int]] = {
26 | "class": "vLargeTextField",
27 | "rows": 15,
28 | }
29 |
30 | def format_value(self, value: str) -> str:
31 | """Pretty format JSON text."""
32 | value = value or "{}"
33 | if not isinstance(value, str):
34 | raise TypeError("Value must JSON parseable string instance")
35 | value = json.loads(value)
36 | return json.dumps(value, indent=4, sort_keys=True)
37 |
38 | def render(
39 | self,
40 | name: str,
41 | value: str,
42 | attrs: dict[str, str | int] | None = None,
43 | renderer: forms.renderers.BaseRenderer | None = None,
44 | ) -> str:
45 | attrs = attrs or JSONWidget.DEFAULT_ATTRS
46 | value = self.format_value(value)
47 | return super().render(name, value, attrs=attrs, renderer=renderer)
48 |
49 |
50 | class MultiEmailField(forms.Field):
51 | """Taken from https://docs.djangoproject.com/en/1.11/ref/forms/validation/#form-field-default-cleaning""" # noqa
52 |
53 | def to_python(self, value: list[str] | str | None) -> list[str]:
54 | """Normalize data to a list of strings."""
55 | if isinstance(value, list):
56 | return value
57 |
58 | if not value:
59 | return []
60 |
61 | return [v.strip() for v in value.split(",")]
62 |
63 | def validate(self, value: list[str]) -> None:
64 | """Check if value consists only of valid emails."""
65 | # Use the parent's handling of required fields, etc.
66 | super().validate(value)
67 | for email in value:
68 | validate_email(email)
69 |
70 |
71 | class MultiEmailTemplateField(forms.Field):
72 | """Convert comma-separated ids into EmailTemplate queryset."""
73 |
74 | def to_python(
75 | self, value: EmailTemplateQuerySet | str | None
76 | ) -> EmailTemplateQuerySet:
77 | """Normalize data to a queryset of EmailTemplates."""
78 | if isinstance(value, EmailTemplateQuerySet):
79 | return value
80 |
81 | if not value:
82 | return EmailTemplate.objects.none()
83 |
84 | values = [int(i) for i in value.split(",")]
85 | return EmailTemplate.objects.filter(pk__in=values)
86 |
87 |
88 | class EmailTestForm(forms.Form):
89 | """Renders email template on intermediate page."""
90 |
91 | from_email = forms.EmailField(
92 | label=_lazy("From"), help_text=_lazy("Email address to be used as the sender")
93 | )
94 | reply_to = MultiEmailField(
95 | label=_lazy("Reply-To"),
96 | help_text=_lazy("Comma separated list of email addresses"),
97 | )
98 | to = MultiEmailField(
99 | label=_lazy("To"), help_text=_lazy("Comma separated list of email addresses")
100 | )
101 | cc = MultiEmailField(
102 | label=_lazy("cc"),
103 | help_text=_lazy("Comma separated list of email addresses"),
104 | required=False,
105 | )
106 | bcc = MultiEmailField(
107 | label=_lazy("bcc"),
108 | help_text=_lazy("Comma separated list of email addresses"),
109 | required=False,
110 | )
111 | context = forms.CharField(
112 | widget=forms.Textarea,
113 | required=False,
114 | help_text=_lazy("JSON used to render the subject and body templates"),
115 | )
116 | # comma separated list of template ids.
117 | templates = MultiEmailTemplateField(widget=forms.HiddenInput())
118 |
119 | def clean_context(self) -> dict:
120 | """Load text input back into JSON."""
121 | context = self.cleaned_data["context"] or "{}"
122 | try:
123 | return json.loads(context)
124 | except (TypeError, ValueError) as ex:
125 | raise forms.ValidationError(_lazy("Invalid JSON: %s" % ex))
126 |
127 | def _create_message(self, template: EmailTemplate) -> AppmailMessage:
128 | """Create EmailMultiMessage from form data."""
129 | return AppmailMessage(
130 | template,
131 | self.cleaned_data["context"],
132 | from_email=self.cleaned_data["from_email"],
133 | to=self.cleaned_data["to"],
134 | cc=self.cleaned_data["cc"],
135 | bcc=self.cleaned_data["bcc"],
136 | )
137 |
138 | def send_emails(self, request: HttpRequest) -> None:
139 | """Send test emails."""
140 | for template in self.cleaned_data.get("templates"):
141 | email = self._create_message(template)
142 | try:
143 | email.send()
144 | except Exception as ex: # noqa: B902
145 | logger.exception("Error sending test email")
146 | messages.error(
147 | request,
148 | _lazy(
149 | "Error sending test email '{}': {}".format(template.name, ex)
150 | ),
151 | )
152 | else:
153 | messages.success(
154 | request,
155 | _lazy(
156 | "'{}' email sent to '{}'".format(
157 | template.name, ", ".join(email.to)
158 | )
159 | ),
160 | )
161 |
--------------------------------------------------------------------------------
/tests/test_views.py:
--------------------------------------------------------------------------------
1 | from unittest import mock
2 |
3 | from django.contrib.auth.models import AnonymousUser, User
4 | from django.test import RequestFactory, TestCase
5 | from django.urls import reverse
6 |
7 | from appmail import views
8 | from appmail.forms import EmailTestForm
9 | from appmail.models import EmailTemplate
10 |
11 |
12 | class ViewTests(TestCase):
13 | def setUp(self):
14 | # Every test needs access to the request factory.
15 | self.factory = RequestFactory()
16 | self.template = EmailTemplate(
17 | subject="ßello, {{user.first_name}}",
18 | body_text="ßello,\n{{user.first_name}}",
19 | body_html="ßello, {{user.first_name}}",
20 | ).save()
21 | self.subject_url = reverse(
22 | "appmail:render_template_subject", kwargs={"template_id": self.template.id}
23 | )
24 | self.body_text_url = reverse(
25 | "appmail:render_template_body_text",
26 | kwargs={"template_id": self.template.id},
27 | )
28 | self.body_html_url = reverse(
29 | "appmail:render_template_body_html",
30 | kwargs={"template_id": self.template.id},
31 | )
32 |
33 | def test_render_template_subject(self):
34 | template = self.template
35 | request = self.factory.get(self.subject_url)
36 | # check that non-staff are denied
37 | request.user = AnonymousUser()
38 | response = views.render_template_subject(request, template.id)
39 | self.assertEqual(response.status_code, 302)
40 | # non-staff user
41 | request.user = User()
42 | response = views.render_template_subject(request, template.id)
43 | self.assertEqual(response.status_code, 302)
44 | # staff user
45 | request.user.is_staff = True
46 | response = views.render_template_subject(request, template.id)
47 | self.assertEqual(response.status_code, 200)
48 | # should render the template with the dummy context
49 | self.assertEqual(
50 | response.content.decode("utf-8"),
51 | template.render_subject(template.test_context),
52 | )
53 |
54 | def test_render_template_body(self):
55 | template = self.template
56 | request = self.factory.get(self.body_text_url)
57 | # check that non-staff are denied
58 | request.user = AnonymousUser()
59 | response = views.render_template_body(
60 | request, template.id, EmailTemplate.CONTENT_TYPE_PLAIN
61 | )
62 | self.assertEqual(response.status_code, 302)
63 | # non-staff user
64 | request.user = User()
65 | response = views.render_template_body(
66 | request, template.id, EmailTemplate.CONTENT_TYPE_PLAIN
67 | )
68 | self.assertEqual(response.status_code, 302)
69 | # staff user
70 | request.user.is_staff = True
71 | response = views.render_template_body(
72 | request, template.id, EmailTemplate.CONTENT_TYPE_PLAIN
73 | )
74 | self.assertEqual(response.status_code, 200)
75 | # should render the template with the dummy context
76 | self.assertEqual(
77 | response.content.decode("utf-8"),
78 | template.render_body(
79 | template.test_context, EmailTemplate.CONTENT_TYPE_PLAIN
80 | ),
81 | )
82 | # now check the HTML version
83 | response = views.render_template_body(
84 | request, template.id, EmailTemplate.CONTENT_TYPE_HTML
85 | )
86 | self.assertEqual(
87 | response.content.decode("utf-8"),
88 | template.render_body(
89 | template.test_context, EmailTemplate.CONTENT_TYPE_HTML
90 | ),
91 | )
92 |
93 | # now check the HTML version
94 | response = views.render_template_body(request, template.id, "foo")
95 | self.assertEqual(response.status_code, 400)
96 | self.assertEqual(
97 | response.content.decode("utf-8"), "Invalid content_type specified."
98 | )
99 |
100 | def test_send_test_emails_GET(self):
101 | user = User.objects.create( # noqa: S106, E501
102 | username="admin", password="password", is_staff=True
103 | )
104 | template = self.template
105 | url = "{}?templates={}".format(reverse("appmail:send_test_email"), template.pk)
106 | response = self.client.get(url)
107 | self.assertEqual(response.status_code, 302)
108 | self.client.force_login(user)
109 | response = self.client.get(url)
110 | self.assertEqual(response.status_code, 200)
111 | self.assertContains(response, template.name)
112 |
113 | @mock.patch.object(EmailTestForm, "send_emails")
114 | def test_send_test_emails_POST(self, mock_send):
115 | user = User.objects.create( # noqa: S106, E501
116 | username="admin", password="password", is_staff=True
117 | )
118 | template = self.template
119 | url = "{}?templates={}".format(reverse("appmail:send_test_email"), template.pk)
120 | self.client.force_login(user)
121 | payload = {
122 | "to": "fred@example.com",
123 | "cc": "",
124 | "bcc": "",
125 | "context": "",
126 | "from_email": "donotreply@example.com",
127 | "reply_to": ["donotreply1@example.com"],
128 | "templates": template.pk,
129 | }
130 | response = self.client.post(url, payload)
131 | self.assertEqual(mock_send.call_count, 1)
132 | mock_send.assert_called_once_with(response.wsgi_request)
133 | self.assertEqual(response.status_code, 302)
134 |
135 | # check that bad response returns 422
136 | response = self.client.post(url, {})
137 | self.assertEqual(mock_send.call_count, 1)
138 | self.assertEqual(response.status_code, 422)
139 |
--------------------------------------------------------------------------------
/appmail/admin.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import json
4 |
5 | from django.contrib import admin, messages
6 | from django.core.exceptions import ValidationError
7 | from django.db.models import JSONField
8 | from django.db.models.query import QuerySet
9 | from django.http import HttpRequest, HttpResponseRedirect
10 | from django.template.defaultfilters import truncatechars
11 | from django.urls import reverse
12 | from django.utils.html import format_html
13 | from django.utils.safestring import mark_safe
14 | from django.utils.translation import gettext_lazy as _lazy
15 |
16 | from .forms import JSONWidget
17 | from .models import EmailTemplate, LoggedMessage
18 |
19 |
20 | class ValidTemplateListFilter(admin.SimpleListFilter):
21 | """Filter on whether the template can be rendered or not."""
22 |
23 | title = _lazy("Is valid")
24 | parameter_name = "valid"
25 |
26 | def lookups(
27 | self, request: HttpRequest, model_admin: admin.ModelAdmin
28 | ) -> tuple[tuple[str, str], tuple[str, str]]:
29 | """
30 | Return valid template True/False filter values tuples.
31 |
32 | The first element in each tuple is the coded value for the option
33 | that will appear in the URL query. The second element is the
34 | human-readable name for the option that will appear
35 | in the right sidebar.
36 |
37 | """
38 | return (("1", _lazy("True")), ("0", _lazy("False")))
39 |
40 | def queryset(self, request: HttpRequest, queryset: QuerySet) -> QuerySet:
41 | """
42 | Return the filtered queryset.
43 |
44 | Filter based on the value provided in the query string and
45 | retrievable via `self.value()`.
46 |
47 | """
48 | valid_ids = []
49 | invalid_ids = []
50 |
51 | if not self.value():
52 | # By default, the lookup is not run at all because it is
53 | # computationally expensive to render the entire list of
54 | # emails on every page load.
55 | return
56 |
57 | for obj in queryset:
58 | try:
59 | obj.clean()
60 | valid_ids.append(obj.pk)
61 | except ValidationError:
62 | invalid_ids.append(obj.pk)
63 |
64 | if self.value() == "1":
65 | return queryset.filter(pk__in=valid_ids)
66 | if self.value() == "0":
67 | return queryset.filter(pk__in=invalid_ids)
68 |
69 |
70 | class AdminBase(admin.ModelAdmin):
71 | def iframe(self, url: str) -> str:
72 | """Return an iframe containing the url for display in change view."""
73 | return format_html(
74 | f""
75 | f"
View in new tab."
76 | )
77 |
78 | def pretty_print(self, data: dict | None) -> str:
79 | """Convert dict into formatted HTML."""
80 | if data is None:
81 | return "(None)"
82 | pretty = json.dumps(data, sort_keys=True, indent=4, separators=(",", ": "))
83 | html = pretty.replace(" ", " ").replace("\n", "
")
84 | return mark_safe("%s
" % html) # noqa: S703,S308
85 |
86 |
87 | @admin.register(EmailTemplate)
88 | class EmailTemplateAdmin(AdminBase):
89 | formfield_overrides = {JSONField: {"widget": JSONWidget}}
90 |
91 | list_display = (
92 | "name",
93 | "subject",
94 | "language",
95 | "version",
96 | "has_text",
97 | "has_html",
98 | "is_valid",
99 | "is_active",
100 | )
101 |
102 | list_filter = ("language", "version", ValidTemplateListFilter, "is_active")
103 |
104 | readonly_fields = ("render_subject", "render_text", "render_html")
105 |
106 | search_fields = ("name", "subject")
107 |
108 | actions = (
109 | "activate_templates",
110 | "deactivate_templates",
111 | "clone_templates",
112 | "send_test_emails",
113 | )
114 |
115 | fieldsets = (
116 | (
117 | "Basic Information",
118 | {"fields": ("name", "description", "language", "version", "is_active")},
119 | ),
120 | ("Email Defaults", {"fields": ("from_email", "reply_to")}),
121 | ("Templates", {"fields": ("subject", "body_text", "body_html")}),
122 | (
123 | "Sample Output",
124 | {
125 | "fields": (
126 | "test_context",
127 | "render_subject",
128 | "render_text",
129 | "render_html",
130 | )
131 | },
132 | ),
133 | )
134 |
135 | # these functions are here rather than on the model so that we can get the
136 | # boolean icon.
137 | def has_text(self, obj: EmailTemplate) -> bool:
138 | return len(obj.body_text or "") > 0
139 |
140 | has_text.boolean = True # type: ignore
141 |
142 | def has_html(self, obj: EmailTemplate) -> bool:
143 | return len(obj.body_html or "") > 0
144 |
145 | has_html.boolean = True # type: ignore
146 |
147 | def is_valid(self, obj: EmailTemplate) -> bool:
148 | """Return True if the template can be rendered."""
149 | try:
150 | obj.clean()
151 | return True
152 | except ValidationError:
153 | return False
154 |
155 | is_valid.boolean = True # type: ignore
156 |
157 | def render_subject(self, obj: EmailTemplate) -> str:
158 | if obj.id is None:
159 | url = ""
160 | else:
161 | url = reverse(
162 | "appmail:render_template_subject", kwargs={"template_id": obj.id}
163 | )
164 | return self.iframe(url)
165 |
166 | render_subject.short_description = "Rendered subject" # type: ignore
167 | render_subject.allow_tags = True # type: ignore
168 |
169 | def render_text(self, obj: EmailTemplate) -> str:
170 | if obj.id is None:
171 | url = ""
172 | else:
173 | url = reverse(
174 | "appmail:render_template_body_text", kwargs={"template_id": obj.id}
175 | )
176 | return self.iframe(url)
177 |
178 | render_text.short_description = "Rendered body (plain)" # type: ignore
179 | render_text.allow_tags = True # type: ignore
180 |
181 | def render_html(self, obj: EmailTemplate) -> str:
182 | if obj.id is None:
183 | url = ""
184 | else:
185 | url = reverse(
186 | "appmail:render_template_body_html", kwargs={"template_id": obj.id}
187 | )
188 | return self.iframe(url)
189 |
190 | render_html.short_description = "Rendered body (html)" # type: ignore
191 | render_html.allow_tags = True # type: ignore
192 |
193 | def send_test_emails(
194 | self, request: HttpRequest, queryset: QuerySet
195 | ) -> HttpResponseRedirect:
196 | selected = ",".join([str(s) for s in queryset.values_list("id", flat=True)])
197 | url = "{}?templates={}".format(reverse("appmail:send_test_email"), selected)
198 | return HttpResponseRedirect(url)
199 |
200 | send_test_emails.short_description = _lazy( # type: ignore
201 | "Send test email for selected templates"
202 | )
203 |
204 | def clone_templates(
205 | self, request: HttpRequest, queryset: QuerySet
206 | ) -> HttpResponseRedirect:
207 | for template in queryset:
208 | template.clone()
209 | messages.success(request, _lazy("Cloned template '%s'" % template.name))
210 | return HttpResponseRedirect(request.path)
211 |
212 | clone_templates.short_description = _lazy( # type: ignore
213 | "Clone selected email templates"
214 | )
215 |
216 | def activate_templates(
217 | self, request: HttpRequest, queryset: QuerySet
218 | ) -> HttpResponseRedirect:
219 | count = queryset.update(is_active=True)
220 | messages.success(request, _lazy("Activated %s templates" % count))
221 | return HttpResponseRedirect(request.path)
222 |
223 | activate_templates.short_description = _lazy( # type: ignore
224 | "Activate selected email templates"
225 | )
226 |
227 | def deactivate_templates(
228 | self, request: HttpRequest, queryset: QuerySet
229 | ) -> HttpResponseRedirect:
230 | count = queryset.update(is_active=False)
231 | messages.success(request, _lazy("Deactivated %s templates" % count))
232 | return HttpResponseRedirect(request.path)
233 |
234 | deactivate_templates.short_description = _lazy( # type: ignore
235 | "Deactivate selected email templates"
236 | )
237 |
238 |
239 | class TemplateNameListFilter(admin.SimpleListFilter):
240 | """
241 | Provide a list of template names to filter by.
242 |
243 | This is more efficient than providing `template__name` to list_filter
244 | because that would ask the question "which templates are in use for
245 | this set of logged messages" which requires a join and is thus slower
246 | over a large result set. This instead asks the question "which templates
247 | are even available" which is much faster.
248 | """
249 |
250 | title = _lazy("Template name")
251 | parameter_name = "template_name"
252 | template = "admin/appmail/loggedmessage/template_name_filter.html"
253 |
254 | def lookups(
255 | self, request: HttpRequest, model_admin: admin.ModelAdmin
256 | ) -> tuple[tuple[str, str], ...]:
257 | templates = (
258 | EmailTemplate.objects.values_list("name", flat=True)
259 | .distinct()
260 | .order_by("name")
261 | )
262 | return tuple((name, name) for name in templates)
263 |
264 | def queryset(self, request: HttpRequest, queryset: QuerySet) -> QuerySet:
265 | """
266 | Return the filtered queryset.
267 |
268 | Filter based on the value provided in the query string and
269 | retrievable via `self.value()`.
270 |
271 | """
272 | if value := self.value():
273 | return queryset.filter(template__name__exact=value)
274 |
275 | return queryset
276 |
277 |
278 | class TemplateLanguageListFilter(admin.SimpleListFilter):
279 | """
280 | Provide a list of template languages to filter by.
281 |
282 | This is more efficient than providing `template__language` to list_filter
283 | because that would ask the question "which template languages are in use
284 | for this set of logged messages" which requires a join and is thus slower
285 | over a large result set. This instead asks the question "which templates
286 | languages are even available" which is much faster.
287 | """
288 |
289 | title = _lazy("Template language")
290 | parameter_name = "template_language"
291 |
292 | def lookups(
293 | self, request: HttpRequest, model_admin: admin.ModelAdmin
294 | ) -> tuple[tuple[str, str], ...]:
295 | templates = (
296 | EmailTemplate.objects.values_list("language", flat=True)
297 | .distinct()
298 | .order_by("language")
299 | )
300 | return tuple((lang, lang) for lang in templates)
301 |
302 | def queryset(self, request: HttpRequest, queryset: QuerySet) -> QuerySet:
303 | """
304 | Return the filtered queryset.
305 |
306 | Filter based on the value provided in the query string and
307 | retrievable via `self.value()`.
308 |
309 | """
310 | if value := self.value():
311 | return queryset.filter(template__language__exact=value)
312 |
313 | return queryset
314 |
315 |
316 | @admin.register(LoggedMessage)
317 | class LoggedMessageAdmin(AdminBase):
318 | exclude = ("html", "context")
319 |
320 | formfield_overrides = {JSONField: {"widget": JSONWidget}}
321 |
322 | list_display = ("to", "template_name", "_subject", "timestamp")
323 |
324 | list_select_related = ("template",)
325 |
326 | list_filter = ("timestamp", TemplateNameListFilter, TemplateLanguageListFilter)
327 |
328 | raw_id_fields = ("user", "template")
329 |
330 | readonly_fields = (
331 | "to",
332 | "user",
333 | "template",
334 | "template_context",
335 | "subject",
336 | "body",
337 | "render_html",
338 | "timestamp",
339 | )
340 |
341 | ordering = ("-timestamp",)
342 |
343 | # If you update this, ensure the indexes are adjusted
344 | # on the model and performance is taken into account.
345 | search_fields = ("to", "subject")
346 |
347 | def _subject(self, obj: LoggedMessage) -> str:
348 | """Truncate the subject for display."""
349 | return truncatechars(obj.subject, 50)
350 |
351 | def template_name(self, obj: LoggedMessage) -> str:
352 | return obj.template.name if obj.template else ""
353 |
354 | def template_context(self, obj: LoggedMessage) -> str:
355 | """Pretty print version of the template context dict."""
356 | return self.pretty_print(obj.context)
357 |
358 | def render_html(self, obj: LoggedMessage) -> str:
359 | if obj.id is None:
360 | url = ""
361 | else:
362 | url = reverse(
363 | "appmail:render_message_body_html", kwargs={"email_id": obj.id}
364 | )
365 | return self.iframe(url)
366 |
367 | render_html.short_description = "HTML (rendered)" # type: ignore
368 | render_html.allow_tags = True # type: ignore
369 |
--------------------------------------------------------------------------------
/tests/test_models.py:
--------------------------------------------------------------------------------
1 | from email.mime.image import MIMEImage
2 | from unittest import mock
3 |
4 | import pytest
5 | from django.conf import settings
6 | from django.core import mail
7 | from django.core.exceptions import ValidationError
8 | from django.core.mail import EmailMultiAlternatives
9 | from django.template import TemplateDoesNotExist, TemplateSyntaxError
10 | from django.test import TestCase
11 | from django.urls import NoReverseMatch
12 |
13 | from appmail.models import (
14 | AppmailMessage,
15 | EmailTemplate,
16 | LoggedMessage,
17 | LoggedMessageManager,
18 | )
19 |
20 |
21 | @pytest.fixture
22 | def appmail_message(scope="class"):
23 | """Pytest fixture that creates a valid AppmailMessage."""
24 | template = EmailTemplate.objects.create(
25 | subject="Welcome message",
26 | body_text="Hello {{ first_name }}",
27 | body_html="Hello {{ first_name }}
",
28 | )
29 | context = {"first_name": "fr¡da"}
30 | return AppmailMessage(template, context, to=["fred@example.com"])
31 |
32 |
33 | class EmailTemplateQuerySetTests(TestCase):
34 | def test_active(self):
35 | template1 = EmailTemplate(name="test1", language="en-us").save()
36 | _ = EmailTemplate(name="test2", language="en-us", is_active=False).save()
37 | self.assertEqual(EmailTemplate.objects.active().get(), template1)
38 |
39 | def test_current(self):
40 | # manually setting the version in the wrong order, so the first
41 | # template is actually the last, when ordered by version.
42 | template1 = EmailTemplate(name="test", language="en-us", version=1).save()
43 | _ = EmailTemplate(name="test", language="en-us", version=0).save()
44 | self.assertEqual(EmailTemplate.objects.current("test"), template1)
45 | self.assertEqual(
46 | EmailTemplate.objects.current("test", language="klingon"), None
47 | )
48 |
49 | def test_version(self):
50 | template1 = EmailTemplate(name="test", language="en-us", version=1).save()
51 | template2 = EmailTemplate(name="test", language="en-us", version=0).save()
52 | self.assertEqual(EmailTemplate.objects.version("test", 1), template1)
53 | self.assertEqual(EmailTemplate.objects.version("test", 0), template2)
54 |
55 |
56 | class EmailTemplateTests(TestCase):
57 | """appmail.models.EmailTemplate model tests."""
58 |
59 | def test_defaults(self):
60 | template = EmailTemplate()
61 | self.assertEqual(template.language, settings.LANGUAGE_CODE)
62 | self.assertEqual(template.from_email, settings.DEFAULT_FROM_EMAIL)
63 | self.assertEqual(template.reply_to, settings.DEFAULT_FROM_EMAIL)
64 | self.assertEqual(template.reply_to_list, [settings.DEFAULT_FROM_EMAIL])
65 | self.assertEqual(template.version, 0)
66 |
67 | template.reply_to = "fred@example.com, ginger@example.com"
68 | self.assertEqual(
69 | template.reply_to_list, ["fred@example.com", "ginger@example.com"]
70 | )
71 |
72 | @mock.patch.object(EmailTemplate, "clean")
73 | def test_save(self, mock_clean):
74 | template = EmailTemplate(
75 | subject="test ßmail",
76 | body_text="this is plain text",
77 | body_html="this is html",
78 | )
79 | with mock.patch("appmail.models.VALIDATE_ON_SAVE", False):
80 | template.save()
81 | self.assertEqual(mock_clean.call_count, 0)
82 | with mock.patch("appmail.models.VALIDATE_ON_SAVE", True):
83 | template.save()
84 | self.assertEqual(mock_clean.call_count, 1)
85 | # test the override
86 | template.save(validate=False)
87 | self.assertEqual(mock_clean.call_count, 1)
88 |
89 | @mock.patch.object(EmailTemplate, "render_subject")
90 | def test__validate_subject(self, mock_render):
91 | template = EmailTemplate()
92 | mock_render.side_effect = TemplateDoesNotExist("foo.html")
93 | self.assertEqual(
94 | template._validate_subject(),
95 | {"subject": "Template does not exist: foo.html"},
96 | )
97 | mock_render.side_effect = TemplateSyntaxError("No can do")
98 | self.assertEqual(template._validate_subject(), {"subject": "No can do"})
99 | mock_render.side_effect = None
100 | self.assertEqual(template._validate_subject(), {})
101 | mock_render.side_effect = Exception("Something else")
102 | self.assertRaises(Exception, template._validate_subject)
103 |
104 | @mock.patch.object(EmailTemplate, "render_body")
105 | def test__validate_body__success(self, mock_render):
106 | template = EmailTemplate()
107 | mock_render.side_effect = None
108 | self.assertEqual(template._validate_body(EmailTemplate.CONTENT_TYPE_HTML), {})
109 |
110 | @mock.patch.object(EmailTemplate, "render_body")
111 | def test__validate_body__template_does_not_exist(self, mock_render):
112 | template = EmailTemplate()
113 | mock_render.side_effect = TemplateDoesNotExist("foo.html")
114 | self.assertEqual(
115 | template._validate_body(content_type=EmailTemplate.CONTENT_TYPE_PLAIN),
116 | {"body_text": "Template does not exist: foo.html"},
117 | )
118 |
119 | @mock.patch.object(EmailTemplate, "render_body")
120 | def test__validate_body__template_syntax_error(self, mock_render):
121 | template = EmailTemplate()
122 | mock_render.side_effect = TemplateSyntaxError("No can do")
123 | self.assertEqual(
124 | template._validate_body(content_type=EmailTemplate.CONTENT_TYPE_HTML),
125 | {"body_html": "No can do"},
126 | )
127 |
128 | @mock.patch.object(EmailTemplate, "render_body")
129 | def test__validate_body__url_no_reverse_match(self, mock_render):
130 | template = EmailTemplate()
131 | mock_render.side_effect = NoReverseMatch("Reverse for 'briefs' not found.")
132 | self.assertEqual(
133 | template._validate_body(content_type=EmailTemplate.CONTENT_TYPE_HTML),
134 | {"body_html": "Reverse for 'briefs' not found."},
135 | )
136 |
137 | @mock.patch.object(EmailTemplate, "render_body")
138 | def test__validate_body__unhandled_exception(self, mock_render):
139 | template = EmailTemplate()
140 | mock_render.side_effect = Exception("Something else")
141 | self.assertRaises(Exception, template._validate_body)
142 |
143 | @mock.patch.object(EmailTemplate, "_validate_body")
144 | def test_clean(self, mock_body):
145 | template = EmailTemplate()
146 | template.clean()
147 | mock_body.return_value = {"body_text": "Template not found"}
148 | self.assertRaises(ValidationError, template.clean)
149 |
150 | def test_render_subject(self):
151 | template = EmailTemplate(subject="Hello {{ first_name }}")
152 | subject = template.render_subject({"first_name": "fråd"})
153 | self.assertEqual(subject, "Hello fråd")
154 |
155 | def test_render_body(self):
156 | template = EmailTemplate(
157 | body_text="Hello {{ first_name }}",
158 | body_html="Hello {{ first_name }}
",
159 | )
160 | context = {"first_name": "fråd"}
161 | self.assertEqual(template.render_body(context), "Hello fråd")
162 | self.assertEqual(
163 | template.render_body(
164 | context, content_type=EmailTemplate.CONTENT_TYPE_PLAIN
165 | ),
166 | "Hello fråd",
167 | )
168 | self.assertEqual(
169 | template.render_body(context, content_type=EmailTemplate.CONTENT_TYPE_HTML),
170 | "Hello fråd
",
171 | )
172 | self.assertRaises(ValueError, template.render_body, context, content_type="foo")
173 |
174 | def test_clone_template(self):
175 | template = EmailTemplate(
176 | name="Test template", language="en-us", version=0
177 | ).save()
178 | pk = template.pk
179 | clone = template.clone()
180 | template = EmailTemplate.objects.get(id=pk)
181 | self.assertEqual(clone.name, template.name)
182 | self.assertEqual(clone.language, template.language)
183 | self.assertEqual(clone.version, 1)
184 | self.assertNotEqual(clone.id, template.id)
185 |
186 |
187 | class AppmailMessageTests(TestCase):
188 | def test_init(self):
189 | template = EmailTemplate(
190 | subject="Welcome message",
191 | body_text="Hello {{ first_name }}",
192 | body_html="Hello {{ first_name }}
",
193 | )
194 | context = {"first_name": "fråd"}
195 | message = AppmailMessage(template, context)
196 | self.assertIsInstance(message, EmailMultiAlternatives)
197 | self.assertEqual(message.subject, "Welcome message")
198 | self.assertEqual(message.body, "Hello fråd")
199 | self.assertEqual(
200 | message.alternatives,
201 | [("Hello fråd
", EmailTemplate.CONTENT_TYPE_HTML)],
202 | )
203 | self.assertEqual(message.to, [])
204 | self.assertEqual(message.cc, [])
205 | self.assertEqual(message.bcc, [])
206 | self.assertEqual(message.from_email, settings.DEFAULT_FROM_EMAIL)
207 | self.assertEqual(message.reply_to, [settings.DEFAULT_FROM_EMAIL])
208 |
209 | message = AppmailMessage(
210 | template,
211 | context,
212 | to=["bruce@kung.fu"],
213 | cc=["fred@example.com"],
214 | bcc=["ginger@example.com"],
215 | from_email="Fred ",
216 | )
217 | self.assertEqual(message.to, ["bruce@kung.fu"])
218 | self.assertEqual(message.cc, ["fred@example.com"])
219 | self.assertEqual(message.bcc, ["ginger@example.com"])
220 | self.assertEqual(message.from_email, "Fred ")
221 | # and so on - not going to test every property.
222 |
223 | # but we will check the three illegal kwargs
224 | with self.assertRaisesMessage(
225 | ValueError, "Invalid argument: 'subject' is set from the template."
226 | ):
227 | AppmailMessage(template, {}, subject="foo")
228 | with self.assertRaisesMessage(
229 | ValueError, "Invalid argument: 'body' is set from the template."
230 | ):
231 | AppmailMessage(template, {}, body="foo")
232 | with self.assertRaisesMessage(
233 | ValueError, "Invalid argument: 'alternatives' is set from the template."
234 | ):
235 | AppmailMessage(template, {}, alternatives="foo")
236 |
237 | def test_init__with_attachments__allowed(self):
238 | template = EmailTemplate(
239 | subject="Welcome {{ first_name }}",
240 | body_text="Hello {{ first_name }}",
241 | body_html="Hello {{ first_name }}
",
242 | supports_attachments=True,
243 | )
244 | AppmailMessage(template, {}, attachments=[mock.Mock(spec=MIMEImage)])
245 |
246 | def test_init__with_attachments__disallowed(self):
247 | template = EmailTemplate(
248 | subject="Welcome {{ first_name }}",
249 | body_text="Hello {{ first_name }}",
250 | body_html="Hello {{ first_name }}
",
251 | supports_attachments=False,
252 | )
253 | with self.assertRaisesMessage(
254 | ValueError, "Email template does not support attachments."
255 | ):
256 | AppmailMessage(template, {}, attachments=[mock.Mock(spec=MIMEImage)])
257 |
258 | def test_init__special_characters(self):
259 | template = EmailTemplate(
260 | subject="Welcome {{ first_name }}",
261 | body_text="Hello {{ first_name }}",
262 | body_html="Hello {{ first_name }}
",
263 | )
264 |
265 | context = {"first_name": "Test & Company"}
266 | message = AppmailMessage(template, context)
267 | self.assertIsInstance(message, EmailMultiAlternatives)
268 | self.assertEqual(message.subject, "Welcome Test & Company")
269 | self.assertEqual(message.body, "Hello Test & Company")
270 | self.assertEqual(
271 | message.alternatives,
272 | [("Hello Test & Company
", EmailTemplate.CONTENT_TYPE_HTML)],
273 | )
274 |
275 | def test_init__special_characters__complex_context(self):
276 | template = EmailTemplate(
277 | subject="Hello {{ user.first_name }} and welcome to {{ company_name }}",
278 | body_text="Hello {{ user.first_name }} and welcome to {{ company_name }}",
279 | body_html=(
280 | "Hello {{ user.first_name }}
"
281 | "Welcome to {{ company_name }}
"
282 | ),
283 | )
284 | context = {
285 | "user": {"first_name": "Test & Company"},
286 | "company_name": "Me & Co Inc",
287 | }
288 | message = AppmailMessage(template, context)
289 | self.assertIsInstance(message, EmailMultiAlternatives)
290 | self.assertEqual(
291 | message.subject, "Hello Test & Company and welcome to Me & Co Inc"
292 | )
293 | self.assertEqual(
294 | message.body, "Hello Test & Company and welcome to Me & Co Inc"
295 | )
296 | self.assertEqual(
297 | message.alternatives,
298 | [
299 | (
300 | "Hello Test & Company
"
301 | "Welcome to Me & Co Inc
",
302 | EmailTemplate.CONTENT_TYPE_HTML,
303 | )
304 | ],
305 | )
306 |
307 | @mock.patch.object(LoggedMessageManager, "log")
308 | def test_send__logging(self, mock_log):
309 | template = EmailTemplate.objects.create(
310 | subject="Welcome message",
311 | body_text="Hello {{ first_name }}",
312 | body_html="Hello {{ first_name }}
",
313 | )
314 | context = {"first_name": "fråd"}
315 | message = AppmailMessage(template, context, to=["fred@example.com"])
316 | message.send()
317 | assert mock_log.call_count == 1
318 |
319 | @mock.patch.object(LoggedMessageManager, "log")
320 | def test_send__no_logging(self, mock_log):
321 | template = EmailTemplate.objects.create(
322 | subject="Welcome message",
323 | body_text="Hello {{ first_name }}",
324 | body_html="Hello {{ first_name }}
",
325 | )
326 | context = {"first_name": "fråd"}
327 | message = AppmailMessage(template, context, to=["fred@example.com"])
328 | message.send(log_sent_emails=False)
329 | assert mock_log.call_count == 0
330 |
331 |
332 | @pytest.mark.django_db
333 | class TestLoggedMessage:
334 | def test_template_name(self):
335 | template = EmailTemplate(name="foo")
336 | message = LoggedMessage(template=template)
337 | assert message.template_name == "foo"
338 |
339 | def test_rehydrate(self, appmail_message):
340 | appmail_message.send(log_sent_emails=True, fail_silently=False)
341 | logged = LoggedMessage.objects.get()
342 | message2 = logged.rehydrate()
343 | assert message2.template == appmail_message.template
344 | assert message2.context == appmail_message.context
345 |
346 | def test_resend(self, appmail_message):
347 | appmail_message.send(log_sent_emails=True, fail_silently=False)
348 | assert len(mail.outbox) == 1
349 | logged = LoggedMessage.objects.get()
350 | logged.resend()
351 | assert len(mail.outbox) == 2
352 | assert LoggedMessage.objects.count() == 2
353 |
--------------------------------------------------------------------------------
/appmail/models.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import Any, Callable
4 |
5 | from django.conf import settings
6 | from django.contrib.auth import get_user_model
7 | from django.core.exceptions import ValidationError
8 | from django.core.mail import EmailMultiAlternatives
9 | from django.core.serializers.json import DjangoJSONEncoder
10 | from django.db import models, transaction
11 | from django.http import HttpRequest
12 | from django.template import Context, Template, TemplateDoesNotExist, TemplateSyntaxError
13 | from django.urls import NoReverseMatch
14 | from django.utils.timezone import now as tz_now
15 | from django.utils.translation import gettext as _
16 | from django.utils.translation import gettext_lazy as _lazy
17 |
18 | from . import helpers
19 | from .settings import (
20 | ADD_EXTRA_HEADERS,
21 | CONTEXT_PROCESSORS,
22 | LOG_SENT_EMAILS,
23 | VALIDATE_ON_SAVE,
24 | )
25 |
26 | User = get_user_model()
27 |
28 |
29 | class EmailTemplateQuerySet(models.query.QuerySet):
30 | def active(self) -> EmailTemplateQuerySet:
31 | """Return active templates only."""
32 | return self.filter(is_active=True)
33 |
34 | def current(
35 | self, name: str, language: str = settings.LANGUAGE_CODE
36 | ) -> EmailTemplateQuerySet:
37 | """Return the latest version of a template."""
38 | return (
39 | self.active()
40 | .filter(name=name, language=language)
41 | .order_by("version")
42 | .last()
43 | )
44 |
45 | def version(
46 | self, name: str, version: str, language: str = settings.LANGUAGE_CODE
47 | ) -> EmailTemplate:
48 | """Return a specific version of a template."""
49 | return self.active().get(name=name, language=language, version=version)
50 |
51 |
52 | class EmailTemplate(models.Model):
53 | """
54 | Email template. Contains HTML and plain text variants.
55 |
56 | Each Template object has a unique name:language.version combination, which
57 | means that localisation of templates is managed through having multiple
58 | objects with the same name - there is no inheritence model. This is to
59 | keep it simple:
60 |
61 | order-confirmation:en.0
62 | order-confirmation:de.0
63 | order-confirmation:fr.0
64 |
65 | Templates contain HTML and plain text content.
66 |
67 | """
68 |
69 | CONTENT_TYPE_PLAIN = "text/plain"
70 | CONTENT_TYPE_HTML = "text/html"
71 | CONTENT_TYPES = (CONTENT_TYPE_PLAIN, CONTENT_TYPE_HTML)
72 |
73 | name = models.CharField(
74 | _lazy("Template name"),
75 | max_length=100,
76 | help_text=_lazy(
77 | "Template name - must be unique for a given language/version combination."
78 | ),
79 | db_index=True,
80 | )
81 | description = models.CharField(
82 | _lazy("Description"),
83 | max_length=100,
84 | help_text=_lazy(
85 | "Optional description. e.g. used to differentiate variants ('new header')."
86 | ), # noqa
87 | blank=True,
88 | )
89 | # language is free text and not a choices field as we make no assumption
90 | # as to how the end user is storing / managing languages.
91 | language = models.CharField(
92 | _lazy("Language"),
93 | max_length=20,
94 | default=settings.LANGUAGE_CODE,
95 | help_text=_lazy(
96 | "Used to support localisation of emails, defaults to "
97 | "`settings.LANGUAGE_CODE`, but can be any string, e.g. 'London', 'NYC'."
98 | ),
99 | db_index=True,
100 | )
101 | version = models.IntegerField(
102 | _lazy("Version (or variant)"),
103 | default=0,
104 | help_text=_lazy("Integer value - can be used for versioning or A/B testing."),
105 | db_index=True,
106 | )
107 | subject = models.CharField(
108 | _lazy("Subject line template"),
109 | max_length=100,
110 | help_text=_lazy("Email subject line (may contain template variables)."),
111 | )
112 | body_text = models.TextField(
113 | _lazy("Plain text template"),
114 | help_text=_lazy("Plain text content (may contain template variables)."),
115 | )
116 | body_html = models.TextField(
117 | _lazy("HTML template"),
118 | help_text=_lazy("HTML content (may contain template variables)."),
119 | )
120 | test_context = models.JSONField(
121 | default=dict,
122 | blank=True,
123 | help_text=_lazy(
124 | "Dummy JSON used for test rendering (set automatically on first save)."
125 | ),
126 | )
127 | is_active = models.BooleanField(
128 | _lazy("Active (live)"),
129 | help_text=_lazy("Set to False to remove from `current` queryset."),
130 | default=True,
131 | )
132 | from_email = models.CharField(
133 | _lazy("Sender"),
134 | max_length=254,
135 | help_text=_lazy(
136 | "Default sender address if none specified. Verbose form is accepted."
137 | ),
138 | default=settings.DEFAULT_FROM_EMAIL,
139 | )
140 | reply_to = models.CharField(
141 | _lazy("Reply-To"),
142 | max_length=254,
143 | help_text=_lazy("Comma separated list of Reply-To recipients."),
144 | default=settings.DEFAULT_FROM_EMAIL,
145 | )
146 | supports_attachments = models.BooleanField(
147 | _lazy("Supports attachments"),
148 | default=False,
149 | help_text=_lazy("Does this template support file attachments?"),
150 | )
151 |
152 | objects = EmailTemplateQuerySet().as_manager()
153 |
154 | class Meta:
155 | unique_together = ("name", "language", "version")
156 |
157 | def __str__(self) -> str:
158 | return f"{self.name} (language={self.language}; version={self.version})"
159 |
160 | def __repr__(self) -> str:
161 | return (
162 | f""
164 | )
165 |
166 | @property
167 | def extra_headers(self) -> dict[str, str]:
168 | return {
169 | "X-Appmail-Template": (
170 | f"name={self.name}; language={self.language}; version={self.version}"
171 | )
172 | }
173 |
174 | @property
175 | def reply_to_list(self) -> list[str]:
176 | """Convert the reply_to field to a list."""
177 | return [a.strip() for a in self.reply_to.split(",")]
178 |
179 | def save(self, *args: Any, **kwargs: Any) -> EmailTemplate:
180 | """
181 | Update dummy context on first save and validate template contents.
182 |
183 | Kwargs:
184 | validate: set to False to bypass template validation; defaults
185 | to settings.VALIDATE_ON_SAVE.
186 |
187 | """
188 | if self.pk is None:
189 | self.test_context = helpers.get_context(
190 | self.subject + self.body_text + self.body_html
191 | )
192 | validate = kwargs.pop("validate", VALIDATE_ON_SAVE)
193 | if validate:
194 | self.clean()
195 | super(EmailTemplate, self).save(*args, **kwargs)
196 | return self
197 |
198 | def clean(self) -> None:
199 | """Validate model - specifically that the template can be rendered."""
200 | validation_errors = {}
201 | validation_errors.update(self._validate_body(EmailTemplate.CONTENT_TYPE_PLAIN))
202 | validation_errors.update(self._validate_body(EmailTemplate.CONTENT_TYPE_HTML))
203 | validation_errors.update(self._validate_subject())
204 | if validation_errors:
205 | raise ValidationError(validation_errors)
206 |
207 | def render_subject(
208 | self,
209 | context: dict,
210 | processors: list[Callable[[HttpRequest], dict]] = CONTEXT_PROCESSORS,
211 | ) -> str:
212 | """Render subject line."""
213 | ctx = Context(helpers.patch_context(context, processors), autoescape=False)
214 | return Template(self.subject).render(ctx)
215 |
216 | def _validate_subject(self) -> dict[str, str]:
217 | """Try rendering the body template and capture any errors."""
218 | try:
219 | self.render_subject({})
220 | except TemplateDoesNotExist as ex:
221 | return {"subject": _lazy("Template does not exist: {}".format(ex))}
222 | except TemplateSyntaxError as ex:
223 | return {"subject": str(ex)}
224 | else:
225 | return {}
226 |
227 | def render_body(
228 | self,
229 | context: dict,
230 | content_type: str = CONTENT_TYPE_PLAIN,
231 | processors: list[Callable[[HttpRequest], dict]] = CONTEXT_PROCESSORS,
232 | ) -> str:
233 | """Render email body in plain text or HTML format."""
234 | if content_type not in EmailTemplate.CONTENT_TYPES:
235 | raise ValueError(_(f"Invalid content type. Value supplied: {content_type}"))
236 | if content_type == EmailTemplate.CONTENT_TYPE_PLAIN:
237 | ctx = Context(helpers.patch_context(context, processors), autoescape=False)
238 | return Template(self.body_text).render(ctx)
239 | if content_type == EmailTemplate.CONTENT_TYPE_HTML:
240 | ctx = Context(helpers.patch_context(context, processors))
241 | return Template(self.body_html).render(ctx)
242 | raise ValueError(f"Invalid content_type '{content_type}'.")
243 |
244 | def _validate_body(self, content_type: str) -> dict[str, str]:
245 | """Try rendering the body template and capture any errors."""
246 | if content_type == EmailTemplate.CONTENT_TYPE_PLAIN:
247 | field_name = "body_text"
248 | elif content_type == EmailTemplate.CONTENT_TYPE_HTML:
249 | field_name = "body_html"
250 | else:
251 | raise ValueError("Invalid template content_type.")
252 | try:
253 | self.render_body({}, content_type=content_type)
254 | except TemplateDoesNotExist as ex:
255 | return {field_name: _("Template does not exist: {}".format(ex))}
256 | except (TemplateSyntaxError, NoReverseMatch) as ex:
257 | return {field_name: str(ex)}
258 | else:
259 | return {}
260 |
261 | def clone(self) -> EmailTemplate:
262 | """Create a copy of the current object, increase version by 1."""
263 | self.pk = None
264 | self.version += 1
265 | self.is_active = False
266 | return self.save()
267 |
268 |
269 | class AppmailMessage(EmailMultiAlternatives):
270 | """
271 | Subclass EmailMultiAlternatives to be template-aware.
272 |
273 | This class behaves like a standard Django EmailMultiAlternatives
274 | email, but with the subject, body and 'text/html' alternative set
275 | from the template + context.
276 |
277 | The send method is overridden to enable the saving a copy of the
278 | message after it is sent.
279 |
280 | """
281 |
282 | def __init__(self, template: EmailTemplate, context: dict, **email_kwargs: Any):
283 | """
284 | Build AppmailMessage from template and context.
285 |
286 | The template and context are used to render the email subject line,
287 | body and HTML.
288 |
289 | The `email_kwargs` are passed direct to the EmailMultiAlternatives
290 | constructor - so can be anything that that supports (cc, bcc, etc.)
291 |
292 | """
293 | if "subject" in email_kwargs:
294 | raise ValueError(_("Invalid argument: 'subject' is set from the template."))
295 | if "body" in email_kwargs:
296 | raise ValueError(_("Invalid argument: 'body' is set from the template."))
297 | if "alternatives" in email_kwargs:
298 | raise ValueError(
299 | _("Invalid argument: 'alternatives' is set from the template.")
300 | )
301 |
302 | email_kwargs.setdefault("reply_to", template.reply_to_list)
303 | email_kwargs.setdefault("from_email", template.from_email)
304 | email_kwargs.setdefault("headers", {})
305 | if ADD_EXTRA_HEADERS:
306 | email_kwargs["headers"].update(template.extra_headers)
307 |
308 | if email_kwargs.get("attachments", None) and not template.supports_attachments:
309 | raise ValueError(_("Email template does not support attachments."))
310 |
311 | email_kwargs["subject"] = template.render_subject(context)
312 | email_kwargs["body"] = template.render_body(
313 | context, content_type=EmailTemplate.CONTENT_TYPE_PLAIN
314 | )
315 | html = template.render_body(
316 | context, content_type=EmailTemplate.CONTENT_TYPE_HTML
317 | )
318 | email_kwargs["alternatives"] = [(html, EmailTemplate.CONTENT_TYPE_HTML)]
319 |
320 | super().__init__(**email_kwargs)
321 |
322 | self.template = template
323 | self.context = context
324 | self.html = html
325 |
326 | def size_in_bytes(self) -> int:
327 | """Return the size of the underlying message in bytes."""
328 | return len(self.message().as_bytes())
329 |
330 | def _user(self, email: str) -> settings.AUTH_USER_MODEL | None:
331 | """
332 | Fetch a user matching the 'to' email address.
333 |
334 | This method assumes that emails are unique. If users share emails,
335 | then we return None, as we cannot determine who is the correct
336 | recipient.
337 |
338 | """
339 | email = email.strip().lower()
340 | if not email:
341 | return None
342 | try:
343 | return User.objects.get(email__iexact=email)
344 | except (User.DoesNotExist, User.MultipleObjectsReturned):
345 | return None
346 |
347 | @transaction.atomic
348 | def send(
349 | self,
350 | *,
351 | log_sent_emails: bool = LOG_SENT_EMAILS,
352 | fail_silently: bool = False,
353 | ) -> int:
354 | """
355 | Send the email and add to audit log.
356 |
357 | This method first sends the email using the underlying
358 | send method inherited from EmailMultiMessage. If any messages
359 | are sent (return value > 0), then the message is logged.
360 |
361 | """
362 | sent = super().send(fail_silently=fail_silently)
363 | if not log_sent_emails:
364 | return sent
365 | if not sent:
366 | return 0
367 | return len(LoggedMessage.objects.log(self))
368 |
369 |
370 | class LoggedMessageManager(models.Manager):
371 | def log(self, message: AppmailMessage) -> list[LoggedMessage]:
372 | """Log the sending of emails from an AppmailMessage."""
373 | return [
374 | LoggedMessage.objects.create(
375 | template=message.template,
376 | to=email,
377 | user=message._user(email),
378 | subject=message.subject,
379 | body=message.body,
380 | html=message.html,
381 | context=message.context,
382 | )
383 | for email in message.to
384 | ]
385 |
386 |
387 | class LoggedMessage(models.Model):
388 | """Record of emails sent via Appmail."""
389 |
390 | # ensure we record the email address itself, even if we don't have a User object
391 | to = models.EmailField(help_text=_lazy("Address to which the the Email was sent."))
392 | # nullable, as sometimes we may have unknown senders / recipients?
393 | user = models.ForeignKey(
394 | settings.AUTH_USER_MODEL,
395 | related_name="logged_emails",
396 | on_delete=models.SET_NULL,
397 | null=True,
398 | blank=True,
399 | db_index=True,
400 | )
401 | template: EmailTemplate = models.ForeignKey(
402 | EmailTemplate,
403 | related_name="logged_emails",
404 | on_delete=models.SET_NULL,
405 | blank=True,
406 | null=True,
407 | help_text=_lazy("The appmail template used."),
408 | )
409 | timestamp = models.DateTimeField(
410 | default=tz_now, help_text=_lazy("When the email was sent."), db_index=True
411 | )
412 | subject = models.TextField(blank=True, help_text=_lazy("Email subject line."))
413 | body = models.TextField(
414 | "Plain text", blank=True, help_text=_lazy("Plain text content.")
415 | )
416 | html = models.TextField("HTML", blank=True, help_text=_lazy("HTML content."))
417 | context = models.JSONField(
418 | default=dict,
419 | encoder=DjangoJSONEncoder,
420 | help_text=_lazy("Appmail template context."),
421 | )
422 |
423 | objects = LoggedMessageManager()
424 |
425 | class Meta:
426 | get_latest_by = "timestamp"
427 | verbose_name = "Email message"
428 | verbose_name_plural = "Email messages sent"
429 | indexes = (
430 | # Indexes to help the admin search.
431 | models.Index(fields=["to"]),
432 | models.Index(fields=["subject"]),
433 | )
434 |
435 | def __repr__(self) -> str:
436 | return (
437 | f""
439 | )
440 |
441 | def __str__(self) -> str:
442 | return f"LoggedMessage sent to {self.to} ['{self.template_name}']>"
443 |
444 | @property
445 | def template_name(self) -> str:
446 | """Return the name of the template used."""
447 | if not self.template:
448 | return ""
449 | return self.template.name
450 |
451 | def rehydrate(self) -> AppmailMessage:
452 | """Create a new AppmailMessage message from this email."""
453 | return AppmailMessage(
454 | template=self.template, context=self.context, to=[self.to]
455 | )
456 |
457 | def resend(
458 | self,
459 | log_sent_emails: bool = LOG_SENT_EMAILS,
460 | fail_silently: bool = False,
461 | ) -> None:
462 | """Recreate new AppmailMessage from this log and send it."""
463 | self.rehydrate().send(
464 | log_sent_emails=log_sent_emails,
465 | fail_silently=fail_silently,
466 | )
467 |
--------------------------------------------------------------------------------