├── .gitattributes ├── .darglint ├── src ├── __init__.py └── templated_email_md │ ├── __init__.py │ ├── urls.py │ ├── migrations │ └── __init__.py │ ├── locale │ └── es │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po │ ├── apps.py │ ├── exceptions.py │ ├── templates │ └── templated_email │ │ ├── license.txt │ │ ├── markdown_base.html │ │ └── markdown_styles.css │ └── backend.py ├── .prettierignore ├── example_project ├── __init__.py ├── example │ ├── __init__.py │ ├── models.py │ ├── migrations │ │ └── __init__.py │ ├── locale │ │ └── es │ │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── templates │ │ ├── templated_email │ │ │ ├── test_no_subject_preheader.md │ │ │ ├── base_email.md │ │ │ ├── test_markdown_table.md │ │ │ ├── test_unusual_markdown.md │ │ │ ├── test_message_with_link.md │ │ │ ├── test_subject_block.md │ │ │ ├── test_preheader_block.md │ │ │ ├── test_message.md │ │ │ ├── test_translation.md │ │ │ ├── child_email.md │ │ │ ├── test_subject_preheader_provided.md │ │ │ ├── test_no_content_block.md │ │ │ ├── test_render_local_links.md │ │ │ ├── test_translated_message.md │ │ │ └── test_large_content.md │ │ └── example │ │ │ └── index.html │ ├── apps.py │ └── views.py ├── conftest.py ├── asgi.py ├── wsgi.py ├── urls.py ├── settings.py └── test_django_templated_email_md.py ├── docs ├── codeofconduct.md ├── license.md ├── _static │ ├── email_screenshot.png │ └── inbox_screenshot.png ├── contributing.md ├── reference.md ├── requirements.txt ├── index.md ├── terminology.md ├── conf.py ├── settings.md └── usage.md ├── compose └── django │ ├── entrypoint │ ├── .django │ ├── start │ └── Dockerfile ├── bandit.yml ├── codecov.yml ├── .gitignore ├── .github ├── workflows │ ├── constraints.txt │ ├── labeler.yml │ ├── tests.yml │ └── release.yml ├── dependabot.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── release-drafter.yml └── labels.yml ├── .editorconfig ├── .readthedocs.yml ├── .flake8 ├── docker-compose.yml ├── manage.py ├── LICENSE ├── requirements.txt ├── CHANGELOG.md ├── SECURITY.md ├── .pre-commit-config.yaml ├── CONTRIBUTING.md ├── pyproject.toml ├── CODE_OF_CONDUCT.md ├── noxfile.py └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.darglint: -------------------------------------------------------------------------------- 1 | [darglint] 2 | strictness = long 3 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- 1 | """Initialize module.""" 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore: 2 | .nox 3 | *.html 4 | -------------------------------------------------------------------------------- /example_project/__init__.py: -------------------------------------------------------------------------------- 1 | """Initialize core module.""" 2 | -------------------------------------------------------------------------------- /docs/codeofconduct.md: -------------------------------------------------------------------------------- 1 | ```{include} ../CODE_OF_CONDUCT.md 2 | 3 | ``` 4 | -------------------------------------------------------------------------------- /example_project/example/__init__.py: -------------------------------------------------------------------------------- 1 | """Initialize example package.""" 2 | -------------------------------------------------------------------------------- /example_project/example/models.py: -------------------------------------------------------------------------------- 1 | """Models for the example app.""" 2 | -------------------------------------------------------------------------------- /src/templated_email_md/__init__.py: -------------------------------------------------------------------------------- 1 | """django-templated-email-md.""" 2 | -------------------------------------------------------------------------------- /src/templated_email_md/urls.py: -------------------------------------------------------------------------------- 1 | """URLs for templated_email_md.""" 2 | -------------------------------------------------------------------------------- /src/templated_email_md/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | """Initialize migrations.""" 2 | -------------------------------------------------------------------------------- /example_project/example/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | """Initialize migrations.""" 2 | -------------------------------------------------------------------------------- /docs/license.md: -------------------------------------------------------------------------------- 1 | # License 2 | 3 | ```{literalinclude} ../LICENSE 4 | --- 5 | language: none 6 | --- 7 | ``` 8 | -------------------------------------------------------------------------------- /compose/django/entrypoint: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o pipefail 5 | set -o nounset 6 | 7 | exec "$@" 8 | -------------------------------------------------------------------------------- /bandit.yml: -------------------------------------------------------------------------------- 1 | assert_used: 2 | skips: [".nox", "tests", "*/test_*.py"] 3 | exclude_dirs: [".nox", "tests", "*/test_*.py"] 4 | -------------------------------------------------------------------------------- /docs/_static/email_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OmenApps/django-templated-email-md/HEAD/docs/_static/email_screenshot.png -------------------------------------------------------------------------------- /docs/_static/inbox_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OmenApps/django-templated-email-md/HEAD/docs/_static/inbox_screenshot.png -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | ```{include} ../CONTRIBUTING.md 2 | --- 3 | end-before: 4 | --- 5 | ``` 6 | 7 | [code of conduct]: codeofconduct 8 | -------------------------------------------------------------------------------- /compose/django/.django: -------------------------------------------------------------------------------- 1 | # General 2 | # ------------------------------------------------------------------------------ 3 | USE_DOCKER=yes 4 | IPYTHONDIR=/app/.ipython 5 | -------------------------------------------------------------------------------- /src/templated_email_md/locale/es/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OmenApps/django-templated-email-md/HEAD/src/templated_email_md/locale/es/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example_project/example/locale/es/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OmenApps/django-templated-email-md/HEAD/example_project/example/locale/es/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example_project/example/templates/templated_email/test_no_subject_preheader.md: -------------------------------------------------------------------------------- 1 | {% block content %} 2 | 3 | # Hello {{ name }}! 4 | 5 | This is a test message. 6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | comment: false 2 | coverage: 3 | status: 4 | project: 5 | default: 6 | target: "85" 7 | patch: 8 | default: 9 | target: "85" 10 | -------------------------------------------------------------------------------- /example_project/example/templates/templated_email/base_email.md: -------------------------------------------------------------------------------- 1 | {% block subject %}Default Subject{% endblock %} 2 | 3 | {% block content %} 4 | Default content. 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /example_project/example/templates/templated_email/test_markdown_table.md: -------------------------------------------------------------------------------- 1 | {% block content %} 2 | | Header 1 | Header 2 | 3 | |----------|----------| 4 | | Cell 1 | Cell 2 | 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .mypy_cache/ 2 | /build 3 | /.coverage 4 | /.coverage.* 5 | /.nox/ 6 | /.python-version 7 | /.pytype/ 8 | /dist/ 9 | /docs/_build/ 10 | /src/*.egg-info/ 11 | __pycache__/ 12 | .venv/ 13 | -------------------------------------------------------------------------------- /example_project/example/templates/templated_email/test_unusual_markdown.md: -------------------------------------------------------------------------------- 1 | {% block content %} 2 | Here is a footnote reference[^1]. 3 | 4 | [^1]: 5 | This is the footnote. 6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /example_project/example/templates/templated_email/test_message_with_link.md: -------------------------------------------------------------------------------- 1 | {% block content %} 2 | 3 | # Hello {{ name }}! 4 | 5 | This is a test message with a [link](http://example.com). 6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /.github/workflows/constraints.txt: -------------------------------------------------------------------------------- 1 | pip==24.0 2 | pipx==1.5.0 3 | nox==2024.4.15 4 | nox-poetry==1.0.3 5 | poetry==1.8.3 6 | poetry-plugin-export==1.8.0 7 | pytest==8.2.1 8 | pytest-cov==5.0.0 9 | pytest-django==4.8.0 10 | -------------------------------------------------------------------------------- /example_project/example/templates/templated_email/test_subject_block.md: -------------------------------------------------------------------------------- 1 | {% block subject %}Subject from Template{% endblock %} 2 | 3 | {% block content %} 4 | 5 | # Hello {{ name }}! 6 | 7 | This is a test message. 8 | {% endblock %} 9 | -------------------------------------------------------------------------------- /example_project/example/templates/templated_email/test_preheader_block.md: -------------------------------------------------------------------------------- 1 | {% block preheader %}Preheader from Template{% endblock %} 2 | 3 | {% block content %} 4 | 5 | # Hello {{ name }}! 6 | 7 | This is a test message. 8 | {% endblock %} 9 | -------------------------------------------------------------------------------- /compose/django/start: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o pipefail 5 | set -o nounset 6 | 7 | uv run --prerelease=allow python manage.py migrate --noinput --skip-checks 8 | uv run --prerelease=allow python manage.py runserver 0.0.0.0:8111 --skip-checks 9 | -------------------------------------------------------------------------------- /example_project/example/apps.py: -------------------------------------------------------------------------------- 1 | """Example app config.""" 2 | 3 | from django.apps import AppConfig 4 | 5 | 6 | class ExampleConfig(AppConfig): 7 | """Example app config.""" 8 | 9 | default_auto_field = "django.db.models.BigAutoField" 10 | name = "example_project.example" 11 | -------------------------------------------------------------------------------- /example_project/example/templates/templated_email/test_message.md: -------------------------------------------------------------------------------- 1 | {% block subject %}Test Email{% endblock %} 2 | 3 | {% block content %}# Hello {{ name }}! 4 | 5 | This is a **bold** test. 6 | 7 | 1. Item one 8 | 2. Item two 9 | 10 | [A link](http://example.com) 11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /example_project/example/templates/templated_email/test_translation.md: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | {% block subject %}{% trans "Test Email" %}{% endblock %} 4 | 5 | {% block content %} 6 | 7 | # {% trans "Hello" %} {{ name }}! 8 | 9 | {% trans "This is a test email." %} 10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | 9 | [*.{py,toml}] 10 | indent_style = space 11 | indent_size = 4 12 | 13 | [*.yml,yaml,json] 14 | indent_style = space 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /example_project/example/templates/templated_email/child_email.md: -------------------------------------------------------------------------------- 1 | {% extends 'templated_email/base_email.md' %} 2 | 3 | {% block subject %}Child Email Subject{% endblock %} 4 | 5 | {% block content %} 6 | 7 | # Child Email Content 8 | 9 | This is the child email content. 10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /docs/reference.md: -------------------------------------------------------------------------------- 1 | # API Reference 2 | 3 | This reference provides detailed information about the modules and classes in the django-templated-email-md package. 4 | 5 | ## Backend 6 | 7 | ```{eval-rst} 8 | .. automodule:: templated_email_md.backend 9 | :members: 10 | :show-inheritance: 11 | ``` 12 | -------------------------------------------------------------------------------- /example_project/example/templates/templated_email/test_subject_preheader_provided.md: -------------------------------------------------------------------------------- 1 | {% block subject %}Subject from Template{% endblock %} 2 | {% block preheader %}Preheader from Template{% endblock %} 3 | 4 | {% block content %} 5 | 6 | # Hello {{ name }}! 7 | 8 | This is a test message. 9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /example_project/conftest.py: -------------------------------------------------------------------------------- 1 | """Pytest fixtures for templated_email_md tests.""" 2 | 3 | import pytest 4 | 5 | from templated_email_md.backend import MarkdownTemplateBackend 6 | 7 | 8 | @pytest.fixture 9 | def backend(): 10 | """Create a MarkdownTemplateBackend instance.""" 11 | return MarkdownTemplateBackend() 12 | -------------------------------------------------------------------------------- /src/templated_email_md/apps.py: -------------------------------------------------------------------------------- 1 | """App configuration for templated_email_md.""" 2 | 3 | from django.apps import AppConfig 4 | 5 | 6 | class TemplatedEmailMdConfig(AppConfig): 7 | """App configuration for templated_email_md.""" 8 | 9 | name = "templated_email_md" 10 | verbose_name = "Templated Email Markdown" 11 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | # Set the OS, Python version and other tools you might need 4 | build: 5 | os: ubuntu-24.04 6 | tools: 7 | python: "3.12" 8 | 9 | sphinx: 10 | configuration: docs/conf.py 11 | formats: all 12 | python: 13 | install: 14 | - requirements: docs/requirements.txt 15 | - path: . 16 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | select = B,B9,C,D,DAR,E,F,N,RST,W 3 | ignore = E203,E501,RST201,RST203,RST301,W503,R0903 4 | max-line-length = 120 5 | max-complexity = 10 6 | docstring-convention = google 7 | rst-roles = class,const,func,meth,mod,ref 8 | rst-directives = deprecated 9 | count = true 10 | exclude = .git,.nox,__pycache__,dist,migrations 11 | -------------------------------------------------------------------------------- /example_project/example/templates/example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Example Project 4 | 5 | 6 |

{{ message }}

7 | 8 |

This is an example project.

9 | 10 |

You entered some_id of: {{ some_id }}.

