11# MJML core classes adapted from py-htmltools
22
33## TODO: make sure Ending tags are rendered as needed
4- # https://documentation.mjml.io/#ending-tags
4+ # https://documentation.mjml.io/#ending-tags
55
66from typing import Dict , Mapping , Optional , Sequence , Union
77import warnings
8+ from io import BytesIO
89from mjml import mjml2html
910
1011
1112# Types for MJML
12- TagAttrValue = Union [str , float , bool , None ]
13+
14+ TagAttrValue = Union [str , float , bool , None , bytes , BytesIO ]
1315TagAttrs = Union [Dict [str , TagAttrValue ], "TagAttrDict" ]
1416TagChild = Union ["MJMLTag" , str , float , None , Sequence ["TagChild" ]]
1517
1618
17- class TagAttrDict (Dict [str , str ]):
19+ class TagAttrDict (Dict [str , Union [ str , bytes , BytesIO ] ]):
1820 """
19- MJML attribute dictionary. All values are stored as strings.
21+ MJML attribute dictionary. Most values are stored as strings, but bytes/BytesIO are preserved .
2022 """
2123
22- def __init__ (
23- self , * args : Mapping [str , TagAttrValue ]
24- ) -> None :
24+ def __init__ (self , * args : Mapping [str , TagAttrValue ]) -> None :
2525 super ().__init__ ()
2626 for mapping in args :
2727 for k , v in mapping .items ():
2828 if v is not None :
29- self [k ] = str (v )
29+ # Preserve bytes and BytesIO objects as-is for image processing
30+ if isinstance (v , (bytes , BytesIO )):
31+ self [k ] = v
32+ else :
33+ self [k ] = str (v )
3034
3135 def update (self , * args : Mapping [str , TagAttrValue ]) -> None :
3236 for mapping in args :
3337 for k , v in mapping .items ():
3438 if v is not None :
35- self [k ] = str (v )
39+ # Preserve bytes and BytesIO objects as-is for image processing
40+ if isinstance (v , (bytes , BytesIO )):
41+ self [k ] = v
42+ else :
43+ self [k ] = str (v )
3644
3745
3846class MJMLTag :
@@ -52,7 +60,7 @@ def __init__(
5260 self .attrs = TagAttrDict ()
5361 self .children = []
5462 self ._is_leaf = _is_leaf
55-
63+
5664 # Runtime validation for leaf tags
5765 if self ._is_leaf :
5866 # For leaf tags, treat the first positional argument as content if provided
@@ -64,56 +72,67 @@ def __init__(
6472 self .content = args [0 ]
6573 else :
6674 self .content = content
67-
75+
6876 # Validate content type
69- if self .content is not None and not isinstance (self .content , (str , int , float )):
77+ if self .content is not None and not isinstance (
78+ self .content , (str , int , float )
79+ ):
7080 raise TypeError (
7181 f"<{ tagName } > content must be a string, int, or float, "
7282 f"got { type (self .content ).__name__ } "
7383 )
74-
84+
7585 # Validate attributes parameter type
76- if attributes is not None and not isinstance (attributes , (dict , TagAttrDict )):
86+ if attributes is not None and not isinstance (
87+ attributes , (dict , TagAttrDict )
88+ ):
7789 raise TypeError (
7890 f"attributes must be a dict or TagAttrDict, got { type (attributes ).__name__ } ."
7991 )
80-
92+
8193 # Process attributes
8294 if attributes is not None :
8395 self .attrs .update (attributes )
8496 else :
8597 # For container tags
8698 self .content = content
87-
99+
88100 # Validate attributes parameter type
89- if attributes is not None and not isinstance (attributes , (dict , TagAttrDict )):
101+ if attributes is not None and not isinstance (
102+ attributes , (dict , TagAttrDict )
103+ ):
90104 raise TypeError (
91105 f"attributes must be a dict or TagAttrDict, got { type (attributes ).__name__ } . "
92106 f"If you meant to pass children, use positional arguments for container tags."
93107 )
94-
108+
95109 # Collect children (for non-leaf tags only)
96110 for arg in args :
97111 if (
98- isinstance (arg , (str , float )) or arg is None or isinstance (arg , MJMLTag )
112+ isinstance (arg , (str , float ))
113+ or arg is None
114+ or isinstance (arg , MJMLTag )
99115 ):
100116 self .children .append (arg )
101117 elif isinstance (arg , Sequence ) and not isinstance (arg , str ):
102118 self .children .extend (arg )
103-
119+
104120 # Process attributes
105121 if attributes is not None :
106122 self .attrs .update (attributes )
107-
123+
108124 # TODO: confirm if this is the case... I don't think it is
109125 # # If content is provided, children should be empty
110126 # if self.content is not None:
111127 # self.children = []
112128
113- def render_mjml (self , indent : int = 0 , eol : str = "\n " ) -> str :
129+ def _to_mjml (self , indent : int = 0 , eol : str = "\n " ) -> str :
114130 """
115131 Render MJMLTag and its children to MJML markup.
116132 Ported from htmltools Tag rendering logic.
133+
134+ Note: BytesIO/bytes in image src attributes are not supported by _to_mjml().
135+ Pass the MJMLTag directly to mjml_to_intermediate_email() instead.
117136 """
118137
119138 def _flatten (children ):
@@ -125,6 +144,16 @@ def _flatten(children):
125144 elif isinstance (c , (str , float )):
126145 yield c
127146
147+ # Check for BytesIO/bytes in mj-image tags and raise clear error
148+ if self .tagName == "mj-image" and "src" in self .attrs :
149+ src_value = self .attrs ["src" ]
150+ if isinstance (src_value , (bytes , BytesIO )):
151+ raise ValueError (
152+ "Cannot render MJML with BytesIO/bytes in image src attribute. "
153+ "Pass the MJMLTag object directly to mjml_to_intermediate_email() instead of calling _to_mjml() first. "
154+ "Example: i_email = mjml_to_intermediate_email(doc)"
155+ )
156+
128157 # Build attribute string
129158 attr_str = ""
130159 if self .attrs :
@@ -138,7 +167,7 @@ def _flatten(children):
138167 child_strs = []
139168 for child in _flatten (self .children ):
140169 if isinstance (child , MJMLTag ):
141- child_strs .append (child .render_mjml (indent + 2 , eol ))
170+ child_strs .append (child ._to_mjml (indent + 2 , eol ))
142171 else :
143172 child_strs .append (str (child ))
144173 if child_strs :
@@ -152,53 +181,64 @@ def _flatten(children):
152181 return f"{ pad } <{ self .tagName } { attr_str } ></{ self .tagName } >"
153182
154183 def _repr_html_ (self ):
155- return self .to_html ()
184+ from ..ingress import mjml_to_intermediate_email
185+ return mjml_to_intermediate_email (self )._repr_html_ ()
156186
187+ # TODO: make something deliberate
157188 def __repr__ (self ) -> str :
158- return self .render_mjml ()
159-
160- def to_html (self , ** mjml2html_kwargs ):
189+ warnings .warn (
190+ f"__repr__ not yet fully implemented for MJMLTag({ self .tagName } )" ,
191+ UserWarning ,
192+ stacklevel = 2 ,
193+ )
194+ return f"<MJMLTag({ self .tagName } )>"
195+
196+ # warning explain that they are not to pass this to intermediate email
197+ def to_html (self , ** mjml2html_kwargs ) -> str :
161198 """
162199 Render MJMLTag to HTML using mjml2html.
163-
200+
164201 If this is not a top-level <mjml> tag, it will be automatically wrapped
165202 in <mjml><mj-body>...</mj-body></mjml> with a warning.
166-
203+
204+ Note: This method embeds all images as inline data URIs in the HTML.
205+ For email composition with inline attachments, use mjml_to_intermediate_email() instead.
206+
167207 Parameters
168208 ----------
169209 **mjml2html_kwargs
170210 Additional keyword arguments to pass to mjml2html
171-
211+
172212 Returns
173213 -------
174214 str
175- Result from mjml2html containing html content
215+ Result from `mjml-python. mjml2html()` containing html content
176216 """
177217 if self .tagName == "mjml" :
178218 # Already a complete MJML document
179- mjml_markup = self .render_mjml ()
219+ mjml_markup = self ._to_mjml ()
180220 elif self .tagName == "mj-body" :
181221 # Wrap only in mjml tag
182222 warnings .warn (
183223 "to_html() called on <mj-body> tag. "
184224 "Automatically wrapping in <mjml>...</mjml>. "
185225 "For full control, create a complete MJML document with the mjml() tag." ,
186226 UserWarning ,
187- stacklevel = 2
227+ stacklevel = 2 ,
188228 )
189229 wrapped = MJMLTag ("mjml" , self )
190- mjml_markup = wrapped .render_mjml ()
230+ mjml_markup = wrapped ._to_mjml ()
191231 else :
192232 # Warn and wrap in mjml/mj-body
193233 warnings .warn (
194234 f"to_html() called on <{ self .tagName } > tag. "
195235 "Automatically wrapping in <mjml><mj-body>...</mj-body></mjml>. "
196236 "For full control, create a complete MJML document with the mjml() tag." ,
197237 UserWarning ,
198- stacklevel = 2
238+ stacklevel = 2 ,
199239 )
200240 # Wrap in mjml and mj-body
201241 wrapped = MJMLTag ("mjml" , MJMLTag ("mj-body" , self ))
202- mjml_markup = wrapped .render_mjml ()
203-
242+ mjml_markup = wrapped ._to_mjml ()
243+
204244 return mjml2html (mjml_markup , ** mjml2html_kwargs )
0 commit comments