Skip to content

Commit cbf69b1

Browse files
committed
Merge remote-tracking branch 'origin/develop'
2 parents 5e479de + 6e3d342 commit cbf69b1

File tree

10 files changed

+218
-37
lines changed

10 files changed

+218
-37
lines changed

.github/workflows/main.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ jobs:
2121
strategy:
2222
# Define OS and Python versions to use. 3.x is the latest minor version.
2323
matrix:
24-
python-version: ["3.6", "3.7", "3.8", "3.9", "3.x"]
24+
python-version: ["3.8", "3.9", "3.10", "3.11", "3.x"]
2525
os: [ubuntu-latest]
2626

2727
# Sequence of tasks for this job
@@ -55,6 +55,6 @@ jobs:
5555
# Upload coverage report
5656
# https://github.com/codecov/codecov-action
5757
- name: Upload coverage report
58-
uses: codecov/codecov-action@v1
58+
uses: codecov/codecov-action@v2
5959
with:
6060
fail_ci_if_error: true

mailmerge/__main__.py

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,14 @@ def create_sample_input_files(template_path, database_path, config_path):
240240
# username = YOUR_USERNAME_HERE
241241
# ratelimit = 0
242242
243+
# Example: Plain security
244+
# [smtp_server]
245+
# host = newman.eecs.umich.edu
246+
# port = 25
247+
# security = PLAIN
248+
# username = YOUR_USERNAME_HERE
249+
# ratelimit = 0
250+
243251
# Example: No security
244252
# [smtp_server]
245253
# host = newman.eecs.umich.edu
@@ -255,25 +263,47 @@ def create_sample_input_files(template_path, database_path, config_path):
255263
"""))
256264

257265

266+
def detect_database_format(database_file):
267+
"""Automatically detect the database format.
268+
269+
Automatically detect the format ("dialect") using the CSV library's sniffer
270+
class. For example, comma-delimited, tab-delimited, etc. Default to
271+
StrictExcel if automatic detection fails.
272+
273+
"""
274+
class StrictExcel(csv.excel):
275+
# Our helper class is really simple
276+
# pylint: disable=too-few-public-methods, missing-class-docstring
277+
strict = True
278+
279+
# Read a sample from database
280+
sample = database_file.read(1024)
281+
database_file.seek(0)
282+
283+
# Attempt automatic format detection, fall back on StrictExcel default
284+
try:
285+
csvdialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
286+
except csv.Error:
287+
csvdialect = StrictExcel
288+
289+
return csvdialect
290+
291+
258292
def read_csv_database(database_path):
259293
"""Read database CSV file, providing one line at a time.
260294
261-
We'll use a class to modify the csv library's default dialect ('excel') to
262-
enable strict syntax checking. This will trigger errors for things like
295+
Use strict syntax checking, which will trigger errors for things like
263296
unclosed quotes.
264297
265298
We open the file with the utf-8-sig encoding, which skips a byte order mark
266299
(BOM), if any. Sometimes Excel will save CSV files with a BOM. See Issue
267300
#93 https://github.com/awdeorio/mailmerge/issues/93
268301
269302
"""
270-
class StrictExcel(csv.excel):
271-
# Our helper class is really simple
272-
# pylint: disable=too-few-public-methods, missing-class-docstring
273-
strict = True
274-
275303
with database_path.open(encoding="utf-8-sig") as database_file:
276-
reader = csv.DictReader(database_file, dialect=StrictExcel)
304+
csvdialect = detect_database_format(database_file)
305+
csvdialect.strict = True
306+
reader = csv.DictReader(database_file, dialect=csvdialect)
277307
try:
278308
for row in reader:
279309
yield row

mailmerge/sendmail_client.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ def read_config(self):
4949
security = None
5050

5151
# Verify security type
52-
if security not in [None, "SSL/TLS", "STARTTLS"]:
52+
if security not in [None, "SSL/TLS", "STARTTLS", "PLAIN"]:
5353
raise exceptions.MailmergeError(
5454
f"{self.config_path}: unrecognized security type: '{security}'"
5555
)
@@ -100,6 +100,10 @@ def sendmail(self, sender, recipients, message):
100100
smtp.ehlo()
101101
smtp.login(self.config.username, self.password)
102102
smtp.sendmail(sender, recipients, message_flattened)
103+
elif self.config.security == "PLAIN":
104+
with smtplib.SMTP(host, port) as smtp:
105+
smtp.login(self.config.username, self.password)
106+
smtp.sendmail(sender, recipients, message_flattened)
103107
elif self.config.security is None:
104108
with smtplib.SMTP(host, port) as smtp:
105109
smtp.sendmail(sender, recipients, message_flattened)

mailmerge/template_message.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ def render(self, context):
5858
self._transform_markdown()
5959
self._transform_attachments()
6060
self._transform_attachment_references()
61-
self._message.__setitem__('Date', email.utils.formatdate())
61+
self._message.add_header('Date', email.utils.formatdate())
6262
assert self._sender
6363
assert self._recipients
6464
assert self._message
@@ -74,7 +74,9 @@ def _transform_encoding(self, raw_message):
7474

7575
def _transform_recipients(self):
7676
"""Extract sender and recipients from FROM, TO, CC and BCC fields."""
77-
# Extract recipients
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
7880
addrs = email.utils.getaddresses(self._message.get_all("TO", [])) + \
7981
email.utils.getaddresses(self._message.get_all("CC", [])) + \
8082
email.utils.getaddresses(self._message.get_all("BCC", []))
@@ -87,15 +89,15 @@ def _make_message_multipart(self):
8789
Convert self._message into a multipart message.
8890
8991
Specifically, if the message's content-type is not multipart, this
90-
method will create a new `multipart/mixed` message, copy message
92+
method will create a new `multipart/related` message, copy message
9193
headers and re-attach the original payload.
9294
"""
9395
# Do nothing if message already multipart
9496
if self._message.is_multipart():
9597
return
9698