11 | 12 | 13 | -------------------------------------------------------------------------------- /example_project/example/templates/templated_email/test_no_content_block.md: -------------------------------------------------------------------------------- 1 | {% block subject %}Test Email Without Content Block{% endblock %} 2 | {% block preheader %}Test preheader{% endblock %} 3 | 4 | # Simple Markdown 5 | 6 | This template has no explicit content block. 7 | 8 | **Bold text** and *italic text*. 9 | 10 | - List item 1 11 | - List item 2 12 | -------------------------------------------------------------------------------- /example_project/example/templates/templated_email/test_render_local_links.md: -------------------------------------------------------------------------------- 1 | {% block content %} 2 | 3 | # Hello {{ name }}! 4 | 5 | This is a test message with a link to [index]({% url "index" some_id=some_id %}) using the template tag. 6 | 7 | This is a test message with a link to [index]({{ url }}) using the context variable. 8 | 9 | Both should render the same link. 10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /example_project/example/views.py: -------------------------------------------------------------------------------- 1 | """Views for the example app.""" 2 | 3 | from django.template.response import TemplateResponse 4 | 5 | 6 | def index(request, some_id): 7 | """Render the index page.""" 8 | template = "example/index.html" 9 | 10 | context = { 11 | "message": "Hello, world!", 12 | "some_id": some_id, 13 | } 14 | return TemplateResponse(request, template, context) 15 | -------------------------------------------------------------------------------- /.github/workflows/labeler.yml: -------------------------------------------------------------------------------- 1 | name: Labeler 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - master 8 | 9 | jobs: 10 | labeler: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Check out the repository 14 | uses: actions/checkout@v4 15 | 16 | - name: Run Labeler 17 | uses: crazy-max/ghaction-github-labeler@v5.0.0 18 | with: 19 | skip-delete: true 20 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | # Documentation dependencies 2 | Sphinx==7.1.2 3 | sphinx-rtd-theme==2.0.0 4 | sphinx-click==6.0.0 5 | myst-parser==4.0 6 | furo==2024.8.6 7 | linkify-it-py==2.0.3 8 | 9 | # Django and related packages (for autodoc) 10 | Django==5.0.8 11 | click==8.1.7 12 | markdown==3.7 13 | premailer==3.10.0 14 | html2text==2024.2.26 15 | django-templated-email==3.0.1 16 | python-environ==0.4.54 17 | django-anymail==12.0 18 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | ```{include} ../README.md 2 | ``` 3 | 4 | [license]: license 5 | [contributor guide]: contributing 6 | [command-line reference]: usage 7 | 8 | ```{toctree} 9 | --- 10 | hidden: 11 | maxdepth: 1 12 | --- 13 | 14 | usage 15 | settings 16 | terminology 17 | reference 18 | contributing 19 | Code of Conduct 20 | License 21 | Changelog 22 | ``` 23 | -------------------------------------------------------------------------------- /src/templated_email_md/exceptions.py: -------------------------------------------------------------------------------- 1 | """Exceptions for the MarkdownTemplateBackend.""" 2 | 3 | 4 | class MarkdownTemplateBackendError(Exception): 5 | """Base exception for MarkdownTemplateBackend errors.""" 6 | 7 | 8 | class MarkdownRenderError(MarkdownTemplateBackendError): 9 | """Raised when Markdown rendering fails.""" 10 | 11 | 12 | class CSSInliningError(MarkdownTemplateBackendError): 13 | """Raised when CSS inlining fails.""" 14 | -------------------------------------------------------------------------------- /example_project/asgi.py: -------------------------------------------------------------------------------- 1 | """ASGI config for core project. 2 | 3 | It exposes the ASGI callable as a module-level variable named ``application``. 4 | 5 | For more information on this file, see 6 | https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/ 7 | """ 8 | 9 | import os 10 | 11 | from django.core.asgi import get_asgi_application 12 | 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /example_project/wsgi.py: -------------------------------------------------------------------------------- 1 | """WSGI config for core project. 2 | 3 | It exposes the WSGI callable as a module-level variable named ``application``. 4 | 5 | For more information on this file, see 6 | https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/ 7 | """ 8 | 9 | import os 10 | 11 | from django.core.wsgi import get_wsgi_application 12 | 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | - package-ecosystem: pip 8 | directory: "/.github/workflows" 9 | schedule: 10 | interval: weekly 11 | - package-ecosystem: pip 12 | directory: "/docs" 13 | schedule: 14 | interval: weekly 15 | - package-ecosystem: pip 16 | directory: "/" 17 | schedule: 18 | interval: weekly 19 | versioning-strategy: lockfile-only 20 | allow: 21 | - dependency-type: "all" 22 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | django_test: 3 | build: 4 | context: . 5 | dockerfile: ./compose/django/Dockerfile 6 | image: django_templated_email_md 7 | container_name: django_test 8 | 9 | env_file: 10 | - ./compose/django/.django 11 | ports: 12 | - "8111:8111" 13 | volumes: 14 | # Mount source code for live reloading during development 15 | - ./src:/app/src 16 | - ./example_project:/app/example_project 17 | - ./docs:/app/docs 18 | - ./noxfile.py:/app/noxfile.py 19 | - ./manage.py:/app/manage.py 20 | - ./pyproject.toml:/app/pyproject.toml 21 | command: /start 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_project.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /example_project/example/templates/templated_email/test_translated_message.md: -------------------------------------------------------------------------------- 1 | {% load i18n %}{% block subject %}{% trans "Welcome to Our Service" %}{% endblock %} 2 | 3 | {% block preheader %}{% trans "Thanks for signing up!" %}{% endblock %} 4 | 5 | {% block content %} 6 | 7 | # {% trans "Welcome" %}, {{ user.first_name }}! 8 | 9 | {% trans "We're thrilled to have you join our service. Here are a few things you can do to get started:" %} 10 | 11 | 1. **{% trans "Complete your profile" %}** 12 | 2. **{% trans "Explore our features" %}** 13 | 3. **{% trans "Connect with other users" %}** 14 | 15 | {% trans "If you have any questions, don't hesitate to reach out to our support team." %} 16 | 17 | {% trans "Best regards," %} 18 | {% trans "The Team" %} 19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | categories: 2 | - title: ":boom: Breaking Changes" 3 | label: "breaking" 4 | - title: ":rocket: Features" 5 | label: "enhancement" 6 | - title: ":fire: Removals and Deprecations" 7 | label: "removal" 8 | - title: ":beetle: Fixes" 9 | label: "bug" 10 | - title: ":racehorse: Performance" 11 | label: "performance" 12 | - title: ":rotating_light: Testing" 13 | label: "testing" 14 | - title: ":construction_worker: Continuous Integration" 15 | label: "ci" 16 | - title: ":books: Documentation" 17 | label: "documentation" 18 | - title: ":hammer: Refactoring" 19 | label: "refactoring" 20 | - title: ":lipstick: Style" 21 | label: "style" 22 | - title: ":package: Dependencies" 23 | labels: 24 | - "dependencies" 25 | - "build" 26 | template: | 27 | ## Changes 28 | 29 | $CHANGES 30 | -------------------------------------------------------------------------------- /example_project/urls.py: -------------------------------------------------------------------------------- 1 | """URL configuration for core project.""" 2 | 3 | from django.contrib import admin 4 | from django.urls import path 5 | 6 | from example_project.example.views import index 7 | 8 | 9 | """ 10 | The `urlpatterns` list routes URLs to views. For more information please see: 11 | https://docs.djangoproject.com/en/5.0/topics/http/urls/ 12 | Examples: 13 | Function views 14 | 1. Add an import: from my_app import views 15 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 16 | Class-based views 17 | 1. Add an import: from other_app.views import Home 18 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 19 | Including another URLconf 20 | 1. Import the include() function: from django.urls import include, path 21 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 22 | """ 23 | 24 | 25 | urlpatterns = [ 26 | path("admin/", admin.site.urls), 27 | path("/", index, name="index"), 28 | ] 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve the package 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Browser (please complete the following information):** 27 | 28 | - Type [e.g. chrome, safari, etc] 29 | - Version [e.g. 22] 30 | 31 | **Database (please complete the following information):** 32 | 33 | - Device: [e.g. iPhone6] 34 | - Database Version [e.g. 22] 35 | - Running local, remote (e.g. DBaaS), or locally with provided docker-compose 36 | - OS running on: [e.g. Ubuntu 20.04] 37 | 38 | **Additional context** 39 | Add any other context about the problem here. 40 | -------------------------------------------------------------------------------- /src/templated_email_md/locale/es/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2024-10-22 17:49+0000\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? " 20 | "1 : 2;\n" 21 | 22 | #: src/templated_email_md/backend.py:197 23 | msgid "Email template rendering failed." 24 | msgstr "Error al renderizar la plantilla de correo electrónico." 25 | 26 | #: src/templated_email_md/backend.py:208 src/templated_email_md/backend.py:260 27 | msgid "No Subject" 28 | msgstr "No sujeta" 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Omen Apps 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 | -------------------------------------------------------------------------------- /src/templated_email_md/templates/templated_email/license.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) [2013] [Lee Munroe] 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 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv pip compile pyproject.toml -o requirements.txt --prerelease=allow 3 | asgiref==3.10.0 4 | # via django 5 | cachetools==6.2.1 6 | # via premailer 7 | certifi==2025.10.5 8 | # via requests 9 | charset-normalizer==3.4.4 10 | # via requests 11 | click==8.3.0 12 | # via django-templated-email-md (pyproject.toml) 13 | cssselect==1.3.0 14 | # via premailer 15 | cssutils==2.11.1 16 | # via premailer 17 | django==5.2.7 18 | # via 19 | # django-templated-email-md (pyproject.toml) 20 | # django-render-block 21 | django-render-block==0.11 22 | # via 23 | # django-templated-email-md (pyproject.toml) 24 | # django-templated-email 25 | django-templated-email==3.1.1 26 | # via django-templated-email-md (pyproject.toml) 27 | html2text==2024.2.26 28 | # via django-templated-email-md (pyproject.toml) 29 | idna==3.11 30 | # via requests 31 | lxml==6.0.2 32 | # via premailer 33 | markdown==3.9 34 | # via django-templated-email-md (pyproject.toml) 35 | more-itertools==10.8.0 36 | # via cssutils 37 | premailer==3.10.0 38 | # via django-templated-email-md (pyproject.toml) 39 | requests==2.32.5 40 | # via premailer 41 | sqlparse==0.5.3 42 | # via django 43 | urllib3==2.5.0 44 | # via requests 45 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). 6 | 7 | ## [Unreleased] 8 | 9 | 10 | ## [2025.10.1] 11 | 12 | - Update minimum Python and Django versions 13 | - Modernize type hints 14 | - Optimize Docker Compose setup 15 | - Add support for base_url parameter 16 | - Make exception handling more specific 17 | - Improve test coverage 18 | - Add more examples to docs and correct mistakes 19 | 20 | ## [2024.10.5] 21 | 22 | - Add another screenshot to README 23 | - Ensure relative urls can be modified to be full urls 24 | - Improve how html, css, and javascript comments are removed 25 | - Enhance and add tests 26 | - Cleanup code and improve comments 27 | - Improve docs section on overriding templates 28 | - Add docs sections on urls and verbatim code 29 | - Add a docs page documenting all settings 30 | 31 | ## [2024.10.4] 32 | 33 | - Improve README with screenshot example. 34 | - Add the ability to set default values for subject and preheader in the settings. 35 | - Fix more issues with files not being included in the package. 36 | 37 | ## [2024.10.3] 38 | 39 | - Fix issue with files not being included in the package. 40 | 41 | ## [2024.10.2] 42 | 43 | Minor fixes and improvements. 44 | 45 | ## [2024.10.1] 46 | 47 | Initial release! 48 | 49 | ### Added 50 | 51 | - TBD 52 | 53 | [unreleased]: https://github.com/OmenApps/templated_email_md/compare/HEAD...HEAD 54 | [2024.10.1]: https://github.com/OmenApps/templated_email_md/releases/tag/2024.10.1 55 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | tests: 6 | env: 7 | FORCE_COLOR: "1" 8 | PRE_COMMIT_COLOR: "always" 9 | 10 | strategy: 11 | fail-fast: false 12 | 13 | name: "Integration testing" 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: "Set up environment" 19 | run: | 20 | docker compose build --build-arg MULTIPLE_PYTHON=True 21 | docker compose up -d 22 | 23 | - name: "Wait for docker to finish building" 24 | run: sleep 10 25 | 26 | - name: "Run tests" 27 | run: docker compose exec django_test uv run --prerelease=allow nox -r --force-color 28 | 29 | - name: Upload documentation 30 | uses: actions/upload-artifact@v4 31 | with: 32 | name: docs 33 | path: docs/_build 34 | 35 | - name: Combine coverage data and display human readable report 36 | run: | 37 | docker compose exec django_test uv run --prerelease=allow nox --session "coverage(django='5.0')" 38 | 39 | - name: Create coverage report and copy to artifacts 40 | run: | 41 | docker compose exec django_test uv run --prerelease=allow nox --session "coverage(django='5.0')" -- xml 42 | docker compose exec django_test cat coverage.xml > coverage.xml 43 | 44 | - name: Upload coverage report 45 | uses: codecov/codecov-action@v4 46 | env: 47 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 48 | 49 | - name: "Shut down environment" 50 | run: docker compose down 51 | -------------------------------------------------------------------------------- /docs/terminology.md: -------------------------------------------------------------------------------- 1 | # Terminology and Definitions 2 | 3 | ## Django Templated Email 4 | [Django Templated Email](https://github.com/vintasoftware/django-templated-email/) is a package that allows you to send emails using Django templates. It provides a convenient way to create and send HTML and plain text emails using Django's templating system, and it integrates nicely with anymail. It is particularly useful for sending transactional emails. 5 | 6 | ## html2text 7 | [html2text](https://github.com/Alir3z4/html2text/) is a Python library that converts HTML to plain text. In django-templated-email-md, it's used to generate the plain text version of emails from the rendered HTML. 8 | 9 | ## Markdown 10 | Markdown is a lightweight markup language with plain-text formatting syntax. It's designed to be easy to read and write, and can be converted to HTML and other formats. [Markdown](https://github.com/Python-Markdown/markdown) is also the name of a popular Python package that provides tools for working with Markdown text. 11 | 12 | ## Multipart Email 13 | A multipart email is an email that contains multiple parts, typically an HTML version and a plain text version of the message. This allows email clients to display the most appropriate version based on their capabilities. 14 | 15 | ## Preheader 16 | A preheader is a short summary or preview of an email message that appears in the inbox before the email is opened. It's typically displayed next to the subject line and can help entice recipients to open the email. 17 | 18 | ## Premailer 19 | [Premailer](https://github.com/peterbe/premailer) is a tool used to inline CSS styles in HTML documents. In the context of email templates, it helps ensure that styles are applied consistently across different email clients. 20 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | We appreciate and encourage responsible vulnerability disclosure from our users and the security research community. To ensure vulnerabilities are handled securely and efficiently, we request that all vulnerabilities be reported through GitHub's secure vulnerability reporting feature. 6 | 7 | ![Report Vulnerability Button](https://raw.githubusercontent.com/OmenApps/OmenApps/refs/heads/main/media/security_reporting.png) 8 | 9 | ### How to Report a Vulnerability 10 | 11 | 1. Visit the **Security** tab on the GitHub repository. 12 | 2. Click **Report a vulnerability**. 13 | 3. Provide detailed information including: 14 | - A clear description of the vulnerability 15 | - Steps to reproduce 16 | - Potential impact assessment 17 | - Any recommendations for mitigation 18 | 19 | ### Handling Your Report 20 | 21 | Once your vulnerability report is submitted, we will: 22 | 23 | - Acknowledge receipt within **2 business days**. 24 | - Investigate and validate the reported vulnerability. 25 | - Provide regular updates approximately every **7 business days** until resolution. 26 | 27 | ### Vulnerability Acceptance 28 | 29 | - If the vulnerability is **accepted**, we will: 30 | - Coordinate privately to develop and test a fix. 31 | - Aim to resolve critical vulnerabilities within **14 days** and lower severity issues within **30 days**. 32 | - Publicly disclose the vulnerability after a fix is available, providing credit to the reporter (unless anonymity is requested). 33 | 34 | - If the vulnerability is **declined**, we will: 35 | - Clearly explain our reasoning. 36 | - Suggest alternative actions if applicable. 37 | 38 | ### PyPI Package Management 39 | 40 | We follow best practices for managing packages published on PyPI, including the "yanking" of vulnerable package versions. Yanked versions remain available for users to download, but are hidden from new installations by default, preventing further propagation of vulnerabilities. 41 | 42 | Thank you for helping us keep our packages secure! 43 | -------------------------------------------------------------------------------- /.github/labels.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Labels names are important as they are used by Release Drafter to decide 3 | # regarding where to record them in changelog or if to skip them. 4 | # 5 | # The repository labels will be automatically configured using this file and 6 | # the GitHub Action https://github.com/marketplace/actions/github-labeler. 7 | - name: breaking 8 | description: Breaking Changes 9 | color: bfd4f2 10 | - name: bug 11 | description: Something isn't working 12 | color: d73a4a 13 | - name: build 14 | description: Build System and Dependencies 15 | color: bfdadc 16 | - name: ci 17 | description: Continuous Integration 18 | color: 4a97d6 19 | - name: dependencies 20 | description: Pull requests that update a dependency file 21 | color: 0366d6 22 | - name: documentation 23 | description: Improvements or additions to documentation 24 | color: 0075ca 25 | - name: duplicate 26 | description: This issue or pull request already exists 27 | color: cfd3d7 28 | - name: enhancement 29 | description: New feature or request 30 | color: a2eeef 31 | - name: github_actions 32 | description: Pull requests that update Github_actions code 33 | color: "000000" 34 | - name: good first issue 35 | description: Good for newcomers 36 | color: 7057ff 37 | - name: help wanted 38 | description: Extra attention is needed 39 | color: 008672 40 | - name: invalid 41 | description: This doesn't seem right 42 | color: e4e669 43 | - name: performance 44 | description: Performance 45 | color: "016175" 46 | - name: python 47 | description: Pull requests that update Python code 48 | color: 2b67c6 49 | - name: question 50 | description: Further information is requested 51 | color: d876e3 52 | - name: refactoring 53 | description: Refactoring 54 | color: ef67c4 55 | - name: removal 56 | description: Removals and Deprecations 57 | color: 9ae7ea 58 | - name: style 59 | description: Style 60 | color: c120e5 61 | - name: testing 62 | description: Testing 63 | color: b1fc6f 64 | - name: wontfix 65 | description: This will not be worked on 66 | color: ffffff 67 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: local 3 | hooks: 4 | - id: bandit 5 | name: bandit 6 | entry: bandit 7 | language: system 8 | types: [python] 9 | require_serial: true 10 | args: ["-c", "bandit.yml"] 11 | - id: black 12 | name: black 13 | entry: black 14 | language: system 15 | types: [python] 16 | require_serial: true 17 | - id: check-added-large-files 18 | name: Check for added large files 19 | entry: check-added-large-files 20 | language: system 21 | - id: check-toml 22 | name: Check Toml 23 | entry: check-toml 24 | language: system 25 | types: [toml] 26 | - id: check-yaml 27 | name: Check Yaml 28 | entry: check-yaml 29 | language: system 30 | types: [yaml] 31 | - id: darglint 32 | name: darglint 33 | entry: darglint 34 | language: system 35 | types: [python] 36 | stages: [manual] 37 | - id: end-of-file-fixer 38 | name: Fix End of Files 39 | entry: end-of-file-fixer 40 | language: system 41 | types: [text] 42 | stages: [commit, push, manual] 43 | - id: flake8 44 | name: flake8 45 | entry: flake8 46 | language: system 47 | types: [python] 48 | require_serial: true 49 | args: [--darglint-ignore-regex, .*] 50 | - id: isort 51 | name: isort 52 | entry: isort 53 | require_serial: true 54 | language: system 55 | types_or: [cython, pyi, python] 56 | args: ["--filter-files"] 57 | - id: pyupgrade 58 | name: pyupgrade 59 | description: Automatically upgrade syntax for newer versions. 60 | entry: pyupgrade 61 | language: system 62 | types: [python] 63 | args: [--py310-plus] 64 | - id: trailing-whitespace 65 | name: Trim Trailing Whitespace 66 | entry: trailing-whitespace-fixer 67 | language: system 68 | types: [text] 69 | stages: [commit, push, manual] 70 | # - repo: https://github.com/pre-commit/mirrors-prettier 71 | # rev: v2.6.0 72 | # hooks: 73 | # - id: prettier 74 | -------------------------------------------------------------------------------- /compose/django/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/astral-sh/uv:python3.13-bookworm 2 | 3 | ARG BUILD_ENVIRONMENT=local 4 | ARG APP_HOME=/app 5 | ARG DEBIAN_FRONTEND=noninteractive 6 | # ARG MULTIPLE_PYTHON # Set to True if you want to use multiple Python versions 7 | 8 | ENV PYTHONUNBUFFERED 1 9 | ENV PYTHONDONTWRITEBYTECODE 1 10 | ENV BUILD_ENV ${BUILD_ENVIRONMENT} 11 | ENV MULTIPLE_PYTHON=True 12 | ENV PYTHONPATH="${APP_HOME}:${APP_HOME}/src:${PYTHONPATH:-}" 13 | ENV DJANGO_SETTINGS_MODULE=example_project.settings 14 | 15 | WORKDIR ${APP_HOME} 16 | ENV PATH="/app/.venv/bin:$PATH" 17 | 18 | # Install apt packages and clean up cache to reduce image size 19 | RUN apt-get update && apt-get install -y \ 20 | # Some basic tools and libraries 21 | bash curl wget git make gcc libc6 \ 22 | build-essential libssl-dev zlib1g-dev libbz2-dev libreadline-dev \ 23 | libsqlite3-dev llvm libncurses5-dev libncursesw5-dev xz-utils tk-dev \ 24 | libxml2-dev libxmlsec1-dev libffi-dev liblzma-dev python3-pip \ 25 | && rm -rf /var/lib/apt/lists/* 26 | 27 | # Update pip 28 | RUN pip install --upgrade pip cffi 29 | 30 | # Install multiple Python versions with uv 31 | RUN if [ "$MULTIPLE_PYTHON" = "True" ] ; then \ 32 | uv python install 3.11; \ 33 | uv python install 3.12; \ 34 | uv python install 3.13; \ 35 | uv python install 3.14; \ 36 | fi 37 | 38 | # Copy requirements files and source code (needed for local package install) 39 | COPY pyproject.toml uv.lock requirements.txt ${APP_HOME}/ 40 | COPY ./src/ ${APP_HOME}/src/ 41 | 42 | # Install dependencies using uv 43 | RUN uv venv --python 3.13 44 | RUN uv sync --prerelease=allow 45 | 46 | # Copy remaining project files 47 | COPY noxfile.py manage.py ${APP_HOME} 48 | COPY .darglint .editorconfig .flake8 .gitignore .pre-commit-config.yaml .prettierignore .readthedocs.yml bandit.yml ${APP_HOME} 49 | COPY CHANGELOG.md CODE_OF_CONDUCT.md CONTRIBUTING.md LICENSE README.md ${APP_HOME} 50 | COPY ./docs/ ${APP_HOME}/docs 51 | COPY ./example_project/ ${APP_HOME}/example_project/ 52 | 53 | # Project initialization: 54 | COPY ./compose/django/entrypoint /entrypoint 55 | RUN sed -i 's/\r$//g' /entrypoint 56 | RUN chmod +x /entrypoint 57 | 58 | COPY ./compose/django/start /start 59 | RUN sed -i 's/\r$//g' /start 60 | RUN chmod +x /start 61 | 62 | # Initialize git and add . 63 | RUN git init 64 | RUN git add . 65 | 66 | ENTRYPOINT ["/entrypoint"] 67 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | release: 10 | name: Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Check out the repository 14 | uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 2 17 | 18 | - name: Set up Python 19 | uses: actions/setup-python@v5.2.0 20 | with: 21 | python-version: "3.12" 22 | 23 | - name: Upgrade pip 24 | run: | 25 | pip install --constraint=.github/workflows/constraints.txt pip 26 | pip --version 27 | 28 | - name: Install uv 29 | run: | 30 | pip install --constraint=.github/workflows/constraints.txt uv 31 | 32 | - name: Check if there is a parent commit 33 | id: check-parent-commit 34 | run: | 35 | echo "::set-output name=sha::$(git rev-parse --verify --quiet HEAD^)" 36 | 37 | - name: Detect and tag new version 38 | id: check-version 39 | if: steps.check-parent-commit.outputs.sha 40 | uses: salsify/action-detect-and-tag-new-version@v2.0.3 41 | with: 42 | version-command: | 43 | bash -o pipefail -c "uv version | awk '{ print \$2 }'" 44 | 45 | - name: Bump version for developmental release 46 | if: ! steps.check-version.outputs.tag 47 | run: | 48 | poetry version patch && 49 | version=$(poetry version | awk '{ print $2 }') && 50 | poetry version $version.dev.$(date +%s) 51 | 52 | - name: Build package 53 | run: | 54 | poetry build --ansi 55 | 56 | - name: Publish package on PyPI 57 | if: steps.check-version.outputs.tag 58 | uses: pypa/gh-action-pypi-publish@v1.10.2 59 | with: 60 | user: __token__ 61 | password: ${{ secrets.PYPI_TOKEN }} 62 | 63 | - name: Publish package on TestPyPI 64 | if: ! steps.check-version.outputs.tag 65 | uses: pypa/gh-action-pypi-publish@v1.10.2 66 | with: 67 | user: __token__ 68 | password: ${{ secrets.TEST_PYPI_TOKEN }} 69 | repository-url: https://test.pypi.org/legacy/ 70 | 71 | - name: Publish the release notes 72 | uses: release-drafter/release-drafter@v6.0.0 73 | with: 74 | publish: ${{ steps.check-version.outputs.tag != '' }} 75 | tag: ${{ steps.check-version.outputs.tag }} 76 | env: 77 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 78 | -------------------------------------------------------------------------------- /src/templated_email_md/templates/templated_email/markdown_base.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | 4 | 5 | 6 | {{ subject }} 7 | {% block styles %} 8 | 9 | {% endblock styles %} 10 | 11 | 12 | {% spaceless %} 13 | {{ preheader }} 14 | {% endspaceless %} 15 | 20 | 21 | 22 | 64 | 65 | 66 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributor Guide 2 | 3 | Thank you for your interest in improving this project. 4 | This project is open-source under the [MIT license] and 5 | welcomes contributions in the form of bug reports, feature requests, and pull requests. 6 | 7 | Here is a list of important resources for contributors: 8 | 9 | - [Source Code] 10 | - [Documentation] 11 | - [Issue Tracker] 12 | - [Code of Conduct] 13 | 14 | [mit license]: https://opensource.org/licenses/MIT 15 | [source code]: https://github.com/OmenApps/django-templated-email-md 16 | [documentation]: https://django-templated-email-md.readthedocs.io/ 17 | [issue tracker]: https://github.com/OmenApps/django-templated-email-md/issues 18 | 19 | ## How to report a bug 20 | 21 | Report bugs on the [Issue Tracker]. 22 | 23 | When filing an issue, make sure to answer these questions: 24 | 25 | - Which operating system and Python version are you using? 26 | - Which version of this project are you using? 27 | - What did you do? 28 | - What did you expect to see? 29 | - What did you see instead? 30 | 31 | The best way to get your bug fixed is to provide a test case, 32 | and/or steps to reproduce the issue. 33 | 34 | ## How to request a feature 35 | 36 | Request features on the [Issue Tracker]. 37 | 38 | ## How to set up your development environment 39 | 40 | You need Python 3.9+ and the following tools: 41 | 42 | - [Poetry] 43 | - [Nox] 44 | - [nox-poetry] 45 | 46 | Install the package with development requirements: 47 | 48 | ```console 49 | $ poetry install 50 | ``` 51 | 52 | You can now run an interactive Python session, 53 | or the command-line interface: 54 | 55 | ```console 56 | $ poetry run python 57 | $ poetry run django-templated-email-md 58 | ``` 59 | 60 | [poetry]: https://python-poetry.org/ 61 | [nox]: https://nox.thea.codes/ 62 | [nox-poetry]: https://nox-poetry.readthedocs.io/ 63 | 64 | ## How to test the project 65 | 66 | Run the full test suite: 67 | 68 | ```console 69 | $ nox 70 | ``` 71 | 72 | List the available Nox sessions: 73 | 74 | ```console 75 | $ nox --list-sessions 76 | ``` 77 | 78 | You can also run a specific Nox session. 79 | For example, invoke the unit test suite like this: 80 | 81 | ```console 82 | $ nox --session=tests 83 | ``` 84 | 85 | Unit tests are located in the _tests_ directory, 86 | and are written using the [pytest] testing framework. 87 | 88 | [pytest]: https://pytest.readthedocs.io/ 89 | 90 | ## How to submit changes 91 | 92 | Open a [pull request] to submit changes to this project. 93 | 94 | Your pull request needs to meet the following guidelines for acceptance: 95 | 96 | - The Nox test suite must pass without errors and warnings. 97 | - Include unit tests. This project maintains 100% code coverage. 98 | - If your changes add functionality, update the documentation accordingly. 99 | 100 | Feel free to submit early, though—we can always iterate on this. 101 | 102 | To run linting and code formatting checks before committing your change, you can install pre-commit as a Git hook by running the following command: 103 | 104 | ```console 105 | $ nox --session=pre-commit -- install 106 | ``` 107 | 108 | It is recommended to open an issue before starting work on anything. 109 | This will allow a chance to talk it over with the owners and validate your approach. 110 | 111 | [pull request]: https://github.com/OmenApps/django-templated-email-md/pulls 112 | 113 | 114 | 115 | [code of conduct]: CODE_OF_CONDUCT.md 116 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=77.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "django-templated-email-md" 7 | version = "2025.10.2" 8 | description = "An extension for django-templated-email for creating emails with Markdown" 9 | authors = [{ name = "Jack Linke", email = "jacklinke@gmail.com" }] 10 | license = "MIT" 11 | readme = "README.md" 12 | classifiers = [ 13 | "Environment :: Web Environment", 14 | "Framework :: Django", 15 | "Framework :: Django :: 4.2", 16 | "Framework :: Django :: 5.0", 17 | "Framework :: Django :: 5.1", 18 | "Framework :: Django :: 5.2", 19 | "Development Status :: 4 - Beta", 20 | "Intended Audience :: Developers", 21 | "Operating System :: OS Independent", 22 | "Programming Language :: Python", 23 | "Programming Language :: Python :: 3", 24 | "Programming Language :: Python :: 3.11", 25 | "Programming Language :: Python :: 3.12", 26 | "Programming Language :: Python :: 3.13", 27 | "Programming Language :: Python :: 3.14", 28 | "Topic :: Communications :: Email", 29 | "Topic :: Software Development :: Libraries :: Python Modules", 30 | ] 31 | requires-python = ">=3.11,<4.0" 32 | dependencies = [ 33 | "click>=8.1", 34 | "django>=4.2.15", 35 | "django-render-block>=0.8", 36 | "django-templated-email>=3.0", 37 | "html2text>=2024.2", 38 | "markdown>=3.7", 39 | "premailer>=3.10", 40 | ] 41 | 42 | [project.urls] 43 | Homepage = "https://github.com/OmenApps/django-templated-email-md" 44 | Repository = "https://github.com/OmenApps/django-templated-email-md" 45 | Documentation = "https://django-templated-email-md.readthedocs.io" 46 | Changelog = "https://github.com/OmenApps/django-templated-email-md/releases" 47 | 48 | [tool.uv] 49 | dev-dependencies = [ 50 | "bandit==1.8.6", 51 | "black==25.9.0", 52 | "coverage[toml]==7.11.0", 53 | "darglint==1.8.1", 54 | "django-anymail==13.1", 55 | "flake8-bugbear==25.10.21", 56 | "flake8-docstrings==1.7.0", 57 | "flake8-rst-docstrings==0.4.0", 58 | "flake8==7.3.0", 59 | "furo==2025.9.25", 60 | "isort==7.0.0", 61 | "linkify-it-py==2.0.3", 62 | "myst-parser==4.0.1", 63 | "nox[pbs]==2025.10.16", 64 | "pep8-naming==0.15.1", 65 | "pre-commit-hooks==6.0.0", 66 | "pre-commit==4.3.0", 67 | "psycopg-binary==3.2.11", 68 | "Pygments==2.19.2", 69 | "pytest-cov==7.0.0", 70 | "pytest-django==4.11.1", 71 | "pytest==8.4.2", 72 | "python-environ==0.4.54", 73 | "pyupgrade==3.21.0", 74 | "safety==3.6.2", 75 | "sphinx-autobuild==2025.8.25", 76 | "sphinx-click==6.1.0", 77 | "sphinx==8.2.3", 78 | "xdoctest[colors]==1.3.0", 79 | ] 80 | 81 | [tool.setuptools.packages.find] 82 | where = ["src"] 83 | 84 | [tool.setuptools.package-data] 85 | templated_email_md = ["templates/templated_email/*", "locale/*"] 86 | 87 | [tool.black] 88 | line-length = 120 89 | target-version = ["py311", "py312", "py313", "py314"] 90 | force-exclude = ''' 91 | ( 92 | .nox 93 | ) 94 | ''' 95 | 96 | [tool.coverage.paths] 97 | source = ["src", "*/site-packages"] 98 | tests = ["example_project", "*/example_project"] 99 | 100 | [tool.coverage.run] 101 | branch = true 102 | source = ["src", "example_project"] 103 | 104 | [tool.coverage.report] 105 | show_missing = true 106 | fail_under = 50 107 | omit = [".nox/*", "example_project/*", "**/migrations/*", "**/__init__.py"] 108 | 109 | [tool.pytest.ini_options] 110 | DJANGO_SETTINGS_MODULE = "example_project.settings" 111 | python_files = ["*test_*.py", "*_test.py", "example_project/*.py"] 112 | log_cli = true 113 | log_cli_level = "INFO" 114 | 115 | [tool.isort] 116 | profile = "black" 117 | force_single_line = true 118 | lines_after_imports = 2 119 | extend_skip = [".nox"] 120 | -------------------------------------------------------------------------------- /example_project/example/locale/es/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2024-10-22 17:57+0000\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? " 20 | "1 : 2;\n" 21 | 22 | #: docs/usage.md:77 docs/usage.md:248 23 | #: example_project/example/templates/templated_email/test_translated_message.md:1 24 | #: example_project/example/templates/templated_email_md/test_translated_message.md:1 25 | msgid "Welcome to Our Service" 26 | msgstr "Bienvenido a nuestro servicio" 27 | 28 | #: docs/usage.md:79 docs/usage.md:250 29 | #: example_project/example/templates/templated_email/test_translated_message.md:3 30 | #: example_project/example/templates/templated_email_md/test_translated_message.md:3 31 | msgid "Thanks for signing up!" 32 | msgstr "¡Gracias por registrarte!" 33 | 34 | #: docs/usage.md:82 docs/usage.md:253 35 | #: example_project/example/templates/templated_email/test_translated_message.md:7 36 | #: example_project/example/templates/templated_email_md/test_translated_message.md:7 37 | msgid "Welcome" 38 | msgstr "Bienvenido" 39 | 40 | #: docs/usage.md:84 docs/usage.md:255 41 | #: example_project/example/templates/templated_email/test_translated_message.md:9 42 | #: example_project/example/templates/templated_email_md/test_translated_message.md:9 43 | msgid "" 44 | "We're thrilled to have you join our service. Here are a few things you can " 45 | "do to get started:" 46 | msgstr "" 47 | "Estamos encantados de que se una a nuestro servicio. Aquí hay algunas cosas que puede hacer " 48 | "hacer para empezar:" 49 | 50 | #: docs/usage.md:86 docs/usage.md:257 51 | #: example_project/example/templates/templated_email/test_translated_message.md:11 52 | #: example_project/example/templates/templated_email_md/test_translated_message.md:11 53 | msgid "Complete your profile" 54 | msgstr "Completa tu perfil" 55 | 56 | #: docs/usage.md:87 docs/usage.md:258 57 | #: example_project/example/templates/templated_email/test_translated_message.md:12 58 | #: example_project/example/templates/templated_email_md/test_translated_message.md:12 59 | msgid "Explore our features" 60 | msgstr "Explora nuestras características" 61 | 62 | #: docs/usage.md:88 docs/usage.md:259 63 | #: example_project/example/templates/templated_email/test_translated_message.md:13 64 | #: example_project/example/templates/templated_email_md/test_translated_message.md:13 65 | msgid "Connect with other users" 66 | msgstr "Conectar con otros usuarios" 67 | 68 | #: docs/usage.md:90 docs/usage.md:261 69 | #: example_project/example/templates/templated_email/test_translated_message.md:15 70 | #: example_project/example/templates/templated_email_md/test_translated_message.md:15 71 | msgid "" 72 | "If you have any questions, don't hesitate to reach out to our support team." 73 | msgstr "Si tiene alguna pregunta, no dude en comunicarse con nuestro equipo de soporte." 74 | 75 | #: docs/usage.md:92 docs/usage.md:263 76 | #: example_project/example/templates/templated_email/test_translated_message.md:17 77 | #: example_project/example/templates/templated_email_md/test_translated_message.md:17 78 | msgid "Best regards," 79 | msgstr "Atentamente," 80 | 81 | #: docs/usage.md:93 docs/usage.md:264 82 | #: example_project/example/templates/templated_email/test_translated_message.md:18 83 | #: example_project/example/templates/templated_email_md/test_translated_message.md:18 84 | msgid "The Team" 85 | msgstr "El equipo" 86 | 87 | #: example_project/example/templates/templated_email/test_translation.md:3 88 | #: example_project/example/templates/templated_email_md/test_translation.md:3 89 | msgid "Test Email" 90 | msgstr "Correo electrónico de prueba" 91 | 92 | #: example_project/example/templates/templated_email/test_translation.md:7 93 | #: example_project/example/templates/templated_email_md/test_translation.md:7 94 | msgid "Hello" 95 | msgstr "Hola" 96 | 97 | #: example_project/example/templates/templated_email/test_translation.md:9 98 | #: example_project/example/templates/templated_email_md/test_translation.md:9 99 | msgid "This is a test email." 100 | msgstr "Este es un correo electrónico de prueba." 101 | 102 | #~ msgid "Email template rendering failed." 103 | #~ msgstr "Error al renderizar la plantilla de correo electrónico." 104 | 105 | #~ msgid "No Subject" 106 | #~ msgstr "No sujeta" 107 | -------------------------------------------------------------------------------- /example_project/settings.py: -------------------------------------------------------------------------------- 1 | """Django settings for core project. 2 | 3 | For more information on this file, see 4 | https://docs.djangoproject.com/en/5.0/topics/settings/ 5 | 6 | For the full list of settings and their values, see 7 | https://docs.djangoproject.com/en/5.0/ref/settings/ 8 | """ 9 | 10 | from pathlib import Path 11 | 12 | import environ 13 | 14 | 15 | env = environ.Env() 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | READ_DOT_ENV_FILE = env.bool("DJANGO_READ_DOT_ENV_FILE", default=False) 18 | if READ_DOT_ENV_FILE: 19 | # OS environment variables take precedence over variables from .env 20 | env.read_env(str(BASE_DIR / ".env")) 21 | 22 | # Quick-start development settings - unsuitable for production 23 | # See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ 24 | 25 | # SECURITY WARNING: keep the secret key used in production secret! 26 | 27 | SECRET_KEY = "django-insecure-r88%=*g)x(&-&67duelz$=8mat90+aq^wo+6niu!rd2v4(#f#t" # nosec 28 | 29 | # SECURITY WARNING: don't run with debug turned on in production! 30 | 31 | DEBUG = True 32 | 33 | ALLOWED_HOSTS = ["*"] # nosec 34 | 35 | # Application definition 36 | 37 | INSTALLED_APPS = [ 38 | "django.contrib.admin", 39 | "django.contrib.auth", 40 | "django.contrib.contenttypes", 41 | "django.contrib.sessions", 42 | "django.contrib.messages", 43 | "django.contrib.staticfiles", 44 | "anymail", 45 | "templated_email_md", 46 | "example_project.example", 47 | ] 48 | 49 | MIDDLEWARE = [ 50 | "django.middleware.security.SecurityMiddleware", 51 | "django.contrib.sessions.middleware.SessionMiddleware", 52 | "django.middleware.common.CommonMiddleware", 53 | "django.middleware.csrf.CsrfViewMiddleware", 54 | "django.contrib.auth.middleware.AuthenticationMiddleware", 55 | "django.contrib.messages.middleware.MessageMiddleware", 56 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 57 | ] 58 | 59 | ROOT_URLCONF = "example_project.urls" 60 | 61 | TEMPLATES = [ 62 | { 63 | "BACKEND": "django.template.backends.django.DjangoTemplates", 64 | "DIRS": [], 65 | "APP_DIRS": True, 66 | "OPTIONS": { 67 | "context_processors": [ 68 | "django.template.context_processors.debug", 69 | "django.template.context_processors.request", 70 | "django.contrib.auth.context_processors.auth", 71 | "django.contrib.messages.context_processors.messages", 72 | ], 73 | }, 74 | }, 75 | ] 76 | 77 | WSGI_APPLICATION = "example_project.wsgi.application" 78 | ASGI_APPLICATION = "example_project.asgi.application" 79 | 80 | # Database 81 | # https://docs.djangoproject.com/en/5.0/ref/settings/#databases 82 | 83 | DATABASES = { 84 | "default": { 85 | "ENGINE": "django.db.backends.sqlite3", 86 | "NAME": BASE_DIR / "db.sqlite3", 87 | } 88 | } 89 | 90 | # Password validation 91 | # https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators 92 | 93 | AUTH_PASSWORD_VALIDATORS = [ 94 | { 95 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 96 | }, 97 | { 98 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 99 | }, 100 | { 101 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 102 | }, 103 | { 104 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 105 | }, 106 | ] 107 | 108 | # Internationalization 109 | # https://docs.djangoproject.com/en/5.0/topics/i18n/ 110 | 111 | LANGUAGE_CODE = "en-us" 112 | 113 | TIME_ZONE = "UTC" 114 | 115 | USE_I18N = True 116 | LOCALE_PATHS = [BASE_DIR / "example_project/example/locale", BASE_DIR / "src/templated_email_md/locale"] 117 | 118 | USE_TZ = True 119 | 120 | # Static files (CSS, JavaScript, Images) 121 | # https://docs.djangoproject.com/en/5.0/howto/static-files/ 122 | 123 | STATIC_URL = "static/" 124 | 125 | # Default primary key field type 126 | # https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field 127 | 128 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 129 | 130 | # Templated Email and Templated Email Markdown settings 131 | # https://django-templated-email-md.readthedocs.io/en/latest/ 132 | TEMPLATED_EMAIL_BACKEND = "templated_email_md.backend.MarkdownTemplateBackend" 133 | TEMPLATED_EMAIL_BASE_HTML_TEMPLATE = "templated_email/markdown_base.html" 134 | TEMPLATED_EMAIL_FILE_EXTENSION = "md" 135 | TEMPLATED_EMAIL_TEMPLATE_DIR = "templated_email/" 136 | 137 | TEMPLATED_EMAIL_EMAIL_MESSAGE_CLASS = "anymail.message.AnymailMessage" 138 | TEMPLATED_EMAIL_EMAIL_MULTIALTERNATIVES_CLASS = "anymail.message.AnymailMessage" 139 | 140 | # Anymail settings 141 | # https://anymail.dev/en/stable/ 142 | ANYMAIL = { 143 | "MAILGUN_API_KEY": env("MAILGUN_API_KEY", default="key-1234567890abcdef1234567890abcdef"), 144 | "MAILGUN_SENDER_DOMAIN": env("MAILGUN_SENDER_DOMAIN", default="example.com"), 145 | } 146 | EMAIL_BACKEND = "anymail.backends.mailgun.EmailBackend" 147 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | """Sphinx configuration for django-templated-email-md documentation.""" 2 | 3 | import inspect 4 | import os 5 | import sys 6 | from datetime import datetime 7 | 8 | import django 9 | from django.utils.html import strip_tags 10 | 11 | 12 | sys.path.insert(0, os.path.abspath("..")) 13 | os.environ["DJANGO_SETTINGS_MODULE"] = "example_project.settings" 14 | django.setup() 15 | 16 | 17 | # Project information 18 | project = "django-templated-email-md" 19 | author = "Jack Linke" 20 | copyright = f"{datetime.now().year}, {author}" 21 | 22 | # General configuration 23 | extensions = [ 24 | "sphinx.ext.autodoc", 25 | "sphinx.ext.napoleon", 26 | "sphinx.ext.intersphinx", 27 | "sphinx.ext.viewcode", 28 | "sphinx_click", 29 | "myst_parser", 30 | ] 31 | 32 | # Any paths that contain templates here, relative to this directory. 33 | # templates_path = ["_templates"] 34 | 35 | # List of patterns, relative to source directory, that match files and 36 | # directories to ignore when looking for source files. 37 | exclude_patterns = ["_build"] 38 | 39 | # The name of the Pygments (syntax highlighting) style to use. 40 | pygments_style = "sphinx" 41 | 42 | # -- Options for HTML output ------------------------------------------------- 43 | 44 | # The theme to use for HTML and HTML Help pages. 45 | html_theme = "furo" 46 | 47 | # Add any paths that contain custom static files (such as style sheets) here, 48 | # relative to this directory. They are copied after the builtin static files, 49 | # so a file named "default.css" will overwrite the builtin "default.css". 50 | # html_static_path = ["_static"] 51 | 52 | # -- Extension configuration ------------------------------------------------- 53 | 54 | # Napoleon settings 55 | napoleon_google_docstring = True 56 | napoleon_numpy_docstring = False 57 | napoleon_include_init_with_doc = False 58 | napoleon_include_private_with_doc = False 59 | napoleon_include_special_with_doc = True 60 | napoleon_use_admonition_for_examples = False 61 | napoleon_use_admonition_for_notes = False 62 | napoleon_use_admonition_for_references = False 63 | napoleon_use_ivar = False 64 | napoleon_use_param = True 65 | napoleon_use_rtype = True 66 | napoleon_preprocess_types = False 67 | napoleon_type_aliases = None 68 | napoleon_attr_annotations = True 69 | 70 | # Autodoc settings 71 | autodoc_typehints = "description" 72 | autodoc_default_options = { 73 | "members": True, 74 | "special-members": "__init__", 75 | "exclude-members": "__weakref__", 76 | } 77 | autodoc_mock_imports = [ 78 | "django", 79 | ] # Add any modules that might cause import errors during doc building 80 | 81 | # Intersphinx settings 82 | intersphinx_mapping = { 83 | "python": ("https://docs.python.org/3", None), 84 | "django": ("https://docs.djangoproject.com/en/stable/", "https://docs.djangoproject.com/en/stable/_objects/"), 85 | } 86 | 87 | # MyST Parser settings 88 | myst_enable_extensions = [ 89 | "amsmath", 90 | "colon_fence", 91 | "deflist", 92 | "dollarmath", 93 | "html_admonition", 94 | "html_image", 95 | "linkify", 96 | "replacements", 97 | "smartquotes", 98 | "substitution", 99 | "tasklist", 100 | ] 101 | 102 | 103 | def project_django_models(app, what, name, obj, options, lines): # pylint: disable=W0613 disable=R0913 104 | """Process Django models for autodoc. 105 | 106 | From: https://djangosnippets.org/snippets/2533/ 107 | """ 108 | from django.db import models # pylint: disable=C0415 109 | 110 | # Only look at objects that inherit from Django's base model class 111 | if inspect.isclass(obj) and issubclass(obj, models.Model): 112 | # Grab the field list from the meta class 113 | fields = obj._meta.get_fields() # pylint: disable=W0212 114 | 115 | for field in fields: 116 | # If it's a reverse relation, skip it 117 | if isinstance( 118 | field, 119 | ( 120 | models.fields.related.ManyToOneRel, 121 | models.fields.related.ManyToManyRel, 122 | models.fields.related.OneToOneRel, 123 | ), 124 | ): 125 | continue 126 | 127 | # Decode and strip any html out of the field's help text 128 | help_text = strip_tags(field.help_text) if hasattr(field, "help_text") else None 129 | 130 | # Decode and capitalize the verbose name, for use if there isn't 131 | # any help text 132 | verbose_name = field.verbose_name if hasattr(field, "verbose_name") else "" 133 | 134 | if help_text: 135 | # Add the model field to the end of the docstring as a param 136 | # using the help text as the description 137 | lines.append(f":param {field.attname}: {help_text}") 138 | else: 139 | # Add the model field to the end of the docstring as a param 140 | # using the verbose name as the description 141 | lines.append(f":param {field.attname}: {verbose_name}") 142 | 143 | # Add the field's type to the docstring 144 | lines.append(f":type {field.attname}: {field.__class__.__name__}") 145 | 146 | # Return the extended docstring 147 | return lines 148 | 149 | 150 | def setup(app): 151 | """Register the Django model processor with Sphinx.""" 152 | app.connect("autodoc-process-docstring", project_django_models) 153 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | [jacklinke@gmail.com](mailto:jacklinke@gmail.com). 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][mozilla coc]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][faq]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [mozilla coc]: https://github.com/mozilla/diversity 131 | [faq]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | """Nox sessions.""" 2 | import os 3 | import shlex 4 | import shutil 5 | from pathlib import Path 6 | from textwrap import dedent 7 | 8 | import nox 9 | from nox import session 10 | from nox.sessions import Session 11 | 12 | 13 | # DJANGO_STABLE_VERSION should be set to the latest Django LTS version 14 | 15 | DJANGO_STABLE_VERSION = "5.2" 16 | DJANGO_VERSIONS = [ 17 | "4.2", 18 | "5.0", 19 | "5.1", 20 | "5.2", 21 | ] 22 | 23 | # PYTHON_STABLE_VERSION should be set to the latest stable Python version 24 | 25 | PYTHON_STABLE_VERSION = "3.14" 26 | PYTHON_VERSIONS = ["3.11", "3.12", "3.13", "3.14"] 27 | 28 | 29 | PACKAGE = "django_templated_email_md" 30 | 31 | nox.needs_version = ">= 2024.4.15" 32 | nox.options.sessions = ( 33 | "pre-commit", 34 | "safety", 35 | "tests", 36 | "xdoctest", 37 | "docs-build", 38 | ) 39 | nox.options.default_venv_backend = "venv" 40 | 41 | 42 | def activate_virtualenv_in_precommit_hooks(session: Session) -> None: 43 | """Activate virtualenv in hooks installed by pre-commit. 44 | 45 | This function patches git hooks installed by pre-commit to activate the 46 | session's virtual environment. This allows pre-commit to locate hooks in 47 | that environment when invoked from git. 48 | 49 | Args: 50 | session: The Session object. 51 | """ 52 | assert session.bin is not None # nosec 53 | 54 | # Only patch hooks containing a reference to this session's bindir. Support 55 | # quoting rules for Python and bash, but strip the outermost quotes so we 56 | # can detect paths within the bindir, like /python. 57 | bindirs = [ 58 | bindir[1:-1] if bindir[0] in "'\"" else bindir for bindir in (repr(session.bin), shlex.quote(session.bin)) 59 | ] 60 | 61 | virtualenv = session.env.get("VIRTUAL_ENV") 62 | if virtualenv is None: 63 | return 64 | 65 | headers = { 66 | # pre-commit < 2.16.0 67 | "python": f"""\ 68 | import os 69 | os.environ["VIRTUAL_ENV"] = {virtualenv!r} 70 | os.environ["PATH"] = os.pathsep.join(( 71 | {session.bin!r}, 72 | os.environ.get("PATH", ""), 73 | )) 74 | """, 75 | # pre-commit >= 2.16.0 76 | "bash": f"""\ 77 | VIRTUAL_ENV={shlex.quote(virtualenv)} 78 | PATH={shlex.quote(session.bin)}"{os.pathsep}$PATH" 79 | """, 80 | # pre-commit >= 2.17.0 on Windows forces sh shebang 81 | "/bin/sh": f"""\ 82 | VIRTUAL_ENV={shlex.quote(virtualenv)} 83 | PATH={shlex.quote(session.bin)}"{os.pathsep}$PATH" 84 | """, 85 | } 86 | 87 | hookdir = Path(".git") / "hooks" 88 | if not hookdir.is_dir(): 89 | return 90 | 91 | for hook in hookdir.iterdir(): 92 | if hook.name.endswith(".sample") or not hook.is_file(): 93 | continue 94 | 95 | if not hook.read_bytes().startswith(b"#!"): 96 | continue 97 | 98 | text = hook.read_text() 99 | 100 | if not any(Path("A") == Path("a") and bindir.lower() in text.lower() or bindir in text for bindir in bindirs): 101 | continue 102 | 103 | lines = text.splitlines() 104 | 105 | for executable, header in headers.items(): 106 | if executable in lines[0].lower(): 107 | lines.insert(1, dedent(header)) 108 | hook.write_text("\n".join(lines)) 109 | break 110 | 111 | 112 | @session(name="pre-commit", python=PYTHON_STABLE_VERSION) 113 | @nox.parametrize("django", DJANGO_STABLE_VERSION) 114 | def precommit(session: Session, django: str) -> None: 115 | """Lint using pre-commit.""" 116 | args = session.posargs or [ 117 | "run", 118 | "--all-files", 119 | "--hook-stage=manual", 120 | "--show-diff-on-failure", 121 | ] 122 | session.install( 123 | "bandit", 124 | "black", 125 | "darglint", 126 | "flake8", 127 | "flake8-bugbear", 128 | "flake8-docstrings", 129 | "flake8-rst-docstrings", 130 | "isort", 131 | "pep8-naming", 132 | "pre-commit", 133 | "pre-commit-hooks", 134 | "pyupgrade", 135 | ) 136 | session.run("pre-commit", *args) 137 | if args and args[0] == "install": 138 | activate_virtualenv_in_precommit_hooks(session) 139 | 140 | 141 | @session(python=PYTHON_STABLE_VERSION) 142 | @nox.parametrize("django", DJANGO_STABLE_VERSION) 143 | def safety(session: Session, django: str) -> None: 144 | """Scan dependencies for insecure packages.""" 145 | requirements = session.posargs or ["requirements.txt"] 146 | session.install("safety") 147 | session.run("safety", "check", "--full-report", f"--file={requirements}") 148 | 149 | 150 | @session(python=PYTHON_VERSIONS) 151 | @nox.parametrize("django", DJANGO_VERSIONS) 152 | def tests(session: Session, django: str) -> None: 153 | """Run the test suite.""" 154 | session.run("uv", "sync", "--prerelease=allow", "--extra=dev") 155 | try: 156 | 157 | session.run("coverage", "run", "-m", "pytest", "-vv", *session.posargs) 158 | finally: 159 | if session.interactive: 160 | session.notify("coverage", posargs=[]) 161 | 162 | 163 | @session(python=PYTHON_STABLE_VERSION) 164 | @nox.parametrize("django", DJANGO_STABLE_VERSION) 165 | def coverage(session: Session, django: str) -> None: 166 | """Produce the coverage report.""" 167 | args = session.posargs or ["report"] 168 | 169 | session.install("coverage[toml]") 170 | 171 | if not session.posargs and any(Path().glob(".coverage.*")): 172 | session.run("coverage", "combine") 173 | 174 | session.run("coverage", *args) 175 | 176 | 177 | @session(python=PYTHON_STABLE_VERSION) 178 | @nox.parametrize("django", DJANGO_STABLE_VERSION) 179 | def xdoctest(session: Session, django: str) -> None: 180 | """Run examples with xdoctest.""" 181 | if session.posargs: 182 | args = [PACKAGE, *session.posargs] 183 | else: 184 | args = [f"--modname={PACKAGE}", "--command=all"] 185 | if "FORCE_COLOR" in os.environ: 186 | args.append("--colored=1") 187 | 188 | session.install(".") 189 | session.install("xdoctest[colors]") 190 | session.run("python", "-m", "xdoctest", *args) 191 | 192 | 193 | @session(name="docs-build", python=PYTHON_STABLE_VERSION) 194 | @nox.parametrize("django", DJANGO_STABLE_VERSION) 195 | def docs_build(session: Session, django: str) -> None: 196 | """Build the documentation.""" 197 | args = session.posargs or ["docs", "docs/_build"] 198 | if not session.posargs and "FORCE_COLOR" in os.environ: 199 | args.insert(0, "--color") 200 | 201 | session.install(".") 202 | session.install("-r", "docs/requirements.txt") 203 | 204 | build_dir = Path("docs", "_build") 205 | if build_dir.exists(): 206 | shutil.rmtree(build_dir) 207 | 208 | session.run("sphinx-build", *args) 209 | 210 | 211 | @session(python=PYTHON_STABLE_VERSION) 212 | @nox.parametrize("django", DJANGO_STABLE_VERSION) 213 | def docs(session: Session, django: str) -> None: 214 | """Build and serve the documentation with live reloading on file changes.""" 215 | args = session.posargs or ["--open-browser", "docs", "docs/_build"] 216 | session.install(".") 217 | session.install("-r", "docs/requirements.txt") 218 | session.install("sphinx-autobuild") 219 | 220 | build_dir = Path("docs", "_build") 221 | if build_dir.exists(): 222 | shutil.rmtree(build_dir) 223 | 224 | session.run("sphinx-autobuild", *args) 225 | -------------------------------------------------------------------------------- /src/templated_email_md/templates/templated_email/markdown_styles.css: -------------------------------------------------------------------------------- 1 | /* Copyright (c) [2013] [Lee Munroe], see license.txt */ 2 | /* ------------------------------------- 3 | GLOBAL RESETS 4 | ------------------------------------- */ 5 | 6 | /* All the styling goes here */ 7 | 8 | img { 9 | border: none; 10 | -ms-interpolation-mode: bicubic; 11 | max-width: 100%; 12 | } 13 | 14 | body { 15 | background-color: #f6f6f6; 16 | font-family: sans-serif; 17 | -webkit-font-smoothing: antialiased; 18 | font-size: 14px; 19 | line-height: 1.4; 20 | margin: 0; 21 | padding: 0; 22 | -ms-text-size-adjust: 100%; 23 | -webkit-text-size-adjust: 100%; 24 | } 25 | 26 | table { 27 | border-collapse: separate; 28 | mso-table-lspace: 0pt; 29 | mso-table-rspace: 0pt; 30 | width: 100%; 31 | } 32 | 33 | table td { 34 | font-family: sans-serif; 35 | font-size: 14px; 36 | vertical-align: top; 37 | } 38 | 39 | /* ------------------------------------- 40 | BODY & CONTAINER 41 | ------------------------------------- */ 42 | 43 | .body { 44 | background-color: #f6f6f6; 45 | width: 100%; 46 | } 47 | 48 | /* Set a max-width, and make it display as block so it will automatically stretch to that width, but will also shrink down on a phone or something */ 49 | .container { 50 | display: block; 51 | margin: 0 auto !important; 52 | /* makes it centered */ 53 | max-width: 580px; 54 | padding: 10px; 55 | width: 580px; 56 | } 57 | 58 | /* This should also be a block element, so that it will fill 100% of the .container */ 59 | .content { 60 | box-sizing: border-box; 61 | display: block; 62 | margin: 0 auto; 63 | max-width: 580px; 64 | padding: 10px; 65 | } 66 | 67 | /* ------------------------------------- 68 | HEADER, FOOTER, MAIN 69 | ------------------------------------- */ 70 | .main { 71 | background: #ffffff; 72 | border-radius: 3px; 73 | width: 100%; 74 | } 75 | 76 | .wrapper { 77 | box-sizing: border-box; 78 | padding: 20px; 79 | } 80 | 81 | .content-block { 82 | padding-bottom: 10px; 83 | padding-top: 10px; 84 | } 85 | 86 | .footer { 87 | clear: both; 88 | margin-top: 10px; 89 | text-align: center; 90 | width: 100%; 91 | } 92 | 93 | .footer td, 94 | .footer p, 95 | .footer span, 96 | .footer a { 97 | color: #6e6e6e; 98 | font-size: 12px; 99 | text-align: center; 100 | } 101 | 102 | /* ------------------------------------- 103 | TYPOGRAPHY 104 | ------------------------------------- */ 105 | h1, 106 | h2, 107 | h3, 108 | h4 { 109 | color: #000000; 110 | font-family: sans-serif; 111 | font-weight: 400; 112 | line-height: 1.4; 113 | margin: 0; 114 | margin-bottom: 30px; 115 | } 116 | 117 | h1 { 118 | font-size: 35px; 119 | font-weight: 300; 120 | text-align: center; 121 | text-transform: capitalize; 122 | } 123 | 124 | p, 125 | ul, 126 | ol { 127 | font-family: sans-serif; 128 | font-size: 14px; 129 | font-weight: normal; 130 | margin: 0; 131 | margin-bottom: 15px; 132 | } 133 | 134 | p li, 135 | ul li, 136 | ol li { 137 | list-style-position: inside; 138 | margin-left: 5px; 139 | } 140 | 141 | a { 142 | color: #3498db; 143 | text-decoration: underline; 144 | } 145 | 146 | /* ------------------------------------- 147 | BUTTONS 148 | ------------------------------------- */ 149 | .btn { 150 | box-sizing: border-box; 151 | width: 100%; 152 | } 153 | 154 | .btn > tbody > tr > td { 155 | padding-bottom: 15px; 156 | } 157 | 158 | .btn table { 159 | width: auto; 160 | } 161 | 162 | .btn table td { 163 | background-color: #ffffff; 164 | border-radius: 5px; 165 | text-align: center; 166 | } 167 | 168 | .btn a { 169 | background-color: #ffffff; 170 | border: solid 1px #3498db; 171 | border-radius: 5px; 172 | box-sizing: border-box; 173 | color: #3498db; 174 | cursor: pointer; 175 | display: inline-block; 176 | font-size: 14px; 177 | font-weight: bold; 178 | margin: 0; 179 | padding: 12px 25px; 180 | text-decoration: none; 181 | text-transform: capitalize; 182 | } 183 | 184 | .btn-primary table td { 185 | background-color: #3498db; 186 | } 187 | 188 | .btn-primary a { 189 | background-color: #3498db; 190 | border-color: #3498db; 191 | color: #ffffff; 192 | } 193 | 194 | /* ------------------------------------- 195 | OTHER STYLES THAT MIGHT BE USEFUL 196 | ------------------------------------- */ 197 | .last { 198 | margin-bottom: 0; 199 | } 200 | 201 | .first { 202 | margin-top: 0; 203 | } 204 | 205 | .align-center { 206 | text-align: center; 207 | } 208 | 209 | .align-right { 210 | text-align: right; 211 | } 212 | 213 | .align-left { 214 | text-align: left; 215 | } 216 | 217 | .clear { 218 | clear: both; 219 | } 220 | 221 | .mt0 { 222 | margin-top: 0; 223 | } 224 | 225 | .mb0 { 226 | margin-bottom: 0; 227 | } 228 | 229 | .preheader { 230 | color: transparent; 231 | display: none; 232 | height: 0; 233 | max-height: 0; 234 | max-width: 0; 235 | opacity: 0; 236 | overflow: hidden; 237 | mso-hide: all; 238 | visibility: hidden; 239 | width: 0; 240 | } 241 | 242 | .powered-by a { 243 | text-decoration: none; 244 | } 245 | 246 | hr { 247 | border: 0; 248 | border-bottom: 1px solid #f6f6f6; 249 | margin: 20px 0; 250 | } 251 | 252 | /* ------------------------------------- 253 | RESPONSIVE AND MOBILE FRIENDLY STYLES 254 | ------------------------------------- */ 255 | @media only screen and (max-width: 620px) { 256 | table.body h1 { 257 | font-size: 28px !important; 258 | margin-bottom: 10px !important; 259 | } 260 | 261 | table.body p, 262 | table.body ul, 263 | table.body ol, 264 | table.body td, 265 | table.body span, 266 | table.body a { 267 | font-size: 16px !important; 268 | } 269 | 270 | table.body .wrapper, 271 | table.body .article { 272 | padding: 24px !important; 273 | } 274 | 275 | table.body .content { 276 | padding: 0 !important; 277 | } 278 | 279 | table.body .container { 280 | padding: 0 !important; 281 | width: 100% !important; 282 | } 283 | 284 | table.body .main { 285 | border-left-width: 0 !important; 286 | border-radius: 0 !important; 287 | border-right-width: 0 !important; 288 | } 289 | 290 | table.body .btn table { 291 | width: 100% !important; 292 | } 293 | 294 | table.body .btn a { 295 | width: 100% !important; 296 | } 297 | 298 | table.body .img-responsive { 299 | height: auto !important; 300 | max-width: 100% !important; 301 | width: auto !important; 302 | } 303 | } 304 | 305 | /* ------------------------------------- 306 | PRESERVE THESE STYLES IN THE HEAD 307 | ------------------------------------- */ 308 | @media all { 309 | .ExternalClass { 310 | width: 100%; 311 | } 312 | 313 | .ExternalClass, 314 | .ExternalClass p, 315 | .ExternalClass span, 316 | .ExternalClass font, 317 | .ExternalClass td, 318 | .ExternalClass div { 319 | line-height: 100%; 320 | } 321 | 322 | .apple-link a { 323 | color: inherit !important; 324 | font-family: inherit !important; 325 | font-size: inherit !important; 326 | font-weight: inherit !important; 327 | line-height: inherit !important; 328 | text-decoration: none !important; 329 | } 330 | 331 | #MessageViewBody a { 332 | color: inherit; 333 | text-decoration: none; 334 | font-size: inherit; 335 | font-family: inherit; 336 | font-weight: inherit; 337 | line-height: inherit; 338 | } 339 | 340 | .btn-primary table td:hover { 341 | background-color: #34495e !important; 342 | } 343 | 344 | .btn-primary a:hover { 345 | background-color: #34495e !important; 346 | border-color: #34495e !important; 347 | } 348 | } 349 | -------------------------------------------------------------------------------- /docs/settings.md: -------------------------------------------------------------------------------- 1 | 2 | # Settings Guide 3 | 4 | There are several settings available to ensure `django-templated-email-md` can be configured for your needs. 5 | 6 | The only one that is **required** is `TEMPLATED_EMAIL_BACKEND`. 7 | 8 | ## Core Settings 9 | 10 | ### `TEMPLATED_EMAIL_BACKEND` 11 | - **Default:** None 12 | - **Required:** Yes 13 | - **Type:** String 14 | - **Description:** The backend class to use for processing Markdown email templates. 15 | - **Example:** 16 | ```python 17 | TEMPLATED_EMAIL_BACKEND = 'templated_email_md.backend.MarkdownTemplateBackend' 18 | ``` 19 | 20 | ### `TEMPLATED_EMAIL_TEMPLATE_DIR` 21 | - **Default:** 'templated_email/' 22 | - **Required:** No 23 | - **Type:** String 24 | - **Description:** Directory where email templates are stored. Must include a trailing slash. This is set by default in the `django-templated-email` package. 25 | - **Example:** 26 | ```python 27 | TEMPLATED_EMAIL_TEMPLATE_DIR = 'templated_email/' 28 | ``` 29 | - **Further Reading:** [Django Template Loading Documentation](https://docs.djangoproject.com/en/stable/topics/templates/#template-loading) 30 | 31 | ### `TEMPLATED_EMAIL_FILE_EXTENSION` 32 | - **Default:** 'md' 33 | - **Required:** No 34 | - **Type:** String 35 | - **Description:** File extension for Markdown template files. 36 | - **Example:** 37 | ```python 38 | TEMPLATED_EMAIL_FILE_EXTENSION = 'md' 39 | ``` 40 | 41 | ### `TEMPLATED_EMAIL_BASE_HTML_TEMPLATE` 42 | - **Default:** 'templated_email/markdown_base.html' 43 | - **Required:** No 44 | - **Type:** String 45 | - **Description:** Path to the base HTML template that wraps the Markdown content. See the [usage guide](https://django-templated-email-md.readthedocs.io/en/latest/usage.html) for more information on available approaches to overriding the default template. 46 | - **Example:** 47 | ```python 48 | TEMPLATED_EMAIL_BASE_HTML_TEMPLATE = 'my_app/markdown_base.html' 49 | ``` 50 | - **Further Reading:** [Django Template Inheritance](https://docs.djangoproject.com/en/stable/ref/templates/language/#template-inheritance) 51 | 52 | ## Markdown Settings 53 | 54 | ### `TEMPLATED_EMAIL_MARKDOWN_EXTENSIONS` 55 | - **Default:** ['markdown.extensions.extra', 'markdown.extensions.meta', 'markdown.extensions.tables'] 56 | - **Required:** No 57 | - **Type:** List 58 | - **Description:** List of Markdown extensions to enable when processing templates. 59 | - **Example:** 60 | ```python 61 | TEMPLATED_EMAIL_MARKDOWN_EXTENSIONS = [ 62 | 'markdown.extensions.extra', 63 | 'markdown.extensions.meta', 64 | 'markdown.extensions.tables', 65 | 'markdown.extensions.codehilite', 66 | ] 67 | ``` 68 | - **Further Reading:** 69 | - [Python-Markdown Extensions Documentation](https://python-markdown.github.io/extensions/) 70 | - Popular extensions: 71 | - [Extra Extension](https://python-markdown.github.io/extensions/extra/) 72 | - [Meta Extension](https://python-markdown.github.io/extensions/meta_data/) 73 | - [Tables Extension](https://python-markdown.github.io/extensions/tables/) 74 | 75 | ## URL Settings 76 | 77 | ### `TEMPLATED_EMAIL_BASE_URL` 78 | - **Default:** None 79 | - **Required:** No 80 | - **Type:** String 81 | - **Description:** Base URL to prepend to relative URLs in email templates. 82 | - **Example:** 83 | ```python 84 | TEMPLATED_EMAIL_BASE_URL = 'https://example.com' 85 | ``` 86 | - **Further Reading:** 87 | - [django-templated-email-md Usage Guide](https://django-templated-email-md.readthedocs.io/en/latest/usage.html) 88 | - [Django URL Configuration](https://docs.djangoproject.com/en/stable/topics/http/urls/) 89 | 90 | ## Default Content Settings 91 | 92 | ### `TEMPLATED_EMAIL_DEFAULT_SUBJECT` 93 | - **Default:** 'Hello!' 94 | - **Required:** No 95 | - **Type:** String 96 | - **Description:** Default subject line used when no subject is provided in the template or context. 97 | - **Example:** 98 | ```python 99 | TEMPLATED_EMAIL_DEFAULT_SUBJECT = 'Message from Our Company' 100 | ``` 101 | 102 | ### `TEMPLATED_EMAIL_DEFAULT_PREHEADER` 103 | - **Default:** '' 104 | - **Required:** No 105 | - **Type:** String 106 | - **Description:** Default preheader text used when no preheader is provided in the template or context. 107 | - **Example:** 108 | ```python 109 | TEMPLATED_EMAIL_DEFAULT_PREHEADER = 'Important information from Our Company' 110 | ``` 111 | - **Further Reading:** [Email Preheader Best Practices](https://www.litmus.com/blog/the-ultimate-guide-to-preview-text-support/) (referred to in this blog as 'preview text') 112 | 113 | ## Plain Text Generation Settings 114 | 115 | ### `TEMPLATED_EMAIL_HTML2TEXT_SETTINGS` 116 | - **Default:** {} 117 | - **Required:** No 118 | - **Type:** Dictionary 119 | - **Description:** Configuration options for html2text when generating plain text versions of emails. 120 | - **Some of the Available Options:** 121 | - `ignore_links`: Exclude links from plain text output 122 | - `ignore_images`: Exclude image descriptions 123 | - `body_width`: Maximum line width before wrapping 124 | - `ignore_emphasis`: Exclude emphasis markers 125 | - `mark_code`: Wrap code blocks with backticks 126 | - `wrap_links`: Wrap URLs in angle brackets 127 | - **Example:** 128 | ```python 129 | TEMPLATED_EMAIL_HTML2TEXT_SETTINGS = { 130 | 'ignore_links': False, 131 | 'ignore_images': True, 132 | 'body_width': 0, 133 | 'ignore_emphasis': True, 134 | 'mark_code': False, 135 | 'wrap_links': False, 136 | } 137 | ``` 138 | - **Further Reading:** [Available html2text Options](https://github.com/Alir3z4/html2text/blob/master/docs/usage.md#available-options) 139 | 140 | ## Error Handling Settings 141 | 142 | ### `TEMPLATED_EMAIL_FAIL_SILENTLY` 143 | - **Default:** False 144 | - **Required:** No 145 | - **Type:** Boolean 146 | - **Description:** If True, suppresses exceptions during email rendering and sends fallback email instead. 147 | - **Example:** 148 | ```python 149 | TEMPLATED_EMAIL_FAIL_SILENTLY = True 150 | ``` 151 | 152 | ## Complete Configuration Example 153 | 154 | Here's a complete example showing all settings with their default values: 155 | 156 | ```python 157 | # Email Backend Configuration 158 | TEMPLATED_EMAIL_BACKEND = 'templated_email_md.backend.MarkdownTemplateBackend' 159 | TEMPLATED_EMAIL_TEMPLATE_DIR = 'templated_email/' 160 | TEMPLATED_EMAIL_FILE_EXTENSION = 'md' 161 | TEMPLATED_EMAIL_BASE_HTML_TEMPLATE = 'templated_email/markdown_base.html' 162 | 163 | # Markdown Extensions 164 | TEMPLATED_EMAIL_MARKDOWN_EXTENSIONS = [ 165 | 'markdown.extensions.extra', 166 | 'markdown.extensions.meta', 167 | 'markdown.extensions.tables', 168 | ] 169 | 170 | # URL Configuration 171 | TEMPLATED_EMAIL_BASE_URL = '' 172 | 173 | # Default Content 174 | TEMPLATED_EMAIL_DEFAULT_SUBJECT = 'Hello!' 175 | TEMPLATED_EMAIL_DEFAULT_PREHEADER = '' 176 | 177 | # Plain Text Generation 178 | TEMPLATED_EMAIL_HTML2TEXT_SETTINGS = { 179 | 'ignore_links': False, 180 | 'ignore_images': True, 181 | 'body_width': 0, 182 | 'ignore_emphasis': True, 183 | 'mark_code': False, 184 | 'wrap_links': False, 185 | } 186 | 187 | # Error Handling 188 | TEMPLATED_EMAIL_FAIL_SILENTLY = False 189 | ``` 190 | 191 | ## Notes 192 | 193 | 1. To implement any of the above settings, the setting should be added to your Django project's `settings.py` file. 194 | 2. Many settings have sensible defaults and are optional. 195 | 3. The TEMPLATED_EMAIL_BACKEND setting is required and must be set explicitly. 196 | 4. When using relative URLs in templates (either explicitly in the template or when using Django's [url template tags](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#url)), either TEMPLATED_EMAIL_BASE_URL should be set in settings, or base_url should be provided when calling send_templated_mail. 197 | 198 | ## Additional Resources 199 | 200 | - [django-templated-email Documentation](https://github.com/vintasoftware/django-templated-email/) 201 | - [Django Email Documentation](https://docs.djangoproject.com/en/stable/topics/email/) 202 | - [Django Templates Documentation](https://docs.djangoproject.com/en/stable/topics/templates/) 203 | - [Python-Markdown Documentation](https://python-markdown.github.io/) 204 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-templated-email-md 2 | 3 | [![PyPI](https://img.shields.io/pypi/v/django-templated-email-md.svg)][pypi status] 4 | [![Status](https://img.shields.io/pypi/status/django-templated-email-md.svg)][pypi status] 5 | [![Python Version](https://img.shields.io/pypi/pyversions/django-templated-email-md)][pypi status] 6 | [![License](https://img.shields.io/pypi/l/django-templated-email-md)][license] 7 | 8 | [![Read the documentation at https://django-templated-email-md.readthedocs.io/](https://img.shields.io/readthedocs/django-templated-email-md/latest.svg?label=Read%20the%20Docs)][read the docs] 9 | [![Tests](https://github.com/OmenApps/django-templated-email-md/actions/workflows/tests.yml/badge.svg)][tests] 10 | [![Codecov](https://codecov.io/gh/OmenApps/django-templated-email-md/branch/main/graph/badge.svg)][codecov] 11 | 12 | [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)][pre-commit] 13 | [![Black](https://img.shields.io/badge/code%20style-black-000000.svg)][black] 14 | [![Published on Django Packages](https://img.shields.io/badge/Published%20on-Django%20Packages-0c3c26)](https://djangopackages.org/packages/p/django-templated-email-md/) 15 | 16 | [pypi status]: https://pypi.org/project/django-templated-email-md/ 17 | [read the docs]: https://django-templated-email-md.readthedocs.io/ 18 | [tests]: https://github.com/OmenApps/django-templated-email-md/actions?workflow=Tests 19 | [codecov]: https://app.codecov.io/gh/OmenApps/django-templated-email-md 20 | [pre-commit]: https://github.com/pre-commit/pre-commit 21 | [black]: https://github.com/psf/black 22 | 23 | ## Features 24 | 25 | - **Markdown Templates**: Write email templates using Markdown syntax for cleaner and more readable templates. 26 | - **Automatic Conversion**: Automatically converts Markdown to HTML and generates a plain text version of emails. 27 | - **CSS Inlining**: Inlines CSS styles for better email client compatibility using Premailer. 28 | - **Seamless Integration**: Works as an extension of `django-templated-email`, allowing for easy integration into existing projects. 29 | - **Template Inheritance**: Supports Django template inheritance and template tags in your Markdown templates. 30 | 31 | ## Installation 32 | 33 | You can install `django-templated-email-md` via [pip] from [PyPI]: 34 | 35 | ```bash 36 | pip install django-templated-email-md 37 | ``` 38 | 39 | ## Requirements 40 | 41 | - Python 3.11+ 42 | - Django 4.2+ 43 | - django-templated-email 3.0+ 44 | 45 | ### Add to `INSTALLED_APPS` 46 | 47 | Add `templated_email_md` to your `INSTALLED_APPS` in `settings.py`: 48 | 49 | ```python 50 | INSTALLED_APPS = [ 51 | # ... 52 | 'templated_email_md', 53 | # ... 54 | ] 55 | ``` 56 | 57 | ## Configuration 58 | 59 | Assuming you have already installed and configured [django-templated-email](https://github.com/vintasoftware/django-templated-email/), update your Django settings as follows: 60 | 61 | ```python 62 | # settings.py 63 | 64 | # Configure the templated email backend 65 | TEMPLATED_EMAIL_BACKEND = 'templated_email_md.backend.MarkdownTemplateBackend' 66 | 67 | # Optional: Specify the base HTML template for wrapping your content. See the Usage guide for details. 68 | TEMPLATED_EMAIL_BASE_HTML_TEMPLATE = 'templated_email/markdown_base.html' 69 | 70 | # Set the directory where your email templates are stored 71 | TEMPLATED_EMAIL_TEMPLATE_DIR = 'templated_email/' # Ensure there's a trailing slash 72 | 73 | # Define the file extension for your Markdown templates 74 | TEMPLATED_EMAIL_FILE_EXTENSION = 'md' 75 | 76 | # Optional: Specify Markdown extensions if needed 77 | TEMPLATED_EMAIL_MARKDOWN_EXTENSIONS = [ 78 | 'markdown.extensions.extra', 79 | 'markdown.extensions.meta', 80 | 'markdown.extensions.tables', 81 | ] 82 | 83 | # Optional: Base URL for resolving relative URLs in CSS/images 84 | TEMPLATED_EMAIL_BASE_URL = 'https://example.com' 85 | 86 | # Optional: Customize plain text generation 87 | TEMPLATED_EMAIL_HTML2TEXT_SETTINGS = { 88 | 'ignore_emphasis': True, 89 | 'body_width': 0, 90 | } 91 | 92 | # Optional: Fail silently on errors (default: False) 93 | TEMPLATED_EMAIL_FAIL_SILENTLY = False 94 | ``` 95 | 96 | For a complete list of available settings, see the [settings documentation][settings docs]. 97 | 98 | [settings docs]: https://django-templated-email-md.readthedocs.io/en/latest/settings.html 99 | 100 | ## Usage 101 | 102 | ### Creating Markdown Templates 103 | 104 | Place your Markdown email templates in the `templated_email/` directory within your project's templates directory. For example, create a file `templated_email/welcome.md`: 105 | 106 | ```markdown 107 | {% block subject %}Test Email{% endblock %} 108 | {% block preheader %}Thanks for signing up!{% endblock %} 109 | 110 | {% block content %} 111 | # {{ user.first_name }}, you're in! 112 | 113 | ![Gorgeous, golden potato, Spedona, CC BY-SA 3.0 https://creativecommons.org/licenses/by-sa/3.0, via Wikimedia Commons](https://upload.wikimedia.org/wikipedia/commons/a/a4/Icone_pdt.png) 114 | 115 | ## Welcome to The Potato Shop 116 | 117 | ### Hello {{ user.first_name }}! 👋 118 | 119 | > You have been invited to set up an account at the Potato shop on behalf of **{{ inviter.name }}**. 120 | 121 | Please click [this link]({% url 'invitations:accept-invite' key=invitation.key %}) to establish your account. 122 | 123 | {% blocktranslate %}You will be directed to the 'set password' tool, where you can establish your account password.{% endblocktranslate %} 124 | 125 | --- 126 | 127 | Best regards, 128 | 129 | *Jack Linke* 130 | Potato Shop, LLC - Managing Director 131 | 132 | *Semi-round, Starchy Veggies for All* 133 | {% endblock %} 134 | ``` 135 | 136 | ### Sending Emails 137 | 138 | Use the `send_templated_mail` function to send emails using your Markdown templates, just as you would with the base django-templated-email package: 139 | 140 | ```python 141 | from templated_email import send_templated_mail 142 | 143 | send_templated_mail( 144 | template_name='welcome', 145 | from_email='Potato Shop Support ', 146 | recipient_list=['terrence3725fries@wannamashitup.com'], 147 | context={ 148 | 'user': request.user, 149 | 'inviter': inviter, 150 | }, 151 | ) 152 | ``` 153 | 154 | You can also pass a `base_url` parameter for resolving relative URLs in CSS and images: 155 | 156 | ```python 157 | send_templated_mail( 158 | template_name='welcome', 159 | from_email='support@example.com', 160 | recipient_list=['user@example.com'], 161 | context={ 162 | 'user': request.user, 163 | }, 164 | base_url='https://example.com', # Optional: for CSS/image URLs 165 | ) 166 | ``` 167 | 168 | ### The Result 169 | 170 | #### Inbox Preview 171 | 172 | ![Inbox Preview](https://raw.githubusercontent.com/OmenApps/django-templated-email-md/refs/heads/main/docs/_static/inbox_screenshot.png) 173 | 174 | #### Email Preview 175 | 176 | ![Email Preview](https://raw.githubusercontent.com/OmenApps/django-templated-email-md/0495a02b8f4a6affebefb3c2e89562c553851b17/docs/_static/email_screenshot.png) 177 | 178 | More detailed information can be found in the [usage guide][usage guide]. 179 | 180 | ## Documentation 181 | 182 | For more detailed information, please refer to the [full documentation][read the docs]. 183 | 184 | ## Contributing 185 | 186 | Contributions are very welcome. To learn more, see the [Contributor Guide]. 187 | 188 | ## License 189 | 190 | Distributed under the terms of the [MIT license][license], `django-templated-email-md` is free and open source software. 191 | 192 | ## Issues 193 | 194 | If you encounter any problems, please [file an issue] along with a detailed description. 195 | 196 | ## Credits 197 | 198 | We are grateful to the maintainers of the following projects: 199 | 200 | - [django-templated-email](https://github.com/vintasoftware/django-templated-email/) 201 | - [emark](https://github.com/voiio/emark) 202 | 203 | This project was generated from [@OmenApps]'s [Cookiecutter Django Package] template. 204 | 205 | [@omenapps]: https://github.com/OmenApps 206 | [pypi]: https://pypi.org/ 207 | [license]: https://github.com/OmenApps/django-templated-email-md/blob/main/LICENSE 208 | [read the docs]: https://django-templated-email-md.readthedocs.io/ 209 | [usage guide]: https://django-templated-email-md.readthedocs.io/en/latest/usage.html 210 | [contributor guide]: https://github.com/OmenApps/django-templated-email-md/blob/main/CONTRIBUTING.md 211 | [file an issue]: https://github.com/OmenApps/django-templated-email-md/issues 212 | [cookiecutter django package]: https://github.com/OmenApps/cookiecutter-django-package 213 | [pip]: https://pip.pypa.io/ 214 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | # Usage Guide 2 | 3 | ## Installation 4 | 5 | To install `django-templated-email-md`, run the following command: 6 | 7 | ```bash 8 | pip install django-templated-email-md 9 | ``` 10 | 11 | ## Configuration 12 | 13 | ### 1. Add to `INSTALLED_APPS` 14 | 15 | Add `templated_email_md` to your `INSTALLED_APPS` in `settings.py`: 16 | 17 | ```python 18 | INSTALLED_APPS = [ 19 | # ... 20 | 'templated_email_md', 21 | # ... 22 | ] 23 | ``` 24 | 25 | ### 2. Update Settings 26 | 27 | Assuming you have already installed and configured [django-templated-email](https://github.com/vintasoftware/django-templated-email/), update your Django settings as follows: 28 | 29 | ```python 30 | # settings.py 31 | 32 | # Configure the templated email backend 33 | TEMPLATED_EMAIL_BACKEND = 'templated_email_md.backend.MarkdownTemplateBackend' 34 | 35 | # Specify the base HTML template for wrapping your content 36 | TEMPLATED_EMAIL_BASE_HTML_TEMPLATE = 'templated_email/markdown_base.html' 37 | 38 | # Set the directory where your email templates are stored 39 | TEMPLATED_EMAIL_TEMPLATE_DIR = 'templated_email/' # Ensure there's a trailing slash 40 | 41 | # Define the file extension for your Markdown templates 42 | TEMPLATED_EMAIL_FILE_EXTENSION = 'md' 43 | 44 | # Optional: Specify Markdown extensions if needed 45 | TEMPLATED_EMAIL_MARKDOWN_EXTENSIONS = [ 46 | 'markdown.extensions.extra', 47 | 'markdown.extensions.meta', 48 | 'markdown.extensions.tables', 49 | ] 50 | ``` 51 | 52 | ### 3. Ensure Template Loaders Include `APP_DIRS` 53 | 54 | Make sure that your `TEMPLATES` setting includes `APP_DIRS` set to `True` or includes the `django.template.loaders.app_directories.Loader`: 55 | 56 | ```python 57 | # settings.py 58 | 59 | TEMPLATES = [ 60 | { 61 | # ... 62 | 'APP_DIRS': True, 63 | # ... 64 | }, 65 | ] 66 | ``` 67 | 68 | ## Creating Markdown Templates 69 | 70 | Place your Markdown email templates in the `templated_email/` directory within your project's templates directory or within an app's `templates` directory. 71 | 72 | ### Example Template: `templated_email/welcome.md` 73 | 74 | *Note: The subject and preheader can be provided as template blocks or as context arguments when sending email.* 75 | 76 | ```markdown 77 | {% load i18n %} 78 | 79 | {% block subject %}{% trans "Welcome to Our Service" %}{% endblock %} 80 | 81 | {% block preheader %}{% trans "Thanks for signing up!" %}{% endblock %} 82 | 83 | {% block content %} 84 | # {% trans "Welcome" %}, {{ user.first_name }}! 85 | 86 | {% trans "We're thrilled to have you join our service. Here are a few things you can do to get started:" %} 87 | 88 | 1. **{% trans "Complete your profile" %}** 89 | 2. **{% trans "Explore our features" %}** 90 | 3. **{% trans "Connect with other users" %}** 91 | 92 | {% trans "If you have any questions, don't hesitate to reach out to our support team." %} 93 | 94 | {% trans "Best regards," %} 95 | {% trans "The Team" %} 96 | {% endblock %} 97 | ``` 98 | 99 | ### Template Blocks 100 | 101 | - **subject**: Defines the email subject line. 102 | - **preheader** *(optional)*: A short summary text that follows the subject line when viewing the email in an inbox. 103 | - **content**: The main content of your email. 104 | 105 | ### Using Django Template Syntax 106 | 107 | You can use Django's template language within your Markdown templates to make each email dynamic. 108 | 109 | ## Sending Emails 110 | 111 | Use the [`send_templated_mail`](https://github.com/vintasoftware/django-templated-email/?tab=readme-ov-file#sending-templated-emails) function from `django-templated-email` to send emails using your Markdown templates: 112 | 113 | ```python 114 | from templated_email import send_templated_mail 115 | 116 | send_templated_mail( 117 | template_name='welcome', 118 | from_email='from@example.com', 119 | recipient_list=['to@example.com'], 120 | context={ 121 | 'user': user_instance, 122 | # Add other context variables as needed 123 | }, 124 | ) 125 | ``` 126 | 127 | *Remember to provide all necessary context variables when sending the email.* 128 | 129 | ### Important Notes 130 | 131 | - **Context Variables**: Ensure that all variables used in your templates are provided in the `context` dictionary. 132 | - **Template Name**: Do not include the file extension when specifying the `template_name`. 133 | 134 | ## Advanced Usage 135 | 136 | ### Custom Base Template 137 | 138 | While we recommend you stick with the provided base template, you can create a custom base HTML template to wrap your Markdown content. 139 | This allows you to define the overall structure and style of your emails. 140 | 141 | There are two ways to achieve this: 142 | 143 | 1. **Template Overrides**: Within the `templates/` directory in your project's base directory, create a `templated_email/` directory. Then add a `markdown_base.html` file to serve as the base template. This will override the default base template from the package. 144 | 2. **Custom Base Template**: Place your custom base HTML template elsewhere and update the `TEMPLATED_EMAIL_BASE_HTML_TEMPLATE` setting to point to it. 145 | 146 | #### Example: `templated_email/markdown_base.html` 147 | 148 | ```html 149 | 150 | 151 | 152 | 153 | {{ subject }} 154 | 157 | 158 | 159 | {% spaceless %} 160 | {% block content %}{{ markdown_content|safe }}{% endblock %} 161 | {% endspaceless %} 162 | 163 | 164 | ``` 165 | 166 | Update the `TEMPLATED_EMAIL_BASE_HTML_TEMPLATE` setting if you use a custom template in a different location: 167 | 168 | ```python 169 | TEMPLATED_EMAIL_BASE_HTML_TEMPLATE = 'templated_email/markdown_base.html' 170 | ``` 171 | 172 | ### Inline Styles 173 | 174 | The `MarkdownTemplateBackend` uses Premailer to inline CSS styles for better email client compatibility. 175 | 176 | - **Include ` 366 | 375 | Link 376 | 377 | 378 | 379 |
Content
380 | """ 381 | cleaned_html = backend._remove_comments(html_with_comments) # pylint: disable=W0212 382 | 383 | # Regular comments should be removed 384 | assert "HTML Comment" not in cleaned_html 385 | assert "Another Comment" not in cleaned_html 386 | assert "CSS Comment" not in cleaned_html 387 | assert "JavaScript Comment" not in cleaned_html 388 | assert "/* Multi-line" not in cleaned_html 389 | assert "Comment */" not in cleaned_html 390 | assert "End of line comment" not in cleaned_html 391 | assert "This is also a comment" not in cleaned_html 392 | assert "/*" not in cleaned_html 393 | assert "*/" not in cleaned_html 394 | 395 | # Content should remain 396 | assert "
Content
" in cleaned_html 397 | assert ".class { color: red; }" in cleaned_html 398 | assert "var x = 1;" in cleaned_html 399 | assert "Link" in cleaned_html 400 | assert "//cdn.example.com" in cleaned_html 401 | 402 | # IE conditional comments should be preserved 403 | assert "" in cleaned_html 404 | 405 | 406 | def test_default_subject_and_preheader(): 407 | """ 408 | Test that the default subject and preheader are used when 409 | not provided in the template or context. 410 | """ 411 | # Send an email without subject or preheader in template or context 412 | send_templated_mail( 413 | template_name="test_no_subject_preheader", 414 | from_email="from@example.com", 415 | recipient_list=["to@example.com"], 416 | context={"name": "Test User"}, 417 | ) 418 | 419 | assert len(mail.outbox) == 1 420 | email = mail.outbox[0] 421 | 422 | # Default subject should be 'Hello!' as per the default in the backend 423 | assert email.subject == _("Hello!") 424 | # Preheader should be empty string by default 425 | # Since preheader may not be directly visible, check in the HTML content 426 | html_content = email.alternatives[0][0] 427 | assert "Hello!" in email.subject 428 | assert '" in html_content 431 | 432 | 433 | def test_subject_and_preheader_provided(): 434 | """ 435 | Test that when subject and preheader are provided in the template, 436 | they override the default values. 437 | """ 438 | # Send an email with subject and preheader in the template 439 | send_templated_mail( 440 | template_name="test_subject_preheader_provided", 441 | from_email="from@example.com", 442 | recipient_list=["to@example.com"], 443 | context={"name": "Test User"}, 444 | ) 445 | 446 | assert len(mail.outbox) == 1 447 | email = mail.outbox[0] 448 | 449 | # Subject and preheader should be as provided in the template 450 | assert email.subject == "Subject from Template" 451 | html_content = email.alternatives[0][0] 452 | assert 'Preheader from Template" in html_content 454 | 455 | 456 | def test_rendering_local_links(): 457 | """Test that local links are rendered correctly by premailer if base_url is provided.""" 458 | import random # pylint: disable=C0415 459 | 460 | from django.urls import reverse 461 | 462 | some_id = random.randint(1, 100) 463 | send_templated_mail( 464 | template_name="test_render_local_links", 465 | from_email="from@example.com", 466 | recipient_list=["to@example.com"], 467 | base_url="http://exampleeee.com", 468 | context={ 469 | "name": "Test User", 470 | "some_id": some_id, 471 | "url": reverse("index", args=[some_id]), 472 | }, 473 | ) 474 | 475 | assert len(mail.outbox) == 1 476 | email = mail.outbox[0] 477 | html_content = email.alternatives[0][0] 478 | 479 | url = reverse("index", args=[some_id]) 480 | 481 | # Check that the email was sent 482 | assert email.subject == "Hello!" 483 | assert "Hello Test User!" in email.body 484 | assert "Hello Test User!" in html_content 485 | 486 | # Check that the link was rendered correctly twice 487 | assert html_content.count(f'index") == 2 489 | 490 | 491 | def test_custom_default_subject_and_preheader(): 492 | """ 493 | Test that the custom default subject and preheader from settings 494 | are used when not provided in the template or context. 495 | """ 496 | settings.TEMPLATED_EMAIL_DEFAULT_SUBJECT = "Default Subject from Settings" 497 | settings.TEMPLATED_EMAIL_DEFAULT_PREHEADER = "Default Preheader from Settings" 498 | # Send an email without subject or preheader in template or context 499 | send_templated_mail( 500 | template_name="test_no_subject_preheader", 501 | from_email="from@example.com", 502 | recipient_list=["to@example.com"], 503 | context={"name": "Test User"}, 504 | ) 505 | 506 | assert len(mail.outbox) == 1 507 | email = mail.outbox[0] 508 | 509 | # Subject should be the custom default from settings 510 | assert email.subject == "Default Subject from Settings" 511 | # Preheader should be the custom default from settings 512 | html_content = email.alternatives[0][0] 513 | assert 'Default Preheader from Settings" in html_content 515 | 516 | 517 | def test_custom_html2text_settings(backend) -> None: 518 | """Test that custom html2text settings are properly applied.""" 519 | # Set custom html2text settings 520 | backend.html2text_settings = {"ignore_links": True, "ignore_images": True, "body_width": 0} 521 | 522 | html_content = """ 523 |
524 |

