Ver código fonte

Merge pull request #38 from Miksus/dev/email_structure

BUG: Email structure
Mikael Koli 3 anos atrás
pai
commit
d639f8176b

+ 36 - 19
ci/test_send.py

@@ -17,18 +17,18 @@ email = EmailSender(
 logger = logging.getLogger(__name__)
 logger.setLevel(logging.DEBUG)
 
-def send():
+def send_empty():
     msg = email.send(
         sender=os.environ['EMAIL_SENDER'],
         receivers=os.environ['EMAIL_RECEIVERS'].split(","),
-        subject="An example",
+        subject="Empty email",
     )
 
 def send_text():
     msg = email.send(
         sender=os.environ['EMAIL_SENDER'],
         receivers=os.environ['EMAIL_RECEIVERS'].split(","),
-        subject="An example",
+        subject="Email with text",
         text="Hi, this is an example email.",
     )
 
@@ -36,17 +36,17 @@ def send_html():
     msg = email.send(
         sender=os.environ['EMAIL_SENDER'],
         receivers=os.environ['EMAIL_RECEIVERS'].split(","),
-        subject="An example",
-        html="<h1>Hi,</h1><p>this is an example email.</p>",
+        subject="Email with HTML",
+        html="<h2>This is HTML.</h2>",
     )
 
 def send_test_and_html():
     msg = email.send(
         sender=os.environ['EMAIL_SENDER'],
         receivers=os.environ['EMAIL_RECEIVERS'].split(","),
-        subject="An example",
-        text="<h1>Hi,</h1><p>this is an example email.</p>",
-        html="<h1>Hi,</h1><p>this is an example email.</p>",
+        subject="Email with text and HTML",
+        text="This is text (with HTML).",
+        html="<h2>This is HTML (with text).</h2>",
     )
 
 
@@ -54,7 +54,7 @@ def send_attachments():
     msg = email.send(
         sender=os.environ['EMAIL_SENDER'],
         receivers=os.environ['EMAIL_RECEIVERS'].split(","),
-        subject="An attachment",
+        subject="Email with attachment",
         attachments={"a_file.html": (Path(__file__).parent / "file.html")}
     )
 
@@ -62,8 +62,8 @@ def send_attachments_with_text():
     msg = email.send(
         sender=os.environ['EMAIL_SENDER'],
         receivers=os.environ['EMAIL_RECEIVERS'].split(","),
-        subject="An attachment with text body",
-        text="Hi, this contains an attachment.",
+        subject="Email with attachment and text",
+        text="This contains an attachment.",
         attachments={"a_file.html": (Path(__file__).parent / "file.html")}
     )
 
@@ -71,18 +71,33 @@ def send_attachments_with_html():
     msg = email.send(
         sender=os.environ['EMAIL_SENDER'],
         receivers=os.environ['EMAIL_RECEIVERS'].split(","),
-        subject="An attachment with HTML body",
-        html="<h1>Hi, this contains an attachment.</h1>",
+        subject="Email with attachment and HTML",
+        html="<h1>This contains an attachment.</h1>",
         attachments={"a_file.html": (Path(__file__).parent / "file.html")}
     )
 
-def send_attachments_with_text_and_html():
+def send_attachments_with_html_and_image():
     msg = email.send(
         sender=os.environ['EMAIL_SENDER'],
         receivers=os.environ['EMAIL_RECEIVERS'].split(","),
-        subject="An attachment with text and HTML body",
-        text="Hi, this contains an attachment",
-        html="<h1>Hi, this contains an attachment.</h1>",
+        subject="Email with attachment, HTML and inline image",
+        html="<h1>This contains an attachment and image.</h1>{{ path_image }}",
+        body_images={
+            "path_image": Path(__file__).parent.parent / "docs/imgs/email_emb_img.png",
+        },
+        attachments={"a_file.html": (Path(__file__).parent / "file.html")}
+    )
+
+def send_full_features():
+    msg = email.send(
+        sender=os.environ['EMAIL_SENDER'],
+        receivers=os.environ['EMAIL_RECEIVERS'].split(","),
+        subject="Email with full features",
+        text="Hi, this contains an attachment and image.",
+        html="<h1>This contains text, HTML, an attachment and inline image.</h1>{{ path_image }}",
+        body_images={
+            "path_image": Path(__file__).parent.parent / "docs/imgs/email_emb_img.png",
+        },
         attachments={"a_file.html": (Path(__file__).parent / "file.html")}
     )
 
@@ -194,15 +209,17 @@ def log_simple():
 
 
 if __name__ == "__main__":
-    fn_bodies = [send, send_text, send_html, send_test_and_html]
-    fn_attachments = [send_attachments, send_attachments_with_text, send_attachments_with_html, send_attachments_with_text_and_html]
+    fn_bodies = [send_empty, send_text, send_html, send_test_and_html, send_full_features]
+    fn_attachments = [send_attachments, send_attachments_with_text, send_attachments_with_html]
     fn_log = [log_simple, log_multi]
 
     funcs = {
         "minimal": fn_bodies[0],
+        "bodies": fn_bodies,
         "full": fn_bodies + fn_attachments + fn_log,
         "logging": fn_log,
     }[os.environ.get("EMAIL_FUNCS", "full")]
     for func in funcs:
+        print("Running:", func.__name__)
         time.sleep(1)
         func()

+ 69 - 1
docs/references.rst

@@ -20,4 +20,72 @@ Logging Classes
 
 .. autoclass:: redmail.EmailHandler
 
-.. autoclass:: redmail.MultiEmailHandler
+.. autoclass:: redmail.MultiEmailHandler
+
+
+.. _email_structure:
+
+Email Strucure (MIME)
+---------------------
+
+This section covers how Red Mail structures emails with MIME parts.
+You may need this information if you are creating unit tests or 
+if you face problems with rendering your emails by your email provider.
+
+Empty Email
+^^^^^^^^^^^
+
+Empty email has no MIME parts attached. It only has the headers.
+
+
+Email with a text body
+^^^^^^^^^^^^^^^^^^^^^^
+
+* text/plain
+
+
+Email with an HTML body
+^^^^^^^^^^^^^^^^^^^^^^^
+
+* multipart/mixed
+
+    * multipart/alternative
+
+        * text/html
+
+
+Email with an HTML body and inline JPG image
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+* multipart/mixed
+
+    * multipart/alternative
+
+        * multipart/related
+
+            * text/html
+            * image/jpg
+
+
+Email with an attachment
+^^^^^^^^^^^^^^^^^^^^^^^^
+
+* multipart/mixed
+
+    * application/octet-stream
+
+
+Email with all elements
+^^^^^^^^^^^^^^^^^^^^^^^
+
+* multipart/mixed
+
+    * multipart/alternative
+
+        * text/plain
+        * multipart/related
+
+            * text/html
+            * image/jpg
+
+    * application/octet-stream

+ 4 - 1
docs/tutorials/testing.rst

@@ -12,7 +12,10 @@ with unit tests. There are several ways to do this.
 
     Red Mail extends :stdlib:`email.message.EmailMessage <email.message.html#email.message.EmailMessage>`
     from standard library. You may use its attributes and
-    methods for testing the contents of your messages.
+    methods for testing the contents of your messages. 
+    
+    See :ref:`email_structure` for how Red Mail's
+    emails are structured.
 
 Using get_message
 -----------------

+ 0 - 6
redmail/email/attachment.py

@@ -18,12 +18,6 @@ class Attachments:
         self.encoding = encoding
 
     def attach(self, msg:EmailMessage):
-        if msg.get_content_type() == "text/plain":
-            # We need to change the content type
-            # This occurs if no body is defined or only text is defined
-            # The content type is therefore changed to multipart/mixed
-            # See issue #23
-            msg.make_mixed()
         for part in self._get_parts():
             msg.attach(part)
 

+ 26 - 3
redmail/email/sender.py

@@ -323,8 +323,10 @@ class EmailSender:
             cc=cc,
             bcc=bcc,
         )
