├── .bumpversion.cfg ├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .travis.yml ├── AUTHORS ├── CHANGELOG ├── LICENSE ├── MANIFEST.in ├── README.rst ├── pytest.ini ├── requirements.txt ├── setup.py ├── templated_email ├── __init__.py ├── backends │ ├── __init__.py │ └── vanilla_django.py ├── generic_views.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── templates │ └── templated_email │ │ └── saved_email.html ├── urls.py ├── utils.py └── views.py ├── tests ├── __init__.py ├── backends │ ├── __init__.py │ ├── test_vanilla_django_backend.py │ └── utils.py ├── generic_views │ ├── __init__.py │ ├── models.py │ ├── test_views.py │ └── views.py ├── settings.py ├── template_fixtures │ └── templated_email │ │ ├── html_template.email │ │ ├── inexistent_base.email │ │ ├── inheritance_template.email │ │ ├── inline_image.email │ │ ├── legacy.html │ │ ├── legacy.txt │ │ ├── mixed_template.email │ │ ├── multi-template.email │ │ ├── plain_template.email │ │ ├── plain_template_without_subject.email │ │ └── welcome.email ├── test_get_connection.py ├── test_get_templated_mail.py ├── test_inline_image.py ├── test_send_templated_mail.py ├── test_urls.py ├── test_utils.py ├── test_views.py └── utils.py ├── tox-requirements.txt └── tox.ini /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 3.0.1 3 | commit = True 4 | tag = True 5 | tag_name = {new_version} 6 | 7 | [bumpversion:file:setup.py] 8 | search = VERSION = '{current_version}' 9 | replace = VERSION = '{new_version}' 10 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: 4 | - push 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | matrix: 12 | python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] 13 | 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v4 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | python -m pip install tox tox-gh-actions 24 | - name: Test with tox 25 | run: tox 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | .cache 3 | 4 | # C extensions 5 | *.so 6 | 7 | # Packages 8 | *.egg 9 | *.egg-info 10 | dist 11 | build 12 | eggs 13 | parts 14 | bin 15 | var 16 | sdist 17 | develop-eggs 18 | .installed.cfg 19 | lib 20 | lib64 21 | 22 | # Installer logs 23 | pip-log.txt 24 | 25 | # Unit test / coverage reports 26 | .coverage 27 | .tox 28 | nosetests.xml 29 | 30 | #Translations 31 | *.mo 32 | 33 | #Mr Developer 34 | .mr.developer.cfg 35 | 36 | #pycharm 37 | .idea 38 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - "3.10" 5 | - "3.11" 6 | - "3.12" 7 | - "3.13" 8 | 9 | install: 10 | - pip install tox-travis 11 | - pip install -r requirements.txt 12 | - pip install coveralls 13 | 14 | script: 15 | - tox 16 | 17 | after_script: 18 | coveralls 19 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Anatoly Kudinov 2 | Anderson Resende 3 | Andre Ericson 4 | Bradley Whittington 5 | Carlos Coelho 6 | Ewoud Kohl van Wijngaarden 7 | Nitin Hayaran 8 | Simon Ye 9 | Thomas Levine <_@thomaslevine.com> 10 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | UNRELEASED 2 | ---------- 3 | Drop Django 2.2 and 3.1 support 4 | Add Python 3.10 and 3.11 support 5 | Use GitHub Actions for testing 6 | 7 | v3.0.1 8 | ----- 9 | Remove all newlines from subject 10 | 11 | v3.0 12 | ----- 13 | Add escaping to HTML parts in templates 14 | Add tests for django main version 15 | 16 | v2.4 17 | ----- 18 | Add Python 3.7, 3.8 and 3.9 support 19 | Drop Python <3.4 support 20 | Add Django 3.1 and 3.2 support 21 | Drop Django 3.0 support 22 | Remove six dependency 23 | 24 | v2.3 25 | ----- 26 | Remove Django as explicit dependency 27 | Upgrade django-anymail to v3 28 | Drop Python 3.3 support 29 | 30 | v2.2 31 | ----- 32 | Bug fixes and improvements (issues https://github.com/vintasoftware/django-templated-email/issues/102 and https://github.com/vintasoftware/django-templated-email/issues/104) 33 | 34 | 35 | v2.1 36 | ----- 37 | Bug fixes (issues https://github.com/vintasoftware/django-templated-email/issues/94 and https://github.com/vintasoftware/django-templated-email/issues/99) 38 | 39 | 40 | v2.0 41 | ----- 42 | Adding Inline Image 43 | template_name can now accept a list 44 | Optionally save a copy of the email in the database and create a URL to see it in the browser 45 | Created a TemplatedEmailFormViewMixin to use with classes based views' FormView 46 | 47 | 48 | v1.0 49 | ----- 50 | Remove broken postageapp backend. 51 | Adding django 1.10 support. 52 | Use django-render-block. 53 | Adding django-anymail. 54 | Adding attachment. 55 | Remove support for legacy behaviour of having a .html and a .txt file. 56 | 57 | 58 | v0.5 59 | ----- 60 | 61 | Bug fixes, cleanup, Django 1.9 and Python3 support. 62 | 63 | 64 | v0.4.9 65 | ------ 66 | 67 | Be a bit friendlier if someone sets TEMPLATED_EMAIL_FILE_EXTENSION='.html' strip the leading . so inconsistent behavior is not experienced (using '.html' will fall back to legacy loading behaviour when it doesn't find filename..html). 68 | 69 | v0.4.8 70 | ------ 71 | Adds pep-8 fixes, and basic testing infrastructure from https://github.com/bradwhittington/django-templated-email/pull/24 and https://github.com/bradwhittington/django-templated-email/pull/23 72 | 73 | Amends AUTHORS file 74 | 75 | v0.4.7 76 | ------ 77 | Refactor to make template_prefix/suffix passed through everywhere (taking 78 | precedence), with template_dir/file_extension available on backends where it 79 | makes sense, with higher priority on template_prefix/suffix. 80 | 81 | Re-formatted changelog 82 | 83 | Allow TEMPLATED_EMAIL_BACKEND to be a class 84 | 85 | v0.4.6 86 | ------ 87 | Reworked vanilla_django backend allows you to extract the EmailMessage object 88 | without sending email. Refactored code for PEP8 compliance 89 | 90 | v0.4.5 91 | ------ 92 | Better error handling for the PostageApp backend 93 | 94 | v0.4.4 95 | ------ 96 | Allow overriding of file_extension on a call to send_mail for issue #12 97 | https://github.com/bradwhittington/django-templated-email/issues/12 98 | 99 | v0.4.3 100 | ------ 101 | Allow overriding connection, auth_user and auth_password ala 102 | django.core.mail.send_mail for issue #10 103 | https://github.com/bradwhittington/django-templated-email/issues/10 104 | 105 | v0.4.2 106 | ------ 107 | Allow overriding of template_dir on a call to send_mail for issue #12 108 | https://github.com/bradwhittington/django-templated-email/issues/12 109 | 110 | v0.4.1 111 | ------ 112 | Basic fix for issue #3 113 | https://github.com/bradwhittington/django-templated-email/issues/3 to support 114 | better template inheritence 115 | 116 | v0.4 117 | ---- 118 | Adds support for cc'ing and bcc'in recipients, switches mailchimp backend 119 | to use greatape, via @nitinhayaran 120 | 121 | v0.3.3 122 | ------ 123 | Fixes template prefix dir not being used correctly 124 | 125 | v0.3.2 126 | ------ 127 | Bubble up errors on templates when there is an issue with finding blocks 128 | 129 | v0.3.1 130 | ------ 131 | Adds configuration variables for settings.py to change template dir, and 132 | template extension 133 | 134 | v0.3 135 | ---- 136 | Plain part, subject, and HTML part now supported as blocks in a single 137 | .email template 138 | 139 | v0.2.1 140 | ------ 141 | Adds support for adding custom headers where supported 142 | 143 | v0.2 144 | ---- 145 | Adds support for postageapp 146 | 147 | v0.1.3 148 | ------ 149 | Disables autoescaping when rendering templates. 150 | 151 | v0.1.2 152 | ------ 153 | Adds initial support for Mailchimp STS backend 154 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011 Bradley Whittington 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS 2 | include LICENSE 3 | include README.rst 4 | recursive-include templated_email/templates * 5 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ============================== 2 | Django-Templated-Email 3 | ============================== 4 | 5 | |GitterBadge|_ |PypiversionBadge|_ |PythonVersionsBadge|_ |LicenseBadge|_ 6 | 7 | :Info: A Django oriented templated email sending class 8 | :Original Author: Bradley Whittington (http://github.com/bradwhittington, http://twitter.com/darb) 9 | :Maintained by: Vinta Software: https://www.vinta.com.br/ 10 | :Tests: |GABadge|_ |CoverageBadge|_ 11 | 12 | 13 | Overview 14 | ================= 15 | django-templated-email is oriented towards sending templated emails. 16 | The library supports template inheritance, adding cc'd and bcc'd recipients, 17 | configurable template naming and location. 18 | 19 | The send_templated_email method can be thought of as the render_to_response 20 | shortcut for email. 21 | 22 | Make sure you are reading the correct documentation: 23 | 24 | develop branch: https://github.com/vintasoftware/django-templated-email/blob/develop/README.rst 25 | 26 | stable pypi/master: https://github.com/vintasoftware/django-templated-email/blob/master/README.rst 27 | 28 | 29 | Requirements 30 | ================= 31 | * Python (3.9*, 3.10, 3.11, 3.12, 3.13) 32 | * Django (4.2, 5.0, 5.1) 33 | 34 | We **highly recommend** and only officially support the latest patch release of 35 | each Python and Django series. 36 | 37 | Python 3.9 is no longer supported in Django 5.0 and newer, so is only supported with Django 4.2. 38 | 39 | 40 | Getting going - installation 41 | ============================== 42 | 43 | Installing:: 44 | 45 | pip install django-templated-email 46 | 47 | You can add the following to your settings.py (but it works out the box): 48 | 49 | .. code-block:: python 50 | 51 | TEMPLATED_EMAIL_BACKEND = 'templated_email.backends.vanilla_django.TemplateBackend' 52 | 53 | # You can use a shortcut version 54 | TEMPLATED_EMAIL_BACKEND = 'templated_email.backends.vanilla_django' 55 | 56 | # You can also use a class directly 57 | from templated_email.backends.vanilla_django import TemplateBackend 58 | TEMPLATED_EMAIL_BACKEND = TemplateBackend 59 | 60 | 61 | Sending templated emails 62 | ============================== 63 | 64 | Example usage using vanilla_django TemplateBackend backend 65 | 66 | Python to send mail: 67 | 68 | .. code-block:: python 69 | 70 | from templated_email import send_templated_mail 71 | send_templated_mail( 72 | template_name='welcome', 73 | from_email='from@example.com', 74 | recipient_list=['to@example.com'], 75 | context={ 76 | 'username':request.user.username, 77 | 'full_name':request.user.get_full_name(), 78 | 'signup_date':request.user.date_joined 79 | }, 80 | # Optional: 81 | # cc=['cc@example.com'], 82 | # bcc=['bcc@example.com'], 83 | # headers={'My-Custom-Header':'Custom Value'}, 84 | # template_prefix="my_emails/", 85 | # template_suffix="email", 86 | ) 87 | 88 | If you would like finer control on sending the email, you can use **get_templated_email**, which will return a django **EmailMessage** object, prepared using the **vanilla_django** backend: 89 | 90 | .. code-block:: python 91 | 92 | from templated_email import get_templated_mail 93 | get_templated_mail( 94 | template_name='welcome', 95 | from_email='from@example.com', 96 | to=['to@example.com'], 97 | context={ 98 | 'username':request.user.username, 99 | 'full_name':request.user.get_full_name(), 100 | 'signup_date':request.user.date_joined 101 | }, 102 | # Optional: 103 | # cc=['cc@example.com'], 104 | # bcc=['bcc@example.com'], 105 | # headers={'My-Custom-Header':'Custom Value'}, 106 | # template_prefix="my_emails/", 107 | # template_suffix="email", 108 | ) 109 | 110 | You can also **cc** and **bcc** recipients using **cc=['example@example.com']**. 111 | 112 | Your template 113 | ------------- 114 | 115 | The templated_email/ directory needs to be the templates directory. 116 | 117 | The backend will look in *my_app/templates/templated_email/welcome.email* : 118 | 119 | .. code-block:: python 120 | 121 | {% block subject %}My subject for {{username}}{% endblock %} 122 | {% block plain %} 123 | Hi {{full_name}}, 124 | 125 | You just signed up for my website, using: 126 | username: {{username}} 127 | join date: {{signup_date}} 128 | 129 | Thanks, you rock! 130 | {% endblock %} 131 | 132 | If you want to include an HTML part to your emails, simply use the 'html' block : 133 | 134 | .. code-block:: python 135 | 136 | {% block html %} 137 |

Hi {{full_name}},

138 | 139 |

You just signed up for my website, using: 140 |

