ソースを参照

Merge branch 'master' into ref/rename_user_name

Mikael Koli 3 年 前
コミット
9b7608ac80

+ 37 - 21
ci/test_send.py

@@ -17,18 +17,18 @@ email = EmailSender(
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 logger.setLevel(logging.DEBUG)
 logger.setLevel(logging.DEBUG)
 
 
-def send():
+def send_empty():
     msg = email.send(
     msg = email.send(
         sender=os.environ['EMAIL_SENDER'],
         sender=os.environ['EMAIL_SENDER'],
         receivers=os.environ['EMAIL_RECEIVERS'].split(","),
         receivers=os.environ['EMAIL_RECEIVERS'].split(","),
-        subject="An example",
+        subject="Empty email",
     )
     )
 
 
 def send_text():
 def send_text():
     msg = email.send(
     msg = email.send(
         sender=os.environ['EMAIL_SENDER'],
         sender=os.environ['EMAIL_SENDER'],
         receivers=os.environ['EMAIL_RECEIVERS'].split(","),
         receivers=os.environ['EMAIL_RECEIVERS'].split(","),
-        subject="An example",
+        subject="Email with text",
         text="Hi, this is an example email.",
         text="Hi, this is an example email.",
     )
     )
 
 
@@ -36,17 +36,17 @@ def send_html():
     msg = email.send(
     msg = email.send(
         sender=os.environ['EMAIL_SENDER'],
         sender=os.environ['EMAIL_SENDER'],
         receivers=os.environ['EMAIL_RECEIVERS'].split(","),
         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():
 def send_test_and_html():
     msg = email.send(
     msg = email.send(
         sender=os.environ['EMAIL_SENDER'],
         sender=os.environ['EMAIL_SENDER'],
         receivers=os.environ['EMAIL_RECEIVERS'].split(","),
         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(
     msg = email.send(
         sender=os.environ['EMAIL_SENDER'],
         sender=os.environ['EMAIL_SENDER'],
         receivers=os.environ['EMAIL_RECEIVERS'].split(","),
         receivers=os.environ['EMAIL_RECEIVERS'].split(","),
-        subject="An attachment",
+        subject="Email with attachment",
         attachments={"a_file.html": (Path(__file__).parent / "file.html")}
         attachments={"a_file.html": (Path(__file__).parent / "file.html")}
     )
     )
 
 
@@ -62,8 +62,8 @@ def send_attachments_with_text():
     msg = email.send(
     msg = email.send(
         sender=os.environ['EMAIL_SENDER'],
         sender=os.environ['EMAIL_SENDER'],
         receivers=os.environ['EMAIL_RECEIVERS'].split(","),
         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")}
         attachments={"a_file.html": (Path(__file__).parent / "file.html")}
     )
     )
 
 
@@ -71,18 +71,33 @@ def send_attachments_with_html():
     msg = email.send(
     msg = email.send(
         sender=os.environ['EMAIL_SENDER'],
         sender=os.environ['EMAIL_SENDER'],
         receivers=os.environ['EMAIL_RECEIVERS'].split(","),
         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")}
         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(
     msg = email.send(
         sender=os.environ['EMAIL_SENDER'],
         sender=os.environ['EMAIL_SENDER'],
         receivers=os.environ['EMAIL_RECEIVERS'].split(","),
         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")}
         attachments={"a_file.html": (Path(__file__).parent / "file.html")}
     )
     )
 
 
@@ -195,17 +210,18 @@ def log_simple():
 
 
 
 
 if __name__ == "__main__":
 if __name__ == "__main__":
-    fn_bodies = [send, send_text, send_html, send_test_and_html]
-    fn_imgs = [send_images]
-    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]
     fn_log = [log_simple, log_multi]
 
 
     funcs = {
     funcs = {
         "minimal": fn_bodies[0],
         "minimal": fn_bodies[0],
-        "full": fn_bodies + fn_attachments + fn_log + fn_imgs,
+        "bodies": fn_bodies,
+        "full": fn_bodies + fn_attachments + fn_log,
         "logging": fn_log,
         "logging": fn_log,
         "images": fn_imgs,
         "images": fn_imgs,
     }[os.environ.get("EMAIL_FUNCS", "full")]
     }[os.environ.get("EMAIL_FUNCS", "full")]
     for func in funcs:
     for func in funcs:
+        print("Running:", func.__name__)
         time.sleep(1)
         time.sleep(1)
         func()
         func()

+ 69 - 1
docs/references.rst

@@ -20,4 +20,72 @@ Logging Classes
 
 
 .. autoclass:: redmail.EmailHandler
 .. 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

+ 1 - 0
docs/tutorials/index.rst

@@ -30,4 +30,5 @@ And see - :ref:`cookbook <cookbook>` for example use cases.
    cookbook
    cookbook
    config
    config
    client
    client
+   testing
    example
    example

+ 61 - 1
docs/tutorials/jinja_support.rst

@@ -113,4 +113,64 @@ as if statements, for loops, macros etc. Here is a quick illustration:
     )
     )
 
 
 Please see `Jinja documentation <https://jinja.palletsprojects.com/>`_ 
 Please see `Jinja documentation <https://jinja.palletsprojects.com/>`_ 
-for more.
+for more.
+
+
+Pass Unescaped Content
+----------------------
+
+In case you need to include parts that should not be processed by 
+Jinja, you may pass them using `markupsafe.Markup <https://markupsafe.palletsprojects.com/en/2.1.x/escaping/#markupsafe.Markup>`_:
+
+.. code-block:: python
+
+    from markupsafe import Markup
+
+    email.send(
+        subject='email subject',
+        receivers=['first.last@example.com'],
+        html="""
+            <h1>Hi,</h1>
+            <p>{{ raw_content }}</p>
+            <p>Kind regards
+            <br>{{ sender.full_name }}</p>
+        """,
+        body_params={
+            'raw_content': Markup("<strong>this text is passed unescaped as is</strong>")
+        }
+    )
+
+.. warning::
+
+    For HTML, content only from trusted sources should be left unescaped.
+
+
+Disabling Jinja
+---------------
+
+In case you wish to pass raw text/HTML and don't want to use Jinja
+to render the bodies, you may also disable it:
+
+.. code-block:: python
+
+    email.send(
+        subject='email subject',
+        receivers=['first.last@example.com'],
+        text="""
+            Hi,
+            {{ these brackets are not processed }}
+        """,
+        html="""
+            <h1>Hi!</h1>
+            <p>
+                {{ these brackets are not processed }}
+            </p>
+        """,
+        use_jinja=False
+    )
+
+You may also disable Jinja for all sent emails without passing the argument:
+
+.. code-block:: python
+
+    email.use_jinja = False

+ 166 - 0
docs/tutorials/testing.rst

@@ -0,0 +1,166 @@
+
+.. _testing:
+
+How to Test
+===========
+
+For testing purposes, it might be useful to prevent
+sending the actual email. This is especially preferable
+with unit tests. There are several ways to do this.
+
+.. note::
+
+    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. 
+    
+    See :ref:`email_structure` for how Red Mail's
+    emails are structured.
+
+Using get_message
+-----------------
+
+All of the arguments in method :py:meth:`.EmailSender.send`
+are passed to :py:meth:`.EmailSender.get_message` method 
+which generates the message itself. Therefore, the simplest
+solution is to use this method instead of :py:meth:`.EmailSender.send`
+in tests:
+
+.. code-block:: python
+
+    from redmail import EmailSender
+
+    # Just put something as host and port
+    email = EmailSender(host="localhost", port=0)
+
+    msg = email.get_message(
+        subject='email subject',
+        sender="me@example.com",
+        receivers=['you@example.com'],
+        text="Hi, this is an email.",
+    )
+
+    assert str(msg) == """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
+
+    Hi, nice to meet you.
+    """
+
+Mock Server
+-----------
+
+In case changing to method :py:meth:`.EmailSender.get_message` 
+is inconvenient or it does not suit to your testing, you may
+also create a mock SMTP server that imitates an actual SMTP
+server instance:
+
+.. code-block:: python
+
+    class MockSMTP:
+
+        messages = []
+
+        def __init__(self, host, port):
+            self.host = host
+            self.port = port
+
+        def starttls(self):
+            # Called only if use_startls is True
+            return
+
+        def login(self, username, password):
+            # Log in to the server (if credentials passed)
+            self.username = username
+            self.password = password
+            return
+
+        def send_message(self, msg):
+            # Instead of sending, we just store the message
+            self.messages.append(msg)
+
+        def quit(self):
+            # Closing the connection
+            return
+
+Then to use this mock:
+
+.. code-block:: python
+
+    from redmail import EmailSender
+
+    email = EmailSender(
+        host="localhost", 
+        port=0, 
+        username="me@example.com", 
+        password="1234", 
+        cls_smtp=MockServer
+    )
+
+    email.send(
+        subject='email subject',
+        sender="me@example.com",
+        receivers=['you@example.com'],
+        text="Hi, this is an email.",
+    )
+
+    msgs = MockServer.messages
+    assert msgs == ["""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
+
+    Hi, nice to meet you.
+    """]
+
+Note that an instance of ``MockServer`` is created 
+for each connection, often per sent email.
+
+Subclass Sender
+---------------
+
+Another option is to just subclass the sender and 
+change the email sending there:
+
+.. code-block:: python
+
+    from redmail import EmailSender
+
+    class MockSender(EmailSender):
+
+        def __init__(self, *args, **kwargs):
+            super().__init__(*args, **kwargs)
+            self.messages = []
+
+        def send_message(self, msg):
+            self.messages.append(msg)
+
+Then to use this class:
+
+.. code-block:: python
+
+    # Just put something as host and port
+    email = MockSender(host="localhost", port=0)
+
+    email.send(
+        subject='email subject',
+        sender="me@example.com",
+        receivers=['you@example.com'],
+        text="Hi, this is an email.",
+    )
+
+    msgs = email.messages
+    assert msgs == ["""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
+
+    Hi, nice to meet you.
+    """]

+ 0 - 6
redmail/email/attachment.py

@@ -18,12 +18,6 @@ class Attachments:
         self.encoding = encoding
         self.encoding = encoding
 
 
     def attach(self, msg:EmailMessage):
     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():
         for part in self._get_parts():
             msg.attach(part)
             msg.attach(part)
 
 

+ 15 - 12
redmail/email/body.py

@@ -9,7 +9,7 @@ from pathlib import Path
 from redmail.utils import is_bytes
 from redmail.utils import is_bytes
 from redmail.utils import import_from_string
 from redmail.utils import import_from_string
 
 
-from email.utils import make_msgid
+from email.utils import make_msgid, parseaddr
 
 
 from jinja2.environment import Template, Environment
 from jinja2.environment import Template, Environment
 
 
@@ -39,9 +39,10 @@ class BodyImage:
 
 
 class Body:
 class Body:
 
 
-    def __init__(self, template:Template=None, table_template:Template=None):
+    def __init__(self, template:Template=None, table_template:Template=None, use_jinja=True):
         self.template = template
         self.template = template
         self.table_template = table_template
         self.table_template = table_template
+        self.use_jinja = use_jinja
 
 
     def render_body(self, body:str, jinja_params:dict):
     def render_body(self, body:str, jinja_params:dict):
         if body is not None and self.template is not None:
         if body is not None and self.template is not None:
@@ -79,7 +80,8 @@ class Body:
 class TextBody(Body):
 class TextBody(Body):
 
 
     def attach(self, msg:EmailMessage, text:str, **kwargs):
     def attach(self, msg:EmailMessage, text:str, **kwargs):
-        text = self.render(text, **kwargs)
+        if self.use_jinja:
+            text = self.render(text, **kwargs)
         msg.set_content(text)
         msg.set_content(text)
 
 
 
 
@@ -109,16 +111,17 @@ class HTMLBody(Body):
             jinja_params : dict
             jinja_params : dict
                 Extra Jinja parameters for the HTML.
                 Extra Jinja parameters for the HTML.
         """
         """
-        domain = msg["from"].split("@")[-1] if self.domain is None else self.domain
-        html, cids = self.render(
-            html, 
-            images=images,
-            domain=domain,
-            **kwargs
-        )
+        if self.use_jinja:
+            domain = parseaddr(msg["from"])[1].split("@")[-1] if self.domain is None else self.domain
+            html, cids = self.render(
+                html, 
+                images=images,
+                domain=domain,
+                **kwargs
+            )
         msg.add_alternative(html, subtype='html')
         msg.add_alternative(html, subtype='html')
 
 
-        if images is not None:
+        if self.use_jinja and images is not None:
             # https://stackoverflow.com/a/49098251/13696660
             # https://stackoverflow.com/a/49098251/13696660
             html_msg = msg.get_payload()[-1]
             html_msg = msg.get_payload()[-1]
             cid_path_mapping = {cids[name]: path for name, path in images.items()}
             cid_path_mapping = {cids[name]: path for name, path in images.items()}
@@ -244,4 +247,4 @@ class HTMLBody(Body):
                 img_content,
                 img_content,
                 cid=cid,
                 cid=cid,
                 **kwds
                 **kwds
-            )
+            )

+ 39 - 4
redmail/email/sender.py

@@ -79,6 +79,10 @@ class EmailSender:
     html_template : str
     html_template : str
         Name of the template to use as the HTML body of emails 
         Name of the template to use as the HTML body of emails 
         if not specified in the send method.
         if not specified in the send method.
+    use_jinja : bool
+        Use Jinja to render text/HTML. If Jinja is disabled,
+        images cannot be embedded to HTML, templates have no
+        effect and body_params are not used. Defaults True
     templates_html : jinja2.Environment
     templates_html : jinja2.Environment
         Jinja environment used for loading HTML templates
         Jinja environment used for loading HTML templates
         if ``html_template`` is specified in send.
         if ``html_template`` is specified in send.
@@ -158,6 +162,7 @@ class EmailSender:
         self.html = None
         self.html = None
         self.html_template = None
         self.html_template = None
         self.text_template = None
         self.text_template = None
+        self.use_jinja = True
 
 
         self.use_starttls = use_starttls
         self.use_starttls = use_starttls
         self.cls_smtp = cls_smtp
         self.cls_smtp = cls_smtp
@@ -223,6 +228,9 @@ class EmailSender:
             DataFrames.
             DataFrames.
         body_params : dict, optional
         body_params : dict, optional
             Extra Jinja parameters passed to the HTML and text bodies.
             Extra Jinja parameters passed to the HTML and text bodies.
+        use_jinja : bool
+            Use Jinja to render text/HTML. If Jinja is disabled, body content cannot be 
+            embedded, templates have no effect and body parameters do nothing.
         attachments : dict, optional
         attachments : dict, optional
             Attachments of the email. If dict value is string, the attachment content
             Attachments of the email. If dict value is string, the attachment content
             is the string itself. If path, the attachment is the content of the path's file.
             is the string itself. If path, the attachment is the content of the path's file.
@@ -296,7 +304,8 @@ class EmailSender:
                   body_images:Optional[Dict[str, Union[str, bytes, 'plt.Figure', 'Image']]]=None, 
                   body_images:Optional[Dict[str, Union[str, bytes, 'plt.Figure', 'Image']]]=None, 
                   body_tables:Optional[Dict[str, 'pd.DataFrame']]=None, 
                   body_tables:Optional[Dict[str, 'pd.DataFrame']]=None, 
                   body_params:Optional[Dict[str, Any]]=None,
                   body_params:Optional[Dict[str, Any]]=None,
-                  attachments:Optional[Dict[str, Union[str, os.PathLike, 'pd.DataFrame', bytes]]]=None) -> EmailMessage:
+                  attachments:Optional[Dict[str, Union[str, os.PathLike, 'pd.DataFrame', bytes]]]=None,
+                  use_jinja=None) -> EmailMessage:
         """Get the email message"""
         """Get the email message"""
 
 
         subject = subject or self.subject
         subject = subject or self.subject
