From dc0183201023631f4c6b637065f5f837f28373ba Mon Sep 17 00:00:00 2001 From: SZRAJBER Piotr Date: Mon, 24 Jan 2022 08:44:24 +0100 Subject: [PATCH 01/23] added PLAIN security mode --- mailmerge/sendmail_client.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/mailmerge/sendmail_client.py b/mailmerge/sendmail_client.py index 0ec5205..69f7a83 100644 --- a/mailmerge/sendmail_client.py +++ b/mailmerge/sendmail_client.py @@ -49,7 +49,7 @@ def read_config(self): security = None # Verify security type - if security not in [None, "SSL/TLS", "STARTTLS"]: + if security not in [None, "SSL/TLS", "STARTTLS", "PLAIN"]: raise exceptions.MailmergeError( f"{self.config_path}: unrecognized security type: '{security}'" ) @@ -100,6 +100,10 @@ def sendmail(self, sender, recipients, message): smtp.ehlo() smtp.login(self.config.username, self.password) smtp.sendmail(sender, recipients, message_flattened) + elif self.config.security == "PLAIN": + with smtplib.SMTP(host, port) as smtp: + smtp.login(self.config.username, self.password) + smtp.sendmail(sender, recipients, message_flattened) elif self.config.security is None: with smtplib.SMTP(host, port) as smtp: smtp.sendmail(sender, recipients, message_flattened) From 4729ef0bec3ce7b82c70f2ad8eab006228305893 Mon Sep 17 00:00:00 2001 From: Andrew DeOrio Date: Mon, 24 Jan 2022 08:41:10 -0500 Subject: [PATCH 02/23] Fix tox config for pydocstyle --- tox.ini | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index fdb21ed..34bd160 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ # Local host configuration with one Python 3 version [tox] -envlist = py36, py37, py38, py39 +envlist = py36, py37, py38, py39, py310 # GitHub Actions configuration with multiple Python versions # https://github.com/ymyzk/tox-gh-actions#tox-gh-actions-configuration @@ -10,15 +10,18 @@ python = 3.7: py37 3.8: py38 3.9: py39 + 3.10: py310 # Run unit tests +# HACK: Pydocstyle fails to find tests. Invoke a shell to use a glob. [testenv] setenv = PYTHONPATH = {toxinidir} +allowlist_externals = sh extras = test commands = pycodestyle mailmerge tests setup.py - pydocstyle mailmerge tests setup.py + sh -c "pydocstyle mailmerge tests/* setup.py" pylint mailmerge tests setup.py check-manifest pytest -vvs --cov mailmerge From 15892040dea9d253d69bc5e508dc45974755329a Mon Sep 17 00:00:00 2001 From: Andrew DeOrio Date: Mon, 24 Jan 2022 08:47:50 -0500 Subject: [PATCH 03/23] lint --- tests/test_helpers.py | 6 +++--- tests/test_template_message.py | 14 ++++---------- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 39207d2..7e4dceb 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -31,7 +31,7 @@ def test_enumerate_range_stop_value(): def test_enumerate_range_stop_zero(): """Verify stop=0.""" output = list(enumerate_range(["a", "b", "c"], stop=0)) - assert output == [] + assert not output def test_enumerate_range_stop_too_big(): @@ -61,13 +61,13 @@ def test_enumerate_range_start_last_one(): def test_enumerate_range_start_length(): """Verify start=length.""" output = list(enumerate_range(["a", "b", "c"], start=3)) - assert output == [] + assert not output def test_enumerate_range_start_too_big(): """Verify start past the end.""" output = list(enumerate_range(["a", "b", "c"], start=10)) - assert output == [] + assert not output def test_enumerate_range_start_stop(): diff --git a/tests/test_template_message.py b/tests/test_template_message.py index 18471b4..3124cf6 100644 --- a/tests/test_template_message.py +++ b/tests/test_template_message.py @@ -660,10 +660,7 @@ def test_attachment_empty(tmp_path): def test_contenttype_attachment_html_body(tmpdir): - """ - Verify that the content-type of the message is correctly retained with an - HTML body. - """ + """Content-type is preserved in HTML body.""" # Simple attachment attachment_path = Path(tmpdir/"attachment.txt") attachment_path.write_text("Hello world\n", encoding="utf8") @@ -691,10 +688,7 @@ def test_contenttype_attachment_html_body(tmpdir): def test_contenttype_attachment_markdown_body(tmpdir): - """ - Verify that the content-types of the MarkDown message are correct when - attachments are included. - """ + """Content-type for MarkDown messages with attachments.""" # Simple attachment attachment_path = Path(tmpdir/"attachment.txt") attachment_path.write_text("Hello world\n", encoding="utf8") @@ -785,7 +779,7 @@ def test_duplicate_headers_markdown(tmp_path): def test_attachment_image_in_markdown(tmp_path): - """Images sent as attachments should get linked correctly in images""" + """Images sent as attachments should get linked correctly in images.""" shutil.copy(str(utils.TESTDATA/"attachment_3.jpg"), str(tmp_path)) # Create template .txt file @@ -842,7 +836,7 @@ def test_attachment_image_in_markdown(tmp_path): def test_content_id_header_for_attachments(tmpdir): - """All attachments should get a content-id header""" + """All attachments should get a content-id header.""" attachment_path = Path(tmpdir/"attachment.txt") attachment_path.write_text("Hello world\n", encoding="utf8") From b4f8e72c9bff0465a41b0fc2d73a1ab7749d90a4 Mon Sep 17 00:00:00 2001 From: Andrew DeOrio Date: Mon, 24 Jan 2022 10:11:48 -0500 Subject: [PATCH 04/23] bump codecov GH Action version --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 551dad9..7b73df3 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -55,6 +55,6 @@ jobs: # Upload coverage report # https://github.com/codecov/codecov-action - name: Upload coverage report - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v2 with: fail_ci_if_error: true From e732c8badb58a732fcd40e1fce8f5791a0b2929b Mon Sep 17 00:00:00 2001 From: Phil Date: Mon, 23 May 2022 15:35:58 +0200 Subject: [PATCH 05/23] Change mimetype to support inline images in thunderbird Change the content type from `multipart/mixed` to `multipart/related` so that thunderbird will correctly recognize inline images. Closes #142 --- mailmerge/template_message.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mailmerge/template_message.py b/mailmerge/template_message.py index 936041c..c241331 100644 --- a/mailmerge/template_message.py +++ b/mailmerge/template_message.py @@ -95,7 +95,7 @@ def _make_message_multipart(self): return # Create empty multipart message - multipart_message = email.mime.multipart.MIMEMultipart('mixed') + multipart_message = email.mime.multipart.MIMEMultipart('related') # Copy headers. Avoid duplicate Content-Type and MIME-Version headers, # which we set explicitely. MIME-Version was set when we created an From 6ce53de08e8768ed33948739fb6e5f811787e19e Mon Sep 17 00:00:00 2001 From: Phil Date: Wed, 25 May 2022 19:49:38 +0200 Subject: [PATCH 06/23] Fix testcase for multipar/related message type --- tests/test_template_message.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_template_message.py b/tests/test_template_message.py index 3124cf6..cd12a54 100644 --- a/tests/test_template_message.py +++ b/tests/test_template_message.py @@ -300,7 +300,7 @@ def test_markdown(tmp_path): # Verify message is multipart assert message.is_multipart() - assert message.get_content_subtype() == "mixed" + assert message.get_content_subtype() == "related" # Make sure there is a single multipart/alternative payload assert len(message.get_payload()) == 1 From 64681ced502ceef80deb3fe796cf39c20abba1e5 Mon Sep 17 00:00:00 2001 From: Phil Date: Wed, 25 May 2022 20:58:09 +0200 Subject: [PATCH 07/23] Update comments to match new content type `related` --- mailmerge/template_message.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/mailmerge/template_message.py b/mailmerge/template_message.py index c241331..aa92489 100644 --- a/mailmerge/template_message.py +++ b/mailmerge/template_message.py @@ -87,7 +87,7 @@ def _make_message_multipart(self): Convert self._message into a multipart message. Specifically, if the message's content-type is not multipart, this - method will create a new `multipart/mixed` message, copy message + method will create a new `multipart/related` message, copy message headers and re-attach the original payload. """ # Do nothing if message already multipart @@ -128,13 +128,13 @@ def _transform_markdown(self): Specifically, if the message's content-type is `text/markdown`, we transform `self._message` to have the following structure: - multipart/mixed + multipart/related └── multipart/alternative ├── text/plain (original markdown plaintext) └── text/html (converted markdown) Attachments should be added as subsequent payload items of the - top-level `multipart/mixed` message. + top-level `multipart/related` message. """ # Do nothing if Content-Type is not text/markdown if not self._message['Content-Type'].startswith("text/markdown"): @@ -186,11 +186,11 @@ def _transform_attachments(self): """ Parse attachment headers and generate content-id headers for each. - Attachments are added to the payload of a `multipart/mixed` message. + Attachments are added to the payload of a `multipart/related` message. For instance, a plaintext message with attachments would have the following structure: - multipart/mixed + multipart/related ├── text/plain ├── attachment1 └── attachment2 @@ -199,7 +199,7 @@ def _transform_attachments(self): then the message would have the following structure after transforming markdown and attachments: - multipart/mixed + multipart/related ├── multipart/alternative │ ├── text/plain │ └── text/html From 7ff738787b3c5a617e4fe13fc31f4167d509fc1b Mon Sep 17 00:00:00 2001 From: abtsousa Date: Tue, 14 Feb 2023 11:22:56 +0000 Subject: [PATCH 08/23] Adds support for tabs and semicolons as delimiters --- mailmerge/__main__.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/mailmerge/__main__.py b/mailmerge/__main__.py index a364548..d71278d 100644 --- a/mailmerge/__main__.py +++ b/mailmerge/__main__.py @@ -267,13 +267,12 @@ def read_csv_database(database_path): #93 https://github.com/awdeorio/mailmerge/issues/93 """ - class StrictExcel(csv.excel): - # Our helper class is really simple - # pylint: disable=too-few-public-methods, missing-class-docstring - strict = True with database_path.open(encoding="utf-8-sig") as database_file: - reader = csv.DictReader(database_file, dialect=StrictExcel) + csvdialect = csv.Sniffer().sniff(database_file.read(), delimiters=",;\t") + csvdialect.strict = True + database_file.seek(0) + reader = csv.DictReader(database_file, dialect=csvdialect) try: for row in reader: yield row From 035995b8b7e8cc96cdb6c211fdea6d46a2c062f9 Mon Sep 17 00:00:00 2001 From: Andrew DeOrio Date: Tue, 14 Feb 2023 09:43:42 -0500 Subject: [PATCH 09/23] Update docstring --- mailmerge/__main__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/mailmerge/__main__.py b/mailmerge/__main__.py index d71278d..e8ab3d9 100644 --- a/mailmerge/__main__.py +++ b/mailmerge/__main__.py @@ -258,8 +258,11 @@ def create_sample_input_files(template_path, database_path, config_path): def read_csv_database(database_path): """Read database CSV file, providing one line at a time. - We'll use a class to modify the csv library's default dialect ('excel') to - enable strict syntax checking. This will trigger errors for things like + Automatically detect the dialect using the CSV library's sniffer class. + For example, comma-delimiter, tab-delimited, etc. + https://docs.python.org/3/library/csv.html#csv.Sniffer + + Use strict syntax checking, which will trigger errors for things like unclosed quotes. We open the file with the utf-8-sig encoding, which skips a byte order mark From 19d81fcf51eb8580af005be171cce365d40c65dd Mon Sep 17 00:00:00 2001 From: Andrew DeOrio Date: Tue, 14 Feb 2023 09:43:53 -0500 Subject: [PATCH 10/23] Avoid reading the entire file during dialect sniffing --- mailmerge/__main__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mailmerge/__main__.py b/mailmerge/__main__.py index e8ab3d9..b695fca 100644 --- a/mailmerge/__main__.py +++ b/mailmerge/__main__.py @@ -272,7 +272,8 @@ def read_csv_database(database_path): """ with database_path.open(encoding="utf-8-sig") as database_file: - csvdialect = csv.Sniffer().sniff(database_file.read(), delimiters=",;\t") + database_head = database_file.read(1024) + csvdialect = csv.Sniffer().sniff(database_head, delimiters=",;\t") csvdialect.strict = True database_file.seek(0) reader = csv.DictReader(database_file, dialect=csvdialect) From ff6f29f20d45406f0fc81e7b8eb0b7f48ff4acbe Mon Sep 17 00:00:00 2001 From: Andrew DeOrio Date: Tue, 14 Feb 2023 09:46:29 -0500 Subject: [PATCH 11/23] docstring nits --- mailmerge/__main__.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/mailmerge/__main__.py b/mailmerge/__main__.py index b695fca..c5fb7ba 100644 --- a/mailmerge/__main__.py +++ b/mailmerge/__main__.py @@ -258,9 +258,8 @@ def create_sample_input_files(template_path, database_path, config_path): def read_csv_database(database_path): """Read database CSV file, providing one line at a time. - Automatically detect the dialect using the CSV library's sniffer class. - For example, comma-delimiter, tab-delimited, etc. - https://docs.python.org/3/library/csv.html#csv.Sniffer + Automatically detect the format ("dialect") using the CSV library's sniffer + class. For example, comma-delimited, tab-delimited, etc. Use strict syntax checking, which will trigger errors for things like unclosed quotes. @@ -270,10 +269,9 @@ def read_csv_database(database_path): #93 https://github.com/awdeorio/mailmerge/issues/93 """ - with database_path.open(encoding="utf-8-sig") as database_file: - database_head = database_file.read(1024) - csvdialect = csv.Sniffer().sniff(database_head, delimiters=",;\t") + sample = database_file.read(1024) + csvdialect = csv.Sniffer().sniff(sample, delimiters=",;\t") csvdialect.strict = True database_file.seek(0) reader = csv.DictReader(database_file, dialect=csvdialect) From ba9a9499e8b1e3af4465609f7b53e88ce6aa77ff Mon Sep 17 00:00:00 2001 From: Andrew DeOrio Date: Tue, 14 Feb 2023 10:01:29 -0500 Subject: [PATCH 12/23] Fix failing tests --- mailmerge/__main__.py | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/mailmerge/__main__.py b/mailmerge/__main__.py index c5fb7ba..d3fb563 100644 --- a/mailmerge/__main__.py +++ b/mailmerge/__main__.py @@ -255,11 +255,34 @@ def create_sample_input_files(template_path, database_path, config_path): """)) -def read_csv_database(database_path): - """Read database CSV file, providing one line at a time. +def detect_database_format(database_file): + """Automatically detect the database format. Automatically detect the format ("dialect") using the CSV library's sniffer - class. For example, comma-delimited, tab-delimited, etc. + class. For example, comma-delimited, tab-delimited, etc. Default to + StrictExcel if automatic detection fails. + + """ + class StrictExcel(csv.excel): + # Our helper class is really simple + # pylint: disable=too-few-public-methods, missing-class-docstring + strict = True + + # Read a sample from database + sample = database_file.read(1024) + database_file.seek(0) + + # Attempt automatic format detection, fall back on StrictExcel default + try: + csvdialect = csv.Sniffer().sniff(sample, delimiters=",;\t") + except csv.Error: + csvdialect = StrictExcel + + return csvdialect + + +def read_csv_database(database_path): + """Read database CSV file, providing one line at a time. Use strict syntax checking, which will trigger errors for things like unclosed quotes. @@ -270,10 +293,8 @@ def read_csv_database(database_path): """ with database_path.open(encoding="utf-8-sig") as database_file: - sample = database_file.read(1024) - csvdialect = csv.Sniffer().sniff(sample, delimiters=",;\t") + csvdialect = detect_database_format(database_file) csvdialect.strict = True - database_file.seek(0) reader = csv.DictReader(database_file, dialect=csvdialect) try: for row in reader: From daeef515b2da95278471b5ba8bb0ea9587bf4865 Mon Sep 17 00:00:00 2001 From: Andrew DeOrio Date: Tue, 14 Feb 2023 10:05:18 -0500 Subject: [PATCH 13/23] Add tests for TSV and semicolon-delimited databases --- tests/test_main.py | 106 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/tests/test_main.py b/tests/test_main.py index ed4d5f7..0ee2ef7 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -859,3 +859,109 @@ def test_database_bom(tmpdir): >>> Limit was 1 message. To remove the limit, use the --no-limit option. >>> This was a dry run. To send messages, use the --no-dry-run option. """) # noqa: E501 + + +def test_database_tsv(tmpdir): + """Automatically detect TSV database format.""" + # Simple template + template_path = Path(tmpdir/"mailmerge_template.txt") + template_path.write_text(textwrap.dedent("""\ + TO: {{email}} + FROM: My Self + + Hello {{name}} + """), encoding="utf8") + + # Tab-separated format database + database_path = Path(tmpdir/"mailmerge_database.csv") + database_path.write_text(textwrap.dedent("""\ + email\tname + to@test.com\tMy Name + """), encoding="utf8") + + # Simple unsecure server config + config_path = Path(tmpdir/"mailmerge_server.conf") + config_path.write_text(textwrap.dedent("""\ + [smtp_server] + host = open-smtp.example.com + port = 25 + """), encoding="utf8") + + # Run mailmerge + runner = click.testing.CliRunner() + with tmpdir.as_cwd(): + result = runner.invoke(main, ["--output-format", "text"]) + assert not result.exception + assert result.exit_code == 0 + + # Verify output + stdout = copy.deepcopy(result.output) + stdout = re.sub(r"Date:.+", "Date: REDACTED", stdout, re.MULTILINE) + assert stdout == textwrap.dedent("""\ + >>> message 1 + TO: to@test.com + FROM: My Self + MIME-Version: 1.0 + Content-Type: text/plain; charset="us-ascii" + Content-Transfer-Encoding: 7bit + Date: REDACTED + + Hello My Name + + >>> message 1 sent + >>> Limit was 1 message. To remove the limit, use the --no-limit option. + >>> This was a dry run. To send messages, use the --no-dry-run option. + """) # noqa: E501 + + +def test_database_semicolon(tmpdir): + """Automatically detect semicolon-delimited database format.""" + # Simple template + template_path = Path(tmpdir/"mailmerge_template.txt") + template_path.write_text(textwrap.dedent("""\ + TO: {{email}} + FROM: My Self + + Hello {{name}} + """), encoding="utf8") + + # Semicolon-separated format database + database_path = Path(tmpdir/"mailmerge_database.csv") + database_path.write_text(textwrap.dedent("""\ + email;name + to@test.com;My Name + """), encoding="utf8") + + # Simple unsecure server config + config_path = Path(tmpdir/"mailmerge_server.conf") + config_path.write_text(textwrap.dedent("""\ + [smtp_server] + host = open-smtp.example.com + port = 25 + """), encoding="utf8") + + # Run mailmerge + runner = click.testing.CliRunner() + with tmpdir.as_cwd(): + result = runner.invoke(main, ["--output-format", "text"]) + assert not result.exception + assert result.exit_code == 0 + + # Verify output + stdout = copy.deepcopy(result.output) + stdout = re.sub(r"Date:.+", "Date: REDACTED", stdout, re.MULTILINE) + assert stdout == textwrap.dedent("""\ + >>> message 1 + TO: to@test.com + FROM: My Self + MIME-Version: 1.0 + Content-Type: text/plain; charset="us-ascii" + Content-Transfer-Encoding: 7bit + Date: REDACTED + + Hello My Name + + >>> message 1 sent + >>> Limit was 1 message. To remove the limit, use the --no-limit option. + >>> This was a dry run. To send messages, use the --no-dry-run option. + """) # noqa: E501 From 240a0353e97a128651d170e37e71129c9a34814d Mon Sep 17 00:00:00 2001 From: Andrew DeOrio Date: Tue, 14 Feb 2023 11:00:09 -0500 Subject: [PATCH 14/23] Drop Python 3.6 from CI --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 7b73df3..d07ae45 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -21,7 +21,7 @@ jobs: strategy: # Define OS and Python versions to use. 3.x is the latest minor version. matrix: - python-version: ["3.6", "3.7", "3.8", "3.9", "3.x"] + python-version: ["3.7", "3.8", "3.9", "3.x"] os: [ubuntu-latest] # Sequence of tasks for this job From ff55343ff48265b9f74a8af3d4ef188c7fc5f917 Mon Sep 17 00:00:00 2001 From: Andrew DeOrio Date: Tue, 14 Feb 2023 11:02:43 -0500 Subject: [PATCH 15/23] Add Python 3.10 and 3.11 --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d07ae45..08ebb81 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -21,7 +21,7 @@ jobs: strategy: # Define OS and Python versions to use. 3.x is the latest minor version. matrix: - python-version: ["3.7", "3.8", "3.9", "3.x"] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.x"] os: [ubuntu-latest] # Sequence of tasks for this job From 1d4e03eac283dc60bddadeb3970ecbb7207077cb Mon Sep 17 00:00:00 2001 From: Andrew DeOrio Date: Tue, 14 Feb 2023 11:04:28 -0500 Subject: [PATCH 16/23] Replace dunder set/del calls --- mailmerge/template_message.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mailmerge/template_message.py b/mailmerge/template_message.py index aa92489..5977fe6 100644 --- a/mailmerge/template_message.py +++ b/mailmerge/template_message.py @@ -58,7 +58,7 @@ def render(self, context): self._transform_markdown() self._transform_attachments() self._transform_attachment_references() - self._message.__setitem__('Date', email.utils.formatdate()) + self._message.Date = email.utils.formatdate() assert self._sender assert self._recipients assert self._message @@ -79,7 +79,7 @@ def _transform_recipients(self): email.utils.getaddresses(self._message.get_all("CC", [])) + \ email.utils.getaddresses(self._message.get_all("BCC", [])) self._recipients = [x[1] for x in addrs] - self._message.__delitem__("bcc") + del self._message.bcc self._sender = self._message["from"] def _make_message_multipart(self): From 47a78668c0771e26c41aa8d680a8b0ba470fc544 Mon Sep 17 00:00:00 2001 From: Andrew DeOrio Date: Tue, 14 Feb 2023 11:14:06 -0500 Subject: [PATCH 17/23] setattr / delattr --- mailmerge/template_message.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mailmerge/template_message.py b/mailmerge/template_message.py index 5977fe6..2b736a4 100644 --- a/mailmerge/template_message.py +++ b/mailmerge/template_message.py @@ -58,7 +58,7 @@ def render(self, context): self._transform_markdown() self._transform_attachments() self._transform_attachment_references() - self._message.Date = email.utils.formatdate() + setattr(self._message, "Date", email.utils.formatdate()) assert self._sender assert self._recipients assert self._message @@ -79,7 +79,8 @@ def _transform_recipients(self): email.utils.getaddresses(self._message.get_all("CC", [])) + \ email.utils.getaddresses(self._message.get_all("BCC", [])) self._recipients = [x[1] for x in addrs] - del self._message.bcc + if hasattr(self._message, "bcc"): + delattr(self._message, "bcc") self._sender = self._message["from"] def _make_message_multipart(self): From 0ff6c018d64f5106cfae4c423e84ae982c48c7c5 Mon Sep 17 00:00:00 2001 From: Andrew DeOrio Date: Tue, 14 Feb 2023 11:26:32 -0500 Subject: [PATCH 18/23] lint --- mailmerge/template_message.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/mailmerge/template_message.py b/mailmerge/template_message.py index 2b736a4..dbbf2bc 100644 --- a/mailmerge/template_message.py +++ b/mailmerge/template_message.py @@ -58,7 +58,7 @@ def render(self, context): self._transform_markdown() self._transform_attachments() self._transform_attachment_references() - setattr(self._message, "Date", email.utils.formatdate()) + self._message.add_header('Date', email.utils.formatdate()) assert self._sender assert self._recipients assert self._message @@ -74,13 +74,11 @@ def _transform_encoding(self, raw_message): def _transform_recipients(self): """Extract sender and recipients from FROM, TO, CC and BCC fields.""" - # Extract recipients addrs = email.utils.getaddresses(self._message.get_all("TO", [])) + \ email.utils.getaddresses(self._message.get_all("CC", [])) + \ email.utils.getaddresses(self._message.get_all("BCC", [])) self._recipients = [x[1] for x in addrs] - if hasattr(self._message, "bcc"): - delattr(self._message, "bcc") + self._message.__delitem__("bcc") # pylint: disable=unnecessary-dunder-call self._sender = self._message["from"] def _make_message_multipart(self): From dd35c215cb404cbd66bc7185bcb9f7e5d5c60028 Mon Sep 17 00:00:00 2001 From: Andrew DeOrio Date: Tue, 14 Feb 2023 12:37:34 -0500 Subject: [PATCH 19/23] lint --- mailmerge/template_message.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mailmerge/template_message.py b/mailmerge/template_message.py index dbbf2bc..baafd4e 100644 --- a/mailmerge/template_message.py +++ b/mailmerge/template_message.py @@ -74,11 +74,14 @@ def _transform_encoding(self, raw_message): def _transform_recipients(self): """Extract sender and recipients from FROM, TO, CC and BCC fields.""" + # The docs recommend using __delitem__() + # https://docs.python.org/3/library/email.message.html#email.message.EmailMessage.__delitem__ + # pylint: disable=unnecessary-dunder-call addrs = email.utils.getaddresses(self._message.get_all("TO", [])) + \ email.utils.getaddresses(self._message.get_all("CC", [])) + \ email.utils.getaddresses(self._message.get_all("BCC", [])) self._recipients = [x[1] for x in addrs] - self._message.__delitem__("bcc") # pylint: disable=unnecessary-dunder-call + self._message.__delitem__("bcc") self._sender = self._message["from"] def _make_message_multipart(self): From 66efe91bbec3a5b634b332dba3f68804b38bf9eb Mon Sep 17 00:00:00 2001 From: Andrew DeOrio Date: Tue, 14 Feb 2023 12:40:59 -0500 Subject: [PATCH 20/23] Remove Python 3.7 --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 08ebb81..587daa7 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -21,7 +21,7 @@ jobs: strategy: # Define OS and Python versions to use. 3.x is the latest minor version. matrix: - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.x"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.x"] os: [ubuntu-latest] # Sequence of tasks for this job From 02066e35256b185290c044c9d43294450a477465 Mon Sep 17 00:00:00 2001 From: Andrew DeOrio Date: Tue, 14 Feb 2023 13:09:57 -0500 Subject: [PATCH 21/23] Add unit test for PLAIN security --- tests/test_sendmail_client.py | 42 +++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/test_sendmail_client.py b/tests/test_sendmail_client.py index 088f34e..3b03715 100644 --- a/tests/test_sendmail_client.py +++ b/tests/test_sendmail_client.py @@ -250,6 +250,48 @@ def test_security_starttls(mocker, tmp_path): assert smtp.sendmail.call_count == 1 +def test_security_plain(mocker, tmp_path): + """Verify plain security configuration.""" + # Config for Plain SMTP server + config_path = tmp_path/"server.conf" + config_path.write_text(textwrap.dedent("""\ + [smtp_server] + host = newman.eecs.umich.edu + port = 25 + security = PLAIN + username = YOUR_USERNAME_HERE + """)) + + # Simple template + sendmail_client = SendmailClient(config_path, dry_run=False) + message = email.message_from_string("Hello world") + + # Mock SMTP + mock_smtp = mocker.patch('smtplib.SMTP') + mock_smtp_ssl = mocker.patch('smtplib.SMTP_SSL') + + # Mock the password entry + mock_getpass = mocker.patch('getpass.getpass') + mock_getpass.return_value = "password" + + # Send a message + sendmail_client.sendmail( + sender="test@test.com", + recipients=["test@test.com"], + message=message, + ) + + # Verify SMTP library calls + assert mock_getpass.call_count == 1 + assert mock_smtp.call_count == 1 + assert mock_smtp_ssl.call_count == 0 + smtp = mock_smtp.return_value.__enter__.return_value + assert smtp.ehlo.call_count == 0 + assert smtp.starttls.call_count == 0 + assert smtp.login.call_count == 1 + assert smtp.sendmail.call_count == 1 + + def test_security_ssl(mocker, tmp_path): """Verify open (Never) security configuration.""" # Config for SSL SMTP server From 9560ede31baa5dfb26a3c9378397aa3f5f585c53 Mon Sep 17 00:00:00 2001 From: Andrew DeOrio Date: Tue, 14 Feb 2023 13:10:38 -0500 Subject: [PATCH 22/23] Add PLAIN security example --- mailmerge/__main__.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/mailmerge/__main__.py b/mailmerge/__main__.py index a364548..9f4ca3a 100644 --- a/mailmerge/__main__.py +++ b/mailmerge/__main__.py @@ -240,6 +240,14 @@ def create_sample_input_files(template_path, database_path, config_path): # username = YOUR_USERNAME_HERE # ratelimit = 0 + # Example: Plain security + # [smtp_server] + # host = newman.eecs.umich.edu + # port = 25 + # security = PLAIN + # username = YOUR_USERNAME_HERE + # ratelimit = 0 + # Example: No security # [smtp_server] # host = newman.eecs.umich.edu From 6e3d3426fb8297ec8e8083913a9b89c168d6511f Mon Sep 17 00:00:00 2001 From: Andrew DeOrio Date: Tue, 11 Jul 2023 08:40:20 -0400 Subject: [PATCH 23/23] version bump --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index a828bc6..9eba3b8 100644 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ description="A simple, command line mail merge tool", long_description=LONG_DESCRIPTION, long_description_content_type="text/markdown", - version="2.2.1", + version="2.2.2", author="Andrew DeOrio", author_email="awdeorio@umich.edu", url="https://github.com/awdeorio/mailmerge/",