├── tests ├── testdata │ ├── attachment_1.txt │ ├── attachment_17.txt │ ├── mailmerge_database_with_BOM.csv │ ├── attachment_2.pdf │ └── attachment_3.jpg ├── __init__.py ├── utils.py ├── test_helpers.py ├── test_ratelimit.py ├── test_template_message_encodings.py ├── test_sendmail_client.py ├── test_main_output.py ├── test_template_message.py └── test_main.py ├── setup.py ├── MANIFEST.in ├── mailmerge ├── __init__.py ├── exceptions.py ├── sendmail_client.py ├── __main__.py └── template_message.py ├── .pylintrc ├── .gitignore ├── tox.ini ├── .editorconfig ├── CONTRIBUTING.md ├── pyproject.toml ├── LICENSE ├── .github └── workflows │ └── main.yml └── README.md /tests/testdata/attachment_1.txt: -------------------------------------------------------------------------------- 1 | Test Send Attachment 1 2 | -------------------------------------------------------------------------------- /tests/testdata/attachment_17.txt: -------------------------------------------------------------------------------- 1 | Test Send Attachment 17 2 | -------------------------------------------------------------------------------- /tests/testdata/mailmerge_database_with_BOM.csv: -------------------------------------------------------------------------------- 1 | name,email 2 | My Name,to@test.com 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Support legacy tools.""" 2 | 3 | from setuptools import setup 4 | 5 | setup() 6 | -------------------------------------------------------------------------------- /tests/testdata/attachment_2.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awdeorio/mailmerge/HEAD/tests/testdata/attachment_2.pdf -------------------------------------------------------------------------------- /tests/testdata/attachment_3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awdeorio/mailmerge/HEAD/tests/testdata/attachment_3.jpg -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Dummy file needed for pylint to discover unit tests. 3 | 4 | Andrew DeOrio 5 | """ 6 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utilies common to multiple tests. 3 | 4 | Andrew DeOrio 5 | """ 6 | 7 | from pathlib import Path 8 | 9 | 10 | # Directories containing test input files 11 | TESTDIR = Path(__file__).resolve().parent 12 | TESTDATA = TESTDIR / "testdata" 13 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CONTRIBUTING.md 2 | include LICENSE 3 | include MANIFEST.in 4 | include README.md 5 | include .pylintrc 6 | graft tests 7 | 8 | # Avoid dev and and binary files 9 | exclude tox.ini 10 | exclude .editorconfig 11 | global-exclude *.pyc 12 | global-exclude __pycache__ 13 | -------------------------------------------------------------------------------- /mailmerge/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Mail merge module importable API. 3 | 4 | Andrew DeOrio 5 | """ 6 | 7 | 8 | from .sendmail_client import SendmailClient 9 | from .template_message import TemplateMessage 10 | from .exceptions import MailmergeError, MailmergeRateLimitError 11 | -------------------------------------------------------------------------------- /mailmerge/exceptions.py: -------------------------------------------------------------------------------- 1 | """Errors raised by mailmerge.""" 2 | 3 | 4 | class MailmergeError(Exception): 5 | """Top level exception raised by mailmerge functions.""" 6 | 7 | 8 | class MailmergeRateLimitError(MailmergeError): 9 | """Reuse to send message because rate limit exceeded.""" 10 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [SIMILARITIES] 2 | 3 | # Minimum lines number of a similarity. 4 | min-similarity-lines=16 5 | 6 | # Ignore comments when computing similarities. 7 | ignore-comments=yes 8 | 9 | # Ignore docstrings when computing similarities. 10 | ignore-docstrings=yes 11 | 12 | # Ignore imports when computing similarities. 13 | ignore-imports=yes 14 | 15 | 16 | [TYPECHECK] 17 | 18 | # Avoid no-member errors that are endemic to some libraries 19 | ignored-classes=socket 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Mailmerge config files 2 | /mailmerge_database.csv 3 | /mailmerge_template.txt 4 | /mailmerge_server.conf 5 | 6 | # Python temporary files 7 | *.pyc 8 | __pycache__ 9 | 10 | # Python virtual environment 11 | /env/ 12 | /env2/ 13 | /env3/ 14 | /.venv/ 15 | /venv/ 16 | 17 | # Python distribution productions 18 | dist/ 19 | build/ 20 | *.egg-info/ 21 | 22 | # Editors 23 | *~ 24 | 25 | # Test tools 26 | /.tox/ 27 | /tmp/ 28 | /.coverage* 29 | *,cover 30 | coverage.xml 31 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Local host configuration with one Python 3 version 2 | [tox] 3 | envlist = py38, py39, py310, py311, py312 4 | 5 | # GitHub Actions configuration with multiple Python versions 6 | # https://github.com/ymyzk/tox-gh-actions#tox-gh-actions-configuration 7 | [gh-actions] 8 | python = 9 | 3.8: py38 10 | 3.9: py39 11 | 3.10: py310 12 | 3.11: py311 13 | 3.12: py312 14 | 15 | # Run unit tests 16 | # HACK: Pydocstyle fails to find tests. Invoke a shell to use a glob. 17 | [testenv] 18 | setenv = 19 | PYTHONPATH = {toxinidir} 20 | allowlist_externals = sh 21 | extras = test 22 | commands = 23 | pycodestyle mailmerge tests setup.py 24 | sh -c "pydocstyle mailmerge tests/* setup.py" 25 | pylint mailmerge tests setup.py 26 | check-manifest 27 | pytest -vvs --cov mailmerge 28 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | # Matches multiple files with brace expansion notation 13 | # Set default charset 14 | [*.{js,py}] 15 | charset = utf-8 16 | 17 | # 4 space indentation 18 | [*.py] 19 | indent_style = space 20 | indent_size = 4 21 | 22 | # Tab indentation (no size specified) 23 | [Makefile] 24 | indent_style = tab 25 | 26 | # Indentation override for all JS under lib directory 27 | [lib/**.js] 28 | indent_style = space 29 | indent_size = 2 30 | 31 | # Matches the exact files either package.json or .travis.yml 32 | [{package.json,.travis.yml}] 33 | indent_style = space 34 | indent_size = 2 35 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributing to Mailmerge 2 | ========================= 3 | 4 | ## Install development environment 5 | Set up a development virtual environment. 6 | ```console 7 | $ python3 -m venv env 8 | $ source env/bin/activate 9 | $ pip install --editable .[dev,test] 10 | ``` 11 | 12 | A `mailmerge` entry point script is installed in your virtual environment. 13 | ```console 14 | $ which mailmerge 15 | /Users/awdeorio/src/mailmerge/env/bin/mailmerge 16 | ``` 17 | 18 | ## Testing and code quality 19 | Run unit tests 20 | ```console 21 | $ pytest 22 | ``` 23 | 24 | Measure unit test case coverage 25 | ```console 26 | $ pytest --cov ./mailmerge --cov-report term-missing 27 | ``` 28 | 29 | Test code style 30 | ```console 31 | $ pycodestyle mailmerge tests setup.py 32 | $ pydocstyle mailmerge tests setup.py 33 | $ pylint mailmerge tests setup.py 34 | $ check-manifest 35 | ``` 36 | 37 | Run linters and tests in a clean environment. This will automatically create a temporary virtual environment. 38 | ```console 39 | $ tox 40 | ``` 41 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=64.0.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "mailmerge" 7 | version = "2.2.4" 8 | description = "A simple, command line mail merge tool" 9 | keywords = ["mail merge", "mailmerge", "email"] 10 | authors = [{ email = "awdeorio@umich.edu" }, { name = "Andrew DeOrio" }] 11 | license = {file = "LICENSE"} 12 | readme = "README.md" 13 | requires-python = ">=3.6" 14 | dependencies = [ 15 | "click", 16 | "jinja2", 17 | "markdown", 18 | "html5lib" 19 | ] 20 | 21 | [project.optional-dependencies] 22 | dev = [ 23 | "twine", 24 | "tox", 25 | ] 26 | test = [ 27 | "check-manifest", 28 | "freezegun", 29 | "pycodestyle", 30 | "pydocstyle", 31 | "pylint", 32 | "pytest", 33 | "pytest-cov", 34 | "pytest-mock", 35 | ] 36 | 37 | [project.scripts] 38 | mailmerge = "mailmerge.__main__:main" 39 | 40 | [project.urls] 41 | homepage = "https://github.com/awdeorio/mailmerge/" 42 | 43 | [tool.setuptools] 44 | packages = ["mailmerge"] 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Andrew DeOrio 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # GitHub Continuous Integration Configuration 2 | name: CI 3 | 4 | # Define conditions for when to run this action 5 | on: 6 | pull_request: # Run on all pull requests 7 | push: # Run on all pushes to master 8 | branches: 9 | - main 10 | - develop 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. Each job has an id, for 16 | # example, one of our jobs is "lint" 17 | jobs: 18 | test: 19 | name: Tests ${{ matrix.python-version }} 20 | runs-on: ${{ matrix.os }} 21 | strategy: 22 | # Define OS and Python versions to use. 3.x is the latest minor version. 23 | matrix: 24 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.x"] 25 | os: [ubuntu-latest] 26 | 27 | # Sequence of tasks for this job 28 | steps: 29 | # Check out latest code 30 | # Docs: https://github.com/actions/checkout 31 | - name: Checkout code 32 | uses: actions/checkout@v2 33 | 34 | # Set up Python 35 | # Docs: https://github.com/actions/setup-python 36 | - name: Set up Python ${{ matrix.python-version }} 37 | uses: actions/setup-python@v2 38 | with: 39 | python-version: ${{ matrix.python-version }} 40 | 41 | # Install dependencies 42 | # https://github.com/ymyzk/tox-gh-actions#workflow-configuration 43 | - name: Install dependencies 44 | run: | 45 | python -m pip install --upgrade pip 46 | pip install coverage tox tox-gh-actions 47 | 48 | # Run tests 49 | # https://github.com/ymyzk/tox-gh-actions#workflow-configuration 50 | - name: Run tests 51 | run: tox 52 | - name: Combine coverage 53 | run: coverage xml 54 | 55 | # Upload coverage report 56 | # https://github.com/codecov/codecov-action 57 | - name: Upload coverage report 58 | uses: codecov/codecov-action@v4 59 | with: 60 | token: ${{ secrets.CODECOV_TOKEN }} 61 | slug: awdeorio/mailmerge 62 | fail_ci_if_error: true 63 | -------------------------------------------------------------------------------- /tests/test_helpers.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for helper functions. 3 | 4 | Andrew DeOrio 5 | """ 6 | import textwrap 7 | from pathlib import Path 8 | import pytest 9 | from mailmerge.__main__ import enumerate_range, read_csv_database 10 | from mailmerge import MailmergeError 11 | 12 | 13 | def test_enumerate_range_default(): 14 | """Verify default start and stop.""" 15 | output = list(enumerate_range(["a", "b", "c"])) 16 | assert output == [(0, "a"), (1, "b"), (2, "c")] 17 | 18 | 19 | def test_enumerate_range_stop_none(): 20 | """Verify stop=None.""" 21 | output = list(enumerate_range(["a", "b", "c"], stop=None)) 22 | assert output == [(0, "a"), (1, "b"), (2, "c")] 23 | 24 | 25 | def test_enumerate_range_stop_value(): 26 | """Verify stop=value.""" 27 | output = list(enumerate_range(["a", "b", "c"], stop=1)) 28 | assert output == [(0, "a")] 29 | 30 | 31 | def test_enumerate_range_stop_zero(): 32 | """Verify stop=0.""" 33 | output = list(enumerate_range(["a", "b", "c"], stop=0)) 34 | assert not output 35 | 36 | 37 | def test_enumerate_range_stop_too_big(): 38 | """Verify stop when value is greater than length.""" 39 | output = list(enumerate_range(["a", "b", "c"], stop=10)) 40 | assert output == [(0, "a"), (1, "b"), (2, "c")] 41 | 42 | 43 | def test_enumerate_range_start_zero(): 44 | """Verify start=0.""" 45 | output = list(enumerate_range(["a", "b", "c"], start=0)) 46 | assert output == [(0, "a"), (1, "b"), (2, "c")] 47 | 48 | 49 | def test_enumerate_range_start_value(): 50 | """Verify start=1.""" 51 | output = list(enumerate_range(["a", "b", "c"], start=1)) 52 | assert output == [(1, "b"), (2, "c")] 53 | 54 | 55 | def test_enumerate_range_start_last_one(): 56 | """Verify start=length - 1.""" 57 | output = list(enumerate_range(["a", "b", "c"], start=2)) 58 | assert output == [(2, "c")] 59 | 60 | 61 | def test_enumerate_range_start_length(): 62 | """Verify start=length.""" 63 | output = list(enumerate_range(["a", "b", "c"], start=3)) 64 | assert not output 65 | 66 | 67 | def test_enumerate_range_start_too_big(): 68 | """Verify start past the end.""" 69 | output = list(enumerate_range(["a", "b", "c"], start=10)) 70 | assert not output 71 | 72 | 73 | def test_enumerate_range_start_stop(): 74 | """Verify start and stop together.""" 75 | output = list(enumerate_range(["a", "b", "c"], start=1, stop=2)) 76 | assert output == [(1, "b")] 77 | 78 | 79 | def test_csv_bad(tmpdir): 80 | """CSV with unmatched quote.""" 81 | database_path = Path(tmpdir/"database.csv") 82 | database_path.write_text(textwrap.dedent("""\ 83 | a,b 84 | 1,"2 85 | """), encoding="utf8") 86 | with pytest.raises(MailmergeError): 87 | next(read_csv_database(database_path)) 88 | 89 | 90 | def test_csv_quotes_commas(tmpdir): 91 | """CSV with quotes and commas. 92 | 93 | Note that quotes are escaped with double quotes, not backslash. 94 | https://docs.python.org/3.7/library/csv.html#csv.Dialect.doublequote 95 | """ 96 | database_path = Path(tmpdir/"database.csv") 97 | database_path.write_text(textwrap.dedent('''\ 98 | email,message 99 | one@test.com,"Hello, ""world""" 100 | '''), encoding="utf8") 101 | row = next(read_csv_database(database_path)) 102 | assert row["email"] == "one@test.com" 103 | assert row["message"] == 'Hello, "world"' 104 | 105 | 106 | def test_csv_utf8(tmpdir): 107 | """CSV with quotes and commas.""" 108 | database_path = Path(tmpdir/"database.csv") 109 | database_path.write_text(textwrap.dedent("""\ 110 | email,message 111 | Laȝamon ,Laȝamon emoji \xf0\x9f\x98\x80 klâwen 112 | """), encoding="utf8") 113 | row = next(read_csv_database(database_path)) 114 | assert row["email"] == "Laȝamon " 115 | assert row["message"] == "Laȝamon emoji \xf0\x9f\x98\x80 klâwen" 116 | -------------------------------------------------------------------------------- /tests/test_ratelimit.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for SMTP server rate limit feature. 3 | 4 | Andrew DeOrio 5 | """ 6 | import textwrap 7 | import datetime 8 | from pathlib import Path 9 | import email 10 | import email.parser 11 | import freezegun 12 | import pytest 13 | import click.testing 14 | from mailmerge import SendmailClient, MailmergeRateLimitError 15 | from mailmerge.__main__ import main 16 | 17 | 18 | def test_sendmail_ratelimit(mocker, tmp_path): 19 | """Verify SMTP library calls.""" 20 | config_path = tmp_path/"server.conf" 21 | config_path.write_text(textwrap.dedent("""\ 22 | [smtp_server] 23 | host = open-smtp.example.com 24 | port = 25 25 | ratelimit = 60 26 | """), encoding="utf8") 27 | sendmail_client = SendmailClient( 28 | config_path, 29 | dry_run=False, 30 | ) 31 | message = email.message_from_string(""" 32 | TO: to@test.com 33 | SUBJECT: Testing mailmerge 34 | FROM: from@test.com 35 | 36 | Hello world 37 | """) 38 | 39 | # Mock SMTP 40 | mock_smtp = mocker.patch('smtplib.SMTP') 41 | 42 | # First message 43 | sendmail_client.sendmail( 44 | sender="from@test.com", 45 | recipients=["to@test.com"], 46 | message=message, 47 | ) 48 | smtp = mock_smtp.return_value.__enter__.return_value 49 | assert smtp.sendmail.call_count == 1 50 | 51 | # Second message exceeds the rate limit, doesn't try to send a message 52 | with pytest.raises(MailmergeRateLimitError): 53 | sendmail_client.sendmail( 54 | sender="from@test.com", 55 | recipients=["to@test.com"], 56 | message=message, 57 | ) 58 | assert smtp.sendmail.call_count == 1 59 | 60 | # Retry the second message after 1 s because the rate limit is 60 messages 61 | # per minute 62 | # 63 | # Mock the time to be 1.1 s in the future 64 | # Ref: https://github.com/spulec/freezegun 65 | now = datetime.datetime.now() 66 | with freezegun.freeze_time(now + datetime.timedelta(seconds=1)): 67 | sendmail_client.sendmail( 68 | sender="from@test.com", 69 | recipients=["to@test.com"], 70 | message=message, 71 | ) 72 | assert smtp.sendmail.call_count == 2 73 | 74 | 75 | def test_stdout_ratelimit(mocker, tmpdir): 76 | """Verify SMTP server ratelimit parameter.""" 77 | # Simple template 78 | template_path = Path(tmpdir/"mailmerge_template.txt") 79 | template_path.write_text(textwrap.dedent("""\ 80 | TO: {{email}} 81 | FROM: from@test.com 82 | 83 | Hello world 84 | """), encoding="utf8") 85 | 86 | # Simple database with two entries 87 | database_path = Path(tmpdir/"mailmerge_database.csv") 88 | database_path.write_text(textwrap.dedent("""\ 89 | email 90 | one@test.com 91 | two@test.com 92 | """), encoding="utf8") 93 | 94 | # Simple unsecure server config 95 | config_path = Path(tmpdir/"mailmerge_server.conf") 96 | config_path.write_text(textwrap.dedent("""\ 97 | [smtp_server] 98 | host = open-smtp.example.com 99 | port = 25 100 | ratelimit = 60 101 | """), encoding="utf8") 102 | 103 | # Mock SMTP 104 | mock_smtp = mocker.patch('smtplib.SMTP') 105 | 106 | # Run mailmerge 107 | before = datetime.datetime.now() 108 | with tmpdir.as_cwd(): 109 | runner = click.testing.CliRunner(mix_stderr=False) 110 | result = runner.invoke( 111 | main, [ 112 | "--no-limit", 113 | "--no-dry-run", 114 | "--output-format", "text", 115 | ] 116 | ) 117 | after = datetime.datetime.now() 118 | assert after - before > datetime.timedelta(seconds=1) 119 | smtp = mock_smtp.return_value.__enter__.return_value 120 | assert smtp.sendmail.call_count == 2 121 | assert result.exit_code == 0 122 | assert result.stderr == "" 123 | assert ">>> message 1 sent" in result.stdout 124 | assert ">>> rate limit exceeded, waiting ..." in result.stdout 125 | assert ">>> message 2 sent" in result.stdout 126 | -------------------------------------------------------------------------------- /mailmerge/sendmail_client.py: -------------------------------------------------------------------------------- 1 | """ 2 | SMTP client reads configuration and sends message. 3 | 4 | Andrew DeOrio 5 | """ 6 | import collections 7 | import socket 8 | import smtplib 9 | import configparser 10 | import getpass 11 | import datetime 12 | import base64 13 | import ssl 14 | from . import exceptions 15 | 16 | # Type to store info read from config file 17 | MailmergeConfig = collections.namedtuple( 18 | "MailmergeConfig", 19 | ["username", "host", "port", "security", "ratelimit"], 20 | ) 21 | 22 | 23 | class SendmailClient: 24 | """Represent a client connection to an SMTP server.""" 25 | 26 | def __init__(self, config_path, dry_run=False): 27 | """Read configuration from server configuration file.""" 28 | self.config_path = config_path 29 | self.dry_run = dry_run # Do not send real messages 30 | self.config = None # Config read from config_path by read_config() 31 | self.password = None # Password read from stdin 32 | self.lastsent = None # Timestamp of last successful send 33 | self.read_config() 34 | 35 | def read_config(self): 36 | """Read configuration file and return a MailmergeConfig object.""" 37 | try: 38 | parser = configparser.RawConfigParser() 39 | parser.read(str(self.config_path)) 40 | host = parser.get("smtp_server", "host") 41 | port = parser.getint("smtp_server", "port") 42 | security = parser.get("smtp_server", "security", fallback=None) 43 | username = parser.get("smtp_server", "username", fallback=None) 44 | ratelimit = parser.getint("smtp_server", "ratelimit", fallback=0) 45 | except (configparser.Error, ValueError) as err: 46 | raise exceptions.MailmergeError(f"{self.config_path}: {err}") 47 | 48 | # Coerce legacy option "security = Never" 49 | if security == "Never": 50 | security = None 51 | 52 | # Verify security type 53 | if security not in [None, "SSL/TLS", "STARTTLS", "PLAIN", "XOAUTH"]: 54 | raise exceptions.MailmergeError( 55 | f"{self.config_path}: unrecognized security type: '{security}'" 56 | ) 57 | 58 | # Verify username 59 | if security is not None and username is None: 60 | raise exceptions.MailmergeError( 61 | f"{self.config_path}: username is required for " 62 | f"security type '{security}'" 63 | ) 64 | 65 | # Save validated configuration 66 | self.config = MailmergeConfig( 67 | username, host, port, security, ratelimit, 68 | ) 69 | 70 | def sendmail(self, sender, recipients, message): 71 | """Send email message.""" 72 | if self.dry_run: 73 | return 74 | 75 | # Check if we've hit the rate limit 76 | now = datetime.datetime.now() 77 | if self.config.ratelimit and self.lastsent: 78 | waittime = datetime.timedelta(minutes=1.0 / self.config.ratelimit) 79 | if now - self.lastsent < waittime: 80 | raise exceptions.MailmergeRateLimitError() 81 | 82 | # Ask for password if necessary 83 | if self.config.security is not None and self.password is None: 84 | self.password = getpass.getpass( 85 | f">>> password for {self.config.username} on " 86 | f"{self.config.host}: " 87 | ) 88 | 89 | # Send 90 | host, port = self.config.host, self.config.port 91 | try: 92 | if self.config.security == "SSL/TLS": 93 | self.sendmail_ssltls(sender, recipients, message) 94 | elif self.config.security == "STARTTLS": 95 | self.sendmail_starttls(sender, recipients, message) 96 | elif self.config.security == "PLAIN": 97 | self.sendmail_plain(sender, recipients, message) 98 | elif self.config.security == "XOAUTH": 99 | self.sendmail_xoauth(sender, recipients, message) 100 | elif self.config.security is None: 101 | self.sendmail_clear(sender, recipients, message) 102 | except smtplib.SMTPAuthenticationError as err: 103 | raise exceptions.MailmergeError( 104 | f"{host}:{port} failed to authenticate " 105 | f"user '{self.config.username}': {err}" 106 | ) 107 | except smtplib.SMTPException as err: 108 | raise exceptions.MailmergeError( 109 | f"{host}:{port} failed to send message: {err}" 110 | ) 111 | except socket.error as err: 112 | raise exceptions.MailmergeError( 113 | f"{host}:{port} failed to connect to server: {err}" 114 | ) 115 | 116 | # Update timestamp of last sent message 117 | self.lastsent = now 118 | 119 | def sendmail_ssltls(self, sender, recipients, message): 120 | """Send email message with SSL/TLS security.""" 121 | message_flattened = str(message) 122 | try: 123 | ctx = ssl.create_default_context() 124 | except ssl.SSLError as err: 125 | raise exceptions.MailmergeError(f"SSL Error: {err}") 126 | host, port = (self.config.host, self.config.port) 127 | with smtplib.SMTP_SSL(host, port, context=ctx) as smtp: 128 | smtp.login(self.config.username, self.password) 129 | smtp.sendmail(sender, recipients, message_flattened) 130 | 131 | def sendmail_starttls(self, sender, recipients, message): 132 | """Send email message with STARTTLS security.""" 133 | message_flattened = str(message) 134 | with smtplib.SMTP(self.config.host, self.config.port) as smtp: 135 | smtp.ehlo() 136 | smtp.starttls() 137 | smtp.ehlo() 138 | smtp.login(self.config.username, self.password) 139 | smtp.sendmail(sender, recipients, message_flattened) 140 | 141 | def sendmail_plain(self, sender, recipients, message): 142 | """Send email message with plain security.""" 143 | message_flattened = str(message) 144 | with smtplib.SMTP(self.config.host, self.config.port) as smtp: 145 | smtp.login(self.config.username, self.password) 146 | smtp.sendmail(sender, recipients, message_flattened) 147 | 148 | def sendmail_clear(self, sender, recipients, message): 149 | """Send email message with no security.""" 150 | message_flattened = str(message) 151 | with smtplib.SMTP(self.config.host, self.config.port) as smtp: 152 | smtp.sendmail(sender, recipients, message_flattened) 153 | 154 | def sendmail_xoauth(self, sender, recipients, message): 155 | """Send email message with XOAUTH security.""" 156 | xoauth2 = ( 157 | f"user={self.config.username}\x01" 158 | f"auth=Bearer {self.password}\x01\x01" 159 | ) 160 | try: 161 | xoauth2 = xoauth2.encode("ascii") 162 | except UnicodeEncodeError as err: 163 | raise exceptions.MailmergeError( 164 | f"Username and XOAUTH access token must be ASCII '{xoauth2}'. " 165 | f"{err}, " 166 | ) 167 | message_flattened = str(message) 168 | with smtplib.SMTP(self.config.host, self.config.port) as smtp: 169 | smtp.ehlo() 170 | smtp.starttls() 171 | smtp.ehlo() 172 | smtp.docmd('AUTH XOAUTH2') 173 | smtp.docmd(str(base64.b64encode(xoauth2).decode("utf-8"))) 174 | smtp.sendmail(sender, recipients, message_flattened) 175 | -------------------------------------------------------------------------------- /mailmerge/__main__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Command line interface implementation. 3 | 4 | Andrew DeOrio 5 | """ 6 | import sys 7 | import time 8 | import textwrap 9 | from pathlib import Path 10 | import csv 11 | import click 12 | from .template_message import TemplateMessage 13 | from .sendmail_client import SendmailClient 14 | from . import exceptions 15 | 16 | 17 | @click.command(context_settings={"help_option_names": ['-h', '--help']}) 18 | @click.version_option() # Auto detect version from setup.py 19 | @click.option( 20 | "--sample", is_flag=True, default=False, 21 | help="Create sample template, database, and config", 22 | ) 23 | @click.option( 24 | "--dry-run/--no-dry-run", default=True, 25 | help="Don't send email, just print (dry-run)", 26 | ) 27 | @click.option( 28 | "--no-limit", is_flag=True, default=False, 29 | help="Do not limit the number of messages", 30 | ) 31 | @click.option( 32 | "--limit", is_flag=False, default=1, 33 | type=click.IntRange(0, None), 34 | help="Limit the number of messages (1)", 35 | ) 36 | @click.option( 37 | "--resume", is_flag=False, default=1, 38 | type=click.IntRange(1, None), 39 | help="Start on message number INTEGER", 40 | ) 41 | @click.option( 42 | "--template", "template_path", 43 | default="mailmerge_template.txt", 44 | type=click.Path(), 45 | help="template email (mailmerge_template.txt)" 46 | ) 47 | @click.option( 48 | "--database", "database_path", 49 | default="mailmerge_database.csv", 50 | type=click.Path(), 51 | help="database CSV (mailmerge_database.csv)", 52 | ) 53 | @click.option( 54 | "--config", "config_path", 55 | default="mailmerge_server.conf", 56 | type=click.Path(), 57 | help="server configuration (mailmerge_server.conf)", 58 | ) 59 | @click.option( 60 | "--output-format", "output_format", 61 | default="colorized", 62 | type=click.Choice(["colorized", "text", "raw"]), 63 | help="Output format (colorized).", 64 | ) 65 | def main(*, sample, dry_run, limit, no_limit, resume, 66 | template_path, database_path, config_path, 67 | output_format): 68 | """ 69 | Mailmerge is a simple, command line mail merge tool. 70 | 71 | For examples and formatting features, see: 72 | https://github.com/awdeorio/mailmerge 73 | """ 74 | # We need an argument for each command line option. That also means a lot 75 | # of local variables. 76 | # pylint: disable=too-many-arguments 77 | # pylint: disable=too-many-locals 78 | 79 | # Convert paths from string to Path objects 80 | # https://github.com/pallets/click/issues/405 81 | template_path = Path(template_path) 82 | database_path = Path(database_path) 83 | config_path = Path(config_path) 84 | 85 | # Make sure input files exist and provide helpful prompts 86 | check_input_files(template_path, database_path, config_path, sample) 87 | 88 | # Calculate start and stop indexes. Start and stop are zero-based. The 89 | # input --resume is one-based. 90 | start = resume - 1 91 | stop = None if no_limit else resume - 1 + limit 92 | 93 | # Run 94 | message_num = 1 + start 95 | try: 96 | template_message = TemplateMessage(template_path) 97 | csv_database = read_csv_database(database_path) 98 | sendmail_client = SendmailClient(config_path, dry_run) 99 | 100 | for _, row in enumerate_range(csv_database, start, stop): 101 | sender, recipients, message = template_message.render(row) 102 | while True: 103 | try: 104 | sendmail_client.sendmail(sender, recipients, message) 105 | except exceptions.MailmergeRateLimitError: 106 | print_bright_white_on_cyan( 107 | ">>> rate limit exceeded, waiting ...", 108 | output_format, 109 | ) 110 | else: 111 | break 112 | time.sleep(1) 113 | print_bright_white_on_cyan( 114 | f">>> message {message_num}", 115 | output_format, 116 | ) 117 | print_message(message, output_format) 118 | print_bright_white_on_cyan( 119 | f">>> message {message_num} sent", 120 | output_format, 121 | ) 122 | message_num += 1 123 | 124 | except exceptions.MailmergeError as error: 125 | hint_text = "" 126 | if message_num > 1: 127 | hint_text = f'\nHint: "--resume {message_num}"' 128 | sys.exit(f"Error on message {message_num}\n{error}{hint_text}") 129 | 130 | # Hints for user 131 | if not no_limit: 132 | pluralizer = "" if limit == 1 else "s" 133 | print( 134 | f">>> Limit was {limit} message{pluralizer}. " 135 | "To remove the limit, use the --no-limit option." 136 | ) 137 | if dry_run: 138 | print( 139 | ">>> This was a dry run. " 140 | "To send messages, use the --no-dry-run option." 141 | ) 142 | 143 | 144 | if __name__ == "__main__": 145 | main() # pylint: disable=missing-kwoa 146 | 147 | 148 | def check_input_files(template_path, database_path, config_path, sample): 149 | """Check if input files are present and hint the user.""" 150 | if sample: 151 | create_sample_input_files(template_path, database_path, config_path) 152 | sys.exit(0) 153 | 154 | if not template_path.exists(): 155 | sys.exit(textwrap.dedent(f"""\ 156 | Error: can't find template "{template_path}". 157 | 158 | Create a sample (--sample) or specify a file (--template). 159 | 160 | See https://github.com/awdeorio/mailmerge for examples.\ 161 | """)) 162 | 163 | if not database_path.exists(): 164 | sys.exit(textwrap.dedent(f"""\ 165 | Error: can't find database "{database_path}". 166 | 167 | Create a sample (--sample) or specify a file (--database). 168 | 169 | See https://github.com/awdeorio/mailmerge for examples.\ 170 | """)) 171 | 172 | if not config_path.exists(): 173 | sys.exit(textwrap.dedent(f"""\ 174 | Error: can't find config "{config_path}". 175 | 176 | Create a sample (--sample) or specify a file (--config). 177 | 178 | See https://github.com/awdeorio/mailmerge for examples.\ 179 | """)) 180 | 181 | 182 | def create_sample_input_files(template_path, database_path, config_path): 183 | """Create sample template, database and server config.""" 184 | for path in [template_path, database_path, config_path]: 185 | if path.exists(): 186 | sys.exit(f"Error: file exists: {path}") 187 | with template_path.open("w") as template_file: 188 | template_file.write(textwrap.dedent("""\ 189 | TO: {{email}} 190 | SUBJECT: Testing mailmerge 191 | FROM: My Self 192 | 193 | Hi, {{name}}, 194 | 195 | Your number is {{number}}. 196 | """)) 197 | with database_path.open("w") as database_file: 198 | database_file.write(textwrap.dedent("""\ 199 | email,name,number 200 | myself@mydomain.com,"Myself",17 201 | bob@bobdomain.com,"Bob",42 202 | """)) 203 | with config_path.open("w") as config_file: 204 | config_file.write(textwrap.dedent("""\ 205 | # Mailmerge SMTP Server Config 206 | # https://github.com/awdeorio/mailmerge 207 | # 208 | # Pro-tip: SSH or VPN into your network first to avoid spam 209 | # filters and server throttling. 210 | # 211 | # Parameters 212 | # host # SMTP server hostname or IP 213 | # port # SMTP server port 214 | # security # Security protocol: "SSL/TLS", "STARTTLS", or omit 215 | # username # Username for SSL/TLS or STARTTLS security 216 | # ratelimit # Rate limit in messages per minute, 0 for unlimited 217 | 218 | # Example: GMail 219 | [smtp_server] 220 | host = smtp.gmail.com 221 | port = 465 222 | security = SSL/TLS 223 | username = YOUR_USERNAME_HERE 224 | ratelimit = 0 225 | 226 | # Example: SSL/TLS 227 | # [smtp_server] 228 | # host = smtp.mail.umich.edu 229 | # port = 465 230 | # security = SSL/TLS 231 | # username = YOUR_USERNAME_HERE 232 | # ratelimit = 0 233 | 234 | # Example: STARTTLS security 235 | # [smtp_server] 236 | # host = newman.eecs.umich.edu 237 | # port = 25 238 | # security = STARTTLS 239 | # username = YOUR_USERNAME_HERE 240 | # ratelimit = 0 241 | 242 | # Example: Plain security 243 | # [smtp_server] 244 | # host = newman.eecs.umich.edu 245 | # port = 25 246 | # security = PLAIN 247 | # username = YOUR_USERNAME_HERE 248 | # ratelimit = 0 249 | 250 | # Example: XOAUTH 251 | # Enter your token at the password prompt. For Microsoft OAuth 252 | # authentication, a token can be obtained with the oauth2ms tool 253 | # https://github.com/harishkrupo/oauth2ms 254 | # [smtp_server] 255 | # host = smtp.office365.com 256 | # port = 587 257 | # security = XOAUTH 258 | # username = username@example.com 259 | 260 | # Example: No security 261 | # [smtp_server] 262 | # host = newman.eecs.umich.edu 263 | # port = 25 264 | # ratelimit = 0 265 | """)) 266 | print(textwrap.dedent(f"""\ 267 | Created sample template email "{template_path}" 268 | Created sample database "{database_path}" 269 | Created sample config file "{config_path}" 270 | 271 | Edit these files, then run mailmerge again.\ 272 | """)) 273 | 274 | 275 | def detect_database_format(database_file): 276 | """Automatically detect the database format. 277 | 278 | Automatically detect the format ("dialect") using the CSV library's sniffer 279 | class. For example, comma-delimited, tab-delimited, etc. Default to 280 | StrictExcel if automatic detection fails. 281 | 282 | """ 283 | class StrictExcel(csv.excel): 284 | # Our helper class is really simple 285 | # pylint: disable=too-few-public-methods, missing-class-docstring 286 | strict = True 287 | 288 | # Read a sample from database 289 | sample = database_file.read(1024) 290 | database_file.seek(0) 291 | 292 | # Attempt automatic format detection, fall back on StrictExcel default 293 | try: 294 | csvdialect = csv.Sniffer().sniff(sample, delimiters=",;\t") 295 | except csv.Error: 296 | csvdialect = StrictExcel 297 | 298 | return csvdialect 299 | 300 | 301 | def read_csv_database(database_path): 302 | """Read database CSV file, providing one line at a time. 303 | 304 | Use strict syntax checking, which will trigger errors for things like 305 | unclosed quotes. 306 | 307 | We open the file with the utf-8-sig encoding, which skips a byte order mark 308 | (BOM), if any. Sometimes Excel will save CSV files with a BOM. See Issue 309 | #93 https://github.com/awdeorio/mailmerge/issues/93 310 | 311 | """ 312 | with database_path.open(encoding="utf-8-sig") as database_file: 313 | csvdialect = detect_database_format(database_file) 314 | csvdialect.strict = True 315 | reader = csv.DictReader(database_file, dialect=csvdialect) 316 | try: 317 | yield from reader 318 | except csv.Error as err: 319 | raise exceptions.MailmergeError( 320 | f"{database_path}:{reader.line_num}: {err}" 321 | ) 322 | 323 | 324 | def enumerate_range(iterable, start=0, stop=None): 325 | """Enumerate iterable, starting at index "start", stopping before "stop". 326 | 327 | To enumerate the entire iterable, start=0 and stop=None. 328 | """ 329 | assert start >= 0 330 | assert stop is None or stop >= 0 331 | for i, value in enumerate(iterable): 332 | if i < start: 333 | continue 334 | if stop is not None and i >= stop: 335 | return 336 | yield i, value 337 | 338 | 339 | def print_cyan(string, output_format): 340 | """Print string to stdout, optionally enabling color.""" 341 | if output_format == "colorized": 342 | string = "\x1b[36m" + string + "\x1b(B\x1b[m" 343 | print(string) 344 | 345 | 346 | def print_bright_white_on_cyan(string, output_format): 347 | """Print string to stdout, optionally enabling color.""" 348 | if output_format == "colorized": 349 | string = "\x1b[7m\x1b[1m\x1b[36m" + string + "\x1b(B\x1b[m" 350 | print(string) 351 | 352 | 353 | def print_message(message, output_format): 354 | """Print a message with colorized output.""" 355 | assert output_format in ["colorized", "text", "raw"] 356 | 357 | if output_format == "raw": 358 | print(message) 359 | return 360 | 361 | for header, value in message.items(): 362 | print(f"{header}: {value}") 363 | print() 364 | for part in message.walk(): 365 | if part.get_content_maintype() == "multipart": 366 | pass 367 | elif part.get_content_maintype() == "text": 368 | if message.is_multipart(): 369 | # Only print message part dividers for multipart messages 370 | print_cyan( 371 | f">>> message part: {part.get_content_type()}", 372 | output_format, 373 | ) 374 | charset = str(part.get_charset()) 375 | print(part.get_payload(decode=True).decode(charset)) 376 | print() 377 | elif is_attachment(part): 378 | print_cyan( 379 | f">>> message part: attachment {part.get_filename()}", 380 | output_format, 381 | ) 382 | else: 383 | print_cyan( 384 | f">>> message part: {part.get_content_type()}", 385 | output_format, 386 | ) 387 | 388 | 389 | def is_attachment(part): 390 | """Return True if message part looks like an attachment.""" 391 | return ( 392 | part.get_content_maintype() != "multipart" and 393 | part.get_content_maintype() != "text" and 394 | part.get("Content-Disposition") != "inline" and 395 | part.get("Content-Disposition") is not None 396 | ) 397 | -------------------------------------------------------------------------------- /mailmerge/template_message.py: -------------------------------------------------------------------------------- 1 | """ 2 | Represent a templated email message. 3 | 4 | Andrew DeOrio 5 | """ 6 | 7 | import re 8 | from pathlib import Path 9 | from xml.etree import ElementTree 10 | import email 11 | import email.mime 12 | import email.mime.application 13 | import email.mime.multipart 14 | import email.mime.text 15 | import html5lib 16 | import markdown 17 | import jinja2 18 | from . import exceptions 19 | 20 | 21 | class TemplateMessage: 22 | """Represent a templated email message. 23 | 24 | This object combines an email.message object with the template abilities of 25 | Jinja2's Template object. 26 | """ 27 | 28 | # The external interface to this class is pretty simple. We don't need 29 | # more than one public method. 30 | # pylint: disable=too-few-public-methods 31 | 32 | def __init__(self, template_path): 33 | """Initialize variables and Jinja2 template.""" 34 | self.template_path = Path(template_path) 35 | self._message = None 36 | self._sender = None 37 | self._recipients = None 38 | self._attachment_content_ids = {} 39 | 40 | # Configure Jinja2 template engine with the template dirname as root. 41 | template_env = jinja2.Environment( 42 | loader=jinja2.FileSystemLoader(template_path.parent), 43 | undefined=jinja2.StrictUndefined, 44 | ) 45 | self.template = template_env.get_template( 46 | template_path.parts[-1], # basename 47 | ) 48 | 49 | def render(self, context): 50 | """Return rendered message object.""" 51 | try: 52 | raw_message = self.template.render(context) 53 | except jinja2.exceptions.TemplateError as err: 54 | raise exceptions.MailmergeError(f"{self.template_path}: {err}") 55 | self._message = email.message_from_string(raw_message) 56 | self._transform_encoding(raw_message) 57 | self._transform_recipients() 58 | self._transform_markdown() 59 | self._transform_attachments() 60 | self._transform_attachment_references() 61 | self._message.add_header('Date', email.utils.formatdate()) 62 | assert self._sender 63 | assert self._recipients 64 | assert self._message 65 | return self._sender, self._recipients, self._message 66 | 67 | def _transform_encoding(self, raw_message): 68 | """Detect and set character encoding.""" 69 | encoding = "us-ascii" if is_ascii(raw_message) else "utf-8" 70 | for part in self._message.walk(): 71 | if part.get_content_maintype() == 'multipart': 72 | continue 73 | part.set_charset(encoding) 74 | 75 | def _transform_recipients(self): 76 | """Extract sender and recipients from FROM, TO, CC and BCC fields.""" 77 | # The docs recommend using __delitem__() 78 | # https://docs.python.org/3/library/email.message.html#email.message.EmailMessage.__delitem__ 79 | # pylint: disable=unnecessary-dunder-call 80 | addrs = email.utils.getaddresses(self._message.get_all("TO", [])) + \ 81 | email.utils.getaddresses(self._message.get_all("CC", [])) + \ 82 | email.utils.getaddresses(self._message.get_all("BCC", [])) 83 | self._recipients = [x[1] for x in addrs] 84 | self._message.__delitem__("bcc") 85 | self._sender = self._message["from"] 86 | 87 | def _make_message_multipart(self): 88 | """ 89 | Convert self._message into a multipart message. 90 | 91 | Specifically, if the message's content-type is not multipart, this 92 | method will create a new `multipart/related` message, copy message 93 | headers and re-attach the original payload. 94 | """ 95 | # Do nothing if message already multipart 96 | if self._message.is_multipart(): 97 | return 98 | 99 | # Create empty multipart message 100 | multipart_message = email.mime.multipart.MIMEMultipart('related') 101 | 102 | # Copy headers. Avoid duplicate Content-Type and MIME-Version headers, 103 | # which we set explicitely. MIME-Version was set when we created an 104 | # empty mulitpart message. Content-Type will be set when we copy the 105 | # original text later. 106 | for header_key in set(self._message.keys()): 107 | if header_key.lower() in ["content-type", "mime-version"]: 108 | continue 109 | values = self._message.get_all(header_key, failobj=[]) 110 | for value in values: 111 | multipart_message[header_key] = value 112 | 113 | # Copy text, preserving original encoding 114 | original_text = self._message.get_payload(decode=True) 115 | original_subtype = self._message.get_content_subtype() 116 | original_encoding = str(self._message.get_charset()) 117 | multipart_message.attach(email.mime.text.MIMEText( 118 | original_text, 119 | _subtype=original_subtype, 120 | _charset=original_encoding, 121 | )) 122 | 123 | # Replace original message with multipart message 124 | self._message = multipart_message 125 | 126 | def _transform_markdown(self): 127 | """ 128 | Convert markdown in message text to HTML. 129 | 130 | Specifically, if the message's content-type is `text/markdown`, we 131 | transform `self._message` to have the following structure: 132 | 133 | multipart/related 134 | └── multipart/alternative 135 | ├── text/plain (original markdown plaintext) 136 | └── text/html (converted markdown) 137 | 138 | Attachments should be added as subsequent payload items of the 139 | top-level `multipart/related` message. 140 | """ 141 | # Do nothing if Content-Type is not text/markdown 142 | if not self._message['Content-Type'].startswith("text/markdown"): 143 | return 144 | 145 | # Remove the markdown Content-Type header, it's non-standard for email 146 | del self._message['Content-Type'] 147 | 148 | # Make sure the message is multipart. We need a multipart message so 149 | # that we can add an HTML part containing rendered Markdown. 150 | self._make_message_multipart() 151 | 152 | # Extract unrendered text and encoding. We assume that the first 153 | # plaintext payload is formatted with Markdown. 154 | for mimetext in self._message.get_payload(): 155 | if mimetext['Content-Type'].startswith('text/plain'): 156 | original_text_payload = mimetext 157 | encoding = str(mimetext.get_charset()) 158 | text = mimetext.get_payload(decode=True).decode(encoding) 159 | break 160 | assert original_text_payload 161 | assert encoding 162 | assert text 163 | # Remove the original text payload. 164 | self._message.set_payload( 165 | self._message.get_payload().remove(original_text_payload)) 166 | 167 | # Add a multipart/alternative part to the message. Email clients can 168 | # choose which payload-part they wish to render. 169 | # 170 | # Render Markdown to HTML and add the HTML as the last part of the 171 | # multipart/alternative message as per RFC 2046. 172 | # 173 | # https://docs.python.org/3/library/email.mime.html#email.mime.text.MIMEText 174 | html = markdown.markdown(text, extensions=['nl2br']) 175 | html_payload = email.mime.text.MIMEText( 176 | f"{html}", 177 | _subtype="html", 178 | _charset=encoding, 179 | ) 180 | 181 | message_payload = email.mime.multipart.MIMEMultipart('alternative') 182 | message_payload.attach(original_text_payload) 183 | message_payload.attach(html_payload) 184 | 185 | self._message.attach(message_payload) 186 | 187 | def _transform_attachments(self): 188 | """ 189 | Parse attachment headers and generate content-id headers for each. 190 | 191 | Attachments are added to the payload of a `multipart/related` message. 192 | For instance, a plaintext message with attachments would have the 193 | following structure: 194 | 195 | multipart/related 196 | ├── text/plain 197 | ├── attachment1 198 | └── attachment2 199 | 200 | Another example: If the original message contained `text/markdown`, 201 | then the message would have the following structure after transforming 202 | markdown and attachments: 203 | 204 | multipart/related 205 | ├── multipart/alternative 206 | │ ├── text/plain 207 | │ └── text/html 208 | ├── attachment1 209 | └── attachment2 210 | """ 211 | # Do nothing if message has no attachment header 212 | if 'attachment' not in self._message: 213 | return 214 | 215 | # Make sure the message is multipart. We need a multipart message in 216 | # order to add an attachment. 217 | self._make_message_multipart() 218 | 219 | # Add each attachment to the message 220 | for path in self._message.get_all('attachment', failobj=[]): 221 | path = self._resolve_attachment_path(path) 222 | with path.open("rb") as attachment: 223 | content = attachment.read() 224 | basename = path.parts[-1] 225 | part = email.mime.application.MIMEApplication( 226 | content, 227 | Name=str(basename), 228 | ) 229 | part.add_header( 230 | 'Content-Disposition', 231 | f'attachment; filename="{basename}"' 232 | ) 233 | 234 | # When processing inline images in the email body, we will 235 | # reference the Content-ID for an attachment with the same path 236 | # using 'cid:[content-id]'. 237 | cid, cid_header_value = make_attachment_content_id() 238 | self._attachment_content_ids[str(path)] = cid 239 | part.add_header('Content-Id', cid_header_value) 240 | 241 | self._message.attach(part) 242 | 243 | # Remove the attachment header, it's non-standard for email 244 | del self._message['attachment'] 245 | 246 | def _transform_attachment_references(self): 247 | """ 248 | Replace references to inline-images in the email body's HTML content. 249 | 250 | Specifically, match inline-image src attributes with content-ids from 251 | image attachments, if available. 252 | """ 253 | if not self._message.is_multipart(): 254 | return 255 | 256 | for part in self._message.walk(): 257 | if not part['Content-Type'].startswith('text/html'): 258 | continue 259 | 260 | html = part.get_payload(decode=True).decode('utf-8') 261 | document = html5lib.parse(html, namespaceHTMLElements=False) 262 | images = document.findall('.//img') 263 | if len(images) == 0: 264 | continue 265 | 266 | for img in document.findall('.//img'): 267 | src = img.get('src') 268 | try: 269 | src = str(self._resolve_attachment_path(src)) 270 | except exceptions.MailmergeError: 271 | # The src is not a valid filesystem path, so it could not 272 | # have been attached to the email. 273 | continue 274 | 275 | if src in self._attachment_content_ids: 276 | cid = self._attachment_content_ids[src] 277 | url = f"cid:{cid}" 278 | img.set('src', url) 279 | # Only clear the header if we are transforming an 280 | # attachment reference. See comment below for context. 281 | del part['Content-Transfer-Encoding'] 282 | 283 | # Unless the _charset argument is explicitly set to None, the 284 | # MIMEText object created will have both a Content-Type header with 285 | # a charset parameter, and a Content-Transfer-Encoding header. 286 | # This means that a subsequent set_payload call will not result in 287 | # an encoded payload, even if a charset is passed in the 288 | # set_payload() command. 289 | # We “reset” this behavior by deleting the 290 | # Content-Transfer-Encoding header, after which a set_payload() 291 | # call automatically encodes the new payload (and adds a new 292 | # Content-Transfer-Encoding header). 293 | # 294 | # We only need to update the message if we cleared the header, 295 | # which only happens if we transformed an attachment reference. 296 | if 'Content-Transfer-Encoding' not in part: 297 | new_html = ElementTree.tostring(document).decode('utf-8') 298 | part.set_payload(new_html) 299 | 300 | def _resolve_attachment_path(self, path): 301 | """Find attachment file or raise MailmergeError.""" 302 | # Error on empty path 303 | if not path.strip(): 304 | raise exceptions.MailmergeError("Empty attachment header.") 305 | 306 | # Create a Path object and handle home directory (tilde ~) notation 307 | path = Path(path.strip()) 308 | path = path.expanduser() 309 | 310 | # Relative paths are relative to the template's parent dir 311 | if not path.is_absolute(): 312 | path = self.template_path.parent/path 313 | 314 | # Resolve any symlinks 315 | path = path.resolve() 316 | 317 | # Check that the attachment exists 318 | if not path.exists(): 319 | raise exceptions.MailmergeError(f"Attachment not found: {path}") 320 | 321 | return path 322 | 323 | 324 | def is_ascii(string): 325 | """Return True is string contains only is us-ascii encoded characters.""" 326 | def is_ascii_char(char): 327 | return 0 <= ord(char) <= 127 328 | return all(is_ascii_char(char) for char in string) 329 | 330 | 331 | def make_attachment_content_id(): 332 | """ 333 | Return an RFC 2822 compliant Message-ID and corresponding header. 334 | 335 | For instance: `20020201195627.33539.96671@mailmerge.invalid` 336 | """ 337 | # Using domain '.invalid' to prevent leaking the hostname. The TLD is 338 | # reserved, see: https://en.wikipedia.org/wiki/.invalid 339 | cid_header = email.utils.make_msgid(domain="mailmerge.invalid") 340 | # The cid_header is of format ``. We need to extract the cid for 341 | # later lookup. 342 | cid = re.search('<(.*)>', cid_header).group(1) 343 | return cid, cid_header 344 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Mailmerge 2 | ========= 3 | 4 | [![CI main](https://github.com/awdeorio/mailmerge/workflows/CI/badge.svg?branch=develop)](https://github.com/awdeorio/mailmerge/actions?query=branch%3Adevelop) 5 | [![codecov](https://codecov.io/gh/awdeorio/mailmerge/branch/develop/graph/badge.svg)](https://codecov.io/gh/awdeorio/mailmerge) 6 | [![PyPI](https://img.shields.io/pypi/v/mailmerge.svg)](https://pypi.org/project/mailmerge/) 7 | 8 | A simple, command line mail merge tool. 9 | 10 | `mailmerge` uses plain text files and the [jinja2 template engine](http://jinja.pocoo.org/docs/latest/templates/). 11 | 12 | **Table of Contents** 13 | - [Quickstart](#quickstart) 14 | - [Install](#install) 15 | - [Example](#example) 16 | - [Advanced template example](#advanced-template-example) 17 | - [HTML formatting](#html-formatting) 18 | - [Markdown formatting](#markdown-formatting) 19 | - [Attachments](#attachments) 20 | - [Inline Image Attachments](#inline-image-attachments) 21 | - [Contributing](#contributing) 22 | - [Acknowledgements](#acknowledgements) 23 | 24 | ## Quickstart 25 | ```console 26 | $ pipx install mailmerge 27 | $ pipx ensurepath 28 | $ mailmerge 29 | ``` 30 | 31 | `mailmerge` will guide you through the process. Don't worry, it won't send real emails by default. 32 | 33 | ## Install 34 | Local install. 35 | ```console 36 | $ pipx install mailmerge 37 | $ pipx ensurepath 38 | ``` 39 | 40 | Fedora package install. 41 | ```console 42 | $ sudo dnf install python3-mailmerge 43 | ``` 44 | 45 | ## Example 46 | This example will walk you through the steps for creating a template email, database and STMP server configuration. Then, it will show how to test it before sending real emails. 47 | 48 | ### Create a sample template email, database, and config 49 | ```console 50 | $ mailmerge --sample 51 | Created sample template email "mailmerge_template.txt" 52 | Created sample database "mailmerge_database.csv" 53 | Created sample config file "mailmerge_server.conf" 54 | 55 | Edit these files, then run mailmerge again. 56 | ``` 57 | 58 | ### Edit the SMTP server config `mailmerge_server.conf` 59 | The defaults are set up for GMail. Be sure to change your username. If you use 2-factor authentication, create an [app password](https://support.google.com/accounts/answer/185833?hl=en) first. Other configuration examples are in the comments of `mailmerge_server.conf`. 60 | 61 | **Pro-tip:** SSH or VPN into your network first. Running mailmerge from the same network as the SMTP server can help you avoid spam filters and server throttling. This tip doesn't apply to Gmail. 62 | ``` 63 | [smtp_server] 64 | host = smtp.gmail.com 65 | port = 465 66 | security = SSL/TLS 67 | username = YOUR_USERNAME_HERE 68 | ``` 69 | 70 | ### Edit the template email message `mailmerge_template.txt` 71 | The `TO`, `SUBJECT`, and `FROM` fields are required. The remainder is the body of the message. Use `{{ }}` to indicate customized parameters that will be read from the database. For example, `{{email}}` will be filled in from the `email` column of `mailmerge_database.csv`. 72 | ``` 73 | TO: {{email}} 74 | SUBJECT: Testing mailmerge 75 | FROM: My Self 76 | 77 | Hi, {{name}}, 78 | 79 | Your number is {{number}}. 80 | ``` 81 | 82 | ### Edit the database `mailmerge_database.csv` 83 | Notice that the first line is a header that matches the parameters in the template example, for example, `{{email}}`. 84 | 85 | **Pro-tip**: Add yourself as the first recipient. This is helpful for testing. 86 | ``` 87 | email,name,number 88 | myself@mydomain.com,"Myself",17 89 | bob@bobdomain.com,"Bob",42 90 | ``` 91 | 92 | ### Dry run 93 | First, dry run one email message (`mailmerge` defaults). This will fill in the template fields of the first email message and print it to the terminal. 94 | ```console 95 | $ mailmerge 96 | >>> message 1 97 | TO: myself@mydomain.com 98 | SUBJECT: Testing mailmerge 99 | FROM: My Self 100 | MIME-Version: 1.0 101 | Content-Type: text/plain; charset="us-ascii" 102 | Content-Transfer-Encoding: 7bit 103 | Date: Thu, 19 Dec 2019 19:49:11 -0000 104 | 105 | Hi, Myself, 106 | 107 | Your number is 17. 108 | >>> sent message 1 109 | >>> Limit was 1 message. To remove the limit, use the --no-limit option. 110 | >>> This was a dry run. To send messages, use the --no-dry-run option. 111 | ``` 112 | 113 | If this looks correct, try a second dry run, this time with all recipients using the `--no-limit` option. 114 | ```console 115 | $ mailmerge --no-limit 116 | >>> message 1 117 | TO: myself@mydomain.com 118 | SUBJECT: Testing mailmerge 119 | FROM: My Self 120 | MIME-Version: 1.0 121 | Content-Type: text/plain; charset="us-ascii" 122 | Content-Transfer-Encoding: 7bit 123 | Date: Thu, 19 Dec 2019 19:49:33 -0000 124 | 125 | Hi, Myself, 126 | 127 | Your number is 17. 128 | >>> sent message 1 129 | >>> message 2 130 | TO: bob@bobdomain.com 131 | SUBJECT: Testing mailmerge 132 | FROM: My Self 133 | MIME-Version: 1.0 134 | Content-Type: text/plain; charset="us-ascii" 135 | Content-Transfer-Encoding: 7bit 136 | Date: Thu, 19 Dec 2019 19:49:33 -0000 137 | 138 | Hi, Bob, 139 | 140 | Your number is 42. 141 | >>> sent message 2 142 | >>> This was a dry run. To send messages, use the --no-dry-run option. 143 | ``` 144 | 145 | ### Send first email 146 | We're being extra careful in this example to avoid sending spam, so next we'll send *only one real email* (`mailmerge` default). Recall that you added yourself as the first email recipient. 147 | ```console 148 | $ mailmerge --no-dry-run 149 | >>> message 1 150 | TO: myself@mydomain.com 151 | SUBJECT: Testing mailmerge 152 | FROM: My Self 153 | MIME-Version: 1.0 154 | Content-Type: text/plain; charset="us-ascii" 155 | Content-Transfer-Encoding: 7bit 156 | Date: Thu, 19 Dec 2019 19:50:24 -0000 157 | 158 | Hi, Myself, 159 | 160 | Your number is 17. 161 | >>> sent message 1 162 | >>> Limit was 1 message. To remove the limit, use the --no-limit option. 163 | ``` 164 | 165 | You may have to type your email password when prompted. (If you use GMail with 2-factor authentication, don't forget to use the [app password](https://support.google.com/accounts/answer/185833?hl=en) you created while [setting up the SMTP server config](#edit-the-smtp-server-config-mailmerge_serverconf).) 166 | 167 | Now, check your email and make sure the message went through. If everything looks OK, then it's time to send all the messages. 168 | 169 | ### Send all emails 170 | ```console 171 | $ mailmerge --no-dry-run --no-limit 172 | >>> message 1 173 | TO: myself@mydomain.com 174 | SUBJECT: Testing mailmerge 175 | FROM: My Self 176 | MIME-Version: 1.0 177 | Content-Type: text/plain; charset="us-ascii" 178 | Content-Transfer-Encoding: 7bit 179 | Date: Thu, 19 Dec 2019 19:51:01 -0000 180 | 181 | Hi, Myself, 182 | 183 | Your number is 17. 184 | >>> sent message 1 185 | >>> message 2 186 | TO: bob@bobdomain.com 187 | SUBJECT: Testing mailmerge 188 | FROM: My Self 189 | MIME-Version: 1.0 190 | Content-Type: text/plain; charset="us-ascii" 191 | Content-Transfer-Encoding: 7bit 192 | Date: Thu, 19 Dec 2019 19:51:01 -0000 193 | 194 | Hi, Bob, 195 | 196 | Your number is 42. 197 | >>> sent message 2 198 | ``` 199 | 200 | ## Advanced template example 201 | This example will send progress reports to students. The template uses more of the advanced features of the [jinja2 template engine documentation](http://jinja.pocoo.org/docs/latest/templates/) to customize messages to students. 202 | 203 | #### Template `mailmerge_template.txt` 204 | ``` 205 | TO: {{email}} 206 | SUBJECT: EECS 280 Mid-semester Progress Report 207 | FROM: My Self 208 | REPLY-TO: My Reply Self 209 | 210 | Dear {{name}}, 211 | 212 | This email contains our record of your grades EECS 280, as well as an estimated letter grade. 213 | 214 | Project 1: {{p1}} 215 | Project 2: {{p2}} 216 | Project 3: {{p3}} 217 | Midterm exam: {{midterm}} 218 | 219 | At this time, your estimated letter grade is {{grade}}. 220 | 221 | {% if grade == "C-" -%} 222 | I am concerned that if the current trend in your scores continues, you will be on the border of the pass/fail line. 223 | 224 | I have a few suggestions for the rest of the semester. First, it is absolutely imperative that you turn in all assignments. Attend lecture and discussion sections. Get started early on the programming assignments and ask for help. Finally, plan a strategy to help you prepare well for the final exam. 225 | 226 | The good news is that we have completed about half of the course grade, so there is an opportunity to fix this problem. The other professors and I are happy to discuss strategies together during office hours. 227 | {% elif grade in ["D+", "D", "D-", "E", "F"] -%} 228 | I am writing because I am concerned about your grade in EECS 280. My concern is that if the current trend in your scores continues, you will not pass the course. 229 | 230 | If you plan to continue in the course, I urge you to see your instructor in office hours to discuss a plan for the remainder of the semester. Otherwise, if you plan to drop the course, please see your academic advisor. 231 | {% endif -%} 232 | ``` 233 | 234 | #### Database `mailmerge_database.csv` 235 | Again, we'll use the best practice of making yourself the first recipient, which is helpful for testing. 236 | ``` 237 | email,name,p1,p2,p3,midterm,grade 238 | myself@mydomain.com,"My Self",100,100,100,100,A+ 239 | borderline@fixme.com,"Borderline Name",50,50,50,50,C- 240 | failing@fixme.com,"Failing Name",0,0,0,0,F 241 | ``` 242 | 243 | ## HTML formatting 244 | Mailmerge supports HTML formatting. 245 | 246 | ### HTML only 247 | This example will use HTML to format an email. Add `Content-Type: text/html` just under the email headers, then begin your message with ``. 248 | 249 | #### Template `mailmerge_template.txt` 250 | ``` 251 | TO: {{email}} 252 | SUBJECT: Testing mailmerge 253 | FROM: My Self 254 | Content-Type: text/html 255 | 256 | 257 | 258 | 259 |