141 |
username
{{username}}
142 |
join date
{{signup_date}}
143 |
144 |

145 | 146 |

Thanks, you rock!

147 | {% endblock %} 148 | 149 | The plain part can also be calculated from the HTML using `html2text `_. If you don't specify the plain block and `html2text `_ package is installed, the plain part will be calculated from the HTML part. You can disable this behaviour in settings.py : 150 | 151 | .. code-block:: python 152 | 153 | TEMPLATED_EMAIL_AUTO_PLAIN = False 154 | 155 | You can also specify a custom function that converts from HTML to the plain part : 156 | 157 | .. code-block:: python 158 | 159 | def convert_html_to_text(html): 160 | ... 161 | 162 | TEMPLATED_EMAIL_PLAIN_FUNCTION = convert_html_to_text 163 | 164 | You can globally override the template dir, and file extension using the following variables in settings.py : 165 | 166 | .. code-block:: python 167 | 168 | TEMPLATED_EMAIL_TEMPLATE_DIR = 'templated_email/' #use '' for top level template dir, ensure there is a trailing slash 169 | TEMPLATED_EMAIL_FILE_EXTENSION = 'email' 170 | 171 | You can also set a value for **template_prefix** and **template_suffix** for every time you call **send_templated_mail**, if you wish to store a set of templates in a different directory. Remember to include a trailing slash. 172 | 173 | Using with `Django Anymail `_ 174 | ========================================================================= 175 | 176 | Anymail integrates several transactional email service providers (ESPs) into Django, with a consistent API that lets you use ESP-added features without locking your code to a particular ESP. It supports Mailgun, Postmark, SendGrid, SparkPost and more. 177 | 178 | You can use it with django-templated-email, just follow their instructions in their `quick start `_ to configure it. 179 | 180 | Optionally you can use their custom `EmailMessage `_ class with django-templated-email by using the following settings: 181 | 182 | .. code-block:: python 183 | 184 | # This replaces django.core.mail.EmailMessage 185 | TEMPLATED_EMAIL_EMAIL_MESSAGE_CLASS='anymail.message.AnymailMessage' 186 | 187 | # This replaces django.core.mail.EmailMultiAlternatives 188 | TEMPLATED_EMAIL_EMAIL_MULTIALTERNATIVES_CLASS='anymail.message.AnymailMessage' 189 | 190 | 191 | Inline images 192 | ============== 193 | 194 | You can add inline images to your email using the *InlineImage* class. 195 | 196 | First get the image content from a file or a *ImageField*: 197 | 198 | .. code-block:: python 199 | 200 | # From a file 201 | with open('pikachu.png', 'rb') as pikachu: 202 | image = pikachu.read() 203 | 204 | # From an ImageField 205 | # Suppose we have this model 206 | class Company(models.Model): 207 | logo = models.ImageField() 208 | 209 | image = company.logo.read() 210 | 211 | Then create an instance of *InlineImage*: 212 | 213 | .. code-block:: python 214 | 215 | from templated_email import InlineImage 216 | 217 | inline_image = InlineImage(filename="pikachu.png", content=image) 218 | 219 | Now pass the object on the context to the template when you send the email. 220 | 221 | .. code-block:: python 222 | 223 | send_templated_mail(template_name='welcome', 224 | from_email='from@example.com', 225 | recipient_list=['to@example.com'], 226 | context={'pikachu_image': inline_image}) 227 | 228 | Finally in your template add the image on the html template block: 229 | 230 | .. code-block:: html 231 | 232 | 233 | 234 | Note: All *InlineImage* objects you add to the context will be attached to the e-mail, even if they are not used in the template. 235 | 236 | 237 | Add link to view the email on the web 238 | ===================================== 239 | 240 | .. code-block:: python 241 | 242 | # Add templated email to INSTALLED_APPS 243 | INSTALLED_APPS = [ 244 | ... 245 | 'templated_email' 246 | ] 247 | 248 | .. code-block:: python 249 | 250 | # and this to your url patterns 251 | url(r'^', include('templated_email.urls', namespace='templated_email')), 252 | 253 | .. code-block:: python 254 | 255 | # when sending the email use the *create_link* parameter. 256 | send_templated_mail( 257 | template_name='welcome', from_email='from@example.com', 258 | recipient_list=['to@example.com'], 259 | context={}, create_link=True) 260 | 261 | And, finally add the link to your template. 262 | 263 | .. code-block:: html 264 | 265 | 266 | {% if email_uuid %} 267 | 269 | You can view this e-mail on the web here: 270 | 271 | here 272 | 273 | {% endif %} 274 | 275 | Notes: 276 | - A copy of the rendered e-mail will be stored on the database. This can grow 277 | if you send too many e-mails. You are responsible for managing it. 278 | - If you use *InlineImage* all images will be uploaded to your media storage, 279 | keep that in mind too. 280 | 281 | 282 | Class Based Views 283 | ================== 284 | 285 | It's pretty common for emails to be sent after a form is submitted. We include a mixin 286 | to be used with any view that inherit from Django's FormMixin. 287 | 288 | In your view add the mixin and the usual Django's attributes: 289 | 290 | .. code-block:: python 291 | 292 | from templated_email.generic_views import TemplatedEmailFormViewMixin 293 | 294 | class AuthorCreateView(TemplatedEmailFormViewMixin, CreateView): 295 | model = Author 296 | fields = ['name', 'email'] 297 | success_url = '/create_author/' 298 | template_name = 'authors/create_author.html' 299 | 300 | By default the template will have the *form_data* if the form is valid or *from_errors* if the 301 | form is not valid in it's context. 302 | 303 | You can view an example `here `_ 304 | 305 | Now you can use the following attributes/methods to customize it's behavior: 306 | 307 | Attributes: 308 | 309 | **templated_email_template_name** (mandatory if you don't implement **templated_email_get_template_names()**): 310 | String naming the template you want to use for the email. 311 | ie: templated_email_template_name = 'welcome'. 312 | 313 | **templated_email_send_on_success** (default: True): 314 | This attribute tells django-templated-email to send an email if the form is valid. 315 | 316 | **templated_email_send_on_failure** (default: False): 317 | This attribute tells django-templated-email to send an email if the form is invalid. 318 | 319 | **templated_email_from_email** (default: **settings.TEMPLATED_EMAIL_FROM_EMAIL**): 320 | String containing the email to send the email from. 321 | 322 | Methods: 323 | 324 | **templated_email_get_template_names(self, valid)** (mandatory if you don't set **templated_email_template_name**): 325 | If the method returns a string it will use it as the template to render the email. If it returns a list it will send 326 | the email *only* with the first existing template. 327 | 328 | **templated_email_get_recipients(self, form)** (mandatory): 329 | Return the recipient list to whom the email will be sent to. 330 | ie: 331 | 332 | .. code-block:: python 333 | 334 | def templated_email_get_recipients(self, form): 335 | return [form.data['email']] 336 | 337 | **templated_email_get_context_data(**kwargs)** (optional): 338 | Use this method to add extra data to the context used for rendering the template. You should get the parent class's context from 339 | calling super. 340 | ie: 341 | 342 | .. code-block:: python 343 | 344 | def templated_email_get_context_data(self, **kwargs): 345 | context = super(ThisClassView, self).templated_email_get_context_data(**kwargs) 346 | # add things to context 347 | return context 348 | 349 | **templated_email_get_send_email_kwargs(self, valid, form)** (optional): 350 | Add or change the kwargs that will be used to send the e-mail. You should call super to get the default kwargs. 351 | ie: 352 | 353 | .. code-block:: python 354 | 355 | def templated_email_get_send_email_kwargs(valid, form): 356 | kwargs = super(ThisClassView, self).templated_email_get_send_email_kwargs(valid, form) 357 | kwargs['bcc'] = ['admin@example.com'] 358 | return kwargs 359 | 360 | **templated_email_send_templated_mail(*args, **kwargs)** (optional): 361 | This method calls django-templated-email's *send_templated_mail* method. You could change this method to use 362 | a celery's task for example or to handle errors. 363 | 364 | 365 | Settings 366 | ============= 367 | 368 | You can configure Django-Templated-Email by setting the following settings 369 | 370 | .. code-block:: python 371 | 372 | TEMPLATED_EMAIL_FROM_EMAIL = None # String containing the email to send the email from - fallback to DEFAULT_FROM_EMAIL 373 | TEMPLATED_EMAIL_BACKEND = TemplateBackend # The backend class that will send the email, as a string like 'foo.bar.TemplateBackend' or the class reference itself 374 | TEMPLATED_EMAIL_TEMPLATE_DIR = 'templated_email/' # The directory containing the templates, use '' if using the top level 375 | TEMPLATED_EMAIL_FILE_EXTENSION = 'email' # The file extension of the template files 376 | TEMPLATED_EMAIL_AUTO_PLAIN = True # Set to false to disable the behavior of calculating the plain part from the html part of the email when `html2text ` is installed 377 | TEMPLATED_EMAIL_PLAIN_FUNCTION = None # Specify a custom function that converts from HTML to the plain part 378 | 379 | # Specific for anymail integration: 380 | TEMPLATED_EMAIL_EMAIL_MESSAGE_CLASS = 'django.core.mail.EmailMessage' # Replaces django.core.mail.EmailMessage 381 | TEMPLATED_EMAIL_EMAIL_MULTIALTERNATIVES_CLASS = 'django.core.mail.EmailMultiAlternatives' # Replaces django.core.mail.EmailMultiAlternatives 382 | 383 | Future Plans 384 | ============= 385 | 386 | See https://github.com/vintasoftware/django-templated-email/issues?state=open 387 | 388 | Using django_templated_email in 3rd party applications 389 | ======================================================= 390 | 391 | If you would like to use django_templated_email to handle mail in a reusable application, you should note that: 392 | 393 | * Your calls to **send_templated_mail** should set a value for **template_dir**, so you can keep copies of your app-specific templates local to your app (although the loader will find your email templates if you store them in */templates/templated_email*, if **TEMPLATED_EMAIL_TEMPLATE_DIR** has not been overridden) 394 | * If you do (and you should) set a value for **template_dir**, remember to include a trailing slash, i.e. *'my_app_email/'* 395 | * The deployed app may use a different backend which doesn't use the django templating backend, and as such make a note in your README warning developers that if they are using django_templated_email already, with a different backend, they will need to ensure their email provider can send all your templates (ideally enumerate those somewhere convenient) 396 | 397 | Notes on specific backends 398 | ============================== 399 | 400 | Using vanilla_django 401 | -------------------------- 402 | 403 | This is the default backend, and as such requires no special configuration, and will work out of the box. By default it assumes the following settings (should you wish to override them): 404 | 405 | .. code-block:: python 406 | 407 | TEMPLATED_EMAIL_TEMPLATE_DIR = 'templated_email/' #Use '' for top level template dir 408 | TEMPLATED_EMAIL_FILE_EXTENSION = 'email' 409 | 410 | For legacy purposes you can specify email subjects in your settings file (but, the preferred method is to use a **{% block subject %}** in your template): 411 | 412 | .. code-block:: python 413 | 414 | TEMPLATED_EMAIL_DJANGO_SUBJECTS = { 415 | 'welcome':'Welcome to my website', 416 | } 417 | 418 | Additionally you can call **send_templated_mail** and optionally override the following parameters:: 419 | 420 | template_prefix='your_template_dir/' # Override where the method looks for email templates (alternatively, use template_dir) 421 | template_suffix='email' # Override the file extension of the email templates (alternatively, use file_extension) 422 | cc=['fubar@example.com'] # Set a CC on the mail 423 | bcc=['fubar@example.com'] # Set a BCC on the mail 424 | template_dir='your_template_dir/' # Override where the method looks for email templates 425 | connection=your_connection # Takes a django mail backend connection, created using **django.core.mail.get_connection** 426 | auth_user='username' # Override the user that the django mail backend uses, per **django.core.mail.send_mail** 427 | auth_password='password' # Override the password that the django mail backend uses, per **django.core.mail.send_mail** 428 | 429 | Using templated_email_md 430 | ------------------------ 431 | 432 | This is a third-party backend that uses Markdown to render the email templates. 433 | 434 | For installation and usage, see the `django-templated-email-md `_ repository, and the associated `documentation `_. 435 | 436 | Releasing a new version of this package: 437 | ======================================== 438 | 439 | Update CHANGELOG file. 440 | 441 | Execute the following commands:: 442 | 443 | bumpversion [major,minor,patch] 444 | python setup.py publish 445 | git push origin --tags 446 | 447 | 448 | Commercial Support 449 | ================== 450 | 451 | .. image:: https://avatars2.githubusercontent.com/u/5529080?s=80&v=4 452 | :alt: Vinta Logo 453 | :target: https://www.vinta.com.br 454 | 455 | This project, as other `Vinta Software `_ open-source projects is used in products of Vinta's clients. We are always looking for exciting work, so if you need any commercial support, feel free to get in touch: contact@vinta.com.br 456 | 457 | 458 | 459 | .. _Django: http://djangoproject.com 460 | .. |GitterBadge| image:: https://badges.gitter.im/vintasoftware/django-templated-email.svg 461 | .. _GitterBadge: https://gitter.im/vintasoftware/django-templated-email?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge 462 | .. |GABadge| image:: https://github.com/vintasoftware/django-templated-email/actions/workflows/tests.yml/badge.svg 463 | .. _GABadge: https://github.com/vintasoftware/django-templated-email/actions 464 | .. |CoverageBadge| image:: https://coveralls.io/repos/github/vintasoftware/django-templated-email/badge.svg?branch=develop 465 | .. _CoverageBadge: https://coveralls.io/github/vintasoftware/django-templated-email?branch=develop 466 | .. |PypiversionBadge| image:: https://img.shields.io/pypi/v/django-templated-email.svg 467 | .. _PypiversionBadge: https://pypi.python.org/pypi/django-templated-email 468 | .. |PythonVersionsBadge| image:: https://img.shields.io/pypi/pyversions/django-templated-email.svg 469 | .. _PythonVersionsBadge: https://pypi.python.org/pypi/django-templated-email 470 | .. |LicenseBadge| image:: https://img.shields.io/pypi/l/django-templated-email.svg 471 | .. _LicenseBadge: https://github.com/vintasoftware/django-templated-email/blob/develop/LICENSE 472 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE=tests.settings 3 | pythonpaths = . 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -r tox-requirements.txt 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from setuptools import setup, find_packages 4 | 5 | DESCRIPTION = "A Django oriented templated / transaction email abstraction" 6 | VERSION = '3.0.1' 7 | LONG_DESCRIPTION = None 8 | try: 9 | LONG_DESCRIPTION = open('README.rst').read() 10 | except: 11 | pass 12 | 13 | requirements = [ 14 | 'django-render-block>=0.5' 15 | ] 16 | 17 | # python setup.py publish 18 | if sys.argv[-1] == 'publish': 19 | os.system("python setup.py sdist upload") 20 | sys.exit() 21 | 22 | CLASSIFIERS = [ 23 | 'Development Status :: 5 - Production/Stable', 24 | 'Intended Audience :: Developers', 25 | 'License :: OSI Approved :: MIT License', 26 | 'Operating System :: OS Independent', 27 | 'Programming Language :: Python :: 3.9', 28 | 'Programming Language :: Python :: 3.10', 29 | 'Programming Language :: Python :: 3.11', 30 | 'Programming Language :: Python :: 3.12', 31 | 'Programming Language :: Python :: 3.13', 32 | 'Topic :: Software Development :: Libraries :: Python Modules', 33 | 'Framework :: Django', 34 | ] 35 | 36 | setup( 37 | name='django-templated-email', 38 | version=VERSION, 39 | packages=find_packages(exclude=("tests", "tests.*")), 40 | include_package_data=True, 41 | author='Bradley Whittington', 42 | author_email='radbrad182@gmail.com', 43 | url='http://github.com/vintasoftware/django-templated-email/', 44 | license='MIT', 45 | description=DESCRIPTION, 46 | long_description=LONG_DESCRIPTION, 47 | long_description_content_type='text/x-rst', 48 | platforms=['any'], 49 | classifiers=CLASSIFIERS, 50 | install_requires=requirements, 51 | ) 52 | -------------------------------------------------------------------------------- /templated_email/__init__.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.utils.module_loading import import_string 3 | 4 | from templated_email.backends.vanilla_django import TemplateBackend 5 | from templated_email.utils import InlineImage # noqa 6 | 7 | 8 | def get_connection(backend=None, template_prefix=None, template_suffix=None, 9 | fail_silently=False, **kwargs): 10 | """Load a templated e-mail backend and return an instance of it. 11 | 12 | If backend is None (default) settings.TEMPLATED_EMAIL_BACKEND is used. 13 | 14 | Both fail_silently and other keyword arguments are used in the 15 | constructor of the backend. 16 | """ 17 | # This method is mostly a copy of the backend loader present in 18 | # django.core.mail.get_connection 19 | klass_path = backend or getattr(settings, 'TEMPLATED_EMAIL_BACKEND', 20 | TemplateBackend) 21 | if isinstance(klass_path, str): 22 | try: 23 | # First check if class name is omitted and we have module in settings 24 | klass = import_string(klass_path + '.' + 'TemplateBackend') 25 | except ImportError: 26 | # Fallback to class name 27 | klass = import_string(klass_path) 28 | else: 29 | klass = klass_path 30 | 31 | return klass(fail_silently=fail_silently, template_prefix=template_prefix, 32 | template_suffix=template_suffix, **kwargs) 33 | 34 | 35 | def get_templated_mail(template_name, context, from_email=None, to=None, 36 | cc=None, bcc=None, headers=None, 37 | template_prefix=None, template_suffix=None, 38 | template_dir=None, file_extension=None, 39 | create_link=False): 40 | """Returns a templated EmailMessage instance without a connection using 41 | the django templating backend.""" 42 | template_prefix = template_prefix or template_dir 43 | template_suffix = template_suffix or file_extension 44 | templater = TemplateBackend(template_prefix=template_prefix, 45 | template_suffix=template_suffix) 46 | return templater.get_email_message(template_name, context, 47 | from_email=from_email, to=to, 48 | cc=cc, bcc=bcc, headers=headers, 49 | template_prefix=template_prefix, 50 | template_suffix=template_suffix, 51 | create_link=create_link) 52 | 53 | 54 | def send_templated_mail(template_name, from_email, recipient_list, context, 55 | cc=None, bcc=None, fail_silently=False, connection=None, 56 | headers=None, template_prefix=None, 57 | template_suffix=None, 58 | create_link=False, **kwargs): 59 | """Easy wrapper for sending a templated email to a recipient list. 60 | 61 | Final behaviour of sending depends on the currently selected engine. 62 | See BackendClass.send.__doc__ 63 | """ 64 | connection = connection or get_connection(template_prefix=template_prefix, 65 | template_suffix=template_suffix) 66 | return connection.send(template_name, from_email, recipient_list, context, 67 | cc=cc, bcc=bcc, fail_silently=fail_silently, 68 | headers=headers, create_link=create_link, **kwargs) 69 | -------------------------------------------------------------------------------- /templated_email/backends/__init__.py: -------------------------------------------------------------------------------- 1 | class HeaderNotSupportedException(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /templated_email/backends/vanilla_django.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import hashlib 3 | from io import BytesIO 4 | 5 | from django.conf import settings 6 | from django.core.mail import get_connection 7 | from django.template import Context 8 | from django.utils.translation import gettext as _ 9 | from django.core.files.storage import default_storage 10 | 11 | from templated_email.utils import ( 12 | get_emailmessage_klass, get_emailmultialternatives_klass) 13 | from templated_email.utils import InlineImage 14 | from render_block import render_block_to_string, BlockNotFound 15 | 16 | 17 | try: 18 | import html2text 19 | except ImportError: 20 | html2text = None 21 | 22 | 23 | class EmailRenderException(Exception): 24 | pass 25 | 26 | 27 | class TemplateBackend(object): 28 | """ 29 | Backend which uses Django's 30 | templates, and django's send_mail function. 31 | 32 | Heavily inspired by http://stackoverflow.com/questions/2809547/creating-email-templates-with-django 33 | 34 | Default / preferred behaviour works like so: 35 | templates named 36 | templated_email/.email 37 | 38 | {% block subject %} declares the subject 39 | {% block plain %} declares text/plain 40 | {% block html %} declares text/html 41 | 42 | Legacy behaviour loads from: 43 | Subjects for email templates can be configured in one of two ways: 44 | 45 | * If you are using internationalisation, you can simply create entries for 46 | " email subject" as a msgid in your PO file 47 | 48 | * Using a dictionary in settings.py, TEMPLATED_EMAIL_DJANGO_SUBJECTS, 49 | for e.g.: 50 | TEMPLATED_EMAIL_DJANGO_SUBJECTS = { 51 | 'welcome':'Welcome to my website', 52 | } 53 | 54 | Subjects are templatable using the context, i.e. A subject 55 | that resolves to 'Welcome to my website, %(username)s', requires that 56 | the context passed in to the send() method contains 'username' as one 57 | of it's keys 58 | """ 59 | 60 | def __init__(self, fail_silently=False, 61 | template_prefix=None, template_suffix=None, **kwargs): 62 | self.template_prefix = template_prefix or getattr(settings, 'TEMPLATED_EMAIL_TEMPLATE_DIR', 'templated_email/') 63 | self.template_suffix = template_suffix or getattr(settings, 'TEMPLATED_EMAIL_FILE_EXTENSION', 'email') 64 | 65 | def attach_inline_images(self, message, context): 66 | for value in context.values(): 67 | if isinstance(value, InlineImage): 68 | value.attach_to_message(message) 69 | 70 | def host_inline_image(self, inline_image): 71 | from templated_email.urls import app_name 72 | md5sum = hashlib.md5(inline_image.content).hexdigest() 73 | 74 | filename = inline_image.filename 75 | filename = app_name + '/' + md5sum + filename 76 | if not default_storage.exists(filename): 77 | filename = default_storage.save(filename, 78 | BytesIO(inline_image.content)) 79 | return default_storage.url(filename) 80 | 81 | def _render_email(self, template_name, context, 82 | template_dir=None, file_extension=None): 83 | response = {} 84 | errors = {} 85 | 86 | file_extension = file_extension or self.template_suffix 87 | if file_extension.startswith('.'): 88 | file_extension = file_extension[1:] 89 | template_extension = '.%s' % file_extension 90 | 91 | if isinstance(template_name, (tuple, list, )): 92 | prefixed_templates = template_name 93 | else: 94 | prefixed_templates = [template_name] 95 | 96 | full_template_names = [] 97 | for one_prefixed_template in prefixed_templates: 98 | one_full_template_name = ''.join((template_dir or self.template_prefix, one_prefixed_template)) 99 | if not one_full_template_name.endswith(template_extension): 100 | one_full_template_name += template_extension 101 | full_template_names.append(one_full_template_name) 102 | 103 | for part in ['subject', 'html', 'plain']: 104 | render_context = Context(context, autoescape=(part == 'html')) 105 | try: 106 | response[part] = render_block_to_string(full_template_names, part, render_context) 107 | except BlockNotFound as error: 108 | errors[part] = error 109 | 110 | if response == {}: 111 | raise EmailRenderException("Couldn't render email parts. Errors: %s" 112 | % errors) 113 | 114 | return response 115 | 116 | def get_email_message(self, template_name, context, from_email=None, to=None, 117 | cc=None, bcc=None, headers=None, 118 | template_prefix=None, template_suffix=None, 119 | template_dir=None, file_extension=None, 120 | attachments=None, create_link=False): 121 | 122 | if create_link: 123 | email_uuid = uuid.uuid4() 124 | link_context = dict(context) 125 | context['email_uuid'] = email_uuid.hex 126 | for key, value in context.items(): 127 | if isinstance(value, InlineImage): 128 | link_context[key] = self.host_inline_image(value) 129 | 130 | EmailMessage = get_emailmessage_klass() 131 | EmailMultiAlternatives = get_emailmultialternatives_klass() 132 | parts = self._render_email(template_name, context, 133 | template_prefix or template_dir, 134 | template_suffix or file_extension) 135 | plain_part = 'plain' in parts 136 | html_part = 'html' in parts 137 | 138 | if create_link and html_part: 139 | static_html_part = self._render_email( 140 | template_name, link_context, 141 | template_prefix or template_dir, 142 | template_suffix or file_extension)['html'] 143 | from templated_email.models import SavedEmail 144 | SavedEmail.objects.create(content=static_html_part, uuid=email_uuid) 145 | 146 | if 'subject' in parts: 147 | subject = parts['subject'] 148 | else: 149 | subject_dict = getattr(settings, 'TEMPLATED_EMAIL_DJANGO_SUBJECTS', {}) 150 | if isinstance(template_name, (list, tuple)): 151 | for template in template_name: 152 | if template in subject_dict: 153 | subject_template = subject_dict[template] 154 | break 155 | else: 156 | subject_template = _('%s email subject' % template_name[0]) 157 | else: 158 | subject_template = subject_dict.get(template_name, 159 | _('%s email subject' % template_name)) 160 | subject = subject_template % context 161 | subject = subject.strip('\n\r').replace('\n', ' ').replace('\r', ' ') # strip newlines from subject 162 | 163 | if not plain_part: 164 | plain_part = self._generate_plain_part(parts) 165 | 166 | if plain_part and not html_part: 167 | e = EmailMessage( 168 | subject, 169 | parts['plain'], 170 | from_email, 171 | to, 172 | cc=cc, 173 | bcc=bcc, 174 | headers=headers, 175 | attachments=attachments, 176 | ) 177 | 178 | elif html_part and not plain_part: 179 | e = EmailMessage( 180 | subject, 181 | parts['html'], 182 | from_email, 183 | to, 184 | cc=cc, 185 | bcc=bcc, 186 | headers=headers, 187 | attachments=attachments, 188 | ) 189 | e.content_subtype = 'html' 190 | 191 | elif plain_part and html_part: 192 | e = EmailMultiAlternatives( 193 | subject, 194 | parts['plain'], 195 | from_email, 196 | to, 197 | cc=cc, 198 | bcc=bcc, 199 | headers=headers, 200 | attachments=attachments, 201 | ) 202 | e.attach_alternative(parts['html'], 'text/html') 203 | 204 | else: 205 | raise EmailRenderException("Please specify at a plain and/or html block.") 206 | 207 | self.attach_inline_images(e, context) 208 | return e 209 | 210 | def _generate_plain_part(self, parts): 211 | """ 212 | Depending on some settings, generate a plain part from the HTML part. 213 | 214 | The user can choose a custom "plain function" that takes an argument 215 | of the HTML part and returns the plain text. By default this is 216 | "html2text.html2text". 217 | """ 218 | html_part = 'html' in parts 219 | auto_plain = getattr(settings, 'TEMPLATED_EMAIL_AUTO_PLAIN', True) 220 | plain_func = getattr(settings, 'TEMPLATED_EMAIL_PLAIN_FUNCTION', None) 221 | 222 | if not auto_plain: 223 | return 224 | 225 | if not html_part: 226 | return 227 | 228 | if not plain_func and html2text: 229 | plain_func = html2text.html2text 230 | 231 | if not plain_func: 232 | return 233 | 234 | parts['plain'] = plain_func(parts['html']) 235 | return True 236 | 237 | def send(self, template_name, from_email, recipient_list, context, 238 | cc=None, bcc=None, 239 | fail_silently=False, 240 | headers=None, 241 | template_prefix=None, template_suffix=None, 242 | template_dir=None, file_extension=None, 243 | auth_user=None, auth_password=None, 244 | connection=None, attachments=None, 245 | create_link=False, **kwargs): 246 | 247 | connection = connection or get_connection(username=auth_user, 248 | password=auth_password, 249 | fail_silently=fail_silently) 250 | 251 | e = self.get_email_message(template_name, context, from_email=from_email, 252 | to=recipient_list, cc=cc, bcc=bcc, headers=headers, 253 | template_prefix=template_prefix, 254 | template_suffix=template_suffix, 255 | template_dir=template_dir, 256 | file_extension=file_extension, 257 | attachments=attachments, 258 | create_link=create_link) 259 | 260 | e.connection = connection 261 | 262 | try: 263 | e.send(fail_silently) 264 | except NameError: 265 | raise EmailRenderException("Couldn't render plain or html parts") 266 | 267 | return e.extra_headers.get('Message-Id', None) 268 | -------------------------------------------------------------------------------- /templated_email/generic_views.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | 3 | from django.conf import settings 4 | from django.core.exceptions import ImproperlyConfigured 5 | 6 | from templated_email import send_templated_mail 7 | 8 | 9 | class TemplatedEmailFormViewMixin(object): 10 | templated_email_template_name = None 11 | templated_email_send_on_success = True 12 | templated_email_send_on_failure = False 13 | templated_email_from_email = partial(getattr, settings, 'TEMPLATED_EMAIL_FROM_EMAIL', None) 14 | 15 | def templated_email_get_template_names(self, valid): 16 | if self.templated_email_template_name is None: 17 | raise ImproperlyConfigured( 18 | "TemplatedEmailFormViewMixin requires either a definition of " 19 | "'templated_email_template_name' or an implementation of 'templated_email_get_template_names()'") 20 | return [self.templated_email_template_name] 21 | 22 | def templated_email_get_context_data(self, **kwargs): 23 | return kwargs 24 | 25 | def templated_email_get_recipients(self, form): 26 | raise NotImplementedError('You must implement templated_email_get_recipients method') 27 | 28 | def templated_email_get_send_email_kwargs(self, valid, form): 29 | if valid: 30 | context = self.templated_email_get_context_data(form_data=form.data) 31 | else: 32 | context = self.templated_email_get_context_data(form_errors=form.errors) 33 | try: 34 | from_email = self.templated_email_from_email() 35 | except TypeError: 36 | from_email = self.templated_email_from_email 37 | return { 38 | 'template_name': self.templated_email_get_template_names(valid=valid), 39 | 'from_email': from_email, 40 | 'recipient_list': self.templated_email_get_recipients(form), 41 | 'context': context 42 | } 43 | 44 | def templated_email_send_templated_mail(self, *args, **kwargs): 45 | return send_templated_mail(*args, **kwargs) 46 | 47 | def form_valid(self, form): 48 | response = super(TemplatedEmailFormViewMixin, self).form_valid(form) 49 | if self.templated_email_send_on_success: 50 | self.templated_email_send_templated_mail( 51 | **self.templated_email_get_send_email_kwargs(valid=True, form=form)) 52 | return response 53 | 54 | def form_invalid(self, form): 55 | response = super(TemplatedEmailFormViewMixin, self).form_invalid(form) 56 | if self.templated_email_send_on_failure: 57 | self.templated_email_send_templated_mail( 58 | **self.templated_email_get_send_email_kwargs(valid=False, form=form)) 59 | return response 60 | -------------------------------------------------------------------------------- /templated_email/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10 on 2016-10-05 17:09 3 | 4 | from django.db import migrations, models 5 | import uuid 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='SavedEmail', 18 | fields=[ 19 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('uuid', models.UUIDField(default=uuid.uuid4)), 21 | ('content', models.TextField()), 22 | ('created', models.DateTimeField(auto_now_add=True)), 23 | ], 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /templated_email/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vintasoftware/django-templated-email/f906be91cc0f719d2d7532d86c9c317181b75d62/templated_email/migrations/__init__.py -------------------------------------------------------------------------------- /templated_email/models.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | from django.db import models 4 | 5 | 6 | class SavedEmail(models.Model): 7 | uuid = models.UUIDField(default=uuid4) 8 | content = models.TextField() 9 | created = models.DateTimeField(auto_now_add=True) 10 | -------------------------------------------------------------------------------- /templated_email/templates/templated_email/saved_email.html: -------------------------------------------------------------------------------- 1 | {{ object.content|safe }} 2 | -------------------------------------------------------------------------------- /templated_email/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import re_path 2 | 3 | from templated_email.views import ShowEmailView 4 | 5 | app_name = 'templated_email' 6 | urlpatterns = [ 7 | re_path(r'^email/(?P([a-f\d]{32})|([a-f\d]{8}(-[a-f\d]{4}){3}-[a-f\d]{12}))/$', ShowEmailView.as_view(), name='show_email'), 8 | ] 9 | -------------------------------------------------------------------------------- /templated_email/utils.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from email.utils import unquote 3 | from email.mime.image import MIMEImage 4 | 5 | from django.core.mail import make_msgid 6 | from django.utils.module_loading import import_string 7 | from django.conf import settings 8 | 9 | 10 | def _get_klass_from_config(config_variable, default): 11 | klass_path = getattr(settings, config_variable, default) 12 | if isinstance(klass_path, str): 13 | klass_path = import_string(klass_path) 14 | 15 | return klass_path 16 | 17 | 18 | get_emailmessage_klass = partial( 19 | _get_klass_from_config, 20 | 'TEMPLATED_EMAIL_EMAIL_MESSAGE_CLASS', 21 | 'django.core.mail.EmailMessage' 22 | ) 23 | 24 | get_emailmultialternatives_klass = partial( 25 | _get_klass_from_config, 26 | 'TEMPLATED_EMAIL_EMAIL_MULTIALTERNATIVES_CLASS', 27 | 'django.core.mail.EmailMultiAlternatives', 28 | ) 29 | 30 | 31 | class InlineImage(object): 32 | 33 | def __init__(self, filename, content, subtype=None, domain=None): 34 | self.filename = filename 35 | self._content = content 36 | self.subtype = subtype 37 | self.domain = domain 38 | self._content_id = None 39 | 40 | @property 41 | def content(self): 42 | return self._content 43 | 44 | @content.setter 45 | def content(self, value): 46 | self._content_id = None 47 | self._content = value 48 | 49 | def attach_to_message(self, message): 50 | if not self._content_id: 51 | self.generate_cid() 52 | image = MIMEImage(self.content, self.subtype) 53 | image.add_header('Content-Disposition', 'inline', filename=self.filename) 54 | image.add_header('Content-ID', self._content_id) 55 | message.attach(image) 56 | 57 | def generate_cid(self): 58 | self._content_id = make_msgid('img', self.domain) 59 | 60 | def __str__(self): 61 | if not self._content_id: 62 | self.generate_cid() 63 | return 'cid:' + unquote(self._content_id) 64 | -------------------------------------------------------------------------------- /templated_email/views.py: -------------------------------------------------------------------------------- 1 | from django.views.generic import DetailView 2 | 3 | from templated_email.models import SavedEmail 4 | 5 | 6 | class ShowEmailView(DetailView): 7 | model = SavedEmail 8 | template_name = 'templated_email/saved_email.html' 9 | slug_field = 'uuid' 10 | slug_url_kwarg = 'uuid' 11 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vintasoftware/django-templated-email/f906be91cc0f719d2d7532d86c9c317181b75d62/tests/__init__.py -------------------------------------------------------------------------------- /tests/backends/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vintasoftware/django-templated-email/f906be91cc0f719d2d7532d86c9c317181b75d62/tests/backends/__init__.py -------------------------------------------------------------------------------- /tests/backends/test_vanilla_django_backend.py: -------------------------------------------------------------------------------- 1 | import base64 2 | from io import BytesIO 3 | from datetime import date 4 | from email.mime.image import MIMEImage 5 | 6 | from django.test import TestCase, override_settings 7 | from django.core.mail import EmailMessage, EmailMultiAlternatives 8 | try: 9 | from django.core.files.storage import get_storage_class 10 | except ImportError: 11 | from django.core.files.storage import storages 12 | 13 | def get_storage_class(s): 14 | storages[s] 15 | 16 | from django.template import TemplateDoesNotExist 17 | from django.core import mail 18 | 19 | import pytest 20 | from unittest.mock import patch, Mock 21 | from anymail.message import AnymailMessage 22 | 23 | from templated_email.backends.vanilla_django import TemplateBackend, EmailRenderException 24 | from templated_email import InlineImage 25 | from templated_email.models import SavedEmail 26 | from .utils import TempalteBackendBaseMixin 27 | from tests.utils import MockedNetworkTestCaseMixin 28 | 29 | 30 | PLAIN_RESULT = (u'\n Hi,\n\n You just signed up for my website, using:\n ' 31 | u' username: vintasoftware\n join date: Aug. 22, 2016\n' 32 | u'\n Thanks, you rock!\n') 33 | 34 | 35 | HTML_RESULT = (u'

