├── tests ├── attachments │ ├── file.txt │ ├── file_txt │ ├── file_txt.png │ ├── file.png │ ├── file_png │ ├── file_png.txt │ └── file.eml ├── test_initialization.py ├── test_global_state.py ├── __init__.py ├── test_connection.py ├── test_backend.py └── test_mail.py ├── docs ├── changelog.md ├── contributing.md ├── img │ ├── favicon.png │ ├── jetbrains-variant-4.png │ └── icon-white.svg └── index.md ├── flask_mailman ├── backends │ ├── __init__.py │ ├── dummy.py │ ├── locmem.py │ ├── console.py │ ├── base.py │ ├── file.py │ └── smtp.py ├── utils.py ├── __init__.py └── message.py ├── .coveragerc ├── .github ├── ISSUE_TEMPLATE.md └── workflows │ ├── preview.yml │ ├── dev.yml │ └── release.yml ├── .editorconfig ├── makefile ├── setup.cfg ├── mkdocs.yml ├── .pre-commit-config.yaml ├── tox.ini ├── LICENSE ├── README.md ├── .gitignore ├── CHANGELOG.md ├── pyproject.toml └── CONTRIBUTING.md /tests/attachments/file.txt: -------------------------------------------------------------------------------- 1 | flask/flask -------------------------------------------------------------------------------- /tests/attachments/file_txt: -------------------------------------------------------------------------------- 1 | flask/flask -------------------------------------------------------------------------------- /tests/attachments/file_txt.png: -------------------------------------------------------------------------------- 1 | django/django -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | {% 2 | include-markdown "../CHANGELOG.md" 3 | %} 4 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | {% 2 | include-markdown "../CONTRIBUTING.md" 3 | %} 4 | -------------------------------------------------------------------------------- /flask_mailman/backends/__init__.py: -------------------------------------------------------------------------------- 1 | # Mail backends shipped with Django. 2 | -------------------------------------------------------------------------------- /docs/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/waynerv/flask-mailman/HEAD/docs/img/favicon.png -------------------------------------------------------------------------------- /tests/attachments/file.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/waynerv/flask-mailman/HEAD/tests/attachments/file.png -------------------------------------------------------------------------------- /tests/attachments/file_png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/waynerv/flask-mailman/HEAD/tests/attachments/file_png -------------------------------------------------------------------------------- /tests/attachments/file_png.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/waynerv/flask-mailman/HEAD/tests/attachments/file_png.txt -------------------------------------------------------------------------------- /docs/img/jetbrains-variant-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/waynerv/flask-mailman/HEAD/docs/img/jetbrains-variant-4.png -------------------------------------------------------------------------------- /tests/test_initialization.py: -------------------------------------------------------------------------------- 1 | from tests import TestCase 2 | 3 | 4 | class TestInitialization(TestCase): 5 | def test_init_mail(self): 6 | mail = self.mail.init_mail(self.app.config, self.app.testing) 7 | 8 | self.assertEqual(self.mail.state.__dict__, mail.__dict__) 9 | -------------------------------------------------------------------------------- /flask_mailman/backends/dummy.py: -------------------------------------------------------------------------------- 1 | """ 2 | Dummy email backend that does nothing. 3 | """ 4 | 5 | from flask_mailman.backends.base import BaseEmailBackend 6 | 7 | 8 | class EmailBackend(BaseEmailBackend): 9 | def send_messages(self, email_messages): 10 | return len(list(email_messages)) 11 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | # uncomment the following to omit files during running 3 | #omit = 4 | [report] 5 | exclude_lines = 6 | pragma: no cover 7 | def __repr__ 8 | if self.debug: 9 | if settings.DEBUG 10 | raise AssertionError 11 | raise NotImplementedError 12 | if 0: 13 | if __name__ == .__main__.: 14 | def main 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * Flask-Mailman version: 2 | * Python version: 3 | * Operating System: 4 | 5 | ### Description 6 | 7 | Describe what you were trying to get done. 8 | Tell us what happened, what went wrong, and what you expected to happen. 9 | 10 | ### What I Did 11 | 12 | ``` 13 | Paste the command(s) you ran and the output. 14 | If there was a crash, please include the traceback here. 15 | ``` 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [*.bat] 14 | indent_style = tab 15 | end_of_line = crlf 16 | 17 | [LICENSE] 18 | insert_final_newline = false 19 | 20 | [Makefile] 21 | indent_style = tab 22 | 23 | [*.{yml, yaml}] 24 | indent_size = 2 25 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | sources = flask_mailman 2 | 3 | .PHONY: test format lint unittest coverage pre-commit clean 4 | test: format lint unittest 5 | 6 | format: 7 | isort $(sources) tests 8 | black $(sources) tests 9 | 10 | lint: 11 | flake8 $(sources) tests 12 | 13 | unittest: 14 | pytest 15 | 16 | coverage: 17 | pytest -s --cov=$(sources) --cov-append --cov-report term-missing tests 18 | 19 | pre-commit: 20 | pre-commit run --all-files 21 | 22 | clean: 23 | rm -rf .pytest_cache 24 | rm -rf *.egg-info 25 | rm -rf .tox dist site 26 | rm -rf coverage.xml .coverage 27 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.3.0 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:pyproject.toml] 7 | search = version = "{current_version}" 8 | replace = version = "{new_version}" 9 | 10 | [flake8] 11 | max-line-length = 120 12 | max-complexity = 18 13 | ignore = E203, E266, W503 14 | docstring-convention = google 15 | per-file-ignores = __init__.py:F401 16 | exclude = .git, 17 | __pycache__, 18 | setup.py, 19 | build, 20 | dist, 21 | docs, 22 | releases, 23 | .venv, 24 | .tox, 25 | .mypy_cache, 26 | .pytest_cache, 27 | .vscode, 28 | .github, 29 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Flask-Mailman 2 | site_url: https://waynerv.github.io/flask-mailman 3 | repo_url: https://github.com/waynerv/flask-mailman 4 | repo_name: waynerv/flask-mailman 5 | nav: 6 | - Tutorial: index.md 7 | - Contributing: contributing.md 8 | - Changelog: changelog.md 9 | theme: 10 | name: material 11 | palette: 12 | scheme: preference 13 | primary: indigo 14 | accent: indigo 15 | icon: 16 | repo: fontawesome/brands/github-alt 17 | logo: img/icon-white.svg 18 | favicon: img/favicon.png 19 | language: en 20 | markdown_extensions: 21 | - pymdownx.highlight: 22 | linenums: false 23 | linenums_style: pymdownx.inline 24 | - pymdownx.superfences 25 | - pymdownx.inlinehilite 26 | plugins: 27 | - include-markdown 28 | - search: 29 | lang: en 30 | - mkdocstrings 31 | watch: 32 | - docs 33 | - flask_mailman 34 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/Lucas-C/pre-commit-hooks 3 | rev: v1.1.9 4 | hooks: 5 | - id: forbid-crlf 6 | - id: remove-crlf 7 | - repo: https://github.com/pre-commit/pre-commit-hooks 8 | rev: v3.4.0 9 | hooks: 10 | - id: trailing-whitespace 11 | - id: end-of-file-fixer 12 | - id: check-merge-conflict 13 | - id: check-yaml 14 | args: [ --unsafe ] 15 | - repo: https://github.com/pre-commit/mirrors-isort 16 | rev: v5.8.0 17 | hooks: 18 | - id: isort 19 | args: [ "--filter-files" ] 20 | - repo: https://github.com/ambv/black 21 | rev: 21.5b1 22 | hooks: 23 | - id: black 24 | language_version: python3.8 25 | - repo: https://github.com/pycqa/flake8 26 | rev: 3.9.2 27 | hooks: 28 | - id: flake8 29 | additional_dependencies: [ flake8-typing-imports==1.10.0 ] 30 | -------------------------------------------------------------------------------- /flask_mailman/backends/locmem.py: -------------------------------------------------------------------------------- 1 | """ 2 | Backend for test environment. 3 | """ 4 | from flask_mailman.backends.base import BaseEmailBackend 5 | 6 | 7 | class EmailBackend(BaseEmailBackend): 8 | """ 9 | An email backend for use during test sessions. 10 | 11 | The test connection stores email messages in a dummy outbox, 12 | rather than sending them out on the wire. 13 | 14 | The dummy outbox is accessible through the outbox instance attribute. 15 | """ 16 | 17 | def __init__(self, *args, **kwargs): 18 | super().__init__(*args, **kwargs) 19 | if not hasattr(self.mailman, 'outbox'): 20 | self.mailman.outbox = [] 21 | 22 | def send_messages(self, messages): 23 | """Redirect messages to the dummy outbox""" 24 | msg_count = 0 25 | for message in messages: # .message() triggers header validation 26 | message.message() 27 | self.mailman.outbox.append(message) 28 | msg_count += 1 29 | return msg_count 30 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | isolated_build = true 3 | envlist = py38, py39, py310, py311, py312, format, lint, build 4 | 5 | [gh-actions] 6 | python = 7 | 3.13: py313 8 | 3.12: py312 9 | 3.11: py311 10 | 3.10: py310 11 | 3.9: py39 12 | 3.8: py38, format, lint, build 13 | 14 | [testenv] 15 | allowlist_externals = pytest 16 | extras = 17 | test 18 | passenv = * 19 | setenv = 20 | PYTHONPATH = {toxinidir} 21 | PYTHONWARNINGS = ignore 22 | commands = 23 | pytest -s --cov=flask_mailman --cov-append --cov-report=xml --cov-report term-missing tests 24 | 25 | [testenv:format] 26 | allowlist_externals = 27 | isort 28 | black 29 | extras = 30 | test 31 | commands = 32 | isort flask_mailman 33 | black flask_mailman tests 34 | 35 | [testenv:lint] 36 | allowlist_externals = 37 | flake8 38 | extras = 39 | test 40 | commands = 41 | flake8 flask_mailman tests 42 | 43 | [testenv:build] 44 | allowlist_externals = 45 | poetry 46 | mkdocs 47 | twine 48 | extras = 49 | doc 50 | dev 51 | commands = 52 | poetry build 53 | mkdocs build 54 | twine check dist/* 55 | -------------------------------------------------------------------------------- /tests/test_global_state.py: -------------------------------------------------------------------------------- 1 | from email.mime.text import MIMEText 2 | from tests import TestCase 3 | 4 | 5 | class PythonGlobalState(TestCase): 6 | """ 7 | UTF-8 text parts shouldn't pollute global email Python package charset registry when 8 | django.mail.message is imported. 9 | """ 10 | 11 | def test_utf8(self): 12 | txt = MIMEText("UTF-8 encoded body", "plain", "utf-8") 13 | self.assertIn("Content-Transfer-Encoding: base64", txt.as_string()) 14 | 15 | def test_7bit(self): 16 | txt = MIMEText("Body with only ASCII characters.", "plain", "utf-8") 17 | self.assertIn("Content-Transfer-Encoding: base64", txt.as_string()) 18 | 19 | def test_8bit_latin(self): 20 | txt = MIMEText("Body with latin characters: àáä.", "plain", "utf-8") 21 | self.assertIn("Content-Transfer-Encoding: base64", txt.as_string()) 22 | 23 | def test_8bit_non_latin(self): 24 | txt = MIMEText( 25 | "Body with non latin characters: А Б В Г Д Е Ж Ѕ З И І К Л М Н О П.", 26 | "plain", 27 | "utf-8", 28 | ) 29 | self.assertIn("Content-Transfer-Encoding: base64", txt.as_string()) 30 | -------------------------------------------------------------------------------- /flask_mailman/backends/console.py: -------------------------------------------------------------------------------- 1 | """ 2 | Email backend that writes messages to console instead of sending them. 3 | """ 4 | import sys 5 | import threading 6 | 7 | from flask_mailman.backends.base import BaseEmailBackend 8 | 9 | 10 | class EmailBackend(BaseEmailBackend): 11 | def __init__(self, *args, **kwargs): 12 | self.stream = kwargs.pop('stream', sys.stdout) 13 | self._lock = threading.RLock() 14 | super().__init__(*args, **kwargs) 15 | 16 | def write_message(self, message): 17 | msg = message.message() 18 | msg_data = msg.as_bytes() 19 | charset = msg.get_charset().get_output_charset() if msg.get_charset() else 'utf-8' 20 | msg_data = msg_data.decode(charset) 21 | self.stream.write('%s\n' % msg_data) 22 | self.stream.write('-' * 79) 23 | self.stream.write('\n') 24 | 25 | def send_messages(self, email_messages): 26 | """Write all messages to the stream in a thread-safe way.""" 27 | if not email_messages: 28 | return 29 | msg_count = 0 30 | with self._lock: 31 | try: 32 | stream_created = self.open() 33 | for message in email_messages: 34 | self.write_message(message) 35 | self.stream.flush() # flush after each message 36 | msg_count += 1 37 | if stream_created: 38 | self.close() 39 | except Exception: 40 | if not self.fail_silently: 41 | raise 42 | return msg_count 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 Xie Wei. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of Flask-Mailman nor the names of its contributors may be used 15 | to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /.github/workflows/preview.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: stage & preview workflow 4 | 5 | # Controls when the action will run. 6 | on: 7 | # Triggers the workflow on push or pull request events but only for the master branch 8 | push: 9 | branches: [ master, main ] 10 | 11 | # Allows you to run this workflow manually from the Actions tab 12 | workflow_dispatch: 13 | 14 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 15 | jobs: 16 | publish_dev_build: 17 | runs-on: ubuntu-latest 18 | 19 | strategy: 20 | matrix: 21 | python-versions: [ 3.12 ] 22 | 23 | steps: 24 | - uses: actions/checkout@v2 25 | - uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0 26 | with: 27 | python-version: ${{ matrix.python-versions }} 28 | 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | pip install poetry tox tox-gh-actions 33 | 34 | - name: test with tox 35 | run: 36 | tox 37 | 38 | - name: Build wheels and source tarball 39 | run: | 40 | rm -rf dist/ 41 | poetry version $(poetry version --short)-dev.$GITHUB_RUN_NUMBER 42 | poetry version --short 43 | poetry build 44 | 45 | - name: publish to Test PyPI 46 | uses: pypa/gh-action-pypi-publish@master 47 | with: 48 | user: __token__ 49 | password: ${{ secrets.TEST_PYPI_API_TOKEN}} 50 | repository_url: https://test.pypi.org/legacy/ 51 | skip_existing: true 52 | -------------------------------------------------------------------------------- /.github/workflows/dev.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: dev workflow 4 | 5 | # Controls when the action will run. 6 | on: 7 | # Triggers the workflow on push or pull request events but only for the master branch 8 | push: 9 | branches: [ master, main ] 10 | pull_request: 11 | branches: [ master, main ] 12 | 13 | # Allows you to run this workflow manually from the Actions tab 14 | workflow_dispatch: 15 | 16 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 17 | jobs: 18 | # This workflow contains a single job called "test" 19 | test: 20 | # The type of runner that the job will run on 21 | strategy: 22 | matrix: 23 | python-versions: ['3.8', '3.9', '3.10', '3.11', '3.12'] 24 | os: [ubuntu-latest, macos-latest, windows-latest] 25 | runs-on: ${{ matrix.os }} 26 | 27 | # Steps represent a sequence of tasks that will be executed as part of the job 28 | steps: 29 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 30 | - uses: actions/checkout@v2 31 | - uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0 32 | with: 33 | python-version: ${{ matrix.python-versions }} 34 | 35 | - name: Install dependencies 36 | run: | 37 | python -m pip install --upgrade pip 38 | pip install poetry tox tox-gh-actions 39 | 40 | - name: test with tox 41 | run: 42 | tox 43 | 44 | - name: list files 45 | run: ls -l . 46 | 47 | - uses: codecov/codecov-action@v3 48 | with: 49 | files: coverage.xml 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Flask-Mailman 2 | 3 | ![PyPI](https://img.shields.io/pypi/v/flask-mailman?color=blue) 4 | ![PyPI - Downloads](https://img.shields.io/pypi/dm/flask-mailman?color=brightgreen) 5 | [![dev workflow](https://github.com/waynerv/flask-mailman/actions/workflows/dev.yml/badge.svg?branch=master)](https://github.com/waynerv/flask-mailman/actions/workflows/dev.yml) 6 | ![GitHub commits since latest release (by SemVer)](https://img.shields.io/github/commits-since/waynerv/flask-mailman/latest?color=cyan) 7 | ![PyPI - License](https://img.shields.io/pypi/l/flask-mailman?color=blue) 8 | [![codecov](https://codecov.io/gh/waynerv/flask-mailman/graph/badge.svg?token=bCkkuXW4dX)](https://codecov.io/gh/waynerv/flask-mailman) 9 | 10 | Flask-Mailman is a Flask extension providing simple email sending capabilities. 11 | 12 | It was meant to replace unmaintained Flask-Mail with a better warranty and more features. 13 | 14 | ## Usage 15 | 16 | Flask-Mail ported Django's email implementation to your Flask applications, which may be the best mail sending implementation that's available for python. 17 | 18 | The way of using this extension is almost the same as Django. 19 | 20 | Documentation: https://waynerv.github.io/flask-mailman. 21 | 22 | **Note: A few breaking changes have been made in v0.2.0 version** to ensure that API of this extension is basically the same as Django. 23 | Users migrating from Flask-Mail should upgrade with caution. 24 | 25 | ## Credits 26 | 27 | Thanks to [Jetbrains](https://jb.gg/OpenSource) for providing an Open Source license for this project. 28 | 29 | [![Jetbrains Logo](docs/img/jetbrains-variant-4.png)](www.jetbrains.com) 30 | 31 | Build tools and workflows of this project was inspired by [waynerv/cookiecutter-pypackage](https://github.com/waynerv/cookiecutter-pypackage) project template. 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # macOS 2 | .DS_Store 3 | .AppleDouble 4 | .LSOverride 5 | 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | env/ 17 | build/ 18 | develop-eggs/ 19 | dist/ 20 | downloads/ 21 | eggs/ 22 | .eggs/ 23 | lib/ 24 | lib64/ 25 | parts/ 26 | sdist/ 27 | var/ 28 | wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # Jupyter Notebook 77 | .ipynb_checkpoints 78 | 79 | # pyenv 80 | .python-version 81 | 82 | # celery beat schedule file 83 | celerybeat-schedule 84 | 85 | # SageMath parsed files 86 | *.sage.py 87 | 88 | # dotenv 89 | .env 90 | 91 | # virtualenv 92 | .venv 93 | venv/ 94 | ENV/ 95 | 96 | # Spyder project settings 97 | .spyderproject 98 | .spyproject 99 | 100 | # Rope project settings 101 | .ropeproject 102 | 103 | # mkdocs documentation 104 | /site 105 | 106 | # mypy 107 | .mypy_cache/ 108 | 109 | # IDE settings 110 | .vscode/ 111 | .idea/ 112 | 113 | # mkdocs build dir 114 | site/ 115 | -------------------------------------------------------------------------------- /tests/attachments/file.eml: -------------------------------------------------------------------------------- 1 | MIME-Version: 1.0 2 | Received: by 10.220.191.194 with HTTP; Wed, 11 May 2011 12:27:12 -0700 (PDT) 3 | Date: Wed, 11 May 2011 13:27:12 -0600 4 | Delivered-To: jncjkq@gmail.com 5 | Message-ID: 6 | Subject: Test 7 | From: Bill Jncjkq 8 | To: bookmarks@jncjkq.net 9 | Content-Type: multipart/mixed; boundary=bcaec54eecc63acce904a3050f79 10 | 11 | --bcaec54eecc63acce904a3050f79 12 | Content-Type: multipart/alternative; boundary=bcaec54eecc63acce604a3050f77 13 | 14 | --bcaec54eecc63acce604a3050f77 15 | Content-Type: text/plain; charset=ISO-8859-1 16 | 17 | -- 18 | Bill Jncjkq 19 | 20 | --bcaec54eecc63acce604a3050f77 21 | Content-Type: text/html; charset=ISO-8859-1 22 | 23 |
--
Bill Jncjkq
24 | 25 | --bcaec54eecc63acce604a3050f77-- 26 | --bcaec54eecc63acce904a3050f79 27 | Content-Type: text/html; charset=US-ASCII; name="bookmarks-really-short.html" 28 | Content-Disposition: attachment; filename="bookmarks-really-short.html" 29 | Content-Transfer-Encoding: base64 30 | X-Attachment-Id: f_gnknv6u70 31 | 32 | PCFET0NUWVBFIE5FVFNDQVBFLUJvb2ttYXJrLWZpbGUtMT4KCTxIVE1MPgoJPE1FVEEgSFRUUC1F 33 | UVVJVj0iQ29udGVudC1UeXBlIiBDT05URU5UPSJ0ZXh0L2h0bWw7IGNoYXJzZXQ9VVRGLTgiPgoJ 34 | PFRpdGxlPkJvb2ttYXJrczwvVGl0bGU+Cgk8SDE+Qm9va21hcmtzPC9IMT4KCQk8RFQ+PEgzIEZP 35 | TERFRD5UZWNoIE5ld3M8L0gzPgoJCTxETD48cD4KCQkJPERUPjxBIEhSRUY9Imh0dHA6Ly93d3cu 36 | Y25ldC5jb20vIj5DTmV0PC9BPgoJCQk8RFQ+PEEgSFJFRj0iaHR0cDovL3d3dy53aXJlZC5jb20v 37 | Ij5XaXJlZCBOZXdzPC9BPgoJCTwvREw+PHA+CgkJPERUPjxIMyBGT0xERUQ+VG9vbHMgYW5kIFJl 38 | ZmVyZW5jZTwvSDM+CgkJPERMPjxwPgoJCQk8RFQ+PEEgSFJFRj0iaHR0cDovL3d3dy5tb25zdGVy 39 | LmNvbS8iPk1vbnN0ZXIuY29tPC9BPgoJCQk8RFQ+PEEgSFJFRj0iaHR0cDovL3d3dy53ZWJtZC5j 40 | b20vIj5XZWJNRDwvQT4KCQk8L0RMPjxwPgoJCTxEVD48SDMgRk9MREVEPlRyYXZlbDwvSDM+CgkJ 41 | PERMPjxwPgoJCQk8RFQ+PEEgSFJFRj0iaHR0cDovL2ZvZG9ycy5jb20vIj5Gb2RvcnM8L0E+CgkJ 42 | CTxEVD48QSBIUkVGPSJodHRwOi8vd3d3LnRyYXZlbG9jaXR5LmNvbS8iPlRyYXZlbG9jaXR5PC9B 43 | PgoJCTwvREw+PHA+Cgk8L0RMPjxwPgo8L0hUTUw+ 44 | --bcaec54eecc63acce904a3050f79-- -------------------------------------------------------------------------------- /flask_mailman/backends/base.py: -------------------------------------------------------------------------------- 1 | """Base email backend class.""" 2 | from flask import current_app 3 | 4 | 5 | class BaseEmailBackend: 6 | """ 7 | Base class for email backend implementations. 8 | 9 | Subclasses must at least overwrite send_messages(). 10 | 11 | open() and close() can be called indirectly by using a backend object as a 12 | context manager: 13 | 14 | with backend as connection: 15 | # do something with connection 16 | pass 17 | """ 18 | 19 | def __init__(self, mailman=None, fail_silently=False, **kwargs): 20 | self.fail_silently = fail_silently 21 | try: 22 | self.mailman = mailman or current_app.extensions['mailman'] 23 | except KeyError: 24 | raise RuntimeError("The current application was not configured with Flask-Mailman") 25 | 26 | def open(self): 27 | """ 28 | Open a network connection. 29 | 30 | This method can be overwritten by backend implementations to 31 | open a network connection. 32 | 33 | It's up to the backend implementation to track the status of 34 | a network connection if it's needed by the backend. 35 | 36 | This method can be called by applications to force a single 37 | network connection to be used when sending mails. See the 38 | send_messages() method of the SMTP backend for a reference 39 | implementation. 40 | 41 | The default implementation does nothing. 42 | """ 43 | pass 44 | 45 | def close(self): 46 | """Close a network connection.""" 47 | pass 48 | 49 | def __enter__(self): 50 | try: 51 | self.open() 52 | except Exception: 53 | self.close() 54 | raise 55 | return self 56 | 57 | def __exit__(self, exc_type, exc_value, traceback): 58 | self.close() 59 | 60 | def send_messages(self, email_messages): 61 | """ 62 | Send one or more EmailMessage objects and return the number of email 63 | messages sent. 64 | """ 65 | raise NotImplementedError('subclasses of BaseEmailBackend must override send_messages() method') 66 | -------------------------------------------------------------------------------- /flask_mailman/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Email message and email sending related helper functions. 3 | """ 4 | import datetime 5 | import socket 6 | from decimal import Decimal 7 | 8 | 9 | # Cache the hostname, but do it lazily: socket.getfqdn() can take a couple of 10 | # seconds, which slows down the restart of the server. 11 | class CachedDnsName: 12 | def __str__(self): 13 | return self.get_fqdn() 14 | 15 | def get_fqdn(self): 16 | if not hasattr(self, '_fqdn'): 17 | self._fqdn = punycode(socket.getfqdn()) 18 | return self._fqdn 19 | 20 | 21 | DNS_NAME = CachedDnsName() 22 | 23 | 24 | class FlaskUnicodeDecodeError(UnicodeDecodeError): 25 | def __init__(self, obj, *args): 26 | self.obj = obj 27 | super().__init__(*args) 28 | 29 | def __str__(self): 30 | return '%s. You passed in %r (%s)' % (super().__str__(), self.obj, type(self.obj)) 31 | 32 | 33 | _PROTECTED_TYPES = ( 34 | type(None), 35 | int, 36 | float, 37 | Decimal, 38 | datetime.datetime, 39 | datetime.date, 40 | datetime.time, 41 | ) 42 | 43 | 44 | def is_protected_type(obj): 45 | """Determine if the object instance is of a protected type. 46 | 47 | Objects of protected types are preserved as-is when passed to 48 | force_text(strings_only=True). 49 | """ 50 | return isinstance(obj, _PROTECTED_TYPES) 51 | 52 | 53 | def force_str(s, encoding='utf-8', strings_only=False, errors='strict'): 54 | """ 55 | Similar to smart_str(), except that lazy instances are resolved to 56 | strings, rather than kept as lazy objects. 57 | 58 | If strings_only is True, don't convert (some) non-string-like objects. 59 | """ 60 | # Handle the common case first for performance reasons. 61 | if issubclass(type(s), str): 62 | return s 63 | if strings_only and is_protected_type(s): 64 | return s 65 | try: 66 | if isinstance(s, bytes): 67 | s = str(s, encoding, errors) 68 | else: 69 | s = str(s) 70 | except UnicodeDecodeError as e: 71 | raise FlaskUnicodeDecodeError(s, *e.args) 72 | return s 73 | 74 | 75 | def punycode(domain): 76 | """Return the Punycode of the given domain if it's non-ASCII.""" 77 | return domain.encode('idna').decode('ascii') 78 | -------------------------------------------------------------------------------- /flask_mailman/backends/file.py: -------------------------------------------------------------------------------- 1 | """Email backend that writes messages to a file.""" 2 | 3 | import datetime 4 | import os 5 | import random 6 | 7 | from flask_mailman.backends.console import EmailBackend as ConsoleEmailBackend 8 | 9 | 10 | class ImproperlyConfigured(Exception): 11 | """Application is somehow improperly configured""" 12 | 13 | pass 14 | 15 | 16 | class EmailBackend(ConsoleEmailBackend): 17 | def __init__(self, *args, file_path=None, **kwargs): 18 | # Since we're using the console-based backend as a base, 19 | # force the stream to be None, so we don't default to stdout 20 | kwargs['stream'] = None 21 | super().__init__(*args, **kwargs) 22 | self._fname = None 23 | if file_path is not None: 24 | self.file_path = file_path 25 | else: 26 | self.file_path = self.mailman.file_path 27 | self.file_path = os.path.abspath(self.file_path) 28 | try: 29 | os.makedirs(self.file_path, exist_ok=True) 30 | except FileExistsError: 31 | raise ImproperlyConfigured( 32 | 'Path for saving email messages exists, but is not a directory: %s' % self.file_path 33 | ) 34 | except OSError as err: 35 | raise ImproperlyConfigured( 36 | 'Could not create directory for saving email messages: %s (%s)' % (self.file_path, err) 37 | ) 38 | # Make sure that self.file_path is writable. 39 | if not os.access(self.file_path, os.W_OK): 40 | raise ImproperlyConfigured('Could not write to directory: %s' % self.file_path) 41 | 42 | def write_message(self, message): 43 | self.stream.write(message.message().as_bytes() + b'\n') 44 | self.stream.write(b'-' * 79) 45 | self.stream.write(b'\n') 46 | 47 | def _get_filename(self): 48 | """Return a unique file name.""" 49 | if self._fname is None: 50 | timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") 51 | fname = "%s-%s.log" % (timestamp, random.randrange(int(1e15))) 52 | self._fname = os.path.join(self.file_path, fname) 53 | return self._fname 54 | 55 | def open(self): 56 | if self.stream is None: 57 | self.stream = open(self._get_filename(), 'ab') 58 | return True 59 | return False 60 | 61 | def close(self): 62 | try: 63 | if self.stream is not None: 64 | self.stream.close() 65 | finally: 66 | self.stream = None 67 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [1.1.1] - 2024-07-06 4 | 5 | - Fix SafeMIMEText.set_payload() crash on Python 3.13 ([#80](https://github.com/waynerv/flask-mailman/pull/80)). 6 | 7 | ## [1.1.0] - 2024-4-22 8 | 9 | - Add configuration key `MAIL_SEND_OPTIONS` to support setting `mail_options` for `smtplib.SMTP.send_mail` 10 | (e.g. `SMTPUTF8`) ([#61](https://github.com/waynerv/flask-mailman/pull/61)). 11 | - Pre-encodes FQDN str with punycode to improve compatibility ([#66](https://github.com/waynerv/flask-mailman/pull/66)). 12 | - Migrates as many as possible test cases from Django mail module ([#64](https://github.com/waynerv/flask-mailman/pull/64)). 13 | - Improve way of populating smtp key/cert to avoid TypeError in py>=12 ([#68](https://github.com/waynerv/flask-mailman/pull/68)). 14 | 15 | ## [1.0.0] - 2023-11-04 16 | 17 | - Drop Python 3.6 support. 18 | - Fix compatibility issue with Python 3.10 19 | ([#31](https://github.com/waynerv/flask-mailman/pull/31)). 20 | - Fix the log file generation issue to ensure that the log filename is random 21 | ([#30](https://github.com/waynerv/flask-mailman/pull/30)). 22 | - Fix compatibility issue with Python 3.12 23 | ([#56](https://github.com/waynerv/flask-mailman/issues/56)). 24 | - Support passing `from_email` in tuple format to `send_mail()` function 25 | ([#35](https://github.com/waynerv/flask-mailman/issues/35)). 26 | 27 | ## [0.3.0] - 2021-08-08 28 | 29 | ### Added 30 | 31 | - Add support for custom email backend. 32 | 33 | ## [0.2.4] - 2021-06-15 34 | 35 | ### Added 36 | 37 | - Tox and GitHub workflows to run test, staging and release automatically. 38 | 39 | ## [0.2.3] 40 | 41 | ### Added 42 | 43 | - Add support for Flask 2.0 44 | 45 | ### Removed 46 | 47 | - Drop support for Python 3.5(due to flask upgrade). 48 | 49 | ## [0.2.2] 50 | 51 | ### Changed 52 | 53 | - Set `None` as the default value for the `from_email` and `recipient_list` of `send_mail()` function. 54 | 55 | ## [0.2.0] 56 | 57 | A few breaking changes have been made in this version to ensure that API of this extension is basically the same as Django. 58 | Users migrating from Flask-Mail should upgrade with caution. 59 | 60 | ### Added 61 | 62 | - Add documentation site hosted in GitHub Pages. 63 | 64 | ### Changed 65 | 66 | - Set configuration value of in-memory backend to 'locmem'. 67 | - Set MAIL_SERVER default value to 'locaohost'. 68 | - Set MAIL_USE_LOCALTIME default value to False. 69 | 70 | ### Removed 71 | 72 | - Remove `Mail.send()` method and `Message()` class which borrowing from Flask-Mail. 73 | - Remove `mail_admins()` and `mail_managers()` method that come from Django. 74 | -------------------------------------------------------------------------------- /docs/img/icon-white.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # Publish package on main branch if it's tagged with 'v*' 2 | 3 | name: release & publish workflow 4 | 5 | # Controls when the action will run. 6 | on: 7 | # Triggers the workflow on push events but only for the master branch 8 | push: 9 | tags: 10 | - 'v*' 11 | 12 | # Allows you to run this workflow manually from the Actions tab 13 | workflow_dispatch: 14 | 15 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 16 | jobs: 17 | # This workflow contains a single job called "release" 18 | release: 19 | name: Create Release 20 | runs-on: ubuntu-latest 21 | 22 | strategy: 23 | matrix: 24 | python-versions: [3.12] 25 | 26 | # Steps represent a sequence of tasks that will be executed as part of the job 27 | steps: 28 | - name: Get version from tag 29 | id: tag_name 30 | run: | 31 | echo ::set-output name=current_version::${GITHUB_REF#refs/tags/v} 32 | shell: bash 33 | 34 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 35 | - uses: actions/checkout@v2 36 | 37 | - name: Get Changelog Entry 38 | id: changelog_reader 39 | uses: mindsers/changelog-reader-action@v2 40 | with: 41 | validation_depth: 10 42 | version: ${{ steps.tag_name.outputs.current_version }} 43 | path: ./CHANGELOG.md 44 | 45 | - uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0 46 | with: 47 | python-version: ${{ matrix.python-versions }} 48 | 49 | - name: Install dependencies 50 | run: | 51 | python -m pip install --upgrade pip 52 | pip install poetry 53 | 54 | - name: build documentation 55 | run: | 56 | poetry install -E doc 57 | poetry run mkdocs build 58 | 59 | - name: publish documentation 60 | uses: peaceiris/actions-gh-pages@v4 61 | with: 62 | github_token: ${{ secrets.GITHUB_TOKEN }} 63 | publish_dir: ./site 64 | 65 | - name: Build wheels and source tarball 66 | run: >- 67 | poetry build 68 | 69 | - name: show temporary files 70 | run: >- 71 | ls -l 72 | 73 | - name: create github release 74 | id: create_release 75 | uses: softprops/action-gh-release@v1 76 | env: 77 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 78 | with: 79 | body: ${{ steps.changelog_reader.outputs.changes }} 80 | files: dist/*.whl 81 | draft: false 82 | prerelease: false 83 | 84 | - name: publish to PyPI 85 | uses: pypa/gh-action-pypi-publish@release/v1 86 | with: 87 | user: __token__ 88 | password: ${{ secrets.PYPI_API_TOKEN }} 89 | skip_existing: true 90 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "Flask-Mailman" 3 | version = "1.1.1" 4 | description = "Porting Django's email implementation to your Flask applications." 5 | authors = ["Waynerv "] 6 | license = "BSD-3-Clause" 7 | readme="README.md" 8 | repository="https://github.com/waynerv/flask-mailman" 9 | keywords=["flask", "mail", "smtp", "flask-mail"] 10 | exclude = ["docs", "tests*"] 11 | classifiers=[ 12 | "Development Status :: 4 - Beta", 13 | "Intended Audience :: Developers", 14 | "License :: OSI Approved :: BSD License", 15 | "Framework :: Flask", 16 | "Topic :: Software Development :: Build Tools", 17 | "Topic :: Communications :: Email", 18 | "Programming Language :: Python :: 3", 19 | "Programming Language :: Python :: 3.7", 20 | "Programming Language :: Python :: 3.8", 21 | "Programming Language :: Python :: 3.9", 22 | "Programming Language :: Python :: 3.10", 23 | "Programming Language :: Python :: 3.11", 24 | "Programming Language :: Python :: 3.12", 25 | ] 26 | 27 | [tool.poetry.dependencies] 28 | python = "^3.7" 29 | flask = ">= 1.0" 30 | 31 | black = { version = "*", optional = true} 32 | isort = { version = "*", optional = true} 33 | flake8 = { version = "*", optional = true} 34 | pytest = { version = "*", optional = true} 35 | pytest-cov = { version = "*", optional = true} 36 | tox = { version = "*", optional = true} 37 | virtualenv = { version = "*", optional = true} 38 | pip = { version = "*", optional = true} 39 | mkdocs = { version = "*", optional = true} 40 | mkdocs-include-markdown-plugin = { version = "*", optional = true} 41 | mkdocs-material = { version = "*", optional = true} 42 | mkdocstrings = { version = "*", optional = true} 43 | mkdocs-material-extensions = { version = "*", optional = true} 44 | twine = { version = "*", optional = true} 45 | mkdocs-autorefs = {version = "*", optional = true} 46 | pre-commit = {version = "*", optional = true} 47 | toml = {version = "*", optional = true} 48 | bump2version = {version = "*", optional = true} 49 | aiosmtpd = {version = "^1.4.4.post2", optional = true} 50 | 51 | [tool.poetry.extras] 52 | test = [ 53 | "pytest", 54 | "black", 55 | "isort", 56 | "flake8", 57 | "pytest-cov", 58 | "aiosmtpd" 59 | ] 60 | 61 | dev = ["tox", "pre-commit", "virtualenv", "pip", "twine", "toml", "bump2version"] 62 | 63 | doc = [ 64 | "mkdocs", 65 | "mkdocs-include-markdown-plugin", 66 | "mkdocs-material", 67 | "mkdocstrings", 68 | "mkdocs-material-extensions", 69 | "mkdocs-autorefs" 70 | ] 71 | 72 | [tool.black] 73 | line-length = 120 74 | skip-string-normalization = true 75 | target-version = ['py36', 'py37', 'py38'] 76 | include = '\.pyi?$' 77 | exclude = ''' 78 | /( 79 | \.eggs 80 | | \.git 81 | | \.hg 82 | | \.mypy_cache 83 | | \.tox 84 | | \.venv 85 | | _build 86 | | buck-out 87 | | build 88 | | dist 89 | )/ 90 | ''' 91 | 92 | [tool.isort] 93 | multi_line_output = 3 94 | include_trailing_comma = true 95 | force_grid_wrap = 0 96 | use_parentheses = true 97 | ensure_newline_before_comments = true 98 | line_length = 120 99 | skip_gitignore = true 100 | # you can skip files as below 101 | #skip_glob = docs/conf.py 102 | 103 | [build-system] 104 | requires = ["poetry-core>=1.0.0"] 105 | build-backend = "poetry.core.masonry.api" 106 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are welcome, and they are greatly appreciated! Every little bit 4 | helps, and credit will always be given. 5 | 6 | You can contribute in many ways: 7 | 8 | ## Types of Contributions 9 | 10 | ### Report Bugs 11 | 12 | Report bugs at https://github.com/waynerv/flask-mailman/issues. 13 | 14 | If you are reporting a bug, please include: 15 | 16 | * Your operating system name and version. 17 | * Any details about your local setup that might be helpful in troubleshooting. 18 | * Detailed steps to reproduce the bug. 19 | 20 | ### Fix Bugs 21 | 22 | Look through the GitHub issues for bugs. Anything tagged with "bug" and "help 23 | wanted" is open to whoever wants to implement it. 24 | 25 | ### Implement Features 26 | 27 | Look through the GitHub issues for features. Anything tagged with "enhancement" 28 | and "help wanted" is open to whoever wants to implement it. 29 | 30 | ### Write Documentation 31 | 32 | Flask-Mailman could always use more documentation, whether as part of the 33 | official Flask-Mailman docs, in docstrings, or even on the web in blog posts, 34 | articles, and such. 35 | 36 | ### Submit Feedback 37 | 38 | The best way to send feedback is to file an issue at https://github.com/waynerv/flask-mailman/issues. 39 | 40 | If you are proposing a feature: 41 | 42 | * Explain in detail how it would work. 43 | * Keep the scope as narrow as possible, to make it easier to implement. 44 | * Remember that this is a volunteer-driven project, and that contributions 45 | are welcome :) 46 | 47 | ## Get Started! 48 | 49 | Ready to contribute? Here's how to set up `flask-mailman` for local development. 50 | 51 | 1. Fork the `flask-mailman` repo on GitHub. 52 | 2. Clone your fork locally 53 | 54 | ``` 55 | $ git clone git@github.com:your_name_here/flask-mailman.git 56 | ``` 57 | 58 | 3. Ensure [poetry](https://python-poetry.org/docs/) is installed. 59 | 4. Install dependencies and start your virtualenv: 60 | 61 | ``` 62 | $ poetry install -E test -E doc -E dev 63 | ``` 64 | 65 | 5. Create a branch for local development: 66 | 67 | ``` 68 | $ git checkout -b name-of-your-bugfix-or-feature 69 | ``` 70 | 71 | Now you can make your changes locally. 72 | 73 | 6. When you're done making changes, check that your changes pass the 74 | tests, including testing other Python versions, with tox: 75 | 76 | ``` 77 | $ poetry run tox 78 | ``` 79 | 80 | 7. Commit your changes and push your branch to GitHub: 81 | 82 | ``` 83 | $ git add . 84 | $ git commit -m "Your detailed description of your changes." 85 | $ git push origin name-of-your-bugfix-or-feature 86 | ``` 87 | 88 | 8. Submit a pull request through the GitHub website. 89 | 90 | ## Pull Request Guidelines 91 | 92 | Before you submit a pull request, check that it meets these guidelines: 93 | 94 | 1. The pull request should include tests. 95 | 2. If the pull request adds functionality, the docs should be updated. Put 96 | your new functionality into a function with a docstring, and add the 97 | feature to the list in README.md. 98 | 3. The pull request should work for Python 3.6, 3.7, 3.8, 3.9. Check 99 | https://github.com/waynerv/flask-mailman/actions 100 | and make sure that the tests pass for all supported Python versions. 101 | 102 | ## Tips 103 | 104 | ``` 105 | $ poetry run pytest tests/test_flask_mailman.py 106 | ``` 107 | 108 | To run a subset of tests. 109 | 110 | 111 | ## Deploying 112 | 113 | A reminder for the maintainers on how to deploy. 114 | Make sure all your changes are committed (including an entry in CHANGELOG.md). 115 | Then run: 116 | 117 | ``` 118 | $ poetry run bump2version patch # possible: major / minor / patch 119 | $ git push 120 | $ git push --tags 121 | ``` 122 | 123 | GitHub Actions will then deploy to PyPI if tests pass. 124 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | from email import message_from_bytes 2 | import unittest 3 | from contextlib import contextmanager 4 | 5 | import pytest 6 | from flask import Flask 7 | 8 | from flask_mailman import Mail 9 | 10 | 11 | class TestCase(unittest.TestCase): 12 | TESTING = True 13 | MAIL_DEFAULT_SENDER = "support@mysite.com" 14 | 15 | def setUp(self): 16 | self.app = Flask(import_name=__name__) 17 | self.app.config.from_object(self) 18 | self.assertTrue(self.app.testing) 19 | self.mail = Mail(self.app) 20 | self.ctx = self.app.test_request_context() 21 | self.ctx.push() 22 | 23 | def tearDown(self): 24 | self.ctx.pop() 25 | 26 | @contextmanager 27 | def mail_config(self, **settings): 28 | """ 29 | Context manager to alter mail config during a test and restore it after, 30 | even in case of a failure. 31 | """ 32 | original = {} 33 | state = self.mail.state 34 | for key in settings: 35 | assert hasattr(state, key) 36 | original[key] = getattr(state, key) 37 | setattr(state, key, settings[key]) 38 | 39 | yield 40 | # restore 41 | for k, v in original.items(): 42 | setattr(state, k, v) 43 | 44 | def assertIn(self, member, container, msg=None): 45 | if hasattr(unittest.TestCase, 'assertIn'): 46 | return unittest.TestCase.assertIn(self, member, container, msg) 47 | return self.assertTrue(member in container) 48 | 49 | def assertNotIn(self, member, container, msg=None): 50 | if hasattr(unittest.TestCase, 'assertNotIn'): 51 | return unittest.TestCase.assertNotIn(self, member, container, msg) 52 | return self.assertFalse(member in container) 53 | 54 | def assertIsNone(self, obj, msg=None): 55 | if hasattr(unittest.TestCase, 'assertIsNone'): 56 | return unittest.TestCase.assertIsNone(self, obj, msg) 57 | return self.assertTrue(obj is None) 58 | 59 | def assertIsNotNone(self, obj, msg=None): 60 | if hasattr(unittest.TestCase, 'assertIsNotNone'): 61 | return unittest.TestCase.assertIsNotNone(self, obj, msg) 62 | return self.assertTrue(obj is not None) 63 | 64 | @pytest.fixture(autouse=True) 65 | def capsys(self, capsys): 66 | self.capsys = capsys 67 | 68 | 69 | class MailmanCustomizedTestCase(TestCase): 70 | def assertMessageHasHeaders(self, message, headers): 71 | """ 72 | Asserts that the `message` has all `headers`. 73 | 74 | message: can be an instance of an email.Message subclass or a string 75 | with the contents of an email message. 76 | headers: should be a set of (header-name, header-value) tuples. 77 | """ 78 | if isinstance(message, bytes): 79 | message = message_from_bytes(message) 80 | msg_headers = set(message.items()) 81 | self.assertTrue( 82 | headers.issubset(msg_headers), 83 | msg="Message is missing " "the following headers: %s" % (headers - msg_headers), 84 | ) 85 | 86 | def get_decoded_attachments(self, message): 87 | """ 88 | Encode the specified EmailMessage, then decode 89 | it using Python's email.parser module and, for each attachment of the 90 | message, return a list of tuples with (filename, content, mimetype). 91 | """ 92 | msg_bytes = message.message().as_bytes() 93 | email_message = message_from_bytes(msg_bytes) 94 | 95 | def iter_attachments(): 96 | for i in email_message.walk(): 97 | if i.get_content_disposition() == "attachment": 98 | filename = i.get_filename() 99 | content = i.get_payload(decode=True) 100 | mimetype = i.get_content_type() 101 | yield filename, content, mimetype 102 | 103 | return list(iter_attachments()) 104 | 105 | def get_message(self): 106 | return self.mail.outbox[0].message() 107 | 108 | def flush_mailbox(self): 109 | self.mail.outbox.clear() 110 | -------------------------------------------------------------------------------- /tests/test_connection.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from flask_mailman import BadHeaderError, EmailMessage 4 | from tests import TestCase 5 | 6 | 7 | class TestConnection(TestCase): 8 | def test_send_message(self): 9 | msg = EmailMessage( 10 | subject="testing", 11 | to=["to@example.com"], 12 | body="testing", 13 | ) 14 | msg.send() 15 | self.assertEqual(len(self.mail.outbox), 1) 16 | sent_msg = self.mail.outbox[0] 17 | self.assertEqual(sent_msg.from_email, self.app.extensions["mailman"].default_sender) 18 | 19 | def test_send_message_using_connection(self): 20 | with self.mail.get_connection() as conn: 21 | msg = EmailMessage( 22 | subject="testing", 23 | to=["to@example.com"], 24 | body="testing", 25 | connection=conn, 26 | ) 27 | msg.send() 28 | self.assertEqual(len(self.mail.outbox), 1) 29 | sent_msg = self.mail.outbox[0] 30 | self.assertEqual(sent_msg.from_email, self.app.extensions["mailman"].default_sender) 31 | 32 | conn.send_messages([msg]) 33 | self.assertEqual(len(self.mail.outbox), 2) 34 | 35 | def test_send_single(self): 36 | with self.mail.get_connection() as conn: 37 | msg = EmailMessage( 38 | subject="testing", 39 | to=["to@example.com"], 40 | body="testing", 41 | connection=conn, 42 | ) 43 | msg.send() 44 | self.assertEqual(len(self.mail.outbox), 1) 45 | sent_msg = self.mail.outbox[0] 46 | self.assertEqual(sent_msg.subject, "testing") 47 | self.assertEqual(sent_msg.to, ["to@example.com"]) 48 | self.assertEqual(sent_msg.body, "testing") 49 | self.assertEqual(sent_msg.from_email, self.app.extensions["mailman"].default_sender) 50 | 51 | def test_send_many(self): 52 | with self.mail.get_connection() as conn: 53 | msgs = [] 54 | for i in range(10): 55 | msg = EmailMessage(subject="testing", to=["to@example.com"], body="testing") 56 | msgs.append(msg) 57 | conn.send_messages(msgs) 58 | self.assertEqual(len(self.mail.outbox), 10) 59 | sent_msg = self.mail.outbox[0] 60 | self.assertEqual(sent_msg.from_email, self.app.extensions["mailman"].default_sender) 61 | 62 | def test_send_without_sender(self): 63 | self.app.extensions['mailman'].default_sender = None 64 | msg = EmailMessage(subject="testing", to=["to@example.com"], body="testing") 65 | msg.send() 66 | self.assertEqual(len(self.mail.outbox), 1) 67 | sent_msg = self.mail.outbox[0] 68 | self.assertEqual(sent_msg.from_email, None) 69 | 70 | def test_send_without_to(self): 71 | msg = EmailMessage(subject="testing", to=[], body="testing") 72 | assert msg.send() == 0 73 | 74 | def test_bad_header_subject(self): 75 | msg = EmailMessage(subject="testing\n\r", body="testing", to=["to@example.com"]) 76 | with pytest.raises(BadHeaderError): 77 | msg.send() 78 | 79 | def test_arbitrary_keyword(self): 80 | """ 81 | Make sure that get_connection() accepts arbitrary keyword that might be 82 | used with custom backends. 83 | """ 84 | c = self.mail.get_connection(fail_silently=True, foo="bar") 85 | self.assertTrue(c.fail_silently) 86 | 87 | def test_close_connection(self): 88 | """ 89 | Connection can be closed (even when not explicitly opened) 90 | """ 91 | conn = self.mail.get_connection(username="", password="") 92 | conn.close() 93 | 94 | def test_use_as_contextmanager(self): 95 | """ 96 | The connection can be used as a contextmanager. 97 | """ 98 | opened = [False] 99 | closed = [False] 100 | conn = self.mail.get_connection(username="", password="") 101 | 102 | def open(): 103 | opened[0] = True 104 | 105 | conn.open = open 106 | 107 | def close(): 108 | closed[0] = True 109 | 110 | conn.close = close 111 | with conn as same_conn: 112 | self.assertTrue(opened[0]) 113 | self.assertIs(same_conn, conn) 114 | self.assertFalse(closed[0]) 115 | self.assertTrue(closed[0]) 116 | -------------------------------------------------------------------------------- /flask_mailman/backends/smtp.py: -------------------------------------------------------------------------------- 1 | """SMTP email backend class.""" 2 | import smtplib 3 | import ssl 4 | import threading 5 | 6 | from werkzeug.utils import cached_property 7 | 8 | from flask_mailman.backends.base import BaseEmailBackend 9 | from flask_mailman.message import sanitize_address 10 | from flask_mailman.utils import DNS_NAME 11 | 12 | 13 | class EmailBackend(BaseEmailBackend): 14 | """ 15 | A wrapper that manages the SMTP network connection. 16 | """ 17 | 18 | def __init__( 19 | self, 20 | host=None, 21 | port=None, 22 | username=None, 23 | password=None, 24 | use_tls=None, 25 | fail_silently=False, 26 | use_ssl=None, 27 | timeout=None, 28 | ssl_keyfile=None, 29 | ssl_certfile=None, 30 | **kwargs, 31 | ): 32 | super().__init__(fail_silently=fail_silently, **kwargs) 33 | self.host = host or self.mailman.server 34 | self.port = port or self.mailman.port 35 | self.username = self.mailman.username if username is None else username 36 | self.password = self.mailman.password if password is None else password 37 | self.use_tls = self.mailman.use_tls if use_tls is None else use_tls 38 | self.use_ssl = self.mailman.use_ssl if use_ssl is None else use_ssl 39 | self.timeout = self.mailman.timeout if timeout is None else timeout 40 | self.ssl_keyfile = self.mailman.ssl_keyfile if ssl_keyfile is None else ssl_keyfile 41 | self.ssl_certfile = self.mailman.ssl_certfile if ssl_certfile is None else ssl_certfile 42 | if self.use_ssl and self.use_tls: 43 | raise ValueError( 44 | "EMAIL_USE_TLS/EMAIL_USE_SSL are mutually exclusive, so only set " "one of those settings to True." 45 | ) 46 | self.connection = None 47 | self._lock = threading.RLock() 48 | 49 | @property 50 | def connection_class(self): 51 | return smtplib.SMTP_SSL if self.use_ssl else smtplib.SMTP 52 | 53 | @cached_property 54 | def ssl_context(self): 55 | if self.ssl_certfile or self.ssl_keyfile: 56 | ssl_context = ssl.SSLContext(protocol=ssl.PROTOCOL_TLS_CLIENT) 57 | ssl_context.load_cert_chain(self.ssl_certfile, self.ssl_keyfile) 58 | return ssl_context 59 | else: 60 | return ssl.create_default_context() 61 | 62 | def open(self): 63 | """ 64 | Ensure an open connection to the email server. Return whether or not a 65 | new connection was required (True or False) or None if an exception 66 | passed silently. 67 | """ 68 | if self.connection: 69 | # Nothing to do if the connection is already open. 70 | return False 71 | 72 | # If local_hostname is not specified, socket.getfqdn() gets used. 73 | # For performance, we use the cached FQDN for local_hostname. 74 | connection_params = {'local_hostname': DNS_NAME.get_fqdn()} 75 | if self.timeout is not None: 76 | connection_params['timeout'] = self.timeout 77 | if self.use_ssl: 78 | connection_params["context"] = self.ssl_context 79 | try: 80 | self.connection = self.connection_class(self.host, self.port, **connection_params) 81 | 82 | # TLS/SSL are mutually exclusive, so only attempt TLS over 83 | # non-secure connections. 84 | if not self.use_ssl and self.use_tls: 85 | if self.ssl_certfile: 86 | context = ssl.SSLContext().load_cert_chain(self.ssl_certfile, keyfile=self.ssl_keyfile) 87 | else: 88 | context = None 89 | self.connection.starttls(context=context) 90 | if self.username and self.password: 91 | self.connection.login(self.username, self.password) 92 | return True 93 | except OSError: 94 | if not self.fail_silently: 95 | raise 96 | 97 | def close(self): 98 | """Close the connection to the email server.""" 99 | if self.connection is None: 100 | return 101 | try: 102 | try: 103 | self.connection.quit() 104 | except (ssl.SSLError, smtplib.SMTPServerDisconnected): 105 | # This happens when calling quit() on a TLS connection 106 | # sometimes, or when the connection was already disconnected 107 | # by the server. 108 | self.connection.close() 109 | except smtplib.SMTPException: 110 | if self.fail_silently: 111 | return 112 | raise 113 | finally: 114 | self.connection = None 115 | 116 | def send_messages(self, email_messages): 117 | """ 118 | Send one or more EmailMessage objects and return the number of email 119 | messages sent. 120 | """ 121 | if not email_messages: 122 | return 0 123 | with self._lock: 124 | new_conn_created = self.open() 125 | if not self.connection or new_conn_created is None: 126 | # We failed silently on open(). 127 | # Trying to send would be pointless. 128 | return 0 129 | num_sent = 0 130 | for message in email_messages: 131 | sent = self._send(message) 132 | if sent: 133 | num_sent += 1 134 | if new_conn_created: 135 | self.close() 136 | return num_sent 137 | 138 | def _send(self, email_message): 139 | """A helper method that does the actual sending.""" 140 | if not email_message.recipients(): 141 | return False 142 | encoding = email_message.encoding or self.mailman.default_charset 143 | from_email = sanitize_address(email_message.from_email, encoding) 144 | recipients = [sanitize_address(addr, encoding) for addr in email_message.recipients()] 145 | message = email_message.message() 146 | try: 147 | self.connection.sendmail( 148 | from_email, 149 | recipients, 150 | message.as_bytes(linesep='\r\n'), 151 | mail_options=self.mailman.mail_options, 152 | ) 153 | except smtplib.SMTPException: 154 | if not self.fail_silently: 155 | raise 156 | return False 157 | return True 158 | -------------------------------------------------------------------------------- /flask_mailman/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tools for sending email. 3 | """ 4 | import types 5 | import typing as t 6 | from importlib import import_module 7 | 8 | from flask import current_app 9 | 10 | from flask_mailman.utils import DNS_NAME, CachedDnsName 11 | 12 | from .message import ( 13 | DEFAULT_ATTACHMENT_MIME_TYPE, 14 | BadHeaderError, 15 | EmailMessage, 16 | EmailMultiAlternatives, 17 | SafeMIMEMultipart, 18 | SafeMIMEText, 19 | forbid_multi_line_headers, 20 | make_msgid, 21 | ) 22 | 23 | if t.TYPE_CHECKING: 24 | from flask_mailman.backends.base import BaseEmailBackend 25 | 26 | __all__ = [ 27 | 'CachedDnsName', 28 | 'DNS_NAME', 29 | 'EmailMessage', 30 | 'EmailMultiAlternatives', 31 | 'SafeMIMEText', 32 | 'SafeMIMEMultipart', 33 | 'DEFAULT_ATTACHMENT_MIME_TYPE', 34 | 'make_msgid', 35 | 'BadHeaderError', 36 | 'forbid_multi_line_headers', 37 | 'Mail', 38 | ] 39 | 40 | 41 | available_backends = ['console', 'dummy', 'file', 'smtp', 'locmem'] 42 | 43 | 44 | class _MailMixin(object): 45 | def _get_backend_from_module(self, backend_module_name: str, backend_class_name: str) -> "BaseEmailBackend": 46 | """ 47 | import the backend module and return the backend class. 48 | 49 | :param backend_module_name: 50 | the string based module name from where the backend class will be imported. 51 | 52 | :param backend_class_name: 53 | the string based backend class name. 54 | """ 55 | backend_module: types.ModuleType = import_module(backend_module_name) 56 | backend: "BaseEmailBackend" = getattr(backend_module, backend_class_name) 57 | return backend 58 | 59 | def import_backend(self, backend_name: t.Any) -> "BaseEmailBackend": 60 | """ 61 | This is the base method to import the backend service. 62 | This method will implement the feature for flask_mailman to take custom backends. 63 | 64 | Now you can create your own backend class and implement with flask mailman. 65 | 66 | :for example:: 67 | 68 | from flask import Flask 69 | 70 | app = Flask(__name__) 71 | app.config['MAIL_BACKEND'] = 'locmem' 72 | #or 73 | app.config['MAIL_BACKEND'] = 'smtp' 74 | #or 75 | app.config['MAIL_BACKEND'] = 'flask_mailman.backends.locmem' 76 | #or 77 | app.config['MAIL_BACKEND'] = 'flask_mailman.backends.locmem.EmailBackend' 78 | #or 79 | app.config['MAIL_BACKEND'] = 'your_project.mail.backends.custom.EmailBackend' 80 | """ 81 | backend: t.Optional["BaseEmailBackend"] = None 82 | 83 | if not isinstance(backend_name, str): 84 | backend = backend_name 85 | 86 | else: 87 | default_backend_loc: str = "flask_mailman.backends" 88 | default_backend_class: str = "EmailBackend" 89 | 90 | if "." not in backend_name: 91 | backend_module_name: str = default_backend_loc + "." + backend_name 92 | backend: "BaseEmailBackend" = self._get_backend_from_module(backend_module_name, default_backend_class) 93 | 94 | else: 95 | if backend_name.endswith(default_backend_class): 96 | backend_module_name, backend_class_name = backend_name.rsplit('.', 1) 97 | backend: "BaseEmailBackend" = self._get_backend_from_module(backend_module_name, backend_class_name) 98 | 99 | else: 100 | backend: "BaseEmailBackend" = self._get_backend_from_module(backend_name, default_backend_class) 101 | 102 | return backend 103 | 104 | def get_connection(self, backend=None, fail_silently=False, **kwds): 105 | """Load an email backend and return an instance of it. 106 | 107 | If backend is None (default), use app.config.MAIL_BACKEND. 108 | 109 | Both fail_silently and other keyword arguments are used in the 110 | constructor of the backend. 111 | """ 112 | app = getattr(self, "app", None) or current_app 113 | try: 114 | mailman = app.extensions['mailman'] 115 | except KeyError: 116 | raise RuntimeError("The current application was not configured with Flask-Mailman") 117 | 118 | try: 119 | if backend is None: 120 | backend = mailman.backend 121 | 122 | klass = self.import_backend(backend) 123 | 124 | except ImportError: 125 | err_msg = ( 126 | f"Unable to import backend: {backend}. " 127 | f"The available built-in mail backends are: {', '.join(available_backends)}" 128 | ) 129 | raise RuntimeError(err_msg) 130 | 131 | return klass(mailman=mailman, fail_silently=fail_silently, **kwds) 132 | 133 | def send_mail( 134 | self, 135 | subject, 136 | message, 137 | from_email=None, 138 | recipient_list=None, 139 | fail_silently=False, 140 | auth_user=None, 141 | auth_password=None, 142 | connection=None, 143 | html_message=None, 144 | ): 145 | """ 146 | Easy wrapper for sending a single message to a recipient list. All members 147 | of the recipient list will see the other recipients in the 'To' field. 148 | 149 | If auth_user is None, use the MAIL_USERNAME setting. 150 | If auth_password is None, use the MAIL_PASSWORD setting. 151 | """ 152 | connection = connection or self.get_connection( 153 | username=auth_user, 154 | password=auth_password, 155 | fail_silently=fail_silently, 156 | ) 157 | mail = EmailMultiAlternatives(subject, message, from_email, recipient_list, connection=connection) 158 | if html_message: 159 | mail.attach_alternative(html_message, 'text/html') 160 | 161 | return mail.send() 162 | 163 | def send_mass_mail(self, datatuple, fail_silently=False, auth_user=None, auth_password=None, connection=None): 164 | """ 165 | Given a datatuple of (subject, message, from_email, recipient_list), send 166 | each message to each recipient list. Return the number of emails sent. 167 | 168 | If from_email is None, use the MAIL_DEFAULT_SENDER setting. 169 | If auth_user and auth_password are set, use them to log in. 170 | If auth_user is None, use the MAIL_USERNAME setting. 171 | If auth_password is None, use the MAIL_PASSWORD setting. 172 | 173 | Note: The API for this method is frozen. New code wanting to extend the 174 | functionality should use the EmailMessage class directly. 175 | """ 176 | connection = connection or self.get_connection( 177 | username=auth_user, 178 | password=auth_password, 179 | fail_silently=fail_silently, 180 | ) 181 | messages = [ 182 | EmailMessage(subject, message, sender, recipient, connection=connection) 183 | for subject, message, sender, recipient in datatuple 184 | ] 185 | return connection.send_messages(messages) 186 | 187 | 188 | class _Mail(_MailMixin): 189 | """Initialize a state instance with all configs and methods""" 190 | 191 | def __init__( 192 | self, 193 | server, 194 | port, 195 | username, 196 | password, 197 | use_tls, 198 | use_ssl, 199 | default_sender, 200 | timeout, 201 | ssl_keyfile, 202 | ssl_certfile, 203 | use_localtime, 204 | file_path, 205 | default_charset, 206 | mail_options, 207 | backend, 208 | ): 209 | self.server = server 210 | self.port = port 211 | self.username = username 212 | self.password = password 213 | self.use_tls = use_tls 214 | self.use_ssl = use_ssl 215 | self.default_sender = default_sender 216 | self.timeout = timeout 217 | self.ssl_keyfile = ssl_keyfile 218 | self.ssl_certfile = ssl_certfile 219 | self.use_localtime = use_localtime 220 | self.file_path = file_path 221 | self.default_charset = default_charset 222 | self.mail_options = mail_options 223 | self.backend = backend 224 | 225 | 226 | class Mail(_MailMixin): 227 | """Manages email messaging 228 | 229 | :param app: Flask instance 230 | """ 231 | 232 | def __init__(self, app=None): 233 | self.app = app 234 | if app is not None: 235 | self.state = self.init_app(app) 236 | else: 237 | self.state = None 238 | 239 | @staticmethod 240 | def init_mail(config, testing=False): 241 | # Set default mail backend in different environment 242 | mail_backend = config.get('MAIL_BACKEND') 243 | if mail_backend is None or mail_backend == str(): 244 | mail_backend = 'locmem' if testing else 'smtp' 245 | 246 | return _Mail( 247 | config.get('MAIL_SERVER', 'localhost'), 248 | config.get('MAIL_PORT', 25), 249 | config.get('MAIL_USERNAME'), 250 | config.get('MAIL_PASSWORD'), 251 | config.get('MAIL_USE_TLS', False), 252 | config.get('MAIL_USE_SSL', False), 253 | config.get('MAIL_DEFAULT_SENDER'), 254 | config.get('MAIL_TIMEOUT'), 255 | config.get('MAIL_SSL_KEYFILE'), 256 | config.get('MAIL_SSL_CERTFILE'), 257 | config.get('MAIL_USE_LOCALTIME', False), 258 | config.get('MAIL_FILE_PATH'), 259 | config.get('MAIL_DEFAULT_CHARSET', 'utf-8'), 260 | config.get('MAIL_SEND_OPTIONS', []), 261 | mail_backend, 262 | ) 263 | 264 | def init_app(self, app): 265 | """Initializes your mail settings from the application settings. 266 | 267 | You can use this if you want to set up your Mail instance 268 | at configuration time. 269 | 270 | :param app: Flask application instance 271 | """ 272 | state = self.init_mail(app.config, app.testing) 273 | 274 | # register extension with app 275 | app.extensions = getattr(app, 'extensions', {}) 276 | app.extensions['mailman'] = state 277 | return state 278 | 279 | def __getattr__(self, name): 280 | return getattr(self.state, name, None) 281 | -------------------------------------------------------------------------------- /flask_mailman/message.py: -------------------------------------------------------------------------------- 1 | import mimetypes 2 | from email import charset as Charset 3 | from email import encoders as Encoders 4 | from email import generator, message_from_string 5 | from email.errors import HeaderParseError 6 | from email.header import Header 7 | from email.headerregistry import Address, parser 8 | from email.message import Message 9 | from email.mime.base import MIMEBase 10 | from email.mime.message import MIMEMessage 11 | from email.mime.multipart import MIMEMultipart 12 | from email.mime.text import MIMEText 13 | from email.utils import formataddr, formatdate, getaddresses, make_msgid 14 | from io import BytesIO, StringIO 15 | from pathlib import Path 16 | 17 | from flask import current_app 18 | 19 | from flask_mailman.utils import DNS_NAME, force_str, punycode 20 | 21 | # Don't BASE64-encode UTF-8 messages so that we avoid unwanted attention from 22 | # some spam filters. 23 | utf8_charset = Charset.Charset('utf-8') 24 | utf8_charset.body_encoding = None # Python defaults to BASE64 25 | utf8_charset_qp = Charset.Charset('utf-8') 26 | utf8_charset_qp.body_encoding = Charset.QP 27 | 28 | # Default MIME type to use on attachments (if it is not explicitly given 29 | # and cannot be guessed). 30 | DEFAULT_ATTACHMENT_MIME_TYPE = 'application/octet-stream' 31 | 32 | RFC5322_EMAIL_LINE_LENGTH_LIMIT = 998 33 | 34 | 35 | class BadHeaderError(ValueError): 36 | pass 37 | 38 | 39 | # Header names that contain structured address data (RFC #5322) 40 | ADDRESS_HEADERS = { 41 | 'from', 42 | 'sender', 43 | 'reply-to', 44 | 'to', 45 | 'cc', 46 | 'bcc', 47 | 'resent-from', 48 | 'resent-sender', 49 | 'resent-to', 50 | 'resent-cc', 51 | 'resent-bcc', 52 | } 53 | 54 | 55 | def forbid_multi_line_headers(name, val, encoding): 56 | """Forbid multi-line headers to prevent header injection.""" 57 | encoding = encoding or current_app.extensions['mailman'].default_charset 58 | val = str(val) # val may be lazy 59 | if '\n' in val or '\r' in val: 60 | raise BadHeaderError("Header values can't contain newlines (got %r for header %r)" % (val, name)) 61 | try: 62 | val.encode('ascii') 63 | except UnicodeEncodeError: 64 | if name.lower() in ADDRESS_HEADERS: 65 | val = ', '.join(sanitize_address(addr, encoding) for addr in getaddresses((val,))) 66 | else: 67 | val = Header(val, encoding).encode() 68 | else: 69 | if name.lower() == 'subject': 70 | val = Header(val).encode() 71 | return name, val 72 | 73 | 74 | def sanitize_address(addr, encoding): 75 | """ 76 | Format a pair of (name, address) or an email address string. 77 | """ 78 | address = None 79 | if not isinstance(addr, tuple): 80 | addr = force_str(addr) 81 | try: 82 | token, rest = parser.get_mailbox(addr) 83 | except (HeaderParseError, ValueError, IndexError): 84 | raise ValueError('Invalid address "%s"' % addr) 85 | else: 86 | if rest: 87 | # The entire email address must be parsed. 88 | raise ValueError('Invalid address; only %s could be parsed from "%s"' % (token, addr)) 89 | nm = token.display_name or '' 90 | localpart = token.local_part 91 | domain = token.domain or '' 92 | else: 93 | nm, address = addr 94 | localpart, domain = address.rsplit('@', 1) 95 | 96 | address_parts = nm + localpart + domain 97 | if '\n' in address_parts or '\r' in address_parts: 98 | raise ValueError('Invalid address; address parts cannot contain newlines.') 99 | 100 | # Avoid UTF-8 encode, if it's possible. 101 | try: 102 | nm.encode('ascii') 103 | nm = Header(nm).encode() 104 | except UnicodeEncodeError: 105 | nm = Header(nm, encoding).encode() 106 | try: 107 | localpart.encode('ascii') 108 | except UnicodeEncodeError: 109 | localpart = Header(localpart, encoding).encode() 110 | domain = punycode(domain) 111 | 112 | parsed_address = Address(username=localpart, domain=domain) 113 | return formataddr((nm, parsed_address.addr_spec)) 114 | 115 | 116 | class MIMEMixin: 117 | def as_string(self, unixfrom=False, linesep='\n'): 118 | """Return the entire formatted message as a string. 119 | Optional `unixfrom' when True, means include the Unix From_ envelope 120 | header. 121 | 122 | This overrides the default as_string() implementation to not mangle 123 | lines that begin with 'From '. See bug #13433 for details. 124 | """ 125 | fp = StringIO() 126 | g = generator.Generator(fp, mangle_from_=False) 127 | g.flatten(self, unixfrom=unixfrom, linesep=linesep) 128 | return fp.getvalue() 129 | 130 | def as_bytes(self, unixfrom=False, linesep='\n'): 131 | """Return the entire formatted message as bytes. 132 | Optional `unixfrom' when True, means include the Unix From_ envelope 133 | header. 134 | 135 | This overrides the default as_bytes() implementation to not mangle 136 | lines that begin with 'From '. See bug #13433 for details. 137 | """ 138 | fp = BytesIO() 139 | g = generator.BytesGenerator(fp, mangle_from_=False) 140 | g.flatten(self, unixfrom=unixfrom, linesep=linesep) 141 | return fp.getvalue() 142 | 143 | 144 | class SafeMIMEMessage(MIMEMixin, MIMEMessage): 145 | def __setitem__(self, name, val): 146 | # message/rfc822 attachments must be ASCII 147 | name, val = forbid_multi_line_headers(name, val, 'ascii') 148 | MIMEMessage.__setitem__(self, name, val) 149 | 150 | 151 | class SafeMIMEText(MIMEMixin, MIMEText): 152 | def __init__(self, _text, _subtype='plain', _charset=None): 153 | self.encoding = _charset 154 | MIMEText.__init__(self, _text, _subtype=_subtype, _charset=_charset) 155 | 156 | def __setitem__(self, name, val): 157 | name, val = forbid_multi_line_headers(name, val, self.encoding) 158 | MIMEText.__setitem__(self, name, val) 159 | 160 | def set_payload(self, payload, charset=None): 161 | if charset == 'utf-8' and not isinstance(charset, Charset.Charset): 162 | has_long_lines = any(len(line.encode(errors="surrogateescape")) > RFC5322_EMAIL_LINE_LENGTH_LIMIT for line in payload.splitlines()) 163 | # Quoted-Printable encoding has the side effect of shortening long 164 | # lines, if any (#22561). 165 | charset = utf8_charset_qp if has_long_lines else utf8_charset 166 | MIMEText.set_payload(self, payload, charset=charset) 167 | 168 | 169 | class SafeMIMEMultipart(MIMEMixin, MIMEMultipart): 170 | def __init__(self, _subtype='mixed', boundary=None, _subparts=None, encoding=None, **_params): 171 | self.encoding = encoding 172 | MIMEMultipart.__init__(self, _subtype, boundary, _subparts, **_params) 173 | 174 | def __setitem__(self, name, val): 175 | name, val = forbid_multi_line_headers(name, val, self.encoding) 176 | MIMEMultipart.__setitem__(self, name, val) 177 | 178 | 179 | class EmailMessage: 180 | """A container for email information.""" 181 | 182 | content_subtype = 'plain' 183 | mixed_subtype = 'mixed' 184 | encoding = None # None => use settings default 185 | 186 | def __init__( 187 | self, 188 | subject='', 189 | body='', 190 | from_email=None, 191 | to=None, 192 | bcc=None, 193 | connection=None, 194 | attachments=None, 195 | headers=None, 196 | cc=None, 197 | reply_to=None, 198 | ): 199 | """ 200 | Initialize a single email message (which can be sent to multiple 201 | recipients). 202 | """ 203 | if to: 204 | if isinstance(to, str): 205 | raise TypeError('"to" argument must be a list or tuple') 206 | self.to = list(to) 207 | else: 208 | self.to = [] 209 | if cc: 210 | if isinstance(cc, str): 211 | raise TypeError('"cc" argument must be a list or tuple') 212 | self.cc = list(cc) 213 | else: 214 | self.cc = [] 215 | if bcc: 216 | if isinstance(bcc, str): 217 | raise TypeError('"bcc" argument must be a list or tuple') 218 | self.bcc = list(bcc) 219 | else: 220 | self.bcc = [] 221 | if reply_to: 222 | if isinstance(reply_to, str): 223 | raise TypeError('"reply_to" argument must be a list or tuple') 224 | self.reply_to = list(reply_to) 225 | else: 226 | self.reply_to = [] 227 | self.from_email = from_email or current_app.extensions['mailman'].default_sender 228 | if isinstance(self.from_email, tuple): 229 | self.from_email = formataddr(self.from_email) 230 | self.subject = subject 231 | self.body = body or '' 232 | self.attachments = [] 233 | if attachments: 234 | for attachment in attachments: 235 | if isinstance(attachment, MIMEBase): 236 | self.attach(attachment) 237 | else: 238 | self.attach(*attachment) 239 | self.extra_headers = headers or {} 240 | self.connection = connection 241 | 242 | def get_connection(self, fail_silently=False): 243 | if not self.connection: 244 | try: 245 | self.connection = current_app.extensions['mailman'].get_connection(fail_silently=fail_silently) 246 | except KeyError: 247 | raise RuntimeError("The current application was not configured with Flask-Mailman") 248 | return self.connection 249 | 250 | def message(self): 251 | encoding = self.encoding or current_app.extensions['mailman'].default_charset 252 | msg = SafeMIMEText(self.body, self.content_subtype, encoding) 253 | msg = self._create_message(msg) 254 | msg['Subject'] = self.subject 255 | msg['From'] = self.extra_headers.get('From', self.from_email) 256 | self._set_list_header_if_not_empty(msg, 'To', self.to) 257 | self._set_list_header_if_not_empty(msg, 'Cc', self.cc) 258 | self._set_list_header_if_not_empty(msg, 'Reply-To', self.reply_to) 259 | 260 | # Email header names are case-insensitive (RFC 2045), so we have to 261 | # accommodate that when doing comparisons. 262 | header_names = [key.lower() for key in self.extra_headers] 263 | if 'date' not in header_names: 264 | # formatdate() uses stdlib methods to format the date, which use 265 | # the stdlib/OS concept of a timezone, however, Django sets the 266 | # TZ environment variable based on the TIME_ZONE setting which 267 | # will get picked up by formatdate(). 268 | msg['Date'] = formatdate(localtime=current_app.extensions['mailman'].use_localtime) 269 | if 'message-id' not in header_names: 270 | # Use cached DNS_NAME for performance 271 | msg['Message-ID'] = make_msgid(domain=DNS_NAME) 272 | for name, value in self.extra_headers.items(): 273 | if name.lower() != 'from': # From is already handled 274 | msg[name] = value 275 | return msg 276 | 277 | def recipients(self): 278 | """ 279 | Return a list of all recipients of the email (includes direct 280 | addressees as well as Cc and Bcc entries). 281 | """ 282 | return [email for email in (self.to + self.cc + self.bcc) if email] 283 | 284 | def send(self, fail_silently=False): 285 | """Send the email message.""" 286 | if not self.recipients(): 287 | # Don't bother creating the network connection if there's nobody to 288 | # send to. 289 | return 0 290 | return self.get_connection(fail_silently).send_messages([self]) 291 | 292 | def attach(self, filename=None, content=None, mimetype=None): 293 | """ 294 | Attach a file with the given filename and content. The filename can 295 | be omitted and the mimetype is guessed, if not provided. 296 | 297 | If the first parameter is a MIMEBase subclass, insert it directly 298 | into the resulting message attachments. 299 | 300 | For a text/* mimetype (guessed or specified), when a bytes object is 301 | specified as content, decode it as UTF-8. If that fails, set the 302 | mimetype to DEFAULT_ATTACHMENT_MIME_TYPE and don't decode the content. 303 | """ 304 | if isinstance(filename, MIMEBase): 305 | if content is not None or mimetype is not None: 306 | raise ValueError('content and mimetype must not be given when a MIMEBase ' 'instance is provided.') 307 | self.attachments.append(filename) 308 | elif content is None: 309 | raise ValueError('content must be provided.') 310 | else: 311 | mimetype = mimetype or mimetypes.guess_type(filename)[0] or DEFAULT_ATTACHMENT_MIME_TYPE 312 | basetype, subtype = mimetype.split('/', 1) 313 | 314 | if basetype == 'text': 315 | if isinstance(content, bytes): 316 | try: 317 | content = content.decode() 318 | except UnicodeDecodeError: 319 | # If mimetype suggests the file is text but it's 320 | # actually binary, read() raises a UnicodeDecodeError. 321 | mimetype = DEFAULT_ATTACHMENT_MIME_TYPE 322 | 323 | self.attachments.append((filename, content, mimetype)) 324 | 325 | def attach_file(self, path, mimetype=None): 326 | """ 327 | Attach a file from the filesystem. 328 | 329 | Set the mimetype to DEFAULT_ATTACHMENT_MIME_TYPE if it isn't specified 330 | and cannot be guessed. 331 | 332 | For a text/* mimetype (guessed or specified), decode the file's content 333 | as UTF-8. If that fails, set the mimetype to 334 | DEFAULT_ATTACHMENT_MIME_TYPE and don't decode the content. 335 | """ 336 | path = Path(path) 337 | with path.open('rb') as file: 338 | content = file.read() 339 | self.attach(path.name, content, mimetype) 340 | 341 | def _create_message(self, msg): 342 | return self._create_attachments(msg) 343 | 344 | def _create_attachments(self, msg): 345 | if self.attachments: 346 | encoding = self.encoding or current_app.extensions['mailman'].default_charset 347 | body_msg = msg 348 | msg = SafeMIMEMultipart(_subtype=self.mixed_subtype, encoding=encoding) 349 | if self.body or body_msg.is_multipart(): 350 | msg.attach(body_msg) 351 | for attachment in self.attachments: 352 | if isinstance(attachment, MIMEBase): 353 | msg.attach(attachment) 354 | else: 355 | msg.attach(self._create_attachment(*attachment)) 356 | return msg 357 | 358 | def _create_mime_attachment(self, content, mimetype): 359 | """ 360 | Convert the content, mimetype pair into a MIME attachment object. 361 | 362 | If the mimetype is message/rfc822, content may be an 363 | email.Message or EmailMessage object, as well as a str. 364 | """ 365 | basetype, subtype = mimetype.split('/', 1) 366 | if basetype == 'text': 367 | encoding = self.encoding or current_app.extensions['mailman'].default_charset 368 | attachment = SafeMIMEText(content, subtype, encoding) 369 | elif basetype == 'message' and subtype == 'rfc822': 370 | # Bug #18967: per RFC2046 s5.2.1, message/rfc822 attachments 371 | # must not be base64 encoded. 372 | if isinstance(content, EmailMessage): 373 | # convert content into an email.Message first 374 | content = content.message() 375 | elif not isinstance(content, Message): 376 | # For compatibility with existing code, parse the message 377 | # into an email.Message object if it is not one already. 378 | content = message_from_string(force_str(content)) 379 | 380 | attachment = SafeMIMEMessage(content, subtype) 381 | else: 382 | # Encode non-text attachments with base64. 383 | attachment = MIMEBase(basetype, subtype) 384 | attachment.set_payload(content) 385 | Encoders.encode_base64(attachment) 386 | return attachment 387 | 388 | def _create_attachment(self, filename, content, mimetype=None): 389 | """ 390 | Convert the filename, content, mimetype triple into a MIME attachment 391 | object. 392 | """ 393 | attachment = self._create_mime_attachment(content, mimetype) 394 | if filename: 395 | try: 396 | filename.encode('ascii') 397 | except UnicodeEncodeError: 398 | filename = ('utf-8', '', filename) 399 | attachment.add_header('Content-Disposition', 'attachment', filename=filename) 400 | return attachment 401 | 402 | def _set_list_header_if_not_empty(self, msg, header, values): 403 | """ 404 | Set msg's header, either from self.extra_headers, if present, or from 405 | the values argument. 406 | """ 407 | if values: 408 | try: 409 | value = self.extra_headers[header] 410 | except KeyError: 411 | value = ', '.join(str(v) for v in values) 412 | msg[header] = value 413 | 414 | 415 | class EmailMultiAlternatives(EmailMessage): 416 | """ 417 | A version of EmailMessage that makes it easy to send multipart/alternative 418 | messages. For example, including text and HTML versions of the text is 419 | made easier. 420 | """ 421 | 422 | alternative_subtype = 'alternative' 423 | 424 | def __init__( 425 | self, 426 | subject='', 427 | body='', 428 | from_email=None, 429 | to=None, 430 | bcc=None, 431 | connection=None, 432 | attachments=None, 433 | headers=None, 434 | alternatives=None, 435 | cc=None, 436 | reply_to=None, 437 | ): 438 | """ 439 | Initialize a single email message (which can be sent to multiple 440 | recipients). 441 | """ 442 | super().__init__( 443 | subject, 444 | body, 445 | from_email, 446 | to, 447 | bcc, 448 | connection, 449 | attachments, 450 | headers, 451 | cc, 452 | reply_to, 453 | ) 454 | self.alternatives = alternatives or [] 455 | 456 | def attach_alternative(self, content, mimetype): 457 | """Attach an alternative content representation.""" 458 | if content is None or mimetype is None: 459 | raise ValueError('Both content and mimetype must be provided.') 460 | self.alternatives.append((content, mimetype)) 461 | 462 | def _create_message(self, msg): 463 | return self._create_attachments(self._create_alternatives(msg)) 464 | 465 | def _create_alternatives(self, msg): 466 | encoding = self.encoding or current_app.extensions['mailman'].default_charset 467 | if self.alternatives: 468 | body_msg = msg 469 | msg = SafeMIMEMultipart(_subtype=self.alternative_subtype, encoding=encoding) 470 | if self.body: 471 | msg.attach(body_msg) 472 | for alternative in self.alternatives: 473 | msg.attach(self._create_mime_attachment(*alternative)) 474 | return msg 475 | -------------------------------------------------------------------------------- /tests/test_backend.py: -------------------------------------------------------------------------------- 1 | from email.header import Header 2 | from email.utils import parseaddr 3 | import os 4 | import socket 5 | from ssl import SSLError 6 | import tempfile 7 | from unittest import mock 8 | import pytest 9 | 10 | from pathlib import Path 11 | from unittest.mock import Mock, patch 12 | from smtplib import SMTP, SMTPException 13 | from email import message_from_binary_file, message_from_bytes 14 | from io import StringIO 15 | from flask_mailman import EmailMessage 16 | from flask_mailman.backends import locmem, smtp 17 | from tests import MailmanCustomizedTestCase 18 | from aiosmtpd.controller import Controller 19 | 20 | 21 | class SMTPHandler: 22 | def __init__(self, *args, **kwargs): 23 | self.mailbox = [] 24 | 25 | async def handle_DATA(self, server, session, envelope): 26 | data = envelope.content 27 | mail_from = envelope.mail_from 28 | 29 | message = message_from_bytes(data.rstrip()) 30 | message_addr = parseaddr(message.get("from"))[1] 31 | if mail_from != message_addr: 32 | # According to the spec, mail_from does not necessarily match the 33 | # From header - this is the case where the local part isn't 34 | # encoded, so try to correct that. 35 | lp, domain = mail_from.split("@", 1) 36 | lp = Header(lp, "utf-8").encode() 37 | mail_from = "@".join([lp, domain]) 38 | 39 | if mail_from != message_addr: 40 | return f"553 '{mail_from}' != '{message_addr}'" 41 | self.mailbox.append(message) 42 | return "250 OK" 43 | 44 | def flush_mailbox(self): 45 | self.mailbox[:] = [] 46 | 47 | 48 | class SmtpdContext: 49 | def __init__(self, mailman): 50 | self.mailman = mailman 51 | 52 | def __enter__(self): 53 | # Find a free port. 54 | with socket.socket() as s: 55 | s.bind(("127.0.0.1", 0)) 56 | port = s.getsockname()[1] 57 | self.smtp_handler = SMTPHandler() 58 | self.smtp_controller = Controller( 59 | self.smtp_handler, 60 | hostname="127.0.0.1", 61 | port=port, 62 | ) 63 | self.mailman.port = port 64 | self.smtp_controller.start() 65 | 66 | def __exit__(self, *args): 67 | self.smtp_controller.stop() 68 | 69 | 70 | class TestBackend(MailmanCustomizedTestCase): 71 | @classmethod 72 | def setUpClass(cls): 73 | super().setUpClass() 74 | 75 | @classmethod 76 | def stop_smtp(cls): 77 | cls.smtp_controller.stop() 78 | 79 | def test_console_backend(self): 80 | self.app.extensions['mailman'].backend = 'console' 81 | msg = EmailMessage( 82 | subject="testing", 83 | to=["to@example.com"], 84 | body="testing", 85 | ) 86 | msg.send() 87 | 88 | captured = self.capsys.readouterr() 89 | assert "testing" in captured.out 90 | assert "To: to@example.com" in captured.out 91 | 92 | def test_console_stream_kwarg(self): 93 | """ 94 | The console backend can be pointed at an arbitrary stream. 95 | """ 96 | self.app.extensions['mailman'].backend = 'console' 97 | s = StringIO() 98 | connection = self.mail.get_connection(stream=s) 99 | self.mail.send_mail( 100 | "Subject", 101 | "Content", 102 | "from@example.com", 103 | ["to@example.com"], 104 | connection=connection, 105 | ) 106 | message = s.getvalue().split("\n" + ("-" * 79) + "\n")[0].encode() 107 | self.assertMessageHasHeaders( 108 | message, 109 | { 110 | ("MIME-Version", "1.0"), 111 | ("Content-Type", 'text/plain; charset="utf-8"'), 112 | ("Content-Transfer-Encoding", "7bit"), 113 | ("Subject", "Subject"), 114 | ("From", "from@example.com"), 115 | ("To", "to@example.com"), 116 | }, 117 | ) 118 | self.assertIn(b"\nDate: ", message) 119 | 120 | def test_dummy_backend(self): 121 | self.app.extensions['mailman'].backend = 'dummy' 122 | msg = EmailMessage( 123 | subject="testing", 124 | to=["to@example.com"], 125 | body="testing", 126 | ) 127 | assert msg.send() == 1 128 | 129 | def test_file_backend(self): 130 | with tempfile.TemporaryDirectory() as tempdir: 131 | self.app.extensions['mailman'].backend = 'file' 132 | self.app.extensions['mailman'].file_path = tempdir 133 | with self.mail.get_connection() as conn: 134 | msg = EmailMessage( 135 | subject="testing", 136 | to=["to@example.com"], 137 | body="testing", 138 | connection=conn, 139 | ) 140 | msg.send() 141 | 142 | wrote_file = Path(conn._fname) 143 | assert wrote_file.is_file() 144 | assert "To: to@example.com" in wrote_file.read_text() 145 | 146 | def test_file_sessions(self): 147 | """Make sure opening a connection creates a new file""" 148 | with tempfile.TemporaryDirectory() as tempdir: 149 | self.app.extensions['mailman'].backend = 'file' 150 | self.app.extensions['mailman'].file_path = tempdir 151 | msg = EmailMessage( 152 | "Subject", 153 | "Content", 154 | "bounce@example.com", 155 | ["to@example.com"], 156 | headers={"From": "from@example.com"}, 157 | ) 158 | connection = self.mail.get_connection() 159 | connection.send_messages([msg]) 160 | 161 | self.assertEqual(len(os.listdir(tempdir)), 1) 162 | with open(os.path.join(tempdir, os.listdir(tempdir)[0]), "rb") as fp: 163 | message = message_from_binary_file(fp) 164 | self.assertEqual(message.get_content_type(), "text/plain") 165 | self.assertEqual(message.get("subject"), "Subject") 166 | self.assertEqual(message.get("from"), "from@example.com") 167 | self.assertEqual(message.get("to"), "to@example.com") 168 | 169 | connection2 = self.mail.get_connection() 170 | connection2.send_messages([msg]) 171 | self.assertEqual(len(os.listdir(tempdir)), 2) 172 | 173 | connection.send_messages([msg]) 174 | self.assertEqual(len(os.listdir(tempdir)), 2) 175 | 176 | msg.connection = self.mail.get_connection() 177 | self.assertTrue(connection.open()) 178 | msg.send() 179 | self.assertEqual(len(os.listdir(tempdir)), 3) 180 | msg.send() 181 | self.assertEqual(len(os.listdir(tempdir)), 3) 182 | 183 | connection.close() 184 | 185 | def test_locmem_backend(self): 186 | self.app.extensions['mailman'].backend = 'locmem' 187 | msg = EmailMessage( 188 | subject="testing", 189 | to=["to@example.com"], 190 | body="testing", 191 | ) 192 | msg.send() 193 | 194 | self.assertEqual(len(self.mail.outbox), 1) 195 | sent_msg = self.mail.outbox[0] 196 | self.assertEqual(sent_msg.subject, "testing") 197 | self.assertEqual(sent_msg.to, ["to@example.com"]) 198 | self.assertEqual(sent_msg.body, "testing") 199 | self.assertEqual(sent_msg.from_email, self.app.extensions["mailman"].default_sender) 200 | 201 | def test_locmem_shared_messages(self): 202 | """ 203 | Make sure that the locmen backend populates the outbox. 204 | """ 205 | self.app.extensions['mailman'].backend = 'locmem' 206 | connection = locmem.EmailBackend() 207 | connection2 = locmem.EmailBackend() 208 | email = EmailMessage( 209 | "Subject", 210 | "Content", 211 | "bounce@example.com", 212 | ["to@example.com"], 213 | headers={"From": "from@example.com"}, 214 | ) 215 | connection.send_messages([email]) 216 | connection2.send_messages([email]) 217 | self.assertEqual(len(self.mail.outbox), 2) 218 | 219 | def test_smtp_backend(self): 220 | self.app.extensions['mailman'].backend = 'smtp' 221 | msg = EmailMessage( 222 | subject="testing", 223 | to=["to@example.com"], 224 | body="testing", 225 | ) 226 | 227 | with patch.object(smtp.EmailBackend, 'send_messages') as mock_send_fn: 228 | mock_send_fn.return_value = 66 229 | assert msg.send() == 66 230 | 231 | def test_email_authentication_use_settings(self): 232 | mailman = self.app.extensions['mailman'] 233 | mailman.username = 'not empty username' 234 | mailman.password = 'not empty password' 235 | 236 | backend = smtp.EmailBackend() 237 | self.assertEqual(backend.username, "not empty username") 238 | self.assertEqual(backend.password, "not empty password") 239 | 240 | def test_email_disabled_authentication(self): 241 | mailman = self.app.extensions['mailman'] 242 | mailman.username = 'not empty username' 243 | mailman.password = 'not empty password' 244 | 245 | backend = smtp.EmailBackend(username="", password="") 246 | self.assertEqual(backend.username, "") 247 | self.assertEqual(backend.password, "") 248 | 249 | def test_auth_attempted(self): 250 | """ 251 | Opening the backend with non empty username/password tries 252 | to authenticate against the SMTP server. 253 | """ 254 | with SmtpdContext(self.app.extensions['mailman']): 255 | with self.assertRaises(SMTPException, msg="SMTP AUTH extension not supported by server."): 256 | with smtp.EmailBackend(username="not empty username", password="not empty password"): 257 | pass 258 | 259 | def test_server_open(self): 260 | """ 261 | open() returns whether it opened a connection. 262 | """ 263 | with SmtpdContext(self.app.extensions['mailman']): 264 | backend = smtp.EmailBackend(username="", password="") 265 | self.assertIsNone(backend.connection) 266 | opened = backend.open() 267 | backend.close() 268 | self.assertIs(opened, True) 269 | 270 | def test_reopen_connection(self): 271 | backend = smtp.EmailBackend() 272 | # Simulate an already open connection. 273 | backend.connection = Mock(spec=object()) 274 | self.assertIs(backend.open(), False) 275 | 276 | def test_email_tls_use_settings(self): 277 | self.app.extensions['mailman'].use_tls = True 278 | backend = smtp.EmailBackend() 279 | self.assertTrue(backend.use_tls) 280 | 281 | def test_email_tls_override_settings(self): 282 | self.app.extensions['mailman'].use_tls = True 283 | backend = smtp.EmailBackend(use_tls=False) 284 | self.assertFalse(backend.use_tls) 285 | 286 | def test_email_tls_default_disabled(self): 287 | backend = smtp.EmailBackend() 288 | self.assertFalse(backend.use_tls) 289 | 290 | def test_ssl_tls_mutually_exclusive(self): 291 | msg = "USE_TLS/USE_SSL are mutually exclusive, so only set " "one of those settings to True." 292 | with self.assertRaises(ValueError, msg=msg): 293 | smtp.EmailBackend(use_ssl=True, use_tls=True) 294 | 295 | def test_email_ssl_use_settings(self): 296 | self.app.extensions['mailman'].use_ssl = True 297 | backend = smtp.EmailBackend() 298 | self.assertTrue(backend.use_ssl) 299 | 300 | def test_email_ssl_override_settings(self): 301 | self.app.extensions['mailman'].use_ssl = True 302 | backend = smtp.EmailBackend(use_ssl=False) 303 | self.assertFalse(backend.use_ssl) 304 | 305 | def test_email_ssl_default_disabled(self): 306 | backend = smtp.EmailBackend() 307 | self.assertFalse(backend.use_ssl) 308 | 309 | def test_email_ssl_certfile_use_settings(self): 310 | self.app.extensions['mailman'].ssl_certfile = "foo" 311 | backend = smtp.EmailBackend() 312 | self.assertEqual(backend.ssl_certfile, "foo") 313 | 314 | def test_email_ssl_certfile_override_settings(self): 315 | self.app.extensions['mailman'].ssl_certfile = "foo" 316 | backend = smtp.EmailBackend(ssl_certfile="bar") 317 | self.assertEqual(backend.ssl_certfile, "bar") 318 | 319 | def test_email_ssl_certfile_default_disabled(self): 320 | backend = smtp.EmailBackend() 321 | self.assertIsNone(backend.ssl_certfile) 322 | 323 | def test_email_ssl_keyfile_use_settings(self): 324 | self.app.extensions['mailman'].ssl_keyfile = "foo" 325 | backend = smtp.EmailBackend() 326 | self.assertEqual(backend.ssl_keyfile, "foo") 327 | 328 | def test_email_ssl_keyfile_override_settings(self): 329 | self.app.extensions['mailman'].ssl_keyfile = "foo" 330 | backend = smtp.EmailBackend(ssl_keyfile="bar") 331 | self.assertEqual(backend.ssl_keyfile, "bar") 332 | 333 | def test_email_ssl_keyfile_default_disabled(self): 334 | backend = smtp.EmailBackend() 335 | self.assertIsNone(backend.ssl_keyfile) 336 | 337 | def test_email_tls_attempts_starttls(self): 338 | with SmtpdContext(self.app.extensions['mailman']): 339 | self.app.extensions['mailman'].use_tls = True 340 | backend = smtp.EmailBackend() 341 | self.assertTrue(backend.use_tls) 342 | with self.assertRaises(SMTPException, msg="STARTTLS extension not supported by server."): 343 | with backend: 344 | pass 345 | 346 | def test_email_ssl_attempts_ssl_connection(self): 347 | fake_keyfile = os.path.join(os.path.dirname(__file__), "attachments", 'file.txt') 348 | fake_certfile = os.path.join(os.path.dirname(__file__), "attachments", 'file_txt') 349 | with SmtpdContext(self.app.extensions['mailman']): 350 | self.app.extensions['mailman'].use_ssl = True 351 | self.app.extensions['mailman'].ssl_keyfile = fake_keyfile 352 | self.app.extensions['mailman'].ssl_certfile = fake_certfile 353 | 354 | backend = smtp.EmailBackend() 355 | self.assertTrue(backend.use_ssl) 356 | with self.assertRaises(SSLError): 357 | with backend: 358 | pass 359 | 360 | @mock.patch("ssl.SSLContext.load_cert_chain", return_value="") 361 | def test_email_ssl_cached_context(self, result_mocked): 362 | fake_keyfile = os.path.join(os.path.dirname(__file__), "attachments", 'file.txt') 363 | fake_certfile = os.path.join(os.path.dirname(__file__), "attachments", 'file_txt') 364 | 365 | with SmtpdContext(self.app.extensions['mailman']): 366 | self.app.extensions['mailman'].use_ssl = True 367 | 368 | backend_one = smtp.EmailBackend() 369 | backend_another = smtp.EmailBackend() 370 | 371 | self.assertTrue(backend_one.ssl_context, backend_another.ssl_context) 372 | 373 | self.app.extensions['mailman'].ssl_keyfile = fake_keyfile 374 | self.app.extensions['mailman'].ssl_certfile = fake_certfile 375 | 376 | backend_one = smtp.EmailBackend() 377 | backend_another = smtp.EmailBackend() 378 | 379 | self.assertTrue(backend_one.ssl_context, backend_another.ssl_context) 380 | 381 | def test_connection_timeout_default(self): 382 | """The connection's timeout value is None by default.""" 383 | self.app.extensions['mailman'].backend = "smtp" 384 | connection = self.mail.get_connection() 385 | self.assertIsNone(connection.timeout) 386 | 387 | def test_connection_timeout_custom(self): 388 | """The timeout parameter can be customized.""" 389 | with SmtpdContext(self.app.extensions['mailman']): 390 | 391 | class MyEmailBackend(smtp.EmailBackend): 392 | def __init__(self, *args, **kwargs): 393 | kwargs.setdefault("timeout", 42) 394 | super().__init__(*args, **kwargs) 395 | 396 | myemailbackend = MyEmailBackend() 397 | myemailbackend.open() 398 | self.assertEqual(myemailbackend.timeout, 42) 399 | self.assertEqual(myemailbackend.connection.timeout, 42) 400 | myemailbackend.close() 401 | 402 | def test_email_timeout_override_settings(self): 403 | self.app.extensions['mailman'].timeout = 10 404 | backend = smtp.EmailBackend() 405 | self.assertEqual(backend.timeout, 10) 406 | 407 | def test_email_msg_uses_crlf(self): 408 | """Compliant messages are sent over SMTP.""" 409 | with SmtpdContext(self.app.extensions['mailman']): 410 | send = SMTP.send 411 | try: 412 | smtp_messages = [] 413 | 414 | def mock_send(self, s): 415 | smtp_messages.append(s) 416 | return send(self, s) 417 | 418 | SMTP.send = mock_send 419 | 420 | email = EmailMessage("Subject", "Content", "from@example.com", ["to@example.com"]) 421 | self.mail.get_connection(backend='smtp').send_messages([email]) 422 | 423 | # Find the actual message 424 | msg = None 425 | for i, m in enumerate(smtp_messages): 426 | if m[:4] == "data": 427 | msg = smtp_messages[i + 1] 428 | break 429 | 430 | self.assertTrue(msg) 431 | 432 | msg = msg.decode() 433 | # The message only contains CRLF and not combinations of CRLF, LF, and CR. 434 | msg = msg.replace("\r\n", "") 435 | self.assertNotIn("\r", msg) 436 | self.assertNotIn("\n", msg) 437 | 438 | finally: 439 | SMTP.send = send 440 | 441 | def test_send_messages_after_open_failed(self): 442 | """ 443 | send_messages() shouldn't try to send messages if open() raises an 444 | exception after initializing the connection. 445 | """ 446 | backend = smtp.EmailBackend() 447 | # Simulate connection initialization success and a subsequent 448 | # connection exception. 449 | backend.connection = Mock(spec=object()) 450 | backend.open = lambda: None 451 | email = EmailMessage("Subject", "Content", "from@example.com", ["to@example.com"]) 452 | self.assertEqual(backend.send_messages([email]), 0) 453 | 454 | def test_send_messages_empty_list(self): 455 | backend = smtp.EmailBackend() 456 | backend.connection = Mock(spec=object()) 457 | self.assertEqual(backend.send_messages([]), 0) 458 | 459 | def test_send_messages_zero_sent(self): 460 | """A message isn't sent if it doesn't have any recipients.""" 461 | backend = smtp.EmailBackend() 462 | backend.connection = Mock(spec=object()) 463 | email = EmailMessage("Subject", "Content", "from@example.com", to=[]) 464 | sent = backend.send_messages([email]) 465 | self.assertEqual(sent, 0) 466 | 467 | def test_server_stopped(self): 468 | """ 469 | Closing the backend while the SMTP server is stopped doesn't raise an 470 | exception. 471 | """ 472 | with SmtpdContext(self.app.extensions['mailman']): 473 | self.mail.get_connection('smtp').close() 474 | 475 | def test_fail_silently_on_connection_error(self): 476 | """ 477 | A socket connection error is silenced with fail_silently=True. 478 | """ 479 | backend = self.mail.get_connection('smtp') 480 | with self.assertRaises(ConnectionError): 481 | backend.open() 482 | backend.fail_silently = True 483 | backend.open() 484 | 485 | def test_invalid_backend(self): 486 | self.app.extensions['mailman'].backend = 'unknown' 487 | msg = EmailMessage( 488 | subject="testing", 489 | to=["to@example.com"], 490 | body="testing", 491 | ) 492 | 493 | with pytest.raises(RuntimeError) as exc: 494 | msg.send() 495 | assert "The available built-in mail backends" in str(exc) 496 | 497 | def test_override_custom_backend(self): 498 | self.app.extensions['mailman'].backend = 'console' 499 | with self.mail.get_connection(backend=locmem.EmailBackend) as conn: 500 | msg = EmailMessage(subject="testing", to=["to@example.com"], body="testing", connection=conn) 501 | msg.send() 502 | 503 | self.assertEqual(len(self.mail.outbox), 1) 504 | sent_msg = self.mail.outbox[0] 505 | self.assertEqual(sent_msg.subject, "testing") 506 | 507 | def test_import_path_locmem_backend(self): 508 | for i, backend_path in enumerate( 509 | ["flask_mailman.backends.locmem", "flask_mailman.backends.locmem.EmailBackend"] 510 | ): 511 | with self.subTest(): 512 | self.app.extensions['mailman'].backend = backend_path 513 | msg = EmailMessage( 514 | subject="testing", 515 | to=["to@example.com"], 516 | body="testing", 517 | ) 518 | msg.send() 519 | 520 | self.assertEqual(len(self.mail.outbox), i + 1) 521 | sent_msg = self.mail.outbox[0] 522 | self.assertEqual(sent_msg.subject, "testing") 523 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Welcome to Flask-Mailman 2 | 3 | The core part of this extension's source code comes directly from Django's mail module, but with a few interface [differences](#differences-with-django). 4 | 5 | And following documentation also draws heavily from Django's. 6 | 7 | ## Installation 8 | 9 | Install with `pip`: 10 | ```bash 11 | pip install Flask-Mailman 12 | ``` 13 | 14 | ## Configuration 15 | 16 | Flask-Mailman is configured through the standard Flask config API. A list of configuration keys currently understood by the extension: 17 | 18 | - **MAIL_SERVER**: The host to use for sending email. 19 | 20 | default ‘localhost’. 21 | 22 | - **MAIL_PORT**: Port to use for the SMTP server. 23 | 24 | default 25. 25 | 26 | - **MAIL_USERNAME**: Username to use for the SMTP server. If empty, Flask-Mailman won’t attempt authentication. 27 | 28 | default None. 29 | 30 | - **MAIL_PASSWORD**: Password to use for the SMTP server defined in MAIL_HOST. This setting is used in conjunction with MAIL_USERNAME when authenticating to the SMTP server. If either of these configs is empty, Flask-Mailman won’t attempt authentication. 31 | 32 | default None. 33 | 34 | - **MAIL_USE_TLS**: Whether to use a TLS (secure) connection when talking to the SMTP server. This is used for explicit TLS connections, generally on port 587. 35 | 36 | default False. 37 | 38 | - **MAIL_USE_SSL**: Whether to use an implicit TLS (secure) connection when talking to the SMTP server. In most email documentation this type of TLS connection is referred to as SSL. It is generally used on port 465. 39 | 40 | default False 41 | 42 | - **MAIL_TIMEOUT**: Specifies a timeout in seconds for blocking operations like the connection attempt. 43 | 44 | default None. 45 | 46 | - **MAIL_SSL_KEYFILE**: If MAIL_USE_SSL or MAIL_USE_TLS is True, you can optionally specify the path to a PEM-formatted certificate chain file to use for the SSL connection. 47 | 48 | default None. 49 | 50 | - **MAIL_SSL_CERTFILE**: If MAIL_USE_SSL or MAIL_USE_TLS is True, you can optionally specify the path to a PEM-formatted private key file to use for the SSL connection. 51 | 52 | Note that setting MAIL_SSL_CERTFILE and MAIL_SSL_KEYFILE doesn’t result in any certificate checking. They’re passed to the underlying SSL connection. Please refer to the documentation of Python’s `ssl.wrap_socket()` function for details on how the certificate chain file and private key file are handled. 53 | 54 | default None. 55 | 56 | - **MAIL_DEFAULT_SENDER**: Default email address to use for various automated correspondence from the site manager(s). 57 | 58 | default None. 59 | 60 | - **MAIL_BACKEND**: The backend to use for sending emails. 61 | 62 | Default: 'smtp'. In addition the standard Flask TESTING configuration option is used for testing. When `TESTING=True` and `MAIL_BACKEND` is not provided, default will be set to 'locmem'. 63 | 64 | - **MAIL_FILE_PATH**: The directory used by the file email backend to store output files. 65 | 66 | Default: Not defined. 67 | 68 | - **MAIL_USE_LOCALTIME**: Whether to send the SMTP **Date** header of email messages in the local time zone (True) or in UTC (False). 69 | 70 | Default: False. 71 | 72 | - **MAIL_SEND_OPTIONS**: `mail_options` for `smtplib.SMTP.sendmail`. If `SMTPUTF8` is included in `MAIL_SEND_OPTIONS`, and the server supports it, `from_mail` and `to` may contain non-ASCII characters. 73 | 74 | Default: `[]` 75 | 76 | Emails are managed through a *Mail* instance: 77 | ```python 78 | from flask import Flask 79 | from flask_mailman import Mail 80 | 81 | app = Flask(__name__) 82 | mail = Mail(app) 83 | ``` 84 | In this case all emails are sent using the configuration values of the application that was passed to the *Mail* class constructor. 85 | 86 | Alternatively you can set up your *Mail* instance later at configuration time, using the `init_app` method: 87 | ```python 88 | from flask import Flask 89 | from flask_mailman import Mail 90 | 91 | mail = Mail() 92 | 93 | app = Flask(__name__) 94 | mail.init_app(app) 95 | ``` 96 | In this case emails will be sent using the configuration values from Flask’s `current_app` context global. This is useful if you have multiple applications running in the same process but with different configuration options. 97 | 98 | ## Sending messages 99 | 100 | To send a message first create a `EmailMessage` instance: 101 | ```python hl_lines="3-11" 102 | from flask_mailman import EmailMessage 103 | 104 | msg = EmailMessage( 105 | 'Hello', 106 | 'Body goes here', 107 | 'from@example.com', 108 | ['to1@example.com', 'to2@example.com'], 109 | ['bcc@example.com'], 110 | reply_to=['another@example.com'], 111 | headers={'Message-ID': 'foo'}, 112 | ) 113 | msg.send() 114 | ``` 115 | Then send the message using `send()` method: 116 | ```python hl_lines="12 13" 117 | from flask_mailman import EmailMessage 118 | 119 | msg = EmailMessage( 120 | 'Hello', 121 | 'Body goes here', 122 | 'from@example.com', 123 | ['to1@example.com', 'to2@example.com'], 124 | ['bcc@example.com'], 125 | reply_to=['another@example.com'], 126 | headers={'Message-ID': 'foo'}, 127 | ) 128 | msg.send() 129 | ``` 130 | 131 | The `EmailMessage` class is initialized with the following parameters (in the given order, if positional arguments are used). 132 | All parameters are optional and can be set at any time prior to calling the `send()` method. 133 | 134 | - **subject**: The subject line of the email. 135 | - **body**: The body text. This should be a plain text message. 136 | - **from_email**: The sender’s address. Both `fred@example.com` or `"Fred" ` forms are legal. If omitted, the MAIL_DEFAULT_SENDER config is used. 137 | - **to**: A list or tuple of recipient addresses. 138 | - **bcc**: A list or tuple of addresses used in the “Bcc” header when sending the email. 139 | - **connection**: An email backend instance. Use this parameter if you want to use the same connection for multiple messages. If omitted, a new connection is created when `send()` is called. 140 | - **attachments**: A list of attachments to put on the message. These can be either MIMEBase instances, or (filename, content, mimetype) triples. 141 | - **headers**: A dictionary of extra headers to put on the message. The keys are the header name, values are the header values. It’s up to the caller to ensure header names and values are in the correct format for an email message. The corresponding attribute is `extra_headers`. 142 | - **cc**: A list or tuple of recipient addresses used in the “Cc” header when sending the email. 143 | - **reply_to**: A list or tuple of recipient addresses used in the “Reply-To” header when sending the email. 144 | 145 | `EmailMessage.send(fail_silently=False)` sends the message. 146 | 147 | If a connection was specified when the email was constructed, that connection will be used. Otherwise, an instance of the default backend will be instantiated and used. 148 | 149 | If the keyword argument `fail_silently` is True, exceptions raised while sending the message will be quashed. An empty list of recipients will not raise an exception. 150 | 151 | ### Sending html content 152 | 153 | By default, the **MIME** type of the body parameter in an `EmailMessage` is "text/plain". It is good practice to leave this alone, because it guarantees that any recipient will be able to read the email, regardless of their mail client. However, if you are confident that your recipients can handle an alternative content type, you can use the `content_subtype` attribute on the `EmailMessage` class to change the main content type. The major type will always be "text", but you can change the subtype. For example: 154 | 155 | ```python 156 | from flask_mailman import EmailMessage 157 | 158 | subject, from_email, to = 'hello', 'from@example.com', 'to@example.com' 159 | html_content = '

