├── demo ├── __init__.py ├── demo_app │ ├── __init__.py │ ├── templates │ │ ├── 404.html │ │ └── mails │ │ │ ├── custom_form │ │ │ ├── body.txt │ │ │ ├── subject.txt │ │ │ ├── fr │ │ │ │ ├── body.txt │ │ │ │ ├── subject.txt │ │ │ │ └── body.html │ │ │ └── body.html │ │ │ ├── no_custom │ │ │ ├── body.txt │ │ │ ├── subject.txt │ │ │ └── fr │ │ │ │ ├── body.txt │ │ │ │ └── subject.txt │ │ │ └── base.html │ ├── views.py │ ├── static │ │ └── admin │ │ │ └── img │ │ │ └── nav-bg.gif │ ├── tests.py │ └── mails.py ├── urls.py ├── manage.py ├── wsgi.py └── settings.py ├── VERSION ├── docs ├── build │ └── .gitkeep ├── source │ ├── template.rst │ ├── django.rst │ ├── index.rst │ ├── api.rst │ ├── interface.rst │ └── conf.py └── Makefile ├── mail_factory ├── models.py ├── contrib │ ├── __init__.py │ └── auth │ │ ├── __init__.py │ │ ├── mails.py │ │ ├── views.py │ │ └── forms.py ├── templates │ ├── mails │ │ ├── test │ │ │ ├── fr │ │ │ │ ├── body.txt │ │ │ │ └── body.html │ │ │ ├── subject.txt │ │ │ ├── body.txt │ │ │ └── body.html │ │ ├── test_no_html │ │ │ ├── fr │ │ │ │ └── body.txt │ │ │ ├── subject.txt │ │ │ └── body.txt │ │ ├── test_no_txt │ │ │ ├── fr │ │ │ │ └── body.html │ │ │ ├── subject.txt │ │ │ └── body.html │ │ ├── test_no_html_no_txt │ │ │ └── subject.txt │ │ └── password_reset │ │ │ ├── subject.txt │ │ │ └── body.txt │ └── mail_factory │ │ ├── preview_message.html │ │ ├── html_not_found.html │ │ ├── base.html │ │ ├── list.html │ │ └── form.html ├── exceptions.py ├── tests │ ├── __init__.py │ ├── test_messages.py │ ├── test_contrib.py │ ├── test_forms.py │ ├── test_factory.py │ ├── test_mails.py │ └── test_views.py ├── app_no_autodiscover.py ├── apps.py ├── __init__.py ├── urls.py ├── forms.py ├── messages.py ├── factory.py ├── views.py └── mails.py ├── requirements.txt ├── .github ├── CODEOWNERS ├── release-drafter.yml ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── release-drafter.yml │ ├── publish.yml │ └── ci.yml ├── setup.py ├── pyproject.toml ├── INSTALL ├── MANIFEST.in ├── .travis.yml ├── AUTHORS ├── .gitignore ├── Makefile ├── .pre-commit-config.yaml ├── tox.ini ├── LICENSE ├── setup.cfg ├── README.rst └── CHANGELOG /demo/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 0.25.dev0 2 | -------------------------------------------------------------------------------- /docs/build/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mail_factory/models.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/demo_app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/demo_app/templates/404.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mail_factory/contrib/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -e .[dev] 2 | -------------------------------------------------------------------------------- /mail_factory/contrib/auth/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @peopledoc/python-community 2 | -------------------------------------------------------------------------------- /demo/demo_app/views.py: -------------------------------------------------------------------------------- 1 | # Create your views here. 2 | -------------------------------------------------------------------------------- /mail_factory/templates/mails/test/fr/body.txt: -------------------------------------------------------------------------------- 1 | Français 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /mail_factory/templates/mails/test/fr/body.html: -------------------------------------------------------------------------------- 1 |

Français

2 | -------------------------------------------------------------------------------- /mail_factory/templates/mails/test_no_html/fr/body.txt: -------------------------------------------------------------------------------- 1 | Français 2 | -------------------------------------------------------------------------------- /mail_factory/templates/mails/test/subject.txt: -------------------------------------------------------------------------------- 1 | [TestCase] Mail test subject 2 | -------------------------------------------------------------------------------- /mail_factory/templates/mails/test_no_txt/fr/body.html: -------------------------------------------------------------------------------- 1 |

Français

2 | -------------------------------------------------------------------------------- /demo/demo_app/templates/mails/custom_form/body.txt: -------------------------------------------------------------------------------- 1 | Content in english: {{ content }} 2 | -------------------------------------------------------------------------------- /demo/demo_app/templates/mails/custom_form/subject.txt: -------------------------------------------------------------------------------- 1 | Title in english: {{ title }} 2 | -------------------------------------------------------------------------------- /demo/demo_app/templates/mails/no_custom/body.txt: -------------------------------------------------------------------------------- 1 | Content in english: {{ content }} 2 | -------------------------------------------------------------------------------- /demo/demo_app/templates/mails/no_custom/subject.txt: -------------------------------------------------------------------------------- 1 | Title in english: {{ title }} 2 | -------------------------------------------------------------------------------- /mail_factory/templates/mail_factory/preview_message.html: -------------------------------------------------------------------------------- 1 | {{ message.html|safe }} 2 | -------------------------------------------------------------------------------- /mail_factory/templates/mails/test_no_html/subject.txt: -------------------------------------------------------------------------------- 1 | [TestCase] Mail test subject 2 | -------------------------------------------------------------------------------- /mail_factory/templates/mails/test_no_txt/subject.txt: -------------------------------------------------------------------------------- 1 | [TestCase] Mail test subject 2 | -------------------------------------------------------------------------------- /demo/demo_app/templates/mails/custom_form/fr/body.txt: -------------------------------------------------------------------------------- 1 | Contenu en français : {{ content }} 2 | -------------------------------------------------------------------------------- /demo/demo_app/templates/mails/custom_form/fr/subject.txt: -------------------------------------------------------------------------------- 1 | Titre en français : {{ title }} 2 | -------------------------------------------------------------------------------- /demo/demo_app/templates/mails/no_custom/fr/body.txt: -------------------------------------------------------------------------------- 1 | Contenu en français : {{ content }} 2 | -------------------------------------------------------------------------------- /demo/demo_app/templates/mails/no_custom/fr/subject.txt: -------------------------------------------------------------------------------- 1 | Titre en français : {{ title }} 2 | -------------------------------------------------------------------------------- /mail_factory/templates/mails/test_no_html_no_txt/subject.txt: -------------------------------------------------------------------------------- 1 | [TestCase] Mail test subject 2 | -------------------------------------------------------------------------------- /mail_factory/templates/mails/test/body.txt: -------------------------------------------------------------------------------- 1 | {{ title }} 2 | ====== 3 | 4 | Mail test body txt 5 | -------------------------------------------------------------------------------- /mail_factory/templates/mails/test_no_html/body.txt: -------------------------------------------------------------------------------- 1 | {{ title }} 2 | ====== 3 | 4 | Mail test body txt 5 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=45", "wheel", "setuptools_scm>=6.2"] 3 | 4 | [tool.setuptools_scm] 5 | -------------------------------------------------------------------------------- /demo/demo_app/static/admin/img/nav-bg.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peopledoc/django-mail-factory/HEAD/demo/demo_app/static/admin/img/nav-bg.gif -------------------------------------------------------------------------------- /INSTALL: -------------------------------------------------------------------------------- 1 | ############ 2 | Installation 3 | ############ 4 | 5 | See https://github.com/peopledoc/django-mail-factory/ for details about the installation. 6 | -------------------------------------------------------------------------------- /mail_factory/exceptions.py: -------------------------------------------------------------------------------- 1 | class MissingMailContextParamException(Exception): 2 | pass 3 | 4 | 5 | class MailFactoryError(Exception): 6 | pass 7 | -------------------------------------------------------------------------------- /mail_factory/templates/mails/test/body.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

Test mail: {{ title }}

4 | Mail test case 5 | 6 | 7 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include mail_factory * 2 | global-exclude *.pyc .*.swp 3 | include *.txt 4 | include VERSION README.rst CHANGELOG LICENSE INSTALL AUTHORS 5 | -------------------------------------------------------------------------------- /mail_factory/templates/mails/test_no_txt/body.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

Test mail: {{ title }}

4 | Mail test case 5 | 6 | 7 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: '$NEXT_MINOR_VERSION' 2 | tag-template: '$NEXT_MINOR_VERSION' 3 | template: | 4 | $CHANGES 5 | 6 | ## Kudos: 7 | 8 | $CONTRIBUTORS 9 | -------------------------------------------------------------------------------- /mail_factory/templates/mails/password_reset/subject.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %}{% autoescape off %} 2 | {% blocktrans %}Password reset on {{ site_name }}{% endblocktrans %} 3 | {% endautoescape %} 4 | -------------------------------------------------------------------------------- /mail_factory/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from .test_factory import * # noqa 2 | from .test_forms import * # noqa 3 | from .test_mails import * # noqa 4 | from .test_messages import * # noqa 5 | from .test_views import * # noqa 6 | -------------------------------------------------------------------------------- /demo/demo_app/templates/mails/custom_form/body.html: -------------------------------------------------------------------------------- 1 | {% extends "mails/base.html" %} 2 | 3 | {% block content %}content in HTML english: {{ content }}{% endblock %} 4 | 5 | {% block title %}title in HTML english: {{ title }}{% endblock %} 6 | -------------------------------------------------------------------------------- /mail_factory/contrib/auth/mails.py: -------------------------------------------------------------------------------- 1 | from mail_factory import BaseMail 2 | 3 | 4 | class PasswordResetMail(BaseMail): 5 | template_name = "password_reset" 6 | params = ["email", "domain", "site_name", "uid", "user", "token", "protocol"] 7 | -------------------------------------------------------------------------------- /demo/demo_app/templates/mails/custom_form/fr/body.html: -------------------------------------------------------------------------------- 1 | {% extends "mails/base.html" %} 2 | 3 | {% block content %}contenu en HTML et en français : {{ content }}{% endblock %} 4 | 5 | {% block title %}titre en HTML et en français : {{ title }}{% endblock %} 6 | -------------------------------------------------------------------------------- /demo/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import include 2 | from django.contrib import admin 3 | from django.urls import re_path 4 | 5 | urlpatterns = [ 6 | re_path(r"^mail_factory/", include("mail_factory.urls")), 7 | re_path(r"^admin/", admin.site.urls), 8 | ] 9 | -------------------------------------------------------------------------------- /mail_factory/templates/mail_factory/html_not_found.html: -------------------------------------------------------------------------------- 1 | {% extends "mail_factory/base.html" %} 2 | {% load i18n %} 3 | 4 | {% block content %} 5 |
6 |

{% trans "Sorry, no HTML version was found for this email" %}