Hi Foo Bar,

You just signed up for my website, ' 36 | u'using:

username
vintasoftwar' 37 | u'e
join date
Aug. 22, 2016
' 38 | u'

Thanks, you rock!

') 39 | 40 | INHERITANCE_RESULT = (u'

Hello Foo Bar,

You just signed up for my website, ' 41 | u'using:

username
Mr. vintasoftwar' 42 | u'e
join date
Aug. 22, 2016
' 43 | u'

') 44 | 45 | GENERATED_PLAIN_RESULT = (u'Hi Foo Bar,\n\nYou just signed up for my website, using:' 46 | u'\n\nusername\n\n vintasoftware\njoin date\n' 47 | u'\n Aug. 22, 2016\n\nThanks, you rock!\n\n') 48 | 49 | MULTI_TEMPLATE_PLAIN_RESULT = (u'\nJust to make sure the content is read\n') 50 | 51 | SUBJECT_RESULT = 'My subject for vintasoftware' 52 | 53 | MULTI_TEMPLATE_SUBJECT_RESULT = 'A subject' 54 | 55 | NON_ESCAPED_PLAIN_RESULT = (u'\n Hi,\n\n You just signed up for my website, using:\n ' 56 | u' username:

vintasoftware

\n join date: Aug. 22, 2016\n' 57 | u'\n Thanks, you rock!\n') 58 | 59 | 60 | ESCAPED_HTML_RESULT = (u'