This is an important message.

' 160 | 161 | msg = EmailMessage(subject, html_content, from_email, [to]) 162 | msg.content_subtype = "html" # Main content is now text/html 163 | msg.send() 164 | ``` 165 | 166 | ### Sending multiple emails 167 | 168 | Establishing and closing an SMTP connection (or any other network connection, for that matter) is an expensive process. If you have a lot of emails to send, it makes sense to reuse an SMTP connection, rather than creating and destroying a connection every time you want to send an email. 169 | 170 | There are two ways you tell an email backend to reuse a connection. 171 | 172 | Firstly, you can use the `send_messages()` method. `send_messages()` takes a list of `EmailMessage` instances (or subclasses), and sends them all using a single connection. 173 | 174 | For example, if you have a function called `get_notification_email()` that returns a list of `EmailMessage` objects representing some periodic email you wish to send out, you could send these emails using a single call to `send_messages`: 175 | 176 | ```python 177 | from flask import Flask 178 | from flask_mailman import Mail 179 | 180 | app = Flask(__name__) 181 | mail = Mail(app) 182 | 183 | connection = mail.get_connection() # Use default email connection 184 | messages = get_notification_email() 185 | connection.send_messages(messages) 186 | ``` 187 | 188 | In this example, the call to `send_messages()` opens a connection on the backend, sends the list of messages, and then closes the connection again. 189 | 190 | The second approach is to use the `open()` and `close()` methods on the email backend to manually control the connection. `send_messages()` will not manually open or close the connection if it is already open, so if you manually open the connection, you can control when it is closed. For example: 191 | 192 | ```python 193 | from flask import Flask 194 | from flask_mailman import Mail, EmailMessage 195 | 196 | app = Flask(__name__) 197 | mail = Mail(app) 198 | 199 | connection = mail.get_connection() 200 | 201 | # Manually open the connection 202 | connection.open() 203 | 204 | # Construct an email message that uses the connection 205 | email1 = EmailMessage( 206 | 'Hello', 207 | 'Body goes here', 208 | 'from@example.com', 209 | ['to1@example.com'], 210 | connection=connection, 211 | ) 212 | email1.send() # Send the email 213 | 214 | # Construct two more messages 215 | email2 = EmailMessage( 216 | 'Hello', 217 | 'Body goes here', 218 | 'from@example.com', 219 | ['to2@example.com'], 220 | ) 221 | email3 = EmailMessage( 222 | 'Hello', 223 | 'Body goes here', 224 | 'from@example.com', 225 | ['to3@example.com'], 226 | ) 227 | 228 | # Send the two emails in a single call - 229 | connection.send_messages([email2, email3]) 230 | # The connection was already open so send_messages() doesn't close it. 231 | # We need to manually close the connection. 232 | connection.close() 233 | ``` 234 | 235 | Of course there is always a short writing using `with`: 236 | 237 | ```python 238 | from flask import Flask 239 | from flask_mailman import Mail, EmailMessage 240 | 241 | app = Flask(__name__) 242 | mail = Mail(app) 243 | 244 | with mail.get_connection() as conn: 245 | email1 = EmailMessage( 246 | 'Hello', 247 | 'Body goes here', 248 | 'from@example.com', 249 | ['to1@example.com'], 250 | connection=conn, 251 | ) 252 | email1.send() 253 | 254 | email2 = EmailMessage( 255 | 'Hello', 256 | 'Body goes here', 257 | 'from@example.com', 258 | ['to2@example.com'], 259 | ) 260 | email3 = EmailMessage( 261 | 'Hello', 262 | 'Body goes here', 263 | 'from@example.com', 264 | ['to3@example.com'], 265 | ) 266 | conn.send_messages([email2, email3]) 267 | ``` 268 | 269 | ## Attachments 270 | 271 | You can use the following two methods to adding attachments: 272 | 273 | - `EmailMessage.attach()` creates a new file attachment and adds it to the message. There are two ways to call `attach()`: 274 | 275 | - You can pass it a single argument that is a **MIMEBase** instance. This will be inserted directly into the resulting message. 276 | 277 | - Alternatively, you can pass `attach()` three arguments: **filename**, **content** and **mimetype**. filename is the name of the file attachment as it will appear in the email, content is the data that will be contained inside the attachment and mimetype is the optional MIME type for the attachment. If you omit mimetype, the MIME content type will be guessed from the filename of the attachment. 278 | 279 | For example: 280 | 281 | ``` 282 | message.attach('design.png', img_data, 'image/png') 283 | ``` 284 | 285 | If you specify a mimetype of message/rfc822, it will also accept `flask_mailman.EmailMessage` and `email.message.Message`. 286 | 287 | For a mimetype starting with text/, content is expected to be a string. Binary data will be decoded using UTF-8, and if that fails, the MIME type will be changed to application/octet-stream and the data will be attached unchanged. 288 | 289 | In addition, message/rfc822 attachments will no longer be base64-encoded in violation of RFC 2046#section-5.2.1, which can cause issues with displaying the attachments in Evolution and Thunderbird. 290 | 291 | - `EmailMessage.attach_file()` creates a new attachment using a file from your filesystem. Call it with the path of the file to attach and, optionally, the **MIME** type to use for the attachment. If the **MIME** type is omitted, it will be guessed from the filename. You can use it like this: 292 | 293 | ``` 294 | message.attach_file('/images/weather_map.png') 295 | ``` 296 | 297 | For **MIME** types starting with text/, binary data is handled as in `attach()`. 298 | 299 | ## Preventing header injection 300 | 301 | Header injection is a security exploit in which an attacker inserts extra email headers to control the “To:” and “From:” in email messages that your scripts generate. 302 | 303 | The Flask-Mailman email methods outlined above all protect against header injection by forbidding newlines in header values. If any `subject`, `from_email` or `recipient_list` contains a newline (in either Unix, Windows or Mac style), the email method (e.g. `send_mail()`) will raise `flask_mailman.BadHeaderError` (a subclass of `ValueError`) and, hence, will not send the email. It’s your responsibility to validate all data before passing it to the email functions. 304 | 305 | If a message contains headers at the start of the string, the headers will be printed as the first bit of the email message. 306 | 307 | ## Sending alternative content types 308 | 309 | It can be useful to include multiple versions of the content in an email; the classic example is to send both text and HTML versions of a message. With this library, you can do this using the `EmailMultiAlternatives` class. This subclass of `EmailMessage` has an `attach_alternative()` method for including extra versions of the message body in the email. All the other methods (including the class initialization) are inherited directly from `EmailMessage`. 310 | 311 | To send a text and HTML combination, you could write: 312 | 313 | ```python 314 | from flask_mailman import EmailMultiAlternatives 315 | 316 | subject, from_email, to = 'hello', 'from@example.com', 'to@example.com' 317 | text_content = 'This is an important message.' 318 | html_content = '

