Repository: awdeorio/mailmerge

Issue:
Apple Mail with attachments issue (former title: UTF-8 support issue)
I have tried sending PDFs (diplomas) to participants of an event, and I've used UTF-8 in file names as well as in the body of the text. The result was pretty mixed: some mail clients displayed only the text, others just the attachment. 

How stable is UTF-8 support by mailmerge? Would it be possible for example to control if its base64 or quoted printable etc.?

I don't know much about MIME and it's quite painful to debug mailmerge in a 'production' situation... But it looks like a very easy to use and versatile tool that would be very useful to me :-)

Discussion:

Patch:
diff --git a/mailmerge/template_message.py b/mailmerge/template_message.py
index 9ea5caa..638e141 100644
--- a/mailmerge/template_message.py
+++ b/mailmerge/template_message.py
@@ -1,3 +1,5 @@
+# coding=utf-8
+# Python 2 source containing unicode https://www.python.org/dev/peps/pep-0263/
 """
 Represent a templated email message.
 
@@ -94,13 +96,19 @@ class TemplateMessage(object):
         self._sender = self._message["from"]
 
     def _make_message_multipart(self):
-        """Convert a message into a multipart message."""
+        """
+        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
+        headers and re-attach the original payload.
+        """
         # Do nothing if message already multipart
         if self._message.is_multipart():
             return
 
         # Create empty multipart message
-        multipart_message = email.mime.multipart.MIMEMultipart('alternative')
+        multipart_message = email.mime.multipart.MIMEMultipart('mixed')
 
         # Copy headers.  Avoid duplicate Content-Type and MIME-Version headers,
         # which we set explicitely.  MIME-Version was set when we created an
@@ -127,7 +135,20 @@ class TemplateMessage(object):
         self._message = multipart_message
 
     def _transform_markdown(self):
-        """Convert markdown in message text to HTML."""
+        """
+        Convert markdown in message text to HTML.
+
+        Specifically, if the message's content-type is `text/markdown`, we
+        transform `self._message` to have the following structure:
+
+        multipart/mixed
+         └── 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.
+        """
         # Do nothing if Content-Type is not text/markdown
         if not self._message['Content-Type'].startswith("text/markdown"):
             return
@@ -143,29 +164,64 @@ class TemplateMessage(object):
         # plaintext payload is formatted with Markdown.
         for mimetext in self._message.get_payload():
             if mimetext['Content-Type'].startswith('text/plain'):
+                original_text_payload = mimetext
                 encoding = str(mimetext.get_charset())
                 text = mimetext.get_payload(decode=True).decode(encoding)
                 break
+        assert original_text_payload
         assert encoding
         assert text
+        # Remove the original text payload.
+        self._message.set_payload(
+            self._message.get_payload().remove(original_text_payload))
 
+        # Add a multipart/alternative part to the message. Email clients can
+        # choose which payload-part they wish to render.
+        #
         # Render Markdown to HTML and add the HTML as the last part of the
-        # multipart message as per RFC 2046.
+        # multipart/alternative message as per RFC 2046.
         #
         # Note: We need to use u"..." to ensure that unicode string
         # substitution works properly in Python 2.
         #
         # https://docs.python.org/3/library/email.mime.html#email.mime.text.MIMEText
         html = markdown.markdown(text, extensions=['nl2br'])
-        payload = future.backports.email.mime.text.MIMEText(
+        html_payload = future.backports.email.mime.text.MIMEText(
             u"<html><body>{}</body></html>".format(html),
             _subtype="html",
             _charset=encoding,
         )
-        self._message.attach(payload)
+
+        message_payload = email.mime.multipart.MIMEMultipart('alternative')
+        message_payload.attach(original_text_payload)
+        message_payload.attach(html_payload)
+
+        self._message.attach(message_payload)
 
     def _transform_attachments(self):
-        """Parse Attachment headers and add attachments."""
+        """
+        Parse Attachment headers and add attachments.
+
+        Attachments are added to the payload of a `multipart/mixed` message.
+        For instance, a plaintext message with attachments would have the
+        following structure:
+
+        multipart/mixed
+         ├── text/plain
+         ├── attachment1
+         └── attachment2
+
+        Another example: If the original message contained `text/markdown`,
+        then the message would have the following structure after transforming
+        markdown and attachments:
+
+        multipart/mixed
+         ├── multipart/alternative
+         │   ├── text/plain
+         │   └── text/html
+         ├── attachment1
+         └── attachment2
+        """
         # Do nothing if message has no attachment header
         if 'attachment' not in self._message:
             return


Test patch:
diff --git a/tests/test_template_message.py b/tests/test_template_message.py
index 798c8d8..28cc9df 100644
--- a/tests/test_template_message.py
+++ b/tests/test_template_message.py
@@ -274,20 +274,26 @@ def test_markdown(tmp_path):
 
     # Verify message is multipart
     assert message.is_multipart()
+    assert message.get_content_subtype() == "mixed"
 
-    # Make sure there is a plaintext part and an HTML part
-    payload = message.get_payload()
-    assert len(payload) == 2
+    # Make sure there is a single multipart/alternative payload
+    assert len(message.get_payload()) == 1
+    assert message.get_payload()[0].is_multipart()
+    assert message.get_payload()[0].get_content_subtype() == "alternative"
+
+    # And there should be a plaintext part and an HTML part
+    message_payload = message.get_payload()[0].get_payload()
+    assert len(message_payload) == 2
 
     # Ensure that the first part is plaintext and the last part
     # is HTML (as per RFC 2046)
-    plaintext_part = payload[0]
+    plaintext_part = message_payload[0]
     assert plaintext_part['Content-Type'].startswith("text/plain")
     plaintext_encoding = str(plaintext_part.get_charset())
     plaintext = plaintext_part.get_payload(decode=True) \
                               .decode(plaintext_encoding)
 
-    html_part = payload[1]
+    html_part = message_payload[1]
     assert html_part['Content-Type'].startswith("text/html")
     html_encoding = str(html_part.get_charset())
     htmltext = html_part.get_payload(decode=True) \
@@ -323,7 +329,7 @@ def test_markdown_encoding(tmp_path):
 
     # Message should contain an unrendered Markdown plaintext part and a
     # rendered Markdown HTML part
-    plaintext_part, html_part = message.get_payload()
+    plaintext_part, html_part = message.get_payload()[0].get_payload()
 
     # Verify encodings
     assert str(plaintext_part.get_charset()) == "utf-8"
@@ -683,16 +689,19 @@ def test_contenttype_attachment_markdown_body(tmpdir):
         template_message = TemplateMessage(template_path)
         _, _, message = template_message.render({})
 
-    # Markdown: Make sure there is a plaintext part and an HTML part
     payload = message.get_payload()
-    assert len(payload) == 3
+    assert len(payload) == 2
+
+    # Markdown: Make sure there is a plaintext part and an HTML part
+    message_payload = payload[0].get_payload()
+    assert len(message_payload) == 2
 
     # Ensure that the first part is plaintext and the second part
     # is HTML (as per RFC 2046)
-    plaintext_part = payload[0]
+    plaintext_part = message_payload[0]
     assert plaintext_part['Content-Type'].startswith("text/plain")
 
-    html_part = payload[1]
+    html_part = message_payload[1]
     assert html_part['Content-Type'].startswith("text/html")
 
 
diff --git a/tests/test_template_message_encodings.py b/tests/test_template_message_encodings.py
index c244748..643c927 100644
--- a/tests/test_template_message_encodings.py
+++ b/tests/test_template_message_encodings.py
@@ -191,7 +191,8 @@ def test_emoji_markdown(tmp_path):
 
     # Message should contain an unrendered Markdown plaintext part and a
     # rendered Markdown HTML part
-    plaintext_part, html_part = message.get_payload()
+    message_payload = message.get_payload()[0]
+    plaintext_part, html_part = message_payload.get_payload()
 
     # Verify encodings
     assert str(plaintext_part.get_charset()) == "utf-8"