Skip to content

Commit a29912e

Browse files
authored
Merge pull request #15 from posit-dev/mjml-inline-images
feat: support MJML inline images
2 parents 9fa34c7 + 9756f91 commit a29912e

File tree

10 files changed

+654
-119
lines changed

10 files changed

+654
-119
lines changed

emailer_lib/egress.py

Lines changed: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -284,31 +284,33 @@ def send_intermediate_email_with_smtp(
284284
msg_alt.attach(MIMEText(i_email.text, "plain"))
285285

286286
# Attach inline images
287-
for image_name, image_base64 in i_email.inline_attachments.items():
288-
img_bytes = base64.b64decode(image_base64)
289-
img = MIMEImage(img_bytes, _subtype="png", name=f"{image_name}")
287+
if i_email.inline_attachments:
288+
for image_name, image_base64 in i_email.inline_attachments.items():
289+
img_bytes = base64.b64decode(image_base64)
290+
img = MIMEImage(img_bytes, _subtype="png", name=f"{image_name}")
290291

291-
img.add_header("Content-ID", f"<{image_name}>")
292-
img.add_header("Content-Disposition", "inline", filename=f"{image_name}")
292+
img.add_header("Content-ID", f"<{image_name}>")
293+
img.add_header("Content-Disposition", "inline", filename=f"{image_name}")
293294

294-
msg.attach(img)
295+
msg.attach(img)
295296

296297
# Attach external files (any type)
297-
for filename in i_email.external_attachments:
298-
with open(filename, "rb") as f:
299-
file_data = f.read()
300-
301-
# Guess MIME type based on file extension
302-
mime_type, _ = mimetypes.guess_type(filename)
303-
if mime_type is None:
304-
mime_type = "application/octet-stream"
305-
main_type, sub_type = mime_type.split("/", 1)
306-
307-
part = MIMEBase(main_type, sub_type)
308-
part.set_payload(file_data)
309-
encoders.encode_base64(part)
310-
part.add_header("Content-Disposition", "attachment", filename=filename)
311-
msg.attach(part)
298+
if i_email.external_attachments:
299+
for filename in i_email.external_attachments:
300+
with open(filename, "rb") as f:
301+
file_data = f.read()
302+
303+
# Guess MIME type based on file extension
304+
mime_type, _ = mimetypes.guess_type(filename)
305+
if mime_type is None:
306+
mime_type = "application/octet-stream"
307+
main_type, sub_type = mime_type.split("/", 1)
308+
309+
part = MIMEBase(main_type, sub_type)
310+
part.set_payload(file_data)
311+
encoders.encode_base64(part)
312+
part.add_header("Content-Disposition", "attachment", filename=filename)
313+
msg.attach(part)
312314

313315
# Send via SMTP with appropriate security protocol
314316
if security == "ssl":

emailer_lib/ingress.py

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
from __future__ import annotations
22
from base64 import b64encode
33
import json
4-
import re
54

65
from email.message import EmailMessage
76
from mjml import mjml2html
87

98
from .structs import IntermediateEmail
9+
from .mjml import MJMLTag
10+
from .mjml.image_processor import _process_mjml_images
11+
import warnings
1012

1113
__all__ = [
1214
"redmail_to_intermediate_email",
@@ -43,31 +45,33 @@ def yagmail_to_intermediate_email():
4345
pass
4446

4547

46-
def mjml_to_intermediate_email(mjml_content: str) -> IntermediateEmail:
48+
def mjml_to_intermediate_email(
49+
mjml_content: str | MJMLTag,
50+
) -> IntermediateEmail:
4751
"""
4852
Convert MJML markup to an IntermediateEmail
4953
5054
Parameters
51-
------
55+
----------
5256
mjml_content
53-
MJML markup string
57+
MJML markup string or MJMLTag object
5458
5559
Returns
5660
------
5761
An Intermediate Email object
5862
5963
"""
60-
email_content = mjml2html(mjml_content)
64+
# Handle MJMLTag objects by preprocessing images
65+
if isinstance(mjml_content, MJMLTag):
66+
processed_mjml, inline_attachments = _process_mjml_images(mjml_content)
67+
mjml_markup = processed_mjml._to_mjml()
68+
else:
69+
# String-based MJML, no preprocessing needed
70+
warnings.warn("MJMLTag not detected; treating input as plaintext MJML markup", UserWarning)
71+
mjml_markup = mjml_content
72+
inline_attachments = {}
6173

62-
# Find all <img> tags and extract their src attributes
63-
pattern = r'<img[^>]+src="([^"\s]+)"[^>]*>'
64-
matches = re.findall(pattern, email_content)
65-
inline_attachments = {}
66-
for src in matches:
67-
# in theory, retrieve the externally hosted images and save to bytes
68-
# the user would need to pass CID-referenced images directly somehow,
69-
# as mjml doesn't handle them
70-
raise NotImplementedError("mj-image tags are not yet supported")
74+
email_content = mjml2html(mjml_markup)
7175

7276
i_email = IntermediateEmail(
7377
html=email_content,

emailer_lib/mjml/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
navbar_link,
4040
social_element,
4141
)
42+
# _process_mjml_images is called internally by mjml_to_intermediate_email
4243

4344
__all__ = (
4445
"MJMLTag",

emailer_lib/mjml/_core.py

Lines changed: 77 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,46 @@
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

66
from typing import Dict, Mapping, Optional, Sequence, Union
77
import warnings
8+
from io import BytesIO
89
from 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]
1315
TagAttrs = Union[Dict[str, TagAttrValue], "TagAttrDict"]
1416
TagChild = 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

3846
class 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

Comments
 (0)