Hi Foo Bar,

You just signed up for my website, ' 61 | u'using:

username
<p>vintasoftwar' 62 | u'e</p>
join date
Aug. 22, 2016
' 63 | u'

Thanks, you rock!

') 64 | 65 | NON_ESCAPED_SUBJECT_RESULT = 'My subject for

vintasoftware

' 66 | 67 | TXT_FILE = 'test' 68 | 69 | 70 | def decode_b64_msg(msg): 71 | return base64.b64decode(msg).decode("utf-8") 72 | 73 | 74 | class TemplateBackendTestCase(MockedNetworkTestCaseMixin, 75 | TempalteBackendBaseMixin, TestCase): 76 | template_backend_klass = TemplateBackend 77 | 78 | def setUp(self): 79 | self.backend = self.template_backend_klass() 80 | self.context = {'username': 'vintasoftware', 81 | 'joindate': date(2016, 8, 22), 82 | 'full_name': 'Foo Bar'} 83 | 84 | def test_inexistent_base_email(self): 85 | try: 86 | self.backend._render_email('inexistent_base.email', {}) 87 | except TemplateDoesNotExist as e: 88 | self.assertEqual(e.args[0], 'foo') 89 | 90 | def test_inexistent_template_email(self): 91 | try: 92 | self.backend._render_email('foo', {}) 93 | except TemplateDoesNotExist as e: 94 | self.assertEqual(e.args[0], 'templated_email/foo.email') 95 | 96 | def test_render_plain_email(self): 97 | response = self.backend._render_email( 98 | 'plain_template.email', self.context) 99 | self.assertEqual(len(response.keys()), 2) 100 | self.assertEqual(PLAIN_RESULT, response['plain']) 101 | self.assertEqual(SUBJECT_RESULT, response['subject']) 102 | 103 | def test_render_html_email(self): 104 | response = self.backend._render_email( 105 | 'html_template.email', self.context) 106 | self.assertEqual(len(response.keys()), 2) 107 | self.assertHTMLEqual(HTML_RESULT, response['html']) 108 | self.assertEqual(SUBJECT_RESULT, response['subject']) 109 | 110 | def test_render_mixed_email(self): 111 | response = self.backend._render_email( 112 | 'mixed_template.email', self.context) 113 | self.assertEqual(len(response.keys()), 3) 114 | self.assertHTMLEqual(HTML_RESULT, response['html']) 115 | self.assertEqual(PLAIN_RESULT, response['plain']) 116 | self.assertEqual(SUBJECT_RESULT, response['subject']) 117 | 118 | def test_render_inheritance_email(self): 119 | response = self.backend._render_email( 120 | 'inheritance_template.email', self.context) 121 | self.assertEqual(len(response.keys()), 3) 122 | self.assertHTMLEqual(INHERITANCE_RESULT, response['html']) 123 | self.assertEqual(PLAIN_RESULT, response['plain']) 124 | self.assertEqual('Another subject for vintasoftware', response['subject']) 125 | 126 | def test_email_text_escaping(self): 127 | self.context['username'] = '

