Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/Attachments #32

Merged
merged 17 commits into from
Jan 23, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ A simple, command line mail merge tool.
- [Example](#example)
- [Advanced template example](#advanced-template-example)
- [HTML formatting](#html-formatting)
- [Attachments](#attachments)
- [Contributing](#contributing)
- [Acknowledgements](#acknowledgements)

Expand Down Expand Up @@ -305,6 +306,45 @@ Content-ID: <body@here>
</html>
```

## Attachments
This example shows how to add attachments with a special `ATTACHMENT` header.

#### Template `mailmerge_template.txt`
```
TO: {{email}}
SUBJECT: Testing mailmerge
FROM: My Self <[email protected]>
ATTACHMENT: file1.docx
ATTACHMENT: ../file2.pdf
ATTACHMENT: /z/shared/{{name}}_submission.txt

Hi, {{name}},

This email contains three attachments.
Pro-tip: Use Jinja to customize the attachments based on your database!
```

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.
```console
$ mailmerge
>>> message 0
TO: [email protected]
SUBJECT: Testing mailmerge
FROM: My Self <[email protected]>

Hi, Myself,

This email contains three attachments.
Pro-tip: Use Jinja to customize the attachments based on your database!

>>> encoding ascii
>>> attached /Users/awdeorio/Documents/test/file1.docx
>>> attached /Users/awdeorio/Documents/file2.pdf
>>> attached /z/shared/Myself_submission.txt
>>> sent message 0 DRY RUN
>>> This was a dry run. To send messages, use the --no-dry-run option.
```

## Contributing
Contributions from the community are welcome! Check out the [guide for contributing](CONTRIBUTING.md).

Expand Down
90 changes: 84 additions & 6 deletions mailmerge/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -49,12 +53,79 @@ 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 _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:
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 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()
multipart_message.attach(email.mime.text.MIMEText(original_text))
message = multipart_message

attachment_filepaths = message.get_all('attachment', failobj=[])
template_parent_dir = os.path.dirname(template_path)

for attachment_filepath in attachment_filepaths:
attachment_filepath = os.path.expanduser(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
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))

del message['attachments']
return message, len(attachment_filepaths)


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"):
Expand Down Expand Up @@ -102,7 +173,7 @@ 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()


Expand Down Expand Up @@ -238,15 +309,20 @@ 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
(message, num_attachments) = addattachments(message,
seshrs marked this conversation as resolved.
Show resolved Hide resolved
template_filename)
# HACK: For Python2 (see comments in `_create_boundary`)
message = _create_boundary(message)

# Send message
if dry_run:
print(">>> sent message {} DRY RUN".format(i))
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(
Expand All @@ -257,6 +333,8 @@ def main(sample=False,
print(">>> sent message {}".format(i))

# Hints for user
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.")
Expand Down
3 changes: 3 additions & 0 deletions tests/test_send_attachment.database.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
email,name,number
[email protected],"Myself",17
[email protected],"Bob",42
68 changes: 68 additions & 0 deletions tests/test_send_attachment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""Test messages with attachments."""
seshrs marked this conversation as resolved.
Show resolved Hide resolved
import os
import unittest
import future.backports.email as email
import mailmerge
from mailmerge.smtp_dummy import SMTP_dummy


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 _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
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()
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
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 expected_attachment:
correct_file_contents = expected_attachment.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(
database_filename="test_send_attachment.database.csv",
template_filename="test_send_attachment.template.txt",
config_filename="server_dummy.conf",
no_limit=False,
dry_run=False,
)

# Check SMTP server after
self.assertEqual(self.smtp.msg_from, "My Self <[email protected]>")
recipients = ["[email protected]"]
self.assertEqual(self.smtp.msg_to, recipients)

# Check that the message is multipart
message = email.parser.Parser().parsestr(self.smtp.msg)
self._validate_message_contents(message)
11 changes: 11 additions & 0 deletions tests/test_send_attachment.template.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
TO: {{email}}
SUBJECT: Testing mailmerge
FROM: My Self <[email protected]>
ATTACHMENT: test_send_attachment_1.txt
ATTACHMENT: test_send_attachment_2.pdf
ATTACHMENT: test_send_attachment_{{number}}.txt
ATTACHMENT:

Hi, {{name}},

Your number is {{number}}.
1 change: 1 addition & 0 deletions tests/test_send_attachment_1.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Test Send Attachment 1
1 change: 1 addition & 0 deletions tests/test_send_attachment_17.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Test Send Attachment 17
Binary file added tests/test_send_attachment_2.pdf
Binary file not shown.