├── .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 | 
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 |
14 | {% endspaceless %}
15 |
20 |
21 | | |
22 |
23 |
24 |
25 | {% block container %}
26 |
27 |
28 | {% block main %}
29 |
30 |
31 |
32 |
33 | |
34 |
35 | {% spaceless %}
36 | {% block content %}{{ markdown_content|safe }}{% endblock %}
37 | {% endspaceless %}
38 |
39 | |
40 |
41 |
42 | |
43 |
44 | {% endblock main %}
45 |
46 |
47 | {% endblock container %}
48 |
49 |
50 |
61 |
62 |
63 | |
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 status]
4 | [][pypi status]
5 | [][pypi status]
6 | [][license]
7 |
8 | [][read the docs]
9 | [][tests]
10 | [][codecov]
11 |
12 | [][pre-commit]
13 | [][black]
14 | [](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 | 
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 | 
173 |
174 | #### Email Preview
175 |
176 | 
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 '" 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 '" 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 |

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 |
--------------------------------------------------------------------------------