vintasoftware

' 128 | 129 | response = self.backend._render_email( 130 | 'mixed_template.email', self.context) 131 | self.assertHTMLEqual(ESCAPED_HTML_RESULT, response['html']) 132 | self.assertEqual(NON_ESCAPED_PLAIN_RESULT, response['plain']) 133 | self.assertEqual(NON_ESCAPED_SUBJECT_RESULT, response['subject']) 134 | 135 | @patch.object( 136 | template_backend_klass, '_render_email', 137 | return_value={'plain': PLAIN_RESULT, 'subject': SUBJECT_RESULT} 138 | ) 139 | def test_get_email_message(self, mock): 140 | message = self.backend.get_email_message( 141 | 'foo.email', {}, 142 | from_email='from@example.com', cc=['cc@example.com'], 143 | bcc=['bcc@example.com'], to=['to@example.com']) 144 | self.assertTrue(isinstance(message, EmailMessage)) 145 | self.assertEqual(message.body, PLAIN_RESULT) 146 | self.assertEqual(message.subject, SUBJECT_RESULT) 147 | self.assertEqual(message.to, ['to@example.com']) 148 | self.assertEqual(message.cc, ['cc@example.com']) 149 | self.assertEqual(message.bcc, ['bcc@example.com']) 150 | self.assertEqual(message.from_email, 'from@example.com') 151 | 152 | @patch.object( 153 | template_backend_klass, '_render_email', 154 | return_value={'html': HTML_RESULT, 'plain': PLAIN_RESULT, 155 | 'subject': SUBJECT_RESULT} 156 | ) 157 | def test_get_email_message_with_create_link(self, mocked): 158 | self.backend.get_email_message( 159 | 'foo.email', {}, 160 | from_email='from@example.com', cc=['cc@example.com'], 161 | bcc=['bcc@example.com'], to=['to@example.com'], 162 | create_link=True) 163 | first_call_context = mocked.call_args_list[0][0][1] 164 | uuid = first_call_context['email_uuid'] 165 | self.assertTrue(uuid) 166 | second_call_context = mocked.call_args_list[1][0][1] 167 | self.assertEqual(len(second_call_context), 0) 168 | saved_email = SavedEmail.objects.get( 169 | uuid=uuid) 170 | self.assertEqual(saved_email.content, HTML_RESULT) 171 | 172 | @patch('django.core.files.storage.FileSystemStorage.save') 173 | @patch('django.core.files.storage.FileSystemStorage.url') 174 | @patch.object( 175 | template_backend_klass, '_render_email', 176 | return_value={'html': HTML_RESULT, 'plain': PLAIN_RESULT, 177 | 'subject': SUBJECT_RESULT} 178 | ) 179 | def test_get_email_message_with_inline_image(self, mock_render_email, mock_url, mock_save): 180 | mock_url.return_value = 'media/saved_url' 181 | self.backend.get_email_message( 182 | 'foo.email', {'an_image': InlineImage('file.png', b'foo', 183 | subtype='png')}, 184 | from_email='from@example.com', cc=['cc@example.com'], 185 | bcc=['bcc@example.com'], to=['to@example.com'], 186 | create_link=True) 187 | second_call_context = mock_render_email.call_args_list[1][0][1] 188 | self.assertEqual(second_call_context['an_image'], 'media/saved_url') 189 | 190 | @override_settings(TEMPLATED_EMAIL_EMAIL_MESSAGE_CLASS='anymail.message.AnymailMessage') 191 | @patch.object( 192 | template_backend_klass, '_render_email', 193 | return_value={'plain': PLAIN_RESULT, 'subject': SUBJECT_RESULT} 194 | ) 195 | def test_custom_emailmessage_klass(self, mock): 196 | message = self.backend.get_email_message( 197 | 'foo.email', {}, 198 | from_email='from@example.com', cc=['cc@example.com'], 199 | bcc=['bcc@example.com'], to=['to@example.com']) 200 | self.assertTrue(isinstance(message, AnymailMessage)) 201 | 202 | @override_settings(TEMPLATED_EMAIL_DJANGO_SUBJECTS={'foo.email': 203 | 'foo\r\n'}) 204 | @patch.object( 205 | template_backend_klass, '_render_email', 206 | return_value={'plain': PLAIN_RESULT} 207 | ) 208 | def test_get_email_message_without_subject(self, mock): 209 | message = self.backend.get_email_message( 210 | 'foo.email', {}, 211 | from_email='from@example.com', cc=['cc@example.com'], 212 | bcc=['bcc@example.com'], to=['to@example.com']) 213 | self.assertTrue(isinstance(message, EmailMessage)) 214 | self.assertEqual(message.body, PLAIN_RESULT) 215 | self.assertEqual(message.subject, 'foo') 216 | self.assertEqual(message.to, ['to@example.com']) 217 | self.assertEqual(message.cc, ['cc@example.com']) 218 | self.assertEqual(message.bcc, ['bcc@example.com']) 219 | self.assertEqual(message.from_email, 'from@example.com') 220 | 221 | @override_settings(TEMPLATED_EMAIL_DJANGO_SUBJECTS={'foo.email': 222 | 'foo\r\n'}) 223 | @patch.object( 224 | template_backend_klass, '_render_email', 225 | return_value={'plain': PLAIN_RESULT} 226 | ) 227 | def test_get_email_message_without_subject_multiple_templates(self, mock): 228 | message = self.backend.get_email_message( 229 | ['woo.email', 'foo.email'], {}, 230 | from_email='from@example.com', cc=['cc@example.com'], 231 | bcc=['bcc@example.com'], to=['to@example.com']) 232 | self.assertTrue(isinstance(message, EmailMessage)) 233 | self.assertEqual(message.body, PLAIN_RESULT) 234 | self.assertEqual(message.subject, 'foo') 235 | self.assertEqual(message.to, ['to@example.com']) 236 | self.assertEqual(message.cc, ['cc@example.com']) 237 | self.assertEqual(message.bcc, ['bcc@example.com']) 238 | self.assertEqual(message.from_email, 'from@example.com') 239 | 240 | @patch.object( 241 | template_backend_klass, '_render_email', 242 | return_value={'html': HTML_RESULT, 'subject': SUBJECT_RESULT} 243 | ) 244 | def test_get_email_message_generated_plain_text(self, mock): 245 | message = self.backend.get_email_message( 246 | 'foo.email', {}, 247 | from_email='from@example.com', cc=['cc@example.com'], 248 | bcc=['bcc@example.com'], to=['to@example.com']) 249 | self.assertTrue(isinstance(message, EmailMultiAlternatives)) 250 | self.assertHTMLEqual(message.alternatives[0][0], HTML_RESULT) 251 | self.assertEqual(message.alternatives[0][1], 'text/html') 252 | self.assertEqual(message.body, GENERATED_PLAIN_RESULT) 253 | self.assertEqual(message.subject, SUBJECT_RESULT) 254 | self.assertEqual(message.to, ['to@example.com']) 255 | self.assertEqual(message.cc, ['cc@example.com']) 256 | self.assertEqual(message.bcc, ['bcc@example.com']) 257 | self.assertEqual(message.from_email, 'from@example.com') 258 | 259 | @patch.object( 260 | template_backend_klass, '_render_email', 261 | return_value={'html': HTML_RESULT, 'subject': SUBJECT_RESULT} 262 | ) 263 | @override_settings(TEMPLATED_EMAIL_PLAIN_FUNCTION=lambda x: 'hi') 264 | def test_get_email_message_custom_func_generated_plain_text(self, mock): 265 | message = self.backend.get_email_message('foo.email', {}) 266 | self.assertEqual(message.body, 'hi') 267 | 268 | def test_get_multi_match_last_email_message_generated_plain_text(self): 269 | message = self.backend.get_email_message( 270 | ['multi-template.email', 'foo.email', ], {}, 271 | from_email='from@example.com', cc=['cc@example.com'], 272 | bcc=['bcc@example.com'], to=['to@example.com']) 273 | self.assertEqual(message.body, MULTI_TEMPLATE_PLAIN_RESULT) 274 | self.assertEqual(message.subject, MULTI_TEMPLATE_SUBJECT_RESULT) 275 | self.assertEqual(message.to, ['to@example.com']) 276 | self.assertEqual(message.cc, ['cc@example.com']) 277 | self.assertEqual(message.bcc, ['bcc@example.com']) 278 | self.assertEqual(message.from_email, 'from@example.com') 279 | 280 | def test_get_multi_first_match_email_message_generated_plain_text(self): 281 | message = self.backend.get_email_message( 282 | ['foo.email', 'multi-template.email', ], {}, 283 | from_email='from@example.com', cc=['cc@example.com'], 284 | bcc=['bcc@example.com'], to=['to@example.com']) 285 | self.assertEqual(message.body, MULTI_TEMPLATE_PLAIN_RESULT) 286 | self.assertEqual(message.subject, MULTI_TEMPLATE_SUBJECT_RESULT) 287 | self.assertEqual(message.to, ['to@example.com']) 288 | self.assertEqual(message.cc, ['cc@example.com']) 289 | self.assertEqual(message.bcc, ['bcc@example.com']) 290 | self.assertEqual(message.from_email, 'from@example.com') 291 | 292 | def test_get_multi_options_select_last_plain_only(self): 293 | message = self.backend.get_email_message( 294 | ['non-existing.email', 'also-non-existing.email', 'non-existing-without-suffix', 'foo.email', 'multi-template.email', ], {}, 295 | from_email='from@example.com', cc=['cc@example.com'], 296 | bcc=['bcc@example.com'], to=['to@example.com']) 297 | self.assertEqual(message.body, MULTI_TEMPLATE_PLAIN_RESULT) 298 | self.assertEqual(message.subject, MULTI_TEMPLATE_SUBJECT_RESULT) 299 | self.assertEqual(message.to, ['to@example.com']) 300 | self.assertEqual(message.cc, ['cc@example.com']) 301 | self.assertEqual(message.bcc, ['bcc@example.com']) 302 | self.assertEqual(message.from_email, 'from@example.com') 303 | 304 | @patch.object( 305 | template_backend_klass, '_render_email', 306 | return_value={'html': HTML_RESULT, 'plain': PLAIN_RESULT, 307 | 'subject': SUBJECT_RESULT} 308 | ) 309 | def test_get_email_message_with_plain_and_html(self, mock): 310 | message = self.backend.get_email_message( 311 | 'foo.email', {}, 312 | from_email='from@example.com', cc=['cc@example.com'], 313 | bcc=['bcc@example.com'], to=['to@example.com']) 314 | self.assertTrue(isinstance(message, EmailMultiAlternatives)) 315 | self.assertHTMLEqual(message.alternatives[0][0], HTML_RESULT) 316 | self.assertEqual(message.alternatives[0][1], 'text/html') 317 | self.assertEqual(message.body, PLAIN_RESULT) 318 | self.assertEqual(message.subject, SUBJECT_RESULT) 319 | self.assertEqual(message.to, ['to@example.com']) 320 | self.assertEqual(message.cc, ['cc@example.com']) 321 | self.assertEqual(message.bcc, ['bcc@example.com']) 322 | self.assertEqual(message.from_email, 'from@example.com') 323 | 324 | @patch.object( 325 | template_backend_klass, '_render_email', 326 | return_value={'subject': SUBJECT_RESULT} 327 | ) 328 | def test_get_email_message_with_no_body_parts(self, mock): 329 | with pytest.raises(EmailRenderException): 330 | self.backend.get_email_message( 331 | 'foo.email', {}, 332 | from_email='from@example.com', cc=['cc@example.com'], 333 | bcc=['bcc@example.com'], to=['to@example.com']) 334 | 335 | @override_settings(TEMPLATED_EMAIL_EMAIL_MULTIALTERNATIVES_CLASS='anymail.message.AnymailMessage') 336 | @patch.object( 337 | template_backend_klass, '_render_email', 338 | return_value={'html': HTML_RESULT, 'plain': PLAIN_RESULT, 339 | 'subject': SUBJECT_RESULT} 340 | ) 341 | def test_custom_emailmessage_klass_multipart(self, mock): 342 | message = self.backend.get_email_message( 343 | 'foo.email', {}, 344 | from_email='from@example.com', cc=['cc@example.com'], 345 | bcc=['bcc@example.com'], to=['to@example.com']) 346 | self.assertTrue(isinstance(message, AnymailMessage)) 347 | 348 | @override_settings(TEMPLATED_EMAIL_AUTO_PLAIN=False) 349 | @patch.object( 350 | template_backend_klass, '_render_email', 351 | return_value={'html': HTML_RESULT, 352 | 'subject': SUBJECT_RESULT} 353 | ) 354 | def test_get_email_message_html_only(self, mock): 355 | message = self.backend.get_email_message( 356 | 'foo.email', {}, 357 | from_email='from@example.com', cc=['cc@example.com'], 358 | bcc=['bcc@example.com'], to=['to@example.com']) 359 | self.assertTrue(isinstance(message, EmailMessage)) 360 | self.assertHTMLEqual(message.body, HTML_RESULT) 361 | self.assertEqual(message.content_subtype, 'html') 362 | self.assertEqual(message.subject, SUBJECT_RESULT) 363 | self.assertEqual(message.to, ['to@example.com']) 364 | self.assertEqual(message.cc, ['cc@example.com']) 365 | self.assertEqual(message.bcc, ['bcc@example.com']) 366 | self.assertEqual(message.from_email, 'from@example.com') 367 | 368 | @patch.object( 369 | template_backend_klass, '_render_email', 370 | return_value={'html': HTML_RESULT, 'plain': PLAIN_RESULT, 371 | 'subject': SUBJECT_RESULT} 372 | ) 373 | def test_send(self, render_mock): 374 | ret = self.backend.send('mixed_template', 'from@example.com', 375 | ['to@example.com', 'to2@example.com'], {}, 376 | headers={'Message-Id': 'a_message_id'}) 377 | self.assertEqual(ret, 'a_message_id') 378 | self.assertEqual(len(mail.outbox), 1) 379 | message = mail.outbox[0] 380 | self.assertEqual(ret, message.extra_headers['Message-Id']) 381 | self.assertTrue(isinstance(message, EmailMultiAlternatives)) 382 | self.assertHTMLEqual(message.alternatives[0][0], HTML_RESULT) 383 | self.assertEqual(message.alternatives[0][1], 'text/html') 384 | self.assertEqual(message.body, PLAIN_RESULT) 385 | self.assertEqual(message.subject, SUBJECT_RESULT) 386 | self.assertEqual(message.to, ['to@example.com', 'to2@example.com']) 387 | self.assertEqual(message.from_email, 'from@example.com') 388 | 389 | @patch.object( 390 | template_backend_klass, 'get_email_message' 391 | ) 392 | @patch( 393 | 'templated_email.backends.vanilla_django.get_connection' 394 | ) 395 | def test_all_arguments_passed_forward_from_send( 396 | self, get_connection_mock, get_email_message_mock): 397 | kwargs = { 398 | 'template_name': 'foo', 399 | 'from_email': 'from@example.com', 400 | 'recipient_list': ['to@example.com'], 401 | 'context': {'foo': 'bar'}, 402 | 'cc': ['cc@example.com'], 403 | 'bcc': ['bcc@example.com'], 404 | 'fail_silently': True, 405 | 'headers': {'Message-Id': 'a_message_id'}, 406 | 'template_prefix': 'prefix', 407 | 'template_suffix': 'suffix', 408 | 'template_dir': 'tempdir', 409 | 'file_extension': 'ext', 410 | 'auth_user': 'vintasoftware', 411 | 'auth_password': 'password', 412 | 'create_link': False, 413 | } 414 | 415 | send_mock = get_email_message_mock.return_value.send 416 | self.backend.send(**kwargs) 417 | get_connection_mock.assert_called_with( 418 | username=kwargs['auth_user'], 419 | password=kwargs['auth_password'], 420 | fail_silently=kwargs['fail_silently'] 421 | ) 422 | get_email_message_mock.assert_called_with( 423 | kwargs['template_name'], 424 | kwargs['context'], 425 | from_email=kwargs['from_email'], 426 | to=kwargs['recipient_list'], 427 | cc=kwargs['cc'], 428 | bcc=kwargs['bcc'], 429 | headers=kwargs['headers'], 430 | template_prefix=kwargs['template_prefix'], 431 | template_suffix=kwargs['template_suffix'], 432 | template_dir=kwargs['template_dir'], 433 | file_extension=kwargs['file_extension'], 434 | create_link=kwargs['create_link'], 435 | attachments=None, 436 | ) 437 | send_mock.assert_called_with( 438 | kwargs['fail_silently'] 439 | ) 440 | 441 | @patch.object( 442 | template_backend_klass, '_render_email', 443 | return_value={'plain': PLAIN_RESULT, 444 | 'subject': SUBJECT_RESULT} 445 | ) 446 | def test_send_attachment_mime_base(self, render_mock): 447 | self.backend.send('plain_template', 'from@example.com', 448 | ['to@example.com', 'to2@example.com'], {}, 449 | attachments=[MIMEImage(TXT_FILE, 'text/plain')]) 450 | attachment = mail.outbox[0].attachments[0] 451 | self.assertEqual(decode_b64_msg(attachment.get_payload()), 452 | TXT_FILE) 453 | 454 | @patch.object( 455 | template_backend_klass, '_render_email', 456 | return_value={'plain': PLAIN_RESULT, 457 | 'subject': SUBJECT_RESULT} 458 | ) 459 | def test_send_attachment_tripple(self, render_mock): 460 | self.backend.send('plain_template', 'from@example.com', 461 | ['to@example.com', 'to2@example.com'], {}, 462 | attachments=[('black_pixel.png', TXT_FILE, 'text/plain')]) 463 | attachment = mail.outbox[0].attachments[0] 464 | self.assertEqual(('black_pixel.png', TXT_FILE, 'text/plain'), 465 | attachment) 466 | 467 | @patch.object( 468 | template_backend_klass, '_render_email', 469 | return_value={'plain': PLAIN_RESULT, 'subject': SUBJECT_RESULT} 470 | ) 471 | def test_get_email_message_attachment_mime_base(self, mock): 472 | message = self.backend.get_email_message( 473 | 'foo.email', {}, 474 | from_email='from@example.com', cc=['cc@example.com'], 475 | bcc=['bcc@example.com'], to=['to@example.com'], 476 | attachments=[MIMEImage(TXT_FILE, 'text/plain')]) 477 | attachment = message.attachments[0] 478 | self.assertEqual(decode_b64_msg(attachment.get_payload()), 479 | TXT_FILE) 480 | 481 | @patch.object( 482 | template_backend_klass, '_render_email', 483 | return_value={'plain': PLAIN_RESULT, 'subject': SUBJECT_RESULT} 484 | ) 485 | def test_get_email_message_attachment_tripple(self, mock): 486 | message = self.backend.get_email_message( 487 | 'foo.email', {}, 488 | from_email='from@example.com', cc=['cc@example.com'], 489 | bcc=['bcc@example.com'], to=['to@example.com'], 490 | attachments=[('black_pixel.png', TXT_FILE, 'text/plain')]) 491 | attachment = message.attachments[0] 492 | self.assertEqual(('black_pixel.png', TXT_FILE, 'text/plain'), 493 | attachment) 494 | 495 | def test_removal_of_legacy(self): 496 | try: 497 | self.backend._render_email('legacy', {}) 498 | except TemplateDoesNotExist as e: 499 | self.assertEqual(e.args[0], 'templated_email/legacy.email') 500 | 501 | @patch('django.core.files.storage.FileSystemStorage.url') 502 | @patch('django.core.files.storage.FileSystemStorage.save') 503 | def test_host_inline_image_if_not_exist(self, mock_save, mock_url): 504 | mock_url.return_value = 'media/saved_url' 505 | inline_image = InlineImage('foo.jpg', b'bar') 506 | 507 | filename = self.backend.host_inline_image(inline_image) 508 | self.assertEqual(filename, 'media/saved_url') 509 | mock_save.assert_called_once() 510 | name, content = mock_save.call_args[0] 511 | self.assertEqual( 512 | name, 513 | 'templated_email/37b51d194a7513e45b56f6524f2d51f2foo.jpg') 514 | self.assertTrue(isinstance(content, BytesIO)) 515 | 516 | @patch('django.core.files.storage.FileSystemStorage.exists') 517 | @patch('django.core.files.storage.FileSystemStorage.save') 518 | def test_host_inline_image_if_exist(self, mock_save, mock_exists): 519 | inline_image = InlineImage('foo.jpg', b'bar') 520 | mock_exists.return_value = True 521 | 522 | filename = self.backend.host_inline_image(inline_image) 523 | self.assertEqual( 524 | filename, 525 | '/media/templated_email/37b51d194a7513e45b56f6524f2d51f2foo.jpg') 526 | 527 | mock_save.assert_not_called() 528 | mock_exists.assert_called_once_with( 529 | 'templated_email/37b51d194a7513e45b56f6524f2d51f2foo.jpg') 530 | -------------------------------------------------------------------------------- /tests/backends/utils.py: -------------------------------------------------------------------------------- 1 | from django.test import override_settings 2 | 3 | 4 | class TempalteBackendBaseMixin(object): 5 | 6 | @override_settings(TEMPLATED_EMAIL_TEMPLATE_DIR='test_prefix') 7 | def test_uses_prefix_from_config(self): 8 | backend = self.template_backend_klass() 9 | self.assertEqual(backend.template_prefix, 'test_prefix') 10 | 11 | @override_settings(TEMPLATED_EMAIL_FILE_EXTENSION='test_suffix') 12 | def test_uses_suffix_from_config(self): 13 | backend = self.template_backend_klass() 14 | self.assertEqual(backend.template_suffix, 'test_suffix') 15 | 16 | def test_override_prefix_from_config(self): 17 | backend = self.template_backend_klass(template_prefix='test_prefix') 18 | self.assertEqual(backend.template_prefix, 'test_prefix') 19 | 20 | def test_override_suffix_from_config(self): 21 | backend = self.template_backend_klass(template_suffix='test_suffix') 22 | self.assertEqual(backend.template_suffix, 'test_suffix') 23 | -------------------------------------------------------------------------------- /tests/generic_views/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vintasoftware/django-templated-email/f906be91cc0f719d2d7532d86c9c317181b75d62/tests/generic_views/__init__.py -------------------------------------------------------------------------------- /tests/generic_views/models.py: -------------------------------------------------------------------------------- 1 | 2 | from django.db import models 3 | 4 | 5 | class Author(models.Model): 6 | email = models.EmailField() 7 | name = models.CharField(max_length=200) 8 | -------------------------------------------------------------------------------- /tests/generic_views/test_views.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase, RequestFactory, override_settings 2 | from django.core.exceptions import ImproperlyConfigured 3 | from django.core import mail 4 | 5 | from unittest.mock import patch 6 | 7 | from templated_email.generic_views import TemplatedEmailFormViewMixin 8 | from tests.generic_views.views import AuthorCreateView 9 | from tests.generic_views.models import Author 10 | from tests.utils import MockedNetworkTestCaseMixin 11 | 12 | 13 | class TemplatedEmailFormViewMixinUnitTestCase(TestCase): 14 | def setUp(self): 15 | self.mixin_object = TemplatedEmailFormViewMixin() 16 | 17 | def test_templated_email_get_template_names_raises_exception(self): 18 | self.assertRaises(ImproperlyConfigured, 19 | self.mixin_object.templated_email_get_template_names, 20 | valid=True) 21 | self.assertRaises(ImproperlyConfigured, 22 | self.mixin_object.templated_email_get_template_names, 23 | valid=False) 24 | 25 | def test_templated_email_get_template_names_with_template_name(self): 26 | self.mixin_object.templated_email_template_name = 'template_name' 27 | self.assertEqual( 28 | self.mixin_object.templated_email_get_template_names(valid=True), 29 | ['template_name'] 30 | ) 31 | self.assertEqual( 32 | self.mixin_object.templated_email_get_template_names(valid=False), 33 | ['template_name'] 34 | ) 35 | 36 | def test_templated_email_get_context_data(self): 37 | context = self.mixin_object.templated_email_get_context_data() 38 | self.assertEqual(context, {}) 39 | context = self.mixin_object.templated_email_get_context_data(foo='bar') 40 | self.assertEqual(context, {'foo': 'bar'}) 41 | 42 | def test_templated_email_get_recipients(self): 43 | self.assertRaises(NotImplementedError, 44 | self.mixin_object.templated_email_get_recipients, 45 | form=None) 46 | 47 | @patch.object(TemplatedEmailFormViewMixin, 48 | 'templated_email_get_template_names', 49 | return_value=['template']) 50 | @patch.object(TemplatedEmailFormViewMixin, 51 | 'templated_email_get_recipients', 52 | return_value=['foo@example.com']) 53 | def test_templated_email_get_send_email_kwargs_valid( 54 | self, 55 | mocked_get_templated_email_recipients, 56 | mocked_get_templated_email_template_names): 57 | class FakeForm(object): 58 | data = 'foo' 59 | form = FakeForm() 60 | kwargs = self.mixin_object.templated_email_get_send_email_kwargs( 61 | valid=True, form=form) 62 | self.assertEqual(len(kwargs), 4) 63 | self.assertEqual(kwargs['template_name'], ['template']) 64 | self.assertEqual(kwargs['from_email'], None) 65 | self.assertEqual(kwargs['recipient_list'], ['foo@example.com']) 66 | self.assertEqual(kwargs['context'], {'form_data': 'foo'}) 67 | 68 | @patch.object(TemplatedEmailFormViewMixin, 69 | 'templated_email_get_template_names', 70 | return_value=['template']) 71 | @patch.object(TemplatedEmailFormViewMixin, 72 | 'templated_email_get_recipients', 73 | return_value=['foo@example.com']) 74 | def test_templated_email_get_send_email_kwargs_not_valid( 75 | self, 76 | mocked_get_templated_email_recipients, 77 | mocked_get_templated_email_template_names): 78 | class FakeForm(object): 79 | errors = 'errors foo' 80 | form = FakeForm() 81 | kwargs = self.mixin_object.templated_email_get_send_email_kwargs( 82 | valid=False, form=form) 83 | self.assertEqual(len(kwargs), 4) 84 | self.assertEqual(kwargs['template_name'], ['template']) 85 | self.assertEqual(kwargs['from_email'], None) 86 | self.assertEqual(kwargs['recipient_list'], ['foo@example.com']) 87 | self.assertEqual(kwargs['context'], {'form_errors': 'errors foo'}) 88 | 89 | 90 | class TemplatedEmailFormViewMixinTestCase(MockedNetworkTestCaseMixin, TestCase): 91 | def setUp(self): 92 | self.factory = RequestFactory() 93 | self.good_request = self.factory.post( 94 | '/doesnt-matter/', 95 | data={'email': 'author@vinta.com.br', 'name': 'Andre'} 96 | ) 97 | self.bad_request = self.factory.post( 98 | '/doesnt-matter/', 99 | data={'email': 'this_is_not_an_email', 'name': 'Andre'} 100 | ) 101 | 102 | def test_form_valid_with_send_on_success(self): 103 | response = AuthorCreateView.as_view()(self.good_request) 104 | self.assertEqual(response.status_code, 302) 105 | self.assertEqual(Author.objects.count(), 1) 106 | self.assertEqual(len(mail.outbox), 1) 107 | self.assertEqual(mail.outbox[0].alternatives[0][0].strip(), 108 | 'Andre - author@vinta.com.br') 109 | 110 | def test_form_valid_with_send_on_success_false(self): 111 | default_value = AuthorCreateView.templated_email_send_on_success 112 | AuthorCreateView.templated_email_send_on_success = False 113 | response = AuthorCreateView.as_view()(self.good_request) 114 | self.assertEqual(response.status_code, 302) 115 | self.assertEqual(Author.objects.count(), 1) 116 | self.assertEqual(len(mail.outbox), 0) 117 | AuthorCreateView.templated_email_send_on_success = default_value 118 | 119 | def test_form_invalid_with_not_send_on_failure(self): 120 | response = AuthorCreateView.as_view()(self.bad_request) 121 | self.assertEqual(response.status_code, 200) 122 | self.assertEqual(Author.objects.count(), 0) 123 | self.assertEqual(len(mail.outbox), 0) 124 | 125 | def test_form_invalid_with_send_on_failure(self): 126 | default_value = AuthorCreateView.templated_email_send_on_failure 127 | AuthorCreateView.templated_email_send_on_failure = True 128 | response = AuthorCreateView.as_view()(self.bad_request) 129 | self.assertEqual(response.status_code, 200) 130 | self.assertEqual(Author.objects.count(), 0) 131 | self.assertEqual(len(mail.outbox), 1) 132 | self.assertEqual(mail.outbox[0].alternatives[0][0].strip(), 133 | '* Enter a valid email address.') 134 | AuthorCreateView.templated_email_send_on_failure = default_value 135 | 136 | @override_settings(TEMPLATED_EMAIL_FROM_EMAIL='from@vinta.com.br') 137 | def test_from_email(self): 138 | AuthorCreateView.as_view()(self.good_request) 139 | self.assertEqual(len(mail.outbox), 1) 140 | self.assertEqual(mail.outbox[0].from_email, 'from@vinta.com.br') 141 | 142 | def test_from_email_with_templated_email_from_email(self): 143 | default_value = AuthorCreateView.templated_email_from_email 144 | AuthorCreateView.templated_email_from_email = 'from2@vinta.com.br' 145 | AuthorCreateView.as_view()(self.good_request) 146 | self.assertEqual(len(mail.outbox), 1) 147 | self.assertEqual(mail.outbox[0].from_email, 'from2@vinta.com.br') 148 | AuthorCreateView.templated_email_from_email = default_value 149 | -------------------------------------------------------------------------------- /tests/generic_views/views.py: -------------------------------------------------------------------------------- 1 | from django.views.generic.edit import CreateView 2 | 3 | from templated_email.generic_views import TemplatedEmailFormViewMixin 4 | from tests.generic_views.models import Author 5 | 6 | 7 | # This view send a welcome email to the author 8 | class AuthorCreateView(TemplatedEmailFormViewMixin, CreateView): 9 | model = Author 10 | fields = ['name', 'email'] 11 | templated_email_template_name = 'welcome' 12 | template_name = 'authors/create_author.html' 13 | success_url = '/create_author/' 14 | 15 | def templated_email_get_recipients(self, form): 16 | return [form.data['email']] 17 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | DATABASES = { 4 | 'default': { 5 | 'ENGINE': 'django.db.backends.sqlite3', 6 | 'NAME': ':memory:', 7 | } 8 | } 9 | 10 | INSTALLED_APPS = ( 11 | 'django.contrib.auth', 12 | 'django.contrib.contenttypes', 13 | 'templated_email', 14 | 'tests.generic_views', 15 | ) 16 | 17 | SECRET_KEY = "notimportant" 18 | 19 | TEMPLATES = [ 20 | { 21 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 22 | 'DIRS': [ 23 | 'tests/template_fixtures', 24 | ], 25 | 'APP_DIRS': True, 26 | 'OPTIONS': { 27 | 'context_processors': [ 28 | 'django.contrib.auth.context_processors.auth', 29 | 'django.template.context_processors.debug', 30 | 'django.template.context_processors.i18n', 31 | 'django.template.context_processors.media', 32 | 'django.template.context_processors.static', 33 | 'django.template.context_processors.tz', 34 | 'django.contrib.messages.context_processors.messages', 35 | ], 36 | }, 37 | }, 38 | ] 39 | 40 | ROOT_URLCONF = 'tests.test_urls' 41 | BASE_DIR = os.path.dirname(os.path.abspath(__file__)) 42 | MEDIA_ROOT = os.path.join(BASE_DIR, "tmp") 43 | MEDIA_URL = '/media/' 44 | -------------------------------------------------------------------------------- /tests/template_fixtures/templated_email/html_template.email: -------------------------------------------------------------------------------- 1 | {% block subject %}My subject for {{ username }}{% endblock %} 2 | {% block html %} 3 |