-
-        if text is not None or text_template is not None:
+        has_text = text is not None or text_template is not None
+        has_html = html is not None or html_template is not None
+        has_attachments = attachments is not None
+        if has_text:
             body = TextBody(
                 template=self.get_text_template(text_template),
                 table_template=self.get_text_table_template(),
@@ -337,7 +339,7 @@ class EmailSender:
                 jinja_params=self.get_text_params(extra=body_params, sender=sender),
             )
 
-        if html is not None or html_template is not None:
+        if has_html:
             body = HTMLBody(
                 template=self.get_html_template(html_template),
                 table_template=self.get_html_table_template(),
@@ -350,6 +352,14 @@ class EmailSender:
                 tables=body_tables,
                 jinja_params=self.get_html_params(extra=body_params, sender=sender)
             )
+        
+        self._set_content_type(
+            msg,
+            has_text=has_text,
+            has_html=has_html,
+            has_attachments=has_attachments,
+        )
+
         if attachments:
             att = Attachments(attachments, encoding=self.attachment_encoding)
             att.attach(msg)
@@ -385,6 +395,19 @@ class EmailSender:
             msg['bcc'] = bcc
         return msg
 
+    def _set_content_type(self, msg:EmailMessage, has_text, has_html, has_attachments):
+
+        # NOTE: we don't convert emails that have only text/plain to multiplart/mixed
+        # in order to keep the messages minimal (as often desired with simple plain text)
+
+        if has_html or has_attachments:
+            # Change the structure to multipart/mixed if possible.
+            # This seems to be the most versatile and most unproblematic top level content-type
+            # as otherwise content may be missing or it may be misrendered.
+            # See: https://stackoverflow.com/a/23853079/13696660
+            # See issues: #23, #37
+            msg.make_mixed()
+
     def send_message(self, msg:EmailMessage):
         "Send the created message"
         if self.is_alive:

+ 42 - 5
redmail/test/email/test_attachments.py

@@ -7,7 +7,7 @@ from pathlib import Path
 import pytest
 
 from resources import get_mpl_fig, get_pil_image
-from convert import remove_extra_lines
+from convert import remove_extra_lines, payloads_to_dict
 
 def to_encoded(s:str):
     return str(base64.b64encode(s.encode()), 'ascii')
@@ -23,6 +23,14 @@ def test_with_text():
         text="Hi, this is an email",
         attachments={'data.txt': "Some content"}
     )
+    # Validate structure
+    assert payloads_to_dict(msg) == {
+        'multipart/mixed': {
+            'application/octet-stream': 'U29tZSBjb250ZW50\n',
+            'text/plain': 'Hi, this is an email\n',
+        }
+    }
+
     assert msg.get_content_type() == "multipart/mixed"
 
     text, attachment = msg.get_payload()
@@ -45,9 +53,20 @@ def test_with_html():
         html="<h1>Hi, this is an email.</h1>",
         attachments={'data.txt': "Some content"}
     )
-    assert msg.get_content_type() == "multipart/alternative"
+    # Validate structure
+    assert payloads_to_dict(msg) == {
+        'multipart/mixed': {
+            'application/octet-stream': 'U29tZSBjb250ZW50\n',
+            'multipart/alternative': {
+                'text/html': '<h1>Hi, this is an email.</h1>\n'
+            }
+        }
+    }
+
+    assert msg.get_content_type() == "multipart/mixed"
 
-    html, attachment = msg.get_payload()
+    alternative, attachment = msg.get_payload()
+    html = alternative.get_payload()[0]
     assert html.get_content_type() == 'text/html'
     assert attachment.get_content_type() == 'application/octet-stream'
 
