From 6e8a5ea8915225027d87329e8b93a8afd06b0749 Mon Sep 17 00:00:00 2001 From: Sesh Sadasivam Date: Fri, 4 Jan 2019 15:20:37 -0500 Subject: [PATCH 01/14] Added support for sending attachments - Added an option to __main__ - Added sample attachment list template - Updated main driver to attach files to multipart email --- mailmerge/__main__.py | 7 ++- mailmerge/api.py | 114 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 112 insertions(+), 9 deletions(-) diff --git a/mailmerge/__main__.py b/mailmerge/__main__.py index 60bb22c..d47bd3c 100644 --- a/mailmerge/__main__.py +++ b/mailmerge/__main__.py @@ -31,8 +31,12 @@ default=mailmerge.api.CONFIG_FILENAME_DEFAULT, help="configuration file name; default " + mailmerge.api.CONFIG_FILENAME_DEFAULT) +@click.option("--attachments-list", "attachments_list_filename", + default=None, + help="attachments list file name; none by default") def cli(sample, dry_run, limit, no_limit, - database_filename, template_filename, config_filename): + database_filename, template_filename, config_filename, + attachments_list_filename): """Command line interface.""" # pylint: disable=too-many-arguments mailmerge.api.main( @@ -43,6 +47,7 @@ def cli(sample, dry_run, limit, no_limit, database_filename=database_filename, template_filename=template_filename, config_filename=config_filename, + attachments_list_filename=attachments_list_filename, ) diff --git a/mailmerge/api.py b/mailmerge/api.py index cbe0702..e568087 100644 --- a/mailmerge/api.py +++ b/mailmerge/api.py @@ -16,6 +16,10 @@ # NOTE: Python 2.x UTF8 support requires csv and email backports from backports import csv import future.backports.email as email # pylint: disable=useless-import-alias +import future.backports.email.mime # pylint: disable=unused-import +import future.backports.email.mime.application # pylint: disable=unused-import +import future.backports.email.mime.multipart # pylint: disable=unused-import +import future.backports.email.mime.text # pylint: disable=unused-import import future.backports.email.parser # pylint: disable=unused-import import future.backports.email.utils # pylint: disable=unused-import import jinja2 @@ -27,6 +31,7 @@ TEMPLATE_FILENAME_DEFAULT = "mailmerge_template.txt" DATABASE_FILENAME_DEFAULT = "mailmerge_database.csv" CONFIG_FILENAME_DEFAULT = "mailmerge_server.conf" +ATTACHMENTS_LIST_FILENAME_DEFAULT = "mailmerge_attachments_list.txt" def parsemail(raw_message): @@ -49,12 +54,49 @@ def parsemail(raw_message): recipients = [x[1] for x in addrs] message.__delitem__("bcc") message.__setitem__('Date', email.utils.formatdate()) - text = message.as_string() sender = message["from"] - return (text, sender, recipients) + return (message, sender, recipients) -def sendmail(text, sender, recipients, config_filename): + +def addattachments(message, attachment_list, attachment_list_parent_dir): + """Add the attachments from the message from the commandline options.""" + # If the message is not already a multipart message, then make it so + if not message.is_multipart(): + multipart_message = email.mime.multipart.MIMEMultipart() + for header_key in message: + multipart_message[header_key] = message[header_key] + original_text = message.get_payload() + assert isinstance(original_text, str) + multipart_message.attach(email.mime.text.MIMEText(original_text)) + message = multipart_message + + # Remove all comments and empty lines from the attachment list + attachment_filenames = [line + for line in attachment_list.split('\n') + if len(line) != 0 and line[0] != '#'] + + for attachment_filepath in attachment_filenames: + # Check that the attachment exists + full_path = attachment_list_parent_dir + attachment_filepath + normalized_path = os.path.abspath(full_path) + if not os.path.exists(normalized_path): + print("Error: can't find attachment " + normalized_path) + sys.exit(1) + + filename = os.path.basename(normalized_path) + with open(normalized_path, "rb") as attachment: + part = email.mime.application.MIMEApplication(attachment.read(), + Name=filename) + part.add_header('Content-Disposition', + 'attachment; filename="{}"'.format(filename)) + message.attach(part) + print(">>> attached {}".format(normalized_path)) + + return message + + +def sendmail(message, sender, recipients, config_filename): """Send email message using Python SMTP library.""" # Read config file from disk to get SMTP server host, port, username if not hasattr(sendmail, "host"): @@ -102,13 +144,14 @@ def sendmail(text, sender, recipients, config_filename): # Send message. Note that we can't use the elegant # "smtp.send_message(message)" because that's python3 only - smtp.sendmail(sender, recipients, text) + smtp.sendmail(sender, recipients, message.as_string()) smtp.close() def create_sample_input_files(template_filename, database_filename, - config_filename): + config_filename, + attachments_list_filename): """Create sample template email and database.""" print("Creating sample template email {}".format(template_filename)) if os.path.exists(template_filename): @@ -175,6 +218,33 @@ def create_sample_input_files(template_filename, u"# security = Never\n" u"# username = YOUR_USERNAME_HERE\n" ) + if attachments_list_filename is None: + attachments_list_filename = ATTACHMENTS_LIST_FILENAME_DEFAULT + print("Creating sample attachments list file", + attachments_list_filename) + if os.path.exists(attachments_list_filename): + print("Error: file exists: " + attachments_list_filename) + sys.exit(1) + with io.open(attachments_list_filename, "w") as attachments_list_file: + attachments_list_file.write( + u"# Lines beginning with the '#' character are ignored.\n" + u'# List the filepaths of files that you would like to attach to\n' + u'# every email. Paths are relative to the directory containing\n' + u'# this file.\n' + u'\n' + u'attachment1.txt\n' + u'attachment2.pdf\n' + u'\n' + u'# You can also specify a templated filepath to be populated\n' + u'# with information from the database file. For instance:\n' + u"../grades/{{name}}/grades.pdf\n" + u'\n' + u'# Using this attachments lists file, every email sent would\n' + u'# have three attachments.\n' + u'\n' + u"# NOTE: Don't forget to explicitly specify this attachments\n" + u"# list file when running mailmerge.\n" + ) print("Edit these files, and then run mailmerge again") @@ -184,7 +254,8 @@ def main(sample=False, no_limit=False, database_filename=DATABASE_FILENAME_DEFAULT, template_filename=TEMPLATE_FILENAME_DEFAULT, - config_filename=CONFIG_FILENAME_DEFAULT): + config_filename=CONFIG_FILENAME_DEFAULT, + attachments_list_filename=None): """Python API for mailmerge. mailmerge 0.1 by Andrew DeOrio . @@ -202,6 +273,7 @@ def main(sample=False, template_filename, database_filename, config_filename, + attachments_list_filename, ) sys.exit(0) if not os.path.exists(template_filename): @@ -212,6 +284,15 @@ def main(sample=False, print("Error: can't find database_filename " + database_filename) print("Create a sample (--sample) or specify a file (--database)") sys.exit(1) + if attachments_list_filename is not None: + print(">>> Reading attachment list from", + attachments_list_filename) + if not os.path.exists(attachments_list_filename): + print("Error: can't find attachments_list_filename", + attachments_list_filename) + print("Create a sample (--sample)", + "or specify a file (--attachments-list)") + sys.exit(1) try: # Read template @@ -226,6 +307,13 @@ def main(sample=False, for row in reader: database.append(row) + # Read attachment list template + if attachments_list_filename is not None: + attachment_parent_dir = os.path.dirname(attachments_list_filename) + with io.open(attachments_list_filename, "r") as attachment_list: + attachment_list_content = attachment_list.read() + u"\n" + attachment_template = jinja2.Template(attachment_list_content) + # Each row corresponds to one email message for i, row in enumerate(database): if not no_limit and i >= limit: @@ -238,7 +326,13 @@ def main(sample=False, print(raw_message) # Parse message headers and detect encoding - (text, sender, recipients) = parsemail(raw_message) + (message, sender, recipients) = parsemail(raw_message) + # Add attachments if any + if attachments_list_filename is not None: + attachment_list = attachment_template.render(**row) + num_attachments = addattachments(message, + attachment_list, + attachment_parent_dir) # Send message if dry_run: @@ -246,7 +340,7 @@ def main(sample=False, else: # Send message try: - sendmail(text, sender, recipients, config_filename) + sendmail(message, sender, recipients, config_filename) except smtplib.SMTPException as err: print(">>> failed to send message {}".format(i)) timestamp = '{:%Y-%m-%d %H:%M:%S}'.format( @@ -257,6 +351,10 @@ def main(sample=False, print(">>> sent message {}".format(i)) # Hints for user + if attachments_list_filename is None or num_attachments == 0: + print((">>> No attachments were sent with the emails. " + "To specify attachments, use the" + "--attachments-list option.")) if not no_limit: print(">>> Limit was {} messages. ".format(limit) + "To remove the limit, use the --no-limit option.") From 8a793aedafdae1d3108778d6d07d5c7747c71071 Mon Sep 17 00:00:00 2001 From: Sesh Sadasivam Date: Fri, 4 Jan 2019 16:55:55 -0500 Subject: [PATCH 02/14] Updated README --- README.md | 54 ++++++++++++++++++++++++++++++++++++++++++++++++ mailmerge/api.py | 2 +- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ecaf4ae..aca816d 100644 --- a/README.md +++ b/README.md @@ -284,6 +284,60 @@ Content-ID: ``` +# Attachments +For convenience, `mailmerge` also directly supports sending attachments with emails. Modify the sample `mailmerge_attachments_list.txt` file to specify the attachments. + +**mailmerge_attachments_list.txt** +``` +# Lines beginning with an octothorpe are comments. +# Filenames and paths are relative to the parent directory of this file. + +file1.pdf +file2.docx +../files/file3.txt + +# You can also specify a templated filepath to be populated with information +# from the database file. For instance: +../files/{{name}}_submission.txt +``` + +To specify that your emails must include attachments, use the `--attachments-list` flag. Dry running the `mailmerge` script checks that all attachments are valid and that they exist. If your attachment list includes template, be sure to dry run with the `--no-limit` flag before actually sending the emails. + +```shellsession +$ mailmerge --no-limit --attachments-list mailmerge_attachments_list.txt +>>> message 0 +TO: myself@mydomain.com +SUBJECT: Testing mailmerge +FROM: My Self + +Hi, Myself, + +Your number is 17. + +>>> encoding ascii +>>> attached /demo/file1.pdf +>>> attached /demo/file2.docx +>>> attached /files/file3.txt +>>> attached /files/Myself_submission.txt +>>> sent message 0 DRY RUN +>>> message 1 +TO: bob@bobdomain.com +SUBJECT: Testing mailmerge +FROM: My Self + +Hi, Bob, + +Your number is 42. + +>>> encoding ascii +>>> attached /demo/file1.pdf +>>> attached /demo/file2.docx +>>> attached /files/file3.txt +>>> attached /files/Bob_submission.txt +>>> sent message 1 DRY RUN +>>> This was a dry run. To send messages, use the --no-dry-run option. +``` + # Hacking Set up a development environment. This will install a `mailmerge` executable in virtual environment's `PATH` which points to the local python development source code. ```shellsession diff --git a/mailmerge/api.py b/mailmerge/api.py index e568087..5116947 100644 --- a/mailmerge/api.py +++ b/mailmerge/api.py @@ -91,7 +91,7 @@ def addattachments(message, attachment_list, attachment_list_parent_dir): part.add_header('Content-Disposition', 'attachment; filename="{}"'.format(filename)) message.attach(part) - print(">>> attached {}".format(normalized_path)) + print(">>> attached {}".format(normalized_path)) return message From 842748b9f3817900136a94756cc0d20bcc773b39 Mon Sep 17 00:00:00 2001 From: Sesh Sadasivam Date: Sat, 5 Jan 2019 13:28:28 -0500 Subject: [PATCH 03/14] Fixed bug introduced by previous commit --- mailmerge/api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mailmerge/api.py b/mailmerge/api.py index 5116947..70c7656 100644 --- a/mailmerge/api.py +++ b/mailmerge/api.py @@ -330,7 +330,7 @@ def main(sample=False, # Add attachments if any if attachments_list_filename is not None: attachment_list = attachment_template.render(**row) - num_attachments = addattachments(message, + message = addattachments(message, attachment_list, attachment_parent_dir) @@ -351,7 +351,7 @@ def main(sample=False, print(">>> sent message {}".format(i)) # Hints for user - if attachments_list_filename is None or num_attachments == 0: + if attachments_list_filename is None: print((">>> No attachments were sent with the emails. " "To specify attachments, use the" "--attachments-list option.")) From ee7ce38761e0381201e9c6723585f7c828c13781 Mon Sep 17 00:00:00 2001 From: Sesh Sadasivam Date: Sat, 5 Jan 2019 13:29:13 -0500 Subject: [PATCH 04/14] Added functional test for attachments --- tests/test_send_attachment.database.csv | 3 ++ tests/test_send_attachment.py | 68 ++++++++++++++++++++++++ tests/test_send_attachment.template.txt | 7 +++ tests/test_send_attachment_1.txt | 1 + tests/test_send_attachment_17.txt | 1 + tests/test_send_attachment_2.pdf | Bin 0 -> 5617 bytes tests/test_send_attachment_list.txt | 17 ++++++ 7 files changed, 97 insertions(+) create mode 100644 tests/test_send_attachment.database.csv create mode 100644 tests/test_send_attachment.py create mode 100644 tests/test_send_attachment.template.txt create mode 100644 tests/test_send_attachment_1.txt create mode 100644 tests/test_send_attachment_17.txt create mode 100644 tests/test_send_attachment_2.pdf create mode 100644 tests/test_send_attachment_list.txt diff --git a/tests/test_send_attachment.database.csv b/tests/test_send_attachment.database.csv new file mode 100644 index 0000000..f619ea7 --- /dev/null +++ b/tests/test_send_attachment.database.csv @@ -0,0 +1,3 @@ +email,name,number +myself@mydomain.com,"Myself",17 +bob@bobdomain.com,"Bob",42 diff --git a/tests/test_send_attachment.py b/tests/test_send_attachment.py new file mode 100644 index 0000000..71bafa3 --- /dev/null +++ b/tests/test_send_attachment.py @@ -0,0 +1,68 @@ +"""Test messages with attachments.""" +import os +import unittest +import mailmerge +from mailmerge.smtp_dummy import SMTP_dummy +import future.backports.email as email + + +class TestSendAttachment(unittest.TestCase): + """Test messages with attachments.""" + def setUp(self): + """Change directory to tests/ before any unit test.""" + os.chdir(os.path.dirname(__file__)) + + # Initialize dummy SMTP server + self.smtp = SMTP_dummy() + self.smtp.clear() + + def _validateMessageContents(self, message): + """Validate the contents and attachments of the message.""" + self.assertTrue(message.is_multipart()) + # Make sure the attachments are all present and valid + email_body_present = False + expected_attachments = { + "test_send_attachment_1.txt": False, + "test_send_attachment_2.pdf": False, + "test_send_attachment_17.txt": False, + } + for part in message.walk(): + if part.get_content_maintype() == 'multipart': + continue + if part['content-type'].startswith('text/plain'): + # This is the email body + email_body = part.get_payload() + self.assertEqual(email_body, 'Hi, Myself,\n\nYour number is 17.\n') + email_body_present = True + elif part['content-type'].startswith('application/octet-stream'): + # This is an attachment + filename = part.get_param('name') + file_contents = part.get_payload(decode=True) + self.assertIn(filename, expected_attachments) + self.assertFalse(expected_attachments[filename]) + with open(filename, 'rb') as f: + correct_file_contents = f.read() + self.assertEqual(file_contents, correct_file_contents) + expected_attachments[filename] = True + self.assertTrue(email_body_present) + self.assertNotIn(False, expected_attachments.values()) + + def test_send_attachment(self): + """Attachments should be sent as part of the email.""" + mailmerge.api.main( + attachments_list_filename="test_send_attachment_list.txt", + database_filename="test_send_attachment.database.csv", + template_filename="test_send_attachment.template.txt", + config_filename="server_dummy.conf", + dry_run=False, + no_limit=False, + ) + + # Check SMTP server after + self.assertEqual(self.smtp.msg_from, "My Self ") + recipients = ["myself@mydomain.com"] + self.assertEqual(self.smtp.msg_to, recipients) + + # Check that the message is multipart + message = email.parser.Parser().parsestr(self.smtp.msg) + self._validateMessageContents(message) diff --git a/tests/test_send_attachment.template.txt b/tests/test_send_attachment.template.txt new file mode 100644 index 0000000..b7a0683 --- /dev/null +++ b/tests/test_send_attachment.template.txt @@ -0,0 +1,7 @@ +TO: {{email}} +SUBJECT: Testing mailmerge +FROM: My Self + +Hi, {{name}}, + +Your number is {{number}}. diff --git a/tests/test_send_attachment_1.txt b/tests/test_send_attachment_1.txt new file mode 100644 index 0000000..e9ba671 --- /dev/null +++ b/tests/test_send_attachment_1.txt @@ -0,0 +1 @@ +Test Send Attachment 1 diff --git a/tests/test_send_attachment_17.txt b/tests/test_send_attachment_17.txt new file mode 100644 index 0000000..747770e --- /dev/null +++ b/tests/test_send_attachment_17.txt @@ -0,0 +1 @@ +Test Send Attachment 17 diff --git a/tests/test_send_attachment_2.pdf b/tests/test_send_attachment_2.pdf new file mode 100644 index 0000000000000000000000000000000000000000..06dd26934663ae1b8a254b99a6cfd21804e7b605 GIT binary patch literal 5617 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 literal 0 HcmV?d00001 diff --git a/tests/test_send_attachment_list.txt b/tests/test_send_attachment_list.txt new file mode 100644 index 0000000..c85ddcf --- /dev/null +++ b/tests/test_send_attachment_list.txt @@ -0,0 +1,17 @@ +# Lines beginning with the '#' character are ignored. +# List the filepaths of files that you would like to attach to +# every email. Paths are relative to the directory containing +# this file. + +test_send_attachment_1.txt +test_send_attachment_2.pdf + +# You can also specify a templated filepath to be populated +# with information from the database file. For instance: +test_send_attachment_{{number}}.txt + +# Using this attachments lists file, every email sent would +# have three attachments. + +# NOTE: Don't forget to explicitly specify this attachments +# list file when running mailmerge. From e23f3479472cd9ae1115a6800303a8b142880b92 Mon Sep 17 00:00:00 2001 From: Sesh Sadasivam Date: Sat, 5 Jan 2019 13:34:54 -0500 Subject: [PATCH 05/14] Style fixes --- mailmerge/api.py | 4 ++-- tests/test_send_attachment.py | 13 +++++++------ 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/mailmerge/api.py b/mailmerge/api.py index 70c7656..8497588 100644 --- a/mailmerge/api.py +++ b/mailmerge/api.py @@ -331,8 +331,8 @@ def main(sample=False, if attachments_list_filename is not None: attachment_list = attachment_template.render(**row) message = addattachments(message, - attachment_list, - attachment_parent_dir) + attachment_list, + attachment_parent_dir) # Send message if dry_run: diff --git a/tests/test_send_attachment.py b/tests/test_send_attachment.py index 71bafa3..f4f450d 100644 --- a/tests/test_send_attachment.py +++ b/tests/test_send_attachment.py @@ -1,9 +1,9 @@ """Test messages with attachments.""" import os import unittest +import future.backports.email as email import mailmerge from mailmerge.smtp_dummy import SMTP_dummy -import future.backports.email as email class TestSendAttachment(unittest.TestCase): @@ -16,7 +16,7 @@ def setUp(self): self.smtp = SMTP_dummy() self.smtp.clear() - def _validateMessageContents(self, message): + def _validate_message_contents(self, message): """Validate the contents and attachments of the message.""" self.assertTrue(message.is_multipart()) # Make sure the attachments are all present and valid @@ -32,7 +32,8 @@ def _validateMessageContents(self, message): if part['content-type'].startswith('text/plain'): # This is the email body email_body = part.get_payload() - self.assertEqual(email_body, 'Hi, Myself,\n\nYour number is 17.\n') + expected_email_body = 'Hi, Myself,\n\nYour number is 17.\n' + self.assertEqual(email_body, expected_email_body) email_body_present = True elif part['content-type'].startswith('application/octet-stream'): # This is an attachment @@ -40,8 +41,8 @@ def _validateMessageContents(self, message): file_contents = part.get_payload(decode=True) self.assertIn(filename, expected_attachments) self.assertFalse(expected_attachments[filename]) - with open(filename, 'rb') as f: - correct_file_contents = f.read() + with open(filename, 'rb') as expected_attachment: + correct_file_contents = expected_attachment.read() self.assertEqual(file_contents, correct_file_contents) expected_attachments[filename] = True self.assertTrue(email_body_present) @@ -65,4 +66,4 @@ def test_send_attachment(self): # Check that the message is multipart message = email.parser.Parser().parsestr(self.smtp.msg) - self._validateMessageContents(message) + self._validate_message_contents(message) From 3ae2c3ccd1c2e85046a75bc5261674dab2376a51 Mon Sep 17 00:00:00 2001 From: Sesh Sadasivam Date: Mon, 7 Jan 2019 18:50:24 -0500 Subject: [PATCH 06/14] Attachments specified using pseudo-headers instead of additional config file --- mailmerge/__main__.py | 7 +-- mailmerge/api.py | 84 ++++++------------------- tests/test_send_attachment.py | 1 - tests/test_send_attachment.template.txt | 1 + tests/test_send_attachment_list.txt | 17 ----- 5 files changed, 20 insertions(+), 90 deletions(-) delete mode 100644 tests/test_send_attachment_list.txt diff --git a/mailmerge/__main__.py b/mailmerge/__main__.py index d47bd3c..60bb22c 100644 --- a/mailmerge/__main__.py +++ b/mailmerge/__main__.py @@ -31,12 +31,8 @@ default=mailmerge.api.CONFIG_FILENAME_DEFAULT, help="configuration file name; default " + mailmerge.api.CONFIG_FILENAME_DEFAULT) -@click.option("--attachments-list", "attachments_list_filename", - default=None, - help="attachments list file name; none by default") def cli(sample, dry_run, limit, no_limit, - database_filename, template_filename, config_filename, - attachments_list_filename): + database_filename, template_filename, config_filename): """Command line interface.""" # pylint: disable=too-many-arguments mailmerge.api.main( @@ -47,7 +43,6 @@ def cli(sample, dry_run, limit, no_limit, database_filename=database_filename, template_filename=template_filename, config_filename=config_filename, - attachments_list_filename=attachments_list_filename, ) diff --git a/mailmerge/api.py b/mailmerge/api.py index 8497588..ba87770 100644 --- a/mailmerge/api.py +++ b/mailmerge/api.py @@ -31,7 +31,6 @@ TEMPLATE_FILENAME_DEFAULT = "mailmerge_template.txt" DATABASE_FILENAME_DEFAULT = "mailmerge_database.csv" CONFIG_FILENAME_DEFAULT = "mailmerge_server.conf" -ATTACHMENTS_LIST_FILENAME_DEFAULT = "mailmerge_attachments_list.txt" def parsemail(raw_message): @@ -59,8 +58,11 @@ def parsemail(raw_message): return (message, sender, recipients) -def addattachments(message, attachment_list, attachment_list_parent_dir): +def addattachments(message, template_path): """Add the attachments from the message from the commandline options.""" + if 'attachments' not in message: + return message, 0 + # If the message is not already a multipart message, then make it so if not message.is_multipart(): multipart_message = email.mime.multipart.MIMEMultipart() @@ -71,14 +73,14 @@ def addattachments(message, attachment_list, attachment_list_parent_dir): multipart_message.attach(email.mime.text.MIMEText(original_text)) message = multipart_message - # Remove all comments and empty lines from the attachment list - attachment_filenames = [line - for line in attachment_list.split('\n') - if len(line) != 0 and line[0] != '#'] + attachment_filepaths = [filepath.strip() + for filepath in message['attachments'].split(',') + if filepath.strip()] + template_parent_dir = os.path.dirname(template_path) - for attachment_filepath in attachment_filenames: + for attachment_filepath in attachment_filepaths: # Check that the attachment exists - full_path = attachment_list_parent_dir + attachment_filepath + full_path = template_parent_dir + attachment_filepath normalized_path = os.path.abspath(full_path) if not os.path.exists(normalized_path): print("Error: can't find attachment " + normalized_path) @@ -93,7 +95,8 @@ def addattachments(message, attachment_list, attachment_list_parent_dir): message.attach(part) print(">>> attached {}".format(normalized_path)) - return message + del message['attachments'] + return message, len(attachment_filepaths) def sendmail(message, sender, recipients, config_filename): @@ -150,8 +153,7 @@ def sendmail(message, sender, recipients, config_filename): def create_sample_input_files(template_filename, database_filename, - config_filename, - attachments_list_filename): + config_filename): """Create sample template email and database.""" print("Creating sample template email {}".format(template_filename)) if os.path.exists(template_filename): @@ -218,33 +220,6 @@ def create_sample_input_files(template_filename, u"# security = Never\n" u"# username = YOUR_USERNAME_HERE\n" ) - if attachments_list_filename is None: - attachments_list_filename = ATTACHMENTS_LIST_FILENAME_DEFAULT - print("Creating sample attachments list file", - attachments_list_filename) - if os.path.exists(attachments_list_filename): - print("Error: file exists: " + attachments_list_filename) - sys.exit(1) - with io.open(attachments_list_filename, "w") as attachments_list_file: - attachments_list_file.write( - u"# Lines beginning with the '#' character are ignored.\n" - u'# List the filepaths of files that you would like to attach to\n' - u'# every email. Paths are relative to the directory containing\n' - u'# this file.\n' - u'\n' - u'attachment1.txt\n' - u'attachment2.pdf\n' - u'\n' - u'# You can also specify a templated filepath to be populated\n' - u'# with information from the database file. For instance:\n' - u"../grades/{{name}}/grades.pdf\n" - u'\n' - u'# Using this attachments lists file, every email sent would\n' - u'# have three attachments.\n' - u'\n' - u"# NOTE: Don't forget to explicitly specify this attachments\n" - u"# list file when running mailmerge.\n" - ) print("Edit these files, and then run mailmerge again") @@ -254,8 +229,7 @@ def main(sample=False, no_limit=False, database_filename=DATABASE_FILENAME_DEFAULT, template_filename=TEMPLATE_FILENAME_DEFAULT, - config_filename=CONFIG_FILENAME_DEFAULT, - attachments_list_filename=None): + config_filename=CONFIG_FILENAME_DEFAULT): """Python API for mailmerge. mailmerge 0.1 by Andrew DeOrio . @@ -273,7 +247,6 @@ def main(sample=False, template_filename, database_filename, config_filename, - attachments_list_filename, ) sys.exit(0) if not os.path.exists(template_filename): @@ -284,15 +257,6 @@ def main(sample=False, print("Error: can't find database_filename " + database_filename) print("Create a sample (--sample) or specify a file (--database)") sys.exit(1) - if attachments_list_filename is not None: - print(">>> Reading attachment list from", - attachments_list_filename) - if not os.path.exists(attachments_list_filename): - print("Error: can't find attachments_list_filename", - attachments_list_filename) - print("Create a sample (--sample)", - "or specify a file (--attachments-list)") - sys.exit(1) try: # Read template @@ -307,13 +271,6 @@ def main(sample=False, for row in reader: database.append(row) - # Read attachment list template - if attachments_list_filename is not None: - attachment_parent_dir = os.path.dirname(attachments_list_filename) - with io.open(attachments_list_filename, "r") as attachment_list: - attachment_list_content = attachment_list.read() + u"\n" - attachment_template = jinja2.Template(attachment_list_content) - # Each row corresponds to one email message for i, row in enumerate(database): if not no_limit and i >= limit: @@ -328,11 +285,8 @@ def main(sample=False, # Parse message headers and detect encoding (message, sender, recipients) = parsemail(raw_message) # Add attachments if any - if attachments_list_filename is not None: - attachment_list = attachment_template.render(**row) - message = addattachments(message, - attachment_list, - attachment_parent_dir) + (message, num_attachments) = addattachments(message, + template_filename) # Send message if dry_run: @@ -351,10 +305,8 @@ def main(sample=False, print(">>> sent message {}".format(i)) # Hints for user - if attachments_list_filename is None: - print((">>> No attachments were sent with the emails. " - "To specify attachments, use the" - "--attachments-list option.")) + if num_attachments == 0: + print(">>> No attachments were sent with the emails.") if not no_limit: print(">>> Limit was {} messages. ".format(limit) + "To remove the limit, use the --no-limit option.") diff --git a/tests/test_send_attachment.py b/tests/test_send_attachment.py index f4f450d..395bff1 100644 --- a/tests/test_send_attachment.py +++ b/tests/test_send_attachment.py @@ -51,7 +51,6 @@ def _validate_message_contents(self, message): def test_send_attachment(self): """Attachments should be sent as part of the email.""" mailmerge.api.main( - attachments_list_filename="test_send_attachment_list.txt", database_filename="test_send_attachment.database.csv", template_filename="test_send_attachment.template.txt", config_filename="server_dummy.conf", diff --git a/tests/test_send_attachment.template.txt b/tests/test_send_attachment.template.txt index b7a0683..68bc89e 100644 --- a/tests/test_send_attachment.template.txt +++ b/tests/test_send_attachment.template.txt @@ -1,6 +1,7 @@ TO: {{email}} SUBJECT: Testing mailmerge FROM: My Self +Attachments: test_send_attachment_1.txt, test_send_attachment_2.pdf, , test_send_attachment_{{number}}.txt, Hi, {{name}}, diff --git a/tests/test_send_attachment_list.txt b/tests/test_send_attachment_list.txt deleted file mode 100644 index c85ddcf..0000000 --- a/tests/test_send_attachment_list.txt +++ /dev/null @@ -1,17 +0,0 @@ -# Lines beginning with the '#' character are ignored. -# List the filepaths of files that you would like to attach to -# every email. Paths are relative to the directory containing -# this file. - -test_send_attachment_1.txt -test_send_attachment_2.pdf - -# You can also specify a templated filepath to be populated -# with information from the database file. For instance: -test_send_attachment_{{number}}.txt - -# Using this attachments lists file, every email sent would -# have three attachments. - -# NOTE: Don't forget to explicitly specify this attachments -# list file when running mailmerge. From ca37ce59c8679c22f3299af3744ab36af191bc3a Mon Sep 17 00:00:00 2001 From: Sesh Sadasivam Date: Mon, 7 Jan 2019 19:06:26 -0500 Subject: [PATCH 07/14] Updated README --- README.md | 45 +++++++++++++------------ tests/test_send_attachment.template.txt | 2 +- 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index aca816d..8f0ca90 100644 --- a/README.md +++ b/README.md @@ -242,6 +242,7 @@ Content-Type: text/html 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. **mailmerge_template.txt** + ``` TO: {{email}} SUBJECT: Testing mailmerge @@ -285,26 +286,26 @@ Content-ID: ``` # Attachments -For convenience, `mailmerge` also directly supports sending attachments with emails. Modify the sample `mailmerge_attachments_list.txt` file to specify the attachments. +For convenience, `mailmerge` also directly supports sending attachments with emails. Simply add an "Attachments" header to the template. Attachments are comma-separated, with filenames and paths relative to the template's parent directory. + +**/demo/mailmerge_template.txt** -**mailmerge_attachments_list.txt** ``` -# Lines beginning with an octothorpe are comments. -# Filenames and paths are relative to the parent directory of this file. +TO: {{email}} +SUBJECT: Testing mailmerge +FROM: My Self +ATTACHMENTS: file1.docx, ../files/file2.pdf, {{name}}_submission.txt -file1.pdf -file2.docx -../files/file3.txt +Hi, {{name}}, -# You can also specify a templated filepath to be populated with information -# from the database file. For instance: -../files/{{name}}_submission.txt +This email contains three attachments. +Pro-tip: Use Jinja to customize the attachments based on your database! ``` -To specify that your emails must include attachments, use the `--attachments-list` flag. Dry running the `mailmerge` script checks that all attachments are valid and that they exist. If your attachment list includes template, be sure to dry run with the `--no-limit` flag before actually sending the emails. +Dry running the `mailmerge` script checks that all attachments are valid and that they exist. If your attachment list includes templates, it's a good idea to dry run with the `--no-limit` flag before actually sending the emails. ```shellsession -$ mailmerge --no-limit --attachments-list mailmerge_attachments_list.txt +$ mailmerge --no-limit >>> message 0 TO: myself@mydomain.com SUBJECT: Testing mailmerge @@ -312,13 +313,13 @@ FROM: My Self Hi, Myself, -Your number is 17. +This email contains three attachments. +Pro-tip: Use Jinja to customize the attachments based on your database! >>> encoding ascii ->>> attached /demo/file1.pdf ->>> attached /demo/file2.docx ->>> attached /files/file3.txt ->>> attached /files/Myself_submission.txt +>>> attached file1.docx +>>> attached ../files/file2.pdf +>>> attached ../files/Myself_submission.txt >>> sent message 0 DRY RUN >>> message 1 TO: bob@bobdomain.com @@ -327,13 +328,13 @@ FROM: My Self Hi, Bob, -Your number is 42. +This email contains three attachments. +Pro-tip: Use Jinja to customize the attachments based on your database! >>> encoding ascii ->>> attached /demo/file1.pdf ->>> attached /demo/file2.docx ->>> attached /files/file3.txt ->>> attached /files/Bob_submission.txt +>>> attached file1.docx +>>> attached ../files/file2.pdf +>>> attached ../files/Bob_submission.txt >>> sent message 1 DRY RUN >>> This was a dry run. To send messages, use the --no-dry-run option. ``` diff --git a/tests/test_send_attachment.template.txt b/tests/test_send_attachment.template.txt index 68bc89e..c81d4f6 100644 --- a/tests/test_send_attachment.template.txt +++ b/tests/test_send_attachment.template.txt @@ -1,7 +1,7 @@ TO: {{email}} SUBJECT: Testing mailmerge FROM: My Self -Attachments: test_send_attachment_1.txt, test_send_attachment_2.pdf, , test_send_attachment_{{number}}.txt, +ATTACHMENTS: test_send_attachment_1.txt, test_send_attachment_2.pdf, , test_send_attachment_{{number}}.txt, Hi, {{name}}, From bdd6028dffe74d2b421ae233c4e73e45762afed5 Mon Sep 17 00:00:00 2001 From: Sesh Sadasivam Date: Thu, 10 Jan 2019 09:00:35 -0500 Subject: [PATCH 08/14] Supported duplicate attachment headers --- mailmerge/api.py | 23 +++++++++++++++-------- tests/test_send_attachment.template.txt | 5 ++++- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/mailmerge/api.py b/mailmerge/api.py index ba87770..0f4ea6c 100644 --- a/mailmerge/api.py +++ b/mailmerge/api.py @@ -60,28 +60,35 @@ def parsemail(raw_message): def addattachments(message, template_path): """Add the attachments from the message from the commandline options.""" - if 'attachments' not in message: + if 'attachment' not in message: return message, 0 # If the message is not already a multipart message, then make it so if not message.is_multipart(): multipart_message = email.mime.multipart.MIMEMultipart() - for header_key in message: - multipart_message[header_key] = message[header_key] + for header_key in set(message.keys()): + # Preserve duplicate headers + values = message.get_all(header_key, failobj=[]) + for value in values: + multipart_message[header_key] = value original_text = message.get_payload() assert isinstance(original_text, str) multipart_message.attach(email.mime.text.MIMEText(original_text)) message = multipart_message - attachment_filepaths = [filepath.strip() - for filepath in message['attachments'].split(',') - if filepath.strip()] + attachment_filepaths = message.get_all('attachment', failobj=[]) template_parent_dir = os.path.dirname(template_path) for attachment_filepath in attachment_filepaths: + attachment_filepath = attachment_filepath.strip() + if not attachment_filepath: + continue + if not os.path.isabs(attachment_filepath): + # Relative paths are relative to the template's parent directory + attachment_filepath = os.path.join(template_parent_dir, + attachment_filepath) + normalized_path = os.path.abspath(attachment_filepath) # Check that the attachment exists - full_path = template_parent_dir + attachment_filepath - normalized_path = os.path.abspath(full_path) if not os.path.exists(normalized_path): print("Error: can't find attachment " + normalized_path) sys.exit(1) diff --git a/tests/test_send_attachment.template.txt b/tests/test_send_attachment.template.txt index c81d4f6..698a045 100644 --- a/tests/test_send_attachment.template.txt +++ b/tests/test_send_attachment.template.txt @@ -1,7 +1,10 @@ TO: {{email}} SUBJECT: Testing mailmerge FROM: My Self -ATTACHMENTS: test_send_attachment_1.txt, test_send_attachment_2.pdf, , test_send_attachment_{{number}}.txt, +ATTACHMENT: test_send_attachment_1.txt +ATTACHMENT: test_send_attachment_2.pdf +ATTACHMENT: test_send_attachment_{{number}}.txt +ATTACHMENT: Hi, {{name}}, From 24924d244ad22a3cbe1210e4cf54d2bc7311a2ff Mon Sep 17 00:00:00 2001 From: Sesh Sadasivam Date: Thu, 10 Jan 2019 09:14:45 -0500 Subject: [PATCH 09/14] Updated README --- README.md | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 8f0ca90..9ae2536 100644 --- a/README.md +++ b/README.md @@ -286,7 +286,9 @@ Content-ID: ``` # Attachments -For convenience, `mailmerge` also directly supports sending attachments with emails. Simply add an "Attachments" header to the template. Attachments are comma-separated, with filenames and paths relative to the template's parent directory. +For convenience, `mailmerge` also directly supports sending attachments with emails. Simply add an "Attachments" header to the template. Attachments are comma-separated, with filenames and paths relative to the template's parent directory. Absolute paths are also supported. + +_Note: The Unix-style home directory specifier (`~`) is not supported._ **/demo/mailmerge_template.txt** @@ -294,7 +296,9 @@ For convenience, `mailmerge` also directly supports sending attachments with ema TO: {{email}} SUBJECT: Testing mailmerge FROM: My Self -ATTACHMENTS: file1.docx, ../files/file2.pdf, {{name}}_submission.txt +ATTACHMENT: file1.docx +ATTACHMENT: ../files/file2.pdf +ATTACHMENT: /files/{{name}}_submission.txt Hi, {{name}}, @@ -317,9 +321,9 @@ This email contains three attachments. Pro-tip: Use Jinja to customize the attachments based on your database! >>> encoding ascii ->>> attached file1.docx ->>> attached ../files/file2.pdf ->>> attached ../files/Myself_submission.txt +>>> attached /demo/file1.docx +>>> attached /files/file2.pdf +>>> attached /files/Myself_submission.txt >>> sent message 0 DRY RUN >>> message 1 TO: bob@bobdomain.com @@ -332,9 +336,9 @@ This email contains three attachments. Pro-tip: Use Jinja to customize the attachments based on your database! >>> encoding ascii ->>> attached file1.docx ->>> attached ../files/file2.pdf ->>> attached ../files/Bob_submission.txt +>>> attached /demo/file1.docx +>>> attached /files/file2.pdf +>>> attached /files/Bob_submission.txt >>> sent message 1 DRY RUN >>> This was a dry run. To send messages, use the --no-dry-run option. ``` From c29a60c30c1d543089a6dbe967de93524b2cfb5e Mon Sep 17 00:00:00 2001 From: Andrew DeOrio Date: Fri, 11 Jan 2019 13:45:03 -0500 Subject: [PATCH 10/14] nite: reduce diff to upstream --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 9ae2536..c2c6301 100644 --- a/README.md +++ b/README.md @@ -242,7 +242,6 @@ Content-Type: text/html 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. **mailmerge_template.txt** - ``` TO: {{email}} SUBJECT: Testing mailmerge From d899c5229e1705872b6851c11c01f452a12ad58e Mon Sep 17 00:00:00 2001 From: Andrew DeOrio Date: Fri, 11 Jan 2019 14:08:35 -0500 Subject: [PATCH 11/14] Tighten up the README on attachments --- README.md | 39 +++++++++++---------------------------- 1 file changed, 11 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index c2c6301..6b199c9 100644 --- a/README.md +++ b/README.md @@ -285,19 +285,16 @@ Content-ID: ``` # Attachments -For convenience, `mailmerge` also directly supports sending attachments with emails. Simply add an "Attachments" header to the template. Attachments are comma-separated, with filenames and paths relative to the template's parent directory. Absolute paths are also supported. - -_Note: The Unix-style home directory specifier (`~`) is not supported._ - -**/demo/mailmerge_template.txt** +This example shows how to add attachments with a special `ATTACHMENT` header. +**mailmerge_template.txt** ``` TO: {{email}} SUBJECT: Testing mailmerge FROM: My Self ATTACHMENT: file1.docx -ATTACHMENT: ../files/file2.pdf -ATTACHMENT: /files/{{name}}_submission.txt +ATTACHMENT: ../file2.pdf +ATTACHMENT: /z/shared/{{name}}_submission.txt Hi, {{name}}, @@ -305,10 +302,9 @@ This email contains three attachments. Pro-tip: Use Jinja to customize the attachments based on your database! ``` -Dry running the `mailmerge` script checks that all attachments are valid and that they exist. If your attachment list includes templates, it's a good idea to dry run with the `--no-limit` flag before actually sending the emails. - +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. ```shellsession -$ mailmerge --no-limit +$ mailmerge >>> message 0 TO: myself@mydomain.com SUBJECT: Testing mailmerge @@ -320,28 +316,15 @@ This email contains three attachments. Pro-tip: Use Jinja to customize the attachments based on your database! >>> encoding ascii ->>> attached /demo/file1.docx ->>> attached /files/file2.pdf ->>> attached /files/Myself_submission.txt +>>> attached /Users/awdeorio/Documents/test/file1.docx +>>> attached /Users/awdeorio/Documents/file2.pdf +>>> attached /z/shared/Myself_submission.txt >>> sent message 0 DRY RUN ->>> message 1 -TO: bob@bobdomain.com -SUBJECT: Testing mailmerge -FROM: My Self - -Hi, Bob, - -This email contains three attachments. -Pro-tip: Use Jinja to customize the attachments based on your database! - ->>> encoding ascii ->>> attached /demo/file1.docx ->>> attached /files/file2.pdf ->>> attached /files/Bob_submission.txt ->>> sent message 1 DRY RUN >>> This was a dry run. To send messages, use the --no-dry-run option. ``` +Note: The Unix-style home directory specifier (`~`) is not supported. + # Hacking Set up a development environment. This will install a `mailmerge` executable in virtual environment's `PATH` which points to the local python development source code. ```shellsession From a8613ef1147477831fe6fca4be6c14730897ba42 Mon Sep 17 00:00:00 2001 From: Andrew DeOrio Date: Fri, 11 Jan 2019 16:20:30 -0500 Subject: [PATCH 12/14] More output from pytest failures --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index afb299d..b21eb7c 100644 --- a/tox.ini +++ b/tox.ini @@ -10,7 +10,7 @@ deps = pycodestyle # run these commands commands = - pytest --verbose + pytest --verbose --capture=no pycodestyle mailmerge tests setup.py pydocstyle mailmerge tests setup.py pylint --reports=n mailmerge tests setup.py From bbf8e2d010ee75590ef04fda0fa901f25e396c1e Mon Sep 17 00:00:00 2001 From: Sesh Sadasivam Date: Fri, 11 Jan 2019 16:32:46 -0500 Subject: [PATCH 13/14] Added support for unix-style home specifier in attachment filepath --- README.md | 2 -- mailmerge/api.py | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index 6b199c9..36ae653 100644 --- a/README.md +++ b/README.md @@ -323,8 +323,6 @@ Pro-tip: Use Jinja to customize the attachments based on your database! >>> This was a dry run. To send messages, use the --no-dry-run option. ``` -Note: The Unix-style home directory specifier (`~`) is not supported. - # Hacking Set up a development environment. This will install a `mailmerge` executable in virtual environment's `PATH` which points to the local python development source code. ```shellsession diff --git a/mailmerge/api.py b/mailmerge/api.py index 0f4ea6c..ca69984 100644 --- a/mailmerge/api.py +++ b/mailmerge/api.py @@ -80,7 +80,7 @@ def addattachments(message, template_path): template_parent_dir = os.path.dirname(template_path) for attachment_filepath in attachment_filepaths: - attachment_filepath = attachment_filepath.strip() + attachment_filepath = os.path.expanduser(attachment_filepath.strip()) if not attachment_filepath: continue if not os.path.isabs(attachment_filepath): From efa89fe2588137bf80aebe180a249f1b473bd7c0 Mon Sep 17 00:00:00 2001 From: Sesh Sadasivam Date: Fri, 18 Jan 2019 10:48:09 -0500 Subject: [PATCH 14/14] Hack for Python2 support Used a hack to work around a bug in the backports email module's Message.set_boundary method. See the comments in this commit for details about the workaround. --- mailmerge/api.py | 23 ++++++++++++++++++++++- tests/test_send_attachment.py | 6 +++--- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/mailmerge/api.py b/mailmerge/api.py index ca69984..1ace26e 100644 --- a/mailmerge/api.py +++ b/mailmerge/api.py @@ -58,6 +58,26 @@ def parsemail(raw_message): return (message, sender, recipients) +def _create_boundary(message): + """Add boundary parameter to multipart message if they are not present.""" + if not message.is_multipart() or message.get_boundary() is not None: + return message + # HACK: Python2 lists do not natively have a `copy` method. Unfortunately, + # due to a bug in the Backport for the email module, the method + # `Message.set_boundary` converts the Message headers into a native list, + # so that other methods that rely on "copying" the Message headers fail. + # `Message.set_boundary` is called from `Generator.handle_multipart` if the + # message does not already have a boundary present. (This method itself is + # called from `Message.as_string`.) + # Hence, to prevent `Message.set_boundary` from being called, add a + # boundary header manually. + from future.backports.email.generator import Generator + # pylint: disable=protected-access + boundary = Generator._make_boundary(message.policy.linesep) + message.set_param('boundary', boundary) + return message + + def addattachments(message, template_path): """Add the attachments from the message from the commandline options.""" if 'attachment' not in message: @@ -72,7 +92,6 @@ def addattachments(message, template_path): for value in values: multipart_message[header_key] = value original_text = message.get_payload() - assert isinstance(original_text, str) multipart_message.attach(email.mime.text.MIMEText(original_text)) message = multipart_message @@ -294,6 +313,8 @@ def main(sample=False, # Add attachments if any (message, num_attachments) = addattachments(message, template_filename) + # HACK: For Python2 (see comments in `_create_boundary`) + message = _create_boundary(message) # Send message if dry_run: diff --git a/tests/test_send_attachment.py b/tests/test_send_attachment.py index 395bff1..05247db 100644 --- a/tests/test_send_attachment.py +++ b/tests/test_send_attachment.py @@ -32,8 +32,8 @@ def _validate_message_contents(self, message): if part['content-type'].startswith('text/plain'): # This is the email body email_body = part.get_payload() - expected_email_body = 'Hi, Myself,\n\nYour number is 17.\n' - self.assertEqual(email_body, expected_email_body) + expected_email_body = 'Hi, Myself,\n\nYour number is 17.' + self.assertEqual(email_body.rstrip(), expected_email_body) email_body_present = True elif part['content-type'].startswith('application/octet-stream'): # This is an attachment @@ -54,8 +54,8 @@ def test_send_attachment(self): database_filename="test_send_attachment.database.csv", template_filename="test_send_attachment.template.txt", config_filename="server_dummy.conf", - dry_run=False, no_limit=False, + dry_run=False, ) # Check SMTP server after