Test content with a link

525 | test image 526 |
527 | """ 528 | 529 | plain_text = backend._generate_plain_text(html_content) 530 | 531 | # Links and images should be ignored based on our settings 532 | assert "http://example.com" not in plain_text 533 | assert "test image" not in plain_text 534 | 535 | 536 | def test_template_blocks_extraction(backend) -> None: 537 | """Test extraction of subject and content blocks from template.""" 538 | template_content = """ 539 | {% block subject %}Test Subject{% endblock %} 540 | 541 | {% block content %} 542 | # Test Content 543 | 544 | This is test content. 545 | {% endblock %} 546 | """ 547 | 548 | context = {} 549 | blocks = backend._extract_blocks(template_content, context) 550 | 551 | assert blocks["subject"] == "Test Subject" 552 | assert "# Test Content" in blocks["content"] 553 | assert "This is test content." in blocks["content"] 554 | 555 | 556 | def test_default_subject_preheader_settings(backend) -> None: 557 | """Test that default subject and preheader settings are used when not provided.""" 558 | from django.test import override_settings 559 | 560 | custom_subject = "Custom Default Subject" 561 | custom_preheader = "Custom Default Preheader" 562 | 563 | with override_settings( 564 | TEMPLATED_EMAIL_DEFAULT_SUBJECT=custom_subject, TEMPLATED_EMAIL_DEFAULT_PREHEADER=custom_preheader 565 | ): 566 | # Create a new backend instance to pick up the settings 567 | new_backend = MarkdownTemplateBackend() 568 | assert new_backend.default_subject == custom_subject 569 | assert new_backend.default_preheader == custom_preheader 570 | 571 | 572 | def test_fail_silently_render_email(backend) -> None: 573 | """Test that _render_email handles errors gracefully with fail_silently=True.""" 574 | # Set fail_silently on the backend 575 | backend.fail_silently = True 576 | 577 | # Test with non-existent template 578 | result = backend._render_email( 579 | template_name="non_existent_template", 580 | context={}, 581 | ) 582 | 583 | # Should return fallback content 584 | assert result["html"] == "Email template rendering failed." 585 | assert result["plain"] == "Email template rendering failed." 586 | assert result["subject"] == backend.default_subject 587 | assert result["preheader"] == backend.default_preheader 588 | 589 | def test_fail_silently_none_initialization() -> None: 590 | """Test that backend handles fail_silently=None during initialization.""" 591 | from django.test import override_settings 592 | 593 | # When fail_silently is explicitly None, it should fall back to settings 594 | backend_with_none = MarkdownTemplateBackend(fail_silently=None) 595 | 596 | # Should use the default from settings (False in test settings) 597 | assert backend_with_none.fail_silently is False 598 | 599 | # Test with a setting override 600 | with override_settings(TEMPLATED_EMAIL_FAIL_SILENTLY=True): 601 | backend_with_setting = MarkdownTemplateBackend(fail_silently=None) 602 | assert backend_with_setting.fail_silently is True 603 | 604 | 605 | def test_template_without_content_block(backend) -> None: 606 | """Test rendering a template without explicit {% block content %}.""" 607 | result = backend._render_email( 608 | template_name="test_no_content_block", 609 | context={}, 610 | ) 611 | 612 | # Should extract content using fallback regex method 613 | assert "Simple Markdown" in result["html"] 614 | assert "Bold text" in result["html"] 615 | assert "italic text" in result["html"] 616 | # CSS inlining adds styles, so just check for content 617 | assert "List item 1" in result["html"] 618 | assert "List item 2" in result["html"] 619 | 620 | # Subject and preheader should still be extracted 621 | assert result["subject"] == "Test Email Without Content Block" 622 | assert result["preheader"] == "Test preheader" 623 | 624 | # Plain text should also work 625 | assert "Simple Markdown" in result["plain"] 626 | assert "Bold text" in result["plain"] 627 | 628 | 629 | def test_markdown_render_error_with_fail_silently() -> None: 630 | """Test that MarkdownRenderError is caught when fail_silently=True.""" 631 | from unittest.mock import patch 632 | from templated_email_md.exceptions import MarkdownRenderError 633 | 634 | backend = MarkdownTemplateBackend(fail_silently=True) 635 | 636 | # Mock markdown.markdown to raise an exception 637 | with patch('templated_email_md.backend.markdown.markdown') as mock_markdown: 638 | mock_markdown.side_effect = ValueError("Invalid markdown extension") 639 | 640 | # Should return the raw content instead of raising 641 | result = backend._render_markdown("# Test content") 642 | assert result == "# Test content" 643 | 644 | 645 | def test_markdown_render_error_without_fail_silently() -> None: 646 | """Test that MarkdownRenderError is raised when fail_silently=False.""" 647 | from unittest.mock import patch 648 | from templated_email_md.exceptions import MarkdownRenderError 649 | 650 | backend = MarkdownTemplateBackend(fail_silently=False) 651 | 652 | # Mock markdown.markdown to raise an exception 653 | with patch('templated_email_md.backend.markdown.markdown') as mock_markdown: 654 | mock_markdown.side_effect = ValueError("Invalid markdown extension") 655 | 656 | # Should raise MarkdownRenderError 657 | with pytest.raises(MarkdownRenderError) as exc_info: 658 | backend._render_markdown("# Test content") 659 | 660 | assert "Invalid markdown extension" in str(exc_info.value) 661 | 662 | 663 | def test_css_inlining_error_with_fail_silently() -> None: 664 | """Test that CSSInliningError is caught when fail_silently=True.""" 665 | from unittest.mock import patch 666 | from templated_email_md.exceptions import CSSInliningError 667 | 668 | backend = MarkdownTemplateBackend(fail_silently=True) 669 | 670 | # Mock premailer.transform to raise an exception 671 | with patch('templated_email_md.backend.premailer.transform') as mock_premailer: 672 | mock_premailer.side_effect = OSError("Network error fetching CSS") 673 | 674 | # Should return the original HTML instead of raising 675 | test_html = "