This is an important message.

' 319 | msg = EmailMultiAlternatives(subject, text_content, from_email, [to]) 320 | msg.attach_alternative(html_content, "text/html") 321 | msg.send() 322 | ``` 323 | 324 | ## Email backends 325 | 326 | The actual sending of an email is handled by the email backend. 327 | 328 | The email backend class has the following methods: 329 | 330 | - `open()` instantiates a long-lived email-sending connection. 331 | - `close()` closes the current email-sending connection. 332 | 333 | `send_messages(email_messages)` sends a list of `EmailMessage` objects. If the connection is not open, this call will implicitly open the connection, and close the connection afterwards. If the connection is already open, it will be left open after mail has been sent. 334 | It can also be used as a context manager, which will automatically call `open()` and `close()` as needed: 335 | 336 | ```python 337 | from flask import Flask 338 | from flask_mailman import Mail 339 | 340 | app = Flask(__name__) 341 | mail = Mail(app) 342 | 343 | with mail.get_connection() as connection: 344 | mail.EmailMessage( 345 | subject1, body1, from1, [to1], 346 | connection=connection, 347 | ).send() 348 | mail.EmailMessage( 349 | subject2, body2, from2, [to2], 350 | connection=connection, 351 | ).send() 352 | ``` 353 | 354 | ### Obtaining an instance of an email backend 355 | 356 | The `get_connection()` method of **Mail** instance returns an instance of the email backend that you can use. 357 | 358 | ```python 359 | Mail.get_connection(backend=None, fail_silently=False, *args, **kwargs) 360 | ``` 361 | By default, a call to `get_connection()` will return an instance of the email backend specified in MAIL_BACKEND configuration. If you specify the backend argument, an instance of that backend will be instantiated. 362 | 363 | The `fail_silently` argument controls how the backend should handle errors. If `fail_silently` is True, exceptions during the email sending process will be silently ignored. 364 | 365 | All other arguments are passed directly to the constructor of the email backend. 366 | 367 | Flask-Mailman ships with several email sending backends. With the exception of the SMTP backend (which is the default), these backends are only useful during testing and development. If you have special email sending requirements, you can write your own email backend. 368 | 369 | ### SMTP backend 370 | 371 | ```python 372 | class backends.smtp.EmailBackend( 373 | host=None, 374 | port=None, 375 | username=None, 376 | password=None, 377 | use_tls=None, 378 | fail_silently=False, 379 | use_ssl=None, 380 | timeout=None, 381 | ssl_keyfile=None, 382 | ssl_certfile=None, 383 | **kwargs 384 | ) 385 | ``` 386 | This is the default backend. Email will be sent through a SMTP server. 387 | 388 | The value for each argument is retrieved from the matching configuration if the argument is None: 389 | 390 | - host: MAIL_HOST 391 | - port: MAIL_PORT 392 | - username: MAIL_USERNAME 393 | - password: MAIL_PASSWORD 394 | - use_tls: MAIL_USE_TLS 395 | - use_ssl: MAIL_USE_SSL 396 | - timeout: MAIL_TIMEOUT 397 | - ssl_keyfile: MAIL_SSL_KEYFILE 398 | - ssl_certfile: MAIL_SSL_CERTFILE 399 | 400 | The SMTP backend is the default configuration inherited by Flask-Mailman. If you want to specify it explicitly, put the following in your configurations: 401 | 402 | ``` 403 | MAIL_BACKEND = 'smtp' 404 | ``` 405 | If unspecified, the default timeout will be the one provided by `socket.getdefaulttimeout()`, which defaults to None (no timeout). 406 | 407 | ### Console backend 408 | 409 | Instead of sending out real emails the console backend just writes the emails that would be sent to the standard output. By default, the console backend writes to stdout. You can use a different stream-like object by providing the stream keyword argument when constructing the connection. 410 | 411 | To specify this backend, put the following in your settings: 412 | 413 | ``` 414 | MAIL_BACKEND = 'console' 415 | ``` 416 | This backend is not intended for use in production – it is provided as a convenience that can be used during development. 417 | 418 | ### File backend 419 | 420 | The file backend writes emails to a file. A new file is created for each new session that is opened on this backend. The directory to which the files are written is either taken from the MAIL_FILE_PATH configuration or from the `file_path` keyword when creating a connection with `Mail.get_connection()`. 421 | 422 | To specify this backend, put the following in your configurations: 423 | 424 | ``` 425 | MAIL_BACKEND = 'file' 426 | MAIL_FILE_PATH = '/tmp/app-messages' # change this to a proper location 427 | ``` 428 | This backend is not intended for use in production – it is provided as a convenience that can be used during development. 429 | 430 | Support for `pathlib.Path` was added. 431 | 432 | ### In-memory backend 433 | 434 | The 'locmem' backend stores messages in a special attribute of the **Mail** instance. The `outbox` attribute is created when the backend is first created (e.g. by sending a message or calling `.get_connection()`). It’s a list with an `EmailMessage` instance for each message that would be sent. 435 | 436 | To specify this backend, put the following in your configurations: 437 | 438 | ``` 439 | MAIL_BACKEND = 'locmem' 440 | ``` 441 | 442 | This backend is not intended for use in production – it is provided as a convenience that can be used during development and testing. 443 | 444 | When `TESTING=True` and `MAIL_BACKEND` is not provided, this backend will be used for testing. 445 | 446 | ### Dummy backend 447 | 448 | As the name suggests the dummy backend does nothing with your messages. To specify this backend, put the following in your configurations: 449 | 450 | ``` 451 | MAIL_BACKEND = 'dummy' 452 | ``` 453 | 454 | This backend is not intended for use in production – it is provided as a convenience that can be used during development. 455 | 456 | ### Defining a custom email backend 457 | 458 | If you need to change how emails are sent you can write your own email backend. 459 | The `MAIL_BACKEND` configuration in your settings file is then the Python import path for your backend class. for example: 460 | 461 | ``` 462 | MAIL_BACKEND = 'flask_mailman.backens.custom' 463 | ``` 464 | 465 | A more direct way is to import your custom backend class, and pass it to `flask_mailman.get_connection` function: 466 | 467 | ```python 468 | from flask import Flask 469 | from flask_mailman import Mail 470 | 471 | from your_custom_backend import CustomBackend 472 | 473 | app = Flask(__name__) 474 | mail = Mail(app) 475 | 476 | connection = mail.get_connection(backend=CustomBackend) 477 | connection.send_messages(messages) 478 | ``` 479 | 480 | Custom email backends should subclass `BaseEmailBackend` that is located in the `flask_mailman.backends.base` module. 481 | A custom email backend must implement the `send_messages(email_messages)` method. 482 | This method receives a list of `EmailMessage` instances and returns the number of successfully delivered messages. 483 | If your backend has any concept of a persistent session or connection, you should also implement the `open()` and `close()` methods. 484 | 485 | Refer to `flask_mailman.backends.smtp.EmailBackend` for a reference implementation. 486 | 487 | ## Convenient functions 488 | 489 | ### send_mail() 490 | *send_mail(subject, message, from_email, recipient_list, fail_silently=False, auth_user=None, auth_password=None, connection=None, html_message=None)* 491 | 492 | In most cases, you can send email using `Mail.send_mail()`. 493 | 494 | The subject, message, from_email and recipient_list parameters are required. 495 | 496 | - **subject**: A string. 497 | - **message**: A string. 498 | - **from_email**: A string. If None, Flask-Mailman will use the value of the MAIL_DEFAULT_SENDER configuration. 499 | - **recipient_list**: A list of strings, each an email address. Each member of `recipient_list` will see the other recipients in the “To:” field of the email message. 500 | - **fail_silently**: A boolean. When it’s False, `send_mail()` will raise an `smtplib.SMTPException` if an error occurs. See the smtplib docs for a list of possible exceptions, all of which are subclasses of `SMTPException`. 501 | - **auth_user**: The optional username to use to authenticate to the SMTP server. If this isn’t provided, Flask-Mailman will use the value of the MAIL_USERNAME configuration. 502 | - **auth_password**: The optional password to use to authenticate to the SMTP server. If this isn’t provided, Flask-Mailman will use the value of the MAIL_PASSWORD configuration. 503 | - **connection**: The optional email backend to use to send the mail. If unspecified, an instance of the default backend will be used. See the documentation on Email backends for more details. 504 | - **html_message**: If `html_message` is provided, the resulting email will be a multipart/alternative email with message as the text/plain content type and html_message as the text/html content type. 505 | 506 | The return value will be the number of successfully delivered messages (which can be 0 or 1 since it can only send one message). 507 | 508 | ### send_mass_mail() 509 | 510 | *send_mass_mail(datatuple, fail_silently=False, auth_user=None, auth_password=None, connection=None)* 511 | 512 | `Mail.send_mass_mail()` is intended to handle mass emailing. 513 | 514 | datatuple is a tuple in which each element is in this format: 515 | 516 | ``` 517 | (subject, message, from_email, recipient_list) 518 | ``` 519 | `fail_silently`, `auth_user` and `auth_password` have the same functions as in `send_mail()`. 520 | 521 | Each separate element of datatuple results in a separate email message. As in `send_mail()`, recipients in the same `recipient_list` will all see the other addresses in the email messages’ “To:” field. 522 | 523 | For example, the following code would send two different messages to two different sets of recipients; however, only one connection to the mail server would be opened: 524 | 525 | ```python 526 | from flask import Flask 527 | from flask_mailman import Mail 528 | 529 | app = Flask(__name__) 530 | mail = Mail(app) 531 | 532 | message1 = ('Subject here', 'Here is the message', 'from@example.com', ['first@example.com', 'other@example.com']) 533 | message2 = ('Another Subject', 'Here is another message', 'from@example.com', ['second@test.com']) 534 | mail.send_mass_mail((message1, message2), fail_silently=False) 535 | # The return value will be the number of successfully delivered messages. 536 | ``` 537 | 538 | ### send_mass_mail() vs. send_mail() 539 | 540 | The main difference between `send_mass_mail()` and `send_mail()` is that `send_mail()` opens a connection to the mail server each time it’s executed, while `send_mass_mail()` uses a single connection for all of its messages. This makes `send_mass_mail()` slightly more efficient. 541 | 542 | ## Differences with Django 543 | 544 | The name of configuration keys is different here, but you can easily resolve it. 545 | 546 | `mail_admins()` and `mail_managers()` methods were removed, you can write an alternative one in a minute if you need. 547 | -------------------------------------------------------------------------------- /tests/test_mail.py: -------------------------------------------------------------------------------- 1 | import mimetypes 2 | import os 3 | from flask_mailman.utils import DNS_NAME 4 | from email import charset, message_from_bytes 5 | from email.mime.text import MIMEText 6 | from unittest import mock 7 | from flask_mailman.message import EmailMessage, EmailMultiAlternatives, sanitize_address 8 | from tests import MailmanCustomizedTestCase 9 | 10 | 11 | class TestMail(MailmanCustomizedTestCase): 12 | def test_send_mail(self): 13 | self.mail.send_mail( 14 | subject="testing", 15 | message="test", 16 | from_email=self.MAIL_DEFAULT_SENDER, 17 | recipient_list=["tester@example.com"], 18 | ) 19 | self.assertEqual(len(self.mail.outbox), 1) 20 | sent_msg = self.mail.outbox[0] 21 | self.assertEqual(sent_msg.from_email, second=self.MAIL_DEFAULT_SENDER) 22 | 23 | def test_send_mail_with_tuple_from_email(self): 24 | self.mail.send_mail( 25 | subject="testing", 26 | message="test", 27 | from_email=("Name", "support@mysite.com"), 28 | recipient_list=["tester@example.com"], 29 | ) 30 | self.assertEqual(len(self.mail.outbox), 1) 31 | sent_msg = self.mail.outbox[0] 32 | self.assertEqual(sent_msg.from_email, "Name ") 33 | 34 | def test_send_mass_mail(self): 35 | message1 = ( 36 | 'Subject here', 37 | 'Here is the message', 38 | 'from@example.com', 39 | ['first@example.com', 'other@example.com'], 40 | ) 41 | message2 = ('Another Subject', 'Here is another message', ('Name', 'from@example.com'), ['second@test.com']) 42 | self.mail.send_mass_mail((message1, message2), fail_silently=False) 43 | self.assertEqual(len(self.mail.outbox), 2) 44 | msg1 = self.mail.outbox[0] 45 | self.assertEqual(msg1.subject, "Subject here") 46 | self.assertEqual(msg1.to, ['first@example.com', 'other@example.com']) 47 | self.assertEqual(msg1.body, "Here is the message") 48 | self.assertEqual(msg1.from_email, "from@example.com") 49 | msg2 = self.mail.outbox[1] 50 | self.assertEqual(msg2.subject, "Another Subject") 51 | self.assertEqual(msg2.from_email, "Name ") 52 | 53 | @mock.patch("socket.getfqdn", return_value="漢字") 54 | def test_non_ascii_dns_non_unicode_email(self, mocked_getfqdn): 55 | delattr(DNS_NAME, "_fqdn") 56 | email = EmailMessage("subject", "content", "from@example.com", ["to@example.com"]) 57 | email.encoding = "iso-8859-1" 58 | self.assertIn("@xn--p8s937b>", email.message()["Message-ID"]) 59 | 60 | # Following test cases are originally from: 61 | # https://github.com/django/django/tree/main/tests/mail/tests.py 62 | 63 | def test_header_omitted_for_no_to_recipients(self): 64 | message = EmailMessage("Subject", "Content", "from@example.com", cc=["cc@example.com"]).message() 65 | self.assertNotIn("To", message) 66 | 67 | def test_recipients_with_empty_strings(self): 68 | """ 69 | Empty strings in various recipient arguments are always stripped 70 | off the final recipient list. 71 | """ 72 | email = EmailMessage( 73 | "Subject", 74 | "Content", 75 | "from@example.com", 76 | ["to@example.com", ""], 77 | cc=["cc@example.com", ""], 78 | bcc=["", "bcc@example.com"], 79 | reply_to=["", None], 80 | ) 81 | self.assertEqual(email.recipients(), ["to@example.com", "cc@example.com", "bcc@example.com"]) 82 | 83 | def test_cc(self): 84 | email = EmailMessage( 85 | "Subject", 86 | "Content", 87 | "from@example.com", 88 | ["to@example.com"], 89 | cc=["cc@example.com"], 90 | ) 91 | message = email.message() 92 | self.assertEqual(message["Cc"], "cc@example.com") 93 | self.assertEqual(email.recipients(), ["to@example.com", "cc@example.com"]) 94 | 95 | # Test multiple CC with multiple To 96 | email = EmailMessage( 97 | "Subject", 98 | "Content", 99 | "from@example.com", 100 | ["to@example.com", "other@example.com"], 101 | cc=["cc@example.com", "cc.other@example.com"], 102 | ) 103 | message = email.message() 104 | self.assertEqual(message["Cc"], "cc@example.com, cc.other@example.com") 105 | self.assertEqual( 106 | email.recipients(), 107 | [ 108 | "to@example.com", 109 | "other@example.com", 110 | "cc@example.com", 111 | "cc.other@example.com", 112 | ], 113 | ) 114 | 115 | # Testing with Bcc 116 | email = EmailMessage( 117 | "Subject", 118 | "Content", 119 | "from@example.com", 120 | ["to@example.com", "other@example.com"], 121 | cc=["cc@example.com", "cc.other@example.com"], 122 | bcc=["bcc@example.com"], 123 | ) 124 | message = email.message() 125 | self.assertEqual(message["Cc"], "cc@example.com, cc.other@example.com") 126 | self.assertEqual( 127 | email.recipients(), 128 | [ 129 | "to@example.com", 130 | "other@example.com", 131 | "cc@example.com", 132 | "cc.other@example.com", 133 | "bcc@example.com", 134 | ], 135 | ) 136 | 137 | def test_cc_headers(self): 138 | message = EmailMessage( 139 | "Subject", 140 | "Content", 141 | "bounce@example.com", 142 | ["to@example.com"], 143 | cc=["foo@example.com"], 144 | headers={"Cc": "override@example.com"}, 145 | ).message() 146 | self.assertEqual(message["Cc"], "override@example.com") 147 | 148 | def test_cc_in_headers_only(self): 149 | message = EmailMessage( 150 | "Subject", 151 | "Content", 152 | "bounce@example.com", 153 | ["to@example.com"], 154 | headers={"Cc": "foo@example.com"}, 155 | ).message() 156 | self.assertEqual(message["Cc"], "foo@example.com") 157 | 158 | def test_reply_to(self): 159 | email = EmailMessage( 160 | "Subject", 161 | "Content", 162 | "from@example.com", 163 | ["to@example.com"], 164 | reply_to=["reply_to@example.com"], 165 | ) 166 | message = email.message() 167 | self.assertEqual(message["Reply-To"], "reply_to@example.com") 168 | 169 | email = EmailMessage( 170 | "Subject", 171 | "Content", 172 | "from@example.com", 173 | ["to@example.com"], 174 | reply_to=["reply_to1@example.com", "reply_to2@example.com"], 175 | ) 176 | message = email.message() 177 | self.assertEqual(message["Reply-To"], "reply_to1@example.com, reply_to2@example.com") 178 | 179 | def test_recipients_as_tuple(self): 180 | email = EmailMessage( 181 | "Subject", 182 | "Content", 183 | "from@example.com", 184 | ("to@example.com", "other@example.com"), 185 | cc=("cc@example.com", "cc.other@example.com"), 186 | bcc=("bcc@example.com",), 187 | ) 188 | message = email.message() 189 | self.assertEqual(message["Cc"], "cc@example.com, cc.other@example.com") 190 | self.assertEqual( 191 | email.recipients(), 192 | [ 193 | "to@example.com", 194 | "other@example.com", 195 | "cc@example.com", 196 | "cc.other@example.com", 197 | "bcc@example.com", 198 | ], 199 | ) 200 | 201 | def test_recipients_as_string(self): 202 | with self.assertRaises(TypeError, msg='"to" argument must be a list or tuple'): 203 | EmailMessage(to="foo@example.com") 204 | with self.assertRaises(TypeError, msg='"cc" argument must be a list or tuple'): 205 | EmailMessage(cc="foo@example.com") 206 | with self.assertRaises(TypeError, msg='"bcc" argument must be a list or tuple'): 207 | EmailMessage(bcc="foo@example.com") 208 | with self.assertRaises(TypeError, msg='"reply_to" argument must be a list or tuple'): 209 | EmailMessage(reply_to="reply_to@example.com") 210 | 211 | def test_space_continuation(self): 212 | """ 213 | Test for space continuation character in long (ASCII) subject headers 214 | """ 215 | email = EmailMessage( 216 | "Long subject lines that get wrapped should contain a space continuation " 217 | "character to get expected behavior in Outlook and Thunderbird", 218 | "Content", 219 | "from@example.com", 220 | ["to@example.com"], 221 | ) 222 | message = email.message() 223 | self.assertEqual( 224 | message["Subject"].encode(), 225 | b"Long subject lines that get wrapped should contain a space continuation\n" 226 | b" character to get expected behavior in Outlook and Thunderbird", 227 | ) 228 | 229 | def test_message_header_overrides(self): 230 | """ 231 | Specifying dates or message-ids in the extra headers overrides the 232 | default values 233 | """ 234 | headers = {"date": "Fri, 09 Nov 2001 01:08:47 -0000", "Message-ID": "foo"} 235 | email = EmailMessage( 236 | "subject", 237 | "content", 238 | "from@example.com", 239 | ["to@example.com"], 240 | headers=headers, 241 | ) 242 | 243 | self.assertMessageHasHeaders( 244 | email.message(), 245 | { 246 | ("Content-Transfer-Encoding", "7bit"), 247 | ("Content-Type", 'text/plain; charset="utf-8"'), 248 | ("From", "from@example.com"), 249 | ("MIME-Version", "1.0"), 250 | ("Message-ID", "foo"), 251 | ("Subject", "subject"), 252 | ("To", "to@example.com"), 253 | ("date", "Fri, 09 Nov 2001 01:08:47 -0000"), 254 | }, 255 | ) 256 | 257 | def test_from_header(self): 258 | """ 259 | Make sure we can manually set the From header 260 | """ 261 | email = EmailMessage( 262 | "Subject", 263 | "Content", 264 | "bounce@example.com", 265 | ["to@example.com"], 266 | headers={"From": "from@example.com"}, 267 | ) 268 | message = email.message() 269 | self.assertEqual(message["From"], "from@example.com") 270 | 271 | def test_to_header(self): 272 | """ 273 | Make sure we can manually set the To header (#17444) 274 | """ 275 | email = EmailMessage( 276 | "Subject", 277 | "Content", 278 | "bounce@example.com", 279 | ["list-subscriber@example.com", "list-subscriber2@example.com"], 280 | headers={"To": "mailing-list@example.com"}, 281 | ) 282 | message = email.message() 283 | self.assertEqual(message["To"], "mailing-list@example.com") 284 | self.assertEqual(email.to, ["list-subscriber@example.com", "list-subscriber2@example.com"]) 285 | 286 | # If we don't set the To header manually, it should default to the `to` 287 | # argument to the constructor. 288 | email = EmailMessage( 289 | "Subject", 290 | "Content", 291 | "bounce@example.com", 292 | ["list-subscriber@example.com", "list-subscriber2@example.com"], 293 | ) 294 | message = email.message() 295 | self.assertEqual(message["To"], "list-subscriber@example.com, list-subscriber2@example.com") 296 | self.assertEqual(email.to, ["list-subscriber@example.com", "list-subscriber2@example.com"]) 297 | 298 | def test_to_in_headers_only(self): 299 | message = EmailMessage( 300 | "Subject", 301 | "Content", 302 | "bounce@example.com", 303 | headers={"To": "to@example.com"}, 304 | ).message() 305 | self.assertEqual(message["To"], "to@example.com") 306 | 307 | def test_reply_to_header(self): 308 | """ 309 | Specifying 'Reply-To' in headers should override reply_to. 310 | """ 311 | email = EmailMessage( 312 | "Subject", 313 | "Content", 314 | "bounce@example.com", 315 | ["to@example.com"], 316 | reply_to=["foo@example.com"], 317 | headers={"Reply-To": "override@example.com"}, 318 | ) 319 | message = email.message() 320 | self.assertEqual(message["Reply-To"], "override@example.com") 321 | 322 | def test_reply_to_in_headers_only(self): 323 | message = EmailMessage( 324 | "Subject", 325 | "Content", 326 | "from@example.com", 327 | ["to@example.com"], 328 | headers={"Reply-To": "reply_to@example.com"}, 329 | ).message() 330 | self.assertEqual(message["Reply-To"], "reply_to@example.com") 331 | 332 | def test_multiple_message_call(self): 333 | """ 334 | Regression for #13259 - Make sure that headers are not changed when 335 | calling EmailMessage.message() 336 | """ 337 | email = EmailMessage( 338 | "Subject", 339 | "Content", 340 | "bounce@example.com", 341 | ["to@example.com"], 342 | headers={"From": "from@example.com"}, 343 | ) 344 | message = email.message() 345 | self.assertEqual(message["From"], "from@example.com") 346 | message = email.message() 347 | self.assertEqual(message["From"], "from@example.com") 348 | 349 | def test_unicode_address_header(self): 350 | """ 351 | Regression for #11144 - When a to/from/cc header contains Unicode, 352 | make sure the email addresses are parsed correctly (especially with 353 | regards to commas) 354 | """ 355 | email = EmailMessage( 356 | "Subject", 357 | "Content", 358 | "from@example.com", 359 | ['"Firstname Sürname" ', "other@example.com"], 360 | ) 361 | self.assertEqual( 362 | email.message()["To"], 363 | "=?utf-8?q?Firstname_S=C3=BCrname?= , other@example.com", 364 | ) 365 | email = EmailMessage( 366 | "Subject", 367 | "Content", 368 | "from@example.com", 369 | ['"Sürname, Firstname" ', "other@example.com"], 370 | ) 371 | self.assertEqual( 372 | email.message()["To"], 373 | "=?utf-8?q?S=C3=BCrname=2C_Firstname?= , other@example.com", 374 | ) 375 | 376 | def test_unicode_headers(self): 377 | email = EmailMessage( 378 | "Gżegżółka", 379 | "Content", 380 | "from@example.com", 381 | ["to@example.com"], 382 | headers={ 383 | "Sender": '"Firstname Sürname" ', 384 | "Comments": "My Sürname is non-ASCII", 385 | }, 386 | ) 387 | message = email.message() 388 | self.assertEqual(message["Subject"], "=?utf-8?b?R8W8ZWfFvMOzxYJrYQ==?=") 389 | self.assertEqual(message["Sender"], "=?utf-8?q?Firstname_S=C3=BCrname?= ") 390 | self.assertEqual(message["Comments"], "=?utf-8?q?My_S=C3=BCrname_is_non-ASCII?=") 391 | 392 | def test_safe_mime_multipart(self): 393 | """ 394 | Make sure headers can be set with a different encoding than utf-8 in 395 | SafeMIMEMultipart as well 396 | """ 397 | headers = {"Date": "Fri, 09 Nov 2001 01:08:47 -0000", "Message-ID": "foo"} 398 | from_email, to = "from@example.com", '"Sürname, Firstname" ' 399 | text_content = "This is an important message." 400 | html_content = "

