├── 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\n

Welcome to the django-appmail application.

", "test_context": {"first_name": "Fred"}, "is_active": true, "from_email": "webmaster@localhost", "reply_to": "webmaster@localhost", "supports_attachments": false}}] -------------------------------------------------------------------------------- /appmail/migrations/0006_emailtemplate_supports_attachments.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.7 on 2019-11-12 07:35 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [("appmail", "0005_emailtemplate_from_email__reply_to")] 8 | 9 | operations = [ 10 | migrations.AddField( 11 | model_name="emailtemplate", 12 | name="supports_attachments", 13 | field=models.BooleanField( 14 | default=False, 15 | help_text="Does this template support file attachments?", 16 | verbose_name="Supports attachments", 17 | ), 18 | ) 19 | ] 20 | -------------------------------------------------------------------------------- /appmail/migrations/0008_add_logged_message_indexes.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2022-12-21 11:52 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("appmail", "0007_loggedemailmessage"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddIndex( 13 | model_name="loggedmessage", 14 | index=models.Index(fields=["to"], name="appmail_log_to_e76fc8_idx"), 15 | ), 16 | migrations.AddIndex( 17 | model_name="loggedmessage", 18 | index=models.Index( 19 | fields=["subject"], name="appmail_log_subject_d9fc7b_idx" 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /appmail/migrations/0004_emailtemplate_is_active.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.6 on 2017-08-07 06:10 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [("appmail", "0003_emailtemplate_test_context")] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="emailtemplate", 14 | name="is_active", 15 | field=models.BooleanField( 16 | default=True, 17 | help_text="Set to False to remove from `current` queryset.", 18 | verbose_name="Active (live)", 19 | ), 20 | ) 21 | ] 22 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | # python code formatting - will amend files 3 | - repo: https://github.com/ambv/black 4 | rev: 23.10.1 5 | hooks: 6 | - id: black 7 | 8 | - repo: https://github.com/charliermarsh/ruff-pre-commit 9 | # Ruff version. 10 | rev: "v0.1.4" 11 | hooks: 12 | - id: ruff 13 | args: [--fix, --exit-non-zero-on-fix] 14 | 15 | # python static type checking 16 | - repo: https://github.com/pre-commit/mirrors-mypy 17 | rev: v1.6.1 18 | hooks: 19 | - id: mypy 20 | args: 21 | - --disallow-untyped-defs 22 | - --disallow-incomplete-defs 23 | - --check-untyped-defs 24 | - --no-implicit-optional 25 | - --ignore-missing-imports 26 | - --follow-imports=silent 27 | -------------------------------------------------------------------------------- /appmail/migrations/0003_emailtemplate_test_context.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.6 on 2017-05-06 08:15 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [("appmail", "0002_add_template_description")] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="emailtemplate", 14 | name="test_context", 15 | field=models.JSONField( 16 | default=dict, 17 | blank=True, 18 | help_text=( 19 | "Dummy JSON used for test rendering (set automatically on first " 20 | "save)." 21 | ), 22 | ), 23 | ) 24 | ] 25 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We, as YunoJuno, will be building this app out to support our specific 4 | requirements. New features will be added at our convenience. 5 | 6 | All of which means, if the app isn't currently supporting _your_ use case, 7 | **get involved**! 8 | 9 | The usual rules apply: 10 | 11 | 1. If you see something that's wrong, or something that's missing, open an 12 | issue. NB please take a minute to verify that your issue is not already 13 | covered by an existing issue. 14 | 15 | 2. If you want to fix something, or add a new feature / backend etc. then: 16 | 17 | * Fork the repo 18 | * Run the tests locally 19 | * Create a local branch 20 | * Write some code 21 | * Write some tests to prove your code works 22 | * Commit it 23 | * Send a pull request 24 | 25 | Other than that we'll work it out as we go along. 26 | -------------------------------------------------------------------------------- /appmail/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.utils.module_loading import import_string 3 | 4 | # Validate that EmailTemplate can be rendered without 5 | # error on each save. Defaults to True. 6 | VALIDATE_ON_SAVE = getattr(settings, "APPMAIL_VALIDATE_ON_SAVE", True) 7 | # if True then add X-Appmail-* headers to outgoung email objects 8 | ADD_EXTRA_HEADERS = getattr(settings, "APPMAIL_ADD_HEADERS", True) 9 | # list of context processor functions applied on each render 10 | CONTEXT_PROCESSORS = [ 11 | import_string(s) for s in getattr(settings, "APPMAIL_CONTEXT_PROCESSORS", []) 12 | ] # noqa 13 | 14 | # If True then emails will be logged. 15 | LOG_SENT_EMAILS = getattr(settings, "APPMAIL_LOG_SENT_EMAILS", True) 16 | 17 | # The interval, in days, after which logs can be deleted 18 | LOG_RETENTION_PERIOD = getattr(settings, "APPMAIL_LOG_RETENTION_PERIOD", 180) 19 | -------------------------------------------------------------------------------- /appmail/templates/admin/appmail/emailtemplate/change_form.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_form.html" %} 2 | {% load i18n admin_urls static admin_modify %} 3 | {% block extrastyle %} 4 | {{ block.super }} 5 | 12 | {% endblock %} 13 | {% block object-tools-items %} 14 | {{ block.super }} 15 |
  • 16 | {% trans "Send test" %} 17 |
  • 18 | {% endblock %} 19 | {% block admin_change_form_document_ready %} 20 | {{ block.super }} 21 | 27 | {% endblock admin_change_form_document_ready %} 28 | -------------------------------------------------------------------------------- /appmail/templates/admin/appmail/loggedmessage/change_form.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_form.html" %} 2 | {% load i18n admin_urls static admin_modify %} 3 | {% block extrastyle %} 4 | {{ block.super }} 5 | 12 | {% endblock %} 13 | {% block object-tools-items %} 14 | {{ block.super }} 15 |
  • 16 | {% trans "Resend" %} 17 |
  • 18 | {% endblock %} 19 | {% block admin_change_form_document_ready %} 20 | {{ block.super }} 21 | 23 | 29 | {% endblock admin_change_form_document_ready %} 30 | -------------------------------------------------------------------------------- /appmail/templates/admin/appmail/loggedmessage/template_name_filter.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | {% comment %} 4 | Overrides (v4.2.3) django/contrib/admin/templates/admin/filter.html 5 | 6 | 1) Add styles to shrink font size & curtail length of template names. 7 | 2) Updates the HTML to add the styling class and the full template 8 | name as the title, which will appear in tooltips on hover. 9 | 10 | {% endcomment %} 11 | 12 | 21 |
    22 | 23 | {% blocktranslate with filter_title=title %} By {{ filter_title }} {% endblocktranslate %} 24 | 25 | 32 |
    33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 yunojuno 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "django-appmail" 3 | version = "7.0" 4 | description = "Django app for managing localised email templates." 5 | authors = ["YunoJuno "] 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 |
    {% csrf_token %} 28 |
    29 | {% for field in form %} 30 | {% if field.is_hidden %} 31 | {{ field }} 32 | {% else %} 33 |
    34 |
    35 | {{ field.errors }} 36 | 37 | {{ field }} {% if field.help_text %} 38 |

    {{ field.help_text|safe }}

    39 | {% endif %} 40 |
    41 |
    42 | {% endif %} 43 | {% endfor %} 44 |
    45 | 46 |
    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 | [![PyPI](https://img.shields.io/pypi/v/django-appmail.svg)](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 | EmailTemplate admin
102 | change form 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 | EmailTemplate admin
113 | change form 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 | --------------------------------------------------------------------------------