Test content

" 676 | result = backend._inline_css(test_html, base_url="http://example.com") 677 | assert result == test_html 678 | 679 | 680 | def test_css_inlining_error_without_fail_silently() -> None: 681 | """Test that CSSInliningError is raised when fail_silently=False.""" 682 | from unittest.mock import patch 683 | from templated_email_md.exceptions import CSSInliningError 684 | 685 | backend = MarkdownTemplateBackend(fail_silently=False) 686 | 687 | # Mock premailer.transform to raise an exception 688 | with patch('templated_email_md.backend.premailer.transform') as mock_premailer: 689 | mock_premailer.side_effect = OSError("Network error fetching CSS") 690 | 691 | # Should raise CSSInliningError 692 | with pytest.raises(CSSInliningError) as exc_info: 693 | backend._inline_css("

Test content

", base_url="http://example.com") 694 | 695 | assert "Network error fetching CSS" in str(exc_info.value) 696 | 697 | 698 | def test_plain_text_generation_error_with_fail_silently() -> None: 699 | """Test that plain text generation errors are caught when fail_silently=True.""" 700 | from unittest.mock import patch 701 | 702 | backend = MarkdownTemplateBackend(fail_silently=True) 703 | 704 | # Mock html2text to raise an exception 705 | with patch('templated_email_md.backend.html2text.HTML2Text') as mock_html2text: 706 | mock_instance = mock_html2text.return_value 707 | mock_instance.handle.side_effect = AttributeError("Invalid HTML structure") 708 | 709 | # Should return fallback content instead of raising 710 | result = backend._get_plain_text_content_from_template("