This is an important message.

" 401 | msg = EmailMultiAlternatives( 402 | "Message from Firstname Sürname", 403 | text_content, 404 | from_email, 405 | [to], 406 | headers=headers, 407 | ) 408 | msg.attach_alternative(html_content, "text/html") 409 | msg.encoding = "iso-8859-1" 410 | self.assertEqual( 411 | msg.message()["To"], 412 | "=?iso-8859-1?q?S=FCrname=2C_Firstname?= ", 413 | ) 414 | self.assertEqual( 415 | msg.message()["Subject"], 416 | "=?iso-8859-1?q?Message_from_Firstname_S=FCrname?=", 417 | ) 418 | 419 | def test_safe_mime_multipart_with_attachments(self): 420 | """ 421 | EmailMultiAlternatives includes alternatives if the body is empty and 422 | it has attachments. 423 | """ 424 | msg = EmailMultiAlternatives(body="") 425 | html_content = "

This is html

" 426 | msg.attach_alternative(html_content, "text/html") 427 | msg.attach("example.txt", "Text file content", "text/plain") 428 | self.assertIn(html_content, msg.message().as_string()) 429 | 430 | def test_none_body(self): 431 | msg = EmailMessage("subject", None, "from@example.com", ["to@example.com"]) 432 | self.assertEqual(msg.body, "") 433 | self.assertEqual(msg.message().get_payload(), "") 434 | 435 | def test_encoding(self): 436 | """ 437 | Regression for #12791 - Encode body correctly with other encodings 438 | than utf-8 439 | """ 440 | email = EmailMessage( 441 | "Subject", 442 | "Firstname Sürname is a great guy.", 443 | "from@example.com", 444 | ["other@example.com"], 445 | ) 446 | email.encoding = "iso-8859-1" 447 | message = email.message() 448 | self.assertMessageHasHeaders( 449 | message, 450 | { 451 | ("MIME-Version", "1.0"), 452 | ("Content-Type", 'text/plain; charset="iso-8859-1"'), 453 | ("Content-Transfer-Encoding", "quoted-printable"), 454 | ("Subject", "Subject"), 455 | ("From", "from@example.com"), 456 | ("To", "other@example.com"), 457 | }, 458 | ) 459 | self.assertEqual(message.get_payload(), "Firstname S=FCrname is a great guy.") 460 | 461 | # MIME attachments works correctly with other encodings than utf-8. 462 | text_content = "Firstname Sürname is a great guy." 463 | html_content = "