@@ -70,9 +89,20 @@ def test_with_text_and_html():
         html="<h1>Hi, this is an email.</h1>",
         attachments={'data.txt': "Some content"}
     )
-    assert msg.get_content_type() == "multipart/alternative"
+    # Validate structure
+    assert payloads_to_dict(msg) == {
+        'multipart/mixed': {
+            'application/octet-stream': 'U29tZSBjb250ZW50\n',
+            'multipart/alternative': {
+                'text/html': '<h1>Hi, this is an email.</h1>\n',
+                'text/plain': 'Hi, this is an email.\n'
+            }
+        }
+    }
+    assert msg.get_content_type() == "multipart/mixed"
 
-    text, html, attachment = msg.get_payload()
+    alternative, attachment = msg.get_payload()
+    text, html = alternative.get_payload()
     assert text.get_content_type() == "text/plain"
     assert html.get_content_type() == "text/html"
     assert attachment.get_content_type() == 'application/octet-stream'
@@ -94,6 +124,13 @@ def test_no_body():
         subject="Some news",
         attachments={'data.txt': 'Some content'}
     )
+    # Validate structure
+    assert payloads_to_dict(msg) == {
+        'multipart/mixed': {
+            'application/octet-stream': 'U29tZSBjb250ZW50\n',
+        }
+    }
+
     assert msg.get_content_type() == "multipart/mixed"
     assert len(msg.get_payload()) == 1
 

+ 102 - 68
redmail/test/email/test_body.py

@@ -1,99 +1,109 @@
+from textwrap import dedent
 from redmail import EmailSender
 
 import pytest
 
-from convert import remove_extra_lines
+from convert import remove_extra_lines, payloads_to_dict
 from getpass import getpass, getuser
 from platform import node
 
-from convert import remove_email_extra
+from convert import remove_email_extra, remove_email_content_id
 
 def test_text_message():
-    text = "Hi, nice to meet you."
 
     sender = EmailSender(host=None, port=1234)
     msg = sender.get_message(
-        sender="me@gmail.com",
-        receivers="you@gmail.com",
+        sender="me@example.com",
+        receivers="you@example.com",
         subject="Some news",
-        text=text,
+        text="Hi, nice to meet you.",
     )
-    payload = msg.get_payload()
-    expected_headers = {
-        'from': 'me@gmail.com', 
-        'subject': 'Some news', 
-        'to': 'you@gmail.com', 
-        'MIME-Version': '1.0', 
-        'Content-Type': 'text/plain; charset="utf-8"',
-        'Content-Transfer-Encoding': '7bit',
-    }
+    assert str(msg) == dedent("""
+    from: me@example.com
+    subject: Some news
+    to: you@example.com
+    Content-Type: text/plain; charset="utf-8"
+    Content-Transfer-Encoding: 7bit
+    MIME-Version: 1.0
 
-    assert "text/plain" == msg.get_content_type()
-    assert text + "\n" == payload
+    Hi, nice to meet you.
+    """)[1:]
 
-    # Test receivers etc.
-    headers = dict(msg.items())
-    assert expected_headers == headers
 
 def test_html_message():
-    html = "<h3>Hi,</h3><p>Nice to meet you</p>"
 
     sender = EmailSender(host=None, port=1234)
     msg = sender.get_message(
-        sender="me@gmail.com",
-        receivers="you@gmail.com",
+        sender="me@example.com",
+        receivers="you@example.com",
         subject="Some news",
-        html=html,
+        html="<h3>Hi,</h3><p>Nice to meet you</p>",
     )
-    payload = msg.get_payload()
-    expected_headers = {
-        'from': 'me@gmail.com', 
-        'subject': 'Some news', 
-        'to': 'you@gmail.com', 
-        #'MIME-Version': '1.0', 
-        'Content-Type': 'multipart/alternative'
-    }
 
-    assert "multipart/alternative" == msg.get_content_type()
-    assert html + "\n" == payload[0].get_content()
+    assert remove_email_content_id(str(msg)) == dedent("""
+    from: me@example.com
+    subject: Some news
+    to: you@example.com
+    Content-Type: multipart/mixed; boundary="===============<ID>=="
+
+    --===============<ID>==
+    Content-Type: multipart/alternative;
+     boundary="===============<ID>=="
+
+    --===============<ID>==
+    Content-Type: text/html; charset="utf-8"
+    Content-Transfer-Encoding: 7bit
+    MIME-Version: 1.0
+
+    <h3>Hi,</h3><p>Nice to meet you</p>
+
+    --===============<ID>==--
+
+    --===============<ID>==--
+    """)[1:]
 
-    # Test receivers etc.
-    headers = dict(msg.items())
-    assert expected_headers == headers
 
 def test_text_and_html_message():
-    html = "<h3>Hi,</h3><p>nice to meet you.</p>"
-    text = "Hi, nice to meet you."
 
     sender = EmailSender(host=None, port=1234)
     msg = sender.get_message(
-        sender="me@gmail.com",
-        receivers="you@gmail.com",
+        sender="me@example.com",
+        receivers="you@example.com",
         subject="Some news",
-        html=html,
-        text=text,
+        html="<h3>Hi,</h3><p>nice to meet you.</p>",
+        text="Hi, nice to meet you.",
     )
-    payload = msg.get_payload()
-    expected_headers = {
-        'from': 'me@gmail.com', 
-        'subject': 'Some news', 
-        'to': 'you@gmail.com', 
-        'MIME-Version': '1.0', 
-        'Content-Type': 'multipart/alternative'
-    }
 