9799
# Create empty multipart message
98-
multipart_message = email.mime.multipart.MIMEMultipart('mixed')
100+
multipart_message = email.mime.multipart.MIMEMultipart('related')
99101

100102
# Copy headers. Avoid duplicate Content-Type and MIME-Version headers,
101103
# which we set explicitely. MIME-Version was set when we created an
@@ -128,13 +130,13 @@ def _transform_markdown(self):
128130
Specifically, if the message's content-type is `text/markdown`, we
129131
transform `self._message` to have the following structure:
130132
131-
multipart/mixed
133+
multipart/related
132134
└── multipart/alternative
133135
├── text/plain (original markdown plaintext)
134136
└── text/html (converted markdown)
135137
136138
Attachments should be added as subsequent payload items of the
137-
top-level `multipart/mixed` message.
139+
top-level `multipart/related` message.
138140
"""
139141
# Do nothing if Content-Type is not text/markdown
140142
if not self._message['Content-Type'].startswith("text/markdown"):
@@ -186,11 +188,11 @@ def _transform_attachments(self):
186188
"""
187189
Parse attachment headers and generate content-id headers for each.
188190
189-
Attachments are added to the payload of a `multipart/mixed` message.
191+
Attachments are added to the payload of a `multipart/related` message.
190192
For instance, a plaintext message with attachments would have the
191193
following structure:
192194
193-
multipart/mixed
195+
multipart/related
194196
├── text/plain
195197
├── attachment1
196198
└── attachment2
@@ -199,7 +201,7 @@ def _transform_attachments(self):
199201
then the message would have the following structure after transforming
200202
markdown and attachments:
201203
202-
multipart/mixed
204+
multipart/related
203205
├── multipart/alternative
204206
│ ├── text/plain
205207
│ └── text/html

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
description="A simple, command line mail merge tool",
1515
long_description=LONG_DESCRIPTION,
1616
long_description_content_type="text/markdown",
17-
version="2.2.1",
17+
version="2.2.2",
1818
author="Andrew DeOrio",
1919
author_email="[email protected]",
2020
url="https://github.com/awdeorio/mailmerge/",

tests/test_helpers.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def test_enumerate_range_stop_value():
3131
def test_enumerate_range_stop_zero():
3232
"""Verify stop=0."""
3333
output = list(enumerate_range(["a", "b", "c"], stop=0))
34-
assert output == []
34+
assert not output
3535

3636

3737
def test_enumerate_range_stop_too_big():
@@ -61,13 +61,13 @@ def test_enumerate_range_start_last_one():
6161
def test_enumerate_range_start_length():
6262
"""Verify start=length."""
6363
output = list(enumerate_range(["a", "b", "c"], start=3))
64-
assert output == []
64+
assert not output
6565

6666

6767
def test_enumerate_range_start_too_big():
6868
"""Verify start past the end."""
6969
output = list(enumerate_range(["a", "b", "c"], start=10))
70-
assert output == []
70+
assert not output
7171

7272

7373
def test_enumerate_range_start_stop():

tests/test_main.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -859,3 +859,109 @@ def test_database_bom(tmpdir):
859859
>>> Limit was 1 message. To remove the limit, use the --no-limit option.
860860
>>> This was a dry run. To send messages, use the --no-dry-run option.
861861
""") # 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 <[email protected]>
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+
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+
903+
FROM: My Self <[email protected]>
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 <[email protected]>
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+
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+
956+
FROM: My Self <[email protected]>
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

tests/test_sendmail_client.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,48 @@ def test_security_starttls(mocker, tmp_path):
250250
assert smtp.sendmail.call_count == 1
251251

252252

253+
def test_security_plain(mocker, tmp_path):
254+
"""Verify plain security configuration."""
255+
# Config for Plain SMTP server
256+
config_path = tmp_path/"server.conf"
257+
config_path.write_text(textwrap.dedent("""\
258+
[smtp_server]
259+
host = newman.eecs.umich.edu
260+
port = 25
261+
security = PLAIN
262+
username = YOUR_USERNAME_HERE
263+
"""))
264+
265+
# Simple template
266+
sendmail_client = SendmailClient(config_path, dry_run=False)
267+
message = email.message_from_string("Hello world")
268+
269+
# Mock SMTP
270+
mock_smtp = mocker.patch('smtplib.SMTP')
271+
mock_smtp_ssl = mocker.patch('smtplib.SMTP_SSL')
272+
273+
# Mock the password entry
274+
mock_getpass = mocker.patch('getpass.getpass')
275+
mock_getpass.return_value = "password"
276+
277+
# Send a message
278+
sendmail_client.sendmail(
279+
280+
recipients=["[email protected]"],
281+
message=message,
282+
)
283+
284+
# Verify SMTP library calls
285+
assert mock_getpass.call_count == 1
286+
assert mock_smtp.call_count == 1
287+
assert mock_smtp_ssl.call_count == 0
288+
smtp = mock_smtp.return_value.__enter__.return_value
289+
assert smtp.ehlo.call_count == 0
290+
assert smtp.starttls.call_count == 0
291+
assert smtp.login.call_count == 1
292+
assert smtp.sendmail.call_count == 1
293+
294+
253295
def test_security_ssl(mocker, tmp_path):
254296
"""Verify open (Never) security configuration."""
255297
# Config for SSL SMTP server

0 commit comments

Comments
 (0)