Firstname Sürname is a great guy.

" 464 | msg = EmailMultiAlternatives("Subject", text_content, "from@example.com", ["to@example.com"]) 465 | msg.encoding = "iso-8859-1" 466 | msg.attach_alternative(html_content, "text/html") 467 | payload0 = msg.message().get_payload(0) 468 | self.assertMessageHasHeaders( 469 | payload0, 470 | { 471 | ("MIME-Version", "1.0"), 472 | ("Content-Type", 'text/plain; charset="iso-8859-1"'), 473 | ("Content-Transfer-Encoding", "quoted-printable"), 474 | }, 475 | ) 476 | self.assertTrue(payload0.as_bytes().endswith(b"\n\nFirstname S=FCrname is a great guy.")) 477 | payload1 = msg.message().get_payload(1) 478 | self.assertMessageHasHeaders( 479 | payload1, 480 | { 481 | ("MIME-Version", "1.0"), 482 | ("Content-Type", 'text/html; charset="iso-8859-1"'), 483 | ("Content-Transfer-Encoding", "quoted-printable"), 484 | }, 485 | ) 486 | self.assertTrue( 487 | payload1.as_bytes().endswith(b"\n\n

Firstname S=FCrname is a great guy.

") 488 | ) 489 | 490 | def test_attachments(self): 491 | headers = {"Date": "Fri, 09 Nov 2001 01:08:47 -0000", "Message-ID": "foo"} 492 | subject, from_email, to = "hello", "from@example.com", "to@example.com" 493 | text_content = "This is an important message." 494 | html_content = "