-    assert "multipart/alternative" == msg.get_content_type()
+    assert remove_email_content_id(str(msg)) == dedent("""
+    from: me@example.com
+    subject: Some news
+    to: you@example.com
+    MIME-Version: 1.0
+    Content-Type: multipart/mixed; boundary="===============<ID>=="
 
-    assert "text/plain" == payload[0].get_content_type()
-    assert text + "\n" == payload[0].get_content()
+    --===============<ID>==
+    Content-Type: multipart/alternative;
+     boundary="===============<ID>=="
+
+    --===============<ID>==
+    Content-Type: text/plain; charset="utf-8"
+    Content-Transfer-Encoding: 7bit
+
+    Hi, nice to meet you.
+
+    --===============<ID>==
+    Content-Type: text/html; charset="utf-8"
+    Content-Transfer-Encoding: 7bit
+    MIME-Version: 1.0
+
+    <h3>Hi,</h3><p>nice to meet you.</p>
+
+    --===============<ID>==--
+
+    --===============<ID>==--
+    """)[1:]
 
-    assert "text/html" == payload[1].get_content_type()
-    assert html + "\n" == payload[1].get_content()
 
-    # Test receivers etc.
-    headers = dict(msg.items())
-    assert expected_headers == headers
-    
 @pytest.mark.parametrize(
     "html,expected_html,text,expected_text,extra", [
         pytest.param(
@@ -130,10 +140,23 @@ def test_with_jinja_params(html, text, extra, expected_html, expected_text):
         body_params=extra
     )
     
-    assert "multipart/alternative" == msg.get_content_type()
+    # Validate structure
+    structure = payloads_to_dict(msg)
+    assert structure == {
+        'multipart/mixed': {
+            'multipart/alternative': {
+                'text/plain': structure["multipart/mixed"]["multipart/alternative"]["text/plain"],
+                'text/html': structure["multipart/mixed"]["multipart/alternative"]["text/html"],
+            }
+        }
+    }
 
-    text = remove_email_extra(msg.get_payload()[0].get_payload())
-    html = remove_email_extra(msg.get_payload()[1].get_payload())
+    assert "multipart/mixed" == msg.get_content_type()
+    alternative = msg.get_payload()[0]
+    text_part, html_part = alternative.get_payload()
+    
+    text = remove_email_extra(text_part.get_payload())
+    html = remove_email_extra(html_part.get_payload())
 
     assert expected_html == html
     assert expected_text == text
@@ -180,8 +203,12 @@ def test_with_error():
             text="Error occurred \n{{ error }}",
             html="<h1>Error occurred: </h1>{{ error }}",
         )
-    text = remove_email_extra(msg.get_payload()[0].get_payload())
-    html = remove_email_extra(msg.get_payload()[1].get_payload())
+
+    alternative = msg.get_payload()[0]
+    text_part, html_part = alternative.get_payload()
+
+    text = remove_email_extra(text_part.get_payload())
+    html = remove_email_extra(html_part.get_payload())
 
     assert text.startswith('Error occurred\nTraceback (most recent call last):\n  File "')
     assert text.endswith(', in test_with_error\n    raise RuntimeError("Deliberate failure")\nRuntimeError: Deliberate failure\n')
@@ -206,8 +233,15 @@ def test_set_defaults():
 
 def test_cc_bcc():
     email = EmailSender(host=None, port=1234)
-    msg = email.get_message(sender="me@example.com", subject="Something", cc=['you@example.com'], bcc=['he@example.com', 'she@example.com'])
-    assert dict(msg.items()) == {'from': 'me@example.com', 'subject': 'Something', 'cc': 'you@example.com', 'bcc': 'he@example.com, she@example.com'}
+    msg = email.get_message(sender="me@example.com", subject="Some email", cc=['you@example.com'], bcc=['he@example.com', 'she@example.com'])
+
+    assert remove_email_content_id(str(msg)) == dedent("""
+    from: me@example.com
+    subject: Some email
+    cc: you@example.com
+    bcc: he@example.com, she@example.com
+
+    """)[1:]
 
 def test_missing_subject():
     email = EmailSender(host=None, port=1234)
@@ -235,5 +269,5 @@ def test_no_table_templates():
         'subject': 'Some news', 
         'to': 'you@gmail.com', 
         'MIME-Version': '1.0', 
-        'Content-Type': 'multipart/alternative',
+        'Content-Type': 'multipart/mixed',
     }

+ 10 - 6
redmail/test/email/test_cookbook.py

@@ -1,7 +1,10 @@
 
 from typing import Union
+from textwrap import dedent
 from redmail import EmailSender
 
+from convert import remove_email_content_id
+
 def test_distributions():
     class DistrSender(EmailSender):
         "Send email using pre-defined distribution lists"
@@ -37,9 +40,10 @@ def test_distributions():
         cc="group2",
         subject="Some email",
     )
-    assert dict(msg.items()) == {
-        'from': 'me@example.com', 
-        'subject': 'Some email', 
-        'to': 'me@example.com, you@example.com', 
-        'cc': 'he@example.com, she@example.com', 
-    }
+    assert remove_email_content_id(str(msg)) == dedent("""
+    from: me@example.com
+    subject: Some email
+    to: me@example.com, you@example.com
+    cc: he@example.com, she@example.com
+
+    """)[1:]

+ 36 - 19
redmail/test/email/test_inline_media.py

@@ -12,7 +12,7 @@ import pytest
 from redmail.email.utils import pd
 
 from resources import get_mpl_fig, get_pil_image
-from convert import remove_extra_lines
+from convert import remove_extra_lines, payloads_to_dict
 
 
 def compare_image_mime(mime_part, mime_part_html, orig_image:bytes, type_="image/png"):
@@ -52,11 +52,12 @@ def test_with_image_file(get_image_obj, dummy_png):
         body_images={"my_image": image_obj}
     )
     
-    assert "multipart/alternative" == msg.get_content_type()
+    assert "multipart/mixed" == msg.get_content_type()
 