Hi, {{name}},

260 | 261 |

Your number is {{number}}.

262 | 263 |

Sent by mailmerge

264 | 265 | 266 | 267 | ``` 268 | 269 | 270 | ### HTML and plain text 271 | This example shows how to provide both HTML and plain text versions in the same message. A user's mail reader can select either one. 272 | 273 | #### Template `mailmerge_template.txt` 274 | ``` 275 | TO: {{email}} 276 | SUBJECT: Testing mailmerge 277 | FROM: My Self 278 | MIME-Version: 1.0 279 | Content-Type: multipart/alternative; boundary="boundary" 280 | 281 | This is a MIME-encoded message. If you are seeing this, your mail 282 | reader is old. 283 | 284 | --boundary 285 | Content-Type: text/plain; charset=us-ascii 286 | 287 | Hi, {{name}}, 288 | 289 | Your number is {{number}}. 290 | 291 | Sent by mailmerge https://github.com/awdeorio/mailmerge 292 | 293 | --boundary 294 | Content-Type: text/html; charset=us-ascii 295 | 296 | 297 | 298 | 299 |

Hi, {{name}},

300 | 301 |

Your number is {{number}}.

302 | 303 |

Sent by mailmerge

304 | 305 | 306 | 307 | ``` 308 | 309 | 310 | ## Markdown formatting 311 | Mailmerge supports [Markdown](https://daringfireball.net/projects/markdown/syntax) formatting by including the custom custom header `Content-Type: text/markdown` in the message. Mailmerge will render the markdown to HTML, then include both HTML and plain text versions in a multiplart message. A recipient's mail reader can then select either format. 312 | 313 | ### Template `mailmerge_template.txt` 314 | ``` 315 | TO: {{email}} 316 | SUBJECT: Testing mailmerge 317 | FROM: My Self 318 | CONTENT-TYPE: text/markdown 319 | 320 | You can add: 321 | 322 | - Emphasis, aka italics, with *asterisks*. 323 | - Strong emphasis, aka bold, with **asterisks**. 324 | - Combined emphasis with **asterisks and _underscores_**. 325 | - Unordered lists like this one. 326 | - Ordered lists with numbers: 327 | 1. Item 1 328 | 2. Item 2 329 | - Preformatted text with `backticks`. 330 | - How about some [hyperlinks](http://bit.ly/eecs485-wn19-p6)? 331 | 332 | # This is a heading. 333 | ## And another heading. 334 | 335 | Here's an image not attached with the email: 336 | ![python logo not attached](http://pluspng.com/img-png/python-logo-png-open-2000.png) 337 | ``` 338 | 339 | ## Attachments 340 | This example shows how to add attachments with a special `ATTACHMENT` header. 341 | 342 | #### Template `mailmerge_template.txt` 343 | ``` 344 | TO: {{email}} 345 | SUBJECT: Testing mailmerge 346 | FROM: My Self 347 | ATTACHMENT: file1.docx 348 | ATTACHMENT: ../file2.pdf 349 | ATTACHMENT: /z/shared/{{name}}_submission.txt 350 | 351 | Hi, {{name}}, 352 | 353 | This email contains three attachments. 354 | Pro-tip: Use Jinja to customize the attachments based on your database! 355 | ``` 356 | 357 | Dry run to verify attachment files exist. If an attachment filename includes a template, it's a good idea to dry run with the `--no-limit` flag. 358 | ```console 359 | $ mailmerge 360 | >>> message 1 361 | TO: myself@mydomain.com 362 | SUBJECT: Testing mailmerge 363 | FROM: My Self 364 | 365 | Hi, Myself, 366 | 367 | This email contains three attachments. 368 | Pro-tip: Use Jinja to customize the attachments based on your database! 369 | 370 | >>> attached /Users/awdeorio/Documents/test/file1.docx 371 | >>> attached /Users/awdeorio/Documents/file2.pdf 372 | >>> attached /z/shared/Myself_submission.txt 373 | >>> sent message 1 374 | >>> This was a dry run. To send messages, use the --no-dry-run option. 375 | ``` 376 | 377 | ## Inline Image Attachments 378 | 379 | This example shows how to add inline-image-attachments so that the images are rendered directly in the email body. You **must** add the inline-image as an attachment before referencing it in the body. 380 | 381 | #### HTML Example: Template `mailmerge_template.txt` 382 | 383 | ``` 384 | TO: {{email}} 385 | SUBJECT: Testing mailmerge 386 | FROM: My Self 387 | Content-Type: text/html 388 | ATTACHMENT: image.jpg 389 | ATTACHMENT: second/image.jpg 390 | 391 | 392 | 393 | 394 |

Hi, {{name}},

395 | 396 | Sample image 397 | 398 | The second image: second 399 | 400 |

Sent by mailmerge

401 | 402 | 403 | 404 | ``` 405 | 406 | #### Markdown Example: Template `mailmerge_template.txt` 407 | ``` 408 | TO: {{email}} 409 | SUBJECT: Testing mailmerge 410 | FROM: My Self 411 | ATTACHMENT: image.jpg 412 | CONTENT-TYPE: text/markdown 413 | 414 | Hi, {{name}}, 415 | 416 | ![image alt-description](image.jpg) 417 | ``` 418 | 419 | ## Contributing 420 | Contributions from the community are welcome! Check out the [guide for contributing](CONTRIBUTING.md). 421 | 422 | 423 | ## Acknowledgements 424 | Mailmerge is written by Andrew DeOrio , [http://andrewdeorio.com](http://andrewdeorio.com). Sesh Sadasivam (@seshrs) contributed many features and bug fixes. 425 | -------------------------------------------------------------------------------- /tests/test_template_message_encodings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for TemplateMessage with different encodings. 3 | 4 | Andrew DeOrio 5 | """ 6 | import re 7 | import textwrap 8 | from mailmerge import TemplateMessage 9 | 10 | 11 | def test_utf8_template(tmp_path): 12 | """Verify UTF8 support in email template.""" 13 | template_path = tmp_path / "template.txt" 14 | template_path.write_text(textwrap.dedent("""\ 15 | TO: to@test.com 16 | SUBJECT: Testing mailmerge 17 | FROM: from@test.com 18 | 19 | From the Tagelied of Wolfram von Eschenbach (Middle High German): 20 | 21 | Sîne klâwen durh die wolken sint geslagen, 22 | er stîget ûf mit grôzer kraft, 23 | ich sih in grâwen tägelîch als er wil tagen, 24 | den tac, der im geselleschaft 25 | erwenden wil, dem werden man, 26 | den ich mit sorgen în verliez. 27 | ich bringe in hinnen, ob ich kan. 28 | sîn vil manegiu tugent michz leisten hiez. 29 | 30 | http://www.columbia.edu/~fdc/utf8/ 31 | """)) 32 | template_message = TemplateMessage(template_path) 33 | sender, recipients, message = template_message.render({ 34 | "email": "myself@mydomain.com", 35 | }) 36 | 37 | # Verify encoding 38 | assert message.get_content_maintype() == "text" 39 | assert message.get_content_subtype() == "plain" 40 | assert message.get_content_charset() == "utf-8" 41 | 42 | # Verify sender and recipients 43 | assert sender == "from@test.com" 44 | assert recipients == ["to@test.com"] 45 | 46 | # Verify content 47 | plaintext = message.get_payload(decode=True).decode("utf-8") 48 | assert plaintext == textwrap.dedent("""\ 49 | From the Tagelied of Wolfram von Eschenbach (Middle High German): 50 | 51 | Sîne klâwen durh die wolken sint geslagen, 52 | er stîget ûf mit grôzer kraft, 53 | ich sih in grâwen tägelîch als er wil tagen, 54 | den tac, der im geselleschaft 55 | erwenden wil, dem werden man, 56 | den ich mit sorgen în verliez. 57 | ich bringe in hinnen, ob ich kan. 58 | sîn vil manegiu tugent michz leisten hiez. 59 | 60 | http://www.columbia.edu/~fdc/utf8/""") 61 | 62 | 63 | def test_utf8_database(tmp_path): 64 | """Verify UTF8 support when template is rendered with UTF-8 value.""" 65 | # Simple template 66 | template_path = tmp_path / "template.txt" 67 | template_path.write_text(textwrap.dedent("""\ 68 | TO: to@test.com 69 | FROM: from@test.com 70 | 71 | Hi {{name}} 72 | """)) 73 | 74 | # Render template with context containing unicode characters 75 | template_message = TemplateMessage(template_path) 76 | sender, recipients, message = template_message.render({ 77 | "name": "Laȝamon", 78 | }) 79 | 80 | # Verify sender and recipients 81 | assert sender == "from@test.com" 82 | assert recipients == ["to@test.com"] 83 | 84 | # Verify message encoding. The template was ASCII, but when the template 85 | # is rendered with UTF-8 data, the result is UTF-8 encoding. 86 | assert message.get_content_maintype() == "text" 87 | assert message.get_content_subtype() == "plain" 88 | assert message.get_content_charset() == "utf-8" 89 | 90 | # Verify content 91 | plaintext = message.get_payload(decode=True).decode("utf-8") 92 | assert plaintext == "Hi Laȝamon" 93 | 94 | 95 | def test_utf8_to(tmp_path): 96 | """Verify UTF8 support in TO field.""" 97 | template_path = tmp_path / "template.txt" 98 | template_path.write_text(textwrap.dedent("""\ 99 | TO: Laȝamon 100 | FROM: from@test.com 101 | 102 | {{message}} 103 | """)) 104 | template_message = TemplateMessage(template_path) 105 | _, recipients, message = template_message.render({ 106 | "message": "hello", 107 | }) 108 | 109 | # Verify recipient name and email 110 | assert recipients == ["to@test.com"] 111 | assert message["to"] == "Laȝamon " 112 | 113 | 114 | def test_utf8_from(tmp_path): 115 | """Verify UTF8 support in FROM field.""" 116 | template_path = tmp_path / "template.txt" 117 | template_path.write_text(textwrap.dedent("""\ 118 | TO: to@test.com 119 | FROM: Laȝamon 120 | 121 | {{message}} 122 | """)) 123 | template_message = TemplateMessage(template_path) 124 | sender, _, message = template_message.render({ 125 | "message": "hello", 126 | }) 127 | 128 | # Verify sender name and email 129 | assert sender == "Laȝamon " 130 | assert message["from"] == "Laȝamon " 131 | 132 | 133 | def test_utf8_subject(tmp_path): 134 | """Verify UTF8 support in SUBJECT field.""" 135 | template_path = tmp_path / "template.txt" 136 | template_path.write_text(textwrap.dedent("""\ 137 | TO: to@test.com 138 | FROM: from@test.com 139 | SUBJECT: Laȝamon 140 | 141 | {{message}} 142 | """)) 143 | template_message = TemplateMessage(template_path) 144 | _, _, message = template_message.render({ 145 | "message": "hello", 146 | }) 147 | 148 | # Verify subject 149 | assert message["subject"] == "Laȝamon" 150 | 151 | 152 | def test_emoji(tmp_path): 153 | """Verify emoji are encoded.""" 154 | template_path = tmp_path / "template.txt" 155 | template_path.write_text(textwrap.dedent("""\ 156 | TO: test@test.com 157 | SUBJECT: Testing mailmerge 158 | FROM: test@test.com 159 | 160 | Hi 😀 161 | """)) # grinning face emoji 162 | template_message = TemplateMessage(template_path) 163 | _, _, message = template_message.render({}) 164 | 165 | # Verify encoding 166 | assert message.get_charset() == "utf-8" 167 | assert message["Content-Transfer-Encoding"] == "base64" 168 | 169 | # Verify content 170 | plaintext = message.get_payload(decode=True).decode("utf-8") 171 | assert plaintext == "Hi 😀" 172 | 173 | 174 | def test_emoji_markdown(tmp_path): 175 | """Verify emoji are encoded in Markdown formatted messages.""" 176 | template_path = tmp_path / "template.txt" 177 | template_path.write_text(textwrap.dedent("""\ 178 | TO: test@example.com 179 | SUBJECT: Testing mailmerge 180 | FROM: test@example.com 181 | CONTENT-TYPE: text/markdown 182 | 183 | ``` 184 | emoji_string = 😀 185 | ``` 186 | """)) # grinning face emoji 187 | template_message = TemplateMessage(template_path) 188 | _, _, message = template_message.render({}) 189 | 190 | # Message should contain an unrendered Markdown plaintext part and a 191 | # rendered Markdown HTML part 192 | message_payload = message.get_payload()[0] 193 | plaintext_part, html_part = message_payload.get_payload() 194 | 195 | # Verify encodings 196 | assert str(plaintext_part.get_charset()) == "utf-8" 197 | assert str(html_part.get_charset()) == "utf-8" 198 | assert plaintext_part["Content-Transfer-Encoding"] == "base64" 199 | assert html_part["Content-Transfer-Encoding"] == "base64" 200 | 201 | # Verify content, which is base64 encoded grinning face emoji 202 | plaintext = plaintext_part.get_payload(decode=True).decode("utf-8") 203 | htmltext = html_part.get_payload(decode=True).decode("utf-8") 204 | assert plaintext == '```\nemoji_string = \U0001f600\n```' 205 | assert htmltext == ( 206 | "

" 207 | "emoji_string = \U0001f600" 208 | "

" 209 | ) 210 | 211 | 212 | def test_emoji_database(tmp_path): 213 | """Verify emoji are encoded when they are substituted via template db. 214 | 215 | The template is ASCII encoded, but after rendering the template, an emoji 216 | character will substituted into the template. The result should be a utf-8 217 | encoded message. 218 | """ 219 | template_path = tmp_path / "template.txt" 220 | template_path.write_text(textwrap.dedent("""\ 221 | TO: test@test.com 222 | SUBJECT: Testing mailmerge 223 | FROM: test@test.com 224 | 225 | Hi {{emoji}} 226 | """)) 227 | template_message = TemplateMessage(template_path) 228 | _, _, message = template_message.render({ 229 | "emoji": "😀" # grinning face 230 | }) 231 | 232 | # Verify encoding 233 | assert message.get_charset() == "utf-8" 234 | assert message["Content-Transfer-Encoding"] == "base64" 235 | 236 | # Verify content 237 | plaintext = message.get_payload(decode=True).decode("utf-8") 238 | assert plaintext == "Hi 😀" 239 | 240 | 241 | def test_encoding_us_ascii(tmp_path): 242 | """Render a simple template with us-ascii encoding.""" 243 | template_path = tmp_path / "template.txt" 244 | template_path.write_text(textwrap.dedent("""\ 245 | TO: to@test.com 246 | FROM: from@test.com 247 | 248 | Hello world 249 | """)) 250 | template_message = TemplateMessage(template_path) 251 | _, _, message = template_message.render({}) 252 | assert message.get_charset() == "us-ascii" 253 | assert message.get_content_charset() == "us-ascii" 254 | assert message.get_payload() == "Hello world" 255 | 256 | 257 | def test_encoding_utf8(tmp_path): 258 | """Render a simple template with UTF-8 encoding.""" 259 | template_path = tmp_path / "template.txt" 260 | template_path.write_text(textwrap.dedent("""\ 261 | TO: to@test.com 262 | FROM: from@test.com 263 | 264 | Hello Laȝamon 265 | """)) 266 | template_message = TemplateMessage(template_path) 267 | _, _, message = template_message.render({}) 268 | assert message.get_charset() == "utf-8" 269 | assert message.get_content_charset() == "utf-8" 270 | plaintext = message.get_payload(decode=True).decode("utf-8") 271 | assert plaintext == "Hello Laȝamon" 272 | 273 | 274 | def test_encoding_is8859_1(tmp_path): 275 | """Render a simple template with IS8859-1 encoding. 276 | 277 | Mailmerge will coerce the encoding to UTF-8. 278 | """ 279 | template_path = tmp_path / "template.txt" 280 | template_path.write_text(textwrap.dedent("""\ 281 | TO: to@test.com 282 | FROM: from@test.com 283 | 284 | Hello L'Haÿ-les-Roses 285 | """)) 286 | template_message = TemplateMessage(template_path) 287 | _, _, message = template_message.render({}) 288 | assert message.get_charset() == "utf-8" 289 | assert message.get_content_charset() == "utf-8" 290 | plaintext = message.get_payload(decode=True).decode("utf-8") 291 | assert plaintext == "Hello L'Haÿ-les-Roses" 292 | 293 | 294 | def test_encoding_mismatch(tmp_path): 295 | """Render a simple template that lies about its encoding. 296 | 297 | Header says us-ascii, but it contains utf-8. 298 | """ 299 | template_path = tmp_path / "template.txt" 300 | template_path.write_text(textwrap.dedent("""\ 301 | TO: to@test.com 302 | FROM: from@test.com 303 | Content-Type: text/plain; charset="us-ascii" 304 | 305 | Hello Laȝamon 306 | """)) 307 | template_message = TemplateMessage(template_path) 308 | _, _, message = template_message.render({}) 309 | assert message.get_charset() == "utf-8" 310 | assert message.get_content_charset() == "utf-8" 311 | plaintext = message.get_payload(decode=True).decode("utf-8") 312 | assert plaintext == "Hello Laȝamon" 313 | 314 | 315 | def test_encoding_multipart(tmp_path): 316 | """Render a utf-8 template with multipart encoding.""" 317 | template_path = tmp_path / "template.txt" 318 | template_path.write_text(textwrap.dedent("""\ 319 | TO: to@test.com 320 | FROM: from@test.com 321 | MIME-Version: 1.0 322 | Content-Type: multipart/alternative; boundary="boundary" 323 | 324 | This is a MIME-encoded message. If you are seeing this, your mail 325 | reader is old. 326 | 327 | --boundary 328 | Content-Type: text/plain; charset=utf-8 329 | 330 | Hello Laȝamon 331 | 332 | --boundary 333 | Content-Type: text/html; charset=utf-8 334 | 335 | 336 | 337 |

Hello Laȝamon

338 | 339 | 340 | """)) 341 | template_message = TemplateMessage(template_path) 342 | sender, recipients, message = template_message.render({}) 343 | 344 | # Verify sender and recipients 345 | assert sender == "from@test.com" 346 | assert recipients == ["to@test.com"] 347 | 348 | # Should be multipart: plaintext and HTML 349 | assert message.is_multipart() 350 | parts = message.get_payload() 351 | assert len(parts) == 2 352 | plaintext_part, html_part = parts 353 | 354 | # Verify plaintext part 355 | assert plaintext_part.get_charset() == "utf-8" 356 | assert plaintext_part.get_content_charset() == "utf-8" 357 | assert plaintext_part.get_content_type() == "text/plain" 358 | plaintext = plaintext_part.get_payload(decode=True).decode("utf-8") 359 | plaintext = plaintext.strip() 360 | assert plaintext == "Hello Laȝamon" 361 | 362 | # Verify html part 363 | assert html_part.get_charset() == "utf-8" 364 | assert html_part.get_content_charset() == "utf-8" 365 | assert html_part.get_content_type() == "text/html" 366 | htmltext = html_part.get_payload(decode=True).decode("utf-8") 367 | htmltext = re.sub(r"\s+", "", htmltext) # Strip whitespace 368 | assert htmltext == "

HelloLaȝamon

" 369 | 370 | 371 | def test_encoding_multipart_mismatch(tmp_path): 372 | """Render a utf-8 template with multipart encoding and wrong headers. 373 | 374 | Content-Type headers say "us-ascii", but the message contains utf-8. 375 | """ 376 | template_path = tmp_path / "template.txt" 377 | template_path.write_text(textwrap.dedent("""\ 378 | TO: to@test.com 379 | FROM: from@test.com 380 | MIME-Version: 1.0 381 | Content-Type: multipart/alternative; boundary="boundary" 382 | 383 | This is a MIME-encoded message. If you are seeing this, your mail 384 | reader is old. 385 | 386 | --boundary 387 | Content-Type: text/plain; charset=us-ascii 388 | 389 | Hello Laȝamon 390 | 391 | --boundary 392 | Content-Type: text/html; charset=us-ascii 393 | 394 | 395 | 396 |

Hello Laȝamon

397 | 398 | 399 | """)) 400 | template_message = TemplateMessage(template_path) 401 | sender, recipients, message = template_message.render({}) 402 | 403 | # Verify sender and recipients 404 | assert sender == "from@test.com" 405 | assert recipients == ["to@test.com"] 406 | 407 | # Should be multipart: plaintext and HTML 408 | assert message.is_multipart() 409 | parts = message.get_payload() 410 | assert len(parts) == 2 411 | plaintext_part, html_part = parts 412 | 413 | # Verify plaintext part 414 | assert plaintext_part.get_charset() == "utf-8" 415 | assert plaintext_part.get_content_charset() == "utf-8" 416 | assert plaintext_part.get_content_type() == "text/plain" 417 | plaintext = plaintext_part.get_payload(decode=True).decode("utf-8") 418 | plaintext = plaintext.strip() 419 | assert plaintext == "Hello Laȝamon" 420 | 421 | # Verify html part 422 | assert html_part.get_charset() == "utf-8" 423 | assert html_part.get_content_charset() == "utf-8" 424 | assert html_part.get_content_type() == "text/html" 425 | htmltext = html_part.get_payload(decode=True).decode("utf-8") 426 | htmltext = re.sub(r"\s+", "", htmltext) # Strip whitespace 427 | assert htmltext == "

HelloLaȝamon

" 428 | -------------------------------------------------------------------------------- /tests/test_sendmail_client.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for SendmailClient. 3 | 4 | Andrew DeOrio 5 | """ 6 | import textwrap 7 | import socket 8 | import smtplib 9 | import email 10 | import email.parser 11 | import base64 12 | import ssl 13 | import pytest 14 | from mailmerge import SendmailClient, MailmergeError 15 | 16 | 17 | def test_smtp(mocker, tmp_path): 18 | """Verify SMTP library calls.""" 19 | config_path = tmp_path/"server.conf" 20 | config_path.write_text(textwrap.dedent("""\ 21 | [smtp_server] 22 | host = open-smtp.example.com 23 | port = 25 24 | """)) 25 | sendmail_client = SendmailClient( 26 | config_path, 27 | dry_run=False, 28 | ) 29 | message = email.message_from_string(""" 30 | TO: to@test.com 31 | SUBJECT: Testing mailmerge 32 | FROM: from@test.com 33 | 34 | Hello world 35 | """) 36 | 37 | # Execute sendmail with mock SMTP 38 | mock_smtp = mocker.patch('smtplib.SMTP') 39 | sendmail_client.sendmail( 40 | sender="from@test.com", 41 | recipients=["to@test.com"], 42 | message=message, 43 | ) 44 | 45 | # Mock smtp object with function calls recorded 46 | smtp = mock_smtp.return_value.__enter__.return_value 47 | assert smtp.sendmail.call_count == 1 48 | 49 | 50 | def test_dry_run(mocker, tmp_path): 51 | """Verify no sendmail() calls when dry_run=True.""" 52 | config_path = tmp_path/"server.conf" 53 | config_path.write_text(textwrap.dedent("""\ 54 | [smtp_server] 55 | host = open-smtp.example.com 56 | port = 25 57 | security = Never 58 | """)) 59 | sendmail_client = SendmailClient( 60 | config_path, 61 | dry_run=True, 62 | ) 63 | message = email.message_from_string(""" 64 | TO: test@test.com 65 | SUBJECT: Testing mailmerge 66 | FROM: test@test.com 67 | 68 | Hello world 69 | """) 70 | 71 | # Execute sendmail with mock SMTP and getpass 72 | mock_smtp = mocker.patch('smtplib.SMTP') 73 | mock_getpass = mocker.patch('getpass.getpass') 74 | sendmail_client.sendmail( 75 | sender="from@test.com", 76 | recipients=["to@test.com"], 77 | message=message, 78 | ) 79 | 80 | # Verify SMTP wasn't called and password wasn't used 81 | assert mock_getpass.call_count == 0 82 | smtp = mock_smtp.return_value.__enter__.return_value 83 | assert smtp.sendmail.call_count == 0 84 | 85 | 86 | def test_no_dry_run(mocker, tmp_path): 87 | """Verify --no-dry-run calls SMTP sendmail().""" 88 | config_path = tmp_path/"server.conf" 89 | config_path.write_text(textwrap.dedent("""\ 90 | [smtp_server] 91 | host = open-smtp.example.com 92 | port = 465 93 | security = SSL/TLS 94 | username = admin 95 | """)) 96 | sendmail_client = SendmailClient(config_path, dry_run=False) 97 | message = email.message_from_string(""" 98 | TO: test@test.com 99 | SUBJECT: Testing mailmerge 100 | FROM: test@test.com 101 | 102 | Hello world 103 | """) 104 | 105 | # Mock the password entry and SMTP 106 | mock_smtp_ssl = mocker.patch('smtplib.SMTP_SSL') 107 | mock_getpass = mocker.patch('getpass.getpass') 108 | mock_getpass.return_value = "password" 109 | 110 | # Execute sendmail 111 | sendmail_client.sendmail( 112 | sender="from@test.com", 113 | recipients=["to@test.com"], 114 | message=message, 115 | ) 116 | 117 | # Verify function calls for password and sendmail() 118 | assert mock_getpass.call_count == 1 119 | smtp = mock_smtp_ssl.return_value.__enter__.return_value 120 | assert smtp.sendmail.call_count == 1 121 | 122 | 123 | def test_bad_config_key(tmp_path): 124 | """Verify config file with bad key throws an exception.""" 125 | config_path = tmp_path/"server.conf" 126 | config_path.write_text(textwrap.dedent("""\ 127 | [smtp_server] 128 | badkey = open-smtp.example.com 129 | """)) 130 | with pytest.raises(MailmergeError): 131 | SendmailClient(config_path, dry_run=True) 132 | 133 | 134 | def test_security_error(tmp_path): 135 | """Verify config file with bad security type throws an exception.""" 136 | config_path = tmp_path/"server.conf" 137 | config_path.write_text(textwrap.dedent("""\ 138 | [smtp_server] 139 | host = smtp.mail.umich.edu 140 | port = 465 141 | security = bad_value 142 | username = YOUR_USERNAME_HERE 143 | """)) 144 | with pytest.raises(MailmergeError): 145 | SendmailClient(config_path, dry_run=False) 146 | 147 | 148 | def test_security_open(mocker, tmp_path): 149 | """Verify open (Never) security configuration.""" 150 | # Config for no security SMTP server 151 | config_path = tmp_path/"server.conf" 152 | config_path.write_text(textwrap.dedent("""\ 153 | [smtp_server] 154 | host = open-smtp.example.com 155 | port = 25 156 | """)) 157 | 158 | # Simple template 159 | sendmail_client = SendmailClient(config_path, dry_run=False) 160 | message = email.message_from_string("Hello world") 161 | 162 | # Mock SMTP and getpass 163 | mock_smtp = mocker.patch('smtplib.SMTP') 164 | mock_smtp_ssl = mocker.patch('smtplib.SMTP_SSL') 165 | mock_getpass = mocker.patch('getpass.getpass') 166 | 167 | # Send a message 168 | sendmail_client.sendmail( 169 | sender="test@test.com", 170 | recipients=["test@test.com"], 171 | message=message, 172 | ) 173 | 174 | # Verify SMTP library calls 175 | assert mock_getpass.call_count == 0 176 | assert mock_smtp.call_count == 1 177 | assert mock_smtp_ssl.call_count == 0 178 | smtp = mock_smtp.return_value.__enter__.return_value 179 | assert smtp.sendmail.call_count == 1 180 | assert smtp.login.call_count == 0 181 | 182 | 183 | def test_security_open_legacy(mocker, tmp_path): 184 | """Verify legacy "security = Never" configuration.""" 185 | # Config SMTP server with "security = Never" legacy option 186 | config_path = tmp_path/"server.conf" 187 | config_path.write_text(textwrap.dedent("""\ 188 | [smtp_server] 189 | host = open-smtp.example.com 190 | port = 25 191 | security = Never 192 | """)) 193 | 194 | # Simple template 195 | sendmail_client = SendmailClient(config_path, dry_run=False) 196 | message = email.message_from_string("Hello world") 197 | 198 | # Mock SMTP 199 | mock_smtp = mocker.patch('smtplib.SMTP') 200 | 201 | # Send a message 202 | sendmail_client.sendmail( 203 | sender="test@test.com", 204 | recipients=["test@test.com"], 205 | message=message, 206 | ) 207 | 208 | # Verify SMTP library calls 209 | smtp = mock_smtp.return_value.__enter__.return_value 210 | assert smtp.sendmail.call_count == 1 211 | 212 | 213 | def test_security_starttls(mocker, tmp_path): 214 | """Verify open (Never) security configuration.""" 215 | # Config for STARTTLS SMTP server 216 | config_path = tmp_path/"server.conf" 217 | config_path.write_text(textwrap.dedent("""\ 218 | [smtp_server] 219 | host = newman.eecs.umich.edu 220 | port = 25 221 | security = STARTTLS 222 | username = YOUR_USERNAME_HERE 223 | """)) 224 | 225 | # Simple template 226 | sendmail_client = SendmailClient(config_path, dry_run=False) 227 | message = email.message_from_string("Hello world") 228 | 229 | # Mock SMTP 230 | mock_smtp = mocker.patch('smtplib.SMTP') 231 | mock_smtp_ssl = mocker.patch('smtplib.SMTP_SSL') 232 | 233 | # Mock the password entry 234 | mock_getpass = mocker.patch('getpass.getpass') 235 | mock_getpass.return_value = "password" 236 | 237 | # Send a message 238 | sendmail_client.sendmail( 239 | sender="test@test.com", 240 | recipients=["test@test.com"], 241 | message=message, 242 | ) 243 | 244 | # Verify SMTP library calls 245 | assert mock_getpass.call_count == 1 246 | assert mock_smtp.call_count == 1 247 | assert mock_smtp_ssl.call_count == 0 248 | smtp = mock_smtp.return_value.__enter__.return_value 249 | assert smtp.ehlo.call_count == 2 250 | assert smtp.starttls.call_count == 1 251 | assert smtp.login.call_count == 1 252 | assert smtp.sendmail.call_count == 1 253 | 254 | 255 | def test_security_xoauth(mocker, tmp_path): 256 | """Verify XOAUTH security configuration.""" 257 | # Config for XOAUTH SMTP server 258 | config_path = tmp_path/"server.conf" 259 | config_path.write_text(textwrap.dedent("""\ 260 | [smtp_server] 261 | host = smtp.office365.com 262 | port = 587 263 | security = XOAUTH 264 | username = username@example.com 265 | """)) 266 | 267 | # Simple template 268 | sendmail_client = SendmailClient(config_path, dry_run=False) 269 | message = email.message_from_string("Hello world") 270 | 271 | # Mock SMTP 272 | mock_smtp = mocker.patch('smtplib.SMTP') 273 | mock_smtp_ssl = mocker.patch('smtplib.SMTP_SSL') 274 | 275 | # Mock the password entry 276 | mock_getpass = mocker.patch('getpass.getpass') 277 | mock_getpass.return_value = "password" 278 | 279 | # Send a message 280 | sendmail_client.sendmail( 281 | sender="test@test.com", 282 | recipients=["test@test.com"], 283 | message=message, 284 | ) 285 | 286 | # Verify SMTP library calls 287 | assert mock_getpass.call_count == 1 288 | assert mock_smtp.call_count == 1 289 | assert mock_smtp_ssl.call_count == 0 290 | smtp = mock_smtp.return_value.__enter__.return_value 291 | assert smtp.ehlo.call_count == 2 292 | assert smtp.starttls.call_count == 1 293 | assert smtp.login.call_count == 0 294 | assert smtp.docmd.call_count == 2 295 | assert smtp.sendmail.call_count == 1 296 | 297 | # Verify authentication token format. The first call to docmd() is always 298 | # the same. Second call to docmd() contains a base64 encoded username and 299 | # password. 300 | assert smtp.docmd.call_args_list[0].args[0] == "AUTH XOAUTH2" 301 | user_pass = smtp.docmd.call_args_list[1].args[0] 302 | user_pass = base64.b64decode(user_pass) 303 | assert user_pass == \ 304 | b'user=username@example.com\x01auth=Bearer password\x01\x01' 305 | 306 | 307 | def test_security_xoauth_bad_username(mocker, tmp_path): 308 | """Verify exception is thrown for UTF-8 username.""" 309 | # Config for XOAUTH SMTP server 310 | config_path = tmp_path/"server.conf" 311 | config_path.write_text(textwrap.dedent("""\ 312 | [smtp_server] 313 | host = smtp.office365.com 314 | port = 587 315 | security = XOAUTH 316 | username = Laȝamon@example.com 317 | """)) 318 | 319 | # Simple template 320 | sendmail_client = SendmailClient(config_path, dry_run=False) 321 | message = email.message_from_string("Hello world") 322 | 323 | # Mock the password entry 324 | mock_getpass = mocker.patch('getpass.getpass') 325 | mock_getpass.return_value = "password" 326 | 327 | # Send a message 328 | with pytest.raises(MailmergeError) as err: 329 | sendmail_client.sendmail( 330 | sender="test@test.com", 331 | recipients=["test@test.com"], 332 | message=message, 333 | ) 334 | 335 | # Verify exception string 336 | assert "Username and XOAUTH access token must be ASCII" in str(err.value) 337 | 338 | 339 | def test_security_plain(mocker, tmp_path): 340 | """Verify plain security configuration.""" 341 | # Config for Plain SMTP server 342 | config_path = tmp_path/"server.conf" 343 | config_path.write_text(textwrap.dedent("""\ 344 | [smtp_server] 345 | host = newman.eecs.umich.edu 346 | port = 25 347 | security = PLAIN 348 | username = YOUR_USERNAME_HERE 349 | """)) 350 | 351 | # Simple template 352 | sendmail_client = SendmailClient(config_path, dry_run=False) 353 | message = email.message_from_string("Hello world") 354 | 355 | # Mock SMTP 356 | mock_smtp = mocker.patch('smtplib.SMTP') 357 | mock_smtp_ssl = mocker.patch('smtplib.SMTP_SSL') 358 | 359 | # Mock the password entry 360 | mock_getpass = mocker.patch('getpass.getpass') 361 | mock_getpass.return_value = "password" 362 | 363 | # Send a message 364 | sendmail_client.sendmail( 365 | sender="test@test.com", 366 | recipients=["test@test.com"], 367 | message=message, 368 | ) 369 | 370 | # Verify SMTP library calls 371 | assert mock_getpass.call_count == 1 372 | assert mock_smtp.call_count == 1 373 | assert mock_smtp_ssl.call_count == 0 374 | smtp = mock_smtp.return_value.__enter__.return_value 375 | assert smtp.ehlo.call_count == 0 376 | assert smtp.starttls.call_count == 0 377 | assert smtp.login.call_count == 1 378 | assert smtp.sendmail.call_count == 1 379 | 380 | 381 | def test_security_ssl(mocker, tmp_path): 382 | """Verify SSL/TLS security configuration.""" 383 | # Config for SSL SMTP server 384 | config_path = tmp_path/"server.conf" 385 | config_path.write_text(textwrap.dedent("""\ 386 | [smtp_server] 387 | host = smtp.mail.umich.edu 388 | port = 465 389 | security = SSL/TLS 390 | username = YOUR_USERNAME_HERE 391 | """)) 392 | 393 | # Simple template 394 | sendmail_client = SendmailClient(config_path, dry_run=False) 395 | message = email.message_from_string("Hello world") 396 | 397 | # Mock SMTP 398 | mock_smtp = mocker.patch('smtplib.SMTP') 399 | mock_smtp_ssl = mocker.patch('smtplib.SMTP_SSL') 400 | 401 | # Mock SSL 402 | mock_ssl_create_default_context = \ 403 | mocker.patch('ssl.create_default_context') 404 | 405 | # Mock the password entry 406 | mock_getpass = mocker.patch('getpass.getpass') 407 | mock_getpass.return_value = "password" 408 | 409 | # Send a message 410 | sendmail_client.sendmail( 411 | sender="test@test.com", 412 | recipients=["test@test.com"], 413 | message=message, 414 | ) 415 | 416 | # Verify SMTP library calls 417 | assert mock_getpass.call_count == 1 418 | assert mock_smtp.call_count == 0 419 | assert mock_smtp_ssl.call_count == 1 420 | assert mock_ssl_create_default_context.called 421 | assert "context" in mock_smtp_ssl.call_args[1] # SSL cert chain 422 | smtp = mock_smtp_ssl.return_value.__enter__.return_value 423 | assert smtp.ehlo.call_count == 0 424 | assert smtp.starttls.call_count == 0 425 | assert smtp.login.call_count == 1 426 | assert smtp.sendmail.call_count == 1 427 | 428 | 429 | def test_ssl_error(mocker, tmp_path): 430 | """Verify SSL/TLS with an SSL error.""" 431 | # Config for SSL SMTP server 432 | config_path = tmp_path/"server.conf" 433 | config_path.write_text(textwrap.dedent("""\ 434 | [smtp_server] 435 | host = smtp.mail.umich.edu 436 | port = 465 437 | security = SSL/TLS 438 | username = YOUR_USERNAME_HERE 439 | """)) 440 | 441 | # Simple template 442 | sendmail_client = SendmailClient(config_path, dry_run=False) 443 | message = email.message_from_string("Hello world") 444 | 445 | # Mock ssl.create_default_context() to raise an exception 446 | mocker.patch( 447 | 'ssl.create_default_context', 448 | side_effect=ssl.SSLError(1, "CERTIFICATE_VERIFY_FAILED") 449 | ) 450 | 451 | # Mock the password entry 452 | mock_getpass = mocker.patch('getpass.getpass') 453 | mock_getpass.return_value = "password" 454 | 455 | # Send a message 456 | with pytest.raises(MailmergeError) as err: 457 | sendmail_client.sendmail( 458 | sender="test@test.com", 459 | recipients=["test@test.com"], 460 | message=message, 461 | ) 462 | 463 | # Verify exception string 464 | assert "CERTIFICATE_VERIFY_FAILED" in str(err.value) 465 | 466 | 467 | def test_missing_username(tmp_path): 468 | """Verify exception on missing username.""" 469 | config_path = tmp_path/"server.conf" 470 | config_path.write_text(textwrap.dedent("""\ 471 | [smtp_server] 472 | host = smtp.mail.umich.edu 473 | port = 465 474 | security = SSL/TLS 475 | """)) 476 | with pytest.raises(MailmergeError): 477 | SendmailClient(config_path, dry_run=False) 478 | 479 | 480 | def test_smtp_login_error(mocker, tmp_path): 481 | """Login failure.""" 482 | # Config for SSL SMTP server 483 | config_path = tmp_path/"server.conf" 484 | config_path.write_text(textwrap.dedent("""\ 485 | [smtp_server] 486 | host = smtp.gmail.com 487 | port = 465 488 | security = SSL/TLS 489 | username = awdeorio 490 | """)) 491 | 492 | # Simple template 493 | sendmail_client = SendmailClient(config_path, dry_run=False) 494 | message = email.message_from_string("Hello world") 495 | 496 | # Mock SMTP and getpass 497 | mock_smtp_ssl = mocker.patch('smtplib.SMTP_SSL') 498 | 499 | # Mock the password entry 500 | mock_getpass = mocker.patch('getpass.getpass') 501 | mock_getpass.return_value = "password" 502 | 503 | # Configure SMTP login() to raise an exception 504 | mock_smtp_ssl.return_value.__enter__.return_value.login = mocker.Mock( 505 | side_effect=smtplib.SMTPAuthenticationError( 506 | code=535, 507 | msg=( 508 | "5.7.8 Username and Password not accepted. Learn more at " 509 | "5.7.8 https://support.google.com/mail/?p=BadCredentials " 510 | "xyzxyz.32 - gsmtp" 511 | ) 512 | ) 513 | ) 514 | 515 | # Send a message 516 | with pytest.raises(MailmergeError) as err: 517 | sendmail_client.sendmail( 518 | sender="test@test.com", 519 | recipients=["test@test.com"], 520 | message=message, 521 | ) 522 | 523 | # Verify exception string 524 | assert "smtp.gmail.com:465 failed to authenticate user 'awdeorio'" in\ 525 | str(err.value) 526 | assert "535" in str(err.value) 527 | assert ( 528 | "5.7.8 Username and Password not accepted. Learn more at " 529 | "5.7.8 https://support.google.com/mail/?p=BadCredentials " 530 | "xyzxyz.32 - gsmtp" 531 | ) in str(err.value) 532 | 533 | 534 | def test_smtp_sendmail_error(mocker, tmp_path): 535 | """Failure during SMTP protocol.""" 536 | # Config for SSL SMTP server 537 | config_path = tmp_path/"server.conf" 538 | config_path.write_text(textwrap.dedent("""\ 539 | [smtp_server] 540 | host = smtp.gmail.com 541 | port = 465 542 | security = SSL/TLS 543 | username = awdeorio 544 | """)) 545 | 546 | # Simple template 547 | sendmail_client = SendmailClient(config_path, dry_run=False) 548 | message = email.message_from_string("Hello world") 549 | 550 | # Mock SMTP 551 | mock_smtp_ssl = mocker.patch('smtplib.SMTP_SSL') 552 | 553 | # Mock the password entry 554 | mock_getpass = mocker.patch('getpass.getpass') 555 | mock_getpass.return_value = "password" 556 | 557 | # Configure SMTP sendmail() to raise an exception 558 | mock_smtp_ssl.return_value.__enter__.return_value.sendmail = mocker.Mock( 559 | side_effect=smtplib.SMTPException("Dummy error message") 560 | ) 561 | 562 | # Send a message 563 | with pytest.raises(MailmergeError) as err: 564 | sendmail_client.sendmail( 565 | sender="test@test.com", 566 | recipients=["test@test.com"], 567 | message=message, 568 | ) 569 | 570 | # Verify exception string 571 | assert "Dummy error message" in str(err.value) 572 | 573 | 574 | def test_socket_error(mocker, tmp_path): 575 | """Failed socket connection.""" 576 | # Config for SSL SMTP server 577 | config_path = tmp_path/"server.conf" 578 | config_path.write_text(textwrap.dedent("""\ 579 | [smtp_server] 580 | host = smtp.gmail.com 581 | port = 465 582 | security = SSL/TLS 583 | username = awdeorio 584 | """)) 585 | 586 | # Simple template 587 | sendmail_client = SendmailClient(config_path, dry_run=False) 588 | message = email.message_from_string("Hello world") 589 | 590 | # Mock SMTP 591 | mock_smtp_ssl = mocker.patch('smtplib.SMTP_SSL') 592 | 593 | # Mock the password entry 594 | mock_getpass = mocker.patch('getpass.getpass') 595 | mock_getpass.return_value = "password" 596 | 597 | # Configure SMTP_SSL constructor to raise an exception 598 | mock_smtp_ssl.return_value.__enter__ = mocker.Mock( 599 | side_effect=socket.error("Dummy error message") 600 | ) 601 | 602 | # Send a message 603 | with pytest.raises(MailmergeError) as err: 604 | sendmail_client.sendmail( 605 | sender="test@test.com", 606 | recipients=["test@test.com"], 607 | message=message, 608 | ) 609 | 610 | # Verify exception string 611 | assert "Dummy error message" in str(err.value) 612 | -------------------------------------------------------------------------------- /tests/test_main_output.py: -------------------------------------------------------------------------------- 1 | """ 2 | System tests focused on CLI output. 3 | 4 | Andrew DeOrio 5 | 6 | pytest tmpdir docs: 7 | http://doc.pytest.org/en/latest/tmpdir.html#the-tmpdir-fixture 8 | """ 9 | import copy 10 | import os 11 | import re 12 | import textwrap 13 | from pathlib import Path 14 | import click.testing 15 | from mailmerge.__main__ import main 16 | 17 | 18 | def test_stdout(tmpdir): 19 | """Verify stdout and stderr with dry run on simple input files.""" 20 | # Simple template 21 | template_path = Path(tmpdir/"template.txt") 22 | template_path.write_text(textwrap.dedent("""\ 23 | TO: {{email}} 24 | SUBJECT: Testing mailmerge 25 | FROM: My Self 26 | 27 | Hi, {{name}}, 28 | 29 | Your number is {{number}}. 30 | """), encoding="utf8") 31 | 32 | # Simple database 33 | database_path = Path(tmpdir/"database.csv") 34 | database_path.write_text(textwrap.dedent("""\ 35 | email,name,number 36 | myself@mydomain.com,"Myself",17 37 | bob@bobdomain.com,"Bob",42 38 | """), encoding="utf8") 39 | 40 | # Simple unsecure server config 41 | config_path = Path(tmpdir/"server.conf") 42 | config_path.write_text(textwrap.dedent("""\ 43 | [smtp_server] 44 | host = open-smtp.example.com 45 | port = 25 46 | """), encoding="utf8") 47 | 48 | # Run mailmerge 49 | runner = click.testing.CliRunner(mix_stderr=False) 50 | result = runner.invoke(main, [ 51 | "--template", template_path, 52 | "--database", database_path, 53 | "--config", config_path, 54 | "--no-limit", 55 | "--dry-run", 56 | "--output-format", "text", 57 | ]) 58 | assert not result.exception 59 | assert result.exit_code == 0 60 | 61 | # Verify mailmerge output. We'll filter out the Date header because it 62 | # won't match exactly. 63 | assert result.stderr == "" 64 | assert "Date:" in result.stdout 65 | stdout = copy.deepcopy(result.stdout) 66 | stdout = re.sub(r"Date.*\n", "", stdout) 67 | assert stdout == textwrap.dedent("""\ 68 | >>> message 1 69 | TO: myself@mydomain.com 70 | SUBJECT: Testing mailmerge 71 | FROM: My Self 72 | MIME-Version: 1.0 73 | Content-Type: text/plain; charset="us-ascii" 74 | Content-Transfer-Encoding: 7bit 75 | 76 | Hi, Myself, 77 | 78 | Your number is 17. 79 | 80 | >>> message 1 sent 81 | >>> message 2 82 | TO: bob@bobdomain.com 83 | SUBJECT: Testing mailmerge 84 | FROM: My Self 85 | MIME-Version: 1.0 86 | Content-Type: text/plain; charset="us-ascii" 87 | Content-Transfer-Encoding: 7bit 88 | 89 | Hi, Bob, 90 | 91 | Your number is 42. 92 | 93 | >>> message 2 sent 94 | >>> This was a dry run. To send messages, use the --no-dry-run option. 95 | """) 96 | 97 | 98 | def test_stdout_utf8(tmpdir): 99 | """Verify human-readable output when template contains utf-8.""" 100 | # Simple template 101 | template_path = Path(tmpdir/"mailmerge_template.txt") 102 | template_path.write_text(textwrap.dedent("""\ 103 | TO: to@test.com 104 | FROM: from@test.com 105 | 106 | Laȝamon 😀 klâwen 107 | """), encoding="utf8") 108 | 109 | # Simple database 110 | database_path = Path(tmpdir/"mailmerge_database.csv") 111 | database_path.write_text(textwrap.dedent("""\ 112 | email 113 | myself@mydomain.com 114 | """), encoding="utf8") 115 | 116 | # Simple unsecure server config 117 | config_path = Path(tmpdir/"mailmerge_server.conf") 118 | config_path.write_text(textwrap.dedent("""\ 119 | [smtp_server] 120 | host = open-smtp.example.com 121 | port = 25 122 | """), encoding="utf8") 123 | 124 | # Run mailmerge with defaults, which includes dry-run 125 | runner = click.testing.CliRunner(mix_stderr=False) 126 | with tmpdir.as_cwd(): 127 | result = runner.invoke(main, ["--output-format", "text"]) 128 | assert not result.exception 129 | assert result.exit_code == 0 130 | 131 | # Verify mailmerge output. We'll filter out the Date header because it 132 | # won't match exactly. 133 | assert result.stderr == "" 134 | stdout = copy.deepcopy(result.stdout) 135 | assert "Date:" in stdout 136 | stdout = re.sub(r"Date.*\n", "", stdout) 137 | assert stdout == textwrap.dedent("""\ 138 | >>> message 1 139 | TO: to@test.com 140 | FROM: from@test.com 141 | MIME-Version: 1.0 142 | Content-Type: text/plain; charset="utf-8" 143 | Content-Transfer-Encoding: base64 144 | 145 | Laȝamon 😀 klâwen 146 | 147 | >>> message 1 sent 148 | >>> Limit was 1 message. To remove the limit, use the --no-limit option. 149 | >>> This was a dry run. To send messages, use the --no-dry-run option. 150 | """) # noqa: E501 151 | 152 | 153 | def test_stdout_utf8_redirect(tmpdir): 154 | """Verify utf-8 output is properly encoded when redirected. 155 | 156 | UTF-8 print fails when redirecting stdout under Pythnon 2 157 | http://blog.mathieu-leplatre.info/python-utf-8-print-fails-when-redirecting-stdout.html 158 | """ 159 | # Simple template 160 | template_path = Path(tmpdir/"mailmerge_template.txt") 161 | template_path.write_text(textwrap.dedent("""\ 162 | TO: to@test.com 163 | FROM: from@test.com 164 | 165 | Laȝamon 😀 klâwen 166 | """), encoding="utf8") 167 | 168 | # Simple database 169 | database_path = Path(tmpdir/"mailmerge_database.csv") 170 | database_path.write_text(textwrap.dedent("""\ 171 | email 172 | myself@mydomain.com 173 | """), encoding="utf8") 174 | 175 | # Simple unsecure server config 176 | config_path = Path(tmpdir/"mailmerge_server.conf") 177 | config_path.write_text(textwrap.dedent("""\ 178 | [smtp_server] 179 | host = open-smtp.example.com 180 | port = 25 181 | """), encoding="utf8") 182 | 183 | # Run mailmerge. We only care that no exceptions occur. Note that we 184 | # can't use the click test runner here because it doesn't accurately 185 | # recreate the conditions of the bug where the redirect destination lacks 186 | # utf-8 encoding. 187 | with tmpdir.as_cwd(): 188 | exit_code = os.system("mailmerge > mailmerge.out") 189 | assert exit_code == 0 190 | 191 | 192 | def test_english(tmpdir): 193 | """Verify correct English, message vs. messages.""" 194 | # Blank message 195 | template_path = Path(tmpdir/"mailmerge_template.txt") 196 | template_path.write_text(textwrap.dedent("""\ 197 | TO: to@test.com 198 | FROM: from@test.com 199 | """), encoding="utf8") 200 | 201 | # Database with 2 entries 202 | database_path = Path(tmpdir/"mailmerge_database.csv") 203 | database_path.write_text(textwrap.dedent("""\ 204 | dummy 205 | 1 206 | 2 207 | """), encoding="utf8") 208 | 209 | # Simple unsecure server config 210 | config_path = Path(tmpdir/"mailmerge_server.conf") 211 | config_path.write_text(textwrap.dedent("""\ 212 | [smtp_server] 213 | host = open-smtp.example.com 214 | port = 25 215 | """), encoding="utf8") 216 | 217 | # Run mailmerge with several limits 218 | runner = click.testing.CliRunner() 219 | with tmpdir.as_cwd(): 220 | result = runner.invoke(main, ["--limit", "0"]) 221 | assert not result.exception 222 | assert result.exit_code == 0 223 | assert "Limit was 0 messages." in result.output 224 | with tmpdir.as_cwd(): 225 | result = runner.invoke(main, ["--limit", "1"]) 226 | assert not result.exception 227 | assert result.exit_code == 0 228 | assert "Limit was 1 message." in result.output 229 | with tmpdir.as_cwd(): 230 | result = runner.invoke(main, ["--limit", "2"]) 231 | assert not result.exception 232 | assert result.exit_code == 0 233 | assert "Limit was 2 messages." in result.output 234 | 235 | 236 | def test_output_format_bad(tmpdir): 237 | """Verify bad output format.""" 238 | runner = click.testing.CliRunner(mix_stderr=False) 239 | with tmpdir.as_cwd(): 240 | result = runner.invoke(main, ["--output-format", "bad"]) 241 | assert result.exit_code == 2 242 | assert result.stdout == "" 243 | 244 | # Remove single and double quotes from error message. Different versions 245 | # of the click library use different formats. 246 | stderr = copy.deepcopy(result.stderr) 247 | stderr = stderr.replace('"', "") 248 | stderr = stderr.replace("'", "") 249 | assert 'Invalid value for --output-format' in stderr 250 | 251 | 252 | def test_output_format_raw(tmpdir): 253 | """Verify raw output format.""" 254 | # Attachment 255 | attachment_path = Path(tmpdir/"attachment.txt") 256 | attachment_path.write_text("Hello world\n", encoding="utf8") 257 | 258 | # Simple template 259 | template_path = Path(tmpdir/"mailmerge_template.txt") 260 | template_path.write_text(textwrap.dedent("""\ 261 | TO: {{email}} 262 | FROM: from@test.com 263 | 264 | Laȝamon 😀 klâwen 265 | """), encoding="utf8") 266 | 267 | # Simple database 268 | database_path = Path(tmpdir/"mailmerge_database.csv") 269 | database_path.write_text(textwrap.dedent("""\ 270 | email 271 | to@test.com 272 | """), encoding="utf8") 273 | 274 | # Simple unsecure server config 275 | config_path = Path(tmpdir/"mailmerge_server.conf") 276 | config_path.write_text(textwrap.dedent("""\ 277 | [smtp_server] 278 | host = open-smtp.example.com 279 | port = 25 280 | """), encoding="utf8") 281 | 282 | # Run mailmerge 283 | runner = click.testing.CliRunner(mix_stderr=False) 284 | with tmpdir.as_cwd(): 285 | result = runner.invoke(main, ["--output-format", "raw"]) 286 | assert not result.exception 287 | assert result.exit_code == 0 288 | 289 | # Remove the Date string, which will be different each time 290 | stdout = copy.deepcopy(result.stdout) 291 | stdout = re.sub(r"Date:.+", "Date: REDACTED", stdout, re.MULTILINE) 292 | 293 | # Verify output 294 | assert result.stderr == "" 295 | assert stdout == textwrap.dedent("""\ 296 | >>> message 1 297 | TO: to@test.com 298 | FROM: from@test.com 299 | MIME-Version: 1.0 300 | Content-Type: text/plain; charset="utf-8" 301 | Content-Transfer-Encoding: base64 302 | Date: REDACTED 303 | 304 | TGHInWFtb24g8J+YgCBrbMOid2Vu 305 | 306 | >>> message 1 sent 307 | >>> Limit was 1 message. To remove the limit, use the --no-limit option. 308 | >>> This was a dry run. To send messages, use the --no-dry-run option. 309 | """) # noqa: E501 310 | 311 | 312 | def test_output_format_text(tmpdir): 313 | """Verify text output format.""" 314 | # Attachment 315 | attachment_path = Path(tmpdir/"attachment.txt") 316 | attachment_path.write_text("Hello world\n", encoding="utf8") 317 | 318 | # Simple template 319 | template_path = Path(tmpdir/"mailmerge_template.txt") 320 | template_path.write_text(textwrap.dedent("""\ 321 | TO: {{email}} 322 | FROM: from@test.com 323 | 324 | Laȝamon 😀 klâwen 325 | """), encoding="utf8") 326 | 327 | # Simple database 328 | database_path = Path(tmpdir/"mailmerge_database.csv") 329 | database_path.write_text(textwrap.dedent("""\ 330 | email 331 | to@test.com 332 | """), encoding="utf8") 333 | 334 | # Simple unsecure server config 335 | config_path = Path(tmpdir/"mailmerge_server.conf") 336 | config_path.write_text(textwrap.dedent("""\ 337 | [smtp_server] 338 | host = open-smtp.example.com 339 | port = 25 340 | """), encoding="utf8") 341 | 342 | # Run mailmerge 343 | runner = click.testing.CliRunner(mix_stderr=False) 344 | with tmpdir.as_cwd(): 345 | result = runner.invoke(main, ["--output-format", "text"]) 346 | assert not result.exception 347 | assert result.exit_code == 0 348 | 349 | # Remove the Date string, which will be different each time 350 | stdout = copy.deepcopy(result.stdout) 351 | stdout = re.sub(r"Date:.+", "Date: REDACTED", stdout, re.MULTILINE) 352 | 353 | # Verify output 354 | assert result.stderr == "" 355 | assert stdout == textwrap.dedent("""\ 356 | >>> message 1 357 | TO: to@test.com 358 | FROM: from@test.com 359 | MIME-Version: 1.0 360 | Content-Type: text/plain; charset="utf-8" 361 | Content-Transfer-Encoding: base64 362 | Date: REDACTED 363 | 364 | Laȝamon 😀 klâwen 365 | 366 | >>> message 1 sent 367 | >>> Limit was 1 message. To remove the limit, use the --no-limit option. 368 | >>> This was a dry run. To send messages, use the --no-dry-run option. 369 | """) # noqa: E501 370 | 371 | 372 | def test_output_format_colorized(tmpdir): 373 | """Verify colorized output format.""" 374 | # Attachment 375 | attachment_path = Path(tmpdir/"attachment.txt") 376 | attachment_path.write_text("Hello world\n", encoding="utf8") 377 | 378 | # HTML template 379 | template_path = Path(tmpdir/"mailmerge_template.txt") 380 | template_path.write_text(textwrap.dedent("""\ 381 | TO: {{email}} 382 | FROM: from@test.com 383 | MIME-Version: 1.0 384 | Content-Type: multipart/alternative; boundary="boundary" 385 | 386 | This is a MIME-encoded message. If you are seeing this, your mail 387 | reader is old. 388 | 389 | --boundary 390 | Content-Type: text/plain; charset=us-ascii 391 | 392 | Laȝamon 😀 klâwen 393 | 394 | --boundary 395 | Content-Type: text/html; charset=us-ascii 396 | 397 | 398 | 399 |

Laȝamon 😀 klâwen

400 | 401 | 402 | """), encoding="utf8") 403 | 404 | # Simple database 405 | database_path = Path(tmpdir/"mailmerge_database.csv") 406 | database_path.write_text(textwrap.dedent("""\ 407 | email 408 | to@test.com 409 | """), encoding="utf8") 410 | 411 | # Simple unsecure server config 412 | config_path = Path(tmpdir/"mailmerge_server.conf") 413 | config_path.write_text(textwrap.dedent("""\ 414 | [smtp_server] 415 | host = open-smtp.example.com 416 | port = 25 417 | """), encoding="utf8") 418 | 419 | # Run mailmerge 420 | runner = click.testing.CliRunner(mix_stderr=False) 421 | with tmpdir.as_cwd(): 422 | result = runner.invoke(main, ["--output-format", "colorized"]) 423 | assert not result.exception 424 | assert result.exit_code == 0 425 | 426 | # Remove the Date string, which will be different each time 427 | stdout = copy.deepcopy(result.stdout) 428 | stdout = re.sub(r"Date:.+", "Date: REDACTED", stdout, re.MULTILINE) 429 | 430 | # Verify output. The funny looking character sequences are colors. 431 | assert result.stderr == "" 432 | assert stdout == textwrap.dedent("""\ 433 | \x1b[7m\x1b[1m\x1b[36m>>> message 1\x1b(B\x1b[m 434 | TO: to@test.com 435 | FROM: from@test.com 436 | MIME-Version: 1.0 437 | Content-Type: multipart/alternative; boundary="boundary" 438 | Date: REDACTED 439 | 440 | \x1b[36m>>> message part: text/plain\x1b(B\x1b[m 441 | La\u021damon \U0001f600 kl\xe2wen 442 | 443 | 444 | \x1b[36m>>> message part: text/html\x1b(B\x1b[m 445 | 446 | 447 |

La\u021damon \U0001f600 kl\xe2wen

448 | 449 | 450 | 451 | \x1b[7m\x1b[1m\x1b[36m>>> message 1 sent\x1b(B\x1b[m 452 | >>> Limit was 1 message. To remove the limit, use the --no-limit option. 453 | >>> This was a dry run. To send messages, use the --no-dry-run option. 454 | """) # noqa: E501 455 | 456 | 457 | def test_complicated(tmpdir): 458 | """Complicated end-to-end test. 459 | 460 | Includes templating, TO, CC, BCC, UTF8 characters, emoji, attachments, 461 | encoding mismatch (header is us-ascii, characters used are utf-8). Also, 462 | multipart message in plaintext and HTML. 463 | """ 464 | # First attachment 465 | attachment1_path = Path(tmpdir/"attachment1.txt") 466 | attachment1_path.write_text("Hello world\n", encoding="utf8") 467 | 468 | # Second attachment 469 | attachment2_path = Path(tmpdir/"attachment2.csv") 470 | attachment2_path.write_text("hello,mailmerge\n", encoding="utf8") 471 | 472 | # Template with attachment header 473 | template_path = Path(tmpdir/"mailmerge_template.txt") 474 | template_path.write_text(textwrap.dedent("""\ 475 | TO: {{email}} 476 | FROM: from@test.com 477 | CC: cc1@test.com, cc2@test.com 478 | BCC: bcc1@test.com, bcc2@test.com 479 | ATTACHMENT: attachment1.txt 480 | ATTACHMENT: attachment2.csv 481 | MIME-Version: 1.0 482 | Content-Type: multipart/alternative; boundary="boundary" 483 | 484 | This is a MIME-encoded message. If you are seeing this, your mail 485 | reader is old. 486 | 487 | --boundary 488 | Content-Type: text/plain; charset=us-ascii 489 | 490 | {{message}} 491 | 492 | 493 | --boundary 494 | Content-Type: text/html; charset=us-ascii 495 | 496 | 497 | 498 |

{{message}}

499 | 500 | 501 | 502 | 503 | """), encoding="utf8") 504 | 505 | # Database with utf-8, emoji, quotes, and commas. Note that quotes are 506 | # escaped with double quotes, not backslash. 507 | # https://docs.python.org/3.7/library/csv.html#csv.Dialect.doublequote 508 | database_path = Path(tmpdir/"mailmerge_database.csv") 509 | database_path.write_text(textwrap.dedent('''\ 510 | email,message 511 | one@test.com,"Hello, ""world""" 512 | Lazamon,Laȝamon 😀 klâwen 513 | '''), encoding="utf8") 514 | 515 | # Simple unsecure server config 516 | config_path = Path(tmpdir/"mailmerge_server.conf") 517 | config_path.write_text(textwrap.dedent("""\ 518 | [smtp_server] 519 | host = open-smtp.example.com 520 | port = 25 521 | """), encoding="utf8") 522 | 523 | # Run mailmerge in tmpdir with defaults, which includes dry run 524 | runner = click.testing.CliRunner(mix_stderr=False) 525 | with tmpdir.as_cwd(): 526 | result = runner.invoke(main, [ 527 | "--no-limit", 528 | "--output-format", "raw", 529 | ]) 530 | assert not result.exception 531 | assert result.exit_code == 0 532 | 533 | # Remove the Date and Content-ID strings, which will be different each time 534 | stdout = copy.deepcopy(result.stdout) 535 | stdout = re.sub(r"Date:.+", "Date: REDACTED", stdout, re.MULTILINE) 536 | stdout = re.sub(r'Content-Id:.*', '', stdout) 537 | 538 | # Verify stdout and stderr after above corrections 539 | assert result.stderr == "" 540 | assert stdout == textwrap.dedent("""\ 541 | >>> message 1 542 | TO: one@test.com 543 | FROM: from@test.com 544 | CC: cc1@test.com, cc2@test.com 545 | MIME-Version: 1.0 546 | Content-Type: multipart/alternative; boundary="boundary" 547 | Date: REDACTED 548 | 549 | This is a MIME-encoded message. If you are seeing this, your mail 550 | reader is old. 551 | 552 | --boundary 553 | MIME-Version: 1.0 554 | Content-Type: text/plain; charset="us-ascii" 555 | Content-Transfer-Encoding: 7bit 556 | 557 | Hello, "world" 558 | 559 | 560 | --boundary 561 | MIME-Version: 1.0 562 | Content-Type: text/html; charset="us-ascii" 563 | Content-Transfer-Encoding: 7bit 564 | 565 | 566 | 567 |

Hello, "world"

568 | 569 | 570 | 571 | --boundary 572 | Content-Type: application/octet-stream; Name="attachment1.txt" 573 | MIME-Version: 1.0 574 | Content-Transfer-Encoding: base64 575 | Content-Disposition: attachment; filename="attachment1.txt" 576 | 577 | 578 | SGVsbG8gd29ybGQK 579 | 580 | --boundary 581 | Content-Type: application/octet-stream; Name="attachment2.csv" 582 | MIME-Version: 1.0 583 | Content-Transfer-Encoding: base64 584 | Content-Disposition: attachment; filename="attachment2.csv" 585 | 586 | 587 | aGVsbG8sbWFpbG1lcmdlCg== 588 | 589 | --boundary-- 590 | 591 | >>> message 1 sent 592 | >>> message 2 593 | TO: Lazamon 594 | FROM: from@test.com 595 | CC: cc1@test.com, cc2@test.com 596 | MIME-Version: 1.0 597 | Content-Type: multipart/alternative; boundary="boundary" 598 | Date: REDACTED 599 | 600 | This is a MIME-encoded message. If you are seeing this, your mail 601 | reader is old. 602 | 603 | --boundary 604 | MIME-Version: 1.0 605 | Content-Type: text/plain; charset="utf-8" 606 | Content-Transfer-Encoding: base64 607 | 608 | TGHInWFtb24g8J+YgCBrbMOid2VuCgo= 609 | 610 | --boundary 611 | MIME-Version: 1.0 612 | Content-Type: text/html; charset="utf-8" 613 | Content-Transfer-Encoding: base64 614 | 615 | PGh0bWw+CiAgPGJvZHk+CiAgICA8cD5MYcidYW1vbiDwn5iAIGtsw6J3ZW48L3A+CiAgPC9ib2R5 616 | Pgo8L2h0bWw+Cg== 617 | 618 | --boundary 619 | Content-Type: application/octet-stream; Name="attachment1.txt" 620 | MIME-Version: 1.0 621 | Content-Transfer-Encoding: base64 622 | Content-Disposition: attachment; filename="attachment1.txt" 623 | 624 | 625 | SGVsbG8gd29ybGQK 626 | 627 | --boundary 628 | Content-Type: application/octet-stream; Name="attachment2.csv" 629 | MIME-Version: 1.0 630 | Content-Transfer-Encoding: base64 631 | Content-Disposition: attachment; filename="attachment2.csv" 632 | 633 | 634 | aGVsbG8sbWFpbG1lcmdlCg== 635 | 636 | --boundary-- 637 | 638 | >>> message 2 sent 639 | >>> This was a dry run. To send messages, use the --no-dry-run option. 640 | """) 641 | -------------------------------------------------------------------------------- /tests/test_template_message.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for TemplateMessage. 3 | 4 | Andrew DeOrio 5 | """ 6 | import os 7 | import re 8 | import shutil 9 | import textwrap 10 | import collections 11 | from pathlib import Path 12 | import pytest 13 | import markdown 14 | import html5lib 15 | from mailmerge import TemplateMessage, MailmergeError 16 | from . import utils 17 | 18 | 19 | def test_simple(tmp_path): 20 | """Render a simple template.""" 21 | template_path = tmp_path / "template.txt" 22 | template_path.write_text(textwrap.dedent("""\ 23 | TO: to@test.com 24 | SUBJECT: Testing mailmerge 25 | FROM: from@test.com 26 | 27 | Hello {{name}}! 28 | """), encoding="utf8") 29 | template_message = TemplateMessage(template_path) 30 | sender, recipients, message = template_message.render({ 31 | "name": "world", 32 | }) 33 | assert sender == "from@test.com" 34 | assert recipients == ["to@test.com"] 35 | plaintext = message.get_payload() 36 | assert "Hello world!" in plaintext 37 | 38 | 39 | def test_no_substitutions(tmp_path): 40 | """Render a template with an empty context.""" 41 | template_path = tmp_path / "template.txt" 42 | template_path.write_text(textwrap.dedent("""\ 43 | TO: to@test.com 44 | SUBJECT: Testing mailmerge 45 | FROM: from@test.com 46 | 47 | Hello world! 48 | """), encoding="utf8") 49 | template_message = TemplateMessage(template_path) 50 | sender, recipients, message = template_message.render({}) 51 | assert sender == "from@test.com" 52 | assert recipients == ["to@test.com"] 53 | plaintext = message.get_payload() 54 | assert "Hello world!" in plaintext 55 | 56 | 57 | def test_multiple_substitutions(tmp_path): 58 | """Render a template with multiple context variables.""" 59 | template_path = tmp_path / "template.txt" 60 | template_path.write_text(textwrap.dedent("""\ 61 | TO: {{email}} 62 | FROM: from@test.com 63 | 64 | Hi, {{name}}, 65 | 66 | Your number is {{number}}. 67 | """), encoding="utf8") 68 | template_message = TemplateMessage(template_path) 69 | sender, recipients, message = template_message.render({ 70 | "email": "myself@mydomain.com", 71 | "name": "Myself", 72 | "number": 17, 73 | }) 74 | assert sender == "from@test.com" 75 | assert recipients == ["myself@mydomain.com"] 76 | plaintext = message.get_payload() 77 | assert "Hi, Myself," in plaintext 78 | assert "Your number is 17" in plaintext 79 | 80 | 81 | def test_bad_jinja(tmp_path): 82 | """Bad jinja template should produce an error.""" 83 | template_path = tmp_path / "template.txt" 84 | template_path.write_text("TO: {{error_not_in_database}}") 85 | template_message = TemplateMessage(template_path) 86 | with pytest.raises(MailmergeError): 87 | template_message.render({"name": "Bob", "number": 17}) 88 | 89 | 90 | def test_cc_bcc(tmp_path): 91 | """CC recipients should receive a copy.""" 92 | template_path = tmp_path / "template.txt" 93 | template_path.write_text(textwrap.dedent("""\ 94 | TO: {{email}} 95 | SUBJECT: Testing mailmerge 96 | FROM: My Self 97 | CC: My Colleague 98 | BCC: Secret 99 | 100 | Hello world 101 | """), encoding="utf8") 102 | template_message = TemplateMessage(template_path) 103 | sender, recipients, message = template_message.render({ 104 | "email": "myself@mydomain.com", 105 | }) 106 | 107 | # Verify recipients include CC and BCC 108 | assert sender == "My Self " 109 | assert recipients == [ 110 | "myself@mydomain.com", 111 | "mycolleague@mydomain.com", 112 | "secret@mydomain.com", 113 | ] 114 | 115 | # Make sure BCC recipients are *not* in the message 116 | plaintext = message.get_payload() 117 | assert "BCC" not in plaintext 118 | assert "secret@mydomain.com" not in plaintext 119 | assert "Secret" not in plaintext 120 | 121 | 122 | def stripped_strings_equal(s_1, s_2): 123 | """Compare strings ignoring trailing whitespace.""" 124 | s_1 = s_1.strip() if s_1 else '' 125 | s_2 = s_2.strip() if s_2 else '' 126 | return s_1 == s_2 127 | 128 | 129 | def html_docs_equal(e_1, e_2): 130 | """Return true if two HTML trees are equivalent.""" 131 | # Based on: https://stackoverflow.com/a/24349916 132 | if not stripped_strings_equal(e_1.tag, e_2.tag): 133 | return False 134 | if not stripped_strings_equal(e_1.text, e_2.text): 135 | return False 136 | if not stripped_strings_equal(e_1.tail, e_2.tail): 137 | return False 138 | if e_1.attrib != e_2.attrib: 139 | return False 140 | if len(e_1) != len(e_2): 141 | return False 142 | return all(html_docs_equal(c_1, c_2) for c_1, c_2 in zip(e_1, e_2)) 143 | 144 | 145 | def test_html(tmp_path): 146 | """Verify HTML template results in a simple rendered message.""" 147 | template_path = tmp_path / "template.txt" 148 | template_path.write_text(textwrap.dedent("""\ 149 | TO: to@test.com 150 | SUBJECT: Testing mailmerge 151 | FROM: from@test.com 152 | Content-Type: text/html 153 | 154 | 155 | 156 |

{{message}}

157 | 158 | 159 | """), encoding="utf8") 160 | template_message = TemplateMessage(template_path) 161 | sender, recipients, message = template_message.render({ 162 | "message": "Hello world" 163 | }) 164 | 165 | # Verify sender and recipients 166 | assert sender == "from@test.com" 167 | assert recipients == ["to@test.com"] 168 | 169 | # A simple HTML message is not multipart 170 | assert not message.is_multipart() 171 | 172 | # Verify encoding 173 | assert message.get_charset() == "us-ascii" 174 | assert message.get_content_charset() == "us-ascii" 175 | assert message.get_content_type() == "text/html" 176 | 177 | # Verify content 178 | htmltext = html5lib.parse(message.get_payload()) 179 | expected = html5lib.parse("

Hello world

") 180 | assert html_docs_equal(htmltext, expected) 181 | 182 | 183 | def test_html_plaintext(tmp_path): 184 | """Verify HTML and plaintest multipart template.""" 185 | template_path = tmp_path / "template.txt" 186 | template_path.write_text(textwrap.dedent("""\ 187 | TO: to@test.com 188 | SUBJECT: Testing mailmerge 189 | FROM: from@test.com 190 | MIME-Version: 1.0 191 | Content-Type: multipart/alternative; boundary="boundary" 192 | 193 | This is a MIME-encoded message. If you are seeing this, your mail 194 | reader is old. 195 | 196 | --boundary 197 | Content-Type: text/plain; charset=us-ascii 198 | 199 | {{message}} 200 | 201 | --boundary 202 | Content-Type: text/html; charset=us-ascii 203 | 204 | 205 | 206 |

{{message}}

207 | 208 | 209 | """), encoding="utf8") 210 | template_message = TemplateMessage(template_path) 211 | sender, recipients, message = template_message.render({ 212 | "message": "Hello world" 213 | }) 214 | 215 | # Verify sender and recipients 216 | assert sender == "from@test.com" 217 | assert recipients == ["to@test.com"] 218 | 219 | # Should be multipart: plaintext and HTML 220 | assert message.is_multipart() 221 | parts = message.get_payload() 222 | assert len(parts) == 2 223 | plaintext_part, html_part = parts 224 | 225 | # Verify plaintext part 226 | assert plaintext_part.get_charset() == "us-ascii" 227 | assert plaintext_part.get_content_charset() == "us-ascii" 228 | assert plaintext_part.get_content_type() == "text/plain" 229 | plaintext = plaintext_part.get_payload() 230 | plaintext = plaintext.strip() 231 | assert plaintext == "Hello world" 232 | 233 | # Verify html part 234 | assert html_part.get_charset() == "us-ascii" 235 | assert html_part.get_content_charset() == "us-ascii" 236 | assert html_part.get_content_type() == "text/html" 237 | htmltext = html5lib.parse(html_part.get_payload()) 238 | expected = html5lib.parse("

Hello world

") 239 | assert html_docs_equal(htmltext, expected) 240 | 241 | 242 | def extract_text_from_markdown_payload(plaintext_part, mime_type): 243 | """Decode text from the given message part.""" 244 | assert plaintext_part['Content-Type'].startswith(mime_type) 245 | plaintext_encoding = str(plaintext_part.get_charset()) 246 | plaintext = plaintext_part.get_payload(decode=True) \ 247 | .decode(plaintext_encoding) 248 | return plaintext 249 | 250 | 251 | def test_markdown(tmp_path): 252 | """Markdown messages should be converted to HTML.""" 253 | template_path = tmp_path / "template.txt" 254 | template_path.write_text(textwrap.dedent("""\ 255 | TO: {{email}} 256 | SUBJECT: Testing mailmerge 257 | FROM: Bob 258 | CONTENT-TYPE: text/markdown 259 | 260 | Hi, **{{name}}**, 261 | 262 | You can add: 263 | 264 | - Emphasis, aka italics, with *asterisks* or _underscores_. 265 | - Strong emphasis, aka bold, with **asterisks** or __underscores__. 266 | - Combined emphasis with **asterisks and _underscores_**. 267 | - Strikethrough uses two tildes. ~~Scratch this.~~ 268 | - Unordered lists like this one. 269 | - Ordered lists with numbers: 270 | 1. Item 1 271 | 2. Item 2 272 | - Preformatted text with `backticks`. 273 | 274 | --- 275 | 276 | # This is a heading. 277 | ## And another heading. 278 | How about some [hyperlinks](http://bit.ly/eecs485-wn19-p6)? 279 | 280 | Or code blocks? 281 | 282 | ``` 283 | print("Hello world.") 284 | ``` 285 | 286 | Here's an image not attached with the email: 287 | ![python logo not attached]( 288 | http://pluspng.com/img-png/python-logo-png-open-2000.png) 289 | """), encoding="utf8") 290 | template_message = TemplateMessage(template_path) 291 | sender, recipients, message = template_message.render({ 292 | "email": "myself@mydomain.com", 293 | "name": "Myself", 294 | "number": 17, 295 | }) 296 | 297 | # Verify sender and recipients 298 | assert sender == "Bob " 299 | assert recipients == ["myself@mydomain.com"] 300 | 301 | # Verify message is multipart 302 | assert message.is_multipart() 303 | assert message.get_content_subtype() == "related" 304 | 305 | # Make sure there is a single multipart/alternative payload 306 | assert len(message.get_payload()) == 1 307 | assert message.get_payload()[0].is_multipart() 308 | assert message.get_payload()[0].get_content_subtype() == "alternative" 309 | 310 | # And there should be a plaintext part and an HTML part 311 | message_payload = message.get_payload()[0].get_payload() 312 | assert len(message_payload) == 2 313 | 314 | # Ensure that the first part is plaintext and the last part 315 | # is HTML (as per RFC 2046) 316 | plaintext = extract_text_from_markdown_payload(message_payload[0], 317 | 'text/plain') 318 | htmltext = extract_text_from_markdown_payload(message_payload[1], 319 | 'text/html') 320 | 321 | # Verify rendered Markdown 322 | rendered = markdown.markdown(plaintext, extensions=['nl2br']) 323 | expected = html5lib.parse(rendered) 324 | 325 | htmltext_document = html5lib.parse(htmltext) 326 | assert html_docs_equal(htmltext_document, expected) 327 | 328 | 329 | def test_markdown_encoding(tmp_path): 330 | """Verify encoding is preserved when rendering a Markdown template. 331 | 332 | See Issue #59 for a detailed explanation 333 | https://github.com/awdeorio/mailmerge/issues/59 334 | """ 335 | template_path = tmp_path / "template.txt" 336 | template_path.write_text(textwrap.dedent("""\ 337 | TO: {{email}} 338 | SUBJECT: Testing mailmerge 339 | FROM: test@example.com 340 | CONTENT-TYPE: text/markdown 341 | 342 | Hi, {{name}}, 343 | æøå 344 | """), encoding="utf8") 345 | template_message = TemplateMessage(template_path) 346 | _, _, message = template_message.render({ 347 | "email": "myself@mydomain.com", 348 | "name": "Myself", 349 | }) 350 | 351 | # Message should contain an unrendered Markdown plaintext part and a 352 | # rendered Markdown HTML part 353 | plaintext_part, html_part = message.get_payload()[0].get_payload() 354 | 355 | # Verify encodings 356 | assert str(plaintext_part.get_charset()) == "utf-8" 357 | assert str(html_part.get_charset()) == "utf-8" 358 | assert plaintext_part["Content-Transfer-Encoding"] == "base64" 359 | assert html_part["Content-Transfer-Encoding"] == "base64" 360 | 361 | # Verify content, which is base64 encoded 362 | plaintext = plaintext_part.get_payload(decode=True).decode("utf-8") 363 | htmltext = html_part.get_payload(decode=True).decode("utf-8") 364 | assert plaintext == "Hi, Myself,\næøå" 365 | assert html_docs_equal( 366 | html5lib.parse(htmltext), 367 | html5lib.parse( 368 | "

Hi, Myself,
\næøå

" 369 | ) 370 | ) 371 | 372 | 373 | Attachment = collections.namedtuple( 374 | "Attachment", 375 | ["filename", "content", "content_id"], 376 | ) 377 | 378 | 379 | def extract_attachments(message): 380 | """Return a list of attachments as (filename, content) named tuples.""" 381 | attachments = [] 382 | for part in message.walk(): 383 | if part.get_content_maintype() == "multipart": 384 | continue 385 | if part.get_content_maintype() == "text": 386 | continue 387 | if part.get("Content-Disposition") == "inline": 388 | continue 389 | if part.get("Content-Disposition") is None: 390 | continue 391 | if part['content-type'].startswith('application/octet-stream'): 392 | attachments.append(Attachment( 393 | filename=part.get_param('name'), 394 | content=part.get_payload(decode=True), 395 | content_id=part.get("Content-Id") 396 | )) 397 | return attachments 398 | 399 | 400 | def test_attachment_simple(tmpdir): 401 | """Verify a simple attachment.""" 402 | # Simple attachment 403 | attachment_path = Path(tmpdir/"attachment.txt") 404 | attachment_path.write_text("Hello world\n", encoding="utf8") 405 | 406 | # Simple template 407 | template_path = Path(tmpdir/"template.txt") 408 | template_path.write_text(textwrap.dedent("""\ 409 | TO: to@test.com 410 | FROM: from@test.com 411 | ATTACHMENT: attachment.txt 412 | 413 | Hello world 414 | """), encoding="utf8") 415 | 416 | # Render in tmpdir 417 | with tmpdir.as_cwd(): 418 | template_message = TemplateMessage(template_path) 419 | sender, recipients, message = template_message.render({}) 420 | 421 | # Verify sender and recipients 422 | assert sender == "from@test.com" 423 | assert recipients == ["to@test.com"] 424 | 425 | # Verify message is multipart and contains attachment 426 | assert message.is_multipart() 427 | attachments = extract_attachments(message) 428 | assert len(attachments) == 1 429 | 430 | # Verify attachment 431 | filename, content, _ = attachments[0] 432 | assert filename == "attachment.txt" 433 | assert content == b"Hello world\n" 434 | 435 | 436 | def test_attachment_relative(tmpdir): 437 | """Attachment with a relative file path is relative to template dir.""" 438 | # Simple attachment 439 | attachment_path = Path(tmpdir/"attachment.txt") 440 | attachment_path.write_text("Hello world\n", encoding="utf8") 441 | 442 | # Simple template 443 | template_path = Path(tmpdir/"template.txt") 444 | template_path.write_text(textwrap.dedent("""\ 445 | TO: to@test.com 446 | FROM: from@test.com 447 | ATTACHMENT: attachment.txt 448 | 449 | Hello world 450 | """), encoding="utf8") 451 | 452 | # Render 453 | template_message = TemplateMessage(template_path) 454 | _, _, message = template_message.render({}) 455 | 456 | # Verify directory used to render is different from template directory 457 | assert os.getcwd() != tmpdir 458 | 459 | # Verify attachment 460 | attachments = extract_attachments(message) 461 | filename, content, _ = attachments[0] 462 | assert filename == "attachment.txt" 463 | assert content == b"Hello world\n" 464 | 465 | 466 | def test_attachment_absolute(tmpdir): 467 | """Attachment with absolute file path.""" 468 | # Simple attachment lives in sub directory 469 | attachments_dir = tmpdir.mkdir("attachments") 470 | attachment_path = Path(attachments_dir/"attachment.txt") 471 | attachment_path.write_text("Hello world\n", encoding="utf8") 472 | 473 | # Simple template 474 | template_path = Path(tmpdir/"template.txt") 475 | template_path.write_text(textwrap.dedent(f"""\ 476 | TO: to@test.com 477 | FROM: from@test.com 478 | ATTACHMENT: {attachment_path} 479 | 480 | Hello world 481 | """), encoding="utf8") 482 | 483 | # Render in tmpdir 484 | with tmpdir.as_cwd(): 485 | template_message = TemplateMessage(template_path) 486 | _, _, message = template_message.render({}) 487 | 488 | # Verify attachment 489 | attachments = extract_attachments(message) 490 | filename, content, _ = attachments[0] 491 | assert filename == "attachment.txt" 492 | assert content == b"Hello world\n" 493 | 494 | 495 | def test_attachment_template(tmpdir): 496 | """Attachment with template as part of file path.""" 497 | # Simple attachment lives in sub directory 498 | attachments_dir = tmpdir.mkdir("attachments") 499 | attachment_path = Path(attachments_dir/"attachment.txt") 500 | attachment_path.write_text("Hello world\n", encoding="utf8") 501 | 502 | # Simple template 503 | template_path = Path(tmpdir/"template.txt") 504 | template_path.write_text(textwrap.dedent("""\ 505 | TO: to@test.com 506 | FROM: from@test.com 507 | ATTACHMENT: {{filename}} 508 | 509 | Hello world 510 | """), encoding="utf8") 511 | 512 | # Render in tmpdir 513 | with tmpdir.as_cwd(): 514 | template_message = TemplateMessage(template_path) 515 | _, _, message = template_message.render({ 516 | "filename": str(attachment_path), 517 | }) 518 | 519 | # Verify attachment 520 | attachments = extract_attachments(message) 521 | filename, content, _ = attachments[0] 522 | assert filename == "attachment.txt" 523 | assert content == b"Hello world\n" 524 | 525 | 526 | def test_attachment_not_found(tmpdir): 527 | """Attachment file not found.""" 528 | # Template specifying an attachment that doesn't exist 529 | template_path = Path(tmpdir/"template.txt") 530 | template_path.write_text(textwrap.dedent("""\ 531 | TO: to@test.com 532 | FROM: from@test.com 533 | ATTACHMENT: attachment.txt 534 | 535 | Hello world 536 | """), encoding="utf8") 537 | 538 | # Render in tmpdir, which lacks attachment.txt 539 | template_message = TemplateMessage(template_path) 540 | with pytest.raises(MailmergeError): 541 | with tmpdir.as_cwd(): 542 | template_message.render({}) 543 | 544 | 545 | def test_attachment_blank(tmpdir): 546 | """Attachment header without a filename is an error.""" 547 | template_path = Path(tmpdir/"template.txt") 548 | template_path.write_text(textwrap.dedent("""\ 549 | TO: to@test.com 550 | FROM: from@test.com 551 | ATTACHMENT: 552 | 553 | Hello world 554 | """), encoding="utf8") 555 | template_message = TemplateMessage(template_path) 556 | with pytest.raises(MailmergeError) as err: 557 | with tmpdir.as_cwd(): 558 | template_message.render({}) 559 | assert "Empty attachment header" in str(err) 560 | 561 | 562 | def test_attachment_tilde_path(tmpdir): 563 | """Attachment with home directory tilde notation file path.""" 564 | template_path = Path(tmpdir/"template.txt") 565 | template_path.write_text(textwrap.dedent("""\ 566 | TO: to@test.com 567 | FROM: from@test.com 568 | ATTACHMENT: ~/attachment.txt 569 | 570 | Hello world 571 | """), encoding="utf8") 572 | 573 | # Render will throw an error because we didn't create a file in the 574 | # user's home directory. We'll just check the filename. 575 | template_message = TemplateMessage(template_path) 576 | with pytest.raises(MailmergeError) as err: 577 | template_message.render({}) 578 | correct_path = Path.home() / "attachment.txt" 579 | assert str(correct_path) in str(err) 580 | 581 | 582 | def test_attachment_multiple(tmp_path): 583 | """Verify multiple attachments.""" 584 | # Copy attachments to tmp dir 585 | shutil.copy(str(utils.TESTDATA/"attachment_1.txt"), str(tmp_path)) 586 | shutil.copy(str(utils.TESTDATA/"attachment_2.pdf"), str(tmp_path)) 587 | shutil.copy(str(utils.TESTDATA/"attachment_17.txt"), str(tmp_path)) 588 | 589 | # Create template .txt file 590 | template_path = tmp_path / "template.txt" 591 | template_path.write_text(textwrap.dedent("""\ 592 | TO: {{email}} 593 | SUBJECT: Testing mailmerge 594 | FROM: My Self 595 | ATTACHMENT: attachment_1.txt 596 | ATTACHMENT: attachment_2.pdf 597 | ATTACHMENT: attachment_{{number}}.txt 598 | 599 | Hi, {{name}}, 600 | 601 | Your number is {{number}}. 602 | """), encoding="utf8") 603 | template_message = TemplateMessage(template_path) 604 | sender, recipients, message = template_message.render({ 605 | "email": "myself@mydomain.com", 606 | "name": "Myself", 607 | "number": 17, 608 | }) 609 | 610 | # Verify sender and recipients 611 | assert sender == "My Self " 612 | assert recipients == ["myself@mydomain.com"] 613 | 614 | # Verify message is multipart 615 | assert message.is_multipart() 616 | 617 | # Make sure the attachments are all present and valid 618 | email_body_present = False 619 | expected_attachments = { 620 | "attachment_1.txt": False, 621 | "attachment_2.pdf": False, 622 | "attachment_17.txt": False, 623 | } 624 | for part in message.walk(): 625 | if part.get_content_maintype() == 'multipart': 626 | continue 627 | if part['content-type'].startswith('text/plain'): 628 | # This is the email body 629 | email_body = part.get_payload() 630 | assert email_body.rstrip() == 'Hi, Myself,\n\nYour number is 17.' 631 | email_body_present = True 632 | elif part['content-type'].startswith('application/octet-stream'): 633 | # This is an attachment 634 | filename = part.get_param('name') 635 | file_contents = part.get_payload(decode=True) 636 | assert filename in expected_attachments 637 | assert not expected_attachments[filename] 638 | with (utils.TESTDATA/filename).open('rb') as expected_attachment: 639 | correct_file_contents = expected_attachment.read() 640 | assert file_contents == correct_file_contents 641 | expected_attachments[filename] = True 642 | assert email_body_present 643 | assert False not in expected_attachments.values() 644 | 645 | 646 | def test_attachment_empty(tmp_path): 647 | """Err on empty attachment field.""" 648 | template_path = tmp_path / "template.txt" 649 | template_path.write_text(textwrap.dedent("""\ 650 | TO: to@test.com 651 | SUBJECT: Testing mailmerge 652 | FROM: from@test.com 653 | ATTACHMENT: 654 | 655 | Hello world 656 | """), encoding="utf8") 657 | template_message = TemplateMessage(template_path) 658 | with pytest.raises(MailmergeError): 659 | template_message.render({}) 660 | 661 | 662 | def test_contenttype_attachment_html_body(tmpdir): 663 | """Content-type is preserved in HTML body.""" 664 | # Simple attachment 665 | attachment_path = Path(tmpdir/"attachment.txt") 666 | attachment_path.write_text("Hello world\n", encoding="utf8") 667 | 668 | # HTML template 669 | template_path = Path(tmpdir/"template.txt") 670 | template_path.write_text(textwrap.dedent("""\ 671 | TO: to@test.com 672 | FROM: from@test.com 673 | ATTACHMENT: attachment.txt 674 | CONTENT-TYPE: text/html 675 | 676 | Hello world 677 | """), encoding="utf8") 678 | 679 | # Render in tmpdir 680 | with tmpdir.as_cwd(): 681 | template_message = TemplateMessage(template_path) 682 | _, _, message = template_message.render({}) 683 | 684 | # Verify that the message content type is HTML 685 | payload = message.get_payload() 686 | assert len(payload) == 2 687 | assert payload[0].get_content_type() == 'text/html' 688 | 689 | 690 | def test_contenttype_attachment_markdown_body(tmpdir): 691 | """Content-type for MarkDown messages with attachments.""" 692 | # Simple attachment 693 | attachment_path = Path(tmpdir/"attachment.txt") 694 | attachment_path.write_text("Hello world\n", encoding="utf8") 695 | 696 | # HTML template 697 | template_path = Path(tmpdir/"template.txt") 698 | template_path.write_text(textwrap.dedent("""\ 699 | TO: to@test.com 700 | FROM: from@test.com 701 | ATTACHMENT: attachment.txt 702 | CONTENT-TYPE: text/markdown 703 | 704 | Hello **world** 705 | """), encoding="utf8") 706 | 707 | # Render in tmpdir 708 | with tmpdir.as_cwd(): 709 | template_message = TemplateMessage(template_path) 710 | _, _, message = template_message.render({}) 711 | 712 | payload = message.get_payload() 713 | assert len(payload) == 2 714 | 715 | # Markdown: Make sure there is a plaintext part and an HTML part 716 | message_payload = payload[0].get_payload() 717 | assert len(message_payload) == 2 718 | 719 | # Ensure that the first part is plaintext and the second part 720 | # is HTML (as per RFC 2046) 721 | plaintext_part = message_payload[0] 722 | assert plaintext_part['Content-Type'].startswith("text/plain") 723 | 724 | html_part = message_payload[1] 725 | assert html_part['Content-Type'].startswith("text/html") 726 | 727 | 728 | def test_duplicate_headers_attachment(tmp_path): 729 | """Verify multipart messages do not contain duplicate headers. 730 | 731 | Duplicate headers are rejected by some SMTP servers. 732 | """ 733 | # Simple attachment 734 | attachment_path = Path(tmp_path/"attachment.txt") 735 | attachment_path.write_text("Hello world\n", encoding="utf8") 736 | 737 | # Simple message 738 | template_path = tmp_path / "template.txt" 739 | template_path.write_text(textwrap.dedent("""\ 740 | TO: to@test.com 741 | SUBJECT: Testing mailmerge 742 | FROM: from@test.com> 743 | ATTACHMENT: attachment.txt 744 | 745 | {{message}} 746 | """), encoding="utf8") 747 | template_message = TemplateMessage(template_path) 748 | _, _, message = template_message.render({ 749 | "message": "Hello world" 750 | }) 751 | 752 | # Verifty no duplicate headers 753 | assert len(message.keys()) == len(set(message.keys())) 754 | 755 | 756 | def test_duplicate_headers_markdown(tmp_path): 757 | """Verify multipart messages do not contain duplicate headers. 758 | 759 | Duplicate headers are rejected by some SMTP servers. 760 | """ 761 | template_path = tmp_path / "template.txt" 762 | template_path.write_text(textwrap.dedent("""\ 763 | TO: to@test.com 764 | SUBJECT: Testing mailmerge 765 | FROM: from@test.com 766 | CONTENT-TYPE: text/markdown 767 | 768 | ``` 769 | Message as code block: {{message}} 770 | ``` 771 | """), encoding="utf8") 772 | template_message = TemplateMessage(template_path) 773 | _, _, message = template_message.render({ 774 | "message": "hello world", 775 | }) 776 | 777 | # Verifty no duplicate headers 778 | assert len(message.keys()) == len(set(message.keys())) 779 | 780 | 781 | def test_attachment_image_in_markdown(tmp_path): 782 | """Images sent as attachments should get linked correctly in images.""" 783 | shutil.copy(str(utils.TESTDATA/"attachment_3.jpg"), str(tmp_path)) 784 | 785 | # Create template .txt file 786 | template_path = tmp_path / "template.txt" 787 | template_path.write_text(textwrap.dedent("""\ 788 | TO: {{email}} 789 | SUBJECT: Testing mailmerge 790 | FROM: My Self 791 | ATTACHMENT: attachment_3.jpg 792 | CONTENT-TYPE: text/markdown 793 | 794 | ![](./attachment_3.jpg) 795 | """), encoding="utf8") 796 | template_message = TemplateMessage(template_path) 797 | sender, recipients, message = template_message.render({ 798 | "email": "myself@mydomain.com" 799 | }) 800 | 801 | # Verify sender and recipients 802 | assert sender == "My Self " 803 | assert recipients == ["myself@mydomain.com"] 804 | 805 | # Verify message is multipart 806 | assert message.is_multipart() 807 | 808 | # Make sure there is a message body and the attachment 809 | payload = message.get_payload() 810 | assert len(payload) == 2 811 | 812 | # Markdown: Make sure there is a plaintext part and an HTML part 813 | message_payload = payload[0].get_payload() 814 | assert len(message_payload) == 2 815 | 816 | plaintext = extract_text_from_markdown_payload(message_payload[0], 817 | 'text/plain') 818 | htmltext = extract_text_from_markdown_payload(message_payload[1], 819 | 'text/html') 820 | 821 | assert plaintext.strip() == "![](./attachment_3.jpg)" 822 | 823 | attachments = extract_attachments(message) 824 | assert len(attachments) == 1 825 | filename, content, cid = attachments[0] 826 | cid = cid[1:-1] 827 | assert filename == "attachment_3.jpg" 828 | assert len(content) == 697 829 | 830 | expected = html5lib.parse(textwrap.dedent(f"""\ 831 | 832 |

833 | 834 | """)) 835 | assert html_docs_equal(html5lib.parse(htmltext), expected) 836 | 837 | 838 | def test_content_id_header_for_attachments(tmpdir): 839 | """All attachments should get a content-id header.""" 840 | attachment_path = Path(tmpdir/"attachment.txt") 841 | attachment_path.write_text("Hello world\n", encoding="utf8") 842 | 843 | # Simple template 844 | template_path = Path(tmpdir/"template.txt") 845 | template_path.write_text(textwrap.dedent("""\ 846 | TO: to@test.com 847 | FROM: from@test.com 848 | ATTACHMENT: attachment.txt 849 | 850 | Hello world 851 | """), encoding="utf8") 852 | 853 | # Render in tmpdir 854 | with tmpdir.as_cwd(): 855 | template_message = TemplateMessage(template_path) 856 | _, _, message = template_message.render({}) 857 | 858 | # Verify message is multipart and contains attachment 859 | assert message.is_multipart() 860 | attachments = extract_attachments(message) 861 | assert len(attachments) == 1 862 | 863 | # Verify attachment 864 | filename, content, cid_header = attachments[0] 865 | assert filename == "attachment.txt" 866 | assert content == b"Hello world\n" 867 | assert re.match(r'<[\d\w]+(\.[\d\w]+)*@mailmerge\.invalid>', cid_header) 868 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | """ 2 | System tests. 3 | 4 | Andrew DeOrio 5 | 6 | pytest tmpdir docs: 7 | http://doc.pytest.org/en/latest/tmpdir.html#the-tmpdir-fixture 8 | """ 9 | import copy 10 | import shutil 11 | import re 12 | from pathlib import Path 13 | import textwrap 14 | import click.testing 15 | from mailmerge.__main__ import main 16 | from . import utils 17 | 18 | 19 | def test_no_options(tmpdir): 20 | """Verify help message when called with no options. 21 | 22 | Run mailmerge at the CLI with no options. Do this in an empty temporary 23 | directory to ensure that mailmerge doesn't find any default input files. 24 | """ 25 | runner = click.testing.CliRunner() 26 | with tmpdir.as_cwd(): 27 | result = runner.invoke(main, []) 28 | assert result.exit_code == 1 29 | assert 'Error: can\'t find template "mailmerge_template.txt"' in \ 30 | result.output 31 | assert "https://github.com/awdeorio/mailmerge" in result.output 32 | 33 | 34 | def test_sample(tmpdir): 35 | """Verify --sample creates sample input files.""" 36 | runner = click.testing.CliRunner() 37 | with tmpdir.as_cwd(): 38 | result = runner.invoke(main, ["--sample"]) 39 | assert not result.exception 40 | assert result.exit_code == 0 41 | assert Path(tmpdir/"mailmerge_template.txt").exists() 42 | assert Path(tmpdir/"mailmerge_database.csv").exists() 43 | assert Path(tmpdir/"mailmerge_server.conf").exists() 44 | assert "Created sample template" in result.output 45 | assert "Created sample database" in result.output 46 | assert "Created sample config" in result.output 47 | 48 | 49 | def test_sample_clobber_template(tmpdir): 50 | """Verify --sample won't clobber template if it already exists.""" 51 | runner = click.testing.CliRunner() 52 | with tmpdir.as_cwd(): 53 | Path("mailmerge_template.txt").touch() 54 | result = runner.invoke(main, ["--sample"]) 55 | assert result.exit_code == 1 56 | assert "Error: file exists: mailmerge_template.txt" in result.output 57 | 58 | 59 | def test_sample_clobber_database(tmpdir): 60 | """Verify --sample won't clobber database if it already exists.""" 61 | runner = click.testing.CliRunner() 62 | with tmpdir.as_cwd(): 63 | Path("mailmerge_database.csv").touch() 64 | result = runner.invoke(main, ["--sample"]) 65 | assert result.exit_code == 1 66 | assert "Error: file exists: mailmerge_database.csv" in result.output 67 | 68 | 69 | def test_sample_clobber_config(tmpdir): 70 | """Verify --sample won't clobber config if it already exists.""" 71 | runner = click.testing.CliRunner() 72 | with tmpdir.as_cwd(): 73 | Path("mailmerge_server.conf").touch() 74 | result = runner.invoke(main, ["--sample"]) 75 | assert result.exit_code == 1 76 | assert "Error: file exists: mailmerge_server.conf" in result.output 77 | 78 | 79 | def test_defaults(tmpdir): 80 | """When no options are provided, use default input file names.""" 81 | runner = click.testing.CliRunner() 82 | with tmpdir.as_cwd(): 83 | result = runner.invoke(main, ["--sample"]) 84 | assert not result.exception 85 | assert result.exit_code == 0 86 | with tmpdir.as_cwd(): 87 | result = runner.invoke(main, []) 88 | assert not result.exception 89 | assert result.exit_code == 0 90 | assert "message 1 sent" in result.output 91 | assert "Limit was 1 message" in result.output 92 | assert "This was a dry run" in result.output 93 | 94 | 95 | def test_bad_limit(tmpdir): 96 | """Verify --limit with bad value.""" 97 | # Simple template 98 | template_path = Path(tmpdir/"mailmerge_template.txt") 99 | template_path.write_text(textwrap.dedent("""\ 100 | TO: {{email}} 101 | FROM: from@test.com 102 | 103 | Hello world 104 | """), encoding="utf8") 105 | 106 | # Simple database with two entries 107 | database_path = Path(tmpdir/"mailmerge_database.csv") 108 | database_path.write_text(textwrap.dedent("""\ 109 | email 110 | one@test.com 111 | two@test.com 112 | """), encoding="utf8") 113 | 114 | # Simple unsecure server config 115 | config_path = Path(tmpdir/"mailmerge_server.conf") 116 | config_path.write_text(textwrap.dedent("""\ 117 | [smtp_server] 118 | host = open-smtp.example.com 119 | port = 25 120 | """), encoding="utf8") 121 | 122 | # Run mailmerge 123 | runner = click.testing.CliRunner() 124 | with tmpdir.as_cwd(): 125 | result = runner.invoke(main, ["--dry-run", "--limit", "-1"]) 126 | assert result.exit_code == 2 127 | assert "Error: Invalid value" in result.output 128 | 129 | 130 | def test_limit_combo(tmpdir): 131 | """Verify --limit 1 --no-limit results in no limit.""" 132 | # Simple template 133 | template_path = Path(tmpdir/"mailmerge_template.txt") 134 | template_path.write_text(textwrap.dedent("""\ 135 | TO: {{email}} 136 | FROM: from@test.com 137 | 138 | Hello world 139 | """), encoding="utf8") 140 | 141 | # Simple database with two entries 142 | database_path = Path(tmpdir/"mailmerge_database.csv") 143 | database_path.write_text(textwrap.dedent("""\ 144 | email 145 | one@test.com 146 | two@test.com 147 | """), encoding="utf8") 148 | 149 | # Simple unsecure server config 150 | config_path = Path(tmpdir/"mailmerge_server.conf") 151 | config_path.write_text(textwrap.dedent("""\ 152 | [smtp_server] 153 | host = open-smtp.example.com 154 | port = 25 155 | """), encoding="utf8") 156 | 157 | # Run mailmerge 158 | runner = click.testing.CliRunner() 159 | with tmpdir.as_cwd(): 160 | result = runner.invoke(main, ["--no-limit", "--limit", "1"]) 161 | assert not result.exception 162 | assert result.exit_code == 0 163 | assert "message 1 sent" in result.output 164 | assert "message 2 sent" in result.output 165 | assert "Limit was 1" not in result.output 166 | 167 | 168 | def test_template_not_found(tmpdir): 169 | """Verify error when template input file not found.""" 170 | runner = click.testing.CliRunner() 171 | with tmpdir.as_cwd(): 172 | result = runner.invoke(main, ["--template", "notfound.txt"]) 173 | assert result.exit_code == 1 174 | assert "Error: can't find template" in result.output 175 | 176 | 177 | def test_database_not_found(tmpdir): 178 | """Verify error when database input file not found.""" 179 | runner = click.testing.CliRunner() 180 | with tmpdir.as_cwd(): 181 | Path("mailmerge_template.txt").touch() 182 | result = runner.invoke(main, ["--database", "notfound.csv"]) 183 | assert result.exit_code == 1 184 | assert "Error: can't find database" in result.output 185 | 186 | 187 | def test_config_not_found(tmpdir): 188 | """Verify error when config input file not found.""" 189 | runner = click.testing.CliRunner() 190 | with tmpdir.as_cwd(): 191 | Path("mailmerge_template.txt").touch() 192 | Path("mailmerge_database.csv").touch() 193 | result = runner.invoke(main, ["--config", "notfound.conf"]) 194 | assert result.exit_code == 1 195 | assert "Error: can't find config" in result.output 196 | 197 | 198 | def test_help(): 199 | """Verify -h or --help produces a help message.""" 200 | runner = click.testing.CliRunner() 201 | result1 = runner.invoke(main, ["--help"]) 202 | assert result1.exit_code == 0 203 | assert "Usage:" in result1.stdout 204 | assert "Options:" in result1.stdout 205 | result2 = runner.invoke(main, ["-h"]) # Short option is an alias 206 | assert result1.stdout == result2.stdout 207 | 208 | 209 | def test_version(): 210 | """Verify --version produces a version.""" 211 | runner = click.testing.CliRunner() 212 | result = runner.invoke(main, ["--version"]) 213 | assert not result.exception 214 | assert result.exit_code == 0 215 | assert "version" in result.output 216 | 217 | 218 | def test_bad_template(tmpdir): 219 | """Template mismatch with database header should produce an error.""" 220 | # Template has a bad key 221 | template_path = Path(tmpdir/"mailmerge_template.txt") 222 | template_path.write_text(textwrap.dedent("""\ 223 | TO: {{error_not_in_database}} 224 | SUBJECT: Testing mailmerge 225 | FROM: from@test.com 226 | 227 | Hello world 228 | """), encoding="utf8") 229 | 230 | # Normal database 231 | database_path = Path(tmpdir/"mailmerge_database.csv") 232 | database_path.write_text(textwrap.dedent("""\ 233 | email 234 | to@test.com 235 | """), encoding="utf8") 236 | 237 | # Normal, unsecure server config 238 | config_path = Path(tmpdir/"mailmerge_server.conf") 239 | config_path.write_text(textwrap.dedent("""\ 240 | [smtp_server] 241 | host = open-smtp.example.com 242 | port = 25 243 | """), encoding="utf8") 244 | 245 | # Run mailmerge, which should exit 1 246 | runner = click.testing.CliRunner() 247 | with tmpdir.as_cwd(): 248 | result = runner.invoke(main, []) 249 | assert result.exit_code == 1 250 | 251 | # Verify output 252 | assert "template.txt: 'error_not_in_database' is undefined" in \ 253 | result.output 254 | 255 | 256 | def test_bad_database(tmpdir): 257 | """Database read error should produce a sane error.""" 258 | # Normal template 259 | template_path = Path(tmpdir/"mailmerge_template.txt") 260 | template_path.write_text(textwrap.dedent("""\ 261 | TO: to@test.com 262 | FROM: from@test.com 263 | 264 | {{message}} 265 | """), encoding="utf8") 266 | 267 | # Database with unmatched quote 268 | database_path = Path(tmpdir/"mailmerge_database.csv") 269 | database_path.write_text(textwrap.dedent("""\ 270 | message 271 | "hello world 272 | """), encoding="utf8") 273 | 274 | # Normal, unsecure server config 275 | config_path = Path(tmpdir/"mailmerge_server.conf") 276 | config_path.write_text(textwrap.dedent("""\ 277 | [smtp_server] 278 | host = open-smtp.example.com 279 | port = 25 280 | """), encoding="utf8") 281 | 282 | # Run mailmerge, which should exit 1 283 | runner = click.testing.CliRunner() 284 | with tmpdir.as_cwd(): 285 | result = runner.invoke(main, []) 286 | assert result.exit_code == 1 287 | 288 | # Verify output 289 | assert "database.csv:1: unexpected end of data" in result.output 290 | 291 | 292 | def test_bad_config(tmpdir): 293 | """Config containing an error should produce an error.""" 294 | # Normal template 295 | template_path = Path(tmpdir/"mailmerge_template.txt") 296 | template_path.write_text(textwrap.dedent("""\ 297 | TO: to@test.com 298 | FROM: from@test.com 299 | """), encoding="utf8") 300 | 301 | # Normal database 302 | database_path = Path(tmpdir/"mailmerge_database.csv") 303 | database_path.write_text(textwrap.dedent("""\ 304 | dummy 305 | asdf 306 | """), encoding="utf8") 307 | 308 | # Server config is missing host 309 | config_path = Path(tmpdir/"mailmerge_server.conf") 310 | config_path.write_text(textwrap.dedent("""\ 311 | [smtp_server] 312 | port = 25 313 | """), encoding="utf8") 314 | 315 | # Run mailmerge, which should exit 1 316 | runner = click.testing.CliRunner() 317 | with tmpdir.as_cwd(): 318 | result = runner.invoke(main, []) 319 | assert result.exit_code == 1 320 | 321 | # Verify output 322 | assert "server.conf: No option 'host' in section: 'smtp_server'" in \ 323 | result.output 324 | 325 | 326 | def test_attachment(tmpdir): 327 | """Verify attachments feature output.""" 328 | # First attachment 329 | attachment1_path = Path(tmpdir/"attachment1.txt") 330 | attachment1_path.write_text("Hello world\n", encoding="utf8") 331 | 332 | # Second attachment 333 | attachment2_path = Path(tmpdir/"attachment2.txt") 334 | attachment2_path.write_text("Hello mailmerge\n", encoding="utf8") 335 | 336 | # Template with attachment header 337 | template_path = Path(tmpdir/"mailmerge_template.txt") 338 | template_path.write_text(textwrap.dedent("""\ 339 | TO: {{email}} 340 | FROM: from@test.com 341 | ATTACHMENT: attachment1.txt 342 | ATTACHMENT: attachment2.txt 343 | 344 | Hello world 345 | """), encoding="utf8") 346 | 347 | # Simple database 348 | database_path = Path(tmpdir/"mailmerge_database.csv") 349 | database_path.write_text(textwrap.dedent("""\ 350 | email 351 | to@test.com 352 | """), encoding="utf8") 353 | 354 | # Simple unsecure server config 355 | config_path = Path(tmpdir/"mailmerge_server.conf") 356 | config_path.write_text(textwrap.dedent("""\ 357 | [smtp_server] 358 | host = open-smtp.example.com 359 | port = 25 360 | """), encoding="utf8") 361 | 362 | # Run mailmerge 363 | runner = click.testing.CliRunner() 364 | with tmpdir.as_cwd(): 365 | result = runner.invoke(main, ["--output-format", "text"]) 366 | assert not result.exception 367 | assert result.exit_code == 0 368 | 369 | # Verify output 370 | assert ">>> message part: text/plain" in result.output 371 | assert "Hello world" in result.output # message 372 | assert ">>> message part: attachment attachment1.txt" in result.output 373 | assert ">>> message part: attachment attachment2.txt" in result.output 374 | 375 | 376 | def test_utf8_template(tmpdir): 377 | """Message is utf-8 encoded when only the template contains utf-8 chars.""" 378 | # Template with UTF-8 characters and emoji 379 | template_path = Path(tmpdir/"mailmerge_template.txt") 380 | template_path.write_text(textwrap.dedent("""\ 381 | TO: {{email}} 382 | FROM: from@test.com 383 | 384 | Laȝamon 😀 klâwen 385 | """), encoding="utf8") 386 | 387 | # Simple database without utf-8 characters 388 | database_path = Path(tmpdir/"mailmerge_database.csv") 389 | database_path.write_text(textwrap.dedent("""\ 390 | email 391 | to@test.com 392 | """), encoding="utf8") 393 | 394 | # Simple unsecure server config 395 | config_path = Path(tmpdir/"mailmerge_server.conf") 396 | config_path.write_text(textwrap.dedent("""\ 397 | [smtp_server] 398 | host = open-smtp.example.com 399 | port = 25 400 | """), encoding="utf8") 401 | 402 | # Run mailmerge 403 | runner = click.testing.CliRunner() 404 | result = runner.invoke(main, [ 405 | "--template", template_path, 406 | "--database", database_path, 407 | "--config", config_path, 408 | "--dry-run", 409 | "--output-format", "text", 410 | ]) 411 | assert not result.exception 412 | assert result.exit_code == 0 413 | 414 | # Remove the Date string, which will be different each time 415 | stdout = copy.deepcopy(result.output) 416 | stdout = re.sub(r"Date:.+", "Date: REDACTED", stdout, re.MULTILINE) 417 | 418 | # Verify output 419 | assert stdout == textwrap.dedent("""\ 420 | >>> message 1 421 | TO: to@test.com 422 | FROM: from@test.com 423 | MIME-Version: 1.0 424 | Content-Type: text/plain; charset="utf-8" 425 | Content-Transfer-Encoding: base64 426 | Date: REDACTED 427 | 428 | Laȝamon 😀 klâwen 429 | 430 | >>> message 1 sent 431 | >>> Limit was 1 message. To remove the limit, use the --no-limit option. 432 | >>> This was a dry run. To send messages, use the --no-dry-run option. 433 | """) # noqa: E501 434 | 435 | 436 | def test_utf8_database(tmpdir): 437 | """Message is utf-8 encoded when only the databse contains utf-8 chars.""" 438 | # Simple template without UTF-8 characters 439 | template_path = Path(tmpdir/"mailmerge_template.txt") 440 | template_path.write_text(textwrap.dedent("""\ 441 | TO: to@test.com 442 | FROM: from@test.com 443 | 444 | {{message}} 445 | """), encoding="utf8") 446 | 447 | # Database with utf-8 characters and emoji 448 | database_path = Path(tmpdir/"mailmerge_database.csv") 449 | database_path.write_text(textwrap.dedent("""\ 450 | message 451 | Laȝamon 😀 klâwen 452 | """), encoding="utf8") 453 | 454 | # Simple unsecure server config 455 | config_path = Path(tmpdir/"mailmerge_server.conf") 456 | config_path.write_text(textwrap.dedent("""\ 457 | [smtp_server] 458 | host = open-smtp.example.com 459 | port = 25 460 | """), encoding="utf8") 461 | 462 | # Run mailmerge 463 | runner = click.testing.CliRunner() 464 | with tmpdir.as_cwd(): 465 | result = runner.invoke(main, ["--output-format", "text"]) 466 | assert not result.exception 467 | assert result.exit_code == 0 468 | 469 | # Remove the Date string, which will be different each time 470 | stdout = copy.deepcopy(result.output) 471 | stdout = re.sub(r"Date:.+", "Date: REDACTED", stdout, re.MULTILINE) 472 | 473 | # Verify output 474 | assert stdout == textwrap.dedent("""\ 475 | >>> message 1 476 | TO: to@test.com 477 | FROM: from@test.com 478 | MIME-Version: 1.0 479 | Content-Type: text/plain; charset="utf-8" 480 | Content-Transfer-Encoding: base64 481 | Date: REDACTED 482 | 483 | Laȝamon 😀 klâwen 484 | 485 | >>> message 1 sent 486 | >>> Limit was 1 message. To remove the limit, use the --no-limit option. 487 | >>> This was a dry run. To send messages, use the --no-dry-run option. 488 | """) # noqa: E501 489 | 490 | 491 | def test_utf8_headers(tmpdir): 492 | """Message is utf-8 encoded when headers contain utf-8 chars.""" 493 | # Template with UTF-8 characters and emoji in headers 494 | template_path = Path(tmpdir/"mailmerge_template.txt") 495 | template_path.write_text(textwrap.dedent("""\ 496 | TO: Laȝamon 497 | FROM: klâwen 498 | SUBJECT: Laȝamon 😀 klâwen 499 | 500 | {{message}} 501 | """), encoding="utf8") 502 | 503 | # Simple database without utf-8 characters 504 | database_path = Path(tmpdir/"mailmerge_database.csv") 505 | database_path.write_text(textwrap.dedent("""\ 506 | message 507 | hello 508 | """), encoding="utf8") 509 | 510 | # Simple unsecure server config 511 | config_path = Path(tmpdir/"mailmerge_server.conf") 512 | config_path.write_text(textwrap.dedent("""\ 513 | [smtp_server] 514 | host = open-smtp.example.com 515 | port = 25 516 | """), encoding="utf8") 517 | 518 | # Run mailmerge 519 | runner = click.testing.CliRunner() 520 | with tmpdir.as_cwd(): 521 | result = runner.invoke(main, [ 522 | "--template", template_path, 523 | "--database", database_path, 524 | "--config", config_path, 525 | "--dry-run", 526 | "--output-format", "raw", 527 | ]) 528 | assert not result.exception 529 | assert result.exit_code == 0 530 | 531 | # Remove the Date string, which will be different each time 532 | stdout = copy.deepcopy(result.output) 533 | stdout = re.sub(r"Date:.+", "Date: REDACTED", stdout, re.MULTILINE) 534 | 535 | # Verify output 536 | assert stdout == textwrap.dedent("""\ 537 | >>> message 1 538 | TO: =?utf-8?b?TGHInWFtb24gPHRvQHRlc3QuY29tPg==?= 539 | FROM: =?utf-8?b?a2zDondlbiA8ZnJvbUB0ZXN0LmNvbT4=?= 540 | SUBJECT: =?utf-8?b?TGHInWFtb24g8J+YgCBrbMOid2Vu?= 541 | MIME-Version: 1.0 542 | Content-Type: text/plain; charset="utf-8" 543 | Content-Transfer-Encoding: base64 544 | Date: REDACTED 545 | 546 | aGVsbG8= 547 | 548 | >>> message 1 sent 549 | >>> Limit was 1 message. To remove the limit, use the --no-limit option. 550 | >>> This was a dry run. To send messages, use the --no-dry-run option. 551 | """) # noqa: E501 552 | 553 | 554 | def test_resume(tmpdir): 555 | """Verify --resume option starts "in the middle" of the database.""" 556 | # Simple template 557 | template_path = Path(tmpdir/"mailmerge_template.txt") 558 | template_path.write_text(textwrap.dedent("""\ 559 | TO: to@test.com 560 | FROM: from@test.com 561 | 562 | {{message}} 563 | """), encoding="utf8") 564 | 565 | # Database with two entries 566 | database_path = Path(tmpdir/"mailmerge_database.csv") 567 | database_path.write_text(textwrap.dedent("""\ 568 | message 569 | hello 570 | world 571 | """), encoding="utf8") 572 | 573 | # Simple unsecure server config 574 | config_path = Path(tmpdir/"mailmerge_server.conf") 575 | config_path.write_text(textwrap.dedent("""\ 576 | [smtp_server] 577 | host = open-smtp.example.com 578 | port = 25 579 | """), encoding="utf8") 580 | 581 | # Run mailmerge 582 | runner = click.testing.CliRunner() 583 | with tmpdir.as_cwd(): 584 | result = runner.invoke(main, ["--resume", "2", "--no-limit"]) 585 | assert not result.exception 586 | assert result.exit_code == 0 587 | 588 | # Verify only second message was sent 589 | assert "hello" not in result.output 590 | assert "message 1 sent" not in result.output 591 | assert "world" in result.output 592 | assert "message 2 sent" in result.output 593 | 594 | 595 | def test_resume_too_small(tmpdir): 596 | """Verify --resume <= 0 prints an error message.""" 597 | # Simple template 598 | template_path = Path(tmpdir/"mailmerge_template.txt") 599 | template_path.write_text(textwrap.dedent("""\ 600 | TO: to@test.com 601 | FROM: from@test.com 602 | 603 | {{message}} 604 | """), encoding="utf8") 605 | 606 | # Database with two entries 607 | database_path = Path(tmpdir/"mailmerge_database.csv") 608 | database_path.write_text(textwrap.dedent("""\ 609 | message 610 | hello 611 | world 612 | """), encoding="utf8") 613 | 614 | # Simple unsecure server config 615 | config_path = Path(tmpdir/"mailmerge_server.conf") 616 | config_path.write_text(textwrap.dedent("""\ 617 | [smtp_server] 618 | host = open-smtp.example.com 619 | port = 25 620 | """), encoding="utf8") 621 | 622 | # Run "mailmerge --resume 0" and check output 623 | runner = click.testing.CliRunner() 624 | with tmpdir.as_cwd(): 625 | result = runner.invoke(main, ["--resume", "0"]) 626 | assert result.exit_code == 2 627 | assert "Invalid value" in result.output 628 | 629 | # Run "mailmerge --resume -1" and check output 630 | with tmpdir.as_cwd(): 631 | result = runner.invoke(main, ["--resume", "-1"]) 632 | assert result.exit_code == 2 633 | assert "Invalid value" in result.output 634 | 635 | 636 | def test_resume_too_big(tmpdir): 637 | """Verify --resume > database does nothing.""" 638 | # Simple template 639 | template_path = Path(tmpdir/"mailmerge_template.txt") 640 | template_path.write_text(textwrap.dedent("""\ 641 | TO: to@test.com 642 | FROM: from@test.com 643 | 644 | {{message}} 645 | """), encoding="utf8") 646 | 647 | # Database with two entries 648 | database_path = Path(tmpdir/"mailmerge_database.csv") 649 | database_path.write_text(textwrap.dedent("""\ 650 | message 651 | hello 652 | world 653 | """), encoding="utf8") 654 | 655 | # Simple unsecure server config 656 | config_path = Path(tmpdir/"mailmerge_server.conf") 657 | config_path.write_text(textwrap.dedent("""\ 658 | [smtp_server] 659 | host = open-smtp.example.com 660 | port = 25 661 | """), encoding="utf8") 662 | 663 | # Run and check output 664 | runner = click.testing.CliRunner() 665 | with tmpdir.as_cwd(): 666 | result = runner.invoke(main, ["--resume", "3", "--no-limit"]) 667 | assert not result.exception 668 | assert result.exit_code == 0 669 | assert "sent message" not in result.output 670 | 671 | 672 | def test_resume_hint_on_config_error(tmpdir): 673 | """Verify *no* --resume hint when error is after first message.""" 674 | # Simple template 675 | template_path = Path(tmpdir/"mailmerge_template.txt") 676 | template_path.write_text(textwrap.dedent("""\ 677 | TO: to@test.com 678 | FROM: from@test.com 679 | 680 | {{message}} 681 | """), encoding="utf8") 682 | 683 | # Database with error on second entry 684 | database_path = Path(tmpdir/"mailmerge_database.csv") 685 | database_path.write_text(textwrap.dedent("""\ 686 | message 687 | hello 688 | "world 689 | """), encoding="utf8") 690 | 691 | # Server config missing port 692 | config_path = Path(tmpdir/"mailmerge_server.conf") 693 | config_path.write_text(textwrap.dedent("""\ 694 | [smtp_server] 695 | host = open-smtp.example.com 696 | """), encoding="utf8") 697 | 698 | # Run and check output 699 | runner = click.testing.CliRunner() 700 | with tmpdir.as_cwd(): 701 | result = runner.invoke(main, []) 702 | assert result.exit_code == 1 703 | assert "--resume 1" not in result.output 704 | 705 | 706 | def test_resume_hint_on_csv_error(tmpdir): 707 | """Verify --resume hint after CSV error.""" 708 | # Simple template 709 | template_path = Path(tmpdir/"mailmerge_template.txt") 710 | template_path.write_text(textwrap.dedent("""\ 711 | TO: to@test.com 712 | FROM: from@test.com 713 | 714 | {{message}} 715 | """), encoding="utf8") 716 | 717 | # Database with unmatched quote on second entry 718 | database_path = Path(tmpdir/"mailmerge_database.csv") 719 | database_path.write_text(textwrap.dedent("""\ 720 | message 721 | hello 722 | "world 723 | """), encoding="utf8") 724 | 725 | # Simple unsecure server config 726 | config_path = Path(tmpdir/"mailmerge_server.conf") 727 | config_path.write_text(textwrap.dedent("""\ 728 | [smtp_server] 729 | host = open-smtp.example.com 730 | port = 25 731 | """), encoding="utf8") 732 | 733 | # Run and check output 734 | runner = click.testing.CliRunner() 735 | with tmpdir.as_cwd(): 736 | result = runner.invoke(main, ["--resume", "2", "--no-limit"]) 737 | assert result.exit_code == 1 738 | assert "--resume 2" in result.output 739 | 740 | 741 | def test_other_mime_type(tmpdir): 742 | """Verify output with a MIME type that's not text or an attachment.""" 743 | # Template containing a pdf 744 | template_path = Path(tmpdir/"mailmerge_template.txt") 745 | template_path.write_text(textwrap.dedent("""\ 746 | TO: {{email}} 747 | FROM: from@test.com 748 | MIME-Version: 1.0 749 | Content-Type: multipart/alternative; boundary="boundary" 750 | 751 | --boundary 752 | Content-Type: text/plain; charset=us-ascii 753 | 754 | Hello world 755 | 756 | --boundary 757 | Content-Type: application/pdf 758 | 759 | DUMMY 760 | """), encoding="utf8") 761 | 762 | # Simple database with two entries 763 | database_path = Path(tmpdir/"mailmerge_database.csv") 764 | database_path.write_text(textwrap.dedent("""\ 765 | email 766 | one@test.com 767 | """), encoding="utf8") 768 | 769 | # Simple unsecure server config 770 | config_path = Path(tmpdir/"mailmerge_server.conf") 771 | config_path.write_text(textwrap.dedent("""\ 772 | [smtp_server] 773 | host = open-smtp.example.com 774 | port = 25 775 | """), encoding="utf8") 776 | 777 | # Run mailmerge 778 | runner = click.testing.CliRunner() 779 | with tmpdir.as_cwd(): 780 | result = runner.invoke(main, []) 781 | assert not result.exception 782 | assert result.exit_code == 0 783 | 784 | # Verify output 785 | stdout = copy.deepcopy(result.output) 786 | stdout = re.sub(r"Date:.+", "Date: REDACTED", stdout, re.MULTILINE) 787 | assert stdout == textwrap.dedent("""\ 788 | \x1b[7m\x1b[1m\x1b[36m>>> message 1\x1b(B\x1b[m 789 | TO: one@test.com 790 | FROM: from@test.com 791 | MIME-Version: 1.0 792 | Content-Type: multipart/alternative; boundary="boundary" 793 | Date: REDACTED 794 | 795 | \x1b[36m>>> message part: text/plain\x1b(B\x1b[m 796 | Hello world 797 | 798 | 799 | \x1b[36m>>> message part: application/pdf\x1b(B\x1b[m 800 | \x1b[7m\x1b[1m\x1b[36m>>> message 1 sent\x1b(B\x1b[m 801 | >>> Limit was 1 message. To remove the limit, use the --no-limit option. 802 | >>> This was a dry run. To send messages, use the --no-dry-run option. 803 | """) # noqa: E501 804 | 805 | 806 | def test_database_bom(tmpdir): 807 | """Bug fix CSV with a byte order mark (BOM). 808 | 809 | It looks like Excel will sometimes save a file with Byte Order Mark 810 | (BOM). When the mailmerge database contains a BOM, it can't seem to find 811 | the first header key. 812 | https://github.com/awdeorio/mailmerge/issues/93 813 | 814 | """ 815 | # Simple template 816 | template_path = Path(tmpdir/"mailmerge_template.txt") 817 | template_path.write_text(textwrap.dedent("""\ 818 | TO: {{email}} 819 | FROM: My Self 820 | 821 | Hello {{name}} 822 | """), encoding="utf8") 823 | 824 | # Copy database containing a BOM 825 | database_path = Path(tmpdir/"mailmerge_database.csv") 826 | database_with_bom = utils.TESTDATA/"mailmerge_database_with_BOM.csv" 827 | shutil.copyfile(database_with_bom, database_path) 828 | 829 | # Simple unsecure server config 830 | config_path = Path(tmpdir/"mailmerge_server.conf") 831 | config_path.write_text(textwrap.dedent("""\ 832 | [smtp_server] 833 | host = open-smtp.example.com 834 | port = 25 835 | """), encoding="utf8") 836 | 837 | # Run mailmerge 838 | runner = click.testing.CliRunner() 839 | with tmpdir.as_cwd(): 840 | result = runner.invoke(main, ["--output-format", "text"]) 841 | assert not result.exception 842 | assert result.exit_code == 0 843 | 844 | # Verify output 845 | stdout = copy.deepcopy(result.output) 846 | stdout = re.sub(r"Date:.+", "Date: REDACTED", stdout, re.MULTILINE) 847 | assert stdout == textwrap.dedent("""\ 848 | >>> message 1 849 | TO: to@test.com 850 | FROM: My Self 851 | MIME-Version: 1.0 852 | Content-Type: text/plain; charset="us-ascii" 853 | Content-Transfer-Encoding: 7bit 854 | Date: REDACTED 855 | 856 | Hello My Name 857 | 858 | >>> message 1 sent 859 | >>> Limit was 1 message. To remove the limit, use the --no-limit option. 860 | >>> This was a dry run. To send messages, use the --no-dry-run option. 861 | """) # noqa: E501 862 | 863 | 864 | def test_database_tsv(tmpdir): 865 | """Automatically detect TSV database format.""" 866 | # Simple template 867 | template_path = Path(tmpdir/"mailmerge_template.txt") 868 | template_path.write_text(textwrap.dedent("""\ 869 | TO: {{email}} 870 | FROM: My Self 871 | 872 | Hello {{name}} 873 | """), encoding="utf8") 874 | 875 | # Tab-separated format database 876 | database_path = Path(tmpdir/"mailmerge_database.csv") 877 | database_path.write_text(textwrap.dedent("""\ 878 | email\tname 879 | to@test.com\tMy Name 880 | """), encoding="utf8") 881 | 882 | # Simple unsecure server config 883 | config_path = Path(tmpdir/"mailmerge_server.conf") 884 | config_path.write_text(textwrap.dedent("""\ 885 | [smtp_server] 886 | host = open-smtp.example.com 887 | port = 25 888 | """), encoding="utf8") 889 | 890 | # Run mailmerge 891 | runner = click.testing.CliRunner() 892 | with tmpdir.as_cwd(): 893 | result = runner.invoke(main, ["--output-format", "text"]) 894 | assert not result.exception 895 | assert result.exit_code == 0 896 | 897 | # Verify output 898 | stdout = copy.deepcopy(result.output) 899 | stdout = re.sub(r"Date:.+", "Date: REDACTED", stdout, re.MULTILINE) 900 | assert stdout == textwrap.dedent("""\ 901 | >>> message 1 902 | TO: to@test.com 903 | FROM: My Self 904 | MIME-Version: 1.0 905 | Content-Type: text/plain; charset="us-ascii" 906 | Content-Transfer-Encoding: 7bit 907 | Date: REDACTED 908 | 909 | Hello My Name 910 | 911 | >>> message 1 sent 912 | >>> Limit was 1 message. To remove the limit, use the --no-limit option. 913 | >>> This was a dry run. To send messages, use the --no-dry-run option. 914 | """) # noqa: E501 915 | 916 | 917 | def test_database_semicolon(tmpdir): 918 | """Automatically detect semicolon-delimited database format.""" 919 | # Simple template 920 | template_path = Path(tmpdir/"mailmerge_template.txt") 921 | template_path.write_text(textwrap.dedent("""\ 922 | TO: {{email}} 923 | FROM: My Self 924 | 925 | Hello {{name}} 926 | """), encoding="utf8") 927 | 928 | # Semicolon-separated format database 929 | database_path = Path(tmpdir/"mailmerge_database.csv") 930 | database_path.write_text(textwrap.dedent("""\ 931 | email;name 932 | to@test.com;My Name 933 | """), encoding="utf8") 934 | 935 | # Simple unsecure server config 936 | config_path = Path(tmpdir/"mailmerge_server.conf") 937 | config_path.write_text(textwrap.dedent("""\ 938 | [smtp_server] 939 | host = open-smtp.example.com 940 | port = 25 941 | """), encoding="utf8") 942 | 943 | # Run mailmerge 944 | runner = click.testing.CliRunner() 945 | with tmpdir.as_cwd(): 946 | result = runner.invoke(main, ["--output-format", "text"]) 947 | assert not result.exception 948 | assert result.exit_code == 0 949 | 950 | # Verify output 951 | stdout = copy.deepcopy(result.output) 952 | stdout = re.sub(r"Date:.+", "Date: REDACTED", stdout, re.MULTILINE) 953 | assert stdout == textwrap.dedent("""\ 954 | >>> message 1 955 | TO: to@test.com 956 | FROM: My Self 957 | MIME-Version: 1.0 958 | Content-Type: text/plain; charset="us-ascii" 959 | Content-Transfer-Encoding: 7bit 960 | Date: REDACTED 961 | 962 | Hello My Name 963 | 964 | >>> message 1 sent 965 | >>> Limit was 1 message. To remove the limit, use the --no-limit option. 966 | >>> This was a dry run. To send messages, use the --no-dry-run option. 967 | """) # noqa: E501 968 | --------------------------------------------------------------------------------