@@ -310,6 +319,7 @@ class EmailSender:
         text = text or self.text
         text = text or self.text
         html_template = html_template or self.html_template
         html_template = html_template or self.html_template
         text_template = text_template or self.text_template
         text_template = text_template or self.text_template
+        use_jinja = self.use_jinja if use_jinja is None else use_jinja
 
 
         if subject is None:
         if subject is None:
             raise ValueError("Email must have a subject")
             raise ValueError("Email must have a subject")
@@ -321,11 +331,14 @@ class EmailSender:
             cc=cc,
             cc=cc,
             bcc=bcc,
             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(
             body = TextBody(
                 template=self.get_text_template(text_template),
                 template=self.get_text_template(text_template),
                 table_template=self.get_text_table_template(),
                 table_template=self.get_text_table_template(),
+                use_jinja=use_jinja
             )
             )
             body.attach(
             body.attach(
                 msg, 
                 msg, 
@@ -334,10 +347,11 @@ class EmailSender:
                 jinja_params=self.get_text_params(extra=body_params, sender=sender),
                 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(
             body = HTMLBody(
                 template=self.get_html_template(html_template),
                 template=self.get_html_template(html_template),
                 table_template=self.get_html_table_template(),
                 table_template=self.get_html_table_template(),
+                use_jinja=use_jinja
             )
             )
             body.attach(
             body.attach(
                 msg,
                 msg,
@@ -346,6 +360,14 @@ class EmailSender:
                 tables=body_tables,
                 tables=body_tables,
                 jinja_params=self.get_html_params(extra=body_params, sender=sender)
                 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:
         if attachments:
             att = Attachments(attachments, encoding=self.attachment_encoding)
             att = Attachments(attachments, encoding=self.attachment_encoding)
             att.attach(msg)
             att.attach(msg)
@@ -381,6 +403,19 @@ class EmailSender:
             msg['bcc'] = bcc
             msg['bcc'] = bcc
         return msg
         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):
     def send_message(self, msg:EmailMessage):
         "Send the created message"
         "Send the created message"
         if self.is_alive:
         if self.is_alive:

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

@@ -7,7 +7,7 @@ from pathlib import Path
 import pytest
 import pytest
 
 
 from resources import get_mpl_fig, get_pil_image
 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):
 def to_encoded(s:str):
     return str(base64.b64encode(s.encode()), 'ascii')
     return str(base64.b64encode(s.encode()), 'ascii')
@@ -23,6 +23,14 @@ def test_with_text():
         text="Hi, this is an email",
         text="Hi, this is an email",
         attachments={'data.txt': "Some content"}
         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"
     assert msg.get_content_type() == "multipart/mixed"
 
 
     text, attachment = msg.get_payload()
     text, attachment = msg.get_payload()
@@ -45,9 +53,20 @@ def test_with_html():
         html="<h1>Hi, this is an email.</h1>",
         html="<h1>Hi, this is an email.</h1>",
         attachments={'data.txt': "Some content"}
         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 html.get_content_type() == 'text/html'
     assert attachment.get_content_type() == 'application/octet-stream'
     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>",
         html="<h1>Hi, this is an email.</h1>",
         attachments={'data.txt': "Some content"}
         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 text.get_content_type() == "text/plain"
     assert html.get_content_type() == "text/html"
     assert html.get_content_type() == "text/html"
     assert attachment.get_content_type() == 'application/octet-stream'
     assert attachment.get_content_type() == 'application/octet-stream'
@@ -94,6 +124,13 @@ def test_no_body():
         subject="Some news",
         subject="Some news",
         attachments={'data.txt': 'Some content'}
         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 msg.get_content_type() == "multipart/mixed"
     assert len(msg.get_payload()) == 1
     assert len(msg.get_payload()) == 1
 
 

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

@@ -1,99 +1,109 @@
+from textwrap import dedent
 from redmail import EmailSender
 from redmail import EmailSender
 
 
 import pytest
 import pytest
 
 
-from convert import remove_extra_lines
+from convert import remove_extra_lines, payloads_to_dict
 from getpass import getpass, getuser
 from getpass import getpass, getuser
 from platform import node
 from platform import node
 
 
-from convert import remove_email_extra
+from convert import remove_email_extra, remove_email_content_id
 
 
 def test_text_message():
 def test_text_message():
-    text = "Hi, nice to meet you."
 
 
     sender = EmailSender(host=None, port=1234)
     sender = EmailSender(host=None, port=1234)
     msg = sender.get_message(
     msg = sender.get_message(
-        sender="me@gmail.com",
-        receivers="you@gmail.com",
+        sender="me@example.com",
+        receivers="you@example.com",
         subject="Some news",
         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():
 def test_html_message():
-    html = "<h3>Hi,</h3><p>Nice to meet you</p>"
 
 
     sender = EmailSender(host=None, port=1234)
     sender = EmailSender(host=None, port=1234)
     msg = sender.get_message(
     msg = sender.get_message(
-        sender="me@gmail.com",
-        receivers="you@gmail.com",
+        sender="me@example.com",
+        receivers="you@example.com",
         subject="Some news",
         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():
 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)
     sender = EmailSender(host=None, port=1234)
     msg = sender.get_message(
     msg = sender.get_message(
-        sender="me@gmail.com",
-        receivers="you@gmail.com",
+        sender="me@example.com",
+        receivers="you@example.com",
         subject="Some news",
         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(
 @pytest.mark.parametrize(
     "html,expected_html,text,expected_text,extra", [
     "html,expected_html,text,expected_text,extra", [
         pytest.param(
         pytest.param(
@@ -130,14 +140,79 @@ def test_with_jinja_params(html, text, extra, expected_html, expected_text):
         body_params=extra
         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_html == html
     assert expected_text == text
     assert expected_text == text
 
 
+@pytest.mark.parametrize("use_jinja_obj,use_jinja", [
+    pytest.param(None, False, id="Use arg"),
+    pytest.param(False, None, id="Use attr"),
+    pytest.param(True, False, id="Override"),
+])
+def test_without_jinja(use_jinja_obj, use_jinja):
+    html = "<h3>Hi,</h3> <p>This is {{ user }} from { node }. I'm really {{ sender.full_name }}.</p>"
+    text = "Hi, \nThis is {{ user }} from { node }. I'm really {{ sender.full_name }}."
+
+    sender = EmailSender(host=None, port=1234)
+    sender.use_jinja = use_jinja_obj
+    msg = sender.get_message(
+        sender="me@example.com",
+        receivers="you@example.com",
+        subject="Some news",
+        text=text,
+        html=html,
+        use_jinja=use_jinja,
+    )
+    
+    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>=="
+
+    --===============<ID>==
+    Content-Type: multipart/alternative;
+     boundary="===============<ID>=="
+
+    --===============<ID>==
+    Content-Type: text/plain; charset="utf-8"
+    Content-Transfer-Encoding: 7bit
+
+    Hi, 
+    This is {{ user }} from { node }. I'm really {{ sender.full_name }}.
+
+    --===============<ID>==
+    Content-Type: text/html; charset="utf-8"
+    Content-Transfer-Encoding: quoted-printable
+    MIME-Version: 1.0
+
+    <h3>Hi,</h3> <p>This is {{ user }} from { node }. I'm really {{ sender.full_n=
+    ame }}.</p>
+
+    --===============<ID>==--
+
+    --===============<ID>==--
+    """)[1:]
+
+
 def test_with_error():
 def test_with_error():
     sender = EmailSender(host=None, port=1234)
     sender = EmailSender(host=None, port=1234)
     try:
     try:
@@ -150,8 +225,12 @@ def test_with_error():
             text="Error occurred \n{{ error }}",
             text="Error occurred \n{{ error }}",
             html="<h1>Error occurred: </h1>{{ 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.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')
     assert text.endswith(', in test_with_error\n    raise RuntimeError("Deliberate failure")\nRuntimeError: Deliberate failure\n')
@@ -176,8 +255,15 @@ def test_set_defaults():
 
 
 def test_cc_bcc():
 def test_cc_bcc():
     email = EmailSender(host=None, port=1234)
     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():
 def test_missing_subject():
     email = EmailSender(host=None, port=1234)
     email = EmailSender(host=None, port=1234)
@@ -205,5 +291,5 @@ def test_no_table_templates():
         'subject': 'Some news', 
         'subject': 'Some news', 
         'to': 'you@gmail.com', 
         'to': 'you@gmail.com', 
         'MIME-Version': '1.0', 
         '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 typing import Union
+from textwrap import dedent
 from redmail import EmailSender
 from redmail import EmailSender
 
 
+from convert import remove_email_content_id
+
 def test_distributions():
 def test_distributions():
     class DistrSender(EmailSender):
     class DistrSender(EmailSender):
         "Send email using pre-defined distribution lists"
         "Send email using pre-defined distribution lists"
@@ -37,9 +40,10 @@ def test_distributions():
         cc="group2",
         cc="group2",
         subject="Some email",
         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 redmail.email.utils import pd
 
 
 from resources import get_mpl_fig, get_pil_image
 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"):
 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}
         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)
     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', 
         'subject': 'Some news', 
         'to': 'you@gmail.com', 
         'to': 'you@gmail.com', 
         #'MIME-Version': '1.0', 
         #'MIME-Version': '1.0', 
-        'Content-Type': 'multipart/alternative'
+        'Content-Type': 'multipart/mixed'
     } == headers
     } == headers
 
 
 def test_with_image_dict_jpeg():
 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")
     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', 
         'subject': 'Some news', 
         'to': 'you@gmail.com', 
         'to': 'you@gmail.com', 
         #'MIME-Version': '1.0', 
         #'MIME-Version': '1.0', 
-        'Content-Type': 'multipart/alternative'
+        'Content-Type': 'multipart/mixed'
     } == headers
     } == headers
 
 
 
 
@@ -125,11 +138,12 @@ def test_with_image_obj(get_image_obj):
         body_images={"my_image": 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)
     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', 
         'subject': 'Some news', 
         'to': 'you@gmail.com', 
         'to': 'you@gmail.com', 
         #'MIME-Version': '1.0', 
         #'MIME-Version': '1.0', 
-        'Content-Type': 'multipart/alternative'
+        'Content-Type': 'multipart/mixed'
     } == headers
     } == headers
 
 
 def test_with_image_error():
 def test_with_image_error():
@@ -235,10 +249,13 @@ def test_with_html_table_no_error(get_df, tmpdir):
         body_tables={"my_table": df}
         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]
     #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)
     #tmpdir.join("email.html").write(html)
 
 
     # TODO: Test the HTML is as required
     # 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'}
         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_html == html
     assert expected_text == text
     assert expected_text == text

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

@@ -1,4 +1,5 @@
 
 
+from collections import Counter
 import re
 import re
 
 
 def remove_extra_lines(s:str):
 def remove_extra_lines(s:str):
@@ -7,4 +8,29 @@ def remove_extra_lines(s:str):
 
 
 def remove_email_extra(s:str):
 def remove_email_extra(s:str):
     s = remove_extra_lines(s)
     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
 from redmail import EmailHandler
 import logging
 import logging
 
 
+from convert import payloads_to_dict
+
 def _create_dummy_send(messages:list):
 def _create_dummy_send(messages:list):
     def _dummy_send(msg):
     def _dummy_send(msg):
         messages.append(msg)
         messages.append(msg)
@@ -32,7 +34,9 @@ def test_default_body():
                 'Content-Type': 'text/plain; charset="utf-8"',
                 'Content-Type': 'text/plain; charset="utf-8"',
                 'MIME-Version': '1.0',
                 'MIME-Version': '1.0',
             },
             },
-            'a message\n',
+            {
+                'text/plain': 'a message\n'
+            },
             id="Minimal",
             id="Minimal",
         ),
         ),
         pytest.param(
         pytest.param(
@@ -52,7 +56,9 @@ def test_default_body():
                 'Content-Type': 'text/plain; charset="utf-8"',
                 'Content-Type': 'text/plain; charset="utf-8"',
                 'MIME-Version': '1.0',
                 '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)",
             id="Custom message (msg)",
         ),
         ),
         pytest.param(
         pytest.param(
@@ -72,7 +78,9 @@ def test_default_body():
                 'Content-Type': 'text/plain; charset="utf-8"',
                 'Content-Type': 'text/plain; charset="utf-8"',
                 'MIME-Version': '1.0',
                 'MIME-Version': '1.0',
             },
             },
-            'Log Record: \na message\n',
+            {
+                'text/plain': 'Log Record: \na message\n'
+            },
             id="Custom message (record)",
             id="Custom message (record)",
         ),
         ),
         pytest.param(
         pytest.param(
@@ -90,7 +98,9 @@ def test_default_body():
                 'Content-Type': 'text/plain; charset="utf-8"',
                 'Content-Type': 'text/plain; charset="utf-8"',
                 'MIME-Version': '1.0',
                 'MIME-Version': '1.0',
             },
             },
-            'a message\n',
+            {
+                'text/plain': 'a message\n'
+            },
             id="Sender with fomatted subject",
             id="Sender with fomatted subject",
         ),
         ),
         pytest.param(
         pytest.param(
@@ -106,9 +116,15 @@ def test_default_body():
                 "from": "me@example.com",
                 "from": "me@example.com",
                 "to": "he@example.com, she@example.com",
                 "to": "he@example.com, she@example.com",
                 "subject": "A log record",
                 "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)",
             id="Custom message (HTML, msg)",
         ),
         ),
         pytest.param(
         pytest.param(
@@ -124,9 +140,15 @@ def test_default_body():
                 "from": "me@example.com",
                 "from": "me@example.com",
                 "to": "he@example.com, she@example.com",
                 "to": "he@example.com, she@example.com",
                 "subject": "A log record",
                 "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)",
             id="Custom message (HTML, record)",
         ),
         ),
     ]
     ]
@@ -150,9 +172,5 @@ def test_emit(logger, kwargs, exp_headers, exp_payload):
 
 
     assert headers == exp_headers
     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
 from redmail import MultiEmailHandler
 import logging
 import logging
 
 
+from convert import payloads_to_dict
+
 def _create_dummy_send(messages:list):
 def _create_dummy_send(messages:list):
     def _dummy_send(msg):
     def _dummy_send(msg):
         messages.append(msg)
         messages.append(msg)
@@ -40,7 +42,9 @@ def test_sender_with_login():
                 'Content-Type': 'text/plain; charset="utf-8"',
                 'Content-Type': 'text/plain; charset="utf-8"',
                 'MIME-Version': '1.0',
                 'MIME-Version': '1.0',
             },
             },
-            'Log Recods:\na message\n',
+            {
+                'text/plain': 'Log Recods:\na message\n'
+            },
             id="Minimal",
             id="Minimal",
         ),
         ),
         pytest.param(
         pytest.param(
@@ -60,7 +64,9 @@ def test_sender_with_login():
                 'Content-Type': 'text/plain; charset="utf-8"',
                 'Content-Type': 'text/plain; charset="utf-8"',
                 'MIME-Version': '1.0',
                 '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)",
             id="Custom message (msgs)",
         ),
         ),
         pytest.param(
         pytest.param(
@@ -80,7 +86,9 @@ def test_sender_with_login():
                 'Content-Type': 'text/plain; charset="utf-8"',
                 'Content-Type': 'text/plain; charset="utf-8"',
                 'MIME-Version': '1.0',
                 'MIME-Version': '1.0',
             },
             },
-            'The records: \nLog: INFO - a message\n',
+            {
+                'text/plain': 'The records: \nLog: INFO - a message\n',
+            },
             id="Custom message (records)",
             id="Custom message (records)",
         ),
         ),
         pytest.param(
         pytest.param(
@@ -98,7 +106,9 @@ def test_sender_with_login():
                 'Content-Type': 'text/plain; charset="utf-8"',
                 'Content-Type': 'text/plain; charset="utf-8"',
                 'MIME-Version': '1.0',
                 'MIME-Version': '1.0',
             },
             },
-            'Log Recods:\na message\n',
+            {
+                'text/plain': 'Log Recods:\na message\n',
+            },
             id="Sender with fomatted subject",
             id="Sender with fomatted subject",
         ),
         ),
         pytest.param(
         pytest.param(
@@ -114,9 +124,15 @@ def test_sender_with_login():
                 "from": "me@example.com",
                 "from": "me@example.com",
                 "to": "he@example.com, she@example.com",
                 "to": "he@example.com, she@example.com",
                 "subject": "A log record",
                 "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)",
             id="Custom message (HTML, msgs)",
         ),
         ),
         pytest.param(
         pytest.param(
@@ -132,9 +148,15 @@ def test_sender_with_login():
                 "from": "me@example.com",
                 "from": "me@example.com",
                 "to": "he@example.com, she@example.com",
                 "to": "he@example.com, she@example.com",
                 "subject": "A log record",
                 "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)",
             id="Custom message (HTML, records)",
         ),
         ),
     ]
     ]
@@ -160,12 +182,8 @@ def test_emit(logger, kwargs, exp_headers, exp_payload):
 
 
     assert headers == exp_headers
     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):
 def test_flush_multiple(logger):
@@ -189,7 +207,7 @@ def test_flush_multiple(logger):
     assert len(msgs) == 1
     assert len(msgs) == 1
     msg = msgs[0]
     msg = msgs[0]
     headers = dict(msg.items())
     headers = dict(msg.items())
-    payload = msg.get_payload()
+    text = msg.get_payload()
 
 
     assert headers == {
     assert headers == {
         "from": "None",
         "from": "None",
@@ -200,7 +218,7 @@ def test_flush_multiple(logger):
         'MIME-Version': '1.0',
         '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():
 def test_flush_none():
     msgs = []
     msgs = []
@@ -221,7 +239,7 @@ def test_flush_none():
     assert len(msgs) == 1
     assert len(msgs) == 1
     msg = msgs[0]
     msg = msgs[0]
     headers = dict(msg.items())
     headers = dict(msg.items())
-    payload = msg.get_payload()
+    text = msg.get_payload()
 
 
     assert headers == {
     assert headers == {
         "from": "None",
         "from": "None",
@@ -232,4 +250,4 @@ def test_flush_none():
         'MIME-Version': '1.0',
         'MIME-Version': '1.0',
     }
     }
 
 
-    assert payload == "Records: \n"
+    assert text == "Records: \n"