-    #mime_text = msg.get_payload()[0]
-    mime_html = msg.get_payload()[0].get_payload()[0]
-    mime_image  = msg.get_payload()[0].get_payload()[1]
+    alternative = msg.get_payload()[0]
+    related = alternative.get_payload()[0]
+
+    mime_html, mime_image = related.get_payload()
 
     compare_image_mime(mime_image, mime_html, orig_image=dummy_bytes)
 
@@ -67,7 +68,7 @@ def test_with_image_file(get_image_obj, dummy_png):
         'subject': 'Some news', 
         'to': 'you@gmail.com', 
         #'MIME-Version': '1.0', 
-        'Content-Type': 'multipart/alternative'
+        'Content-Type': 'multipart/mixed'
     } == headers
 
 def test_with_image_dict_jpeg():
@@ -87,12 +88,24 @@ def test_with_image_dict_jpeg():
             }
         }
     )
-    
-    assert "multipart/alternative" == msg.get_content_type()
+    # Validate structure
+    structure = payloads_to_dict(msg)
+    assert structure == {
+        "multipart/mixed": {
+            "multipart/alternative": {
+                "multipart/related": {
+                    "text/html": structure["multipart/mixed"]["multipart/alternative"]["multipart/related"]["text/html"],
+                    "image/jpg": structure["multipart/mixed"]["multipart/alternative"]["multipart/related"]["image/jpg"],
+                }
+            }
+        }
+    }
+    assert "multipart/mixed" == msg.get_content_type()
 
-    #mime_text = msg.get_payload()[0]
-    mime_html = msg.get_payload()[0].get_payload()[0]
-    mime_image  = msg.get_payload()[0].get_payload()[1]
+    alternative = msg.get_payload()[0]
+    related = alternative.get_payload()[0]
+
+    mime_html, mime_image = related.get_payload()
 
     compare_image_mime(mime_image, mime_html, orig_image=img_bytes, type_="image/jpg")
 
@@ -103,7 +116,7 @@ def test_with_image_dict_jpeg():
         'subject': 'Some news', 
         'to': 'you@gmail.com', 
         #'MIME-Version': '1.0', 
-        'Content-Type': 'multipart/alternative'
+        'Content-Type': 'multipart/mixed'
     } == headers
 
 
@@ -125,11 +138,12 @@ def test_with_image_obj(get_image_obj):
         body_images={"my_image": image_obj}
     )
     
-    assert "multipart/alternative" == msg.get_content_type()
+    assert "multipart/mixed" == msg.get_content_type()
 
-    #mime_text = msg.get_payload()[0]
-    mime_html = msg.get_payload()[0].get_payload()[0]
-    mime_image  = msg.get_payload()[0].get_payload()[1]
+    alternative = msg.get_payload()[0]
+    related = alternative.get_payload()[0]
+
+    mime_html, mime_image = related.get_payload()
 
     compare_image_mime(mime_image, mime_html, orig_image=image_bytes)
 
@@ -140,7 +154,7 @@ def test_with_image_obj(get_image_obj):
         'subject': 'Some news', 
         'to': 'you@gmail.com', 
         #'MIME-Version': '1.0', 
-        'Content-Type': 'multipart/alternative'
+        'Content-Type': 'multipart/mixed'
     } == headers
 
 def test_with_image_error():
@@ -235,10 +249,13 @@ def test_with_html_table_no_error(get_df, tmpdir):
         body_tables={"my_table": df}
     )
     
-    assert "multipart/alternative" == msg.get_content_type()
+    assert "multipart/mixed" == msg.get_content_type()
+
+    alternative = msg.get_payload()[0]
+    mime_html = alternative.get_payload()[0]
 
     #mime_text = msg.get_payload()[0]
-    html = remove_extra_lines(msg.get_payload()[0].get_payload()).replace("=20", "").replace('"3D', "")
+    html = remove_extra_lines(mime_html.get_payload()).replace("=20", "").replace('"3D', "")
     #tmpdir.join("email.html").write(html)
 
     # TODO: Test the HTML is as required

+ 198 - 0
redmail/test/email/test_structure.py