7 |
8 | {% endblock %} 9 | -------------------------------------------------------------------------------- /demo/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "demo.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /mail_factory/app_no_autodiscover.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class SimpleMailFactoryConfig(AppConfig): 6 | """Simple AppConfig which does not do automatic discovery.""" 7 | 8 | name = "mail_factory" 9 | verbose_name = _("Mail Factory") 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: bionic 2 | sudo: true 3 | language: python 4 | python: 5 | - 2.7 6 | - 3.5 7 | - 3.6 8 | - 3.7 9 | - 3.8 10 | stages: 11 | - lint 12 | - test 13 | jobs: 14 | include: 15 | - { stage: lint, env: TOXENV=lint, python: 3.6 } 16 | 17 | script: 18 | - tox 19 | install: 20 | - pip install tox tox-travis 21 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | ######################## 2 | Authors and contributors 3 | ######################## 4 | 5 | * Rémy Hubscher , 6 | * Mathieu AGOPIAN 7 | 8 | * Florent Messa 9 | * Samuel Martin 10 | * Benoît Bryon 11 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Closes # 2 | 3 | ### Checklist: 4 | 5 | - [ ] Tests 6 | - [ ] (not applicable?) 7 | - [ ] Documentation 8 | - [ ] (not applicable?) 9 | 10 | 12 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | update_release_draft: 10 | runs-on: ubuntu-latest 11 | steps: 12 | # Drafts the next Release notes as Pull Requests are merged into "master" 13 | - uses: release-drafter/release-drafter@v5 14 | env: 15 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 16 | -------------------------------------------------------------------------------- /demo/demo_app/templates/mails/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% block title %}{% endblock %} 6 | 7 | 8 | {% block content %}{% endblock %} 9 | 10 | 11 | -------------------------------------------------------------------------------- /demo/demo_app/tests.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file demonstrates writing tests using the unittest module. These will pass 3 | when you run "manage.py test". 4 | 5 | Replace this with more appropriate tests for your application. 6 | """ 7 | 8 | from django.test import TestCase 9 | 10 | 11 | class SimpleTest(TestCase): 12 | def test_basic_addition(self): 13 | """ 14 | Tests that 1 + 1 always equals 2. 15 | """ 16 | self.assertEqual(1 + 1, 2) 17 | -------------------------------------------------------------------------------- /demo/demo_app/mails.py: -------------------------------------------------------------------------------- 1 | from mail_factory import BaseMail, MailForm, factory 2 | 3 | 4 | class CustomFormMail(BaseMail): 5 | template_name = "custom_form" 6 | params = ["title", "content"] 7 | 8 | 9 | class CustomFormMailForm(MailForm): 10 | class Meta: 11 | initial = {"title": "My initial subject", "content": "My initial content"} 12 | 13 | 14 | factory.register(CustomFormMail, mail_form=CustomFormMailForm) 15 | 16 | 17 | class NoCustomMail(BaseMail): 18 | template_name = "no_custom" 19 | params = ["title", "content"] 20 | 21 | 22 | factory.register(NoCustomMail) # default form 23 | -------------------------------------------------------------------------------- /mail_factory/apps.py: -------------------------------------------------------------------------------- 1 | from importlib import import_module 2 | 3 | from django.apps import AppConfig 4 | from django.utils.translation import gettext_lazy as _ 5 | 6 | 7 | class MailFactoryConfig(AppConfig): 8 | """Simple AppConfig which does not do automatic discovery.""" 9 | 10 | name = "mail_factory" 11 | verbose_name = _("Mail Factory") 12 | 13 | def ready(self): 14 | super().ready() 15 | for app in self.apps.get_app_configs(): 16 | try: 17 | import_module(name=".mails", package=app.module.__name__) 18 | except ImportError: 19 | pass 20 | -------------------------------------------------------------------------------- /mail_factory/templates/mails/password_reset/body.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %}{% autoescape off %} 2 | {% blocktrans %}You're receiving this email because you requested a password reset for your user account at {{ site_name }}.{% endblocktrans %} 3 | 4 | {% trans "Please go to the following page and choose a new password:" %} 5 | {% block reset_link %} 6 | {{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %} 7 | {% endblock %} 8 | {% trans "Your username, in case you've forgotten:" %} {{ user.get_username }} 9 | 10 | {% trans "Thanks for using our site!" %} 11 | 12 | {% blocktrans %}The {{ site_name }} team{% endblocktrans %} 13 | 14 | {% endautoescape %} 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Local binaries (they are generated). 2 | /bin/ 3 | /include/ 4 | 5 | # Local configuration (generated from templates)... 6 | /etc/ 7 | 8 | # External libraries (generated). 9 | /lib/ 10 | 11 | # External source files (managed with their own VCS). 12 | /src/ 13 | 14 | # Data files. 15 | /var/ 16 | /docs/build/ 17 | share/ 18 | 19 | # Python files. 20 | *.pyc 21 | *.pyo 22 | *.egg-info 23 | 24 | # Compiled gettext catalogs. 25 | *.mo 26 | 27 | # Editors' temporary files 28 | .*.swp 29 | 30 | # Buildout files. 31 | .mr.developer.cfg 32 | .coverage 33 | 34 | # tox environments 35 | .tox/ 36 | 37 | # local sqlite database 38 | db.sqlite 39 | 40 | pip-selfcheck.json 41 | .venv 42 | dist 43 | -------------------------------------------------------------------------------- /mail_factory/__init__.py: -------------------------------------------------------------------------------- 1 | """Django Mail Manager""" 2 | 3 | import django 4 | 5 | from mail_factory.app_no_autodiscover import SimpleMailFactoryConfig 6 | from mail_factory.apps import MailFactoryConfig 7 | from mail_factory.factory import MailFactory 8 | from mail_factory.forms import MailForm # NOQA 9 | from mail_factory.mails import BaseMail # NOQA 10 | 11 | pkg_resources = __import__("pkg_resources") 12 | distribution = pkg_resources.get_distribution("django-mail-factory") 13 | 14 | #: Module version, as defined in PEP-0396. 15 | __version__ = distribution.version 16 | 17 | __all__ = ["MailFactoryConfig", "SimpleMailFactoryConfig"] 18 | 19 | factory = MailFactory() 20 | 21 | 22 | if django.VERSION[:2] < (3, 2): 23 | default_app_config = "mail_factory.apps.MailFactoryConfig" 24 | -------------------------------------------------------------------------------- /mail_factory/urls.py: -------------------------------------------------------------------------------- 1 | """URLconf for mail_factory admin interface.""" 2 | from django.conf import settings 3 | from django.urls import re_path 4 | 5 | from mail_factory.views import form, html_not_found, mail_list, preview_message 6 | 7 | LANGUAGE_CODES = "|".join([code for code, name in settings.LANGUAGES]) 8 | 9 | 10 | urlpatterns = [ 11 | re_path(r"^$", mail_list, name="mail_factory_list"), 12 | re_path(r"^detail/(?P.*)/$", form, name="mail_factory_form"), 13 | re_path( 14 | r"^preview/(?P(%s))/(?P.*)/$" % LANGUAGE_CODES, 15 | preview_message, 16 | name="mail_factory_preview_message", 17 | ), 18 | re_path( 19 | r"^html_not_found/(?P.*)/$", 20 | html_not_found, 21 | name="mail_factory_html_not_found", 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: docs test clean 2 | 3 | bin/python: 4 | virtualenv . 5 | bin/python setup.py develop 6 | 7 | test: bin/python 8 | bin/pip install tox 9 | bin/tox 10 | 11 | bin/sphinx-build: bin/python 12 | bin/pip install sphinx 13 | 14 | docs: bin/sphinx-build 15 | bin/pip install sphinx 16 | SPHINXBUILD=../bin/sphinx-build $(MAKE) -C docs html $^ 17 | 18 | #: clean - Basic cleanup, mostly temporary files. 19 | clean: 20 | find . -name '*.pyc' -delete 21 | find . -name '*.pyo' -delete 22 | find . -name __pycache__ -delete 23 | 24 | 25 | #: distclean - Remove local builds, such as *.egg-info. 26 | distclean: clean 27 | rm -rf *.egg 28 | rm -rf *.egg-info 29 | 30 | 31 | #: maintainer-clean - Remove almost everything that can be re-generated. 32 | maintainer-clean: distclean 33 | rm -rf build/ 34 | rm -rf dist/ 35 | rm -rf .tox/ 36 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v6.0.0 6 | hooks: 7 | - id: trailing-whitespace 8 | - id: end-of-file-fixer 9 | - id: check-yaml 10 | - id: check-added-large-files 11 | 12 | - repo: https://github.com/psf/black 13 | rev: "25.1.0" 14 | hooks: 15 | - id: black 16 | 17 | - repo: https://github.com/PyCQA/isort 18 | rev: "6.0.1" 19 | hooks: 20 | - id: isort 21 | 22 | - repo: https://github.com/PyCQA/flake8 23 | rev: "7.3.0" 24 | hooks: 25 | - id: flake8 26 | 27 | - repo: https://github.com/asottile/pyupgrade 28 | rev: "v3.20.0" 29 | hooks: 30 | - id: pyupgrade 31 | 32 | - repo: https://github.com/PyCQA/doc8 33 | rev: "v2.0.0" 34 | hooks: 35 | - id: doc8 36 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{37,38,39}-django{22,30,31} 4 | py{37,38,39,310}-django{32} 5 | py{38,39,310}-django{40} 6 | lint 7 | docs 8 | 9 | [testenv] 10 | usedevelop = True 11 | extras = dev 12 | deps = 13 | django22: Django==2.2.* 14 | django30: Django==3.0.* 15 | django31: Django==3.1.* 16 | django32: Django==3.2.* 17 | django40: Django==4.0.* 18 | commands = 19 | pytest {posargs} 20 | 21 | [testenv:lint] 22 | commands = 23 | isort --check --diff mail_factory demo 24 | flake8 mail_factory demo --show-source 25 | black --check mail_factory demo 26 | 27 | [testenv:docs] 28 | whitelist_externals = 29 | make 30 | commands = 31 | doc8 docs 32 | make -C docs html SPHINXOPTS="-W {posargs}" 33 | 34 | [testenv:format] 35 | commands = 36 | isort mail_factory demo 37 | black mail_factory demo 38 | -------------------------------------------------------------------------------- /mail_factory/contrib/auth/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.views import PasswordResetView as DjangoPasswordResetView 2 | from django.http import HttpResponseRedirect 3 | 4 | from .forms import PasswordResetForm 5 | 6 | 7 | class PasswordResetView(DjangoPasswordResetView): 8 | """Substitute with the mail_factory PasswordResetForm.""" 9 | 10 | form_class = PasswordResetForm 11 | email_template_name = None 12 | 13 | def form_valid(self, form): 14 | opts = { 15 | "use_https": self.request.is_secure(), 16 | "token_generator": self.token_generator, 17 | "from_email": self.from_email, 18 | "email_template_name": self.email_template_name, 19 | "request": self.request, 20 | "extra_email_context": self.extra_email_context, 21 | } 22 | form.mail_factory_email(**opts) 23 | return HttpResponseRedirect(self.get_success_url()) 24 | 25 | 26 | password_reset = PasswordResetView.as_view() 27 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | deploy: 10 | name: Publish package to PyPI 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - name: Set up Python 16 | id: setup-python 17 | uses: actions/setup-python@v2 18 | with: 19 | python-version: ${{ matrix.python_version }} 20 | 21 | - name: Install 22 | run: pip install build 23 | 24 | - name: Build package 25 | run: python -m build 26 | 27 | - name: Wait for tests to succeed 28 | uses: fountainhead/action-wait-for-check@v1.0.0 29 | id: wait-for-ci 30 | with: 31 | token: ${{ secrets.GITHUB_TOKEN }} 32 | checkName: success 33 | 34 | - name: Exit if CI did not succeed 35 | if: steps.wait-for-ci.outputs.conclusion != 'success' 36 | run: exit 1 37 | 38 | - name: Publish a Python distribution to PyPI 39 | uses: pypa/gh-action-pypi-publish@release/v1 40 | with: 41 | user: __token__ 42 | password: "${{ secrets.PYPI_TOKEN }}" 43 | -------------------------------------------------------------------------------- /demo/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for demo project. 3 | 4 | This module contains the WSGI application used by Django's development server 5 | and any production WSGI deployments. It should expose a module-level variable 6 | named ``application``. Django's ``runserver`` and ``runfcgi`` commands discover 7 | this application via the ``WSGI_APPLICATION`` setting. 8 | 9 | Usually you will have the standard Django WSGI application here, but it also 10 | might make sense to replace the whole Django WSGI application with a custom one 11 | that later delegates to the Django one. For example, you could introduce WSGI 12 | middleware here, or combine a Django application with an application of another 13 | framework. 14 | 15 | """ 16 | 17 | import os 18 | 19 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "demo.settings") 20 | 21 | # This application object is used by any WSGI server configured to use this 22 | # file. This includes Django's development server, if the WSGI_APPLICATION 23 | # setting points here. 24 | from django.core.wsgi import get_wsgi_application 25 | 26 | application = get_wsgi_application() 27 | 28 | # Apply WSGI middleware here. 29 | # from helloworld.wsgi import HelloWorldApplication 30 | # application = HelloWorldApplication(application) 31 | -------------------------------------------------------------------------------- /mail_factory/templates/mail_factory/base.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | {% load admin_urls %} 3 | {% load i18n static admin_list %} 4 | 5 | {% block extrastyle %} 6 | {{ block.super }} 7 | 8 | 9 | {% if cl.formset %} 10 | 11 | {% endif %} 12 | {% if not actions_on_top and not actions_on_bottom %} 13 | 16 | {% endif %} 17 | {% endblock %} 18 | 19 | {% block extrahead %} 20 | {{ block.super }} 21 | {{ media.js }} 22 | {% if action_form %}{% if actions_on_top or actions_on_bottom %} 23 | 30 | {% endif %}{% endif %} 31 | {% endblock %} 32 | 33 | {% block bodyclass %}change-list{% endblock %} 34 | 35 | {% block coltype %}flex{% endblock %} 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ####### 2 | License 3 | ####### 4 | 5 | Copyright (c) 2013 HUBSCHER Rémy 6 | 7 | All rights reserved. 8 | Redistribution and use in source and binary forms, with or without 9 | modification, are permitted provided that the following conditions are 10 | met: 11 | 12 | * Redistributions of source code must retain the above copyright 13 | notice, this list of conditions and the following disclaimer. 14 | 15 | * Redistributions in binary form must reproduce the above copyright 16 | notice, this list of conditions and the following disclaimer in the 17 | documentation and/or other materials provided with the distribution. 18 | 19 | * Neither the name of the copyright holder nor the names of its 20 | contributors may be used to endorse or promote products derived from 21 | this software without specific prior written permission. 22 | 23 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 24 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 25 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 26 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 27 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 28 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 29 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 30 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 31 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 32 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 33 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 34 | -------------------------------------------------------------------------------- /mail_factory/templates/mail_factory/list.html: -------------------------------------------------------------------------------- 1 | {% extends "mail_factory/base.html" %} 2 | 3 | {% block content %} 4 |
5 |
6 | {% block result_list %} 7 |
8 | 9 | 10 | 11 | 15 | 19 | 20 | 21 | 22 | {% for template_name, mail_name in mail_map %} 23 | 24 | 25 | 26 | 27 | {% endfor %} 28 | 29 |
12 |
Email
13 |
14 |
16 |
Template
17 |
18 |
{{ mail_name }}{{ template_name }}
30 |
31 | {% endblock %} 32 |
33 |
34 | {% endblock %} 35 | -------------------------------------------------------------------------------- /mail_factory/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | 4 | class MailForm(forms.Form): 5 | """Prepopulated the form using mail params.""" 6 | 7 | mail_class = None 8 | 9 | def __init__(self, *args, **kwargs): 10 | if hasattr(self, "Meta") and hasattr(self.Meta, "initial"): 11 | initial = self.Meta.initial.copy() 12 | if "initial" in kwargs: 13 | initial.update(kwargs["initial"]) 14 | kwargs["initial"] = initial 15 | 16 | if "mail_class" in kwargs: 17 | self.mail_class = kwargs.pop("mail_class") 18 | 19 | super().__init__(*args, **kwargs) 20 | 21 | if self.mail_class is not None: 22 | ordering = [] 23 | 24 | # Automatic param creation for not already defined fields 25 | for param in self.mail_class.params: 26 | if param not in self.fields: 27 | self.fields[param] = self.get_field_for_param(param) 28 | ordering.append(param) 29 | 30 | # Append defined fields at the end of the form 31 | ordering += [ 32 | x for x in self.fields.keys() if x not in self.mail_class.params 33 | ] 34 | 35 | self.order_fields(ordering) 36 | 37 | def get_field_for_param(self, param): 38 | """By default always return a CharField for a param.""" 39 | return forms.CharField() 40 | 41 | def get_preview_data(self, **kwargs): 42 | """Return some preview data, not necessarily valid form data.""" 43 | return kwargs 44 | 45 | def get_context_data(self, **kwargs): 46 | """Return context data used for the mail preview.""" 47 | data = {} 48 | if self.mail_class is not None: 49 | for param in self.mail_class.params: 50 | data[param] = "###" # default 51 | data.update(self.initial) 52 | data.update(kwargs) 53 | return data 54 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = django-mail-factory 3 | description = Django Mail Manager 4 | url = https://github.com/peopledoc/django-mail-factory 5 | author = PeopleDoc 6 | license = BSD 7 | long_description = file: README.rst 8 | version = file: VERSION 9 | classifiers = 10 | Development Status :: 5 - Production/Stable 11 | Framework :: Django 12 | Framework :: Django :: 2.2 13 | Framework :: Django :: 3.0 14 | Framework :: Django :: 3.1 15 | Framework :: Django :: 3.2 16 | Framework :: Django :: 4.0 17 | License :: OSI Approved :: BSD License 18 | Programming Language :: Python 19 | Programming Language :: Python :: 3 20 | Programming Language :: Python :: 3.7 21 | Programming Language :: Python :: 3.8 22 | Programming Language :: Python :: 3.9 23 | Programming Language :: Python :: 3.10 24 | 25 | [options] 26 | zip_safe = True 27 | include_package_data = True 28 | packages = find: 29 | install_requires = 30 | django 31 | html2text 32 | 33 | [options.extras_require] 34 | dev = 35 | black 36 | isort 37 | flake8 38 | pytest 39 | pytest-cov 40 | pytest-django 41 | tox 42 | sphinx 43 | doc8 44 | 45 | [options.packages.find] 46 | include = 47 | mail_factory 48 | mail_factory.* 49 | 50 | [tool:pytest] 51 | addopts = 52 | --cov-report term-missing --cov-branch --cov-report html --cov-report term 53 | --cov=mail_factory -vv --strict-markers -rfE 54 | testpaths = 55 | mail_factory/tests/ 56 | filterwarnings = 57 | error 58 | # Ignoring for now: RemovedInDjango50Warning 59 | ignore:.*The USE_L10N setting is deprecated.* 60 | 61 | DJANGO_SETTINGS_MODULE = demo.settings 62 | 63 | [doc8] 64 | ignore = D001 65 | 66 | [wheel] 67 | universal = 1 68 | 69 | [isort] 70 | sections=FUTURE,STDLIB,THIRDPARTY,DJANGO,FIRSTPARTY,LOCALFOLDER 71 | known_django=django 72 | known_firstparty=mail_factory 73 | profile = black 74 | 75 | [flake8] 76 | ignore = E501,E402 77 | -------------------------------------------------------------------------------- /mail_factory/contrib/auth/forms.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.forms import PasswordResetForm as DjangoPasswordResetForm 2 | from django.contrib.auth.tokens import default_token_generator 3 | from django.contrib.sites.shortcuts import get_current_site 4 | from django.utils.encoding import force_bytes, force_str 5 | from django.utils.http import urlsafe_base64_encode 6 | 7 | from mail_factory import factory 8 | 9 | from .mails import PasswordResetMail 10 | 11 | 12 | class PasswordResetForm(DjangoPasswordResetForm): 13 | """MailFactory PasswordReset alternative.""" 14 | 15 | def mail_factory_email( 16 | self, 17 | domain_override=None, 18 | email_template_name=None, 19 | use_https=False, 20 | token_generator=default_token_generator, 21 | from_email=None, 22 | request=None, 23 | extra_email_context=None, 24 | ): 25 | """ 26 | Generates a one-use only link for resetting password and sends to the 27 | user. 28 | """ 29 | email = self.cleaned_data["email"] 30 | for user in self.get_users(email): 31 | if not domain_override: 32 | current_site = get_current_site(request) 33 | site_name = current_site.name 34 | domain = current_site.domain 35 | else: 36 | site_name = domain = domain_override 37 | context = { 38 | "email": email, 39 | "domain": domain, 40 | "site_name": site_name, 41 | "uid": force_str(urlsafe_base64_encode(force_bytes(user.pk))), 42 | "user": user, 43 | "token": token_generator.make_token(user), 44 | "protocol": "https" if use_https else "http", 45 | } 46 | if extra_email_context is not None: 47 | context.update(extra_email_context) 48 | 49 | if email_template_name is not None: 50 | mail = factory.get_mail_object(email_template_name, context) 51 | else: 52 | mail = PasswordResetMail(context) 53 | 54 | mail.send(emails=[user.email], from_email=from_email) 55 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - 'master' 8 | tags: 9 | - '*' 10 | 11 | jobs: 12 | build: 13 | 14 | strategy: 15 | matrix: 16 | include: 17 | 18 | - name: Python 3.9 / Django 2.2 19 | python_version: "3.9" 20 | tox_env: py39-django22 21 | 22 | - name: Python 3.9 / Django 3.0 23 | python_version: "3.9" 24 | tox_env: py39-django30 25 | 26 | - name: Python 3.9 / Django 3.1 27 | python_version: "3.9" 28 | tox_env: py39-django31 29 | 30 | - name: Python 3.7 / Django 3.2 31 | python_version: "3.7" 32 | tox_env: py37-django32 33 | 34 | - name: Python 3.8 / Django 3.2 35 | python_version: "3.8" 36 | tox_env: py38-django32 37 | 38 | - name: Python 3.9 / Django 3.2 39 | python_version: "3.9" 40 | tox_env: py39-django32 41 | 42 | - name: Python 3.10 / Django 3.2 43 | python_version: "3.10" 44 | tox_env: py310-django32 45 | 46 | - name: Python 3.9 / Django 4.0 47 | python_version: "3.9" 48 | tox_env: py39-django40 49 | 50 | - name: Docs 51 | python_version: "3" 52 | tox_env: docs 53 | 54 | - name: Lint 55 | python_version: "3" 56 | tox_env: lint 57 | 58 | name: "${{ matrix.name }}" 59 | runs-on: ubuntu-latest 60 | 61 | steps: 62 | - uses: actions/checkout@v2 63 | 64 | - name: Set up Python 65 | id: setup-python 66 | uses: actions/setup-python@v2 67 | with: 68 | python-version: ${{ matrix.python_version }} 69 | 70 | - name: Pip cache 71 | uses: actions/cache@v2 72 | with: 73 | path: | 74 | ~/.cache/ 75 | key: ${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('setup.cfg') }} 76 | 77 | - name: Install Tox 78 | run: pip install tox 79 | 80 | - name: Run ${{ matrix.name }} 81 | run: tox 82 | env: 83 | TOXENV: "${{ matrix.tox_env }}" 84 | 85 | report-status: 86 | name: success 87 | runs-on: ubuntu-latest 88 | needs: build 89 | steps: 90 | 91 | - name: Report success 92 | run: echo 'Success !' 93 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ################### 2 | Django Mail Factory 3 | ################### 4 | 5 | .. image:: https://secure.travis-ci.org/peopledoc/django-mail-factory.png?branch=master 6 | :alt: Build Status 7 | :target: https://travis-ci.org/peopledoc/django-mail-factory 8 | .. image:: https://img.shields.io/pypi/v/django-mail-factory.svg 9 | :target: https://crate.io/packages/django-mail-factory/ 10 | .. image:: https://img.shields.io/pypi/dm/django-mail-factory.svg 11 | :target: https://crate.io/packages/django-mail-factory/ 12 | 13 | Django Mail Factory lets you manage your email in a multilingual project. 14 | 15 | * Authors: Rémy Hubscher and `contributors 16 | `_ 17 | * Licence: BSD 18 | * Compatibility: Django 1.11, 2.0, 2.1 and 2.2, python2.7, 3.5, 3.6 and 3.7 19 | * Project URL: https://github.com/peopledoc/django-mail-factory 20 | * Documentation: http://django-mail-factory.rtfd.org/ 21 | 22 | 23 | Hacking 24 | ======= 25 | 26 | Setup your environment: 27 | 28 | :: 29 | 30 | git clone https://github.com/peopledoc/django-mail-factory.git 31 | cd django-mail-factory 32 | 33 | Hack and run the tests using `Tox `_ to test 34 | on all the supported python and Django versions: 35 | 36 | :: 37 | 38 | make test 39 | 40 | If you want to give a look at the demo (also used for the tests): 41 | 42 | :: 43 | 44 | bin/python demo/manage.py syncdb # create an administrator 45 | bin/python demo/manage.py runserver 46 | 47 | You then need to login on http://localhost:8000/admin, and the email 48 | administration (preview or render) is available at 49 | http://localhost:8000/mail_factory/. 50 | 51 | 52 | Release 53 | ======= 54 | 55 | To prepare a new version: 56 | 57 | * Create a branch named ``release/`` 58 | * In a commit, change the ``CHANGELOG`` and ``VERSION`` file to remove the ``.dev0`` and set the date of the release 59 | * In a second commit, change the ``VERSION`` to the next version number + ``.dev0`` 60 | * Create a PR for your branch 61 | * When the PR is merged, tag the first commit with the version number, and create a github release using the ``CHANGELOG`` 62 | 63 | To release a new version (including the wheel):: 64 | 65 | pip install twine 66 | python setup.py sdist bdist_wheel 67 | twine upload --repository-url https://test.pypi.org/legacy/ dist/* 68 | 69 | And after testing everything works fine on the testing repository:: 70 | 71 | twine upload dist/* 72 | -------------------------------------------------------------------------------- /mail_factory/tests/test_messages.py: -------------------------------------------------------------------------------- 1 | """Keep in mind throughout those tests that the mails from demo.demo_app.mails 2 | are automatically registered, and serve as fixture.""" 3 | 4 | 5 | from os.path import basename 6 | 7 | from django.test import TestCase 8 | 9 | from .. import messages 10 | 11 | 12 | class EmailMultiRelatedTest(TestCase): 13 | def setUp(self): 14 | self.message = messages.EmailMultiRelated() 15 | 16 | def test_attach_related_mimebase(self): 17 | mime_message = messages.MIMEBase(None, None) 18 | # no content nor mimetype with a mime message 19 | with self.assertRaises(AssertionError): 20 | self.message.attach_related(filename=mime_message, content="foo") 21 | with self.assertRaises(AssertionError): 22 | self.message.attach_related(filename=mime_message, mimetype="bar") 23 | # attach a mime message 24 | self.message.attach_related(filename=mime_message) 25 | self.assertEqual(self.message.related_attachments, [mime_message]) 26 | 27 | def test_attach_related_non_mimebase(self): 28 | # needs a content if not a mimemessage 29 | with self.assertRaises(AssertionError): 30 | self.message.attach_related(filename="foo") 31 | # attach a non-mime message 32 | self.message.attach_related(filename="foo", content="bar", mimetype="baz") 33 | self.assertEqual(self.message.related_attachments, [("foo", "bar", "baz")]) 34 | 35 | def test_attach_related_file(self): 36 | path = __file__ # attach this very file you're reading 37 | self.message.attach_related_file(path=path, mimetype="baz") 38 | filename, content, mimetype = self.message.related_attachments[0] 39 | self.assertEqual(filename, basename(__file__)) 40 | self.assertEqual(mimetype, "baz") 41 | 42 | def test_create_alternatives(self): 43 | self.message.alternatives = [('', "text/html")] 44 | self.message.related_attachments = [("img.gif", b"", "image/gif")] 45 | self.message._create_alternatives(None) 46 | self.assertEqual( 47 | self.message.alternatives, [('', "text/html")] 48 | ) 49 | 50 | def test_create_related_attachments(self): 51 | self.message.related_attachments = [("img.gif", b"", "image/gif")] 52 | self.message.body = True 53 | new_msg = self.message._create_related_attachments("foo message") 54 | content, attachment = new_msg.get_payload() 55 | self.assertEqual(content, "foo message") 56 | self.assertEqual(attachment.get_filename(), "img.gif") 57 | -------------------------------------------------------------------------------- /docs/source/template.rst: -------------------------------------------------------------------------------- 1 | ============== 2 | Mail templates 3 | ============== 4 | 5 | When you want a multi-alternatives email, you need to provide a subject, the 6 | ``text/plain`` body and the ``text/html`` body. 7 | 8 | All these parts are loaded from your email template directory. 9 | 10 | :file:`templates/mails/invitation/subject.txt`: 11 | 12 | .. code-block:: django 13 | 14 | {% load i18n %}{% blocktrans %}[{{ site_name }}] Invitation to the beta{% endblocktrans %} 15 | 16 | A little warning: the subject needs to be on a single line 17 | 18 | You can also create a different subject file for each language: 19 | 20 | :file:`templates/mails/invitation/en/subject.txt`: 21 | 22 | .. code-block:: django 23 | 24 | [{{ site_name }}] Invitation to the beta 25 | 26 | :file:`templates/mails/invitation/body.txt`: 27 | 28 | .. code-block:: django 29 | 30 | {% load i18n %}{% blocktrans with full_name=user.get_full_name expiration_date=expiration_date|date:"l d F Y" %} 31 | Dear {{ full_name }}, 32 | 33 | You just received an invitation to connect to our beta program. 34 | 35 | Please click on the link below to activate your account: 36 | 37 | {{ activation_url }} 38 | 39 | This link will expire on: {{ expiration_date }} 40 | 41 | {{ site_name }} 42 | ------------------------------- 43 | If you need help for any purpose, please contact us at {{ support_email }} 44 | {% endblocktrans %} 45 | 46 | 47 | If you don't provide a ``body.html`` the mail will be sent in ``text/plain`` 48 | only, if it is present, it will be added as an alternative and displayed if the 49 | user's mail client handles html emails. 50 | 51 | :file:`templates/mails/invitation/body.html`: 52 | 53 | .. code-block:: html 54 | 55 | 56 | 57 | 58 | 59 | {{ site_name }} 60 | 61 | 62 |