Hi {{full_name}},

4 | 5 |

You just signed up for my website, using: 6 |

7 |
username
{{username}}
8 |
join date
{{joindate}}
9 |
10 |

11 | 12 |

Thanks, you rock!

13 | {% endblock %} 14 | -------------------------------------------------------------------------------- /tests/template_fixtures/templated_email/inexistent_base.email: -------------------------------------------------------------------------------- 1 | {% extends 'foo' %} 2 | -------------------------------------------------------------------------------- /tests/template_fixtures/templated_email/inheritance_template.email: -------------------------------------------------------------------------------- 1 | {% extends 'templated_email/mixed_template.email' %} 2 | {% block subject %}Another subject for {{ username }}{% endblock %} 3 | 4 | {% block hello %} 5 |

Hello {{full_name}},

6 | {% endblock %} 7 | 8 | {% block actual_username %}Mr. {{ block.super }}{% endblock %} 9 | 10 | {% block thankyou %} 11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /tests/template_fixtures/templated_email/inline_image.email: -------------------------------------------------------------------------------- 1 | {% block subject %}With inline image{% endblock %} 2 | {% block html %} 3 | 4 | {% endblock %} 5 | -------------------------------------------------------------------------------- /tests/template_fixtures/templated_email/legacy.html: -------------------------------------------------------------------------------- 1 | {% block test %} 2 |