@@ -0,0 +1,198 @@
+from redmail import EmailSender
+
+import pytest
+
+from convert import remove_extra_lines, payloads_to_dict
+from getpass import getpass, getuser
+from platform import node
+import base64, re
+
+from convert import remove_email_extra
+
+def test_empty():
+
+    sender = EmailSender(host=None, port=1234)
+    msg = sender.get_message(
+        sender="me@example.com",
+        receivers="you@example.com",
+        subject="An example",
+    )
+    structure = payloads_to_dict(msg)
+    assert structure == {}
+
+def test_text():
+
+    sender = EmailSender(host=None, port=1234)
+    msg = sender.get_message(
+        sender="me@example.com",
+        receivers="you@example.com",
+        subject="An example",
+        text="Text content",
+    )
+    structure = payloads_to_dict(msg)
+    assert structure == {
+        'text/plain': 'Text content\n'
+    }
+
+def test_html():
+    sender = EmailSender(host=None, port=1234)
+    msg = sender.get_message(
+        sender="me@example.com",
+        receivers="you@example.com",
+        subject="An example",
+        html="<h1>HTML content</h1>",
+    )
+    structure = payloads_to_dict(msg)
+    assert structure == {
+        "multipart/mixed": {
+            'multipart/alternative': {
+                'text/html': '<h1>HTML content</h1>\n'
+            }
+        }
+    }
+
+def test_text_and_html():
+    sender = EmailSender(host=None, port=1234)
+    msg = sender.get_message(
+        sender="me@example.com",
+        receivers="you@example.com",
+        subject="An example",
+        text="Text content",
+        html="<h1>HTML content</h1>",
+    )
+    structure = payloads_to_dict(msg)
+    assert structure == {
+        "multipart/mixed": {
+            'multipart/alternative': {
+                'text/plain': 'Text content\n',
+                'text/html': '<h1>HTML content</h1>\n'
+            }
+        }
+    }
+
+def test_attachment():
+    sender = EmailSender(host=None, port=1234)
+    msg = sender.get_message(
+        sender="me@example.com",
+        receivers="you@example.com",
+        subject="An example",
+        attachments={'data.txt': "Some content"}
+    )
+    structure = payloads_to_dict(msg)
+    assert structure == {
+        "multipart/mixed": {
+            'application/octet-stream': 'U29tZSBjb250ZW50\n'
+        }
+    }
+
+def test_html_inline():
+    img_data = '/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAIBAQIBAQICAgICAgICAwUDAwMDAwYEBAMFBwYHBwcGBwcICQsJCAgKCAcHCg0KCgsMDAwMBwkODw0MDgsMDAz/2wBDAQICAgMDAwYDAwYMCAcIDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAz/wAARCAABAAEDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD5rooor8DP9oD/2Q=='
+    img_bytes = base64.b64decode(img_data)
+
+    sender = EmailSender(host=None, port=1234)
+    msg = sender.get_message(
+        sender="me@example.com",
+        receivers="you@example.com",
+        subject="An example",
+        html='<p>HTML content</p> \n{{ my_image }}',
+        body_images={
+            "my_image": {
+                "content": img_bytes,
+                'subtype': 'jpg'
+            }
+        },
+    )
+
+    structure = payloads_to_dict(msg)
+    cid = re.search('(?<=cid:).+(?=@)', structure["multipart/mixed"]["multipart/alternative"]["multipart/related"]["text/html"]).group()
+    assert structure == {
+        "multipart/mixed": {
+            "multipart/alternative": {
+                "multipart/related": {
+                    'text/html': f'<p>HTML content</p> \n<img src="cid:{cid}@example.com">\n',
+                    'image/jpg': '/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAIBAQIBAQICAgICAgICAwUDAwMDAwYEBAMFBwYHBwcG\nBwcICQsJCAgKCAcHCg0KCgsMDAwMBwkODw0MDgsMDAz/2wBDAQICAgMDAwYDAwYMCAcIDAwMDAwM\nDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAz/wAARCAABAAEDASIA\nAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQA\nAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3\nODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWm\np6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEA\nAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSEx\nBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElK\nU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3\nuLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD5rooo\nr8DP9oD/2Q==\n',
+                }
+            },
+        }
+    }
+
+def test_text_html_inline_attachment():
+    img_data = '/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAIBAQIBAQICAgICAgICAwUDAwMDAwYEBAMFBwYHBwcGBwcICQsJCAgKCAcHCg0KCgsMDAwMBwkODw0MDgsMDAz/2wBDAQICAgMDAwYDAwYMCAcIDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAz/wAARCAABAAEDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD5rooor8DP9oD/2Q=='
+    img_bytes = base64.b64decode(img_data)
+
+    sender = EmailSender(host=None, port=1234)
+    msg = sender.get_message(
+        sender="me@example.com",
+        receivers="you@example.com",
+        subject="An example",
+        text="Text content",
+        html='<p>HTML content</p> \n{{ my_image }}',
+        body_images={
+            "my_image": {
+                "content": img_bytes,
+                'subtype': 'jpg'
+            }
+        },
+        attachments={'data.txt': "Some content"},
+    )
+
+    structure = payloads_to_dict(msg)
+    cid = re.search('(?<=cid:).+(?=@)', structure["multipart/mixed"]["multipart/alternative"]["multipart/related"]["text/html"]).group()
+    assert structure == {
+        "multipart/mixed": {
+            "multipart/alternative": {
+                'text/plain': 'Text content\n',
+                "multipart/related": {
+                    'text/html': f'<p>HTML content</p> \n<img src="cid:{cid}@example.com">\n',
+                    'image/jpg': '/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAIBAQIBAQICAgICAgICAwUDAwMDAwYEBAMFBwYHBwcG\nBwcICQsJCAgKCAcHCg0KCgsMDAwMBwkODw0MDgsMDAz/2wBDAQICAgMDAwYDAwYMCAcIDAwMDAwM\nDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAz/wAARCAABAAEDASIA\nAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQA\nAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3\nODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWm\np6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEA\nAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSEx\nBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElK\nU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3\nuLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD5rooo\nr8DP9oD/2Q==\n',
+                }
+            },
+            # Attachments
+            'application/octet-stream': 'U29tZSBjb250ZW50\n'
+        }
+    }
+
+def test_text_html_inline_attachment_multiple():
+    img_data = '/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAIBAQIBAQICAgICAgICAwUDAwMDAwYEBAMFBwYHBwcGBwcICQsJCAgKCAcHCg0KCgsMDAwMBwkODw0MDgsMDAz/2wBDAQICAgMDAwYDAwYMCAcIDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAz/wAARCAABAAEDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD5rooor8DP9oD/2Q=='
+    img_bytes = base64.b64decode(img_data)
+
+    sender = EmailSender(host=None, port=1234)
+    msg = sender.get_message(
+        sender="me@example.com",
+        receivers="you@example.com",
+        subject="An example",
+        text="Text content",
+        html='<p>HTML content</p> \n{{ my_image_1 }}\n{{ my_image_2 }}',
+        body_images={
+            "my_image_1": {
+                "content": img_bytes,
+                'subtype': 'jpg'
+            },
+            "my_image_2": {
+                "content": img_bytes,
+                'subtype': 'jpg'
+            },
+        },
+        attachments={
+            'data_1.txt': "Some content 1",
+            "data_2.txt": "Some content 2",
+        },
+    )
+
+    structure = payloads_to_dict(msg)
+    cid_1, cid_2 = re.findall('(?<=cid:).+(?=@)', structure["multipart/mixed"]["multipart/alternative"]["multipart/related"]["text/html"])
+    assert structure == {
+        "multipart/mixed": {
+            "multipart/alternative": {
+                'text/plain': 'Text content\n',
+                "multipart/related": {
+                    'text/html': f'<p>HTML content</p> \n<img src="cid:{cid_1}@example.com">\n<img src="cid:{cid_2}@example.com">\n',
+                    'image/jpg':   '/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAIBAQIBAQICAgICAgICAwUDAwMDAwYEBAMFBwYHBwcG\nBwcICQsJCAgKCAcHCg0KCgsMDAwMBwkODw0MDgsMDAz/2wBDAQICAgMDAwYDAwYMCAcIDAwMDAwM\nDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAz/wAARCAABAAEDASIA\nAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQA\nAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3\nODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWm\np6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEA\nAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSEx\nBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElK\nU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3\nuLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD5rooo\nr8DP9oD/2Q==\n',
+                    'image/jpg_1': '/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAIBAQIBAQICAgICAgICAwUDAwMDAwYEBAMFBwYHBwcG\nBwcICQsJCAgKCAcHCg0KCgsMDAwMBwkODw0MDgsMDAz/2wBDAQICAgMDAwYDAwYMCAcIDAwMDAwM\nDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAz/wAARCAABAAEDASIA\nAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQA\nAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3\nODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWm\np6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEA\nAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSEx\nBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElK\nU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3\nuLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD5rooo\nr8DP9oD/2Q==\n',
+                }
+            },
+            # Attachments
+            'application/octet-stream': 'U29tZSBjb250ZW50IDE=\n',
+            'application/octet-stream_1': 'U29tZSBjb250ZW50IDI=\n'
+        }
+    }

