├── 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 |
|
12 | Email
13 |
14 | |
15 |
16 | Template
17 |
18 | |
19 |
|---|---|
| {{ mail_name }} | 25 |{{ template_name }} | 26 |
', "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 | 
{% 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 | 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
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
"))
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 |
--------------------------------------------------------------------------------