Test

") 711 | assert result == "Email template rendering failed." 712 | 713 | 714 | def test_plain_text_generation_error_without_fail_silently() -> None: 715 | """Test that plain text generation errors are raised when fail_silently=False.""" 716 | from unittest.mock import patch 717 | 718 | backend = MarkdownTemplateBackend(fail_silently=False) 719 | 720 | # Mock html2text to raise an exception 721 | with patch('templated_email_md.backend.html2text.HTML2Text') as mock_html2text: 722 | mock_instance = mock_html2text.return_value 723 | mock_instance.handle.side_effect = AttributeError("Invalid HTML structure") 724 | 725 | # Should raise AttributeError 726 | with pytest.raises(AttributeError) as exc_info: 727 | backend._get_plain_text_content_from_template("

Test

") 728 | 729 | assert "Invalid HTML structure" in str(exc_info.value) 730 | 731 | 732 | def test_context_base_url_restoration(backend) -> None: 733 | """Test that _base_url in context is properly restored after send().""" 734 | from django.core import mail 735 | 736 | # Create context with a pre-existing _base_url 737 | context = { 738 | "name": "Test User", 739 | "_base_url": "http://original-url.com", 740 | } 741 | 742 | # Send email with a different base_url 743 | backend.send( 744 | template_name="test_message", 745 | from_email="from@example.com", 746 | recipient_list=["to@example.com"], 747 | context=context, 748 | base_url="http://new-url.com", 749 | ) 750 | 751 | # Context should be restored to original value 752 | assert context["_base_url"] == "http://original-url.com" 753 | 754 | 755 | def test_markdown_import_error() -> None: 756 | """Test handling of ImportError when markdown extension is not found.""" 757 | from unittest.mock import patch 758 | from templated_email_md.exceptions import MarkdownRenderError 759 | 760 | backend = MarkdownTemplateBackend(fail_silently=False) 761 | 762 | # Mock markdown.markdown to raise ImportError 763 | with patch('templated_email_md.backend.markdown.markdown') as mock_markdown: 764 | mock_markdown.side_effect = ImportError("Extension 'invalid.extension' not found") 765 | 766 | # Should raise MarkdownRenderError 767 | with pytest.raises(MarkdownRenderError) as exc_info: 768 | backend._render_markdown("# Test content") 769 | 770 | assert "Extension 'invalid.extension' not found" in str(exc_info.value) 771 | --------------------------------------------------------------------------------