+ 6 - 4
redmail/test/email/test_template.py

@@ -38,11 +38,13 @@ def test_template(tmpdir):
         body_params={"friend": "Jack", 'project_name': 'RedMail'}
     )
     
-    assert "multipart/alternative" == msg.get_content_type()
+    assert "multipart/mixed" == msg.get_content_type()
 
-    #text = remove_extra_lines(msg.get_payload()[0].get_payload())
-    text = remove_email_extra(msg.get_payload()[0].get_payload())
-    html = remove_email_extra(msg.get_payload()[1].get_payload())
+    alternative = msg.get_payload()[0]
+    text_part, html_part = alternative.get_payload()
+
+    text = remove_email_extra(text_part.get_payload())
+    html = remove_email_extra(html_part.get_payload())
 
     assert expected_html == html
     assert expected_text == text

+ 27 - 1
redmail/test/helpers/convert.py

@@ -1,4 +1,5 @@
 
+from collections import Counter
 import re
 
 def remove_extra_lines(s:str):
@@ -7,4 +8,29 @@ def remove_extra_lines(s:str):
 
 def remove_email_extra(s:str):
     s = remove_extra_lines(s)
-    return s.replace("=20", "").replace('"3D', "").replace("=\n", "")
+    return s.replace("=20", "").replace('"3D', "").replace("=\n", "")
+
+def remove_email_content_id(s:str, repl="<ID>"):
+    return re.sub(r"(?<================)[0-9]+(?===)", repl, s)
+
+def payloads_to_dict(*parts):
+    data = {}
+    for part in parts:
+
+        payload = part.get_payload()
+        key = part.get_content_type()
+        if key in data:
+            new_key = key
+            n = 0
+            while new_key in data:
+                n += 1
+                new_key = key + f"_{n}"
+            key = new_key
+        if isinstance(payload, str):
+            data[key] = payload
+        elif payload is None:
+            # Most likely empty message
+            pass
+        else:
+            data[key] = payloads_to_dict(*payload)
+    return data

+ 32 - 14
redmail/test/log/test_handler.py

@@ -4,6 +4,8 @@ from redmail import EmailSender
 from redmail import EmailHandler
 import logging
 
+from convert import payloads_to_dict
+
 def _create_dummy_send(messages:list):
     def _dummy_send(msg):
         messages.append(msg)
@@ -32,7 +34,9 @@ def test_default_body():
                 'Content-Type': 'text/plain; charset="utf-8"',
                 'MIME-Version': '1.0',
             },
-            'a message\n',
+            {
+                'text/plain': 'a message\n'
+            },
             id="Minimal",
         ),
         pytest.param(
@@ -52,7 +56,9 @@ def test_default_body():
                 'Content-Type': 'text/plain; charset="utf-8"',
                 'MIME-Version': '1.0',
             },
-            'Log Record: \n_test: INFO: a message\n',
+            {
+                'text/plain': 'Log Record: \n_test: INFO: a message\n'
+            },
             id="Custom message (msg)",
         ),
         pytest.param(
@@ -72,7 +78,9 @@ def test_default_body():
                 'Content-Type': 'text/plain; charset="utf-8"',
                 'MIME-Version': '1.0',
             },
-            'Log Record: \na message\n',
+            {
+                'text/plain': 'Log Record: \na message\n'
+            },
             id="Custom message (record)",
         ),
         pytest.param(
@@ -90,7 +98,9 @@ def test_default_body():
                 'Content-Type': 'text/plain; charset="utf-8"',
                 'MIME-Version': '1.0',
             },
-            'a message\n',
+            {
+                'text/plain': 'a message\n'
+            },
             id="Sender with fomatted subject",
         ),
         pytest.param(
@@ -106,9 +116,15 @@ def test_default_body():
                 "from": "me@example.com",
                 "to": "he@example.com, she@example.com",
                 "subject": "A log record",
-                'Content-Type': 'multipart/alternative',
+                'Content-Type': 'multipart/mixed',
+            },
+            {
+                'multipart/mixed': {
+                    'multipart/alternative': {
+                        'text/html': "<h1>INFO</h1><p>_test: INFO: a message</p>\n"
+                    }
+                }
             },
