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'{alt}' + + # 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'{alt}' - - # 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\n

Subject: {}

'.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\n

Subject: {}

'.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}>" 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 == "\n
Custom 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 "
Cell
" 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 == "\n
Custom 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 "
Cell
" 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 '