From be96d31e90f4a7a6bfe7413f477199bc763eb582 Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Mon, 3 Nov 2025 16:59:28 -0500
Subject: [PATCH 01/10] conditionally look for email attachments
---
emailer_lib/egress.py | 44 ++++++++++++++++++++++---------------------
1 file changed, 23 insertions(+), 21 deletions(-)
diff --git a/emailer_lib/egress.py b/emailer_lib/egress.py
index c4e3178..24c7bd1 100644
--- a/emailer_lib/egress.py
+++ b/emailer_lib/egress.py
@@ -284,31 +284,33 @@ def send_intermediate_email_with_smtp(
msg_alt.attach(MIMEText(i_email.text, "plain"))
# Attach inline images
- for image_name, image_base64 in i_email.inline_attachments.items():
- img_bytes = base64.b64decode(image_base64)
- img = MIMEImage(img_bytes, _subtype="png", name=f"{image_name}")
+ if i_email.inline_attachments:
+ for image_name, image_base64 in i_email.inline_attachments.items():
+ img_bytes = base64.b64decode(image_base64)
+ img = MIMEImage(img_bytes, _subtype="png", name=f"{image_name}")
- img.add_header("Content-ID", f"<{image_name}>")
- img.add_header("Content-Disposition", "inline", filename=f"{image_name}")
+ img.add_header("Content-ID", f"<{image_name}>")
+ img.add_header("Content-Disposition", "inline", filename=f"{image_name}")
- msg.attach(img)
+ msg.attach(img)
# Attach external files (any type)
- for filename in i_email.external_attachments:
- with open(filename, "rb") as f:
- file_data = f.read()
-
- # Guess MIME type based on file extension
- mime_type, _ = mimetypes.guess_type(filename)
- if mime_type is None:
- mime_type = "application/octet-stream"
- main_type, sub_type = mime_type.split("/", 1)
-
- part = MIMEBase(main_type, sub_type)
- part.set_payload(file_data)
- encoders.encode_base64(part)
- part.add_header("Content-Disposition", "attachment", filename=filename)
- msg.attach(part)
+ if i_email.external_attachments:
+ for filename in i_email.external_attachments:
+ with open(filename, "rb") as f:
+ file_data = f.read()
+
+ # Guess MIME type based on file extension
+ mime_type, _ = mimetypes.guess_type(filename)
+ if mime_type is None:
+ mime_type = "application/octet-stream"
+ main_type, sub_type = mime_type.split("/", 1)
+
+ part = MIMEBase(main_type, sub_type)
+ part.set_payload(file_data)
+ encoders.encode_base64(part)
+ part.add_header("Content-Disposition", "attachment", filename=filename)
+ msg.attach(part)
# Send via SMTP with appropriate security protocol
if security == "ssl":
From 85f2ab548224933a52196a764bb7993e36d09e4c Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Mon, 3 Nov 2025 17:20:17 -0500
Subject: [PATCH 02/10] first pass mjml inline images
---
emailer_lib/ingress.py | 31 ++--
emailer_lib/mjml/__init__.py | 4 +
emailer_lib/mjml/_core.py | 5 +-
emailer_lib/mjml/image_processor.py | 227 ++++++++++++++++++++++++++++
4 files changed, 250 insertions(+), 17 deletions(-)
create mode 100644 emailer_lib/mjml/image_processor.py
diff --git a/emailer_lib/ingress.py b/emailer_lib/ingress.py
index e7aad6d..914dfbb 100644
--- a/emailer_lib/ingress.py
+++ b/emailer_lib/ingress.py
@@ -1,12 +1,12 @@
from __future__ import annotations
from base64 import b64encode
import json
-import re
from email.message import EmailMessage
from mjml import mjml2html
from .structs import IntermediateEmail
+from .mjml import MJMLTag, process_mjml_images
__all__ = [
"redmail_to_intermediate_email",
@@ -43,38 +43,39 @@ def yagmail_to_intermediate_email():
pass
-def mjml_to_intermediate_email(mjml_content: str) -> IntermediateEmail:
+def mjml_to_intermediate_email(
+ mjml_content: str | MJMLTag,
+) -> IntermediateEmail:
"""
Convert MJML markup to an IntermediateEmail
Parameters
- ------
+ ----------
mjml_content
- MJML markup string
+ MJML markup string or MJMLTag object
Returns
------
An Intermediate Email object
"""
- email_content = mjml2html(mjml_content)
+ # Handle MJMLTag objects by preprocessing images
+ if isinstance(mjml_content, MJMLTag):
+ processed_mjml, inline_attachments = process_mjml_images(mjml_content)
+ mjml_markup = processed_mjml.render_mjml()
+ else:
+ # String-based MJML, no preprocessing needed
+ mjml_markup = mjml_content
+ inline_attachments = {}
- # Find all
tags and extract their src attributes
- pattern = r'
]+src="([^"\s]+)"[^>]*>'
- matches = re.findall(pattern, email_content)
- inline_attachments = {}
- for src in matches:
- # in theory, retrieve the externally hosted images and save to bytes
- # the user would need to pass CID-referenced images directly somehow,
- # as mjml doesn't handle them
- raise NotImplementedError("mj-image tags are not yet supported")
+ email_content = mjml2html(mjml_markup)
i_email = IntermediateEmail(
html=email_content,
subject="",
rsc_email_supress_report_attachment=False,
rsc_email_supress_scheduled=False,
- inline_attachments=inline_attachments,
+ inline_attachments=inline_attachments if inline_attachments else None,
)
return i_email
diff --git a/emailer_lib/mjml/__init__.py b/emailer_lib/mjml/__init__.py
index 959139c..447e1df 100644
--- a/emailer_lib/mjml/__init__.py
+++ b/emailer_lib/mjml/__init__.py
@@ -39,10 +39,14 @@
navbar_link,
social_element,
)
+# Import image_bytes for BytesIO/bytes pre-processing
+from .image_processor import process_mjml_images, image_bytes
__all__ = (
"MJMLTag",
"TagAttrDict",
+ "process_mjml_images",
+ "image_bytes",
"mjml",
"head",
"body",
diff --git a/emailer_lib/mjml/_core.py b/emailer_lib/mjml/_core.py
index d2068f2..4e2fed9 100644
--- a/emailer_lib/mjml/_core.py
+++ b/emailer_lib/mjml/_core.py
@@ -125,10 +125,11 @@ def _flatten(children):
elif isinstance(c, (str, float)):
yield c
- # Build attribute string
+ # Build attribute string (filter out internal attributes starting with _)
attr_str = ""
if self.attrs:
- attr_str = " " + " ".join(f'{k}="{v}"' for k, v in self.attrs.items())
+ public_attrs = {k: v for k, v in self.attrs.items() if not k.startswith("_")}
+ attr_str = " " + " ".join(f'{k}="{v}"' for k, v in public_attrs.items())
# Render children/content
inner = ""
diff --git a/emailer_lib/mjml/image_processor.py b/emailer_lib/mjml/image_processor.py
new file mode 100644
index 0000000..de474d4
--- /dev/null
+++ b/emailer_lib/mjml/image_processor.py
@@ -0,0 +1,227 @@
+"""
+Image preprocessing for MJML tags.
+
+This module handles conversion of image bytes to CID-referenced inline
+attachments for email embedding without using globals.
+"""
+
+from __future__ import annotations
+from typing import Any, Dict, Tuple
+import base64
+from io import BytesIO
+
+from ._core import MJMLTag
+
+__all__ = ["process_mjml_images", "image_bytes"]
+
+# Counter for generating sequential CID filenames
+_cid_counter = [0]
+
+
+def image_bytes(*args, attributes=None, content=None):
+ """
+ Create an MJML image tag from bytes or BytesIO.
+
+ This function pre-processes BytesIO/bytes into mj-raw tags containing
+ HTML img elements with CID references. The base64 data and CID filename
+ are stored as tag attributes for extraction by process_mjml_images().
+
+ Parameters
+ ----------
+ *args
+ Children (MJMLTag objects)
+ attributes
+ Optional dict of tag attributes (alt, width, etc.)
+ content
+ Optional text content for the tag
+
+ Returns
+ -------
+ MJMLTag
+ mj-raw tag with HTML img using actual CID reference (e.g., cid:plot_1.png)
+ """
+ from .tags import image as original_image
+
+ # If no attributes or no src, use regular image tag
+ if not attributes or "src" not in attributes:
+ return original_image(*args, attributes=attributes, content=content)
+
+ src_value = attributes["src"]
+
+ # If src is bytes or BytesIO, process immediately
+ if isinstance(src_value, (bytes, BytesIO)):
+ image_bytes_data = _convert_to_bytes(src_value)
+ b64_string = base64.b64encode(image_bytes_data).decode("utf-8")
+
+ # Generate CID filename immediately (for use in repr)
+ _cid_counter[0] += 1
+ cid_filename = f"plot_{_cid_counter[0]}.png"
+
+ # Create HTML img tag with actual CID reference
+ width = attributes.get("width", "100%")
+ alt = attributes.get("alt", "Image")
+ html_content = f'
'
+
+ # Return mj-raw tag with both CID filename and base64 data in attributes
+ tag_attrs = {
+ "_cid_filename": cid_filename,
+ "_cid_data": b64_string
+ }
+ return MJMLTag("mj-raw", content=html_content, attributes=tag_attrs, _is_leaf=True)
+
+ # If src is a string (URL), use regular image tag
+ return original_image(*args, attributes=attributes, content=content)
+
+
+def _convert_to_bytes(obj: Any) -> bytes:
+ """
+ Convert bytes or BytesIO to bytes.
+
+ Parameters
+ ----------
+ obj
+ The object to convert (bytes or BytesIO)
+
+ Returns
+ -------
+ bytes
+ The binary representation of the object
+
+ Raises
+ ------
+ TypeError
+ If the object type is not supported
+ """
+ if isinstance(obj, BytesIO):
+ return obj.getvalue()
+
+ if isinstance(obj, bytes):
+ return obj
+
+ raise TypeError(
+ f"Unsupported image type: {type(obj).__name__}. "
+ "Expected bytes or BytesIO."
+ )
+
+
+def _extract_base64_from_uri(uri: str) -> str:
+ """
+ Extract base64 data from a data URI.
+
+ Parameters
+ ----------
+ uri
+ A data URI in format: data:image/png;base64,
+
+ Returns
+ -------
+ str
+ The base64 string
+
+ Raises
+ ------
+ ValueError
+ If URI format is invalid
+ """
+ if not uri.startswith("data:image/"):
+ raise ValueError(f"Invalid data URI: {uri}")
+
+ if ";base64," not in uri:
+ raise ValueError(f"Data URI must be base64-encoded: {uri}")
+
+ return uri.split(";base64,", 1)[1]
+
+
+def process_mjml_images(mjml_tag: MJMLTag) -> Tuple[MJMLTag, Dict[str, str]]:
+ """
+ Extract inline attachments from MJML tree.
+
+ This function recursively walks through the MJML tag tree and finds
+ mj-raw tags that have _cid_data and _cid_filename attributes (created
+ by image_bytes()). It extracts the base64 data and builds the attachments
+ dictionary. The HTML img tags already have correct cid: references from
+ when image_bytes() created them.
+
+ Parameters
+ ----------
+ mjml_tag
+ The MJML tag tree to process
+
+ Returns
+ -------
+ Tuple[MJMLTag, Dict[str, str]]
+ A tuple of:
+ - The modified MJML tag tree with special attributes removed
+ - Dictionary mapping CID filenames to base64-encoded image data
+
+ Examples
+ --------
+ ```python
+ from emailer_lib.mjml import mjml, body, section, column
+ from emailer_lib.mjml.image_processor import image_bytes, process_mjml_images
+ from io import BytesIO
+
+ # Create BytesIO with image data
+ buf = BytesIO(b'...png binary data...')
+
+ # Create MJML using image_bytes() - stores CID and data in tag attributes
+ email = mjml(
+ body(
+ section(
+ column(
+ image_bytes(attributes={
+ "src": buf,
+ "alt": "My Plot",
+ "width": "600px"
+ })
+ )
+ )
+ )
+ )
+
+ # Process to extract attachments
+ processed_email, attachments = process_mjml_images(email)
+
+ # Result:
+ # - processed_email has img src already set to cid:plot_1.png
+ # - attachments = {"plot_1.png": "iVBORw0KGgo..."}
+ ```
+ """
+ inline_attachments: Dict[str, str] = {}
+
+ def _process_tag(tag: MJMLTag) -> MJMLTag:
+ """Recursively process a tag and its children."""
+
+ # Handle mj-raw tags with _cid_data attribute (from image_bytes)
+ if tag.tagName == "mj-raw" and "_cid_data" in tag.attrs:
+ # Extract the CID filename and base64 data
+ cid_filename = tag.attrs.get("_cid_filename", "image.png")
+ b64_string = tag.attrs["_cid_data"]
+
+ # Store in attachments
+ inline_attachments[cid_filename] = b64_string
+
+ # Create new tag without the special attributes
+ new_tag = MJMLTag(tag.tagName, content=tag.content, _is_leaf=tag._is_leaf)
+ # Copy other attributes except _cid_data and _cid_filename
+ for k, v in tag.attrs.items():
+ if k not in ("_cid_data", "_cid_filename"):
+ new_tag.attrs[k] = v
+
+ return new_tag
+
+ # For all other tags, process recursively
+ new_tag = MJMLTag(tag.tagName, attributes=dict(tag.attrs), content=tag.content, _is_leaf=tag._is_leaf)
+
+ for child in tag.children:
+ if isinstance(child, MJMLTag):
+ new_tag.children.append(_process_tag(child))
+ else:
+ new_tag.children.append(child)
+
+ return new_tag
+
+ # Process the entire tag tree
+ processed_tag = _process_tag(mjml_tag)
+
+ return processed_tag, inline_attachments
From 65e38ab4776b5e7fb09682dac819814b3d2c26af Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Mon, 3 Nov 2025 17:20:24 -0500
Subject: [PATCH 03/10] first pass mjml inline images
---
emailer_lib/mjml/image_processor.py | 28 ----------------------------
1 file changed, 28 deletions(-)
diff --git a/emailer_lib/mjml/image_processor.py b/emailer_lib/mjml/image_processor.py
index de474d4..9bc8016 100644
--- a/emailer_lib/mjml/image_processor.py
+++ b/emailer_lib/mjml/image_processor.py
@@ -104,34 +104,6 @@ def _convert_to_bytes(obj: Any) -> bytes:
)
-def _extract_base64_from_uri(uri: str) -> str:
- """
- Extract base64 data from a data URI.
-
- Parameters
- ----------
- uri
- A data URI in format: data:image/png;base64,
-
- Returns
- -------
- str
- The base64 string
-
- Raises
- ------
- ValueError
- If URI format is invalid
- """
- if not uri.startswith("data:image/"):
- raise ValueError(f"Invalid data URI: {uri}")
-
- if ";base64," not in uri:
- raise ValueError(f"Data URI must be base64-encoded: {uri}")
-
- return uri.split(";base64,", 1)[1]
-
-
def process_mjml_images(mjml_tag: MJMLTag) -> Tuple[MJMLTag, Dict[str, str]]:
"""
Extract inline attachments from MJML tree.
From 259db90fc7aa7adf68f325469b3300897cfe9e07 Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Wed, 5 Nov 2025 18:38:11 -0500
Subject: [PATCH 04/10] handle inline images in mjml content
Todo: attach local files on disc as inline attachments
---
emailer_lib/ingress.py | 2 +-
emailer_lib/mjml/__init__.py | 5 +-
emailer_lib/mjml/_core.py | 88 +++++++++++------
emailer_lib/mjml/image_processor.py | 145 +++++++++++-----------------
emailer_lib/structs.py | 101 +++++++++++++++----
emailer_lib/tests/test_ingress.py | 73 +++++++++++++-
6 files changed, 269 insertions(+), 145 deletions(-)
diff --git a/emailer_lib/ingress.py b/emailer_lib/ingress.py
index 914dfbb..30f85e5 100644
--- a/emailer_lib/ingress.py
+++ b/emailer_lib/ingress.py
@@ -75,7 +75,7 @@ def mjml_to_intermediate_email(
subject="",
rsc_email_supress_report_attachment=False,
rsc_email_supress_scheduled=False,
- inline_attachments=inline_attachments if inline_attachments else None,
+ inline_attachments=inline_attachments,
)
return i_email
diff --git a/emailer_lib/mjml/__init__.py b/emailer_lib/mjml/__init__.py
index 447e1df..dedc64c 100644
--- a/emailer_lib/mjml/__init__.py
+++ b/emailer_lib/mjml/__init__.py
@@ -39,14 +39,13 @@
navbar_link,
social_element,
)
-# Import image_bytes for BytesIO/bytes pre-processing
-from .image_processor import process_mjml_images, image_bytes
+# Import process_mjml_images for BytesIO/bytes processing at conversion time
+from .image_processor import process_mjml_images
__all__ = (
"MJMLTag",
"TagAttrDict",
"process_mjml_images",
- "image_bytes",
"mjml",
"head",
"body",
diff --git a/emailer_lib/mjml/_core.py b/emailer_lib/mjml/_core.py
index 4e2fed9..3fcae70 100644
--- a/emailer_lib/mjml/_core.py
+++ b/emailer_lib/mjml/_core.py
@@ -1,38 +1,46 @@
# MJML core classes adapted from py-htmltools
## TODO: make sure Ending tags are rendered as needed
-# https://documentation.mjml.io/#ending-tags
+# https://documentation.mjml.io/#ending-tags
from typing import Dict, Mapping, Optional, Sequence, Union
import warnings
+from io import BytesIO
from mjml import mjml2html
# Types for MJML
-TagAttrValue = Union[str, float, bool, None]
+
+TagAttrValue = Union[str, float, bool, None, bytes, BytesIO]
TagAttrs = Union[Dict[str, TagAttrValue], "TagAttrDict"]
TagChild = Union["MJMLTag", str, float, None, Sequence["TagChild"]]
-class TagAttrDict(Dict[str, str]):
+class TagAttrDict(Dict[str, Union[str, bytes, BytesIO]]):
"""
- MJML attribute dictionary. All values are stored as strings.
+ MJML attribute dictionary. Most values are stored as strings, but bytes/BytesIO are preserved.
"""
- def __init__(
- self, *args: Mapping[str, TagAttrValue]
- ) -> None:
+ def __init__(self, *args: Mapping[str, TagAttrValue]) -> None:
super().__init__()
for mapping in args:
for k, v in mapping.items():
if v is not None:
- self[k] = str(v)
+ # Preserve bytes and BytesIO objects as-is for image processing
+ if isinstance(v, (bytes, BytesIO)):
+ self[k] = v
+ else:
+ self[k] = str(v)
def update(self, *args: Mapping[str, TagAttrValue]) -> None:
for mapping in args:
for k, v in mapping.items():
if v is not None:
- self[k] = str(v)
+ # Preserve bytes and BytesIO objects as-is for image processing
+ if isinstance(v, (bytes, BytesIO)):
+ self[k] = v
+ else:
+ self[k] = str(v)
class MJMLTag:
@@ -52,7 +60,7 @@ def __init__(
self.attrs = TagAttrDict()
self.children = []
self._is_leaf = _is_leaf
-
+
# Runtime validation for leaf tags
if self._is_leaf:
# For leaf tags, treat the first positional argument as content if provided
@@ -64,47 +72,55 @@ def __init__(
self.content = args[0]
else:
self.content = content
-
+
# Validate content type
- if self.content is not None and not isinstance(self.content, (str, int, float)):
+ if self.content is not None and not isinstance(
+ self.content, (str, int, float)
+ ):
raise TypeError(
f"<{tagName}> content must be a string, int, or float, "
f"got {type(self.content).__name__}"
)
-
+
# Validate attributes parameter type
- if attributes is not None and not isinstance(attributes, (dict, TagAttrDict)):
+ if attributes is not None and not isinstance(
+ attributes, (dict, TagAttrDict)
+ ):
raise TypeError(
f"attributes must be a dict or TagAttrDict, got {type(attributes).__name__}."
)
-
+
# Process attributes
if attributes is not None:
self.attrs.update(attributes)
else:
# For container tags
self.content = content
-
+
# Validate attributes parameter type
- if attributes is not None and not isinstance(attributes, (dict, TagAttrDict)):
+ if attributes is not None and not isinstance(
+ attributes, (dict, TagAttrDict)
+ ):
raise TypeError(
f"attributes must be a dict or TagAttrDict, got {type(attributes).__name__}. "
f"If you meant to pass children, use positional arguments for container tags."
)
-
+
# Collect children (for non-leaf tags only)
for arg in args:
if (
- isinstance(arg, (str, float)) or arg is None or isinstance(arg, MJMLTag)
+ isinstance(arg, (str, float))
+ or arg is None
+ or isinstance(arg, MJMLTag)
):
self.children.append(arg)
elif isinstance(arg, Sequence) and not isinstance(arg, str):
self.children.extend(arg)
-
+
# Process attributes
if attributes is not None:
self.attrs.update(attributes)
-
+
# TODO: confirm if this is the case... I don't think it is
# # If content is provided, children should be empty
# if self.content is not None:
@@ -114,6 +130,9 @@ def render_mjml(self, indent: int = 0, eol: str = "\n") -> str:
"""
Render MJMLTag and its children to MJML markup.
Ported from htmltools Tag rendering logic.
+
+ Note: BytesIO/bytes in image src attributes are not supported by render_mjml().
+ Pass the MJMLTag directly to mjml_to_intermediate_email() instead.
"""
def _flatten(children):
@@ -125,11 +144,20 @@ def _flatten(children):
elif isinstance(c, (str, float)):
yield c
- # Build attribute string (filter out internal attributes starting with _)
+ # Check for BytesIO/bytes in mj-image tags and raise clear error
+ if self.tagName == "mj-image" and "src" in self.attrs:
+ src_value = self.attrs["src"]
+ if isinstance(src_value, (bytes, BytesIO)):
+ raise ValueError(
+ "Cannot render MJML with BytesIO/bytes in image src attribute. "
+ "Pass the MJMLTag object directly to mjml_to_intermediate_email() instead of calling render_mjml() first. "
+ "Example: i_email = mjml_to_intermediate_email(doc)"
+ )
+
+ # Build attribute string
attr_str = ""
if self.attrs:
- public_attrs = {k: v for k, v in self.attrs.items() if not k.startswith("_")}
- attr_str = " " + " ".join(f'{k}="{v}"' for k, v in public_attrs.items())
+ attr_str = " " + " ".join(f'{k}="{v}"' for k, v in self.attrs.items())
# Render children/content
inner = ""
@@ -161,15 +189,15 @@ def __repr__(self) -> str:
def to_html(self, **mjml2html_kwargs):
"""
Render MJMLTag to HTML using mjml2html.
-
+
If this is not a top-level tag, it will be automatically wrapped
in ... with a warning.
-
+
Parameters
----------
**mjml2html_kwargs
Additional keyword arguments to pass to mjml2html
-
+
Returns
-------
str
@@ -185,7 +213,7 @@ def to_html(self, **mjml2html_kwargs):
"Automatically wrapping in .... "
"For full control, create a complete MJML document with the mjml() tag.",
UserWarning,
- stacklevel=2
+ stacklevel=2,
)
wrapped = MJMLTag("mjml", self)
mjml_markup = wrapped.render_mjml()
@@ -196,10 +224,10 @@ def to_html(self, **mjml2html_kwargs):
"Automatically wrapping in .... "
"For full control, create a complete MJML document with the mjml() tag.",
UserWarning,
- stacklevel=2
+ stacklevel=2,
)
# Wrap in mjml and mj-body
wrapped = MJMLTag("mjml", MJMLTag("mj-body", self))
mjml_markup = wrapped.render_mjml()
-
+
return mjml2html(mjml_markup, **mjml2html_kwargs)
diff --git a/emailer_lib/mjml/image_processor.py b/emailer_lib/mjml/image_processor.py
index 9bc8016..5387add 100644
--- a/emailer_lib/mjml/image_processor.py
+++ b/emailer_lib/mjml/image_processor.py
@@ -12,67 +12,12 @@
from ._core import MJMLTag
-__all__ = ["process_mjml_images", "image_bytes"]
+__all__ = ["process_mjml_images"]
# Counter for generating sequential CID filenames
_cid_counter = [0]
-def image_bytes(*args, attributes=None, content=None):
- """
- Create an MJML image tag from bytes or BytesIO.
-
- This function pre-processes BytesIO/bytes into mj-raw tags containing
- HTML img elements with CID references. The base64 data and CID filename
- are stored as tag attributes for extraction by process_mjml_images().
-
- Parameters
- ----------
- *args
- Children (MJMLTag objects)
- attributes
- Optional dict of tag attributes (alt, width, etc.)
- content
- Optional text content for the tag
-
- Returns
- -------
- MJMLTag
- mj-raw tag with HTML img using actual CID reference (e.g., cid:plot_1.png)
- """
- from .tags import image as original_image
-
- # If no attributes or no src, use regular image tag
- if not attributes or "src" not in attributes:
- return original_image(*args, attributes=attributes, content=content)
-
- src_value = attributes["src"]
-
- # If src is bytes or BytesIO, process immediately
- if isinstance(src_value, (bytes, BytesIO)):
- image_bytes_data = _convert_to_bytes(src_value)
- b64_string = base64.b64encode(image_bytes_data).decode("utf-8")
-
- # Generate CID filename immediately (for use in repr)
- _cid_counter[0] += 1
- cid_filename = f"plot_{_cid_counter[0]}.png"
-
- # Create HTML img tag with actual CID reference
- width = attributes.get("width", "100%")
- alt = attributes.get("alt", "Image")
- html_content = f'
'
-
- # Return mj-raw tag with both CID filename and base64 data in attributes
- tag_attrs = {
- "_cid_filename": cid_filename,
- "_cid_data": b64_string
- }
- return MJMLTag("mj-raw", content=html_content, attributes=tag_attrs, _is_leaf=True)
-
- # If src is a string (URL), use regular image tag
- return original_image(*args, attributes=attributes, content=content)
-
-
def _convert_to_bytes(obj: Any) -> bytes:
"""
Convert bytes or BytesIO to bytes.
@@ -106,13 +51,14 @@ def _convert_to_bytes(obj: Any) -> bytes:
def process_mjml_images(mjml_tag: MJMLTag) -> Tuple[MJMLTag, Dict[str, str]]:
"""
- Extract inline attachments from MJML tree.
+ Extract inline attachments from MJML tree and convert bytes/BytesIO to CID references.
- This function recursively walks through the MJML tag tree and finds
- mj-raw tags that have _cid_data and _cid_filename attributes (created
- by image_bytes()). It extracts the base64 data and builds the attachments
- dictionary. The HTML img tags already have correct cid: references from
- when image_bytes() created them.
+ This function recursively walks through the MJML tag tree and finds mj-image tags
+ with BytesIO or bytes in their src attribute. It converts these to CID references
+ and extracts the base64 data for the inline_attachments dictionary.
+
+ Note: This function should be called before render_mjml(). If render_mjml() is called
+ on a tag with BytesIO/bytes, it will raise an error directing you to use this approach.
Parameters
----------
@@ -123,25 +69,25 @@ def process_mjml_images(mjml_tag: MJMLTag) -> Tuple[MJMLTag, Dict[str, str]]:
-------
Tuple[MJMLTag, Dict[str, str]]
A tuple of:
- - The modified MJML tag tree with special attributes removed
+ - The modified MJML tag tree with BytesIO/bytes converted to CID references
- Dictionary mapping CID filenames to base64-encoded image data
Examples
--------
```python
- from emailer_lib.mjml import mjml, body, section, column
- from emailer_lib.mjml.image_processor import image_bytes, process_mjml_images
+ from emailer_lib.mjml import mjml, body, section, column, image
+ from emailer_lib import mjml_to_intermediate_email
from io import BytesIO
# Create BytesIO with image data
buf = BytesIO(b'...png binary data...')
- # Create MJML using image_bytes() - stores CID and data in tag attributes
+ # Create MJML using regular image() with BytesIO as src
email = mjml(
body(
section(
column(
- image_bytes(attributes={
+ image(attributes={
"src": buf,
"alt": "My Plot",
"width": "600px"
@@ -151,12 +97,10 @@ def process_mjml_images(mjml_tag: MJMLTag) -> Tuple[MJMLTag, Dict[str, str]]:
)
)
- # Process to extract attachments
- processed_email, attachments = process_mjml_images(email)
+ # Pass directly to mjml_to_intermediate_email (calls process_mjml_images internally)
+ i_email = mjml_to_intermediate_email(email)
- # Result:
- # - processed_email has img src already set to cid:plot_1.png
- # - attachments = {"plot_1.png": "iVBORw0KGgo..."}
+ # Result: i_email.inline_attachments = {"plot_1.png": "iVBORw0KGgo..."}
```
"""
inline_attachments: Dict[str, str] = {}
@@ -164,26 +108,49 @@ def process_mjml_images(mjml_tag: MJMLTag) -> Tuple[MJMLTag, Dict[str, str]]:
def _process_tag(tag: MJMLTag) -> MJMLTag:
"""Recursively process a tag and its children."""
- # Handle mj-raw tags with _cid_data attribute (from image_bytes)
- if tag.tagName == "mj-raw" and "_cid_data" in tag.attrs:
- # Extract the CID filename and base64 data
- cid_filename = tag.attrs.get("_cid_filename", "image.png")
- b64_string = tag.attrs["_cid_data"]
-
- # Store in attachments
- inline_attachments[cid_filename] = b64_string
-
- # Create new tag without the special attributes
- new_tag = MJMLTag(tag.tagName, content=tag.content, _is_leaf=tag._is_leaf)
- # Copy other attributes except _cid_data and _cid_filename
- for k, v in tag.attrs.items():
- if k not in ("_cid_data", "_cid_filename"):
- new_tag.attrs[k] = v
+ # Handle mj-image tags with BytesIO/bytes in src attribute
+ if tag.tagName == "mj-image" and "src" in tag.attrs:
+ src_value = tag.attrs["src"]
- return new_tag
+ # Check if src is BytesIO or bytes
+ if isinstance(src_value, (bytes, BytesIO)):
+ # Convert to bytes and encode to base64
+ image_bytes_data = _convert_to_bytes(src_value)
+ b64_string = base64.b64encode(image_bytes_data).decode("utf-8")
+
+ # Generate CID filename
+ _cid_counter[0] += 1
+ cid_filename = f"plot_{_cid_counter[0]}.png"
+
+ # Store in attachments
+ inline_attachments[cid_filename] = b64_string
+
+ # Create new tag with CID reference instead of BytesIO
+ new_attrs = dict(tag.attrs)
+ new_attrs["src"] = f"cid:{cid_filename}"
+ new_tag = MJMLTag(
+ tag.tagName,
+ attributes=new_attrs,
+ content=tag.content,
+ _is_leaf=tag._is_leaf
+ )
+
+ # Process children
+ for child in tag.children:
+ if isinstance(child, MJMLTag):
+ new_tag.children.append(_process_tag(child))
+ else:
+ new_tag.children.append(child)
+
+ return new_tag
# For all other tags, process recursively
- new_tag = MJMLTag(tag.tagName, attributes=dict(tag.attrs), content=tag.content, _is_leaf=tag._is_leaf)
+ new_tag = MJMLTag(
+ tag.tagName,
+ attributes=dict(tag.attrs),
+ content=tag.content,
+ _is_leaf=tag._is_leaf
+ )
for child in tag.children:
if isinstance(child, MJMLTag):
diff --git a/emailer_lib/structs.py b/emailer_lib/structs.py
index f338cfa..177c195 100644
--- a/emailer_lib/structs.py
+++ b/emailer_lib/structs.py
@@ -70,12 +70,91 @@ class IntermediateEmail:
text: str | None = None # sometimes present in quarto
recipients: list[str] | None = None # not present in quarto
+ def _generate_preview_html(self) -> str:
+ """
+ Generate preview HTML with inline attachments embedded as base64 data URIs.
+
+ This internal method converts `cid:` references in the HTML to base64 data URIs,
+ making the HTML self-contained for preview purposes. This is distinct from the
+ HTML used in egress.py where cid references are kept and images are attached
+ as separate MIME parts.
+
+ Returns
+ -------
+ str
+ HTML content with inline attachments embedded as base64 data URIs.
+ """
+ html_with_inline = re.sub(
+ r'src="cid:([^"\s]+)"',
+ _add_base_64_to_inline_attachments(self.inline_attachments),
+ self.html,
+ )
+ return html_with_inline
+
+ def _add_subject_header(self, html: str) -> str:
+ """
+ Add subject line as a header in the HTML.
+
+ Parameters
+ ----------
+ html
+ The HTML content to add the subject to
+
+ Returns
+ -------
+ str
+ HTML with subject header added
+ """
+ if "]*>)",
+ r'\1\nSubject: {}
'.format(self.subject),
+ html,
+ count=1,
+ flags=re.IGNORECASE,
+ )
+ else:
+ # Fallback: prepend if no tag found
+ html = f'Subject: {self.subject}
\n' + html
+
+ return html
+
+ def _repr_html_(self) -> str:
+ """
+ Return HTML representation with inline attachments for rich display.
+
+ This method enables rich display of the IntermediateEmail in Jupyter notebooks
+ and other IPython-compatible environments. It converts cid: references to
+ base64 data URIs so the email can be previewed directly in the notebook.
+
+ Returns
+ -------
+ str
+ HTML content with inline attachments embedded as base64 data URIs.
+
+ Examples
+ --------
+ ```python
+ # In a Jupyter notebook, simply display the email object:
+ email = IntermediateEmail(
+ html='Hello 
',
+ subject="Test Email",
+ inline_attachments={"img1.png": "iVBORw0KGgo..."}
+ )
+ email # This will automatically call _repr_html_() for rich display
+ ```
+ """
+ html_with_inline = self._generate_preview_html()
+ return self._add_subject_header(html_with_inline)
+
def write_preview_email(self, out_file: str = "preview_email.html") -> None:
"""
Write a preview HTML file with inline attachments embedded.
This method replaces image sources in the HTML with base64-encoded data from
inline attachments, allowing you to preview the email as it would appear to recipients.
+ The generated HTML is self-contained with base64 data URIs, distinct from the email
+ sent via egress.py which uses cid references with MIME attachments.
Parameters
----------
@@ -96,24 +175,10 @@ def write_preview_email(self, out_file: str = "preview_email.html") -> None:
------
Raises ValueError if external attachments are present, as preview does not support them.
"""
- html_with_inline = re.sub(
- r'src="cid:([^"\s]+)"',
- _add_base_64_to_inline_attachments(self.inline_attachments),
- self.html,
- )
-
- # Insert subject as after the opening tag, if present
- if "]*>)",
- r'\1\nSubject: {}
'.format(self.subject),
- html_with_inline,
- count=1,
- flags=re.IGNORECASE,
- )
- else:
- # Fallback: prepend if no tag found
- html_with_inline = f'Subject: {self.subject}
\n' + html_with_inline
+ # Generate the preview HTML with inline base64 images
+ html_with_inline = self._generate_preview_html()
+ # Add subject header
+ html_with_inline = self._add_subject_header(html_with_inline)
with open(out_file, "w", encoding="utf-8") as f:
f.write(html_with_inline)
diff --git a/emailer_lib/tests/test_ingress.py b/emailer_lib/tests/test_ingress.py
index 113d304..ef63565 100644
--- a/emailer_lib/tests/test_ingress.py
+++ b/emailer_lib/tests/test_ingress.py
@@ -135,7 +135,7 @@ def test_mjml_to_intermediate_email_no_images():
assert result.inline_attachments == {}
-def test_mjml_to_intermediate_email_with_image_raises():
+def test_mjml_to_intermediate_email_with_string_url():
mjml_content = """
@@ -148,8 +148,73 @@ def test_mjml_to_intermediate_email_with_image_raises():
"""
- with pytest.raises(NotImplementedError, match="mj-image tags are not yet supported"):
- mjml_to_intermediate_email(mjml_content)
+ result = mjml_to_intermediate_email(mjml_content)
+
+ assert isinstance(result, IntermediateEmail)
+ assert result.inline_attachments == {}
+ assert "https://example.com/image.jpg" in result.html
+
+
+def test_mjml_to_intermediate_email_with_bytesio():
+ from io import BytesIO
+ from emailer_lib.mjml import mjml, body, section, column, image
+
+ buf = BytesIO(b'\x89PNG\r\n\x1a\n')
+
+ mjml_tag = mjml(
+ body(
+ section(
+ column(
+ image(attributes={
+ "src": buf,
+ "alt": "Test Plot",
+ "width": "600px"
+ })
+ )
+ )
+ )
+ )
+
+ result = mjml_to_intermediate_email(mjml_tag)
+
+ assert isinstance(result, IntermediateEmail)
+ assert len(result.inline_attachments) == 1
+
+ cid_filename = list(result.inline_attachments.keys())[0]
+ assert cid_filename.endswith(".png")
+ assert f"cid:{cid_filename}" in result.html
+ assert result.inline_attachments[cid_filename] != ""
+
+
+def test_mjml_render_mjml_with_bytesio_raises_error():
+ from io import BytesIO
+ from emailer_lib.mjml import mjml, body, section, column, image
+
+ # Create a simple BytesIO object with fake image data
+ buf = BytesIO(b'\x89PNG\r\n\x1a\n')
+
+ mjml_tag = mjml(
+ body(
+ section(
+ column(
+ image(attributes={
+ "src": buf,
+ "alt": "Test Plot",
+ "width": "600px"
+ })
+ )
+ )
+ )
+ )
+
+ # Calling render_mjml() should raise an error with a helpful message
+ with pytest.raises(ValueError, match="Cannot render MJML with BytesIO/bytes"):
+ mjml_tag.render_mjml()
+
+ # But passing the tag directly to mjml_to_intermediate_email should work
+ result = mjml_to_intermediate_email(mjml_tag)
+ assert isinstance(result, IntermediateEmail)
+ assert len(result.inline_attachments) == 1
def test_quarto_json_to_intermediate_email_basic(tmp_path):
@@ -217,4 +282,4 @@ def test_quarto_json_to_intermediate_email_empty_lists(tmp_path):
result = quarto_json_to_intermediate_email(str(json_file))
assert result.external_attachments == []
- assert result.inline_attachments == {}
\ No newline at end of file
+ assert result.inline_attachments == {}
From 513d5aed6d9557a655479dc4dc0408ef3c9a7c52 Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Thu, 6 Nov 2025 15:21:42 -0500
Subject: [PATCH 05/10] add tests for image handling
---
emailer_lib/mjml/image_processor.py | 10 +-
emailer_lib/mjml/tests/test_core.py | 43 +++++
.../mjml/tests/test_image_processor.py | 148 ++++++++++++++++++
3 files changed, 195 insertions(+), 6 deletions(-)
create mode 100644 emailer_lib/mjml/tests/test_image_processor.py
diff --git a/emailer_lib/mjml/image_processor.py b/emailer_lib/mjml/image_processor.py
index 5387add..f3cc2bf 100644
--- a/emailer_lib/mjml/image_processor.py
+++ b/emailer_lib/mjml/image_processor.py
@@ -8,15 +8,13 @@
from __future__ import annotations
from typing import Any, Dict, Tuple
import base64
+import uuid
from io import BytesIO
from ._core import MJMLTag
__all__ = ["process_mjml_images"]
-# Counter for generating sequential CID filenames
-_cid_counter = [0]
-
def _convert_to_bytes(obj: Any) -> bytes:
"""
@@ -118,9 +116,9 @@ def _process_tag(tag: MJMLTag) -> MJMLTag:
image_bytes_data = _convert_to_bytes(src_value)
b64_string = base64.b64encode(image_bytes_data).decode("utf-8")
- # Generate CID filename
- _cid_counter[0] += 1
- cid_filename = f"plot_{_cid_counter[0]}.png"
+ # Generate CID filename using UUID
+ cid_id = uuid.uuid4().hex[:8]
+ cid_filename = f"plot_{cid_id}.png"
# Store in attachments
inline_attachments[cid_filename] = b64_string
diff --git a/emailer_lib/mjml/tests/test_core.py b/emailer_lib/mjml/tests/test_core.py
index 935d955..d665184 100644
--- a/emailer_lib/mjml/tests/test_core.py
+++ b/emailer_lib/mjml/tests/test_core.py
@@ -1,4 +1,5 @@
import pytest
+from io import BytesIO
from emailer_lib.mjml._core import MJMLTag, TagAttrDict
@@ -171,3 +172,45 @@ def test_children_sequence_flattening():
assert "Text 1" in mjml_content
assert "Text 2" in mjml_content
assert "Text 3" in mjml_content
+
+
+def test_render_mjml_raises_on_bytesio_in_image_src():
+ image_data = BytesIO(b"fake image data")
+ image_tag = MJMLTag(
+ "mj-image",
+ attributes={"src": image_data, "alt": "Test"}
+ )
+
+ with pytest.raises(ValueError, match="Cannot render MJML with BytesIO/bytes"):
+ image_tag.render_mjml()
+
+
+def test_render_mjml_raises_on_bytes_in_image_src():
+ image_data = b"fake image data"
+ image_tag = MJMLTag(
+ "mj-image",
+ attributes={"src": image_data, "alt": "Test"}
+ )
+
+ with pytest.raises(ValueError, match="Cannot render MJML with BytesIO/bytes"):
+ image_tag.render_mjml()
+
+
+def test_tagattr_dict_stores_bytesio():
+ """Test that TagAttrDict can store BytesIO values."""
+ image_data = BytesIO(b"test data")
+ attrs = TagAttrDict({"src": image_data, "alt": "Test"})
+
+ # Verify BytesIO is stored as-is
+ assert isinstance(attrs["src"], BytesIO)
+ assert attrs["alt"] == "Test"
+
+
+def test_tagattr_dict_stores_bytes():
+ """Test that TagAttrDict can store bytes values."""
+ image_data = b"test data"
+ attrs = TagAttrDict({"src": image_data, "alt": "Test"})
+
+ # Verify bytes are stored as-is
+ assert isinstance(attrs["src"], bytes)
+ assert attrs["alt"] == "Test"
diff --git a/emailer_lib/mjml/tests/test_image_processor.py b/emailer_lib/mjml/tests/test_image_processor.py
new file mode 100644
index 0000000..18eacd4
--- /dev/null
+++ b/emailer_lib/mjml/tests/test_image_processor.py
@@ -0,0 +1,148 @@
+import pytest
+from io import BytesIO
+
+from emailer_lib.mjml.image_processor import _convert_to_bytes, process_mjml_images
+from emailer_lib.mjml._core import MJMLTag
+
+
+def test_convert_to_bytes_from_bytesio():
+ data = b"test image data"
+ bytesio_obj = BytesIO(data)
+
+ result = _convert_to_bytes(bytesio_obj)
+
+ assert isinstance(result, bytes)
+ assert result == data
+
+
+def test_convert_to_bytes_from_bytes():
+ data = b"test image data"
+
+ result = _convert_to_bytes(data)
+
+ assert isinstance(result, bytes)
+ assert result == data
+
+
+def test_convert_to_bytes_raises_on_invalid_type():
+ with pytest.raises(TypeError, match="Unsupported image type"):
+ _convert_to_bytes("string")
+
+ with pytest.raises(TypeError, match="Unsupported image type"):
+ _convert_to_bytes(123)
+
+ with pytest.raises(TypeError, match="Unsupported image type"):
+ _convert_to_bytes([b"data"])
+
+
+def test_process_mjml_images_with_bytesio():
+ image_data = b"fake png data"
+ bytesio_obj = BytesIO(image_data)
+
+ # Create MJML tag with BytesIO image
+ image_tag = MJMLTag(
+ "mj-image",
+ attributes={"src": bytesio_obj, "alt": "Test"}
+ )
+
+ processed_tag, inline_attachments = process_mjml_images(image_tag)
+
+ assert len(inline_attachments) == 1
+
+ cid_filename = list(inline_attachments.keys())[0]
+ assert cid_filename.startswith("plot_") and cid_filename.endswith(".png")
+ assert processed_tag.attrs["src"] == f"cid:{cid_filename}"
+ assert isinstance(inline_attachments[cid_filename], str)
+ assert len(inline_attachments[cid_filename]) > 0
+
+
+def test_process_mjml_images_with_bytes():
+ image_data = b"fake png data"
+
+ # Create MJML tag with bytes image
+ image_tag = MJMLTag(
+ "mj-image",
+ attributes={"src": image_data, "alt": "Test"}
+ )
+
+ processed_tag, inline_attachments = process_mjml_images(image_tag)
+
+ assert len(inline_attachments) == 1
+
+ cid_filename = list(inline_attachments.keys())[0]
+ assert cid_filename.startswith("plot_") and cid_filename.endswith(".png")
+ assert processed_tag.attrs["src"] == f"cid:{cid_filename}"
+ assert isinstance(inline_attachments[cid_filename], str)
+ assert len(inline_attachments[cid_filename]) > 0
+
+
+def test_process_mjml_images_multiple_images():
+ image_data_1 = b"fake png data 1"
+ image_data_2 = b"fake png data 2"
+
+ # Create MJML structure with multiple images
+ col = MJMLTag(
+ "mj-column",
+ MJMLTag("mj-image", attributes={"src": BytesIO(image_data_1), "alt": "Img1"}),
+ MJMLTag("mj-image", attributes={"src": BytesIO(image_data_2), "alt": "Img2"}),
+ )
+
+ processed_tag, inline_attachments = process_mjml_images(col)
+
+ assert len(inline_attachments) == 2
+
+ cid_filenames = list(inline_attachments.keys())
+ assert all(f.startswith("plot_") and f.endswith(".png") for f in cid_filenames)
+ assert cid_filenames[0] != cid_filenames[1]
+
+ children_with_images = [c for c in processed_tag.children if isinstance(c, MJMLTag)]
+ assert children_with_images[0].attrs["src"] == f"cid:{cid_filenames[0]}"
+ assert children_with_images[1].attrs["src"] == f"cid:{cid_filenames[1]}"
+
+
+def test_process_mjml_images_preserves_other_attributes():
+ image_data = b"fake png data"
+
+ image_tag = MJMLTag(
+ "mj-image",
+ attributes={
+ "src": BytesIO(image_data),
+ "alt": "Test Image",
+ "width": "600px",
+ "padding": "10px"
+ }
+ )
+
+ processed_tag, _ = process_mjml_images(image_tag)
+
+ assert processed_tag.attrs["alt"] == "Test Image"
+ assert processed_tag.attrs["width"] == "600px"
+ assert processed_tag.attrs["padding"] == "10px"
+
+
+def test_process_mjml_images_preserves_non_image_tags():
+ # Create MJML structure with mixed content
+ section = MJMLTag(
+ "mj-section",
+ MJMLTag("mj-column",
+ MJMLTag("mj-text", content="Hello"),
+ MJMLTag("mj-image", attributes={"src": BytesIO(b"data"), "alt": "Img"}),
+ MJMLTag("mj-text", content="World"),
+ )
+ )
+
+ processed_tag, inline_attachments = process_mjml_images(section)
+
+ assert processed_tag.tagName == "mj-section"
+ column = processed_tag.children[0]
+ assert column.tagName == "mj-column"
+ assert len(column.children) == 3
+
+ assert column.children[0].tagName == "mj-text"
+ assert column.children[0].content == "Hello"
+ assert column.children[2].tagName == "mj-text"
+ assert column.children[2].content == "World"
+
+ assert column.children[1].tagName == "mj-image"
+ cid_filename = list(inline_attachments.keys())[0]
+ assert column.children[1].attrs["src"] == f"cid:{cid_filename}"
From 58aede84dbf61e9ae4f8f6c5dd3b0fe195e651fb Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Thu, 6 Nov 2025 15:37:41 -0500
Subject: [PATCH 06/10] make process_mjml_images private
---
emailer_lib/ingress.py | 5 +++--
emailer_lib/mjml/__init__.py | 4 +---
emailer_lib/mjml/image_processor.py | 9 ++++++---
emailer_lib/mjml/tests/test_image_processor.py | 12 ++++++------
4 files changed, 16 insertions(+), 14 deletions(-)
diff --git a/emailer_lib/ingress.py b/emailer_lib/ingress.py
index 30f85e5..bbf8fa1 100644
--- a/emailer_lib/ingress.py
+++ b/emailer_lib/ingress.py
@@ -6,7 +6,8 @@
from mjml import mjml2html
from .structs import IntermediateEmail
-from .mjml import MJMLTag, process_mjml_images
+from .mjml import MJMLTag
+from .mjml.image_processor import _process_mjml_images
__all__ = [
"redmail_to_intermediate_email",
@@ -61,7 +62,7 @@ def mjml_to_intermediate_email(
"""
# Handle MJMLTag objects by preprocessing images
if isinstance(mjml_content, MJMLTag):
- processed_mjml, inline_attachments = process_mjml_images(mjml_content)
+ processed_mjml, inline_attachments = _process_mjml_images(mjml_content)
mjml_markup = processed_mjml.render_mjml()
else:
# String-based MJML, no preprocessing needed
diff --git a/emailer_lib/mjml/__init__.py b/emailer_lib/mjml/__init__.py
index dedc64c..ea3b2f1 100644
--- a/emailer_lib/mjml/__init__.py
+++ b/emailer_lib/mjml/__init__.py
@@ -39,13 +39,11 @@
navbar_link,
social_element,
)
-# Import process_mjml_images for BytesIO/bytes processing at conversion time
-from .image_processor import process_mjml_images
+# _process_mjml_images is called internally by mjml_to_intermediate_email
__all__ = (
"MJMLTag",
"TagAttrDict",
- "process_mjml_images",
"mjml",
"head",
"body",
diff --git a/emailer_lib/mjml/image_processor.py b/emailer_lib/mjml/image_processor.py
index f3cc2bf..414ff25 100644
--- a/emailer_lib/mjml/image_processor.py
+++ b/emailer_lib/mjml/image_processor.py
@@ -13,7 +13,7 @@
from ._core import MJMLTag
-__all__ = ["process_mjml_images"]
+__all__ = []
def _convert_to_bytes(obj: Any) -> bytes:
@@ -47,10 +47,13 @@ def _convert_to_bytes(obj: Any) -> bytes:
)
-def process_mjml_images(mjml_tag: MJMLTag) -> Tuple[MJMLTag, Dict[str, str]]:
+def _process_mjml_images(mjml_tag: MJMLTag) -> Tuple[MJMLTag, Dict[str, str]]:
"""
Extract inline attachments from MJML tree and convert bytes/BytesIO to CID references.
+ This is a private function. Users should not call it directly.
+ It is called automatically by mjml_to_intermediate_email().
+
This function recursively walks through the MJML tag tree and finds mj-image tags
with BytesIO or bytes in their src attribute. It converts these to CID references
and extracts the base64 data for the inline_attachments dictionary.
@@ -95,7 +98,7 @@ def process_mjml_images(mjml_tag: MJMLTag) -> Tuple[MJMLTag, Dict[str, str]]:
)
)
- # Pass directly to mjml_to_intermediate_email (calls process_mjml_images internally)
+ # Pass directly to mjml_to_intermediate_email (calls _process_mjml_images internally)
i_email = mjml_to_intermediate_email(email)
# Result: i_email.inline_attachments = {"plot_1.png": "iVBORw0KGgo..."}
diff --git a/emailer_lib/mjml/tests/test_image_processor.py b/emailer_lib/mjml/tests/test_image_processor.py
index 18eacd4..5fe2f5c 100644
--- a/emailer_lib/mjml/tests/test_image_processor.py
+++ b/emailer_lib/mjml/tests/test_image_processor.py
@@ -1,7 +1,7 @@
import pytest
from io import BytesIO
-from emailer_lib.mjml.image_processor import _convert_to_bytes, process_mjml_images
+from emailer_lib.mjml.image_processor import _convert_to_bytes, _process_mjml_images
from emailer_lib.mjml._core import MJMLTag
@@ -45,7 +45,7 @@ def test_process_mjml_images_with_bytesio():
attributes={"src": bytesio_obj, "alt": "Test"}
)
- processed_tag, inline_attachments = process_mjml_images(image_tag)
+ processed_tag, inline_attachments = _process_mjml_images(image_tag)
assert len(inline_attachments) == 1
@@ -65,7 +65,7 @@ def test_process_mjml_images_with_bytes():
attributes={"src": image_data, "alt": "Test"}
)
- processed_tag, inline_attachments = process_mjml_images(image_tag)
+ processed_tag, inline_attachments = _process_mjml_images(image_tag)
assert len(inline_attachments) == 1
@@ -87,7 +87,7 @@ def test_process_mjml_images_multiple_images():
MJMLTag("mj-image", attributes={"src": BytesIO(image_data_2), "alt": "Img2"}),
)
- processed_tag, inline_attachments = process_mjml_images(col)
+ processed_tag, inline_attachments = _process_mjml_images(col)
assert len(inline_attachments) == 2
@@ -113,7 +113,7 @@ def test_process_mjml_images_preserves_other_attributes():
}
)
- processed_tag, _ = process_mjml_images(image_tag)
+ processed_tag, _ = _process_mjml_images(image_tag)
assert processed_tag.attrs["alt"] == "Test Image"
assert processed_tag.attrs["width"] == "600px"
@@ -131,7 +131,7 @@ def test_process_mjml_images_preserves_non_image_tags():
)
)
- processed_tag, inline_attachments = process_mjml_images(section)
+ processed_tag, inline_attachments = _process_mjml_images(section)
assert processed_tag.tagName == "mj-section"
column = processed_tag.children[0]
From 1593b7cfebbd73139f783dd4180025c5f3c44947 Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Thu, 6 Nov 2025 17:56:03 -0500
Subject: [PATCH 07/10] make render_mjml private
---
emailer_lib/ingress.py | 4 +++-
emailer_lib/mjml/_core.py | 32 ++++++++++++++++++-----------
emailer_lib/mjml/image_processor.py | 3 ---
emailer_lib/mjml/tests/test_core.py | 22 ++++++++++----------
emailer_lib/mjml/tests/test_tags.py | 24 +++++++++++-----------
emailer_lib/tests/test_ingress.py | 4 ++--
6 files changed, 48 insertions(+), 41 deletions(-)
diff --git a/emailer_lib/ingress.py b/emailer_lib/ingress.py
index bbf8fa1..1f88f48 100644
--- a/emailer_lib/ingress.py
+++ b/emailer_lib/ingress.py
@@ -8,6 +8,7 @@
from .structs import IntermediateEmail
from .mjml import MJMLTag
from .mjml.image_processor import _process_mjml_images
+import warnings
__all__ = [
"redmail_to_intermediate_email",
@@ -63,9 +64,10 @@ def mjml_to_intermediate_email(
# Handle MJMLTag objects by preprocessing images
if isinstance(mjml_content, MJMLTag):
processed_mjml, inline_attachments = _process_mjml_images(mjml_content)
- mjml_markup = processed_mjml.render_mjml()
+ mjml_markup = processed_mjml._render_mjml()
else:
# String-based MJML, no preprocessing needed
+ warnings.warn("MJMLTag not detected; treating input as plaintext MJML markup", UserWarning)
mjml_markup = mjml_content
inline_attachments = {}
diff --git a/emailer_lib/mjml/_core.py b/emailer_lib/mjml/_core.py
index 3fcae70..f97f891 100644
--- a/emailer_lib/mjml/_core.py
+++ b/emailer_lib/mjml/_core.py
@@ -126,12 +126,12 @@ def __init__(
# if self.content is not None:
# self.children = []
- def render_mjml(self, indent: int = 0, eol: str = "\n") -> str:
+ def _render_mjml(self, indent: int = 0, eol: str = "\n") -> str:
"""
Render MJMLTag and its children to MJML markup.
Ported from htmltools Tag rendering logic.
- Note: BytesIO/bytes in image src attributes are not supported by render_mjml().
+ Note: BytesIO/bytes in image src attributes are not supported by _render_mjml().
Pass the MJMLTag directly to mjml_to_intermediate_email() instead.
"""
@@ -150,7 +150,7 @@ def _flatten(children):
if isinstance(src_value, (bytes, BytesIO)):
raise ValueError(
"Cannot render MJML with BytesIO/bytes in image src attribute. "
- "Pass the MJMLTag object directly to mjml_to_intermediate_email() instead of calling render_mjml() first. "
+ "Pass the MJMLTag object directly to mjml_to_intermediate_email() instead of calling _render_mjml() first. "
"Example: i_email = mjml_to_intermediate_email(doc)"
)
@@ -167,7 +167,7 @@ def _flatten(children):
child_strs = []
for child in _flatten(self.children):
if isinstance(child, MJMLTag):
- child_strs.append(child.render_mjml(indent + 2, eol))
+ child_strs.append(child._render_mjml(indent + 2, eol))
else:
child_strs.append(str(child))
if child_strs:
@@ -181,12 +181,20 @@ def _flatten(children):
return f"{pad}<{self.tagName}{attr_str}>{self.tagName}>"
def _repr_html_(self):
- return self.to_html()
+ from ..ingress import mjml_to_intermediate_email
+ return mjml_to_intermediate_email(self)._repr_html_()
+ # TODO: make something deliberate
def __repr__(self) -> str:
- return self.render_mjml()
-
- def to_html(self, **mjml2html_kwargs):
+ warnings.warn(
+ f"__repr__ not yet fully implemented for MJMLTag({self.tagName})",
+ UserWarning,
+ stacklevel=2,
+ )
+ return f""
+
+ # warning explain that they are not to pass this to intermediate email
+ def to_html(self, **mjml2html_kwargs) -> str:
"""
Render MJMLTag to HTML using mjml2html.
@@ -201,11 +209,11 @@ def to_html(self, **mjml2html_kwargs):
Returns
-------
str
- Result from mjml2html containing html content
+ Result from `mjml-python.mjml2html()` containing html content
"""
if self.tagName == "mjml":
# Already a complete MJML document
- mjml_markup = self.render_mjml()
+ mjml_markup = self._render_mjml()
elif self.tagName == "mj-body":
# Wrap only in mjml tag
warnings.warn(
@@ -216,7 +224,7 @@ def to_html(self, **mjml2html_kwargs):
stacklevel=2,
)
wrapped = MJMLTag("mjml", self)
- mjml_markup = wrapped.render_mjml()
+ mjml_markup = wrapped._render_mjml()
else:
# Warn and wrap in mjml/mj-body
warnings.warn(
@@ -228,6 +236,6 @@ def to_html(self, **mjml2html_kwargs):
)
# Wrap in mjml and mj-body
wrapped = MJMLTag("mjml", MJMLTag("mj-body", self))
- mjml_markup = wrapped.render_mjml()
+ mjml_markup = wrapped._render_mjml()
return mjml2html(mjml_markup, **mjml2html_kwargs)
diff --git a/emailer_lib/mjml/image_processor.py b/emailer_lib/mjml/image_processor.py
index 414ff25..02c61ed 100644
--- a/emailer_lib/mjml/image_processor.py
+++ b/emailer_lib/mjml/image_processor.py
@@ -58,9 +58,6 @@ def _process_mjml_images(mjml_tag: MJMLTag) -> Tuple[MJMLTag, Dict[str, str]]:
with BytesIO or bytes in their src attribute. It converts these to CID references
and extracts the base64 data for the inline_attachments dictionary.
- Note: This function should be called before render_mjml(). If render_mjml() is called
- on a tag with BytesIO/bytes, it will raise an error directing you to use this approach.
-
Parameters
----------
mjml_tag
diff --git a/emailer_lib/mjml/tests/test_core.py b/emailer_lib/mjml/tests/test_core.py
index d665184..22b714a 100644
--- a/emailer_lib/mjml/tests/test_core.py
+++ b/emailer_lib/mjml/tests/test_core.py
@@ -37,7 +37,7 @@ def test_tag_with_dict_attributes():
def test_tag_filters_none_children():
tag = MJMLTag("mj-column", MJMLTag("mj-text", content="Text"), None)
- mjml_content = tag.render_mjml()
+ mjml_content = tag._render_mjml()
# None should not appear in output
assert mjml_content.count("") == 1
@@ -45,25 +45,25 @@ def test_tag_filters_none_children():
def test_render_empty_tag():
tag = MJMLTag("mj-spacer")
- mjml_content = tag.render_mjml()
+ mjml_content = tag._render_mjml()
assert mjml_content == ""
def test_render_with_attributes():
tag = MJMLTag("mj-spacer", attributes={"height": "20px"})
- mjml_content = tag.render_mjml()
+ mjml_content = tag._render_mjml()
assert mjml_content == ''
def test_render_with_custom_indent():
tag = MJMLTag("mj-text", content="Hello")
- mjml_content = tag.render_mjml(indent=4)
+ mjml_content = tag._render_mjml(indent=4)
assert mjml_content.startswith(" ")
def test_render_with_custom_eol():
tag = MJMLTag("mj-text", content="Hello")
- mjml_content = tag.render_mjml(eol="\r\n")
+ mjml_content = tag._render_mjml(eol="\r\n")
assert "\r\n" in mjml_content
@@ -71,7 +71,7 @@ def test_render_nested_tags():
tag = MJMLTag(
"mj-section", MJMLTag("mj-column", MJMLTag("mj-text", content="Nested"))
)
- mjml_content = tag.render_mjml()
+ mjml_content = tag._render_mjml()
assert "" in mjml_content
assert "" in mjml_content
@@ -82,7 +82,7 @@ def test_render_nested_tags():
def test_render_with_string_and_tag_children():
child_tag = MJMLTag("mj-text", content="Tagged")
tag = MJMLTag("mj-column", "Plain text", child_tag, "More text")
- mjml_content = tag.render_mjml()
+ mjml_content = tag._render_mjml()
assert "Plain text" in mjml_content
assert "" in mjml_content
@@ -92,7 +92,7 @@ def test_render_with_string_and_tag_children():
def test_repr_returns_mjml():
tag = MJMLTag("mj-text", content="Hello")
- assert repr(tag) == tag.render_mjml()
+ assert repr(tag) == tag._render_mjml()
def test_to_html_with_complete_mjml_document():
@@ -166,7 +166,7 @@ def test_children_sequence_flattening():
assert tag.children[1] == child2
assert tag.children[2] == child3
- mjml_content = tag.render_mjml()
+ mjml_content = tag._render_mjml()
assert mjml_content.count("") == 3
assert "Text 1" in mjml_content
@@ -182,7 +182,7 @@ def test_render_mjml_raises_on_bytesio_in_image_src():
)
with pytest.raises(ValueError, match="Cannot render MJML with BytesIO/bytes"):
- image_tag.render_mjml()
+ image_tag._render_mjml()
def test_render_mjml_raises_on_bytes_in_image_src():
@@ -193,7 +193,7 @@ def test_render_mjml_raises_on_bytes_in_image_src():
)
with pytest.raises(ValueError, match="Cannot render MJML with BytesIO/bytes"):
- image_tag.render_mjml()
+ image_tag._render_mjml()
def test_tagattr_dict_stores_bytesio():
diff --git a/emailer_lib/mjml/tests/test_tags.py b/emailer_lib/mjml/tests/test_tags.py
index ea10c32..2f18f8a 100644
--- a/emailer_lib/mjml/tests/test_tags.py
+++ b/emailer_lib/mjml/tests/test_tags.py
@@ -39,7 +39,7 @@ def test_container_tag_accepts_children():
assert len(sec.children) == 1
assert sec.children[0].tagName == "mj-column"
- mjml_content = sec.render_mjml()
+ mjml_content = sec._render_mjml()
assert "" in mjml_content
assert "" in mjml_content
assert "" in mjml_content
@@ -53,7 +53,7 @@ def test_container_tag_accepts_attributes():
assert sec.attrs["background-color"] == "#fff"
assert sec.attrs["padding"] == "20px"
- mjml_content = sec.render_mjml()
+ mjml_content = sec._render_mjml()
assert '' in mjml_content
@@ -66,7 +66,7 @@ def test_container_tag_accepts_children_and_attrs():
assert len(sec.children) == 2
assert sec.attrs["background-color"] == "#f0f0f0"
- mjml_content = sec.render_mjml()
+ mjml_content = sec._render_mjml()
assert 'background-color="#f0f0f0"' in mjml_content
assert "Col 1" in mjml_content
assert "Col 2" in mjml_content
@@ -79,7 +79,7 @@ def test_leaf_tag_accepts_content():
assert txt.tagName == "mj-text"
assert txt.content == "Hello World"
- mjml_content = txt.render_mjml()
+ mjml_content = txt._render_mjml()
assert mjml_content == "\nHello World\n"
@@ -89,7 +89,7 @@ def test_leaf_tag_accepts_attributes():
assert txt.attrs["color"] == "red"
assert txt.attrs["font-size"] == "16px"
- mjml_content = txt.render_mjml()
+ mjml_content = txt._render_mjml()
assert 'color="red"' in mjml_content
assert 'font-size="16px"' in mjml_content
assert "Hello" in mjml_content
@@ -108,7 +108,7 @@ def test_button_is_leaf_tag():
assert btn.content == "Click Me"
assert btn.attrs["href"] == "https://example.com"
- mjml_content = btn.render_mjml()
+ mjml_content = btn._render_mjml()
assert 'href="https://example.com"' in mjml_content
assert "Click Me" in mjml_content
assert "Custom HTML"
- mjml_content = r.render_mjml()
+ mjml_content = r._render_mjml()
assert mjml_content == "\nCustom HTML
\n"
@@ -167,7 +167,7 @@ def test_table_tag():
assert tbl.tagName == "mj-table"
assert "" in tbl.content
- mjml_content = tbl.render_mjml()
+ mjml_content = tbl._render_mjml()
assert "" in mjml_content
assert "" in mjml_content
@@ -195,7 +195,7 @@ def test_image_tag():
assert img.attrs["src"] == "https://example.com/image.jpg"
assert img.attrs["alt"] == "Test Image"
- mjml_content = img.render_mjml()
+ mjml_content = img._render_mjml()
assert 'src="https://example.com/image.jpg"' in mjml_content
assert 'alt="Test Image"' in mjml_content
assert "" in mjml_content
assert "" in mjml_content
assert '
Date: Thu, 6 Nov 2025 17:59:51 -0500
Subject: [PATCH 08/10] update test reprs
---
emailer_lib/mjml/tests/test_core.py | 13 ++++++++-----
1 file changed, 8 insertions(+), 5 deletions(-)
diff --git a/emailer_lib/mjml/tests/test_core.py b/emailer_lib/mjml/tests/test_core.py
index 22b714a..1de5d33 100644
--- a/emailer_lib/mjml/tests/test_core.py
+++ b/emailer_lib/mjml/tests/test_core.py
@@ -1,5 +1,6 @@
import pytest
from io import BytesIO
+from emailer_lib.ingress import mjml_to_intermediate_email
from emailer_lib.mjml._core import MJMLTag, TagAttrDict
@@ -89,10 +90,10 @@ def test_render_with_string_and_tag_children():
assert "More text" in mjml_content
-def test_repr_returns_mjml():
+def test_repr_returns_simple_string():
tag = MJMLTag("mj-text", content="Hello")
- assert repr(tag) == tag._render_mjml()
+ assert repr(tag) == ""
def test_to_html_with_complete_mjml_document():
@@ -121,12 +122,14 @@ def test_to_html_warns_and_wraps_other_tags():
assert "html" in html_result
-def test_repr_html_calls_to_html():
+def test_repr_html_returns_intermediate_email_repr_html():
tag = MJMLTag("mjml", MJMLTag("mj-body"))
html_from_repr = tag._repr_html_()
- html_from_method = tag.to_html()
- assert html_from_repr == html_from_method
+ # _repr_html_() should return the HTML representation from mjml_to_intermediate_email
+ assert "
Date: Thu, 6 Nov 2025 18:02:50 -0500
Subject: [PATCH 09/10] update comment
---
emailer_lib/mjml/_core.py | 3 +++
1 file changed, 3 insertions(+)
diff --git a/emailer_lib/mjml/_core.py b/emailer_lib/mjml/_core.py
index f97f891..d0e0073 100644
--- a/emailer_lib/mjml/_core.py
+++ b/emailer_lib/mjml/_core.py
@@ -201,6 +201,9 @@ def to_html(self, **mjml2html_kwargs) -> str:
If this is not a top-level tag, it will be automatically wrapped
in ... with a warning.
+ Note: This method embeds all images as inline data URIs in the HTML.
+ For email sending with separate attachments, use mjml_to_intermediate_email() instead.
+
Parameters
----------
**mjml2html_kwargs
From 9756f91163dcbf0770ed9c9fb94dd4b1f512be28 Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Fri, 7 Nov 2025 10:09:11 -0500
Subject: [PATCH 10/10] rename _render_mjml to _to_mjml
---
emailer_lib/ingress.py | 2 +-
emailer_lib/mjml/_core.py | 16 ++++++++--------
emailer_lib/mjml/tests/test_core.py | 24 ++++++++++++------------
emailer_lib/mjml/tests/test_tags.py | 24 ++++++++++++------------
emailer_lib/tests/test_ingress.py | 6 +++---
5 files changed, 36 insertions(+), 36 deletions(-)
diff --git a/emailer_lib/ingress.py b/emailer_lib/ingress.py
index 1f88f48..e92e09a 100644
--- a/emailer_lib/ingress.py
+++ b/emailer_lib/ingress.py
@@ -64,7 +64,7 @@ def mjml_to_intermediate_email(
# Handle MJMLTag objects by preprocessing images
if isinstance(mjml_content, MJMLTag):
processed_mjml, inline_attachments = _process_mjml_images(mjml_content)
- mjml_markup = processed_mjml._render_mjml()
+ mjml_markup = processed_mjml._to_mjml()
else:
# String-based MJML, no preprocessing needed
warnings.warn("MJMLTag not detected; treating input as plaintext MJML markup", UserWarning)
diff --git a/emailer_lib/mjml/_core.py b/emailer_lib/mjml/_core.py
index d0e0073..d55913e 100644
--- a/emailer_lib/mjml/_core.py
+++ b/emailer_lib/mjml/_core.py
@@ -126,12 +126,12 @@ def __init__(
# if self.content is not None:
# self.children = []
- def _render_mjml(self, indent: int = 0, eol: str = "\n") -> str:
+ def _to_mjml(self, indent: int = 0, eol: str = "\n") -> str:
"""
Render MJMLTag and its children to MJML markup.
Ported from htmltools Tag rendering logic.
- Note: BytesIO/bytes in image src attributes are not supported by _render_mjml().
+ Note: BytesIO/bytes in image src attributes are not supported by _to_mjml().
Pass the MJMLTag directly to mjml_to_intermediate_email() instead.
"""
@@ -150,7 +150,7 @@ def _flatten(children):
if isinstance(src_value, (bytes, BytesIO)):
raise ValueError(
"Cannot render MJML with BytesIO/bytes in image src attribute. "
- "Pass the MJMLTag object directly to mjml_to_intermediate_email() instead of calling _render_mjml() first. "
+ "Pass the MJMLTag object directly to mjml_to_intermediate_email() instead of calling _to_mjml() first. "
"Example: i_email = mjml_to_intermediate_email(doc)"
)
@@ -167,7 +167,7 @@ def _flatten(children):
child_strs = []
for child in _flatten(self.children):
if isinstance(child, MJMLTag):
- child_strs.append(child._render_mjml(indent + 2, eol))
+ child_strs.append(child._to_mjml(indent + 2, eol))
else:
child_strs.append(str(child))
if child_strs:
@@ -202,7 +202,7 @@ def to_html(self, **mjml2html_kwargs) -> str:
in ... with a warning.
Note: This method embeds all images as inline data URIs in the HTML.
- For email sending with separate attachments, use mjml_to_intermediate_email() instead.
+ For email composition with inline attachments, use mjml_to_intermediate_email() instead.
Parameters
----------
@@ -216,7 +216,7 @@ def to_html(self, **mjml2html_kwargs) -> str:
"""
if self.tagName == "mjml":
# Already a complete MJML document
- mjml_markup = self._render_mjml()
+ mjml_markup = self._to_mjml()
elif self.tagName == "mj-body":
# Wrap only in mjml tag
warnings.warn(
@@ -227,7 +227,7 @@ def to_html(self, **mjml2html_kwargs) -> str:
stacklevel=2,
)
wrapped = MJMLTag("mjml", self)
- mjml_markup = wrapped._render_mjml()
+ mjml_markup = wrapped._to_mjml()
else:
# Warn and wrap in mjml/mj-body
warnings.warn(
@@ -239,6 +239,6 @@ def to_html(self, **mjml2html_kwargs) -> str:
)
# Wrap in mjml and mj-body
wrapped = MJMLTag("mjml", MJMLTag("mj-body", self))
- mjml_markup = wrapped._render_mjml()
+ mjml_markup = wrapped._to_mjml()
return mjml2html(mjml_markup, **mjml2html_kwargs)
diff --git a/emailer_lib/mjml/tests/test_core.py b/emailer_lib/mjml/tests/test_core.py
index 1de5d33..6069c15 100644
--- a/emailer_lib/mjml/tests/test_core.py
+++ b/emailer_lib/mjml/tests/test_core.py
@@ -38,7 +38,7 @@ def test_tag_with_dict_attributes():
def test_tag_filters_none_children():
tag = MJMLTag("mj-column", MJMLTag("mj-text", content="Text"), None)
- mjml_content = tag._render_mjml()
+ mjml_content = tag._to_mjml()
# None should not appear in output
assert mjml_content.count("") == 1
@@ -46,25 +46,25 @@ def test_tag_filters_none_children():
def test_render_empty_tag():
tag = MJMLTag("mj-spacer")
- mjml_content = tag._render_mjml()
+ mjml_content = tag._to_mjml()
assert mjml_content == ""
def test_render_with_attributes():
tag = MJMLTag("mj-spacer", attributes={"height": "20px"})
- mjml_content = tag._render_mjml()
+ mjml_content = tag._to_mjml()
assert mjml_content == ''
def test_render_with_custom_indent():
tag = MJMLTag("mj-text", content="Hello")
- mjml_content = tag._render_mjml(indent=4)
+ mjml_content = tag._to_mjml(indent=4)
assert mjml_content.startswith(" ")
def test_render_with_custom_eol():
tag = MJMLTag("mj-text", content="Hello")
- mjml_content = tag._render_mjml(eol="\r\n")
+ mjml_content = tag._to_mjml(eol="\r\n")
assert "\r\n" in mjml_content
@@ -72,7 +72,7 @@ def test_render_nested_tags():
tag = MJMLTag(
"mj-section", MJMLTag("mj-column", MJMLTag("mj-text", content="Nested"))
)
- mjml_content = tag._render_mjml()
+ mjml_content = tag._to_mjml()
assert "" in mjml_content
assert "" in mjml_content
@@ -83,7 +83,7 @@ def test_render_nested_tags():
def test_render_with_string_and_tag_children():
child_tag = MJMLTag("mj-text", content="Tagged")
tag = MJMLTag("mj-column", "Plain text", child_tag, "More text")
- mjml_content = tag._render_mjml()
+ mjml_content = tag._to_mjml()
assert "Plain text" in mjml_content
assert "" in mjml_content
@@ -169,7 +169,7 @@ def test_children_sequence_flattening():
assert tag.children[1] == child2
assert tag.children[2] == child3
- mjml_content = tag._render_mjml()
+ mjml_content = tag._to_mjml()
assert mjml_content.count("") == 3
assert "Text 1" in mjml_content
@@ -177,7 +177,7 @@ def test_children_sequence_flattening():
assert "Text 3" in mjml_content
-def test_render_mjml_raises_on_bytesio_in_image_src():
+def test_to_mjml_raises_on_bytesio_in_image_src():
image_data = BytesIO(b"fake image data")
image_tag = MJMLTag(
"mj-image",
@@ -185,10 +185,10 @@ def test_render_mjml_raises_on_bytesio_in_image_src():
)
with pytest.raises(ValueError, match="Cannot render MJML with BytesIO/bytes"):
- image_tag._render_mjml()
+ image_tag._to_mjml()
-def test_render_mjml_raises_on_bytes_in_image_src():
+def test_to_mjml_raises_on_bytes_in_image_src():
image_data = b"fake image data"
image_tag = MJMLTag(
"mj-image",
@@ -196,7 +196,7 @@ def test_render_mjml_raises_on_bytes_in_image_src():
)
with pytest.raises(ValueError, match="Cannot render MJML with BytesIO/bytes"):
- image_tag._render_mjml()
+ image_tag._to_mjml()
def test_tagattr_dict_stores_bytesio():
diff --git a/emailer_lib/mjml/tests/test_tags.py b/emailer_lib/mjml/tests/test_tags.py
index 2f18f8a..3ea9305 100644
--- a/emailer_lib/mjml/tests/test_tags.py
+++ b/emailer_lib/mjml/tests/test_tags.py
@@ -39,7 +39,7 @@ def test_container_tag_accepts_children():
assert len(sec.children) == 1
assert sec.children[0].tagName == "mj-column"
- mjml_content = sec._render_mjml()
+ mjml_content = sec._to_mjml()
assert "" in mjml_content
assert "" in mjml_content
assert "" in mjml_content
@@ -53,7 +53,7 @@ def test_container_tag_accepts_attributes():
assert sec.attrs["background-color"] == "#fff"
assert sec.attrs["padding"] == "20px"
- mjml_content = sec._render_mjml()
+ mjml_content = sec._to_mjml()
assert '' in mjml_content
@@ -66,7 +66,7 @@ def test_container_tag_accepts_children_and_attrs():
assert len(sec.children) == 2
assert sec.attrs["background-color"] == "#f0f0f0"
- mjml_content = sec._render_mjml()
+ mjml_content = sec._to_mjml()
assert 'background-color="#f0f0f0"' in mjml_content
assert "Col 1" in mjml_content
assert "Col 2" in mjml_content
@@ -79,7 +79,7 @@ def test_leaf_tag_accepts_content():
assert txt.tagName == "mj-text"
assert txt.content == "Hello World"
- mjml_content = txt._render_mjml()
+ mjml_content = txt._to_mjml()
assert mjml_content == "\nHello World\n"
@@ -89,7 +89,7 @@ def test_leaf_tag_accepts_attributes():
assert txt.attrs["color"] == "red"
assert txt.attrs["font-size"] == "16px"
- mjml_content = txt._render_mjml()
+ mjml_content = txt._to_mjml()
assert 'color="red"' in mjml_content
assert 'font-size="16px"' in mjml_content
assert "Hello" in mjml_content
@@ -108,7 +108,7 @@ def test_button_is_leaf_tag():
assert btn.content == "Click Me"
assert btn.attrs["href"] == "https://example.com"
- mjml_content = btn._render_mjml()
+ mjml_content = btn._to_mjml()
assert 'href="https://example.com"' in mjml_content
assert "Click Me" in mjml_content
assert "Custom HTML"
- mjml_content = r._render_mjml()
+ mjml_content = r._to_mjml()
assert mjml_content == "\nCustom HTML
\n"
@@ -167,7 +167,7 @@ def test_table_tag():
assert tbl.tagName == "mj-table"
assert "" in tbl.content
- mjml_content = tbl._render_mjml()
+ mjml_content = tbl._to_mjml()
assert "" in mjml_content
assert "" in mjml_content
@@ -195,7 +195,7 @@ def test_image_tag():
assert img.attrs["src"] == "https://example.com/image.jpg"
assert img.attrs["alt"] == "Test Image"
- mjml_content = img._render_mjml()
+ mjml_content = img._to_mjml()
assert 'src="https://example.com/image.jpg"' in mjml_content
assert 'alt="Test Image"' in mjml_content
assert "" in mjml_content
assert "" in mjml_content
assert '