Sfoglia il codice sorgente

Merge branch 'master' into ref/rename_user_name

Mikael Koli 3 anni fa
parent
commit
9b7608ac80

+ 37 - 21
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")}
     )
 
@@ -195,17 +210,18 @@ def log_simple():
 
 
 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]
 
     funcs = {
         "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,
         "images": fn_imgs,
     }[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

+ 1 - 0
docs/tutorials/index.rst

@@ -30,4 +30,5 @@ And see - :ref:`cookbook <cookbook>` for example use cases.
    cookbook
    config
    client
+   testing
    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/>`_ 
-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
 
     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)
 

+ 15 - 12
redmail/email/body.py

@@ -9,7 +9,7 @@ from pathlib import Path
 from redmail.utils import is_bytes
 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
 
@@ -39,9 +39,10 @@ class BodyImage:
 
 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.table_template = table_template
+        self.use_jinja = use_jinja
 
     def render_body(self, body:str, jinja_params:dict):
         if body is not None and self.template is not None:
@@ -79,7 +80,8 @@ class Body:
 class TextBody(Body):
 
     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)
 
 
@@ -109,16 +111,17 @@ class HTMLBody(Body):
             jinja_params : dict
                 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')
 
-        if images is not None:
+        if self.use_jinja and images is not None:
             # https://stackoverflow.com/a/49098251/13696660
             html_msg = msg.get_payload()[-1]
             cid_path_mapping = {cids[name]: path for name, path in images.items()}
@@ -244,4 +247,4 @@ class HTMLBody(Body):
                 img_content,
                 cid=cid,
                 **kwds
-            )
+            )

+ 39 - 4
redmail/email/sender.py

@@ -79,6 +79,10 @@ class EmailSender:
     html_template : str
         Name of the template to use as the HTML body of emails 
         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
         Jinja environment used for loading HTML templates
         if ``html_template`` is specified in send.
@@ -158,6 +162,7 @@ class EmailSender:
         self.html = None
         self.html_template = None
         self.text_template = None
+        self.use_jinja = True
 
         self.use_starttls = use_starttls
         self.cls_smtp = cls_smtp
@@ -223,6 +228,9 @@ class EmailSender:
             DataFrames.
         body_params : dict, optional
             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 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.
@@ -296,7 +304,8 @@ class EmailSender:
                   body_images:Optional[Dict[str, Union[str, bytes, 'plt.Figure', 'Image']]]=None, 
                   body_tables:Optional[Dict[str, 'pd.DataFrame']]=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"""
 
         subject = subject or self.subject
@@ -310,6 +319,7 @@ class EmailSender:
         text = text or self.text
         html_template = html_template or self.html_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:
             raise ValueError("Email must have a subject")
@@ -321,11 +331,14 @@ 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(),
+                use_jinja=use_jinja
             )
             body.attach(
                 msg, 
@@ -334,10 +347,11 @@ 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(),
+                use_jinja=use_jinja
             )
             body.attach(
                 msg,
@@ -346,6 +360,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)
@@ -381,6 +403,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
 

+ 154 - 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,14 +140,79 @@ 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
 
+@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():
     sender = EmailSender(host=None, port=1234)
     try:
@@ -150,8 +225,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')
@@ -176,8 +255,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)
@@ -205,5 +291,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)
@@ -40,7 +42,9 @@ def test_sender_with_login():
                 '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(
@@ -60,7 +64,9 @@ def test_sender_with_login():
                 '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(
@@ -80,7 +86,9 @@ def test_sender_with_login():
                 '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(
@@ -98,7 +106,9 @@ def test_sender_with_login():
                 '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(
@@ -114,9 +124,15 @@ def test_sender_with_login():
                 "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(
@@ -132,9 +148,15 @@ def test_sender_with_login():
                 "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)",
         ),
     ]
@@ -160,12 +182,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):
@@ -189,7 +207,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",
@@ -200,7 +218,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 = []
@@ -221,7 +239,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",
@@ -232,4 +250,4 @@ def test_flush_none():
         'MIME-Version': '1.0',
     }
 
-    assert payload == "Records: \n"
+    assert text == "Records: \n"