This is an important message.

" 495 | msg = EmailMultiAlternatives(subject, text_content, from_email, [to], headers=headers) 496 | msg.attach_alternative(html_content, "text/html") 497 | msg.attach("an attachment.pdf", b"%PDF-1.4.%...", mimetype="application/pdf") 498 | msg_bytes = msg.message().as_bytes() 499 | message = message_from_bytes(msg_bytes) 500 | self.assertTrue(message.is_multipart()) 501 | self.assertEqual(message.get_content_type(), "multipart/mixed") 502 | self.assertEqual(message.get_default_type(), "text/plain") 503 | payload = message.get_payload() 504 | self.assertEqual(payload[0].get_content_type(), "multipart/alternative") 505 | self.assertEqual(payload[1].get_content_type(), "application/pdf") 506 | 507 | def test_attachments_two_tuple(self): 508 | msg = EmailMessage(attachments=[("filename1", "content1")]) 509 | filename, content, mimetype = self.get_decoded_attachments(msg)[0] 510 | self.assertEqual(filename, "filename1") 511 | self.assertEqual(content, b"content1") 512 | self.assertEqual(mimetype, "application/octet-stream") 513 | 514 | def test_attachments_MIMEText(self): 515 | txt = MIMEText("content1") 516 | msg = EmailMessage(attachments=[txt]) 517 | payload = msg.message().get_payload() 518 | self.assertEqual(payload[0], txt) 519 | 520 | def test_non_ascii_attachment_filename(self): 521 | headers = {"Date": "Fri, 09 Nov 2001 01:08:47 -0000", "Message-ID": "foo"} 522 | subject, from_email, to = "hello", "from@example.com", "to@example.com" 523 | content = "This is the message." 524 | msg = EmailMessage(subject, content, from_email, [to], headers=headers) 525 | # Unicode in file name 526 | msg.attach("une pièce jointe.pdf", b"%PDF-1.4.%...", mimetype="application/pdf") 527 | msg_bytes = msg.message().as_bytes() 528 | message = message_from_bytes(msg_bytes) 529 | payload = message.get_payload() 530 | self.assertEqual(payload[1].get_filename(), "une pièce jointe.pdf") 531 | 532 | def test_attach_file(self): 533 | """ 534 | Test attaching a file against different mimetypes and make sure that 535 | a file will be attached and sent properly even if an invalid mimetype 536 | is specified. 537 | """ 538 | files = ( 539 | # filename, actual mimetype 540 | ("file.txt", "text/plain"), 541 | ("file.png", "image/png"), 542 | ("file_txt", None), 543 | ("file_png", None), 544 | ("file_txt.png", "image/png"), 545 | ("file_png.txt", "text/plain"), 546 | ("file.eml", "message/rfc822"), 547 | ) 548 | test_mimetypes = ["text/plain", "image/png", None] 549 | 550 | for basename, real_mimetype in files: 551 | for mimetype in test_mimetypes: 552 | email = EmailMessage("subject", "body", "from@example.com", ["to@example.com"]) 553 | self.assertEqual(mimetypes.guess_type(basename)[0], real_mimetype) 554 | self.assertEqual(email.attachments, []) 555 | file_path = os.path.join(os.path.dirname(__file__), "attachments", basename) 556 | email.attach_file(file_path, mimetype=mimetype) 557 | self.assertEqual(len(email.attachments), 1) 558 | self.assertIn(basename, email.attachments[0]) 559 | msgs_sent_num = email.send() 560 | self.assertEqual(msgs_sent_num, 1) 561 | 562 | def test_attach_text_as_bytes(self): 563 | msg = EmailMessage("subject", "body", "from@example.com", ["to@example.com"]) 564 | msg.attach("file.txt", b"file content") 565 | sent_num = msg.send() 566 | self.assertEqual(sent_num, 1) 567 | filename, content, mimetype = self.get_decoded_attachments(msg)[0] 568 | self.assertEqual(filename, "file.txt") 569 | self.assertEqual(content, b"file content") 570 | self.assertEqual(mimetype, "text/plain") 571 | 572 | def test_attach_utf8_text_as_bytes(self): 573 | """ 574 | Non-ASCII characters encoded as valid UTF-8 are correctly transported 575 | and decoded. 576 | """ 577 | msg = EmailMessage("subject", "body", "from@example.com", ["to@example.com"]) 578 | msg.attach("file.txt", b"\xc3\xa4") # UTF-8 encoded a umlaut. 579 | filename, content, mimetype = self.get_decoded_attachments(msg)[0] 580 | self.assertEqual(filename, "file.txt") 581 | self.assertEqual(content, b"\xc3\xa4") 582 | self.assertEqual(mimetype, "text/plain") 583 | 584 | def test_attach_non_utf8_text_as_bytes(self): 585 | """ 586 | Binary data that can't be decoded as UTF-8 overrides the MIME type 587 | instead of decoding the data. 588 | """ 589 | msg = EmailMessage("subject", "body", "from@example.com", ["to@example.com"]) 590 | msg.attach("file.txt", b"\xff") # Invalid UTF-8. 591 | filename, content, mimetype = self.get_decoded_attachments(msg)[0] 592 | self.assertEqual(filename, "file.txt") 593 | # Content should be passed through unmodified. 594 | self.assertEqual(content, b"\xff") 595 | self.assertEqual(mimetype, "application/octet-stream") 596 | 597 | def test_attach_mimetext_content_mimetype(self): 598 | email_msg = EmailMessage() 599 | txt = MIMEText("content") 600 | msg = "content and mimetype must not be given when a MIMEBase instance " "is provided." 601 | with self.assertRaises(ValueError, msg=msg): 602 | email_msg.attach(txt, content="content") 603 | with self.assertRaises(ValueError, msg=msg): 604 | email_msg.attach(txt, mimetype="text/plain") 605 | 606 | def test_attach_content_none(self): 607 | email_msg = EmailMessage() 608 | msg = "content must be provided." 609 | with self.assertRaises(ValueError, msg=msg): 610 | email_msg.attach("file.txt", mimetype="application/pdf") 611 | 612 | def test_dont_mangle_from_in_body(self): 613 | """ 614 | Make sure that EmailMessage doesn't mangle 615 | 'From ' in message body. 616 | """ 617 | email = EmailMessage( 618 | "Subject", 619 | "From the future", 620 | "bounce@example.com", 621 | ["to@example.com"], 622 | headers={"From": "from@example.com"}, 623 | ) 624 | self.assertNotIn(b">From the future", email.message().as_bytes()) 625 | 626 | def test_dont_base64_encode(self): 627 | """Shouldn't use Base64 encoding at all""" 628 | msg = EmailMessage( 629 | "Subject", 630 | "UTF-8 encoded body", 631 | "bounce@example.com", 632 | ["to@example.com"], 633 | headers={"From": "from@example.com"}, 634 | ) 635 | self.assertIn(b"Content-Transfer-Encoding: 7bit", msg.message().as_bytes()) 636 | 637 | # Shouldn't use quoted printable, should detect it can represent 638 | # content with 7 bit data. 639 | msg = EmailMessage( 640 | "Subject", 641 | "Body with only ASCII characters.", 642 | "bounce@example.com", 643 | ["to@example.com"], 644 | headers={"From": "from@example.com"}, 645 | ) 646 | s = msg.message().as_bytes() 647 | self.assertIn(b"Content-Transfer-Encoding: 7bit", s) 648 | 649 | # Shouldn't use quoted printable, should detect it can represent 650 | # content with 8 bit data. 651 | msg = EmailMessage( 652 | "Subject", 653 | "Body with latin characters: àáä.", 654 | "bounce@example.com", 655 | ["to@example.com"], 656 | headers={"From": "from@example.com"}, 657 | ) 658 | s = msg.message().as_bytes() 659 | self.assertIn(b"Content-Transfer-Encoding: 8bit", s) 660 | s = msg.message().as_string() 661 | self.assertIn("Content-Transfer-Encoding: 8bit", s) 662 | 663 | msg = EmailMessage( 664 | "Subject", 665 | "Body with non latin characters: А Б В Г Д Е Ж Ѕ З И І К Л М Н О П.", 666 | "bounce@example.com", 667 | ["to@example.com"], 668 | headers={"From": "from@example.com"}, 669 | ) 670 | s = msg.message().as_bytes() 671 | self.assertIn(b"Content-Transfer-Encoding: 8bit", s) 672 | s = msg.message().as_string() 673 | self.assertIn("Content-Transfer-Encoding: 8bit", s) 674 | 675 | def test_dont_base64_encode_message_rfc822(self): 676 | """Shouldn't use base64 encoding for a child EmailMessage attachment.""" 677 | # Create a child message first 678 | child_msg = EmailMessage( 679 | "Child Subject", 680 | "Some body of child message", 681 | "bounce@example.com", 682 | ["to@example.com"], 683 | headers={"From": "from@example.com"}, 684 | ) 685 | child_s = child_msg.message().as_string() 686 | 687 | # Now create a parent 688 | parent_msg = EmailMessage( 689 | "Parent Subject", 690 | "Some parent body", 691 | "bounce@example.com", 692 | ["to@example.com"], 693 | headers={"From": "from@example.com"}, 694 | ) 695 | 696 | # Attach to parent as a string 697 | parent_msg.attach(content=child_s, mimetype="message/rfc822") 698 | parent_s = parent_msg.message().as_string() 699 | 700 | # The child message header is not base64 encoded 701 | self.assertIn("Child Subject", parent_s) 702 | 703 | # Feature test: try attaching email.Message object directly to the mail. 704 | parent_msg = EmailMessage( 705 | "Parent Subject", 706 | "Some parent body", 707 | "bounce@example.com", 708 | ["to@example.com"], 709 | headers={"From": "from@example.com"}, 710 | ) 711 | parent_msg.attach(content=child_msg.message(), mimetype="message/rfc822") 712 | parent_s = parent_msg.message().as_string() 713 | 714 | # The child message header is not base64 encoded 715 | self.assertIn("Child Subject", parent_s) 716 | 717 | # Feature test: try attaching EmailMessage object directly to the mail. 718 | parent_msg = EmailMessage( 719 | "Parent Subject", 720 | "Some parent body", 721 | "bounce@example.com", 722 | ["to@example.com"], 723 | headers={"From": "from@example.com"}, 724 | ) 725 | parent_msg.attach(content=child_msg, mimetype="message/rfc822") 726 | parent_s = parent_msg.message().as_string() 727 | 728 | # The child message header is not base64 encoded 729 | self.assertIn("Child Subject", parent_s) 730 | 731 | def test_custom_utf8_encoding(self): 732 | """A UTF-8 charset with a custom body encoding is respected.""" 733 | body = "Body with latin characters: àáä." 734 | msg = EmailMessage("Subject", body, "bounce@example.com", ["to@example.com"]) 735 | encoding = charset.Charset("utf-8") 736 | encoding.body_encoding = charset.QP 737 | msg.encoding = encoding 738 | message = msg.message() 739 | self.assertMessageHasHeaders( 740 | message, 741 | { 742 | ("MIME-Version", "1.0"), 743 | ("Content-Type", 'text/plain; charset="utf-8"'), 744 | ("Content-Transfer-Encoding", "quoted-printable"), 745 | }, 746 | ) 747 | self.assertEqual(message.get_payload(), encoding.body_encode(body)) 748 | 749 | def test_sanitize_address(self): 750 | """Email addresses are properly sanitized.""" 751 | for email_address, encoding, expected_result in ( 752 | # ASCII addresses. 753 | ("to@example.com", "ascii", "to@example.com"), 754 | ("to@example.com", "utf-8", "to@example.com"), 755 | (("A name", "to@example.com"), "ascii", "A name "), 756 | ( 757 | ("A name", "to@example.com"), 758 | "utf-8", 759 | "A name ", 760 | ), 761 | ("localpartonly", "ascii", "localpartonly"), 762 | # ASCII addresses with display names. 763 | ("A name ", "ascii", "A name "), 764 | ("A name ", "utf-8", "A name "), 765 | ('"A name" ', "ascii", "A name "), 766 | ('"A name" ', "utf-8", "A name "), 767 | # Unicode addresses (supported per RFC-6532). 768 | ("tó@example.com", "utf-8", "=?utf-8?b?dMOz?=@example.com"), 769 | ("to@éxample.com", "utf-8", "to@xn--xample-9ua.com"), 770 | ( 771 | ("Tó Example", "tó@example.com"), 772 | "utf-8", 773 | "=?utf-8?q?T=C3=B3_Example?= <=?utf-8?b?dMOz?=@example.com>", 774 | ), 775 | # Unicode addresses with display names. 776 | ( 777 | "Tó Example ", 778 | "utf-8", 779 | "=?utf-8?q?T=C3=B3_Example?= <=?utf-8?b?dMOz?=@example.com>", 780 | ), 781 | ( 782 | "To Example ", 783 | "ascii", 784 | "To Example ", 785 | ), 786 | ( 787 | "To Example ", 788 | "utf-8", 789 | "To Example ", 790 | ), 791 | # Addresses with two @ signs. 792 | ('"to@other.com"@example.com', "utf-8", r'"to@other.com"@example.com'), 793 | ( 794 | '"to@other.com" ', 795 | "utf-8", 796 | '"to@other.com" ', 797 | ), 798 | ( 799 | ("To Example", "to@other.com@example.com"), 800 | "utf-8", 801 | 'To Example <"to@other.com"@example.com>', 802 | ), 803 | # Addresses with long unicode display names. 804 | ( 805 | "Tó Example very long" * 4 + " ", 806 | "utf-8", 807 | "=?utf-8?q?T=C3=B3_Example_very_longT=C3=B3_Example_very_longT" 808 | "=C3=B3_Example_?=\n" 809 | " =?utf-8?q?very_longT=C3=B3_Example_very_long?= " 810 | "", 811 | ), 812 | ( 813 | ("Tó Example very long" * 4, "to@example.com"), 814 | "utf-8", 815 | "=?utf-8?q?T=C3=B3_Example_very_longT=C3=B3_Example_very_longT" 816 | "=C3=B3_Example_?=\n" 817 | " =?utf-8?q?very_longT=C3=B3_Example_very_long?= " 818 | "", 819 | ), 820 | # Address with long display name and unicode domain. 821 | ( 822 | ("To Example very long" * 4, "to@exampl€.com"), 823 | "utf-8", 824 | "To Example very longTo Example very longTo Example very longT" 825 | "o Example very\n" 826 | " long ", 827 | ), 828 | ): 829 | with self.subTest(email_address=email_address, encoding=encoding): 830 | self.assertEqual(sanitize_address(email_address, encoding), expected_result) 831 | 832 | def test_sanitize_address_invalid(self): 833 | for email_address in ( 834 | # Invalid address with two @ signs. 835 | "to@other.com@example.com", 836 | # Invalid address without the quotes. 837 | "to@other.com ", 838 | # Other invalid addresses. 839 | "@", 840 | "to@", 841 | "@example.com", 842 | ("", ""), 843 | ): 844 | with self.subTest(email_address=email_address): 845 | with self.assertRaises(ValueError, msg="Invalid address"): 846 | sanitize_address(email_address, encoding="utf-8") 847 | 848 | def test_sanitize_address_header_injection(self): 849 | msg = "Invalid address; address parts cannot contain newlines." 850 | tests = [ 851 | "Name\nInjection ", 852 | ("Name\nInjection", "to@xample.com"), 853 | "Name ", 854 | ("Name", "to\ninjection@example.com"), 855 | ] 856 | for email_address in tests: 857 | with self.subTest(email_address=email_address): 858 | with self.assertRaises(ValueError, msg=msg): 859 | sanitize_address(email_address, encoding="utf-8") 860 | 861 | def test_email_multi_alternatives_content_mimetype_none(self): 862 | email_msg = EmailMultiAlternatives() 863 | msg = "Both content and mimetype must be provided." 864 | with self.assertRaises(ValueError, msg=msg): 865 | email_msg.attach_alternative(None, "text/html") 866 | with self.assertRaises(ValueError, msg=msg): 867 | email_msg.attach_alternative("