{{ site_name }}

63 |

{% trans 'Invitation to the beta' %}

64 |

{% blocktrans with full_name=user.get_full_name %}Dear {{ full_name }},{% endblocktrans %}

65 |

{% trans "You just received an invitation to connect to our beta program:" %}

66 |

{% trans 'Please click on the link below to activate your account:' %}

67 |

{{ activation_url }}

68 |

{{ site_name }}

69 |

{% blocktrans %}If you need help for any purpose, please contact us at 70 | {{ support_email }}{% endblocktrans %}

71 | 72 | 73 | -------------------------------------------------------------------------------- /docs/source/django.rst: -------------------------------------------------------------------------------- 1 | =============================== 2 | Django default mail integration 3 | =============================== 4 | 5 | If you use Django Mail Factory, you will definitly want to manage all 6 | your application mails from Django Mail Factory. 7 | 8 | Even if your are using some Django generic views that send mails. 9 | 10 | 11 | Password Reset Mail 12 | =================== 13 | 14 | Here is an example of how you can use Mail Factory with the 15 | ``django.contrib.auth.views.password_reset`` view. 16 | 17 | You can first add this pattern in your ``urls.py``: 18 | 19 | .. code-block:: python 20 | 21 | from mail_factory.contrib.auth.views import password_reset 22 | 23 | 24 | urlpatterns = [ 25 | url(_(r'^password_reset/$'), password_reset, name="password_reset"), 26 | 27 | ... 28 | ] 29 | 30 | 31 | Then you can overload the default templates 32 | ``mails/password_reset/subject.txt`` and ``mails/password_reset/body.txt``. 33 | 34 | But you can also register your own ``PasswordResetMail``: 35 | 36 | .. code-block:: python 37 | 38 | from django.conf import settings 39 | from mail_factory import factory 40 | from mail_factory.contrib.auth.mails import PasswordResetMail 41 | from myapp.mails import AppBaseMail, AppBaseMailForm 42 | 43 | class PasswordResetMail(AppBaseMail, PasswordResetMail): 44 | """Add the App header + i18n for PasswordResetMail.""" 45 | template_name = 'password_reset' 46 | 47 | 48 | class PasswordResetForm(AppBaseMailForm): 49 | class Meta: 50 | mail_class = PasswordResetMail 51 | initial = {'email': settings.ADMINS[0][1], 52 | 'domain': settings.SITE_URL.split('/')[2], 53 | 'site_name': settings.SITE_NAME, 54 | 'uid': u'4', 55 | 'user': 4, 56 | 'token': '3gg-37af4e5097565a629f2e', 57 | 'protocol': settings.SITE_URL.split('/')[0].rstrip(':')} 58 | 59 | 60 | factory.register(PasswordResetMail, PasswordResetForm) 61 | 62 | You can then update your urls.py to use this new form: 63 | 64 | .. code-block:: python 65 | 66 | from mail_factory.contrib.auth.views import PasswordResetView 67 | 68 | url(_(r'^password_reset/$'), 69 | PasswordResetView.as_view(email_template_name="password_reset"), 70 | name="password_reset"), 71 | 72 | 73 | The default PasswordResetMail is not registered in the factory so that 74 | people that don't use it are not disturb. 75 | 76 | If you want to use it as is, you can just register it in your app 77 | ``mails.py`` file like that: 78 | 79 | 80 | .. code-block:: python 81 | 82 | from mail_factory import factory 83 | from mail_factory.contrib.auth.mails import PasswordResetMail 84 | 85 | factory.register(PasswordResetMail) 86 | -------------------------------------------------------------------------------- /mail_factory/tests/test_contrib.py: -------------------------------------------------------------------------------- 1 | """Keep in mind throughout those tests that the mails from demo.demo_app.mails 2 | are automatically registered, and serve as fixture.""" 3 | 4 | 5 | from django.contrib import admin 6 | from django.contrib.auth.models import User 7 | from django.contrib.auth.views import PasswordResetConfirmView, PasswordResetDoneView 8 | from django.core import mail 9 | from django.test import TestCase, override_settings 10 | from django.urls import re_path, reverse 11 | 12 | from mail_factory import factory 13 | from mail_factory.contrib.auth.mails import PasswordResetMail 14 | from mail_factory.contrib.auth.views import PasswordResetView, password_reset 15 | 16 | urlpatterns = [ 17 | re_path(r"^reset/$", password_reset, name="reset"), 18 | re_path( 19 | r"^reset_template_name/$", 20 | PasswordResetView.as_view(email_template_name="password_reset"), 21 | name="reset_template_name", 22 | ), 23 | re_path( 24 | r"^password_reset/(?P[0-9A-Za-z_\-]+)/" 25 | r"(?P[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,64})/$", 26 | PasswordResetConfirmView.as_view(), 27 | name="password_reset_confirm", 28 | ), 29 | re_path( 30 | r"^password_reset/done/$", 31 | PasswordResetDoneView.as_view(), 32 | name="password_reset_done", 33 | ), 34 | re_path(r"^admin/", admin.site.urls), 35 | ] 36 | 37 | 38 | def with_registered_mail_klass(mail_klass): 39 | def wrapper(func): 40 | def wrapped(*args, **kwargs): 41 | factory.register(mail_klass) 42 | result = func(*args, **kwargs) 43 | factory.unregister(mail_klass) 44 | return result 45 | 46 | return wrapped 47 | 48 | return wrapper 49 | 50 | 51 | @override_settings(ROOT_URLCONF="mail_factory.tests.test_contrib") 52 | class ContribTestCase(TestCase): 53 | def test_password_reset_default(self): 54 | 55 | user = User.objects.create_user( 56 | username="user", email="admin@example.com", password="password" 57 | ) 58 | 59 | response = self.client.post(reverse("reset"), data={"email": user.email}) 60 | self.assertRedirects(response, reverse("password_reset_done")) 61 | 62 | self.assertEqual(len(mail.outbox), 1) 63 | email = mail.outbox[0] 64 | self.assertEqual(email.subject, "Password reset on example.com") 65 | 66 | @with_registered_mail_klass(PasswordResetMail) 67 | def test_password_reset_with_template_name(self): 68 | 69 | user = User.objects.create_user( 70 | username="user", email="admin@example.com", password="password" 71 | ) 72 | 73 | response = self.client.post( 74 | reverse("reset_template_name"), data={"email": user.email} 75 | ) 76 | self.assertRedirects(response, reverse("password_reset_done")) 77 | 78 | self.assertEqual(len(mail.outbox), 1) 79 | email = mail.outbox[0] 80 | self.assertEqual(email.subject, "Password reset on example.com") 81 | -------------------------------------------------------------------------------- /mail_factory/tests/test_forms.py: -------------------------------------------------------------------------------- 1 | """Keep in mind throughout those tests that the mails from demo.demo_app.mails 2 | are automatically registered, and serve as fixture.""" 3 | 4 | 5 | from django import forms 6 | from django.test import TestCase 7 | 8 | from ..forms import MailForm 9 | from ..mails import BaseMail 10 | 11 | 12 | class FormTest(TestCase): 13 | def test_init_initial(self): 14 | class TestForm(MailForm): 15 | class Meta: 16 | initial = {"title": "My subject", "content": "My content"} 17 | 18 | # without Meta 19 | self.assertEqual(MailForm().initial, {}) 20 | self.assertEqual(MailForm(initial={"foo": "bar"}).initial["foo"], "bar") 21 | # with Meta 22 | self.assertEqual(TestForm().initial["content"], "My content") 23 | self.assertEqual(TestForm().initial["title"], "My subject") 24 | # with "initial" provided to the form constructor (takes precedence) 25 | self.assertEqual(TestForm(initial={"content": "foo"}).initial["content"], "foo") 26 | 27 | def test_init_mail_class(self): 28 | class CommentMail(BaseMail): 29 | params = ["content"] 30 | template_name = "comment" 31 | 32 | class CommentForm(MailForm): 33 | title = forms.CharField() 34 | 35 | class Meta: 36 | initial = {"title": "My subject", "content": "My content"} 37 | 38 | class CommentFormWithMailClass(MailForm): 39 | title = forms.CharField() 40 | mail_class = CommentMail 41 | 42 | class Meta: 43 | initial = {"title": "My subject", "content": "My content"} 44 | 45 | # without mail_class 46 | mailform = CommentForm() 47 | self.assertIn("title", mailform.fields) 48 | self.assertNotIn("content", mailform.fields) 49 | # with mail_class as constructor parameter 50 | mailform = CommentForm(mail_class=CommentMail) 51 | self.assertEqual(mailform.mail_class, CommentMail) 52 | self.assertIn("title", mailform.fields) 53 | self.assertIn("content", mailform.fields) 54 | self.assertEqual(list(mailform.fields.keys()), ["content", "title"]) 55 | # with mail_class as class attribute 56 | mailform = CommentFormWithMailClass() 57 | self.assertEqual(mailform.mail_class, CommentMail) 58 | self.assertIn("title", mailform.fields) 59 | self.assertIn("content", mailform.fields) 60 | self.assertEqual(list(mailform.fields.keys()), ["content", "title"]) 61 | 62 | def test_get_field_for_params(self): 63 | field = MailForm().get_field_for_param("foo") 64 | self.assertTrue(isinstance(field, forms.CharField)) 65 | 66 | def test_get_context_data(self): 67 | self.assertEqual(MailForm().get_context_data(), {}) 68 | self.assertEqual( 69 | MailForm(initial={"foo": "bar"}).get_context_data()["foo"], "bar" 70 | ) 71 | self.assertEqual(MailForm().get_context_data(foo="bar")["foo"], "bar") 72 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. django-mail-factory documentation master file, created by 2 | sphinx-quickstart on Wed Jan 23 17:31:52 2013. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to django-mail-factory's documentation! 7 | =============================================== 8 | 9 | Django Mail Factory is a little Django app that let's you manage emails 10 | for your project very easily. 11 | It's compatible with Django >= 1.11. 12 | 13 | Features 14 | -------- 15 | 16 | Django Mail Factory has support for: 17 | 18 | * Multilingual 19 | * Administration to preview or render emails 20 | * Multi-alternatives emails: text and html 21 | * Attachments 22 | * HTML inline display of attached images 23 | 24 | 25 | Other resources 26 | --------------- 27 | 28 | Fork it on: http://github.com/peopledoc/django-mail-factory/ 29 | 30 | Documentation: http://django-mail-factory.rtfd.org/ 31 | 32 | 33 | Get started 34 | ----------- 35 | 36 | From PyPI:: 37 | 38 | pip install django-mail-factory 39 | 40 | From the github tree:: 41 | 42 | pip install -e http://github.com/peopledoc/django-mail-factory/ 43 | 44 | Then add ``mail_factory`` to your *INSTALLED_APPS*:: 45 | 46 | INSTALLED_APPS = ( 47 | ... 48 | 'mail_factory', 49 | ... 50 | ) 51 | 52 | 53 | Create your first mail 54 | ---------------------- 55 | 56 | :file:`my_app/mails.py`: 57 | 58 | .. code-block:: python 59 | 60 | from mail_factory import factory 61 | from mail_factory.mails import BaseMail 62 | 63 | class WelcomeEmail(BaseMail): 64 | template_name = 'activation_email' 65 | params = ['user', 'site_name', 'site_url'] 66 | 67 | factory.register(WelcomeEmail) 68 | 69 | Then you must also create the templates: 70 | 71 | * :file:`templates/mails/activation_email/subject.txt` 72 | 73 | :: 74 | 75 | [{{site_name }}] Dear {{ user.first_name }}, your account is created 76 | 77 | 78 | * :file:`templates/mails/activation_email/body.txt` 79 | 80 | :: 81 | 82 | Dear {{ user.first_name }}, 83 | 84 | Your account has been created for the site {{ site_name }}, and is 85 | available at {{ site_url }}. 86 | 87 | See you there soon! 88 | 89 | 90 | The awesome {{ site_name }} team 91 | 92 | 93 | * :file:`templates/mails/activation_email/body.html` (optional) 94 | 95 | 96 | Send a mail 97 | ----------- 98 | 99 | **Using the factory**: 100 | 101 | .. code-block:: python 102 | 103 | from mail_factory import factory 104 | 105 | 106 | factory.mail('activation_email', [user.email], 107 | {'user': user, 108 | 'site_name': settings.SITE_NAME, 109 | 'site_url': settings.SITE_URL}) 110 | 111 | 112 | **Using the mail class**: 113 | 114 | .. code-block:: python 115 | 116 | from my_app.mails import WelcomeEmail 117 | 118 | 119 | msg = WelcomeEmail({'user': user, 120 | 'site_name': settings.SITE_NAME, 121 | 'site_url': settings.SITE_URL}) 122 | msg.send([user.email]) 123 | 124 | 125 | How does it work? 126 | ----------------- 127 | 128 | At startup, all :file:`mails.py` files in your application folders are 129 | automatically discovered and the emails are registered to the factory. 130 | 131 | You can then directly call your emails from the factory with their 132 | ``template_name``. 133 | 134 | It also allows you to list your emails in the administration, preview and test 135 | them by sending them to a custom address with a custom context. 136 | 137 | .. note:: `mail_factory` automatically performs autodiscovery of mails modules 138 | in installed applications. To prevent it, change your INSTALLED_APPS 139 | to contain 'mail_factory.SimpleMailFactoryConfig' instead of 140 | 'mail_factory'. 141 | 142 | 143 | Contents 144 | -------- 145 | 146 | .. toctree:: 147 | :maxdepth: 2 148 | 149 | django 150 | api 151 | template 152 | interface 153 | -------------------------------------------------------------------------------- /mail_factory/templates/mail_factory/form.html: -------------------------------------------------------------------------------- 1 | {% extends "mail_factory/base.html" %} 2 | {% load i18n %} 3 | 4 | {% if not is_popup %} 5 | {% block breadcrumbs %} 6 | 10 | {% endblock %} 11 | {% endif %} 12 | 13 | {% block extrahead %} 14 | {{ block.super }} 15 | {{ form.media }} 16 | 17 | 36 | 37 | 57 | {% endblock %} 58 | 59 | {% block content %} 60 |
61 |
62 | {% if preview_messages|length > 1 %} 63 |