test

3 | {% endblock %} 4 | -------------------------------------------------------------------------------- /tests/template_fixtures/templated_email/legacy.txt: -------------------------------------------------------------------------------- 1 | {% block test %} 2 | test 3 | {% endblock %} 4 | -------------------------------------------------------------------------------- /tests/template_fixtures/templated_email/mixed_template.email: -------------------------------------------------------------------------------- 1 | {% block subject %}My subject for {{ username }}{% endblock %} 2 | {% block html %} 3 | {% block hello %} 4 |

Hi {{full_name}},

5 | {% endblock %} 6 | 7 |

You just signed up for my website, using: 8 |

9 | {% block username %} 10 |
username
{% block actual_username %}{{username}}{% endblock %}
11 | {% endblock %} 12 |
join date
{{joindate}}
13 |
14 |

15 | 16 | {% block thankyou %} 17 |

Thanks, you rock!

18 | {% endblock %} 19 | {% endblock %} 20 | {% block plain %} 21 | Hi, 22 | 23 | You just signed up for my website, using: 24 | username: {{ username }} 25 | join date: {{ joindate }} 26 | 27 | Thanks, you rock! 28 | {% endblock %} 29 | -------------------------------------------------------------------------------- /tests/template_fixtures/templated_email/multi-template.email: -------------------------------------------------------------------------------- 1 | {% block subject %}A subject{% endblock %} 2 | {% block plain %} 3 | Just to make sure the content is read 4 | {% endblock %} 5 | -------------------------------------------------------------------------------- /tests/template_fixtures/templated_email/plain_template.email: -------------------------------------------------------------------------------- 1 | {% block subject %}My subject for {{ username }}{% endblock %} 2 | {% block plain %} 3 | Hi, 4 | 5 | You just signed up for my website, using: 6 | username: {{ username }} 7 | join date: {{ joindate }} 8 | 9 | Thanks, you rock! 10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /tests/template_fixtures/templated_email/plain_template_without_subject.email: -------------------------------------------------------------------------------- 1 | {% block plain %} 2 | Hi, 3 | 4 | You just signed up for my website, using: 5 | username: {{ username }} 6 | join date: {{ joindate }} 7 | 8 | Thanks, you rock! 9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /tests/template_fixtures/templated_email/welcome.email: -------------------------------------------------------------------------------- 1 | {% block subject %}welcome{% endblock %} 2 | {% block html %} 3 | {% if form_errors %} 4 | {{ form_errors.email.as_text }} 5 | {% else %} 6 | {{ form_data.name }} - {{ form_data.email }} 7 | {% endif %} 8 | {% endblock %} 9 | -------------------------------------------------------------------------------- /tests/test_get_connection.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from templated_email import get_connection, backends 4 | 5 | 6 | class GetConnectionTestCase(TestCase): 7 | def test_default(self): 8 | connection = get_connection() 9 | 10 | self.assertIsInstance(connection, 11 | backends.vanilla_django.TemplateBackend) 12 | 13 | def test_class_name(self): 14 | klass = 'templated_email.backends.vanilla_django.TemplateBackend' 15 | 16 | connection = get_connection(klass) 17 | 18 | self.assertIsInstance(connection, 19 | backends.vanilla_django.TemplateBackend) 20 | 21 | def test_class_name_omitted(self): 22 | klass = 'templated_email.backends.vanilla_django' 23 | 24 | connection = get_connection(klass) 25 | 26 | self.assertIsInstance(connection, 27 | backends.vanilla_django.TemplateBackend) 28 | 29 | def test_class_instance(self): 30 | klass = backends.vanilla_django.TemplateBackend 31 | 32 | connection = get_connection(klass) 33 | 34 | self.assertIsInstance(connection, klass) 35 | 36 | def test_non_existing_module(self): 37 | klass = 'templated_email.backends.non_existing.NoBackend' 38 | 39 | self.assertRaises(ImportError, get_connection, klass) 40 | 41 | def test_non_existing_class(self): 42 | klass = 'templated_email.backends.vanilla_django.NoBackend' 43 | 44 | self.assertRaises(ImportError, get_connection, klass) 45 | -------------------------------------------------------------------------------- /tests/test_get_templated_mail.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from unittest.mock import patch 4 | 5 | from templated_email import get_templated_mail 6 | 7 | 8 | class GetTemplatedMailTestCase(TestCase): 9 | TEST_ARGS = ['a_template_name', {'context': 'content'}] 10 | TEST_KWARGS = { 11 | 'from_email': 'from@example.com', 12 | 'to': ['to@example.com'], 13 | 'cc': ['cc@example.com'], 14 | 'bcc': ['bcc@example.com'], 15 | 'headers': {'A_HEADER': 'foo'}, 16 | 'template_prefix': 'prefix', 17 | 'template_suffix': 'suffix', 18 | 'template_dir': 'dirp', 19 | 'file_extension': 'ext', 20 | 'create_link': False, 21 | } 22 | 23 | @patch('templated_email.TemplateBackend') 24 | def test_get_templated_mail_returns_response_of_get_email_message( 25 | self, mocked_backend): 26 | ret = get_templated_mail(*self.TEST_ARGS) 27 | self.assertTrue( 28 | ret is mocked_backend.return_value.get_email_message.return_value) 29 | 30 | @patch('templated_email.TemplateBackend') 31 | def test_called_get_email_message_from_vanilla_backend(self, mocked_backend): 32 | get_templated_mail(*self.TEST_ARGS) 33 | mocked_backend.return_value.get_email_message.assert_called_once() 34 | 35 | @patch('templated_email.TemplateBackend') 36 | def test_arguments_get_passsed_to_get_email_message(self, mocked_backend): 37 | get_templated_mail(*self.TEST_ARGS, **self.TEST_KWARGS) 38 | 39 | mocked_backend.assert_called_with(template_prefix='prefix', 40 | template_suffix='suffix') 41 | 42 | get_email_message = mocked_backend.return_value.get_email_message 43 | 44 | kwargs = dict(self.TEST_KWARGS) 45 | del kwargs['template_dir'] 46 | del kwargs['file_extension'] 47 | get_email_message.assert_called_with(*self.TEST_ARGS, **kwargs) 48 | 49 | @patch('templated_email.TemplateBackend') 50 | def test_arguments_get_email_message_fallback(self, mocked_backend): 51 | kwargs = dict(self.TEST_KWARGS) 52 | del kwargs['template_prefix'] 53 | del kwargs['template_suffix'] 54 | 55 | get_templated_mail(*self.TEST_ARGS, **kwargs) 56 | 57 | mocked_backend.assert_called_with(template_prefix=kwargs['template_dir'], 58 | template_suffix=kwargs['file_extension']) 59 | 60 | get_email_message = mocked_backend.return_value.get_email_message 61 | 62 | kwargs['template_prefix'] = kwargs.pop('template_dir') 63 | kwargs['template_suffix'] = kwargs.pop('file_extension') 64 | get_email_message.assert_called_with(*self.TEST_ARGS, **kwargs) 65 | -------------------------------------------------------------------------------- /tests/test_inline_image.py: -------------------------------------------------------------------------------- 1 | import base64 2 | from email.utils import unquote 3 | 4 | from django.test import TestCase 5 | 6 | from templated_email.backends.vanilla_django import TemplateBackend 7 | from templated_email import InlineImage 8 | 9 | from tests.utils import MockedNetworkTestCaseMixin 10 | 11 | imageb64 = ('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1B' 12 | 'MVEUAAACnej3aAAAAAXRSTlMAQObYZgAAAApJREFUCNdjYAAAAAIAAeIhvD' 13 | 'MAAAAASUVORK5CYII=') 14 | 15 | 16 | class GetMessageWithInlineMessageTestCase(MockedNetworkTestCaseMixin, TestCase): 17 | def setUp(self): 18 | self.backend = TemplateBackend() 19 | self.inline_image = InlineImage('foo.png', base64.b64decode(imageb64)) 20 | self.message = self.backend.get_email_message( 21 | 'inline_image.email', {'image_file': self.inline_image}, 22 | from_email='from@example.com', cc=['cc@example.com'], 23 | bcc=['bcc@example.com'], to=['to@example.com']) 24 | 25 | def test_cid_in_message(self): 26 | alternative_message = self.message.alternatives[0][0] 27 | self.assertIn('cid:%s' % unquote(self.inline_image._content_id), 28 | alternative_message) 29 | 30 | def test_image_in_attachments(self): 31 | mimage = self.message.attachments[0] 32 | attachment_content = base64.b64encode(mimage.get_payload(decode=True)) 33 | self.assertEqual(attachment_content.decode(), imageb64) 34 | -------------------------------------------------------------------------------- /tests/test_send_templated_mail.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from unittest.mock import patch, Mock 4 | 5 | from templated_email import send_templated_mail 6 | 7 | 8 | class SendTemplatedMailTestCase(TestCase): 9 | TEST_ARGS = ['a_template_name', 'from@example.com', ['to@example.com'], 10 | {'context': 'content'}] 11 | TEST_KWARGS = { 12 | 'cc': ['cc@example.com'], 13 | 'bcc': ['bcc@example.com'], 14 | 'fail_silently': True, 15 | 'headers': {'A_HEADER': 'foo'}, 16 | 'template_prefix': 'prefix', 17 | 'template_suffix': 'suffix', 18 | 'something': 'else', 19 | 'create_link': False, 20 | } 21 | 22 | def test_send_templated_mail_returns_send_response(self): 23 | mocked_connection = Mock() 24 | ret = send_templated_mail(*self.TEST_ARGS, connection=mocked_connection, 25 | **self.TEST_KWARGS) 26 | self.assertTrue(ret is mocked_connection.send.return_value) 27 | 28 | def test_with_connection_in_args(self): 29 | mocked_connection = Mock() 30 | send_templated_mail(*self.TEST_ARGS, connection=mocked_connection, 31 | **self.TEST_KWARGS) 32 | 33 | kwargs = dict(self.TEST_KWARGS) 34 | del kwargs['template_prefix'] 35 | del kwargs['template_suffix'] 36 | mocked_connection.send.assert_called_with(*self.TEST_ARGS, **kwargs) 37 | 38 | @patch('templated_email.get_connection') 39 | def test_without_connection_in_args(self, mocked_get_connection): 40 | send_templated_mail(*self.TEST_ARGS, **self.TEST_KWARGS) 41 | 42 | mocked_get_connection.assert_called_with(template_prefix='prefix', 43 | template_suffix='suffix') 44 | 45 | mocked_connection = mocked_get_connection.return_value 46 | kwargs = dict(self.TEST_KWARGS) 47 | del kwargs['template_prefix'] 48 | del kwargs['template_suffix'] 49 | mocked_connection.send.assert_called_with(*self.TEST_ARGS, **kwargs) 50 | -------------------------------------------------------------------------------- /tests/test_urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import include 2 | from django.urls import re_path 3 | 4 | urlpatterns = [ 5 | re_path(r'^', include('templated_email.urls', namespace='templated_email')), 6 | ] 7 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch, Mock 2 | 3 | from django.test import TestCase 4 | 5 | from templated_email import InlineImage 6 | from tests.utils import MockedNetworkTestCaseMixin 7 | 8 | 9 | class InlineMessageTestCase(MockedNetworkTestCaseMixin, TestCase): 10 | def setUp(self): 11 | self.inline_image = InlineImage('foo.png', 'content', 'png') 12 | 13 | def test_needs_two_args(self): 14 | with self.assertRaises(TypeError): 15 | InlineImage() 16 | with self.assertRaises(TypeError): 17 | InlineImage('foo') 18 | 19 | def test_has_no_cid(self): 20 | self.assertIsNone(self.inline_image._content_id) 21 | 22 | def test_generate_cid(self): 23 | str(self.inline_image) 24 | self.assertIsNotNone(self.inline_image._content_id) 25 | 26 | @patch('templated_email.utils.make_msgid', return_value='foo') 27 | def test_str(self, mocked): 28 | self.assertEqual(str(self.inline_image), 'cid:foo') 29 | 30 | @patch('templated_email.utils.make_msgid', return_value='foo') 31 | def test_should_cache_cid(self, mocked): 32 | str(self.inline_image) 33 | str(self.inline_image) 34 | mocked.assert_called_once() 35 | 36 | def test_changing_content_should_generate_new_cid(self): 37 | src_value = str(self.inline_image) 38 | cid = self.inline_image._content_id 39 | self.inline_image.content = 'content2' 40 | cid2 = self.inline_image._content_id 41 | src_value2 = str(self.inline_image) 42 | self.assertNotEqual(src_value, src_value2) 43 | cid3 = self.inline_image._content_id 44 | self.assertNotEqual(cid, cid2) 45 | self.assertNotEqual(cid2, cid3) 46 | 47 | def test_attach_to_message(self): 48 | message = Mock() 49 | self.inline_image.attach_to_message(message) 50 | mimeimage = message.attach.call_args[0][0] 51 | self.assertEqual(mimeimage.get('Content-ID'), 52 | self.inline_image._content_id) 53 | self.assertEqual(mimeimage.get('Content-Disposition'), 54 | 'inline; filename="foo.png"') 55 | -------------------------------------------------------------------------------- /tests/test_views.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from django.urls import reverse 4 | from django.test import TestCase 5 | from django.test import Client 6 | 7 | from templated_email.models import SavedEmail 8 | 9 | 10 | class ShowEmailViewTestCase(TestCase): 11 | 12 | def setUp(self): 13 | self.uuid = uuid.uuid4() 14 | self.saved_email = SavedEmail.objects.create(uuid=self.uuid, content='foo') 15 | self.url = '/email/%s/' % self.uuid 16 | self.url_hex = '/email/%s/' % self.uuid.hex 17 | self.client = Client() 18 | 19 | def test_get(self): 20 | response = self.client.get(self.url) 21 | self.assertEqual(response.status_code, 200) 22 | 23 | def test_get_hex(self): 24 | response = self.client.get(self.url_hex) 25 | self.assertEqual(response.status_code, 200) 26 | 27 | def test_get_non_valid(self): 28 | response = self.client.get('/email/bar/') 29 | self.assertEqual(response.status_code, 404) 30 | 31 | def test_url(self): 32 | self.assertEqual( 33 | reverse('templated_email:show_email', 34 | kwargs={'uuid': self.saved_email.uuid}), 35 | self.url) 36 | 37 | def test_url_hex(self): 38 | self.assertEqual( 39 | reverse('templated_email:show_email', 40 | kwargs={'uuid': self.saved_email.uuid.hex}), 41 | self.url_hex) 42 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | import unittest.mock 2 | 3 | 4 | class MockedNetworkTestCaseMixin(object): 5 | # getfqdn can be too slow, mock it for speed. 6 | # See: https://code.djangoproject.com/ticket/24380 7 | @classmethod 8 | def setUpClass(cls): 9 | cls.getfqdn_patcher = unittest.mock.patch( 10 | 'django.core.mail.utils.socket.getfqdn', 11 | return_value='vinta.local') 12 | cls.getfqdn_patcher.start() 13 | super(MockedNetworkTestCaseMixin, cls).setUpClass() 14 | 15 | @classmethod 16 | def tearDownClass(cls): 17 | cls.getfqdn_patcher.stop() 18 | super(MockedNetworkTestCaseMixin, cls).tearDownClass() 19 | -------------------------------------------------------------------------------- /tox-requirements.txt: -------------------------------------------------------------------------------- 1 | django-anymail 2 | django-pytest 3 | django-render-block 4 | html2text 5 | pytest 6 | pytest-django 7 | pytest-pythonpath 8 | tox 9 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | linux-py39-django42 4 | linux-py{310,311,312,313}-django{42,50,51} 5 | 6 | [gh-actions] 7 | python = 8 | 3.9: py39 9 | 3.10: py310 10 | 3.11: py311 11 | 3.12: py312 12 | 3.13: py313 13 | 14 | 15 | [gh-actions:env] 16 | OS = 17 | ubuntu-latest: linux 18 | DJANGO = 19 | 4.2: django42 20 | 5.0: django50 21 | 5.1: django51 22 | 23 | [testenv] 24 | commands= 25 | py.test --cov=templated_email tests/ 26 | deps= 27 | -rtox-requirements.txt 28 | pytest-cov 29 | django42: django~=4.2 30 | django50: django~=5.0 31 | django51: django~=5.1 32 | --------------------------------------------------------------------------------