content

", None) 868 | 869 | def test_send_unicode(self): 870 | email = EmailMessage("Chère maman", "Je t'aime très fort", "from@example.com", ["to@example.com"]) 871 | num_sent = email.send() 872 | self.assertEqual(num_sent, 1) 873 | sent_msg = self.get_message() 874 | self.assertEqual(sent_msg['subject'], "=?utf-8?q?Ch=C3=A8re_maman?=") 875 | self.assertEqual(sent_msg.get_payload(decode=True).decode(), "Je t'aime très fort") 876 | 877 | def test_send_long_lines(self): 878 | """ 879 | Email line length is limited to 998 chars by the RFC 5322 Section 880 | 2.1.1. 881 | Message body containing longer lines are converted to Quoted-Printable 882 | to avoid having to insert newlines, which could be hairy to do properly. 883 | """ 884 | # Unencoded body length is < 998 (840) but > 998 when utf-8 encoded. 885 | email = EmailMessage("Subject", "В южных морях " * 60, "from@example.com", ["to@example.com"]) 886 | email.send() 887 | message = self.get_message() 888 | self.assertMessageHasHeaders( 889 | message, 890 | { 891 | ("MIME-Version", "1.0"), 892 | ("Content-Type", 'text/plain; charset="utf-8"'), 893 | ("Content-Transfer-Encoding", "quoted-printable"), 894 | }, 895 | ) 896 | 897 | def test_send_verbose_name(self): 898 | email = EmailMessage( 899 | "Subject", 900 | "Content", 901 | '"Firstname Sürname" ', 902 | ["to@example.com"], 903 | ) 904 | email.send() 905 | message = self.get_message() 906 | self.assertEqual(message["subject"], "Subject") 907 | self.assertEqual(message.get_payload(), "Content") 908 | self.assertEqual(message["from"], "=?utf-8?q?Firstname_S=C3=BCrname?= ") 909 | 910 | def test_plaintext_send_mail(self): 911 | """ 912 | Test send_mail without the html_message 913 | regression test for adding html_message parameter to send_mail() 914 | """ 915 | self.mail.send_mail("Subject", "Content", "sender@example.com", ["nobody@example.com"]) 916 | message = self.get_message() 917 | 918 | self.assertEqual(message.get("subject"), "Subject") 919 | self.assertEqual(message.get_all("to"), ["nobody@example.com"]) 920 | self.assertFalse(message.is_multipart()) 921 | self.assertEqual(message.get_payload(), "Content") 922 | self.assertEqual(message.get_content_type(), "text/plain") 923 | 924 | def test_html_send_mail(self): 925 | """Test html_message argument to send_mail""" 926 | self.mail.send_mail( 927 | "Subject", 928 | "Content", 929 | "sender@example.com", 930 | ["nobody@example.com"], 931 | html_message="HTML Content", 932 | ) 933 | message = self.get_message() 934 | 935 | self.assertEqual(message.get("subject"), "Subject") 936 | self.assertEqual(message.get_all("to"), ["nobody@example.com"]) 937 | self.assertTrue(message.is_multipart()) 938 | self.assertEqual(len(message.get_payload()), 2) 939 | self.assertEqual(message.get_payload(0).get_payload(), "Content") 940 | self.assertEqual(message.get_payload(0).get_content_type(), "text/plain") 941 | self.assertEqual(message.get_payload(1).get_payload(), "HTML Content") 942 | self.assertEqual(message.get_payload(1).get_content_type(), "text/html") 943 | 944 | def test_message_cc_header(self): 945 | email = EmailMessage( 946 | "Subject", 947 | "Content", 948 | "from@example.com", 949 | ["to@example.com"], 950 | cc=["cc@example.com"], 951 | ) 952 | self.mail.get_connection().send_messages([email]) 953 | message = self.get_message() 954 | self.assertMessageHasHeaders( 955 | message, 956 | { 957 | ("MIME-Version", "1.0"), 958 | ("Content-Type", 'text/plain; charset="utf-8"'), 959 | ("Content-Transfer-Encoding", "7bit"), 960 | ("Subject", "Subject"), 961 | ("From", "from@example.com"), 962 | ("To", "to@example.com"), 963 | ("Cc", "cc@example.com"), 964 | }, 965 | ) 966 | self.assertIn("\nDate: ", message.as_string()) 967 | 968 | def test_idn_send(self): 969 | self.assertTrue(self.mail.send_mail("Subject", "Content", "from@öäü.com", ["to@öäü.com"])) 970 | message = self.get_message() 971 | self.assertEqual(message.get("subject"), "Subject") 972 | self.assertEqual(message.get("from"), "from@xn--4ca9at.com") 973 | self.assertEqual(message.get("to"), "to@xn--4ca9at.com") 974 | 975 | self.flush_mailbox() 976 | 977 | m = EmailMessage("Subject", "Content", "from@öäü.com", ["to@öäü.com"], cc=["cc@öäü.com"]) 978 | m.send() 979 | message = self.get_message() 980 | self.assertEqual(message.get("subject"), "Subject") 981 | self.assertEqual(message.get("from"), "from@xn--4ca9at.com") 982 | self.assertEqual(message.get("to"), "to@xn--4ca9at.com") 983 | self.assertEqual(message.get("cc"), "cc@xn--4ca9at.com") 984 | 985 | def test_recipient_without_domain(self): 986 | """ 987 | Regression test for #15042 988 | """ 989 | self.assertTrue(self.mail.send_mail("Subject", "Content", "tester", ["little bird"])) 990 | message = self.get_message() 991 | self.assertEqual(message.get("subject"), "Subject") 992 | self.assertEqual(message.get("from"), "tester") 993 | self.assertEqual(message.get("to"), "little bird") 994 | --------------------------------------------------------------------------------