{% trans "View mail" %}

64 |
65 |
    66 | {% for lang_code, message in preview_messages.items %} 67 |
  • 68 | {{ lang_code }} 69 |
  • 70 | {% endfor %} 71 |
72 |
73 |
74 | {% endif %} 75 | 76 |
77 |
    78 | {% for lang_code, message in preview_messages.items %} 79 |
95 |
96 |
97 |
98 | 99 |
100 |

Send mail

101 |
102 |
{% csrf_token %} 103 | {{ form.as_p }} 104 | 105 |
106 | 107 | 108 |
109 |
110 |
111 |
112 | {% endblock %} 113 | -------------------------------------------------------------------------------- /mail_factory/messages.py: -------------------------------------------------------------------------------- 1 | import re 2 | from email.mime.base import MIMEBase 3 | from os.path import basename 4 | 5 | from django.conf import settings 6 | from django.core.mail import EmailMultiAlternatives, SafeMIMEMultipart 7 | 8 | 9 | # http://djangosnippets.org/snippets/2215/ 10 | class EmailMultiRelated(EmailMultiAlternatives): 11 | """ 12 | A version of EmailMessage that makes it easy to send multipart/related 13 | messages. For example, including text and HTML versions with inline images. 14 | """ 15 | 16 | related_subtype = "related" 17 | 18 | def __init__( 19 | self, 20 | subject="", 21 | body="", 22 | from_email=None, 23 | to=None, 24 | bcc=None, 25 | connection=None, 26 | attachments=None, 27 | headers=None, 28 | alternatives=None, 29 | ): 30 | self.related_attachments = [] 31 | super().__init__( 32 | subject, 33 | body, 34 | from_email, 35 | to, 36 | bcc, 37 | connection, 38 | attachments, 39 | headers, 40 | alternatives, 41 | ) 42 | 43 | def attach_related(self, filename=None, content=None, mimetype=None): 44 | """ 45 | Attaches a file with the given filename and content. The filename can 46 | be omitted and the mimetype is guessed, if not provided. 47 | 48 | If the first parameter is a MIMEBase subclass it is inserted directly 49 | into the resulting message attachments. 50 | """ 51 | if isinstance(filename, MIMEBase): 52 | assert content is None and mimetype is None 53 | self.related_attachments.append(filename) 54 | else: 55 | assert content is not None 56 | self.related_attachments.append((filename, content, mimetype)) 57 | 58 | def attach_related_file(self, path, mimetype=None, filename=None): 59 | """Attaches a file from the filesystem.""" 60 | if not filename: 61 | filename = basename(path) 62 | 63 | with open(path, "rb") as fd: 64 | content = fd.read() 65 | 66 | self.attach_related(filename, content, mimetype) 67 | 68 | def _create_message(self, msg): 69 | return self._create_attachments( 70 | self._create_related_attachments(self._create_alternatives(msg)) 71 | ) 72 | 73 | def _create_alternatives(self, msg): 74 | for i, (content, mimetype) in enumerate(self.alternatives): 75 | if mimetype == "text/html": 76 | for filename, _, _ in self.related_attachments: 77 | content = re.sub( 78 | r"(?" % filename) 113 | return attachment 114 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 0.25 (unreleased) 5 | ----------------- 6 | 7 | 8 | 0.24 (2022-02-08) 9 | ----------------- 10 | 11 | - Add version to setup.cfg 12 | 13 | 14 | 0.23 (2022-02-07) 15 | ----------------- 16 | 17 | - Update release-drafter.yml 18 | - Django3 compatibility 19 | - Create CODEOWNERS 20 | 21 | 22 | 0.22 (2020-02-03) 23 | ----------------- 24 | 25 | - Run tests for Django 2.2 and Python 3.5, 3.6, 3.7 & 3.8 26 | - Fix mail_factory.contrib app for django >= 2.1 27 | - Isort the code 28 | - Display python warnings while running tox targets 29 | - Remove some Django < 1.11 compatibility code 30 | 31 | 32 | 0.21 (2018-09-20) 33 | ----------------- 34 | 35 | - Migrate GitHub organization 36 | - Drop Python 3.4 support 37 | - Drop Django 1.9 support 38 | - Add Python 3.6 support 39 | - Add Django 2.0 support 40 | - Drop Django 1.8 support 41 | - Drop Django 1.10 support 42 | 43 | 44 | 0.20 (2017-10-12) 45 | ----------------- 46 | 47 | - Do not load url templatetag in templates 48 | 49 | 50 | 0.19 (2017-09-21) 51 | ----------------- 52 | 53 | - Drop Python 2.6 support 54 | - Drop Django < 1.7 support 55 | - Run the tests with Django 1.11 56 | 57 | 58 | 0.18 (2017-07-17) 59 | ----------------- 60 | 61 | - Fix django 1.10 compatibility (render() must be called with a dict, not a Context) 62 | 63 | 64 | 0.17 (2017-05-10) 65 | ----------------- 66 | 67 | - Dropped support for Django < 1.8 (#67) 68 | - Added support for Django 1.9, 1.10 (#68, #69) 69 | 70 | 71 | 0.16 (2016-07-01) 72 | ----------------- 73 | 74 | - Use standard library instead of django.utils.importlib 75 | - Fix flake8 issue with imports 76 | 77 | 78 | 0.15 (2015-11-18) 79 | ----------------- 80 | 81 | - Run the tests with Django 1.8 82 | 83 | 84 | 0.14 (2015-02-03) 85 | ----------------- 86 | 87 | - Add support of no reply address (#54). 88 | 89 | 90 | 0.13 (2014-10-08) 91 | ----------------- 92 | 93 | - Add Reply-To default header as well as headers management. 94 | 95 | 96 | 0.12 (2014-07-28) 97 | ----------------- 98 | 99 | - add compatibility with Django1.7 100 | - use django.utils.translation.override in the text to provide better isolation 101 | 102 | 103 | 0.11 (2014-01-13) 104 | ----------------- 105 | 106 | * fixes #47m: wrong arg order in administration 107 | 108 | 109 | 0.10 (2013-11-26) 110 | ----------------- 111 | 112 | * fixes #43: allow any template name for mails (including those containing "/") 113 | * fixes #42: add wheel support 114 | * repository moved to "novapost" on github 115 | 116 | 117 | 0.9 (2013-08-12) 118 | ---------------- 119 | 120 | * fixes #22: Document how to use inline images 121 | * fixed the doc pdf build on RTD 122 | * small doc fixes 123 | * fixes #25: document how to use the new 'get_preview_data' 124 | * fixes #28: document how to use BaseMailForm by default 125 | * fixes #23: do not display the language chooser if only one language 126 | * fixes #26: autodiscover bubble exception if mails.py present 127 | * fixes #27: display a message when no HTML template was found instead of internal error 128 | * fixes #29: update conf.py version with package version 129 | * link to django default mail integration doc page 130 | 131 | 132 | 0.8 (2013-07-26) 133 | ---------------- 134 | 135 | * Fixed release tags. 136 | 137 | 0.7 (2013-06-19) 138 | ---------------- 139 | 140 | - Added Form.get_preview_data(): provide custom data to override the form's data 141 | 142 | 143 | 0.6 (2013-05-13) 144 | ---------------- 145 | 146 | - Create a password_reset generic views that use mail factory to send the confirmation email. 147 | 148 | 149 | 0.5 (2013-04-03) 150 | ---------------- 151 | 152 | - Fixes #18 - Return object from get_context_data ModelChoicesField 153 | - #17 - Display image in Previews 154 | 155 | 156 | 0.4 (2013-03-12) 157 | ---------------- 158 | 159 | - only display the email preview link to the html alternative if it exist 160 | - full test coverage (100%) 161 | - refactor/rewrite (cleanup, no more meta-programming) 162 | - merged email previewing in MailForm 163 | - now uses tox, added py33 to travis 164 | - ported to python3 165 | - fixed error with mail_admin (was "working" with django<1.5) 166 | 167 | 168 | 0.3 (2013-02-26) 169 | ---------------- 170 | 171 | - Added email previewing (with fake data): https://django-mail-factory.readthedocs.org/en/latest/interface.html#previewing-your-email 172 | - Added ``factory.get_mail_object`` 173 | - Renamed ``factory.get_mail_object`` to ``get_mail_class`` 174 | - Added ``get_text_for`` and ``get_subject_for`` to the factory 175 | - Some refactoring 176 | 177 | 0.2 (2013-02-19) 178 | ---------------- 179 | 180 | - Custom base mail form in the factory 181 | - Strip subject 182 | 183 | 184 | 0.1 (2013-01-29) 185 | ---------------- 186 | 187 | - Mail Administration 188 | - Unittest with coverage 189 | - EmailRelated to get inline images 190 | - Documentation 191 | - Create MailFactory 192 | - Create BaseMail 193 | - Create MailForm 194 | - Demo app 195 | -------------------------------------------------------------------------------- /mail_factory/factory.py: -------------------------------------------------------------------------------- 1 | import base64 2 | 3 | from . import exceptions 4 | from .forms import MailForm 5 | 6 | 7 | class MailFactory: 8 | mail_form = MailForm 9 | _registry = {} # Needed: django.utils.module_loading.autodiscover_modules. 10 | form_map = {} 11 | 12 | def register(self, mail_klass, mail_form=None): 13 | """Register a Mail class with an optional mail form.""" 14 | if not hasattr(mail_klass, "template_name"): 15 | raise exceptions.MailFactoryError( 16 | "%s needs a template_name parameter to be registered" 17 | % (mail_klass.__name__) 18 | ) 19 | 20 | if mail_klass.template_name in self._registry: 21 | raise exceptions.MailFactoryError( 22 | "%s is already registered for %s" 23 | % ( 24 | mail_klass.template_name, 25 | self._registry[mail_klass.template_name].__name__, 26 | ) 27 | ) 28 | self._registry[mail_klass.template_name] = mail_klass 29 | 30 | mail_form = mail_form or self.mail_form 31 | self.form_map[mail_klass.template_name] = mail_form 32 | 33 | def unregister(self, mail_klass): 34 | """Unregister a Mail class from the factory map.""" 35 | if mail_klass not in self._registry.values(): 36 | raise exceptions.MailFactoryError( 37 | "%s is not registered" % mail_klass.template_name 38 | ) 39 | 40 | key = mail_klass.template_name 41 | 42 | del self._registry[key] 43 | del self.form_map[key] 44 | 45 | def get_mail_class(self, template_name): 46 | """Return the registered mail class for this template name.""" 47 | if template_name not in self._registry: 48 | raise exceptions.MailFactoryError("%s is not registered" % template_name) 49 | 50 | return self._registry[template_name] 51 | 52 | def get_mail_object(self, template_name, context=None): 53 | """Return the registered mail class instance for this template name.""" 54 | mail_class = self.get_mail_class(template_name) 55 | return mail_class(context) 56 | 57 | def get_mail_form(self, template_name): 58 | """Return the registered MailForm for this template name.""" 59 | if template_name not in self.form_map: 60 | raise exceptions.MailFactoryError( 61 | "No form registered for %s" % template_name 62 | ) 63 | return self.form_map[template_name] 64 | 65 | def mail( 66 | self, 67 | template_name, 68 | emails, 69 | context, 70 | attachments=None, 71 | from_email=None, 72 | headers=None, 73 | ): 74 | """Send a mail given its template_name.""" 75 | mail = self.get_mail_object(template_name, context) 76 | mail.send(emails, attachments, from_email, headers) 77 | 78 | def mail_admins(self, template_name, context, attachments=None, from_email=None): 79 | """Send a mail given its template name to admins.""" 80 | mail = self.get_mail_object(template_name, context) 81 | mail.mail_admins(attachments, from_email) 82 | 83 | def get_html_for(self, template_name, context, lang=None, cid_to_data=False): 84 | """Preview the body.html mail.""" 85 | mail = self.get_mail_object(template_name, context) 86 | mail_content = mail._render_part("body.html", lang=lang) 87 | 88 | if cid_to_data: 89 | attachments = mail.get_attachments() 90 | for filepath, filename, mimetype in attachments: 91 | with open(filepath, "rb") as attachment: 92 | if mimetype.startswith("image"): 93 | data_url_encode = "data:{};base64,{}".format( 94 | mimetype, 95 | base64.b64encode(attachment.read()), 96 | ) 97 | mail_content = mail_content.replace( 98 | "cid:%s" % filename, data_url_encode 99 | ) 100 | return mail_content 101 | 102 | def get_text_for(self, template_name, context, lang=None): 103 | """Return the rendered mail text body.""" 104 | mail = self.get_mail_object(template_name, context) 105 | return mail._render_part("body.txt", lang=lang) 106 | 107 | def get_subject_for(self, template_name, context, lang=None): 108 | """Return the rendered mail subject.""" 109 | mail = self.get_mail_object(template_name, context) 110 | return mail._render_part("subject.txt", lang=lang) 111 | 112 | def get_raw_content( 113 | self, template_name, emails, context, lang=None, from_email=None 114 | ): 115 | """Return raw mail source before sending.""" 116 | mail = self.get_mail_object(template_name, context) 117 | return mail.create_email_msg(emails, from_email=from_email, lang=lang) 118 | -------------------------------------------------------------------------------- /docs/source/api.rst: -------------------------------------------------------------------------------- 1 | =================== 2 | Hacking MailFactory 3 | =================== 4 | 5 | MailFactory is a tool to help you manage your emails in the real world. 6 | 7 | That means that for the same email, regarding the context, you may want a 8 | different branding, different language, etc. 9 | 10 | 11 | Specify the language for your emails 12 | ==================================== 13 | 14 | If you need to specify the language for your email, other than the currently 15 | used language, you can do so by overriding the ``get_language`` method on your 16 | custom class. 17 | 18 | Let's say that our user has a ``language_code`` as a profile attribute: 19 | 20 | .. code-block:: python 21 | 22 | class ActivationEmail(BaseMail): 23 | template_name = 'activation' 24 | params = ['user', 'activation_key'] 25 | 26 | def get_language(self): 27 | return self.context['user'].get_profile().language_code 28 | 29 | 30 | Force a param for all emails 31 | ============================ 32 | 33 | You can also overriding the ``get_params`` method of a custom ancestor class to 34 | add some mandatory parameters for all your emails: 35 | 36 | .. code-block:: python 37 | 38 | class MyProjectBaseMail(BaseMail): 39 | 40 | def get_params(self): 41 | params = super(MyProjectBaseMail, self).get_params() 42 | return params.append('user') 43 | 44 | class ActivationEmail(MyProjectBaseMail): 45 | template_name = 'activation' 46 | params = ['activation_key'] 47 | 48 | This way, all your emails will have the user in the context by default. 49 | 50 | 51 | Add context data 52 | ================ 53 | 54 | If you have some information that must be added to every email context, you can 55 | put them here: 56 | 57 | .. code-block:: python 58 | 59 | class MyProjectBaseMail(BaseMail): 60 | 61 | def get_context_data(self, **kwargs): 62 | data = super(MyProjectBaseMail, self).get_context_data(**kwargs) 63 | data['site_name'] = settings.SITE_NAME 64 | data['site_url'] = settings.SITE_URL 65 | return data 66 | 67 | 68 | Add attachments 69 | =============== 70 | 71 | Same thing here, if your branding needs a logo or a header in every emails, you 72 | can define it here: 73 | 74 | 75 | .. code-block:: python 76 | 77 | from django.contrib.staticfiles import finders 78 | 79 | class MyProjectBaseMail(BaseMail): 80 | 81 | def get_attachments(self, files=None): 82 | attach = super(MyProjectBaseMail, self).get_attachments(files) 83 | attach.append((finders.find('mails/header.png'), 84 | 'header.png', 'image/png')) 85 | return attach 86 | 87 | Now, if you want to use this attached image in your html template, you need to 88 | use the `cid URI scheme`_ with the name of the attachment, which is the second 89 | item of the tuple (``header.png`` in our example above): 90 | 91 | .. _cid URI scheme: https://en.wikipedia.org/wiki/URI_scheme 92 | 93 | .. code-block:: html 94 | 95 | This is the header 96 | 97 | 98 | Template loading 99 | ================ 100 | 101 | By default, the template parts will be searched in: 102 | 103 | * ``templates/mails/TEMPLATE_NAME/LANGUAGE_CODE/`` 104 | * ``templates/mails/TEMPLATE_NAME/`` 105 | 106 | But you may want to search in different locations, ie: 107 | 108 | * ``templates/SITE_DOMAIN/mails/TEMPLATE_NAME/`` 109 | 110 | To do that, you can override the ``get_template_part`` method: 111 | 112 | .. code-block:: python 113 | 114 | class ActivationEmail(BaseMail): 115 | template_name = 'activation' 116 | params = ['activation_key', 'site'] 117 | 118 | def get_template_part(self, part): 119 | """Return a mail part (body, html body or subject) template 120 | 121 | Try in order: 122 | 123 | 1/ domain specific localized: 124 | example.com/mails/activation/fr/ 125 | 2/ domain specific: 126 | example.com/mails/activation/ 127 | 3/ default localized: 128 | mails/activation/fr/ 129 | 4/ fallback: 130 | mails/activation/ 131 | 132 | """ 133 | templates = [] 134 | 135 | site = self.context['site'] 136 | # 1/ {{ domain_name }}/mails/{{ template_name }}/{{ language_code}}/ 137 | templates.append(path.join(site.domain, 138 | 'mails', 139 | self.template_name, 140 | self.lang, 141 | part)) 142 | # 2/ {{ domain_name }}/mails/{{ template_name }}/ 143 | templates.append(path.join(site.domain, 144 | 'mails', 145 | self.template_name, 146 | part)) 147 | # 3/ and 4/ provided by the base class 148 | base_temps = super(MyProjectBaseMail, self).get_template_part(part) 149 | return templates + base_temps 150 | 151 | ``get_template_part`` returns a list of template and will take the first one 152 | available. 153 | -------------------------------------------------------------------------------- /demo/settings.py: -------------------------------------------------------------------------------- 1 | # Django settings for demo project. 2 | 3 | DEBUG = True 4 | 5 | ADMINS = (("Some Admin", "some_admin@example.com"),) 6 | 7 | MANAGERS = ADMINS 8 | 9 | DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": "db.sqlite"}} 10 | 11 | # Local time zone for this installation. Choices can be found here: 12 | # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name 13 | # although not all choices may be available on all operating systems. 14 | # In a Windows environment this must be set to your system time zone. 15 | TIME_ZONE = "America/Chicago" 16 | 17 | # Language code for this installation. All choices can be found here: 18 | # http://www.i18nguy.com/unicode/language-identifiers.html 19 | LANGUAGE_CODE = "en" 20 | 21 | LANGUAGES = ( 22 | ("en", "English"), 23 | ("fr", "Français"), 24 | ) 25 | 26 | SITE_ID = 1 27 | 28 | # If you set this to False, Django will make some optimizations so as not 29 | # to load the internationalization machinery. 30 | USE_I18N = True 31 | 32 | # If you set this to False, Django will not format dates, numbers and 33 | # calendars according to the current locale. 34 | USE_L10N = True 35 | 36 | # If you set this to False, Django will not use timezone-aware datetimes. 37 | USE_TZ = True 38 | 39 | # Absolute filesystem path to the directory that will hold user-uploaded files. 40 | # Example: "/home/media/media.lawrence.com/media/" 41 | MEDIA_ROOT = "" 42 | 43 | # URL that handles the media served from MEDIA_ROOT. Make sure to use a 44 | # trailing slash. 45 | # Examples: "http://media.lawrence.com/media/", "http://example.com/media/" 46 | MEDIA_URL = "" 47 | 48 | # Absolute path to the directory static files should be collected to. 49 | # Don't put anything in this directory yourself; store your static files 50 | # in apps' "static/" subdirectories and in STATICFILES_DIRS. 51 | # Example: "/home/media/media.lawrence.com/static/" 52 | STATIC_ROOT = "" 53 | 54 | # URL prefix for static files. 55 | # Example: "http://media.lawrence.com/static/" 56 | STATIC_URL = "/static/" 57 | 58 | # Additional locations of static files 59 | STATICFILES_DIRS = ( 60 | # Put strings here, like "/home/html/static" or "C:/www/django/static". 61 | # Always use forward slashes, even on Windows. 62 | # Don't forget to use absolute paths, not relative paths. 63 | ) 64 | 65 | # List of finder classes that know how to find static files in 66 | # various locations. 67 | STATICFILES_FINDERS = ( 68 | "django.contrib.staticfiles.finders.FileSystemFinder", 69 | "django.contrib.staticfiles.finders.AppDirectoriesFinder", 70 | # 'django.contrib.staticfiles.finders.DefaultStorageFinder', 71 | ) 72 | 73 | # Make this unique, and don't share it with anybody. 74 | SECRET_KEY = "-ts#o*zbf9!8yz$+c_)43cd#+pts6m=1n+x65@kcgz3!5i@#))" 75 | 76 | # List of callables that know how to import templates from various sources. 77 | TEMPLATES = [ 78 | { 79 | "BACKEND": "django.template.backends.django.DjangoTemplates", 80 | "APP_DIRS": True, 81 | "OPTIONS": { 82 | "context_processors": [ 83 | "django.contrib.auth.context_processors.auth", 84 | "django.contrib.messages.context_processors.messages", 85 | ] 86 | }, 87 | }, 88 | ] 89 | 90 | MIDDLEWARE = ( 91 | "django.middleware.common.CommonMiddleware", 92 | "django.contrib.sessions.middleware.SessionMiddleware", 93 | "django.middleware.csrf.CsrfViewMiddleware", 94 | "django.contrib.auth.middleware.AuthenticationMiddleware", 95 | "django.contrib.messages.middleware.MessageMiddleware", 96 | # Uncomment the next line for simple clickjacking protection: 97 | # 'django.middleware.clickjacking.XFrameOptionsMiddleware', 98 | ) 99 | 100 | ROOT_URLCONF = "demo.urls" 101 | 102 | # Python dotted path to the WSGI application used by Django's runserver. 103 | WSGI_APPLICATION = "demo.wsgi.application" 104 | 105 | 106 | INSTALLED_APPS = ( 107 | "django.contrib.auth", 108 | "django.contrib.contenttypes", 109 | "django.contrib.sessions", 110 | "django.contrib.sites", 111 | "django.contrib.messages", 112 | "django.contrib.staticfiles", 113 | # Uncomment the next line to enable the admin: 114 | "django.contrib.admin", 115 | # Uncomment the next line to enable admin documentation: 116 | # 'django.contrib.admindocs', 117 | "mail_factory", 118 | "demo.demo_app", 119 | ) 120 | 121 | # A sample logging configuration. The only tangible logging 122 | # performed by this configuration is to send an email to 123 | # the site admins on every HTTP 500 error when DEBUG=False. 124 | # See http://docs.djangoproject.com/en/dev/topics/logging for 125 | # more details on how to customize your logging configuration. 126 | LOGGING = { 127 | "version": 1, 128 | "disable_existing_loggers": False, 129 | "filters": {"require_debug_false": {"()": "django.utils.log.RequireDebugFalse"}}, 130 | "handlers": { 131 | "mail_admins": { 132 | "level": "ERROR", 133 | "filters": ["require_debug_false"], 134 | "class": "django.utils.log.AdminEmailHandler", 135 | } 136 | }, 137 | "loggers": { 138 | "django.request": { 139 | "handlers": ["mail_admins"], 140 | "level": "ERROR", 141 | "propagate": True, 142 | }, 143 | }, 144 | } 145 | -------------------------------------------------------------------------------- /mail_factory/views.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib import messages 3 | from django.contrib.auth.decorators import user_passes_test 4 | from django.http import Http404, HttpResponse 5 | from django.shortcuts import redirect 6 | from django.template import TemplateDoesNotExist 7 | from django.views.generic import FormView, TemplateView 8 | 9 | from . import exceptions, factory 10 | 11 | admin_required = user_passes_test(lambda x: x.is_superuser) 12 | 13 | 14 | class MailListView(TemplateView): 15 | """Return a list of mails.""" 16 | 17 | template_name = "mail_factory/list.html" 18 | 19 | def get_context_data(self, **kwargs): 20 | """Return object_list.""" 21 | data = super().get_context_data(**kwargs) 22 | mail_list = [] 23 | for mail_name, mail_class in sorted( 24 | factory._registry.items(), key=lambda x: x[0] 25 | ): 26 | mail_list.append((mail_name, mail_class.__name__)) 27 | data["mail_map"] = mail_list 28 | return data 29 | 30 | 31 | class MailPreviewMixin: 32 | def get_html_alternative(self, message): 33 | """Return the html alternative, if present.""" 34 | alternatives = {v: k for k, v in message.alternatives} 35 | if "text/html" in alternatives: 36 | return alternatives["text/html"] 37 | 38 | def get_mail_preview(self, template_name, lang, cid_to_data=False): 39 | """Return a preview from a mail's form's initial data.""" 40 | form_class = factory.get_mail_form(self.mail_name) 41 | form = form_class(mail_class=self.mail_class) 42 | 43 | form = form_class(form.get_context_data(), mail_class=self.mail_class) 44 | data = form.get_context_data() 45 | if form.is_valid(): 46 | data.update(form.cleaned_data) 47 | 48 | # overwrite with preview data if any 49 | data.update(form.get_preview_data()) 50 | 51 | mail = self.mail_class(data) 52 | message = mail.create_email_msg([settings.ADMINS], lang=lang) 53 | 54 | try: 55 | message.html = factory.get_html_for( 56 | self.mail_name, data, lang=lang, cid_to_data=True 57 | ) 58 | except TemplateDoesNotExist: 59 | message.html = False 60 | 61 | return message 62 | 63 | 64 | class MailFormView(MailPreviewMixin, FormView): 65 | template_name = "mail_factory/form.html" 66 | 67 | def dispatch(self, request, mail_name): 68 | self.mail_name = mail_name 69 | 70 | try: 71 | self.mail_class = factory.get_mail_class(self.mail_name) 72 | except exceptions.MailFactoryError: 73 | raise Http404 74 | 75 | self.raw = "raw" in request.POST 76 | self.send = "send" in request.POST 77 | self.email = request.POST.get("email") 78 | 79 | return super().dispatch(request) 80 | 81 | def get_form_kwargs(self): 82 | kwargs = super().get_form_kwargs() 83 | kwargs["mail_class"] = self.mail_class 84 | return kwargs 85 | 86 | def get_form_class(self): 87 | return factory.get_mail_form(self.mail_name) 88 | 89 | def form_valid(self, form): 90 | if self.raw: 91 | return HttpResponse( 92 | "
%s
" 93 | % factory.get_raw_content( 94 | self.mail_name, [settings.DEFAULT_FROM_EMAIL], form.cleaned_data 95 | ).message() 96 | ) 97 | 98 | if self.send: 99 | factory.mail(self.mail_name, [self.email], form.cleaned_data) 100 | messages.success( 101 | self.request, "{} mail sent to {}".format(self.mail_name, self.email) 102 | ) 103 | return redirect("mail_factory_list") 104 | 105 | data = None 106 | 107 | if form: 108 | data = form.get_context_data() 109 | if hasattr(form, "cleaned_data"): 110 | data.update(form.cleaned_data) 111 | 112 | try: 113 | html = factory.get_html_for(self.mail_name, data, cid_to_data=True) 114 | except TemplateDoesNotExist: 115 | return redirect("mail_factory_html_not_found", mail_name=self.mail_name) 116 | return HttpResponse(html) 117 | 118 | def get_context_data(self, **kwargs): 119 | data = super().get_context_data(**kwargs) 120 | data["mail_name"] = self.mail_name 121 | 122 | preview_messages = {} 123 | for lang_code, lang_name in settings.LANGUAGES: 124 | message = self.get_mail_preview(self.mail_name, lang_code) 125 | preview_messages[lang_code] = message 126 | data["preview_messages"] = preview_messages 127 | 128 | return data 129 | 130 | 131 | class HTMLNotFoundView(TemplateView): 132 | """No HTML template was found""" 133 | 134 | template_name = "mail_factory/html_not_found.html" 135 | 136 | 137 | class MailPreviewMessageView(MailPreviewMixin, TemplateView): 138 | template_name = "mail_factory/preview_message.html" 139 | 140 | def dispatch(self, request, mail_name, lang): 141 | self.mail_name = mail_name 142 | self.lang = lang 143 | 144 | try: 145 | self.mail_class = factory.get_mail_class(self.mail_name) 146 | except exceptions.MailFactoryError: 147 | raise Http404 148 | 149 | return super().dispatch(request) 150 | 151 | def get_context_data(self, **kwargs): 152 | data = super().get_context_data(**kwargs) 153 | message = self.get_mail_preview(self.mail_name, self.lang) 154 | data["mail_name"] = self.mail_name 155 | data["message"] = message 156 | return data 157 | 158 | 159 | mail_list = admin_required(MailListView.as_view()) 160 | form = admin_required(MailFormView.as_view()) 161 | html_not_found = admin_required(HTMLNotFoundView.as_view()) 162 | preview_message = admin_required(MailPreviewMessageView.as_view()) 163 | -------------------------------------------------------------------------------- /mail_factory/mails.py: -------------------------------------------------------------------------------- 1 | from os.path import join 2 | 3 | import html2text 4 | 5 | from django.conf import settings 6 | from django.template import TemplateDoesNotExist 7 | from django.template.loader import select_template 8 | from django.utils import translation 9 | 10 | from . import exceptions 11 | from .messages import EmailMultiRelated 12 | 13 | 14 | class BaseMail: 15 | """Abstract class that helps creating emails. 16 | 17 | You need to define: 18 | * template_name : The template_dir in which to find parts. (subject, body) 19 | * params : Mandatory variable in the context to render the mail. 20 | 21 | You also may overwrite: 22 | * get_params: to build the mandatory variable list in the mail context 23 | * get_context_data: to add global context such as SITE_NAME 24 | * get_template_part: to get the list of possible paths to get parts. 25 | """ 26 | 27 | def __init__(self, context=None): 28 | """Create a mail instance from a context.""" 29 | # Create the context 30 | context = context or {} 31 | self.context = self.get_context_data(**context) 32 | self.lang = self.get_language() 33 | 34 | # Check that all the mandatory context is present. 35 | for key in self.get_params(): 36 | if key not in context: 37 | raise exceptions.MissingMailContextParamException(repr(key)) 38 | 39 | def get_language(self): 40 | # Auto detect the current language 41 | return translation.get_language() # Get current language 42 | 43 | def get_params(self): 44 | """Returns the list of mandatory context variables.""" 45 | return self.params 46 | 47 | def get_context_data(self, **kwargs): 48 | """Returns automatic context_data.""" 49 | return kwargs.copy() 50 | 51 | def get_attachments(self, attachments=None): 52 | """Return the attachments.""" 53 | return attachments or [] 54 | 55 | def get_template_part(self, part, lang=None): 56 | """Return a mail part 57 | 58 | * subject.txt 59 | * body.txt 60 | * body.html 61 | 62 | Try in order: 63 | 64 | 1/ localized: mails/{{ template_name }}/fr/ 65 | 2/ fallback: mails/{{ template_name }}/ 66 | 67 | """ 68 | templates = [] 69 | # 1/ localized: mails/invitation_code/fr/ 70 | localized = join("mails", self.template_name, lang or self.lang, part) 71 | templates.append(localized) 72 | 73 | # 2/ fallback: mails/invitation_code/ 74 | fallback = join("mails", self.template_name, part) 75 | templates.append(fallback) 76 | 77 | # return the list of templates path candidates 78 | return templates 79 | 80 | def _render_part(self, part, lang=None): 81 | """Render a mail part against the mail context. 82 | 83 | Part can be: 84 | 85 | * subject.txt 86 | * body.txt 87 | * body.html 88 | 89 | """ 90 | tpl = select_template(self.get_template_part(part, lang=lang)) 91 | with translation.override(lang or self.lang): 92 | rendered = tpl.render(self.context) 93 | return rendered.strip() 94 | 95 | def create_email_msg( 96 | self, 97 | emails, 98 | attachments=None, 99 | from_email=None, 100 | lang=None, 101 | message_class=EmailMultiRelated, 102 | headers=None, 103 | ): 104 | """Create an email message instance.""" 105 | 106 | from_email = from_email or settings.DEFAULT_FROM_EMAIL 107 | subject = self._render_part("subject.txt", lang=lang) 108 | try: 109 | body = self._render_part("body.txt", lang=lang) 110 | except TemplateDoesNotExist: 111 | body = None 112 | try: 113 | html_content = self._render_part("body.html", lang=lang) 114 | except TemplateDoesNotExist: 115 | html_content = None 116 | 117 | # If we have neither a html or txt template 118 | if html_content is None and body is None: 119 | raise TemplateDoesNotExist("Txt and html templates have not been found") 120 | 121 | # If we have the html template only, we build automatically 122 | # txt content. 123 | if html_content is not None and body is None: 124 | h = html2text.HTML2Text() 125 | body = h.handle(html_content) 126 | 127 | if headers is None: 128 | reply_to = getattr(settings, "NO_REPLY_EMAIL", None) 129 | if not reply_to: 130 | reply_to = getattr( 131 | settings, "SUPPORT_EMAIL", settings.DEFAULT_FROM_EMAIL 132 | ) 133 | 134 | headers = {"Reply-To": reply_to} 135 | 136 | msg = message_class(subject, body, from_email, emails, headers=headers) 137 | if html_content: 138 | msg.attach_alternative(html_content, "text/html") 139 | 140 | attachments = self.get_attachments(attachments) 141 | 142 | if attachments: 143 | for filepath, filename, mimetype in attachments: 144 | with open(filepath, "rb") as attachment: 145 | if mimetype.startswith("image"): 146 | msg.attach_related_file(filepath, mimetype, filename) 147 | else: 148 | msg.attach(filename, attachment.read(), mimetype) 149 | return msg 150 | 151 | def send(self, emails, attachments=None, from_email=None, headers=None): 152 | """Create the message and send it to emails.""" 153 | message = self.create_email_msg( 154 | emails, attachments=attachments, from_email=from_email, headers=headers 155 | ) 156 | message.send() 157 | 158 | def mail_admins(self, attachments=None, from_email=None): 159 | """Send email to admins.""" 160 | self.send([a[1] for a in settings.ADMINS], attachments, from_email) 161 | -------------------------------------------------------------------------------- /mail_factory/tests/test_factory.py: -------------------------------------------------------------------------------- 1 | """Keep in mind throughout those tests that the mails from demo.demo_app.mails 2 | are automatically registered, and serve as fixture.""" 3 | 4 | 5 | from django.conf import settings 6 | from django.test import TestCase 7 | 8 | from .. import factory 9 | from ..exceptions import MailFactoryError 10 | from ..forms import MailForm 11 | from ..mails import BaseMail 12 | 13 | 14 | class RegistrationTest(TestCase): 15 | def tearDown(self): 16 | if "foo" in factory._registry: 17 | del factory._registry["foo"] 18 | 19 | def test_registration_without_template_name(self): 20 | class TestMail(BaseMail): 21 | pass 22 | 23 | with self.assertRaises(MailFactoryError): 24 | factory.register(TestMail) 25 | 26 | def test_registration_already_registered(self): 27 | class TestMail(BaseMail): 28 | template_name = "foo" 29 | 30 | factory.register(TestMail) 31 | with self.assertRaises(MailFactoryError): 32 | factory.register(TestMail) 33 | 34 | def test_registration(self): 35 | class TestMail(BaseMail): 36 | template_name = "foo" 37 | 38 | factory.register(TestMail) 39 | self.assertIn("foo", factory._registry) 40 | self.assertEqual(factory._registry["foo"], TestMail) 41 | self.assertIn("foo", factory.form_map) 42 | self.assertEqual(factory.form_map["foo"], MailForm) # default form 43 | 44 | def test_registration_with_custom_form(self): 45 | class TestMail(BaseMail): 46 | template_name = "foo" 47 | 48 | class TestMailForm(MailForm): 49 | pass 50 | 51 | factory.register(TestMail, TestMailForm) 52 | self.assertIn("foo", factory.form_map) 53 | self.assertEqual(factory.form_map["foo"], TestMailForm) # custom form 54 | 55 | def test_factory_unregister(self): 56 | class TestMail(BaseMail): 57 | template_name = "foo" 58 | 59 | factory.register(TestMail) 60 | self.assertIn("foo", factory._registry) 61 | factory.unregister(TestMail) 62 | self.assertNotIn("foo", factory._registry) 63 | with self.assertRaises(MailFactoryError): 64 | factory.unregister(TestMail) 65 | 66 | 67 | class FactoryTest(TestCase): 68 | def setUp(self): 69 | class TestMail(BaseMail): 70 | template_name = "test" 71 | params = ["title"] 72 | 73 | self.test_mail = TestMail 74 | factory.register(TestMail) 75 | 76 | def tearDown(self): 77 | factory.unregister(self.test_mail) 78 | 79 | def test_get_mail_class_not_registered(self): 80 | with self.assertRaises(MailFactoryError): 81 | factory.get_mail_class("not registered") 82 | 83 | def test_factory_get_mail_class(self): 84 | self.assertEqual(factory.get_mail_class("test"), self.test_mail) 85 | 86 | def test_factory_get_mail_object(self): 87 | self.assertTrue( 88 | isinstance( 89 | factory.get_mail_object("test", {"title": "foo"}), self.test_mail 90 | ) 91 | ) 92 | 93 | def test_get_mail_form_not_registered(self): 94 | with self.assertRaises(MailFactoryError): 95 | factory.get_mail_form("not registered") 96 | 97 | def test_factory_get_mail_form(self): 98 | self.assertEqual(factory.get_mail_form("test"), MailForm) 99 | 100 | def test_html_for(self): 101 | """Get the html body of the mail.""" 102 | message = factory.get_html_for("test", {"title": "Et hop"}) 103 | self.assertIn("Et hop", message) 104 | 105 | def test_text_for(self): 106 | """Get the text body of the mail.""" 107 | message = factory.get_text_for("test", {"title": "Et hop"}) 108 | self.assertIn("Et hop", message) 109 | 110 | def test_subject_for(self): 111 | """Get the subject of the mail.""" 112 | subject = factory.get_subject_for("test", {"title": "Et hop"}) 113 | self.assertEqual(subject, "[TestCase] Mail test subject") 114 | 115 | def test_get_raw_content(self): 116 | """Get the message object.""" 117 | message = factory.get_raw_content( 118 | "test", ["test@mail.com"], {"title": "Et hop"} 119 | ) 120 | self.assertEqual(message.to, ["test@mail.com"]) 121 | self.assertEqual(message.from_email, settings.DEFAULT_FROM_EMAIL) 122 | self.assertIn("Et hop", str(message.message())) 123 | 124 | 125 | class FactoryMailTest(TestCase): 126 | def setUp(self): 127 | class MockMail: # mock mail to check if its methods are called 128 | mail_admins_called = False 129 | send_called = False 130 | template_name = "mockmail" 131 | 132 | def send(self, *args, **kwargs): 133 | self.send_called = True 134 | 135 | def mail_admins(self, *args, **kwargs): 136 | self.mail_admins_called = True 137 | 138 | self.mock_mail = MockMail() 139 | self.mock_mail_class = MockMail 140 | factory.register(MockMail) 141 | self.old_get_mail_object = factory.get_mail_object 142 | factory.get_mail_object = self._mock_get_mail_object 143 | 144 | def tearDown(self): 145 | factory.unregister(self.mock_mail_class) 146 | self.mock_mail.send_called = False 147 | self.mock_mail.mail_admins_called = False 148 | factory.get_mail_object = self.old_get_mail_object 149 | 150 | def _mock_get_mail_object(self, template_name, context): 151 | return self.mock_mail 152 | 153 | def test_mail(self): 154 | self.assertFalse(self.mock_mail.send_called) 155 | factory.mail("test", ["foo@example.com"], {}) 156 | self.assertTrue(self.mock_mail.send_called) 157 | 158 | def test_mail_admins(self): 159 | self.assertFalse(self.mock_mail.mail_admins_called) 160 | factory.mail_admins("test", {}) 161 | self.assertTrue(self.mock_mail.mail_admins_called) 162 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = -W 6 | ifndef SPHINXBUILD 7 | SPHINXBUILD = sphinx-build 8 | endif 9 | PAPER = 10 | BUILDDIR = build 11 | 12 | # Internal variables. 13 | PAPEROPT_a4 = -D latex_paper_size=a4 14 | PAPEROPT_letter = -D latex_paper_size=letter 15 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 16 | # the i18n builder cannot share the environment and doctrees with the others 17 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 18 | 19 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 20 | 21 | help: 22 | @echo "Please use \`make ' where is one of" 23 | @echo " html to make standalone HTML files" 24 | @echo " dirhtml to make HTML files named index.html in directories" 25 | @echo " singlehtml to make a single large HTML file" 26 | @echo " pickle to make pickle files" 27 | @echo " json to make JSON files" 28 | @echo " htmlhelp to make HTML files and a HTML help project" 29 | @echo " qthelp to make HTML files and a qthelp project" 30 | @echo " devhelp to make HTML files and a Devhelp project" 31 | @echo " epub to make an epub" 32 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 33 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 34 | @echo " text to make text files" 35 | @echo " man to make manual pages" 36 | @echo " texinfo to make Texinfo files" 37 | @echo " info to make Texinfo files and run them through makeinfo" 38 | @echo " gettext to make PO message catalogs" 39 | @echo " changes to make an overview of all changed/added/deprecated items" 40 | @echo " linkcheck to check all external links for integrity" 41 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 42 | 43 | clean: 44 | -rm -rf $(BUILDDIR)/* 45 | 46 | html: 47 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 48 | @echo 49 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 50 | 51 | dirhtml: 52 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 53 | @echo 54 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 55 | 56 | singlehtml: 57 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 58 | @echo 59 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 60 | 61 | pickle: 62 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 63 | @echo 64 | @echo "Build finished; now you can process the pickle files." 65 | 66 | json: 67 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 68 | @echo 69 | @echo "Build finished; now you can process the JSON files." 70 | 71 | htmlhelp: 72 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 73 | @echo 74 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 75 | ".hhp project file in $(BUILDDIR)/htmlhelp." 76 | 77 | qthelp: 78 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 79 | @echo 80 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 81 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 82 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-mail-factory.qhcp" 83 | @echo "To view the help file:" 84 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-mail-factory.qhc" 85 | 86 | devhelp: 87 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 88 | @echo 89 | @echo "Build finished." 90 | @echo "To view the help file:" 91 | @echo "# mkdir -p $$HOME/.local/share/devhelp/django-mail-factory" 92 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-mail-factory" 93 | @echo "# devhelp" 94 | 95 | epub: 96 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 97 | @echo 98 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 99 | 100 | latex: 101 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 102 | @echo 103 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 104 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 105 | "(use \`make latexpdf' here to do that automatically)." 106 | 107 | latexpdf: 108 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 109 | @echo "Running LaTeX files through pdflatex..." 110 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 111 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 112 | 113 | text: 114 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 115 | @echo 116 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 117 | 118 | man: 119 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 120 | @echo 121 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 122 | 123 | texinfo: 124 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 125 | @echo 126 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 127 | @echo "Run \`make' in that directory to run these through makeinfo" \ 128 | "(use \`make info' here to do that automatically)." 129 | 130 | info: 131 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 132 | @echo "Running Texinfo files through makeinfo..." 133 | make -C $(BUILDDIR)/texinfo info 134 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 135 | 136 | gettext: 137 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 138 | @echo 139 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 140 | 141 | changes: 142 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 143 | @echo 144 | @echo "The overview file is in $(BUILDDIR)/changes." 145 | 146 | linkcheck: 147 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 148 | @echo 149 | @echo "Link check complete; look for any errors in the above output " \ 150 | "or in $(BUILDDIR)/linkcheck/output.txt." 151 | 152 | doctest: 153 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 154 | @echo "Testing of doctests in the sources finished, look at the " \ 155 | "results in $(BUILDDIR)/doctest/output.txt." 156 | -------------------------------------------------------------------------------- /docs/source/interface.rst: -------------------------------------------------------------------------------- 1 | ========================= 2 | The MailFactory Interface 3 | ========================= 4 | 5 | In daily work email branding is important because it will call your 6 | customer to action. 7 | 8 | MailFactory comes with a little tool that helps you to test your emails. 9 | 10 | To do that, we generate a Django Form so that you may provide a context for 11 | your email. 12 | 13 | Here's how you can customize your administration form. 14 | 15 | 16 | Enabling MailFactory administration 17 | =================================== 18 | 19 | You just need to enable the urls: 20 | 21 | :file:`project/urls.py`: 22 | 23 | .. code-block:: python 24 | 25 | urlpatterns = ( 26 | # ... 27 | url(r'^admin/mails/', include('mail_factory.urls')), 28 | url(r'^admin/', include(admin.site.urls)), 29 | # ... 30 | ) 31 | 32 | Then you can connect to `/admin/mails/ 33 | `_ to try out your emails. 34 | 35 | 36 | Registering a specific form 37 | =========================== 38 | 39 | These two calls are equivalent: 40 | 41 | .. code-block:: python 42 | 43 | from mail_factory import factory, BaseMail 44 | 45 | 46 | class InvitationMail(BaseMail): 47 | template_name = "invitation" 48 | params = ['user'] 49 | 50 | factory.register(InvitationMail) 51 | 52 | .. code-block:: python 53 | 54 | from mail_factory import factory, MailForm, BaseMail 55 | 56 | 57 | class InvitationMail(BaseMail): 58 | template_name = "invitation" 59 | params = ['user'] 60 | 61 | factory.register(InvitationMail, MailForm) 62 | 63 | 64 | Creating a custom MailForm 65 | ========================== 66 | 67 | We may also want to build a very specific form for our email. 68 | 69 | Let's say we have a *share this page* email, with a custom message: 70 | 71 | .. code-block:: python 72 | 73 | from mail_factory import factory, BaseMail, MailForm 74 | 75 | 76 | class SharePageMail(BaseMail): 77 | template_name = "share_page" 78 | params = ['user', 'message', 'date'] 79 | 80 | 81 | class SharePageMailForm(MailForm): 82 | user = forms.ModelChoiceField(queryset=User.objects.all()) 83 | message = forms.CharField(widget=forms.Textarea) 84 | date = forms.DateTimeField() 85 | 86 | 87 | factory.register(SharePageMail, SharePageMailForm) 88 | 89 | 90 | Define form initial data 91 | ======================== 92 | 93 | You can define ``Meta.initial`` to automatically provide a context for 94 | your mail. 95 | 96 | .. code-block:: python 97 | 98 | import datetime 99 | import uuid 100 | 101 | from django import forms 102 | from django.conf import settings 103 | from django.urls import reverse_lazy as reverse 104 | from mail_factory import factory, MailForm, BaseMail 105 | 106 | 107 | class ShareBucketMail(BaseMail): 108 | template_name = 'share_bucket' 109 | params = ['first_name', 'last_name', 'comment', 'expiration_date', 110 | 'activation_url'] 111 | 112 | 113 | def activation_url(): 114 | return '%s%s' % ( 115 | settings.SITE_URL, reverse('share:index', 116 | args=[str(uuid.uuid4()).replace('-', '')])) 117 | 118 | 119 | class ShareBucketForm(MailForm): 120 | expiration_date = forms.DateField() 121 | 122 | class Meta: 123 | initial = {'first_name': 'Thibaut', 124 | 'last_name': 'Dupont', 125 | 'comment': 'I shared with you documents we talked about.', 126 | 'expiration_date': datetime.date.today, 127 | 'activation_url': activation_url} 128 | 129 | factory.register(ShareBucketMail, ShareBucketForm) 130 | 131 | Then the mail form will be autopopulated with this data. 132 | 133 | 134 | Creating your application custom MailForm 135 | ========================================= 136 | 137 | By default, all email params are represented as a ``forms.CharField()``, which 138 | uses a basic text input. 139 | 140 | Let's create a project wide ``BaseMailForm`` that uses a ``ModelChoiceField`` 141 | on ``auth.models.User`` each time a ``user`` param is needed in the email. 142 | 143 | .. code-block:: python 144 | 145 | from django.contrib.auth.models import User 146 | from django import forms 147 | from mail_factory.forms import MailForm 148 | 149 | 150 | class BaseMailForm(MailForm): 151 | def get_field_for_param(self, param): 152 | if param == 'user': 153 | return forms.ModelChoiceField( 154 | queryset=User.objects.order_by('last_name', 'first_name')) 155 | 156 | return super(BaseMailForm, self).get_field_for_param(param) 157 | 158 | Now you need to inherit from this ``BaseMailForm`` to make use of it for your 159 | custom mail forms: 160 | 161 | .. code-block:: python 162 | 163 | class MyCustomMailForm(BaseMailForm): 164 | # your own customizations here 165 | 166 | If you want this ``BaseMailForm`` to be used automatically when registering a 167 | mail with no custom form, here's how to do it: 168 | 169 | .. code-block:: python 170 | 171 | from mail_factory import MailFactory 172 | 173 | 174 | class BaseMailFactory(MailFactory): 175 | mail_form = BaseMailForm 176 | factory = BaseMailFactory 177 | 178 | And use this new factory everywhere in your code instead of 179 | ``mail_factory.factory``. 180 | 181 | 182 | Previewing your email 183 | ===================== 184 | 185 | Sometimes however, you don't need or want to **render** the email, having to 186 | provide some real data (eg a user, a site, some complex model...). 187 | 188 | The emails may be written by your sales or marketing team, set up by your 189 | designer, and all of those don't want to cope with the setting up of real data. 190 | 191 | All they want is to be able to preview the email, in the different languages 192 | available. 193 | 194 | This is where email **previewing** is useful. 195 | 196 | Previewing is available straight away thanks to sane defaults. It uses the 197 | data returned by ``get_preview_data`` to add (possibly) non valid data to the 198 | context used to preview the mail. 199 | 200 | This data will override any data that was returned by ``get_context_data``, 201 | which in turn uses the form's Meta.initial, and in last resort, returns "###". 202 | 203 | The preview can thus use fake data: let's take the second example from this 204 | page, the ``SharePageMail``: 205 | 206 | .. code-block:: python 207 | 208 | import datetime 209 | 210 | from django.contrib.auth.models import User 211 | from django.conf import settings 212 | 213 | from mail_factory import factory, MailForm 214 | 215 | 216 | class SharePageMailForm(MailForm): 217 | user = forms.ModelChoiceField(queryset=User.objects.all()) 218 | message = forms.CharField(widget=forms.Textarea) 219 | date = forms.DateTimeField() 220 | 221 | class Meta: 222 | initial = {'message': 'Some message'} 223 | 224 | def get_preview_data(self, **kwargs): 225 | data = super(SharePageMailForm, self).get_preview_data(**kwargs) 226 | data['date'] = datetime.date.today() 227 | # create on-the-fly fake User, not saved in database: not valid data 228 | # but still added to context for previewing 229 | data['user'] = User(first_name='John', last_name='Doe') 230 | return data 231 | 232 | factory.register(SharePageMail, SharePageMailForm) 233 | 234 | With this feature, when displaying the mail form in the admin (to render the 235 | email with real data), the email will also be previewed in the different 236 | available languages with the fake data provided by the form's 237 | ``get_preview_data``, which overrides the data returned by 238 | ``get_context_data``. 239 | -------------------------------------------------------------------------------- /mail_factory/tests/test_mails.py: -------------------------------------------------------------------------------- 1 | """Keep in mind throughout those tests that the mails from demo.demo_app.mails 2 | are automatically registered, and serve as fixture.""" 3 | 4 | 5 | from django.conf import settings 6 | from django.contrib.staticfiles import finders 7 | from django.core import mail 8 | from django.template import TemplateDoesNotExist 9 | from django.test import TestCase 10 | from django.utils import translation 11 | 12 | from ..exceptions import MissingMailContextParamException 13 | from ..mails import BaseMail 14 | 15 | 16 | class MailTest(TestCase): 17 | def test_init(self): 18 | class TestMail(BaseMail): 19 | params = ["foo"] 20 | 21 | with self.assertRaises(MissingMailContextParamException): 22 | TestMail() 23 | with self.assertRaises(MissingMailContextParamException): 24 | TestMail({}) 25 | with self.assertRaises(MissingMailContextParamException): 26 | TestMail({"bar": "bar"}) 27 | self.assertTrue(TestMail({"foo": "bar"})) 28 | 29 | def test_get_language(self): 30 | class TestMail(BaseMail): 31 | params = [] 32 | 33 | with translation.override("en"): 34 | self.assertEqual(TestMail().get_language(), "en") 35 | with translation.override("fr"): 36 | self.assertEqual(TestMail().get_language(), "fr") 37 | 38 | def test_get_params(self): 39 | class TestMail(BaseMail): 40 | params = [] 41 | 42 | self.assertEqual(TestMail().get_params(), []) 43 | 44 | class TestMail2(BaseMail): 45 | params = ["foo"] 46 | 47 | self.assertEqual(TestMail2({"foo": "bar"}).get_params(), ["foo"]) 48 | 49 | def test_get_context_data(self): 50 | class TestMail(BaseMail): 51 | params = [] 52 | 53 | self.assertEqual(TestMail().get_context_data(), {}) 54 | self.assertEqual(TestMail().get_context_data(foo="bar"), {"foo": "bar"}) 55 | 56 | def test_get_attachments(self): 57 | class TestMail(BaseMail): 58 | params = [] 59 | 60 | self.assertEqual(TestMail().get_attachments(None), []) 61 | self.assertEqual(TestMail().get_attachments("foo"), "foo") 62 | 63 | def test_get_template_part(self): 64 | class TestMail(BaseMail): 65 | params = [] 66 | template_name = "test" 67 | 68 | test_mail = TestMail() 69 | test_mail.lang = "foo" 70 | self.assertEqual( 71 | test_mail.get_template_part("stuff"), 72 | ["mails/test/foo/stuff", "mails/test/stuff"], 73 | ) 74 | self.assertEqual( 75 | test_mail.get_template_part("stuff", "bar"), 76 | ["mails/test/bar/stuff", "mails/test/stuff"], 77 | ) 78 | 79 | def test_render_part(self): 80 | class TestMail(BaseMail): 81 | params = [] 82 | template_name = "test" 83 | 84 | test_mail = TestMail() 85 | with self.assertRaises(TemplateDoesNotExist): 86 | test_mail._render_part("stuff") 87 | 88 | # active language before stays the same 89 | cur_lang = translation.get_language() 90 | test_mail._render_part("subject.txt") 91 | self.assertEqual(cur_lang, translation.get_language()) 92 | 93 | # without a proper language, fallback 94 | test_mail.lang = "not a lang" 95 | self.assertEqual( 96 | test_mail._render_part("subject.txt"), "[TestCase] Mail test subject" 97 | ) 98 | # with a proper language, use it 99 | test_mail.lang = "fr" 100 | self.assertEqual(test_mail._render_part("body.txt"), "Français") 101 | # use provided language 102 | test_mail.lang = "not a lang" 103 | self.assertEqual( 104 | test_mail._render_part("subject.txt", "en"), "[TestCase] Mail test subject" 105 | ) 106 | self.assertEqual(test_mail._render_part("body.txt", "fr"), "Français") 107 | # if provided language doesn't exist, fallback 108 | self.assertEqual( 109 | test_mail._render_part("subject.txt", "not a lang"), 110 | "[TestCase] Mail test subject", 111 | ) 112 | 113 | def test_create_email_msg(self): 114 | class TestMail(BaseMail): 115 | params = [] 116 | template_name = "test" 117 | 118 | test_mail = TestMail() 119 | # no "from email" given => use settings.DEFAULT_FROM_EMAIL 120 | self.assertEqual( 121 | test_mail.create_email_msg([]).from_email, settings.DEFAULT_FROM_EMAIL 122 | ) 123 | msg = test_mail.create_email_msg([], from_email="foo") 124 | # Default header sets reply-to 125 | msg = test_mail.create_email_msg([]) 126 | self.assertIn("Reply-To", msg.extra_headers) 127 | self.assertEqual(msg.extra_headers["Reply-To"], settings.DEFAULT_FROM_EMAIL) 128 | # If headers are forced, check the ones passed are used 129 | msg = test_mail.create_email_msg([], headers={"MyHeader": "Override"}) 130 | self.assertEqual(msg.extra_headers, {"MyHeader": "Override"}) 131 | # templates with html 132 | msg = test_mail.create_email_msg([], lang="fr") 133 | self.assertEqual(len(msg.alternatives), 1) 134 | msg = test_mail.create_email_msg([], lang="en") 135 | self.assertEqual(len(msg.alternatives), 1) 136 | # templates without html 137 | test_mail.template_name = "test_no_html" 138 | msg = test_mail.create_email_msg([], lang="fr") 139 | self.assertEqual(len(msg.alternatives), 0) 140 | msg = test_mail.create_email_msg([], lang="en") 141 | self.assertEqual(len(msg.alternatives), 0) 142 | # template without txt 143 | test_mail.template_name = "test_no_txt" 144 | self.assertEqual( 145 | test_mail.create_email_msg( 146 | emails=[ 147 | "receiver@mail.com", 148 | ], 149 | from_email="receiver@mail.com", 150 | lang="fr", 151 | ).body, 152 | "# Français\n\n", 153 | ) 154 | # template without html and without txt 155 | test_mail.template_name = "test_no_html_no_txt" 156 | 157 | with self.assertRaises(TemplateDoesNotExist): 158 | test_mail.create_email_msg( 159 | emails=[ 160 | "receiver@mail.com", 161 | ], 162 | from_email="receiver@mail.com", 163 | lang="fr", 164 | ) 165 | 166 | def test_create_email_msg_attachments(self): 167 | class TestMail(BaseMail): 168 | params = [] 169 | template_name = "test" 170 | 171 | test_mail = TestMail() 172 | attachments = [ 173 | (finders.find("admin/img/nav-bg.gif"), "nav-bg.gif", "image/png"), 174 | (finders.find("admin/css/base.css"), "base.css", "plain/text"), 175 | ] 176 | msg = test_mail.create_email_msg([], attachments=attachments) 177 | self.assertEqual(len(msg.attachments), 1) # base.css 178 | self.assertEqual(len(msg.related_attachments), 1) # nav-bg.gif 179 | 180 | def test_send(self): 181 | class TestMail(BaseMail): 182 | params = [] 183 | template_name = "test" 184 | 185 | before = len(mail.outbox) 186 | TestMail().send(["foo@bar.com"]) 187 | self.assertEqual(len(mail.outbox), before + 1) 188 | self.assertEqual(mail.outbox[-1].to, ["foo@bar.com"]) 189 | 190 | def test_mail_admins(self): 191 | class TestMail(BaseMail): 192 | params = [] 193 | template_name = "test" 194 | 195 | before = len(mail.outbox) 196 | TestMail().mail_admins() 197 | self.assertEqual(len(mail.outbox), before + 1) 198 | self.assertEqual(mail.outbox[-1].to, ["some_admin@example.com"]) 199 | 200 | def test_no_reply(self): 201 | class TestMail(BaseMail): 202 | params = [] 203 | template_name = "test" 204 | 205 | test_mail = TestMail() 206 | msg = test_mail.create_email_msg([]) 207 | 208 | self.assertIn("Reply-To", msg.extra_headers) 209 | self.assertEqual(msg.extra_headers["Reply-To"], settings.DEFAULT_FROM_EMAIL) 210 | 211 | settings.NO_REPLY_EMAIL = "no-reply@example.com" 212 | msg = test_mail.create_email_msg([]) 213 | 214 | self.assertIn("Reply-To", msg.extra_headers) 215 | self.assertEqual(msg.extra_headers["Reply-To"], settings.NO_REPLY_EMAIL) 216 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # 2 | # django-mail-factory documentation build configuration file, created by 3 | # sphinx-quickstart on Wed Jan 23 17:31:52 2013. 4 | # 5 | # This file is execfile()d with the current directory set to its containing dir. 6 | # 7 | # Note that not all possible configuration values are present in this 8 | # autogenerated file. 9 | # 10 | # All configuration values have a default; values that are commented out 11 | # serve to show the default. 12 | 13 | # If extensions (or modules to document with autodoc) are in another directory, 14 | # add these directories to sys.path here. If the directory is relative to the 15 | # documentation root, use os.path.abspath to make it absolute, like shown here. 16 | # sys.path.insert(0, os.path.abspath('.')) 17 | 18 | # -- General configuration ----------------------------------------------------- 19 | 20 | # If your documentation needs a minimal Sphinx version, state it here. 21 | # needs_sphinx = '1.0' 22 | 23 | # Add any Sphinx extension module names here, as strings. They can be extensions 24 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 25 | extensions = ["sphinx.ext.autodoc", "sphinx.ext.doctest"] 26 | 27 | # Add any paths that contain templates here, relative to this directory. 28 | templates_path = ["_templates"] 29 | 30 | # The suffix of source filenames. 31 | source_suffix = ".rst" 32 | 33 | # The encoding of source files. 34 | # source_encoding = 'utf-8-sig' 35 | 36 | # The master toctree document. 37 | master_doc = "index" 38 | 39 | # General information about the project. 40 | project = "django-mail-factory" 41 | copyright = "2013, Rémy HUBSCHER" 42 | 43 | # The version info for the project you're documenting, acts as replacement for 44 | # |version| and |release|, also used in various other places throughout the 45 | # built documents. 46 | # 47 | 48 | # read the VERSION file which is three level up 49 | from os.path import abspath, dirname, join 50 | 51 | with open(join(dirname(dirname(dirname(abspath(__file__)))), "VERSION")) as f: 52 | release = version = f.read() 53 | 54 | # The language for content autogenerated by Sphinx. Refer to documentation 55 | # for a list of supported languages. 56 | # language = None 57 | 58 | # There are two options for replacing |today|: either, you set today to some 59 | # non-false value, then it is used: 60 | # today = '' 61 | # Else, today_fmt is used as the format for a strftime call. 62 | # today_fmt = '%B %d, %Y' 63 | 64 | # List of patterns, relative to source directory, that match files and 65 | # directories to ignore when looking for source files. 66 | exclude_patterns = [] 67 | 68 | # The reST default role (used for this markup: `text`) to use for all documents. 69 | # default_role = None 70 | 71 | # If true, '()' will be appended to :func: etc. cross-reference text. 72 | # add_function_parentheses = True 73 | 74 | # If true, the current module name will be prepended to all description 75 | # unit titles (such as .. function::). 76 | # add_module_names = True 77 | 78 | # If true, sectionauthor and moduleauthor directives will be shown in the 79 | # output. They are ignored by default. 80 | # show_authors = False 81 | 82 | # The name of the Pygments (syntax highlighting) style to use. 83 | pygments_style = "sphinx" 84 | 85 | # A list of ignored prefixes for module index sorting. 86 | # modindex_common_prefix = [] 87 | 88 | 89 | # -- Options for HTML output --------------------------------------------------- 90 | 91 | # The theme to use for HTML and HTML Help pages. See the documentation for 92 | # a list of builtin themes. 93 | html_theme = "default" 94 | 95 | # Theme options are theme-specific and customize the look and feel of a theme 96 | # further. For a list of options available for each theme, see the 97 | # documentation. 98 | # html_theme_options = {} 99 | 100 | # Add any paths that contain custom themes here, relative to this directory. 101 | # html_theme_path = [] 102 | 103 | # The name for this set of Sphinx documents. If None, it defaults to 104 | # " v documentation". 105 | # html_title = None 106 | 107 | # A shorter title for the navigation bar. Default is the same as html_title. 108 | # html_short_title = None 109 | 110 | # The name of an image file (relative to this directory) to place at the top 111 | # of the sidebar. 112 | # html_logo = None 113 | 114 | # The name of an image file (within the static path) to use as favicon of the 115 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 116 | # pixels large. 117 | # html_favicon = None 118 | 119 | # Add any paths that contain custom static files (such as style sheets) here, 120 | # relative to this directory. They are copied after the builtin static files, 121 | # so a file named "default.css" will overwrite the builtin "default.css". 122 | html_static_path = [] 123 | 124 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 125 | # using the given strftime format. 126 | # html_last_updated_fmt = '%b %d, %Y' 127 | 128 | # If true, SmartyPants will be used to convert quotes and dashes to 129 | # typographically correct entities. 130 | # html_use_smartypants = True 131 | 132 | # Custom sidebar templates, maps document names to template names. 133 | # html_sidebars = {} 134 | 135 | # Additional templates that should be rendered to pages, maps page names to 136 | # template names. 137 | # html_additional_pages = {} 138 | 139 | # If false, no module index is generated. 140 | # html_domain_indices = True 141 | 142 | # If false, no index is generated. 143 | # html_use_index = True 144 | 145 | # If true, the index is split into individual pages for each letter. 146 | # html_split_index = False 147 | 148 | # If true, links to the reST sources are added to the pages. 149 | # html_show_sourcelink = True 150 | 151 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 152 | # html_show_sphinx = True 153 | 154 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 155 | # html_show_copyright = True 156 | 157 | # If true, an OpenSearch description file will be output, and all pages will 158 | # contain a tag referring to it. The value of this option must be the 159 | # base URL from which the finished HTML is served. 160 | # html_use_opensearch = '' 161 | 162 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 163 | # html_file_suffix = None 164 | 165 | # Output file base name for HTML help builder. 166 | htmlhelp_basename = "django-mail-factorydoc" 167 | 168 | 169 | # -- Options for LaTeX output -------------------------------------------------- 170 | 171 | latex_elements = { 172 | # The paper size ('letterpaper' or 'a4paper'). 173 | # 'papersize': 'letterpaper', 174 | # The font size ('10pt', '11pt' or '12pt'). 175 | # 'pointsize': '10pt', 176 | # Additional stuff for the LaTeX preamble. 177 | # 'preamble': '', 178 | } 179 | 180 | # Grouping the document tree into LaTeX files. List of tuples 181 | # (source start file, target name, title, author, documentclass [howto/manual]). 182 | latex_documents = [ 183 | ( 184 | "index", 185 | "django-mail-factory.tex", 186 | "django-mail-factory Documentation", 187 | "Rémy HUBSCHER", 188 | "manual", 189 | ), 190 | ] 191 | 192 | # The name of an image file (relative to this directory) to place at the top of 193 | # the title page. 194 | # latex_logo = None 195 | 196 | # For "manual" documents, if this is true, then toplevel headings are parts, 197 | # not chapters. 198 | # latex_use_parts = False 199 | 200 | # If true, show page references after internal links. 201 | # latex_show_pagerefs = False 202 | 203 | # If true, show URL addresses after external links. 204 | # latex_show_urls = False 205 | 206 | # Documents to append as an appendix to all manuals. 207 | # latex_appendices = [] 208 | 209 | # If false, no module index is generated. 210 | # latex_domain_indices = True 211 | 212 | 213 | # -- Options for manual page output -------------------------------------------- 214 | 215 | # One entry per manual page. List of tuples 216 | # (source start file, name, description, authors, manual section). 217 | man_pages = [ 218 | ( 219 | "index", 220 | "django-mail-factory", 221 | "django-mail-factory Documentation", 222 | ["Rémy HUBSCHER"], 223 | 1, 224 | ) 225 | ] 226 | 227 | # If true, show URL addresses after external links. 228 | # man_show_urls = False 229 | 230 | 231 | # -- Options for Texinfo output ------------------------------------------------ 232 | 233 | # Grouping the document tree into Texinfo files. List of tuples 234 | # (source start file, target name, title, author, 235 | # dir menu entry, description, category) 236 | texinfo_documents = [ 237 | ( 238 | "index", 239 | "django-mail-factory", 240 | "django-mail-factory Documentation", 241 | "Rémy HUBSCHER", 242 | "django-mail-factory", 243 | "One line description of project.", 244 | "Miscellaneous", 245 | ), 246 | ] 247 | 248 | # Documents to append as an appendix to all manuals. 249 | # texinfo_appendices = [] 250 | 251 | # If false, no module index is generated. 252 | # texinfo_domain_indices = True 253 | 254 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 255 | # texinfo_show_urls = 'footnote' 256 | -------------------------------------------------------------------------------- /mail_factory/tests/test_views.py: -------------------------------------------------------------------------------- 1 | """Keep in mind throughout those tests that the mails from demo.demo_app.mails 2 | are automatically registered, and serve as fixture.""" 3 | 4 | 5 | from django.contrib.auth.models import User 6 | from django.http import Http404, HttpResponse 7 | from django.test import TestCase 8 | from django.test.client import RequestFactory 9 | from django.urls import reverse 10 | 11 | from .. import factory, views 12 | from ..forms import MailForm 13 | 14 | 15 | class MailListViewTest(TestCase): 16 | def test_get_context_data(self): 17 | data = views.MailListView().get_context_data() 18 | self.assertIn("mail_map", data) 19 | self.assertEqual(len(data["mail_map"]), len(factory._registry)) 20 | 21 | 22 | class TemplateTest(TestCase): 23 | """ 24 | Simple test case to get a view and check the template doesn't 25 | raise any TemplateError. 26 | 27 | """ 28 | 29 | def setUp(self): 30 | super().setUp() 31 | 32 | credentials = { 33 | "username": "admin", 34 | "password": "admin", 35 | } 36 | User.objects.create_superuser(email="admin@example.com", **credentials) 37 | self.client.login(**credentials) 38 | 39 | def test_get_mail_factory_list_django110(self): 40 | response = self.client.get(reverse("mail_factory_list")) 41 | self.assertEqual(response.status_code, 200) 42 | 43 | 44 | class MailPreviewMixinTest(TestCase): 45 | def test_get_html_alternative(self): 46 | view = views.MailFormView() 47 | # no_custom has no html alternative 48 | form_class = factory.get_mail_form("no_custom") 49 | mail_class = factory.get_mail_class("no_custom") 50 | form = form_class(mail_class=mail_class) 51 | mail = mail_class(form.get_context_data()) 52 | message = mail.create_email_msg([]) 53 | self.assertFalse(view.get_html_alternative(message)) 54 | # custom_form has an html alternative 55 | form_class = factory.get_mail_form("custom_form") 56 | mail_class = factory.get_mail_class("custom_form") 57 | form = form_class(mail_class=mail_class) 58 | mail = mail_class(form.get_context_data()) 59 | message = mail.create_email_msg([]) 60 | self.assertTrue(view.get_html_alternative(message)) 61 | 62 | def test_get_mail_preview_language(self): 63 | view = views.MailFormView() 64 | view.mail_name = "no_custom" 65 | view.mail_class = factory._registry["no_custom"] 66 | message = view.get_mail_preview("no_custom", "en") 67 | self.assertEqual(message.subject, "Title in english: ###") 68 | self.assertEqual(message.body, "Content in english: ###") 69 | message = view.get_mail_preview("no_custom", "fr") 70 | self.assertEqual(message.subject, "Titre en français : ###") 71 | self.assertEqual(message.body, "Contenu en français : ###") 72 | 73 | def test_get_mail_preview_no_html(self): 74 | view = views.MailFormView() 75 | view.mail_name = "no_custom" # no template for html alternative 76 | view.mail_class = factory._registry["no_custom"] 77 | message = view.get_mail_preview("no_custom", "en") 78 | self.assertFalse(message.html) 79 | 80 | def test_get_mail_preview_html(self): 81 | view = views.MailFormView() 82 | view.mail_name = "custom_form" # has templates for html alternative 83 | view.mail_class = factory._registry["custom_form"] 84 | message = view.get_mail_preview("custom_form", "en") 85 | self.assertTrue(message.html) 86 | 87 | 88 | class MailFormViewTest(TestCase): 89 | def setUp(self): 90 | self.factory = RequestFactory() 91 | 92 | def test_dispatch_unknown_mail(self): 93 | request = self.factory.get( 94 | reverse("mail_factory_form", kwargs={"mail_name": "unknown"}) 95 | ) 96 | view = views.MailFormView() 97 | with self.assertRaises(Http404): 98 | view.dispatch(request, "unknown") 99 | 100 | def test_dispatch(self): 101 | request = self.factory.post( 102 | reverse("mail_factory_form", kwargs={"mail_name": "no_custom"}), 103 | {"raw": "foo", "send": "foo", "email": "email"}, 104 | ) 105 | view = views.MailFormView() 106 | view.request = request 107 | view.dispatch(request, "no_custom") 108 | self.assertEqual(view.mail_name, "no_custom") 109 | self.assertEqual(view.mail_class, factory._registry["no_custom"]) 110 | self.assertTrue(view.raw) 111 | self.assertTrue(view.send) 112 | self.assertEqual(view.email, "email") 113 | 114 | def test_get_form_kwargs(self): 115 | request = self.factory.get( 116 | reverse("mail_factory_form", kwargs={"mail_name": "unknown"}) 117 | ) 118 | view = views.MailFormView() 119 | view.mail_name = "no_custom" 120 | view.mail_class = factory._registry["no_custom"] 121 | view.request = request 122 | self.assertIn("mail_class", view.get_form_kwargs()) 123 | self.assertEqual(view.get_form_kwargs()["mail_class"].__name__, "NoCustomMail") 124 | 125 | def test_get_form_class(self): 126 | view = views.MailFormView() 127 | view.mail_name = "no_custom" 128 | self.assertEqual(view.get_form_class(), MailForm) 129 | 130 | def test_form_valid_raw(self): 131 | class MockForm: 132 | cleaned_data = {"title": "title", "content": "content"} 133 | 134 | view = views.MailFormView() 135 | view.mail_name = "no_custom" 136 | view.raw = True 137 | response = view.form_valid(MockForm()) 138 | self.assertEqual(response.status_code, 200) 139 | self.assertTrue(isinstance(response, HttpResponse)) 140 | self.assertTrue(response.content.startswith(b"
"))
141 | 
142 |     def test_form_valid_send(self):
143 |         class MockForm:
144 |             cleaned_data = {"title": "title", "content": "content"}
145 | 
146 |         request = self.factory.get(
147 |             reverse("mail_factory_form", kwargs={"mail_name": "unknown"})
148 |         )
149 |         view = views.MailFormView()
150 |         view.request = request
151 |         view.mail_name = "no_custom"
152 |         view.raw = False
153 |         view.send = True
154 |         view.email = "foo@example.com"
155 |         old_factory_mail = factory.mail  # save current mail method
156 |         # save current django.contrib.messages.success (imported in .views)
157 |         old_messages_success = views.messages.success
158 |         self.factory_send_called = False
159 | 
160 |         def mock_factory_mail(mail_name, to, context):
161 |             self.factory_send_called = True  # noqa
162 | 
163 |         factory.mail = mock_factory_mail  # mock mail method
164 |         views.messages.success = lambda x, y: True  # mock messages.success
165 |         response = view.form_valid(MockForm())
166 |         factory.mail = old_factory_mail  # restore mail method
167 |         views.messages.success = old_messages_success  # restore messages
168 |         self.assertTrue(self.factory_send_called)
169 |         self.assertEqual(response.status_code, 302)
170 |         self.assertEqual(response["location"], reverse("mail_factory_list"))
171 | 
172 |     def test_form_valid_html(self):
173 |         class MockForm:
174 |             cleaned_data = {"title": "title", "content": "content"}
175 | 
176 |             def get_context_data(self):
177 |                 return self.cleaned_data
178 | 
179 |         view = views.MailFormView()
180 |         view.mail_name = "custom_form"  # has templates for html alternative
181 |         view.raw = False
182 |         view.send = False
183 |         response = view.form_valid(MockForm())
184 |         self.assertEqual(response.status_code, 200)
185 |         self.assertTrue(isinstance(response, HttpResponse))
186 | 
187 |     def test_get_context_data(self):
188 |         request = self.factory.get(
189 |             reverse("mail_factory_form", kwargs={"mail_name": "unknown"})
190 |         )
191 |         view = views.MailFormView()
192 |         view.mail_name = "no_custom"
193 |         view.mail_class = factory._registry["no_custom"]
194 |         view.request = request
195 |         # save the current
196 |         old_get_mail_preview = views.MailPreviewMixin.get_mail_preview
197 |         # mock
198 |         views.MailPreviewMixin.get_mail_preview = lambda x, y, z: "mocked"
199 |         data = view.get_context_data()
200 |         # restore after mock
201 |         views.MailPreviewMixin.get_mail_preview = old_get_mail_preview
202 |         self.assertIn("mail_name", data)
203 |         self.assertEqual(data["mail_name"], "no_custom")
204 |         self.assertIn("preview_messages", data)
205 |         self.assertDictEqual(data["preview_messages"], {"fr": "mocked", "en": "mocked"})
206 | 
207 | 
208 | class MailPreviewMessageViewTest(TestCase):
209 |     def setUp(self):
210 |         self.factory = RequestFactory()
211 | 
212 |     def test_dispatch_unknown_mail(self):
213 |         request = self.factory.get(
214 |             reverse(
215 |                 "mail_factory_preview_message",
216 |                 kwargs={"mail_name": "unknown", "lang": "fr"},
217 |             )
218 |         )
219 |         view = views.MailPreviewMessageView()
220 |         with self.assertRaises(Http404):
221 |             view.dispatch(request, "unknown", "fr")
222 | 
223 |     def test_dispatch(self):
224 |         request = self.factory.get(
225 |             reverse(
226 |                 "mail_factory_preview_message",
227 |                 kwargs={"mail_name": "no_custom", "lang": "fr"},
228 |             )
229 |         )
230 |         view = views.MailPreviewMessageView()
231 |         view.request = request
232 |         view.dispatch(request, "no_custom", "fr")
233 |         self.assertEqual(view.mail_name, "no_custom")
234 |         self.assertEqual(view.lang, "fr")
235 |         self.assertEqual(view.mail_class, factory._registry["no_custom"])
236 | 
237 |     def test_get_context_data(self):
238 |         view = views.MailPreviewMessageView()
239 |         view.lang = "fr"
240 |         view.mail_name = "no_custom"
241 |         view.mail_class = factory._registry["no_custom"]
242 |         data = view.get_context_data()
243 |         self.assertIn("mail_name", data)
244 |         self.assertEqual(data["mail_name"], "no_custom")
245 |         self.assertIn("message", data)
246 | 


--------------------------------------------------------------------------------