-            ["<h1>INFO</h1><p>_test: INFO: a message</p>\n"],
             id="Custom message (HTML, msg)",
         ),
         pytest.param(
@@ -124,9 +140,15 @@ def test_default_body():
                 "from": "me@example.com",
                 "to": "he@example.com, she@example.com",
                 "subject": "A log record",
-                'Content-Type': 'multipart/alternative',
+                'Content-Type': 'multipart/mixed',
+            },
+            {
+                'multipart/mixed': {
+                    'multipart/alternative': {
+                        'text/html': "<h1>INFO</h1><p>a message</p>\n"
+                    }
+                }
             },
-            ["<h1>INFO</h1><p>a message</p>\n"],
             id="Custom message (HTML, record)",
         ),
     ]
@@ -150,9 +172,5 @@ def test_emit(logger, kwargs, exp_headers, exp_payload):
 
     assert headers == exp_headers
 
-    if isinstance(payload, str):
-        assert payload == exp_payload
-    else:
-        # HTML (and text) of payloads
-        payloads = [pl.get_payload() for pl in payload]
-        assert payloads == exp_payload
+    structure = payloads_to_dict(msg)
+    assert structure == exp_payload

+ 36 - 18
redmail/test/log/test_handler_multi.py

@@ -4,6 +4,8 @@ from redmail import EmailSender
 from redmail import MultiEmailHandler
 import logging
 
+from convert import payloads_to_dict
+
 def _create_dummy_send(messages:list):
     def _dummy_send(msg):
         messages.append(msg)
@@ -32,7 +34,9 @@ def test_default_body():
                 'Content-Type': 'text/plain; charset="utf-8"',
                 'MIME-Version': '1.0',
             },
-            'Log Recods:\na message\n',
+            {
+                'text/plain': 'Log Recods:\na message\n'
+            },
             id="Minimal",
         ),
         pytest.param(
@@ -52,7 +56,9 @@ def test_default_body():
                 'Content-Type': 'text/plain; charset="utf-8"',
                 'MIME-Version': '1.0',
             },
-            'The records: \nLog: _test - INFO - a message\n',
+            {
+                'text/plain': 'The records: \nLog: _test - INFO - a message\n'
+            },
             id="Custom message (msgs)",
         ),
         pytest.param(
@@ -72,7 +78,9 @@ def test_default_body():
                 'Content-Type': 'text/plain; charset="utf-8"',
                 'MIME-Version': '1.0',
             },
-            'The records: \nLog: INFO - a message\n',
+            {
+                'text/plain': 'The records: \nLog: INFO - a message\n',
+            },
             id="Custom message (records)",
         ),
         pytest.param(
@@ -90,7 +98,9 @@ def test_default_body():
                 'Content-Type': 'text/plain; charset="utf-8"',
                 'MIME-Version': '1.0',
             },
-            'Log Recods:\na message\n',
+            {
+                'text/plain': 'Log Recods:\na message\n',
+            },
             id="Sender with fomatted subject",
         ),
         pytest.param(
@@ -106,9 +116,15 @@ def test_default_body():
                 "from": "me@example.com",
                 "to": "he@example.com, she@example.com",
                 "subject": "A log record",
-                'Content-Type': 'multipart/alternative',
+                'Content-Type': 'multipart/mixed',
+            },
+            {
+                'multipart/mixed': {
+                    'multipart/alternative': {
+                        'text/html': "<h1>The records:</h1><p>Log: _test - INFO - a message</p>\n",
+                    }
+                }
             },
-            ["<h1>The records:</h1><p>Log: _test - INFO - a message</p>\n"],
             id="Custom message (HTML, msgs)",
         ),
         pytest.param(
@@ -124,9 +140,15 @@ def test_default_body():
                 "from": "me@example.com",
                 "to": "he@example.com, she@example.com",
                 "subject": "A log record",
-                'Content-Type': 'multipart/alternative',
+                'Content-Type': 'multipart/mixed',
+            },
+            {
+                'multipart/mixed': {
+                    'multipart/alternative': {
+                        'text/html': "<h1>The records:</h1><p>Log: INFO - a message</p>\n",
+                    }
+                }
             },
-            ["<h1>The records:</h1><p>Log: INFO - a message</p>\n"],
             id="Custom message (HTML, records)",
         ),
     ]
@@ -152,12 +174,8 @@ def test_emit(logger, kwargs, exp_headers, exp_payload):
 
     assert headers == exp_headers
 
-    if isinstance(payload, str):
-        assert payload == exp_payload
-    else:
-        # HTML (and text) of payloads
-        payloads = [pl.get_payload() for pl in payload]
-        assert payloads == exp_payload
+    structure = payloads_to_dict(msg)
+    assert structure == exp_payload
 
 
 def test_flush_multiple(logger):
@@ -181,7 +199,7 @@ def test_flush_multiple(logger):
     assert len(msgs) == 1
     msg = msgs[0]
     headers = dict(msg.items())
-    payload = msg.get_payload()
+    text = msg.get_payload()
 
     assert headers == {
         "from": "None",
@@ -192,7 +210,7 @@ def test_flush_multiple(logger):
         'MIME-Version': '1.0',
     }
 
-    assert payload == "Records: \nINFO - an info\nDEBUG - a debug\n"
+    assert text == "Records: \nINFO - an info\nDEBUG - a debug\n"
 
 def test_flush_none():
     msgs = []
@@ -213,7 +231,7 @@ def test_flush_none():
     assert len(msgs) == 1
     msg = msgs[0]
     headers = dict(msg.items())
-    payload = msg.get_payload()
+    text = msg.get_payload()
 
     assert headers == {
         "from": "None",
@@ -224,4 +242,4 @@ def test_flush_none():
         'MIME-Version': '1.0',
     }
 
-    assert payload == "Records: \n"
+    assert text == "Records: \n"