From 3cc7b12449cd65f21b84017fcdbf4dcf6d09657f Mon Sep 17 00:00:00 2001 From: Jules <54960783+juleswg23@users.noreply.github.com> Date: Mon, 10 Nov 2025 16:07:18 -0500 Subject: [PATCH 01/15] first pass composing library nearly identical in an abstract sense to blastula, but content is wrapped in mjml tags instead of my own custom html template. --- Makefile | 4 +- nbmail/compose/__init__.py | 22 +++ nbmail/compose/blocks.py | 241 +++++++++++++++++++++++++ nbmail/compose/compose.py | 313 +++++++++++++++++++++++++++++++++ nbmail/compose/inline_utils.py | 285 ++++++++++++++++++++++++++++++ pyproject.toml | 1 + 6 files changed, 864 insertions(+), 2 deletions(-) create mode 100644 nbmail/compose/__init__.py create mode 100644 nbmail/compose/blocks.py create mode 100644 nbmail/compose/compose.py create mode 100644 nbmail/compose/inline_utils.py diff --git a/Makefile b/Makefile index b743416..f13bbf3 100644 --- a/Makefile +++ b/Makefile @@ -5,10 +5,10 @@ preview: cd docs && quarto preview test: - pytest nbmail/tests nbmail/mjml/tests --cov-report=xml + pytest nbmail/tests nbmail/mjml/tests nbmail/compose/tests --cov-report=xml test-update: - pytest nbmail/tests nbmail/mjml/tests --snapshot-update + pytest nbmail/tests nbmail/mjml/tests nbmail/compose/tests --snapshot-update generate-mjml-tags: python3 nbmail/mjml/scripts/generate_tags.py diff --git a/nbmail/compose/__init__.py b/nbmail/compose/__init__.py new file mode 100644 index 0000000..cf9ef8f --- /dev/null +++ b/nbmail/compose/__init__.py @@ -0,0 +1,22 @@ +from .compose import compose_email, create_blocks +from .blocks import block_text, block_title, block_spacer +from .inline_utils import ( + add_image, + add_plot, + md, + add_cta_button, + add_readable_time, +) + +__all__ = ( + "compose_email", + "create_blocks", + "block_text", + "block_title", + "block_spacer", + "add_image", + "add_plot", + "md", + "add_cta_button", + "add_readable_time", +) diff --git a/nbmail/compose/blocks.py b/nbmail/compose/blocks.py new file mode 100644 index 0000000..47a8c1e --- /dev/null +++ b/nbmail/compose/blocks.py @@ -0,0 +1,241 @@ +from typing import Union +from nbmail.mjml.tags import section, column, text as mjml_text, spacer as mjml_spacer +from nbmail.mjml._core import MJMLTag +from .inline_utils import _process_markdown +from typing import Literal + + +__all__ = [ + "Block", + "BlockList", + "block_text", + "block_title", + "block_spacer", +] + + +class Block: + """ + Represents a single block component in an email. + + Parameters + ---------- + mjml_tag + The underlying MJML tag + """ + + def __init__(self, mjml_tag: MJMLTag): + """ + Internal constructor. Users create blocks via `block_*()` functions. + + Parameters + ---------- + mjml_tag + The underlying MJML tag + """ + self._mjml_tag = mjml_tag + + def _to_mjml(self) -> MJMLTag: + """ + Internal method: retrieve the underlying MJML tag for processing. + + Returns + ------- + MJMLTag + The underlying MJML tag structure. + """ + return self._mjml_tag + + +class BlockList: + """ + Container for multiple block components. + + Parameters + ---------- + *args + One or more Block objects or strings (which will be converted to blocks). + + Examples + -------- + Users typically create BlockList via the `create_blocks()` function: + + ```python + from nbmail.compose import create_blocks, block_text, block_title + + content = create_blocks( + block_title("My Email"), + block_text("Hello world!") + ) + ``` + """ + + def __init__(self, *args: Union["Block", str]): + """ + Parameters + ---------- + *args + One or more `Block` objects or strings. + """ + self.items = list(args) + + def _to_mjml_list(self) -> list[MJMLTag]: + """ + Internal method: Convert all blocks to MJML tags. + + Used by `compose_email()`. + + Returns + ------- + list[MJMLTag] + A list of MJML tag structures. + """ + result = [] + for item in self.items: + if isinstance(item, Block): + result.append(item._to_mjml()) + elif isinstance(item, str): + html = _process_markdown(item) + + # Create a simple text block from the string + mjml_tree = section( + column(mjml_text(content=html)), + attributes={"padding": "0px"}, # TODO check what happens if we remove this + ) + result.append(mjml_tree) + return result + + def __repr__(self) -> str: + return f"" + + +def block_text( + text: str, align: Literal["left", "center", "right", "justify"] = "left" +) -> Block: + """ + Create a block of text (supports Markdown). + + Parameters + ---------- + text + Plain text or Markdown. Markdown will be converted to HTML. + + align + Text alignment. Default is `"left"`. + + Returns + ------- + Block + A block containing the formatted text. + + Examples + -------- + ```python + from nbmail.compose import block_text + + # Simple text + block = block_text("Hello world") + + # Markdown text + block = block_text("This is **bold** and this is *italic*") + + # Centered text + block = block_text("Centered content", align="center") + ``` + """ + html = _process_markdown(text) + + mjml_tree = section( + column( + mjml_text(content=html, attributes={"align": align}), + ), + attributes={"padding": "0px"}, + ) + + return Block(mjml_tree) + + +def block_title( + title: str, align: Literal["left", "center", "right", "justify"] = "center" +) -> Block: + """ + Create a block of large, emphasized text for headings. + + Parameters + ---------- + title + The title text. Markdown will be converted to HTML. + align + Text alignment. Default is "center". + + Returns + ------- + Block + A block containing the formatted title. + + Examples + -------- + ```python + from nbmail.compose import block_title + + # Simple title + title = block_title("My Newsletter") + + # Centered title (default) + title = block_title("Welcome!", align="center") + ``` + """ + + html = _process_markdown(title) + html_wrapped = ( + f'

{html}

' + ) + + mjml_tree = section( + column( + mjml_text( + content=html_wrapped, + attributes={"align": align}, + ) + ), + attributes={"padding": "0px"}, + ) + + return Block(mjml_tree) + + +def block_spacer(height: str = "20px") -> Block: + """ + Insert vertical spacing. + + Parameters + ---------- + height + The height of the spacer. Can be any valid CSS height value (e.g., "20px", "2em"). + Default is "20px". + + Returns + ------- + Block + A block containing the spacer. + + Examples + -------- + ```python + from nbmail.compose import block_spacer, create_blocks, block_text + + email_body = create_blocks( + block_text("First section"), + block_spacer("30px"), + block_text("Second section"), + ) + ``` + """ + mjml_tree = section( + column( + mjml_spacer(attributes={"height": height}), + ), + attributes={"padding": "0px"}, + ) + + return Block(mjml_tree) diff --git a/nbmail/compose/compose.py b/nbmail/compose/compose.py new file mode 100644 index 0000000..3b860f8 --- /dev/null +++ b/nbmail/compose/compose.py @@ -0,0 +1,313 @@ +from typing import Optional, Union + +from nbmail.ingress import mjml_to_email +from nbmail.structs import Email +from nbmail.mjml.tags import ( + mjml, + body as mj_body, # to not confuse with body arg + head, + mj_attributes, + mj_all, + section, + column, +) +from nbmail.mjml._core import MJMLTag +from .blocks import Block, BlockList, block_title + +__all__ = [ + "compose_email", + "create_blocks", +] + + +def create_blocks(*args: Union[Block, str]) -> BlockList: + """ + Group block components for use in compose_email(). + + Collects multiple `block_*()` function calls into a renderable structure. + + Parameters + ---------- + *args + One or more block_*() calls or strings. + + Returns + ------- + BlockList + Container for blocks, renderable to email content. + + Examples + -------- + ```python + from nbmail.compose import create_blocks, block_text, block_title, block_spacer + + content = create_blocks( + block_title("Welcome!"), + block_text("This is the main content."), + block_spacer("20px"), + block_text("Thanks for reading!") + ) + ``` + """ + return BlockList(*args) + + +def compose_email( + body: Optional[Union[str, Block, BlockList]] = None, + header: Optional[Union[str, Block, BlockList]] = None, + footer: Optional[Union[str, Block, BlockList]] = None, + title: Optional[str] = None, + template: str = "blastula", + **kwargs, +) -> Email: + """ + Compose an email message using simple building blocks or Markdown. + + This is the primary entry point for creating emails in nbmail. It accepts + optional header, body, and footer sections, processes them into MJML, + and returns an `Email` object ready for preview or sending. + + Parameters + ---------- + body + Main email content. Can be a Markdown string, single Block, or `blocks()` result. + + header + Optional header section (appears at top). + + footer + Optional footer section (appears at bottom). + + title + Large title/header text to display at the top of the email. + If provided, this creates a `block_title()` in the header section. + Note: This is NOT the email subject line; use email metadata for that. + + template + Email template style. Default is "blastula", which wraps content in a grey + border container (similar to Blastula's `html_email_template_1`). + Use `"none"` for no template wrapper. + + **kwargs + Additional template options (reserved for future use). + + Returns + ------- + Email + Converted Email object ready for preview or sending. + + Examples + -------- + Simple email with single block: + + ```python + from nbmail.compose import compose_email, block_text + + email = compose_email( + body=block_text("This is a simple email.") + ) + ``` + + Email with title/header and multiple blocks: + + ```python + from nbmail.compose import compose_email, create_blocks, block_title, block_text + + email = compose_email( + title="Welcome!", # Creates a large title block at top + body=create_blocks( + block_text("Welcome to this week's update!"), + block_text("Here's what's new...") + ) + ) + ``` + + Email with header section and body: + + ```python + from nbmail.compose import compose_email, create_blocks, block_title, block_text + + email = compose_email( + header=create_blocks(block_title("Newsletter")), + body=create_blocks( + block_text("Welcome to this week's update!"), + block_text("Here's what's new...") + ), + footer=create_blocks(block_text("© 2025 My Company")) + ) + ``` + + Email with embedded images: + + ```python + from nbmail.compose import compose_email, add_image, block_text, md + + img_html = add_image("path/to/image.png", alt="Product image", width="500px") + email = compose_email( + title="Product Feature", + body=block_text(md(f"Check this out:\\n\\n{img_html}")) + ) + ``` + """ + # Convert sections (header, body, footer) to MJML lists + header_mjml_list = _section_to_mjml_list(header) + body_mjml_list = _section_to_mjml_list(body) + footer_mjml_list = _section_to_mjml_list(footer) + + # If title is provided, prepend it to header + if title: + title_block_mjml = block_title(title)._to_mjml() + header_mjml_list = [title_block_mjml] + header_mjml_list + + # Apply template wrapper if requested + if template == "blastula": + all_sections = _apply_blastula_template( + header_mjml_list, body_mjml_list, footer_mjml_list + ) + elif template == "none": + # Combine all sections without template + all_sections = header_mjml_list + body_mjml_list + footer_mjml_list + else: + raise ValueError(f"Unknown template: {template}. Use 'blastula' or 'none'.") + + # Build full MJML email structure with head containing spacing defaults (padding: 0px) + email_structure = mjml( + head( + mj_attributes( + mj_all(attributes={"padding": "0px"}), + section(attributes={"padding": "0px"}), + ), + ), + mj_body( + *all_sections, + attributes={"width": "600px"}, # TODO check what happens if we remove this + ), + ) + + email_obj = mjml_to_email(email_structure) + + return email_obj + + +def _section_to_mjml_list(section: Optional[Union[str, Block, BlockList]]): + """ + Convert a section (string, Block, BlockList, or None) to a list of MJML tags. + + Internal helper for `compose_email()`. + + Parameters + ---------- + section + The section content to convert. + + Returns + ------- + list[MJMLTag] + A list of MJML tag structures (empty if section is None). + """ + if section is None: + return [] + + elif isinstance(section, Block): + # Auto-wrap single Block in BlockList + return [section._to_mjml()] + + elif isinstance(section, BlockList): + return section._to_mjml_list() + + elif isinstance(section, str): + # Convert string to BlockList and then to MJML + block_list = BlockList(section) + return block_list._to_mjml_list() + + else: + raise TypeError( + f"Expected str, Block, BlockList, or None, got {type(section).__name__}" + ) + + +def _apply_blastula_template( + header_sections: list, body_sections: list, footer_sections: list +): + """ + Apply Blastula-style template with grey border around body content. + + This creates a three-part layout:\n + - Header sections with grey background at top + - Body sections wrapped in a white box with grey border/padding around it + - Footer sections with grey background at bottom + + All three parts together form a unified visual container with the body + content highlighted in white against the grey border. + + Parameters + ---------- + header_sections + MJML sections for the header (styled with grey background). + + body_sections + MJML sections for the main content (wrapped in white box with grey border). + + footer_sections + MJML sections for the footer (styled with grey background). + + Returns + ------- + list[MJMLTag] + Styled sections: header + wrapped_body + footer. + """ + # Apply grey background to header sections + grey_attrs = { + "background-color": "#f6f6f6", + "padding-right": "16px", + "padding-left": "16px", + } + styled_header = [ + _apply_section_attributes(section, grey_attrs) for section in header_sections + ] + + # Wrap body sections in a white box with grey border/padding + body_wrapper = section( + column( + *body_sections, + attributes={ + "background-color": "white", + "padding": "0px", + }, + ), + attributes={ + "background-color": "#f6f6f6", # Grey background creates the "border" effect + "padding": "16px", + }, + ) + + # Apply grey background to footer sections + styled_footer = [ + _apply_section_attributes(section, grey_attrs) for section in footer_sections + ] + + return styled_header + [body_wrapper] + styled_footer + + +def _apply_section_attributes(section: MJMLTag, attributes: dict) -> MJMLTag: + """ + Apply or merge attributes to a section tag. + + Internal helper for `_apply_blastula_template()`. + + Parameters + ---------- + section + The section tag to modify. + + attributes + Attributes to apply or merge. + + Returns + ------- + MJMLTag + A new section tag with merged attributes. + """ + merged_attrs = {**(section.attrs or {}), **attributes} + section.attrs = merged_attrs + return section diff --git a/nbmail/compose/inline_utils.py b/nbmail/compose/inline_utils.py new file mode 100644 index 0000000..ea93326 --- /dev/null +++ b/nbmail/compose/inline_utils.py @@ -0,0 +1,285 @@ +# import base64 +# from datetime import datetime +# from pathlib import Path +from typing import Optional + +__all__ = [ + "md", + "add_image", + "add_plot", + "add_cta_button", + "add_readable_time", +] + + +def _process_markdown(content: Optional[str]) -> Optional[str]: + """ + Convert Markdown text to HTML (internal utility). + + Used internally by block functions. For public use, call `md()` instead. + + Parameters + ---------- + content + Markdown text to convert. If None, returns None. + + Returns + ------- + str or None + HTML representation of the Markdown, or None if content is None. + """ + if content is None: + return None + + try: + import markdown + except ImportError: + raise ImportError( + "The 'markdown' package is required for Markdown processing. " + "Install it with: pip install markdown" + ) + + html = markdown.markdown(content, extensions=["extra", "codehilite", "toc"]) + return html + + +def md(text: str) -> str: + """ + Process Markdown text to HTML. + + Public utility function for converting Markdown strings to HTML. + Can include images created via `add_image()` or `add_plot()`. + + Parameters + ---------- + text + Markdown text to convert. + + Returns + ------- + str + HTML representation of the Markdown. + + Examples + -------- + ```python + from nbmail.compose import md, block_text + + # Simple markdown + html = md("This is **bold** and this is *italic*") + + # With embedded image + img_html = add_image("path/to/image.png") + html = md(f"Check this out!\\n\\n{img_html}") + + # Use in a block + email = compose_email(body=block_text(html)) + ``` + """ + return _process_markdown(text) + + +def add_image( + src: str, + alt: str = "", + width: str = "520px", + align: str = "center", +) -> str: + """ + Create HTML img tag for embedding images. + + Parameters + ---------- + src + URL or path to image file. + + alt + Alt text for accessibility. Default is empty string. + + width + Image width (e.g., `"520px"`) + + align + Image alignment. + + Returns + ------- + str + HTML img tag that can be embedded in Markdown or passed to `block_text()`. + + Examples + -------- + ```python + from nbmail.compose import add_image, block_text, compose_email + + # From URL + img_html = add_image("https://example.com/image.png", alt="Example image") + + # From local file + img_html = add_image("path/to/image.png", alt="My image", width="600px") + + # Use in email + email = compose_email( + body=block_text(f"Check this out:\\n{img_html}") + ) + ``` + """ + # Determine alignment style + align_style = "" + if align == "center": + align_style = "display: block; margin: 0 auto;" + elif align == "left": + align_style = "display: block; margin: 0;" + elif align == "right": + align_style = "float: right;" + # "inline" has no special style + + # Create img tag + img_tag = ( + f'{alt}' + ) + + return img_tag + + +def add_plot( + fig, + alt: str = "", + width: str = "520px", +) -> str: + """ + Convert a plot figure to embedded HTML img tag. + + Parameters + ---------- + fig + Plot figure object. Supports matplotlib.figure.Figure and plotly.graph_objects.Figure. + + alt + Alt text for accessibility. Default is empty string. + + width + Image width (e.g., "520px"). Default is "520px". + + Returns + ------- + str + HTML img tag with base64-encoded plot that can be embedded in emails. + + Examples + -------- + ```python + from nbmail.compose import add_plot, block_text, compose_email + import matplotlib.pyplot as plt + + fig, ax = plt.subplots() + ax.plot([1, 2, 3, 4], [1, 4, 2, 3]) + + plot_html = add_plot(fig, alt="Sales trends", width="600px") + + email = compose_email( + body=block_text(f"Here's the trend:\\n{plot_html}") + ) + ``` + """ + return NotImplementedError("Coming soon.") + + +def add_cta_button( + label: str, + url: str, + bg_color: str = "#007bff", + text_color: str = "#ffffff", +) -> str: + """ + Create a call-to-action button. + + Parameters + ---------- + label + Button text. + + url + Target URL. + + bg_color + Button background color (hex). Default is `"#007bff"`. + + text_color + Button text color (hex). Default is `"#ffffff"`. + + Returns + ------- + str + HTML button element that can be embedded in emails. + + Examples + -------- + ```python + from nbmail.compose import add_cta_button, block_text, compose_email + + button_html = add_cta_button("Learn More", "https://example.com") + + email = compose_email( + body=block_text(f"Ready?\\n\\n{button_html}") + ) + ``` + """ + button_html = ( + f'{label}' + ) + return button_html + + +def add_readable_time( + dt, + format_str: str = "%B %d, %Y", +) -> str: + """ + Format a datetime as readable text. + + Parameters + ---------- + dt + Datetime object to format. + + format_str + Python strftime format string. Default is "%B %d, %Y" (e.g., "November 10, 2025"). + + Returns + ------- + str + Formatted date/time string. + + Examples + -------- + ```python + from datetime import datetime + from nbmail.compose import add_readable_time, block_text, compose_email + + time_str = add_readable_time(datetime.now()) + # Output: "November 10, 2025" + + # Custom format + time_str = add_readable_time(datetime.now(), format_str="%A, %B %d") + # Output: "Sunday, November 10" + + email = compose_email( + body=block_text(f"Report generated: {time_str}") + ) + ``` + """ + raise NotImplementedError("Coming soon.") + + # if not isinstance(dt, datetime): + # raise TypeError(f"Expected datetime object, got {type(dt).__name__}") + + # return dt.strftime(format_str) diff --git a/pyproject.toml b/pyproject.toml index 096c7c9..d068def 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ requires-python = ">=3.9" dependencies = [ "dotenv", "mjml-python>=1.3.6", + "markdown" ] [project.optional-dependencies] From 59bc83b55dedda1a65e161d1c31bcce24f5023fb Mon Sep 17 00:00:00 2001 From: Jules <54960783+juleswg23@users.noreply.github.com> Date: Mon, 10 Nov 2025 16:41:04 -0500 Subject: [PATCH 02/15] add some of the tests more to come --- nbmail/compose/tests/test_compose.py | 202 +++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 nbmail/compose/tests/test_compose.py diff --git a/nbmail/compose/tests/test_compose.py b/nbmail/compose/tests/test_compose.py new file mode 100644 index 0000000..bd5c92e --- /dev/null +++ b/nbmail/compose/tests/test_compose.py @@ -0,0 +1,202 @@ +import pytest + +from nbmail.compose import ( + compose_email, + block_text, + block_title, + block_spacer, + create_blocks, +) +from nbmail.structs import Email + + +def test_block_text_simple(): + block = block_text("Hello world") + email = compose_email(body=block) + + assert "Hello world" in email.html + + +@pytest.mark.parametrize("align", ["left", "center", "right", "justify"]) +def test_block_text_with_alignment(align): + block = block_text("Test block", align=align) + email = compose_email(body=block) + + assert "Test block" in email.html + assert f"text-align:{align}" in email.html + + +def test_block_text_with_markdown(): + block = block_text("This is **bold** and *italic*") + email = compose_email(body=block) + + assert "bold" in email.html + assert "italic" in email.html + + +def test_block_title_simple(): + block = block_title("My Title") + email = compose_email(body=block) + + assert "My Title" in email.html + assert "text-align:center;color:#000000;" in email.html + # assert False + + +def test_block_title_with_markdown(): + block = block_title("**Important** Title") + email = compose_email(body=block) + + assert "Important" in email.html + assert "Title" in email.html + + +def test_block_spacer_default(): + email = compose_email( + body=create_blocks( + block_text("Before"), + block_spacer(), + block_text("After"), + ) + ) + + assert "Before" in email.html + assert '
' in email.html + assert "After" in email.html + + +def test_block_spacer_custom_height(): + for height in ["50px", "2em"]: + email = compose_email( + body=create_blocks( + block_text("Before"), + block_spacer(height), + block_text("After"), + ) + ) + + assert "Before" in email.html + assert f'
' in email.html + assert "Content here..." in email.html + assert "© 2025 Company" in email.html + assert "Weekly Update" in email.html + + +def test_compose_email_invalid_template(): + try: + compose_email( + body=block_text("Content"), + template="invalid_template", + ) + assert False, "Should have raised ValueError" + except ValueError as e: + assert "Unknown template" in str(e) + assert "blastula" in str(e) or "none" in str(e) + + +def test_blocks_preserve_order_in_html(): + email = compose_email( + body=create_blocks( + block_title("Report"), + block_spacer("20px"), + block_text("Here's the data:"), + block_spacer("10px"), + block_text("- Item 1\n- Item 2"), + ) + ) + + # Check relative positioning + html = email.html + report_idx = html.index("Report") + data_idx = html.index("Here's the data:") + item1_idx = html.index("Item 1") + + assert report_idx < data_idx < item1_idx, ( + "Blocks should appear in order: Report → Data → Items" + ) From 184efdcad58f5b54411755f7d65719f89402962d Mon Sep 17 00:00:00 2001 From: Jules <54960783+juleswg23@users.noreply.github.com> Date: Mon, 10 Nov 2025 16:52:08 -0500 Subject: [PATCH 03/15] add futher tests --- nbmail/compose/tests/test_compose.py | 13 +- nbmail/compose/tests/test_inline_utils.py | 104 ++++++++++++++ pyproject.toml | 3 +- uv.lock | 159 +++++++++++++--------- 4 files changed, 214 insertions(+), 65 deletions(-) create mode 100644 nbmail/compose/tests/test_inline_utils.py diff --git a/nbmail/compose/tests/test_compose.py b/nbmail/compose/tests/test_compose.py index bd5c92e..d9da0d2 100644 --- a/nbmail/compose/tests/test_compose.py +++ b/nbmail/compose/tests/test_compose.py @@ -86,7 +86,7 @@ def test_create_blocks_with_multiple_blocks(): block_text("Content"), block_spacer("20px"), ) - + assert block_list is not None assert len(block_list.items) == 3 @@ -200,3 +200,14 @@ def test_blocks_preserve_order_in_html(): assert report_idx < data_idx < item1_idx, ( "Blocks should appear in order: Report → Data → Items" ) + + +def test_blocklist_repr(): + block_list = create_blocks( + block_text("First"), + block_text("Second"), + block_text("Third"), + ) + + repr_str = repr(block_list) + assert "" in repr_str diff --git a/nbmail/compose/tests/test_inline_utils.py b/nbmail/compose/tests/test_inline_utils.py new file mode 100644 index 0000000..0a4fd3d --- /dev/null +++ b/nbmail/compose/tests/test_inline_utils.py @@ -0,0 +1,104 @@ +"""Tests for compose module utility functions.""" + +from datetime import datetime + +import pytest + +from nbmail.compose import ( + md, + add_image, + add_cta_button, + add_readable_time, +) + + +def test_md_simple_text(): + result = md("Hello world") + + assert result == "

Hello world

" + + +def test_md_bold(): + result = md("**bold**") + assert "

bold

" == result + + +def test_md_italic(): + result = md("*italic*") + assert "

italic

" == result + + +def test_md_list(): + result = md("- Item 1\n- Item 2") + + assert "
  • Item 1
  • " in result + assert "
  • Item 2
  • " in result + + +def test_md_heading(): + result = md("# Heading 1") + + assert "Heading 1" in result + + +def test_add_image_url(): + html = add_image("https://example.com/image.png", alt="Test image") + + assert "Click Me" in html + + +def test_add_cta_button_colors(): + html = add_cta_button( + "Button", "https://example.com", bg_color="#FF0000", text_color="#FFFFFF" + ) + + assert "#FF0000" in html + assert "#FFFFFF" in html + +@pytest.mark.xfail +def test_add_readable_time_default_format(): + dt = datetime(2025, 11, 10) + result = add_readable_time(dt) + + assert "November" in result + assert "2025" in result + + +@pytest.mark.xfail +def test_add_readable_time_custom_format(): + dt = datetime(2025, 11, 10, 14, 30, 0) + result = add_readable_time(dt, format_str="%Y-%m-%d") + assert result == "2025-11-10" diff --git a/pyproject.toml b/pyproject.toml index d068def..691ec87 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,5 +58,6 @@ exclude_also = [ ] include = ["nbmail/*"] omit = [ - "nbmail/tests/*" + "nbmail/tests/*", + "nbmail/**/tests/*", ] diff --git a/uv.lock b/uv.lock index 14764be..ae36b07 100644 --- a/uv.lock +++ b/uv.lock @@ -1041,69 +1041,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892, upload-time = "2025-02-19T22:15:01.647Z" }, ] -[[package]] -name = "nbmail" -version = "0.0.1" -source = { editable = "." } -dependencies = [ - { name = "dotenv" }, - { name = "mjml-python" }, -] - -[package.optional-dependencies] -dev = [ - { name = "aiosmtpd" }, - { name = "griffe" }, - { name = "pytest" }, - { name = "pytest-cov" }, - { name = "quartodoc" }, - { name = "syrupy", version = "4.9.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "syrupy", version = "5.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, -] -docs = [ - { name = "css-inline" }, - { name = "great-tables" }, - { name = "ipykernel" }, - { name = "jupyter" }, - { name = "nbclient" }, - { name = "nbformat" }, - { name = "pandas" }, - { name = "plotnine", version = "0.13.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "plotnine", version = "0.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "polars" }, - { name = "pyarrow" }, - { name = "redmail" }, -] -mailgun = [ - { name = "mailgun", version = "1.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "mailgun", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, -] - -[package.metadata] -requires-dist = [ - { name = "aiosmtpd", marker = "extra == 'dev'" }, - { name = "css-inline", marker = "extra == 'docs'", specifier = ">=0.17.0" }, - { name = "dotenv" }, - { name = "great-tables", marker = "extra == 'docs'", specifier = ">=0.18.0" }, - { name = "griffe", marker = "extra == 'dev'" }, - { name = "ipykernel", marker = "extra == 'docs'", specifier = ">=6.29.5" }, - { name = "jupyter", marker = "extra == 'docs'" }, - { name = "mailgun", marker = "extra == 'mailgun'" }, - { name = "mjml-python", specifier = ">=1.3.6" }, - { name = "nbclient", marker = "extra == 'docs'" }, - { name = "nbformat", marker = "extra == 'docs'" }, - { name = "pandas", marker = "extra == 'docs'", specifier = ">=2.3.3" }, - { name = "plotnine", marker = "extra == 'docs'", specifier = ">=0.13.6" }, - { name = "polars", marker = "extra == 'docs'", specifier = ">=1.34.0" }, - { name = "pyarrow", marker = "extra == 'docs'", specifier = ">=21.0.0" }, - { name = "pytest", marker = "extra == 'dev'" }, - { name = "pytest-cov", marker = "extra == 'dev'" }, - { name = "quartodoc", marker = "extra == 'dev'" }, - { name = "redmail", marker = "extra == 'docs'", specifier = ">=0.6.0" }, - { name = "syrupy", marker = "extra == 'dev'" }, -] -provides-extras = ["dev", "mailgun", "docs"] - [[package]] name = "exceptiongroup" version = "1.3.0" @@ -2038,6 +1975,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f3/e9/4eac212df9a4ded027c2d5ad21492bf812294a0c8c0226381f076dae6061/mailgun-1.2.0-py3-none-any.whl", hash = "sha256:0b947760d36e24565aa648c1a8d4ac883cd553984100205fc14b383173ffc1bd", size = 51118, upload-time = "2025-10-02T20:13:58.84Z" }, ] +[[package]] +name = "markdown" +version = "3.9" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8d/37/02347f6d6d8279247a5837082ebc26fc0d5aaeaf75aa013fcbb433c777ab/markdown-3.9.tar.gz", hash = "sha256:d2900fe1782bd33bdbbd56859defef70c2e78fc46668f8eb9df3128138f2cb6a", size = 364585, upload-time = "2025-09-04T20:25:22.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/ae/44c4a6a4cbb496d93c6257954260fe3a6e91b7bed2240e5dad2a717f5111/markdown-3.9-py3-none-any.whl", hash = "sha256:9f4d91ed810864ea88a6f32c07ba8bee1346c0cc1f6b1f9f6c822f2a9667d280", size = 107441, upload-time = "2025-09-04T20:25:21.784Z" }, +] + +[[package]] +name = "markdown" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/7dd27d9d863b3376fcf23a5a13cb5d024aed1db46f963f1b5735ae43b3be/markdown-3.10.tar.gz", hash = "sha256:37062d4f2aa4b2b6b32aefb80faa300f82cc790cb949a35b8caede34f2b68c0e", size = 364931, upload-time = "2025-11-03T19:51:15.007Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/81/54e3ce63502cd085a0c556652a4e1b919c45a446bd1e5300e10c44c8c521/markdown-3.10-py3-none-any.whl", hash = "sha256:b5b99d6951e2e4948d939255596523444c0e677c669700b1d17aa4a8a464cb7c", size = 107678, upload-time = "2025-11-03T19:51:13.887Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -2443,6 +2410,72 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b", size = 78454, upload-time = "2024-04-04T11:20:34.895Z" }, ] +[[package]] +name = "nbmail" +version = "0.0.1" +source = { editable = "." } +dependencies = [ + { name = "dotenv" }, + { name = "markdown", version = "3.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "markdown", version = "3.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "mjml-python" }, +] + +[package.optional-dependencies] +dev = [ + { name = "aiosmtpd" }, + { name = "griffe" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "quartodoc" }, + { name = "syrupy", version = "4.9.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "syrupy", version = "5.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +docs = [ + { name = "css-inline" }, + { name = "great-tables" }, + { name = "ipykernel" }, + { name = "jupyter" }, + { name = "nbclient" }, + { name = "nbformat" }, + { name = "pandas" }, + { name = "plotnine", version = "0.13.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "plotnine", version = "0.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "polars" }, + { name = "pyarrow" }, + { name = "redmail" }, +] +mailgun = [ + { name = "mailgun", version = "1.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "mailgun", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiosmtpd", marker = "extra == 'dev'" }, + { name = "css-inline", marker = "extra == 'docs'", specifier = ">=0.17.0" }, + { name = "dotenv" }, + { name = "great-tables", marker = "extra == 'docs'", specifier = ">=0.18.0" }, + { name = "griffe", marker = "extra == 'dev'" }, + { name = "ipykernel", marker = "extra == 'docs'", specifier = ">=6.29.5" }, + { name = "jupyter", marker = "extra == 'docs'" }, + { name = "mailgun", marker = "extra == 'mailgun'" }, + { name = "markdown" }, + { name = "mjml-python", specifier = ">=1.3.6" }, + { name = "nbclient", marker = "extra == 'docs'" }, + { name = "nbformat", marker = "extra == 'docs'" }, + { name = "pandas", marker = "extra == 'docs'", specifier = ">=2.3.3" }, + { name = "plotnine", marker = "extra == 'docs'", specifier = ">=0.13.6" }, + { name = "polars", marker = "extra == 'docs'", specifier = ">=1.34.0" }, + { name = "pyarrow", marker = "extra == 'docs'", specifier = ">=21.0.0" }, + { name = "pytest", marker = "extra == 'dev'" }, + { name = "pytest-cov", marker = "extra == 'dev'" }, + { name = "quartodoc", marker = "extra == 'dev'" }, + { name = "redmail", marker = "extra == 'docs'", specifier = ">=0.6.0" }, + { name = "syrupy", marker = "extra == 'dev'" }, +] +provides-extras = ["dev", "mailgun", "docs"] + [[package]] name = "nest-asyncio" version = "1.6.0" From afb6b64552d10a8509248b43ce2863b1b576b902 Mon Sep 17 00:00:00 2001 From: Jules <54960783+juleswg23@users.noreply.github.com> Date: Wed, 12 Nov 2025 12:10:31 -0500 Subject: [PATCH 04/15] format blocks and compose --- nbmail/compose/blocks.py | 33 ++++++------ nbmail/compose/compose.py | 102 ++++++++++++++++++-------------------- 2 files changed, 67 insertions(+), 68 deletions(-) diff --git a/nbmail/compose/blocks.py b/nbmail/compose/blocks.py index 47a8c1e..45874ad 100644 --- a/nbmail/compose/blocks.py +++ b/nbmail/compose/blocks.py @@ -21,7 +21,7 @@ class Block: Parameters ---------- mjml_tag - The underlying MJML tag + The underlying MJML section tag """ def __init__(self, mjml_tag: MJMLTag): @@ -49,7 +49,7 @@ def _to_mjml(self) -> MJMLTag: class BlockList: """ - Container for multiple block components. + Container for multiple block components. A block is equivalent to an mj-section. Parameters ---------- @@ -77,7 +77,7 @@ def __init__(self, *args: Union["Block", str]): *args One or more `Block` objects or strings. """ - self.items = list(args) + self.sections = list(args) def _to_mjml_list(self) -> list[MJMLTag]: """ @@ -88,25 +88,27 @@ def _to_mjml_list(self) -> list[MJMLTag]: Returns ------- list[MJMLTag] - A list of MJML tag structures. + A list of MJML sections. """ result = [] - for item in self.items: + for item in self.sections: if isinstance(item, Block): result.append(item._to_mjml()) elif isinstance(item, str): html = _process_markdown(item) # Create a simple text block from the string - mjml_tree = section( - column(mjml_text(content=html)), + mjml_section = section( + column(mjml_text(content=html)), # or mj-raw? attributes={"padding": "0px"}, # TODO check what happens if we remove this ) - result.append(mjml_tree) + + result.append(mjml_section) return result + # TODO improve repr to display actual content def __repr__(self) -> str: - return f"" + return f"" def block_text( @@ -145,14 +147,14 @@ def block_text( """ html = _process_markdown(text) - mjml_tree = section( + mjml_section = section( column( mjml_text(content=html, attributes={"align": align}), ), attributes={"padding": "0px"}, ) - return Block(mjml_tree) + return Block(mjml_section) def block_title( @@ -191,7 +193,8 @@ def block_title( f'

    {html}

    ' ) - mjml_tree = section( + + mjml_section = section( column( mjml_text( content=html_wrapped, @@ -201,7 +204,7 @@ def block_title( attributes={"padding": "0px"}, ) - return Block(mjml_tree) + return Block(mjml_section) def block_spacer(height: str = "20px") -> Block: @@ -231,11 +234,11 @@ def block_spacer(height: str = "20px") -> Block: ) ``` """ - mjml_tree = section( + mjml_section = section( column( mjml_spacer(attributes={"height": height}), ), attributes={"padding": "0px"}, ) - return Block(mjml_tree) + return Block(mjml_section) diff --git a/nbmail/compose/compose.py b/nbmail/compose/compose.py index 3b860f8..e0ff4c6 100644 --- a/nbmail/compose/compose.py +++ b/nbmail/compose/compose.py @@ -1,4 +1,4 @@ -from typing import Optional, Union +from typing import Literal, Optional, Union from nbmail.ingress import mjml_to_email from nbmail.structs import Email @@ -10,9 +10,10 @@ mj_all, section, column, + wrapper, ) from nbmail.mjml._core import MJMLTag -from .blocks import Block, BlockList, block_title +from .blocks import Block, BlockList, block_text, block_title __all__ = [ "compose_email", @@ -150,9 +151,9 @@ def compose_email( ``` """ # Convert sections (header, body, footer) to MJML lists - header_mjml_list = _section_to_mjml_list(header) - body_mjml_list = _section_to_mjml_list(body) - footer_mjml_list = _section_to_mjml_list(footer) + header_mjml_list = _component_to_mjml_section(header, component_type="header") + body_mjml_list = _component_to_mjml_section(body, component_type="body") + footer_mjml_list = _component_to_mjml_section(footer, component_type="footer") # If title is provided, prepend it to header if title: @@ -174,8 +175,13 @@ def compose_email( email_structure = mjml( head( mj_attributes( - mj_all(attributes={"padding": "0px"}), - section(attributes={"padding": "0px"}), + # section(attributes={"padding": "55px"}), + mj_all( + attributes={ + "padding": "0px 6px", + "font-family": "Helvetica, sans-serif", + } + ), ), ), mj_body( @@ -184,40 +190,45 @@ def compose_email( ), ) + print(email_structure._to_mjml()) + email_obj = mjml_to_email(email_structure) return email_obj -def _section_to_mjml_list(section: Optional[Union[str, Block, BlockList]]): +def _component_to_mjml_section( + component: Optional[Union[str, Block, BlockList]], + component_type: Literal["body", "header", "footer"], +) -> list[MJMLTag]: """ - Convert a section (string, Block, BlockList, or None) to a list of MJML tags. + Convert a component (string, Block, BlockList, or None) to a list of MJML tags. Internal helper for `compose_email()`. Parameters ---------- - section - The section content to convert. + component + The component content to convert. Returns ------- list[MJMLTag] - A list of MJML tag structures (empty if section is None). + A list of MJML sections (empty if component is None). """ - if section is None: + if component is None: return [] - elif isinstance(section, Block): + elif isinstance(component, Block): # Auto-wrap single Block in BlockList - return [section._to_mjml()] + return [component._to_mjml()] - elif isinstance(section, BlockList): - return section._to_mjml_list() + elif isinstance(component, BlockList): + return component._to_mjml_list() - elif isinstance(section, str): + elif isinstance(component, str): # Convert string to BlockList and then to MJML - block_list = BlockList(section) + block_list = BlockList(component) return block_list._to_mjml_list() else: @@ -226,31 +237,13 @@ def _section_to_mjml_list(section: Optional[Union[str, Block, BlockList]]): ) + def _apply_blastula_template( header_sections: list, body_sections: list, footer_sections: list -): +) -> list[MJMLTag]: """ Apply Blastula-style template with grey border around body content. - This creates a three-part layout:\n - - Header sections with grey background at top - - Body sections wrapped in a white box with grey border/padding around it - - Footer sections with grey background at bottom - - All three parts together form a unified visual container with the body - content highlighted in white against the grey border. - - Parameters - ---------- - header_sections - MJML sections for the header (styled with grey background). - - body_sections - MJML sections for the main content (wrapped in white box with grey border). - - footer_sections - MJML sections for the footer (styled with grey background). - Returns ------- list[MJMLTag] @@ -262,29 +255,32 @@ def _apply_blastula_template( "padding-right": "16px", "padding-left": "16px", } + styled_header = [ _apply_section_attributes(section, grey_attrs) for section in header_sections ] - # Wrap body sections in a white box with grey border/padding - body_wrapper = section( - column( - *body_sections, - attributes={ - "background-color": "white", - "padding": "0px", - }, - ), + styled_footer = [ + _apply_section_attributes(section, grey_attrs) for section in footer_sections + ] + + body_attrs = { + "background-color": "white", + "padding": "0px", + } + + styled_body = [ + _apply_section_attributes(section, body_attrs) for section in body_sections + ] + + body_wrapper = wrapper( + *styled_body, attributes={ - "background-color": "#f6f6f6", # Grey background creates the "border" effect + "background-color": "#f6f6f6", "padding": "16px", }, ) - # Apply grey background to footer sections - styled_footer = [ - _apply_section_attributes(section, grey_attrs) for section in footer_sections - ] return styled_header + [body_wrapper] + styled_footer From 1677abd786c1e835e6b50dd9f7b028ccade99e42 Mon Sep 17 00:00:00 2001 From: Jules <54960783+juleswg23@users.noreply.github.com> Date: Wed, 12 Nov 2025 12:18:40 -0500 Subject: [PATCH 05/15] update tests and snap tests --- nbmail/compose/tests/test_compose.py | 8 ++++---- nbmail/tests/__snapshots__/test_structs.ambr | 11 +++-------- nbmail/tests/test_structs.py | 8 +++----- 3 files changed, 10 insertions(+), 17 deletions(-) diff --git a/nbmail/compose/tests/test_compose.py b/nbmail/compose/tests/test_compose.py index d9da0d2..07a0579 100644 --- a/nbmail/compose/tests/test_compose.py +++ b/nbmail/compose/tests/test_compose.py @@ -88,14 +88,14 @@ def test_create_blocks_with_multiple_blocks(): ) assert block_list is not None - assert len(block_list.items) == 3 + assert len(block_list.sections) == 3 def test_create_blocks_with_strings(): block_list = create_blocks("Hello", "World") assert block_list is not None - assert len(block_list.items) == 2 + assert len(block_list.sections) == 2 def test_create_blocks_mixed_content(): @@ -106,7 +106,7 @@ def test_create_blocks_mixed_content(): ) assert block_list is not None - assert len(block_list.items) == 3 + assert len(block_list.sections) == 3 def test_compose_email_with_body_string(): @@ -210,4 +210,4 @@ def test_blocklist_repr(): ) repr_str = repr(block_list) - assert "" in repr_str + assert "" in repr_str diff --git a/nbmail/tests/__snapshots__/test_structs.ambr b/nbmail/tests/__snapshots__/test_structs.ambr index 63d4f55..b18013a 100644 --- a/nbmail/tests/__snapshots__/test_structs.ambr +++ b/nbmail/tests/__snapshots__/test_structs.ambr @@ -12,8 +12,7 @@ .content { padding: 20px; } - -

    Subject: Complex Email Structure

    +

    email subject: Complex\ Email\ Structure

    Welcome!

    @@ -31,16 +30,12 @@ ''' # --- # name: test_preview_email_simple_html - ''' - -

    Subject: Simple Test Email

    Hello World!

    - ''' + '

    email subject: Simple\\ Test\\ Email

    Hello World!

    ' # --- # name: test_preview_email_with_inline_attachments ''' - -

    Subject: Email with Inline Images

    +

    email subject: Email\ with\ Inline\ Images

    Email with Images

    Logo

    Some text content

    diff --git a/nbmail/tests/test_structs.py b/nbmail/tests/test_structs.py index d5d4d37..dc976b5 100644 --- a/nbmail/tests/test_structs.py +++ b/nbmail/tests/test_structs.py @@ -45,11 +45,9 @@ def test_subject_inserts_after_body(tmp_path): email.write_preview_email(str(out_file)) content = out_file.read_text(encoding="utf-8") - # Check subject is inserted after assert re.search( - r"]*>\s*

    Subject: Test Subject

    ", + r'

    email subject:', content, - re.IGNORECASE, ) @@ -62,8 +60,8 @@ def test_subject_prepends_if_no_body(tmp_path): out_file = tmp_path / "preview2.html" email.write_preview_email(str(out_file)) content = out_file.read_text(encoding="utf-8") - # Should start with the subject h2 - assert content.startswith('

    Subject: NoBody

    ') + + assert content == '

    email subject: NoBody

    Hello!

    ' def test_raises_on_external_attachments(tmp_path): From a091786cbb40ce05f7a7091c06f35e71dc593f4e Mon Sep 17 00:00:00 2001 From: Jules <54960783+juleswg23@users.noreply.github.com> Date: Wed, 12 Nov 2025 14:37:22 -0500 Subject: [PATCH 06/15] commiting font size changes to header and footer This code will be cleaned in the next commit --- nbmail/compose/blocks.py | 8 +-- nbmail/compose/compose.py | 142 ++++++++++++++++++++++++++++---------- 2 files changed, 109 insertions(+), 41 deletions(-) diff --git a/nbmail/compose/blocks.py b/nbmail/compose/blocks.py index 45874ad..2237604 100644 --- a/nbmail/compose/blocks.py +++ b/nbmail/compose/blocks.py @@ -100,7 +100,7 @@ def _to_mjml_list(self) -> list[MJMLTag]: # Create a simple text block from the string mjml_section = section( column(mjml_text(content=html)), # or mj-raw? - attributes={"padding": "0px"}, # TODO check what happens if we remove this + # attributes={"padding": "0px"}, # TODO check what happens if we remove this ) result.append(mjml_section) @@ -151,7 +151,7 @@ def block_text( column( mjml_text(content=html, attributes={"align": align}), ), - attributes={"padding": "0px"}, + # attributes={"padding": "0px"}, ) return Block(mjml_section) @@ -201,7 +201,7 @@ def block_title( attributes={"align": align}, ) ), - attributes={"padding": "0px"}, + # attributes={"padding": "0px"}, ) return Block(mjml_section) @@ -238,7 +238,7 @@ def block_spacer(height: str = "20px") -> Block: column( mjml_spacer(attributes={"height": height}), ), - attributes={"padding": "0px"}, + # attributes={"padding": "0px"}, ) return Block(mjml_section) diff --git a/nbmail/compose/compose.py b/nbmail/compose/compose.py index e0ff4c6..37e004a 100644 --- a/nbmail/compose/compose.py +++ b/nbmail/compose/compose.py @@ -1,4 +1,4 @@ -from typing import Literal, Optional, Union +from typing import Optional, Union from nbmail.ingress import mjml_to_email from nbmail.structs import Email @@ -9,11 +9,10 @@ mj_attributes, mj_all, section, - column, wrapper, ) from nbmail.mjml._core import MJMLTag -from .blocks import Block, BlockList, block_text, block_title +from .blocks import Block, BlockList, block_title __all__ = [ "compose_email", @@ -151,9 +150,9 @@ def compose_email( ``` """ # Convert sections (header, body, footer) to MJML lists - header_mjml_list = _component_to_mjml_section(header, component_type="header") - body_mjml_list = _component_to_mjml_section(body, component_type="body") - footer_mjml_list = _component_to_mjml_section(footer, component_type="footer") + header_mjml_list = _component_to_mjml_section(header) + body_mjml_list = _component_to_mjml_section(body) + footer_mjml_list = _component_to_mjml_section(footer) # If title is provided, prepend it to header if title: @@ -171,15 +170,15 @@ def compose_email( else: raise ValueError(f"Unknown template: {template}. Use 'blastula' or 'none'.") - # Build full MJML email structure with head containing spacing defaults (padding: 0px) + # Build full MJML email structure email_structure = mjml( head( mj_attributes( - # section(attributes={"padding": "55px"}), mj_all( attributes={ - "padding": "0px 6px", + "padding": "0px", "font-family": "Helvetica, sans-serif", + "font-size": "14px", } ), ), @@ -190,6 +189,7 @@ def compose_email( ), ) + # TODO: remove, this is for testing purposes print(email_structure._to_mjml()) email_obj = mjml_to_email(email_structure) @@ -199,7 +199,6 @@ def compose_email( def _component_to_mjml_section( component: Optional[Union[str, Block, BlockList]], - component_type: Literal["body", "header", "footer"], ) -> list[MJMLTag]: """ Convert a component (string, Block, BlockList, or None) to a list of MJML tags. @@ -237,9 +236,10 @@ def _component_to_mjml_section( ) - def _apply_blastula_template( - header_sections: list, body_sections: list, footer_sections: list + header_sections: list[MJMLTag], + body_sections: list[MJMLTag], + footer_sections: list[MJMLTag], ) -> list[MJMLTag]: """ Apply Blastula-style template with grey border around body content. @@ -249,61 +249,129 @@ def _apply_blastula_template( list[MJMLTag] Styled sections: header + wrapped_body + footer. """ - # Apply grey background to header sections - grey_attrs = { - "background-color": "#f6f6f6", - "padding-right": "16px", - "padding-left": "16px", - } + # Apply attributes to header sections + styled_header = [ + _apply_attributes( + section, + tag_names=None, + attributes={ + "background-color": "#f6f6f6", + "padding": "0px 20px", + }, + ) + for section in header_sections + ] + # Apply text-level attributes (like font-size) to footer text elements styled_header = [ - _apply_section_attributes(section, grey_attrs) for section in header_sections + _apply_attributes( + section, + tag_names=["mj-text"], + attributes={ + "font-size": "12px", + "color": "#999999", + "align": "center", + }, + ) + for section in styled_header ] + # Apply attributes to footer sections styled_footer = [ - _apply_section_attributes(section, grey_attrs) for section in footer_sections + _apply_attributes( + section, + tag_names=None, + attributes={ + "background-color": "#f6f6f6", + "padding": "0px 20px", + }, + ) + for section in footer_sections ] - body_attrs = { - "background-color": "white", - "padding": "0px", - } + # Apply text-level attributes (like font-size) to footer text elements + styled_footer = [ + _apply_attributes( + section, + tag_names=["mj-text"], + attributes={ + "font-size": "12px", + "color": "#999999", + "align": "center", + }, + ) + for section in styled_footer + ] + # Apply attributes to body sections styled_body = [ - _apply_section_attributes(section, body_attrs) for section in body_sections + _apply_attributes( + section, + tag_names=None, + attributes={ + "background-color": "white", + "padding": "0px 10px", + }, + ) + for section in body_sections ] + # Wrap body with styling body_wrapper = wrapper( *styled_body, attributes={ "background-color": "#f6f6f6", - "padding": "16px", + "padding": "10px", }, ) - return styled_header + [body_wrapper] + styled_footer -def _apply_section_attributes(section: MJMLTag, attributes: dict) -> MJMLTag: +def _apply_attributes( + tag: MJMLTag, tag_names: Optional[list[str]], attributes: dict +) -> MJMLTag: """ - Apply or merge attributes to a section tag. + Recursively apply attributes to tags matching specified names. - Internal helper for `_apply_blastula_template()`. + If tag_names is None, applies attributes to the top-level tag itself. + If tag_names is a list, applies attributes only to matching tags within children. + + Internal helper for applying attributes to MJML structures. Parameters ---------- - section - The section tag to modify. + tag + The tag to traverse. + + tag_names + List of tag names to match (e.g., ["mj-text", "mj-button"]). + If None, applies to the top-level tag. attributes - Attributes to apply or merge. + Attributes to apply to matching tags. Returns ------- MJMLTag - A new section tag with merged attributes. + The tag with attributes applied. """ - merged_attrs = {**(section.attrs or {}), **attributes} - section.attrs = merged_attrs - return section + if tag_names is None: + # Apply to the tag itself + merged_attrs = {**(tag.attrs or {}), **attributes} + tag.attrs = merged_attrs + else: + # Recursively apply to matching child tags + def apply_to_children(current_tag: MJMLTag) -> None: + if current_tag.children: + for child in current_tag.children: + if isinstance(child, MJMLTag): + if child.tagName in tag_names: + if child.attrs is None: + child.attrs = {} + child.attrs.update(attributes) + apply_to_children(child) + + apply_to_children(tag) + + return tag From 34758f5c907fa68e711fb50f42f2384df5cf4ea3 Mon Sep 17 00:00:00 2001 From: Jules <54960783+juleswg23@users.noreply.github.com> Date: Wed, 12 Nov 2025 14:46:28 -0500 Subject: [PATCH 07/15] cleanup last commit --- nbmail/compose/compose.py | 79 +++++++++++++-------------------------- 1 file changed, 25 insertions(+), 54 deletions(-) diff --git a/nbmail/compose/compose.py b/nbmail/compose/compose.py index 37e004a..ae038cd 100644 --- a/nbmail/compose/compose.py +++ b/nbmail/compose/compose.py @@ -249,59 +249,26 @@ def _apply_blastula_template( list[MJMLTag] Styled sections: header + wrapped_body + footer. """ - # Apply attributes to header sections - styled_header = [ - _apply_attributes( - section, - tag_names=None, - attributes={ - "background-color": "#f6f6f6", - "padding": "0px 20px", - }, - ) - for section in header_sections - ] - - # Apply text-level attributes (like font-size) to footer text elements - styled_header = [ - _apply_attributes( - section, - tag_names=["mj-text"], - attributes={ - "font-size": "12px", - "color": "#999999", - "align": "center", - }, - ) - for section in styled_header - ] - - # Apply attributes to footer sections - styled_footer = [ - _apply_attributes( - section, - tag_names=None, - attributes={ - "background-color": "#f6f6f6", - "padding": "0px 20px", - }, - ) - for section in footer_sections - ] - - # Apply text-level attributes (like font-size) to footer text elements - styled_footer = [ - _apply_attributes( - section, - tag_names=["mj-text"], - attributes={ - "font-size": "12px", - "color": "#999999", - "align": "center", - }, - ) - for section in styled_footer - ] + section_attrs = { + "background-color": "#f6f6f6", + "padding": "0px 20px", + } + text_attrs = { + "font-size": "12px", + "color": "#999999", + "align": "center", + } + + def apply_blastula_styles(sections: list[MJMLTag]) -> list[MJMLTag]: + """Apply section-level and text-level attributes.""" + result = [ + _apply_attributes(s, tag_names=None, attributes=section_attrs) + for s in sections + ] + return [ + _apply_attributes(s, tag_names=["mj-text"], attributes=text_attrs) + for s in result + ] # Apply attributes to body sections styled_body = [ @@ -325,7 +292,11 @@ def _apply_blastula_template( }, ) - return styled_header + [body_wrapper] + styled_footer + return ( + apply_blastula_styles(header_sections) + + [body_wrapper] + + apply_blastula_styles(footer_sections) + ) def _apply_attributes( From 52f130a218a79b553226cf4647d623dee85d7388 Mon Sep 17 00:00:00 2001 From: Jules <54960783+juleswg23@users.noreply.github.com> Date: Wed, 12 Nov 2025 15:24:41 -0500 Subject: [PATCH 08/15] update subject building, add show_browser method --- nbmail/structs.py | 50 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/nbmail/structs.py b/nbmail/structs.py index d8a1ca7..ad74d78 100644 --- a/nbmail/structs.py +++ b/nbmail/structs.py @@ -1,9 +1,14 @@ from __future__ import annotations from dataclasses import dataclass, field +from functools import partial +from http.server import HTTPServer, SimpleHTTPRequestHandler +from pathlib import Path import re import json from email.message import EmailMessage +import tempfile +import webbrowser from .utils import _add_base_64_to_inline_attachments @@ -106,18 +111,28 @@ def _add_subject_header(self, html: str) -> str: str HTML with subject header added """ + if self.subject: + subject_ln = ( + "

    " + "email subject: " + f"{re.escape(self.subject)}" + "
    " + ) + else: + subject_ln = "" + if "]*>)", - r'\1\n

    Subject: {}

    '.format(self.subject), + r'\1' + subject_ln, html, count=1, flags=re.IGNORECASE, ) else: # Fallback: prepend if no tag found - html = f'

    Subject: {self.subject}

    \n' + html - + html = subject_ln + html + return html def _repr_html_(self) -> str: @@ -207,6 +222,20 @@ def write_email_message(self) -> EmailMessage: """ raise NotImplementedError + def show_browser(self): + with tempfile.TemporaryDirectory() as tmp_dir: + f_path = Path(tmp_dir) / "index.html" + + # Generate the preview HTML with inline base64 images + html_with_inline = self._generate_preview_html() + html_with_inline = self._add_subject_header(html_with_inline) + f_path.write_text(html_with_inline, encoding="utf-8") + + # create a server that closes after 1 request ---- + server = _create_temp_file_server(f_path) + webbrowser.open(f"http://127.0.0.1:{server.server_port}/{f_path.name}") + server.handle_request() + def preview_send_email(self): """ Send a preview of the email to a test recipient. @@ -291,3 +320,18 @@ def write_quarto_json(self, out_file: str = ".output_metadata.json") -> None: json.dump(metadata, f, indent=2) +#### Helpers #### + + +## To help mimic Great Tables method: GT.show(target="browser") +class PatchedHTTPRequestHandler(SimpleHTTPRequestHandler): + """Patched handler, which does not log requests to stderr""" + + +def _create_temp_file_server(fname: Path) -> HTTPServer: + """Return a HTTPServer, so we can serve a single request (to show the table).""" + + Handler = partial(PatchedHTTPRequestHandler, directory=str(fname.parent)) + server = HTTPServer(("127.0.0.1", 0), Handler) + + return server From 00028130aaf7d4702a5cc026432dbd8cd53fa0af Mon Sep 17 00:00:00 2001 From: Jules <54960783+juleswg23@users.noreply.github.com> Date: Wed, 12 Nov 2025 16:19:17 -0500 Subject: [PATCH 09/15] move add_image to block_image --- nbmail/compose/__init__.py | 8 +- nbmail/compose/blocks.py | 222 ++++++++++++++++++- nbmail/compose/compose.py | 13 +- nbmail/compose/inline_utils.py | 161 ++++++-------- nbmail/compose/tests/test_inline_utils.py | 33 +-- nbmail/structs.py | 2 +- nbmail/tests/__snapshots__/test_structs.ambr | 6 +- 7 files changed, 301 insertions(+), 144 deletions(-) diff --git a/nbmail/compose/__init__.py b/nbmail/compose/__init__.py index cf9ef8f..3685248 100644 --- a/nbmail/compose/__init__.py +++ b/nbmail/compose/__init__.py @@ -1,8 +1,6 @@ from .compose import compose_email, create_blocks -from .blocks import block_text, block_title, block_spacer +from .blocks import block_text, block_title, block_spacer, block_image, block_plot from .inline_utils import ( - add_image, - add_plot, md, add_cta_button, add_readable_time, @@ -14,8 +12,8 @@ "block_text", "block_title", "block_spacer", - "add_image", - "add_plot", + "block_image", + "block_plot", "md", "add_cta_button", "add_readable_time", diff --git a/nbmail/compose/blocks.py b/nbmail/compose/blocks.py index 2237604..d9f84e0 100644 --- a/nbmail/compose/blocks.py +++ b/nbmail/compose/blocks.py @@ -1,8 +1,8 @@ -from typing import Union +from pathlib import Path +from typing import Union, Literal from nbmail.mjml.tags import section, column, text as mjml_text, spacer as mjml_spacer from nbmail.mjml._core import MJMLTag -from .inline_utils import _process_markdown -from typing import Literal +from .inline_utils import _is_url, _process_markdown __all__ = [ @@ -11,6 +11,8 @@ "block_text", "block_title", "block_spacer", + "block_image", + "block_plot", ] @@ -242,3 +244,217 @@ def block_spacer(height: str = "20px") -> Block: ) return Block(mjml_section) + + +def block_image( + file: str, + alt: str = "", + width: Union[int, str] = 520, + align: Literal["center", "left", "right", "inline"] = "center", + float: Literal["none", "left", "right"] = "none", +) -> Block: + """ + Create a block containing an embedded image. + + This function returns a Block with an MJML image tag. For local files, the image + bytes are stored and later converted to CID references by `mjml_to_email()`. + + Parameters + ---------- + file + Path to local image file or HTTP(S) URL. Local files are automatically + read and embedded. URLs (http://, https://, or //) are used directly. + + alt + Alt text for accessibility. Default is empty string. + + width + Image width. Can be an integer (interpreted as pixels, e.g., 520 → "520px") + or a CSS string (e.g., "600px", "50%"). Default is 520 (pixels). + + align + Block-level alignment: "center", "left", "right", or "inline". + + float + CSS float value for text wrapping: "none", "left", or "right". + When float is not "none", it takes precedence and wraps content around + the image. + + Returns + ------- + Block + A block containing the image. + + Raises + ------ + FileNotFoundError + If a local file path is provided but the file does not exist. + + Examples + -------- + ```python + from nbmail.compose import block_image, create_blocks, block_text + + email = compose_email( + body=create_blocks( + block_text("Here's an image:"), + block_image("path/to/image.png", alt="Example", width=600), + block_text("And some text after it.") + ) + ) + ``` + + Notes + ----- + - Images from local files are converted to inline attachments with CID references + during email processing by mjml_to_email(). + - If `float` is not "none", it takes precedence and overrides `align`. + """ + # Convert integer width to CSS string + if isinstance(width, int): + width_str = f"{width}px" + else: + width_str = width + + # Determine alignment style based on float and align parameters + align_style = "" + + # Float takes precedence if not "none" + if float != "none": + align_style = f"float: {float};" + else: + # Use align parameter if float is "none" + if align == "center": + align_style = "display: block; margin: 0 auto;" + elif align == "left": + align_style = "display: block; margin: 0;" + elif align == "right": + align_style = "display: block; margin: 0 0 0 auto;" + # "inline" has no special style + + # Detect URL vs local file + if _is_url(file): + # For URLs, use as-is + src = file + else: + # For local files, read as bytes for processing by _process_mjml_images + file_path = Path(file) + if not file_path.exists(): + raise FileNotFoundError(f"Image file not found: {file}") + if not file_path.is_file(): + raise ValueError(f"Path is not a file: {file}") + + with open(file_path, "rb") as f: + src = f.read() + + attrs = { + "src": src, + "alt": alt, + "width": width_str, + } + + if align_style: + attrs["style"] = f"{align_style} max-width: 100%; height: auto;" + else: + attrs["style"] = "max-width: 100%; height: auto;" + + image_tag = MJMLTag("mj-image", attributes=attrs, _is_leaf=True) + mjml_section = section(column(image_tag)) + + return Block(mjml_section) + + +def block_plot( + fig, + alt: str = "", + width: Union[int, str] = 520, + align: Literal["center", "left", "right", "inline"] = "center", + float: Literal["none", "left", "right"] = "none", +) -> Block: + """ + Create a block containing an embedded plotnine plot. + + This function saves a plotnine plot to a temporary PNG file and wraps it as a Block + with an embedded image. + + Parameters + ---------- + fig + A plotnine plot object (ggplot). + + alt + Alt text for accessibility. Default is empty string. + + width + Image width. Can be an integer (interpreted as pixels, e.g., 520 → "520px") + or a CSS string (e.g., "600px", "50%"). Default is 520 (pixels). + + align + Block-level alignment: "center", "left", "right", or "inline". + Default is "center". + + float + CSS float value for text wrapping: "none", "left", or "right". + Default is "none". + + Returns + ------- + Block + A block containing the plot image. + + Raises + ------ + ImportError + If the plotnine package is not installed. + + Examples + -------- + ```python + from nbmail.compose import block_plot, create_blocks, block_text + from plotnine import ggplot, aes, geom_point, mtcars + + plot = ( + ggplot(mtcars, aes("disp", "hp")) + + geom_point() + ) + + email = compose_email( + body=create_blocks( + block_text("Here's my plot:"), + block_plot(plot, alt="Scatter plot"), + block_text("What do you think?") + ) + ) + ``` + """ + import tempfile + from pathlib import Path + import importlib.util + + if importlib.util.find_spec("plotnine") is None: + raise ImportError( + "The 'plotnine' package is required for plot embedding. " + "Install it with: pip install plotnine" + ) + + # Create a temporary PNG file + tmpfile = tempfile.NamedTemporaryFile(suffix=".png", delete=False) + tmpfile_path = tmpfile.name + tmpfile.close() + + try: + fig.save(tmpfile_path, dpi=200, verbose=False) + + img_block = block_image( + file=tmpfile_path, + alt=alt, + width=width, + align=align, + float=float, + ) + + return img_block + + finally: + Path(tmpfile_path).unlink(missing_ok=True) + diff --git a/nbmail/compose/compose.py b/nbmail/compose/compose.py index ae038cd..251319c 100644 --- a/nbmail/compose/compose.py +++ b/nbmail/compose/compose.py @@ -140,12 +140,15 @@ def compose_email( Email with embedded images: ```python - from nbmail.compose import compose_email, add_image, block_text, md + from nbmail.compose import compose_email, block_image, block_text, md, create_blocks - img_html = add_image("path/to/image.png", alt="Product image", width="500px") + # Use block_image for embedding local files or URLs email = compose_email( title="Product Feature", - body=block_text(md(f"Check this out:\\n\\n{img_html}")) + body=create_blocks( + block_text(md("Check this out:")), + block_image("path/to/image.png", alt="Product image", width="500px") + ) ) ``` """ @@ -185,12 +188,12 @@ def compose_email( ), mj_body( *all_sections, - attributes={"width": "600px"}, # TODO check what happens if we remove this + attributes={"width": "600px", "background-color": "#f6f6f6"}, ), ) # TODO: remove, this is for testing purposes - print(email_structure._to_mjml()) + # print(email_structure._to_mjml()) email_obj = mjml_to_email(email_structure) diff --git a/nbmail/compose/inline_utils.py b/nbmail/compose/inline_utils.py index ea93326..34580b3 100644 --- a/nbmail/compose/inline_utils.py +++ b/nbmail/compose/inline_utils.py @@ -1,12 +1,13 @@ -# import base64 -# from datetime import datetime -# from pathlib import Path +import base64 +import mimetypes +from datetime import datetime +from pathlib import Path from typing import Optional +import re + __all__ = [ "md", - "add_image", - "add_plot", "add_cta_button", "add_readable_time", ] @@ -48,7 +49,6 @@ def md(text: str) -> str: Process Markdown text to HTML. Public utility function for converting Markdown strings to HTML. - Can include images created via `add_image()` or `add_plot()`. Parameters ---------- @@ -68,10 +68,6 @@ def md(text: str) -> str: # Simple markdown html = md("This is **bold** and this is *italic*") - # With embedded image - img_html = add_image("path/to/image.png") - html = md(f"Check this out!\\n\\n{img_html}") - # Use in a block email = compose_email(body=block_text(html)) ``` @@ -79,111 +75,83 @@ def md(text: str) -> str: return _process_markdown(text) -def add_image( - src: str, - alt: str = "", - width: str = "520px", - align: str = "center", -) -> str: +def _is_url(file: str) -> bool: """ - Create HTML img tag for embedding images. + Detect if the file parameter is a URL (HTTP/HTTPS) or protocol-relative URL. Parameters ---------- - src - URL or path to image file. - - alt - Alt text for accessibility. Default is empty string. - - width - Image width (e.g., `"520px"`) - - align - Image alignment. + file + The file path or URL string to test. Returns ------- - str - HTML img tag that can be embedded in Markdown or passed to `block_text()`. + bool + True if file is a URL (http://, https://, or //), False otherwise. + """ + pattern = r"^(https?:)?//" + return bool(re.match(pattern, file, re.IGNORECASE)) - Examples - -------- - ```python - from nbmail.compose import add_image, block_text, compose_email - # From URL - img_html = add_image("https://example.com/image.png", alt="Example image") +def _guess_mime_type(file: str) -> str: + """ + Guess MIME type from file extension. - # From local file - img_html = add_image("path/to/image.png", alt="My image", width="600px") + Parameters + ---------- + file + File path or URL. - # Use in email - email = compose_email( - body=block_text(f"Check this out:\\n{img_html}") - ) - ``` + Returns + ------- + str + MIME type string (e.g., "image/png", "image/jpeg"). + Defaults to "image/png" if type cannot be determined. """ - # Determine alignment style - align_style = "" - if align == "center": - align_style = "display: block; margin: 0 auto;" - elif align == "left": - align_style = "display: block; margin: 0;" - elif align == "right": - align_style = "float: right;" - # "inline" has no special style - - # Create img tag - img_tag = ( - f'{alt}' - ) - - return img_tag + mime_type, _ = mimetypes.guess_type(file) + return mime_type or "image/png" -def add_plot( - fig, - alt: str = "", - width: str = "520px", -) -> str: +def _read_local_file_as_data_uri(file: str) -> str: """ - Convert a plot figure to embedded HTML img tag. + Read a local file and convert to data URI with base64 encoding. Parameters ---------- - fig - Plot figure object. Supports matplotlib.figure.Figure and plotly.graph_objects.Figure. - - alt - Alt text for accessibility. Default is empty string. - - width - Image width (e.g., "520px"). Default is "520px". + file + Path to local file. Returns ------- str - HTML img tag with base64-encoded plot that can be embedded in emails. + Data URI string (e.g., "data:image/png;base64,iVBORw0KG..."). + + Raises + ------ + FileNotFoundError + If the file does not exist. + IOError + If the file cannot be read. + """ + file_path = Path(file) - Examples - -------- - ```python - from nbmail.compose import add_plot, block_text, compose_email - import matplotlib.pyplot as plt + if not file_path.exists(): + raise FileNotFoundError(f"Image file not found: {file}") - fig, ax = plt.subplots() - ax.plot([1, 2, 3, 4], [1, 4, 2, 3]) + if not file_path.is_file(): + raise ValueError(f"Path is not a file: {file}") - plot_html = add_plot(fig, alt="Sales trends", width="600px") + # Read file as binary + with open(file_path, "rb") as f: + file_bytes = f.read() - email = compose_email( - body=block_text(f"Here's the trend:\\n{plot_html}") - ) - ``` - """ - return NotImplementedError("Coming soon.") + # Encode to base64 + b64_string = base64.b64encode(file_bytes).decode("utf-8") + + # Guess MIME type + mime_type = _guess_mime_type(str(file_path)) + + return f"data:{mime_type};base64,{b64_string}" def add_cta_button( @@ -240,7 +208,7 @@ def add_cta_button( def add_readable_time( - dt, + dt: datetime, format_str: str = "%B %d, %Y", ) -> str: """ @@ -259,6 +227,11 @@ def add_readable_time( str Formatted date/time string. + Raises + ------ + TypeError + If dt is not a datetime object. + Examples -------- ```python @@ -277,9 +250,7 @@ def add_readable_time( ) ``` """ - raise NotImplementedError("Coming soon.") - - # if not isinstance(dt, datetime): - # raise TypeError(f"Expected datetime object, got {type(dt).__name__}") + if not isinstance(dt, datetime): + raise TypeError(f"Expected datetime object, got {type(dt).__name__}") - # return dt.strftime(format_str) + return dt.strftime(format_str) diff --git a/nbmail/compose/tests/test_inline_utils.py b/nbmail/compose/tests/test_inline_utils.py index 0a4fd3d..f5fc56f 100644 --- a/nbmail/compose/tests/test_inline_utils.py +++ b/nbmail/compose/tests/test_inline_utils.py @@ -6,7 +6,6 @@ from nbmail.compose import ( md, - add_image, add_cta_button, add_readable_time, ) @@ -41,37 +40,6 @@ def test_md_heading(): assert "Heading 1" in result -def test_add_image_url(): - html = add_image("https://example.com/image.png", alt="Test image") - - assert " str: subject_ln = ( "

    " "email subject: " - f"{re.escape(self.subject)}" + f"{self.subject}" "
    " ) else: diff --git a/nbmail/tests/__snapshots__/test_structs.ambr b/nbmail/tests/__snapshots__/test_structs.ambr index b18013a..5dd6fb2 100644 --- a/nbmail/tests/__snapshots__/test_structs.ambr +++ b/nbmail/tests/__snapshots__/test_structs.ambr @@ -12,7 +12,7 @@ .content { padding: 20px; } -

    email subject: Complex\ Email\ Structure
    +

    email subject: Complex Email Structure

    Welcome!

    @@ -30,12 +30,12 @@ ''' # --- # name: test_preview_email_simple_html - '

    email subject: Simple\\ Test\\ Email

    Hello World!

    ' + '

    email subject: Simple Test Email

    Hello World!

    ' # --- # name: test_preview_email_with_inline_attachments ''' -

    email subject: Email\ with\ Inline\ Images
    +

    email subject: Email with Inline Images

    Email with Images

    Logo

    Some text content

    From b5618d137b1429f3ef70c476cda245b2122da0a1 Mon Sep 17 00:00:00 2001 From: Jules <54960783+juleswg23@users.noreply.github.com> Date: Fri, 14 Nov 2025 12:35:48 -0500 Subject: [PATCH 10/15] docs and repr upgrades --- docs/_quarto.yml | 16 +++++++ nbmail/compose/blocks.py | 88 ++++++++++++++++++++++++++-------- nbmail/compose/compose.py | 33 ++++--------- nbmail/compose/inline_utils.py | 20 +++----- nbmail/egress.py | 20 -------- nbmail/mjml/README.md | 29 +++++------ nbmail/mjml/image_processor.py | 39 ++++++++++----- nbmail/structs.py | 72 ++++++++++++++-------------- 8 files changed, 174 insertions(+), 143 deletions(-) diff --git a/docs/_quarto.yml b/docs/_quarto.yml index 44882bf..a343b5c 100644 --- a/docs/_quarto.yml +++ b/docs/_quarto.yml @@ -77,6 +77,22 @@ quartodoc: contents: - write_email_message_to_file + - title: Templated Authoring + desc: > + Write responsive emails with the Blastula template + package: nbmail + contents: + - compose.compose_email + - compose.create_blocks + - compose.block_text + - compose.block_title + - compose.block_spacer + - compose.block_image + - compose.block_plot + - compose.md + - compose.add_cta_button + - compose.add_readable_time + - title: MJML Authoring desc: > Write responsive emails with MJML diff --git a/nbmail/compose/blocks.py b/nbmail/compose/blocks.py index d9f84e0..a5fc184 100644 --- a/nbmail/compose/blocks.py +++ b/nbmail/compose/blocks.py @@ -5,6 +5,7 @@ from .inline_utils import _is_url, _process_markdown + __all__ = [ "Block", "BlockList", @@ -48,6 +49,22 @@ def _to_mjml(self) -> MJMLTag: """ return self._mjml_tag + def _repr_html_(self) -> str: + """ + Return HTML representation for rich display in Jupyter notebooks. + + Examples + -------- + ```{python} + from nbmail.compose import block_text + + block = block_text("Hello world!") + block + ``` + """ + block_list = BlockList(self) + return block_list._repr_html_() + class BlockList: """ @@ -62,7 +79,7 @@ class BlockList: -------- Users typically create BlockList via the `create_blocks()` function: - ```python + ```{python} from nbmail.compose import create_blocks, block_text, block_title content = create_blocks( @@ -80,6 +97,36 @@ def __init__(self, *args: Union["Block", str]): One or more `Block` objects or strings. """ self.sections = list(args) + + def _repr_html_(self) -> str: + """ + Return HTML representation for rich display in Jupyter notebooks. + + This method wraps the BlockList in a compose_email() call and delegates + to the Email's _repr_html_() method for rendering, enabling interactive + preview of blocks directly in notebooks. + + Returns + ------- + str + HTML content with inline attachments embedded as base64 data URIs. + + Examples + -------- + ```{python} + from nbmail.compose import create_blocks, block_title, block_text + + content = create_blocks( + block_title("My Email"), + block_text("Hello world!") + ) + content + ``` + """ + from .compose import compose_email + + email = compose_email(body=self) + return email._repr_html_() def _to_mjml_list(self) -> list[MJMLTag]: """ @@ -134,12 +181,14 @@ def block_text( Examples -------- - ```python + ```{python} from nbmail.compose import block_text # Simple text block = block_text("Hello world") + print(1+1) + # Markdown text block = block_text("This is **bold** and this is *italic*") @@ -179,14 +228,10 @@ def block_title( Examples -------- - ```python + ```{python} from nbmail.compose import block_title - # Simple title - title = block_title("My Newsletter") - - # Centered title (default) - title = block_title("Welcome!", align="center") + block_title("My Newsletter") ``` """ @@ -226,10 +271,10 @@ def block_spacer(height: str = "20px") -> Block: Examples -------- - ```python + ```{python} from nbmail.compose import block_spacer, create_blocks, block_text - email_body = create_blocks( + create_blocks( block_text("First section"), block_spacer("30px"), block_text("Second section"), @@ -292,14 +337,14 @@ def block_image( Examples -------- - ```python - from nbmail.compose import block_image, create_blocks, block_text + ```{python} + from nbmail.compose import block_image, create_blocks, block_text, compose_email, md - email = compose_email( + compose_email( + title="Product Feature", body=create_blocks( - block_text("Here's an image:"), - block_image("path/to/image.png", alt="Example", width=600), - block_text("And some text after it.") + block_text(md("Check this out:")), + block_image("https://fastly.picsum.photos/id/630/300/200.jpg?hmac=dSM5_yM5Z9Pb3CX6OviVW3dEbyHmkD04otrIKU2LQ50", alt="Product image", width="500px") ) ) ``` @@ -409,16 +454,17 @@ def block_plot( Examples -------- - ```python - from nbmail.compose import block_plot, create_blocks, block_text - from plotnine import ggplot, aes, geom_point, mtcars + ```{python} + from nbmail.compose import block_plot, create_blocks, block_text, compose_email + from plotnine import ggplot, aes, geom_point + from great_tables.data import gtcars plot = ( - ggplot(mtcars, aes("disp", "hp")) + ggplot(gtcars, aes("trq", "hp")) + geom_point() ) - email = compose_email( + compose_email( body=create_blocks( block_text("Here's my plot:"), block_plot(plot, alt="Scatter plot"), diff --git a/nbmail/compose/compose.py b/nbmail/compose/compose.py index 251319c..c54bd80 100644 --- a/nbmail/compose/compose.py +++ b/nbmail/compose/compose.py @@ -38,10 +38,10 @@ def create_blocks(*args: Union[Block, str]) -> BlockList: Examples -------- - ```python + ```{python} from nbmail.compose import create_blocks, block_text, block_title, block_spacer - content = create_blocks( + create_blocks( block_title("Welcome!"), block_text("This is the main content."), block_spacer("20px"), @@ -100,21 +100,21 @@ def compose_email( -------- Simple email with single block: - ```python + ```{python} from nbmail.compose import compose_email, block_text - email = compose_email( + compose_email( body=block_text("This is a simple email.") ) ``` Email with title/header and multiple blocks: - ```python + ```{python} from nbmail.compose import compose_email, create_blocks, block_title, block_text - email = compose_email( - title="Welcome!", # Creates a large title block at top + compose_email( + title="Welcome!", body=create_blocks( block_text("Welcome to this week's update!"), block_text("Here's what's new...") @@ -124,10 +124,10 @@ def compose_email( Email with header section and body: - ```python + ```{python} from nbmail.compose import compose_email, create_blocks, block_title, block_text - email = compose_email( + compose_email( header=create_blocks(block_title("Newsletter")), body=create_blocks( block_text("Welcome to this week's update!"), @@ -136,21 +136,6 @@ def compose_email( footer=create_blocks(block_text("© 2025 My Company")) ) ``` - - Email with embedded images: - - ```python - from nbmail.compose import compose_email, block_image, block_text, md, create_blocks - - # Use block_image for embedding local files or URLs - email = compose_email( - title="Product Feature", - body=create_blocks( - block_text(md("Check this out:")), - block_image("path/to/image.png", alt="Product image", width="500px") - ) - ) - ``` """ # Convert sections (header, body, footer) to MJML lists header_mjml_list = _component_to_mjml_section(header) diff --git a/nbmail/compose/inline_utils.py b/nbmail/compose/inline_utils.py index 34580b3..d0d5a2a 100644 --- a/nbmail/compose/inline_utils.py +++ b/nbmail/compose/inline_utils.py @@ -62,14 +62,13 @@ def md(text: str) -> str: Examples -------- - ```python - from nbmail.compose import md, block_text + ```{python} + from nbmail.compose import md, block_text, compose_email - # Simple markdown html = md("This is **bold** and this is *italic*") # Use in a block - email = compose_email(body=block_text(html)) + compose_email(body=block_text(html)) ``` """ return _process_markdown(text) @@ -184,12 +183,12 @@ def add_cta_button( Examples -------- - ```python + ```{python} from nbmail.compose import add_cta_button, block_text, compose_email button_html = add_cta_button("Learn More", "https://example.com") - email = compose_email( + compose_email( body=block_text(f"Ready?\\n\\n{button_html}") ) ``` @@ -234,18 +233,13 @@ def add_readable_time( Examples -------- - ```python + ```{python} from datetime import datetime from nbmail.compose import add_readable_time, block_text, compose_email time_str = add_readable_time(datetime.now()) - # Output: "November 10, 2025" - # Custom format - time_str = add_readable_time(datetime.now(), format_str="%A, %B %d") - # Output: "Sunday, November 10" - - email = compose_email( + compose_email( body=block_text(f"Report generated: {time_str}") ) ``` diff --git a/nbmail/egress.py b/nbmail/egress.py index ba8ec70..4b3d6e1 100644 --- a/nbmail/egress.py +++ b/nbmail/egress.py @@ -325,26 +325,6 @@ def send_email_with_smtp( email, security="tls" ) - - # SSL connection (port 465) - send_email_with_smtp( - "smtp.example.com", - 465, - "user@example.com", - "password123", - email, - security="ssl" - ) - - # Plain SMTP (port 25) - insecure, for testing only - send_email_with_smtp( - "127.0.0.1", - 8025, - "test@example.com", - "password", - email, - security="smtp" - ) ``` """ if security not in ("tls", "ssl", "smtp"): diff --git a/nbmail/mjml/README.md b/nbmail/mjml/README.md index 2b3c624..b865f9c 100644 --- a/nbmail/mjml/README.md +++ b/nbmail/mjml/README.md @@ -26,18 +26,15 @@ from nbmail import mjml as mj from nbmail.mjml import mjml, body, section, column, text # Build an MJML email structure -email = mjml( +mjml( body( section( column( - text(content="Hello, World!", color="#ff6600") + text(content="Hello, World!", attributes={"color":"#ff6600"}) ) ) ) ) - -# Render to MJML markup -mjml_string = email.render() ``` ## Tag Types @@ -53,6 +50,9 @@ These tags accept children (other MJML components) and optional content: Example: ```python +from nbmail.mjml import mjml, body, section, column, text + +# Build an MJML email structure section( column( text(content="First column") @@ -60,7 +60,7 @@ section( column( text(content="Second column") ), - background_color="#f0f0f0" + attributes={"background_color":"#f0f0f0"} ) ``` @@ -81,14 +81,12 @@ Example: ```python text( content="Bold text and a link", - font_size="16px", - color="#333333" + attributes={"font_size": "16px", "color": "#333333"} ) button( content="Click Here", - href="https://example.com", - background_color="#007bff" + attributes={"href": "https://example.com", "background_color": "#007bff"} ) ``` @@ -104,7 +102,7 @@ from nbmail.mjml import MJMLTag tag = MJMLTag( "mj-text", content="Hello", - color="#ff6600" + attributes={"color": "#ff6600"} ) ``` @@ -141,7 +139,7 @@ from nbmail.mjml import body, section, column, text, image layout = body( section( column( - image(src="https://example.com/logo.png"), + image(attributes={"src": "https://example.com/logo.png"}), text(content="Column 1") ), column( @@ -159,13 +157,12 @@ layout = body( ```python from nbmail.mjml import section, column, text -# Attributes as kwargs +# Using attributes parameter section( column( - text(content="Styled text", color="#ff0000", font_size="20px") + text(content="Styled text", attributes={"color": "#ff0000", "font_size": "20px"}) ), - background_color="#f5f5f5", - padding="20px" + attributes={"background_color": "#f5f5f5", "padding": "20px"} ) ``` diff --git a/nbmail/mjml/image_processor.py b/nbmail/mjml/image_processor.py index 483705f..6e10dbb 100644 --- a/nbmail/mjml/image_processor.py +++ b/nbmail/mjml/image_processor.py @@ -66,24 +66,40 @@ def _process_mjml_images(mjml_tag: MJMLTag) -> Tuple[MJMLTag, Dict[str, str]]: Returns ------- Tuple[MJMLTag, Dict[str, str]] - A tuple of: + A tuple of:\n - The modified MJML tag tree with BytesIO/bytes converted to CID references - Dictionary mapping CID filenames to base64-encoded image data Examples -------- - ```python - from nbmail.mjml import mjml, body, section, column, image + ```{python} + from nbmail.mjml import mjml, body, section, column, image, text from nbmail import mjml_to_email from io import BytesIO - - # Create BytesIO with image data - buf = BytesIO(b'...png binary data...') - - # Create MJML using regular image() with BytesIO as src + import numpy as np + import pandas as pd + from plotnine import ggplot, aes, geom_boxplot + + # Create the plot data + variety = np.repeat(["A", "B", "C", "D", "E", "F", "G"], 40) + treatment = np.tile(np.repeat(["high", "low"], 20), 7) + note = np.arange(1, 281) + np.random.choice(np.arange(1, 151), 280, replace=True) + data = pd.DataFrame({"variety": variety, "treatment": treatment, "note": note}) + + # Create the plot + gg = ggplot(data, aes(x="variety", y="note", fill="treatment")) + geom_boxplot() + + # Save plot to BytesIO buffer + buf = BytesIO() + gg.save(buf, format='png', dpi=100, verbose=False) + buf.seek(0) + email = mjml( body( section( + column( + text("A plot from plotnine in an email") + ), column( image(attributes={ "src": buf, @@ -94,11 +110,8 @@ def _process_mjml_images(mjml_tag: MJMLTag) -> Tuple[MJMLTag, Dict[str, str]]: ) ) ) - - # Pass directly to mjml_to_email (calls _process_mjml_images internally) - i_email = mjml_to_email(email) - - # Result: i_email.inline_attachments = {"plot_1.png": "iVBORw0KGgo..."} + + mjml_to_email(email) ``` """ inline_attachments: Dict[str, str] = {} diff --git a/nbmail/structs.py b/nbmail/structs.py index f86f8b9..3749a69 100644 --- a/nbmail/structs.py +++ b/nbmail/structs.py @@ -52,13 +52,14 @@ class Email: Examples -------- - ```python - email = Email( + ```{python} + from nbmail import Email + + Email( html="

    Hello world

    ", subject="Test Email", recipients=["user@example.com"], ) - email.write_preview_email("preview.html") ``` """ @@ -113,7 +114,7 @@ def _add_subject_header(self, html: str) -> str: """ if self.subject: subject_ln = ( - "

    " + '

    ' "email subject: " f"{self.subject}" "
    " @@ -124,7 +125,7 @@ def _add_subject_header(self, html: str) -> str: if "]*>)", - r'\1' + subject_ln, + r"\1" + subject_ln, html, count=1, flags=re.IGNORECASE, @@ -150,18 +151,42 @@ def _repr_html_(self) -> str: Examples -------- - ```python - # In a Jupyter notebook, simply display the email object: + ```{python} + from nbmail import Email + email = Email( - html='

    Hello

    ', + html='

    Hello

    ', subject="Test Email", - inline_attachments={"img1.png": "iVBORw0KGgo..."} ) - email # This will automatically call _repr_html_() for rich display + email ``` """ html_with_inline = self._generate_preview_html() - return self._add_subject_header(html_with_inline) + html_with_subject = self._add_subject_header(html_with_inline) + + # TODO: this is a dirty workaround for some weird quarto behavior. + # There is probably a better approach to previewing that doesn't + # involve removing content from the email + + # Strip the tag from MJML output to prevent style bleed + # MJML generates a full HTML document with styles that can + # interfere with Quarto/Jupyter page rendering + html_without_body = re.sub( + r"]*>(.*?)", + r"\1", + html_with_subject, + flags=re.DOTALL | re.IGNORECASE, + ) + + # Also remove any tags + html_without_html = re.sub( + r"]*>", "", html_without_body, flags=re.IGNORECASE + ) + + # Wrap in a container div to isolate the email preview styles + wrapped_html = f'
    {html_without_html}
    ' + + return wrapped_html def write_preview_email(self, out_file: str = "preview_email.html") -> None: """ @@ -181,12 +206,6 @@ def write_preview_email(self, out_file: str = "preview_email.html") -> None: ------- None - Examples - -------- - ```python - email.write_preview_email("preview.html") - ``` - Notes ------ Raises ValueError if external attachments are present, as preview does not support them. @@ -214,11 +233,6 @@ def write_email_message(self) -> EmailMessage: EmailMessage The constructed EmailMessage object. - Examples - -------- - ```python - msg = email.write_email_message() - ``` """ raise NotImplementedError @@ -247,11 +261,6 @@ def preview_send_email(self): ------- None - Examples - -------- - ```python - email.preview_send_email() - ``` """ raise NotImplementedError @@ -272,15 +281,6 @@ def write_quarto_json(self, out_file: str = ".output_metadata.json") -> None: ------- None - Examples - -------- - ```python - email = Email( - html="

    Hello world

    ", - subject="Test Email", - ) - email.write_quarto_json("email_metadata.json") - ``` Notes ------ From d31bdd2fb89ea7322b95822a8b1bab3a5431faf0 Mon Sep 17 00:00:00 2001 From: Jules <54960783+juleswg23@users.noreply.github.com> Date: Fri, 14 Nov 2025 12:39:16 -0500 Subject: [PATCH 11/15] docs and repr upgrades --- README.md | 6 +++--- nbmail/mjml/_core.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 2c21d00..12e2384 100644 --- a/README.md +++ b/README.md @@ -38,13 +38,13 @@ from nbmail import ( ) # Read a Quarto email JSON file -email_struct = quarto_json_to_email("email.json") +email = quarto_json_to_email("email.json") # Preview the email as HTML -email_struct.write_preview_email("preview.html") +email.write_preview_email("preview.html") # Send the email via Gmail -send_email_with_gmail("your_email@gmail.com", "your_password", email_struct) +send_email_with_gmail("your_email@gmail.com", "your_password", email) ``` ## Features diff --git a/nbmail/mjml/_core.py b/nbmail/mjml/_core.py index 9331639..e0697c5 100644 --- a/nbmail/mjml/_core.py +++ b/nbmail/mjml/_core.py @@ -151,7 +151,7 @@ def _flatten(children): raise ValueError( "Cannot render MJML with BytesIO/bytes in image src attribute. " "Pass the MJMLTag object directly to mjml_to_email() instead of calling _to_mjml() first. " - "Example: i_email = mjml_to_email(doc)" + "Example: email = mjml_to_email(doc)" ) # Build attribute string From bf99650a0fd6f34a30464807ae11142935120dc2 Mon Sep 17 00:00:00 2001 From: Jules <54960783+juleswg23@users.noreply.github.com> Date: Fri, 14 Nov 2025 13:36:43 -0500 Subject: [PATCH 12/15] update one example --- nbmail/compose/compose.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/nbmail/compose/compose.py b/nbmail/compose/compose.py index c54bd80..85d84d5 100644 --- a/nbmail/compose/compose.py +++ b/nbmail/compose/compose.py @@ -115,10 +115,7 @@ def compose_email( compose_email( title="Welcome!", - body=create_blocks( - block_text("Welcome to this week's update!"), - block_text("Here's what's new...") - ) + body=block_text("Welcome to this week's update!"), ) ``` From 15bdfba68af8f3371ad918e284cd73720db94432 Mon Sep 17 00:00:00 2001 From: Jules <54960783+juleswg23@users.noreply.github.com> Date: Fri, 14 Nov 2025 14:41:53 -0500 Subject: [PATCH 13/15] update readmes --- nbmail/compose/README.md | 70 ++++++++++++++++++++++++++++++++++++++++ nbmail/mjml/README.md | 47 ++++++--------------------- 2 files changed, 79 insertions(+), 38 deletions(-) create mode 100644 nbmail/compose/README.md diff --git a/nbmail/compose/README.md b/nbmail/compose/README.md new file mode 100644 index 0000000..6d5a9d8 --- /dev/null +++ b/nbmail/compose/README.md @@ -0,0 +1,70 @@ +# nbmail.compose + +The `compose` module provides a high-level, Pythonic API for building HTML emails using simple building blocks. It's designed for data science workflows where you need to create professional-looking emails without writing raw HTML or MJML. + +## Quick Start + +```{python} +from nbmail.compose import compose_email, block_text, block_title, create_blocks + +# Simple email +email = compose_email( + body=block_text("Hello world!") +) + +# Email with multiple blocks +email = compose_email( + title="Weekly Report", + body=create_blocks( + block_text("Welcome to this week's update!"), + block_text("Here's what's new...") + ) +) +``` + +## Architecture + +The compose module is built on three main concepts: + +1. **Blocks** - Individual content units (text, images, plots, spacers, markdown) +2. **BlockList** - Collections of blocks that can be rendered together +3. **compose_email()** - The main entry point that converts blocks to MJML and then to Email objects + +### Why Blocks? + +Blocks provide a simple, composable abstraction over MJML's more complex tag structure. They encapsulate common email patterns (text sections, images, spacers) in an easy-to-use API. + +### MJML Under the Hood + +Blocks compile to MJML tags internally. MJML provides: +- Responsive design by default +- Cross-client compatibility +- Semantic structure (sections, columns, etc.) + +### Inline Attachments + +Local images are read as bytes and stored in `Email.inline_attachments` as base64 strings. During sending, these are converted to CID references in the MIME structure. + +### Jupyter Display Support + +Blocks and BlockLists implement `_repr_html_()` for rich display in notebooks: + +```python +# In a Jupyter notebook: +block = block_text("Preview me!") +block # Automatically renders as HTML +``` + +## Dependencies + +**Required:** +- `markdown` - For Markdown processing in text blocks + +**Optional:** +- `plotnine` - For `block_plot()` functionality + + + +## API Reference + +Complete API documentation is available in the [nbmail reference docs](https://posit-dev.github.io/nbmail/reference/). diff --git a/nbmail/mjml/README.md b/nbmail/mjml/README.md index b865f9c..edd397c 100644 --- a/nbmail/mjml/README.md +++ b/nbmail/mjml/README.md @@ -16,13 +16,13 @@ This module provides Python functions for creating MJML markup, the responsive e This module is part of the `nbmail` package: -```python +```{python} from nbmail import mjml as mj ``` ## Quick Start -```python +```{python} from nbmail.mjml import mjml, body, section, column, text # Build an MJML email structure @@ -49,7 +49,7 @@ These tags accept children (other MJML components) and optional content: - Configuration: `attributes`, `breakpoint`, `font`, `html_attributes`, `style`, `title` Example: -```python +```{python} from nbmail.mjml import mjml, body, section, column, text # Build an MJML email structure @@ -78,7 +78,7 @@ These tags accept text or HTML content but **not** MJML children: - `carousel_image` - Carousel images Example: -```python +```{python} text( content="Bold text and a link", attributes={"font_size": "16px", "color": "#333333"} @@ -90,34 +90,14 @@ button( ) ``` -## Core Classes - -### `MJMLTag` - -The base class for all MJML elements. Can be instantiated directly or via helper functions. - -```python -from nbmail.mjml import MJMLTag - -tag = MJMLTag( - "mj-text", - content="Hello", - attributes={"color": "#ff6600"} -) -``` - -### `TagAttrDict` - -A dictionary type for tag attributes. - ## Examples ### Simple Email -```python +```{python} from nbmail.mjml import mjml, head, body, section, column, text, title -email = mjml( +mjml_email = mjml( head( title(content="Welcome Email") ), @@ -133,7 +113,7 @@ email = mjml( ### Multi-column Layout -```python +```{python} from nbmail.mjml import body, section, column, text, image layout = body( @@ -154,7 +134,7 @@ layout = body( ### Using Attributes -```python +```{python} from nbmail.mjml import section, column, text # Using attributes parameter @@ -166,18 +146,9 @@ section( ) ``` -## Rendering - -Use the `.render()` method to convert MJML structures to markup: - -```python -mjml_markup = email.render() -# Pass to MJML API or tool for HTML conversion -``` - ## API Reference -For detailed documentation of all tags and their attributes, see the [API Reference](https://posit-dev.github.io/email-for-data-science/reference/). +For detailed documentation of all tags and their attributes, see the [API Reference](https://posit-dev.github.io/nbmail/reference/). ## Resources From 2a8257b1b9d262b3d51678ac201b4423bb578c99 Mon Sep 17 00:00:00 2001 From: Jules <54960783+juleswg23@users.noreply.github.com> Date: Fri, 14 Nov 2025 14:43:59 -0500 Subject: [PATCH 14/15] update code blocks to render with syntax highlighting --- nbmail/compose/README.md | 2 +- nbmail/mjml/README.md | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/nbmail/compose/README.md b/nbmail/compose/README.md index 6d5a9d8..3935cbd 100644 --- a/nbmail/compose/README.md +++ b/nbmail/compose/README.md @@ -4,7 +4,7 @@ The `compose` module provides a high-level, Pythonic API for building HTML email ## Quick Start -```{python} +```python from nbmail.compose import compose_email, block_text, block_title, create_blocks # Simple email diff --git a/nbmail/mjml/README.md b/nbmail/mjml/README.md index edd397c..c92d1a2 100644 --- a/nbmail/mjml/README.md +++ b/nbmail/mjml/README.md @@ -16,13 +16,13 @@ This module provides Python functions for creating MJML markup, the responsive e This module is part of the `nbmail` package: -```{python} +```python from nbmail import mjml as mj ``` ## Quick Start -```{python} +```python from nbmail.mjml import mjml, body, section, column, text # Build an MJML email structure @@ -49,7 +49,7 @@ These tags accept children (other MJML components) and optional content: - Configuration: `attributes`, `breakpoint`, `font`, `html_attributes`, `style`, `title` Example: -```{python} +```python from nbmail.mjml import mjml, body, section, column, text # Build an MJML email structure @@ -78,7 +78,7 @@ These tags accept text or HTML content but **not** MJML children: - `carousel_image` - Carousel images Example: -```{python} +```python text( content="Bold text and a link", attributes={"font_size": "16px", "color": "#333333"} @@ -94,7 +94,7 @@ button( ### Simple Email -```{python} +```python from nbmail.mjml import mjml, head, body, section, column, text, title mjml_email = mjml( @@ -113,7 +113,7 @@ mjml_email = mjml( ### Multi-column Layout -```{python} +```python from nbmail.mjml import body, section, column, text, image layout = body( @@ -134,7 +134,7 @@ layout = body( ### Using Attributes -```{python} +```python from nbmail.mjml import section, column, text # Using attributes parameter From 3e2b0c1ad48f826ccc907f580f02dca6c80097ce Mon Sep 17 00:00:00 2001 From: Jules <54960783+juleswg23@users.noreply.github.com> Date: Fri, 14 Nov 2025 15:08:26 -0500 Subject: [PATCH 15/15] remove redundant comments --- nbmail/compose/blocks.py | 11 +---------- nbmail/compose/compose.py | 8 -------- nbmail/compose/inline_utils.py | 1 - 3 files changed, 1 insertion(+), 19 deletions(-) diff --git a/nbmail/compose/blocks.py b/nbmail/compose/blocks.py index a5fc184..53eabea 100644 --- a/nbmail/compose/blocks.py +++ b/nbmail/compose/blocks.py @@ -202,7 +202,6 @@ def block_text( column( mjml_text(content=html, attributes={"align": align}), ), - # attributes={"padding": "0px"}, ) return Block(mjml_section) @@ -248,7 +247,6 @@ def block_title( attributes={"align": align}, ) ), - # attributes={"padding": "0px"}, ) return Block(mjml_section) @@ -285,7 +283,6 @@ def block_spacer(height: str = "20px") -> Block: column( mjml_spacer(attributes={"height": height}), ), - # attributes={"padding": "0px"}, ) return Block(mjml_section) @@ -354,32 +351,26 @@ def block_image( - Images from local files are converted to inline attachments with CID references during email processing by mjml_to_email(). - If `float` is not "none", it takes precedence and overrides `align`. - """ - # Convert integer width to CSS string + """ if isinstance(width, int): width_str = f"{width}px" else: width_str = width - # Determine alignment style based on float and align parameters align_style = "" - # Float takes precedence if not "none" if float != "none": align_style = f"float: {float};" else: - # Use align parameter if float is "none" if align == "center": align_style = "display: block; margin: 0 auto;" elif align == "left": align_style = "display: block; margin: 0;" elif align == "right": align_style = "display: block; margin: 0 0 0 auto;" - # "inline" has no special style # Detect URL vs local file if _is_url(file): - # For URLs, use as-is src = file else: # For local files, read as bytes for processing by _process_mjml_images diff --git a/nbmail/compose/compose.py b/nbmail/compose/compose.py index 85d84d5..fed0d0c 100644 --- a/nbmail/compose/compose.py +++ b/nbmail/compose/compose.py @@ -134,7 +134,6 @@ def compose_email( ) ``` """ - # Convert sections (header, body, footer) to MJML lists header_mjml_list = _component_to_mjml_section(header) body_mjml_list = _component_to_mjml_section(body) footer_mjml_list = _component_to_mjml_section(footer) @@ -144,13 +143,11 @@ def compose_email( title_block_mjml = block_title(title)._to_mjml() header_mjml_list = [title_block_mjml] + header_mjml_list - # Apply template wrapper if requested if template == "blastula": all_sections = _apply_blastula_template( header_mjml_list, body_mjml_list, footer_mjml_list ) elif template == "none": - # Combine all sections without template all_sections = header_mjml_list + body_mjml_list + footer_mjml_list else: raise ValueError(f"Unknown template: {template}. Use 'blastula' or 'none'.") @@ -174,9 +171,6 @@ def compose_email( ), ) - # TODO: remove, this is for testing purposes - # print(email_structure._to_mjml()) - email_obj = mjml_to_email(email_structure) return email_obj @@ -211,7 +205,6 @@ def _component_to_mjml_section( return component._to_mjml_list() elif isinstance(component, str): - # Convert string to BlockList and then to MJML block_list = BlockList(component) return block_list._to_mjml_list() @@ -268,7 +261,6 @@ def apply_blastula_styles(sections: list[MJMLTag]) -> list[MJMLTag]: for section in body_sections ] - # Wrap body with styling body_wrapper = wrapper( *styled_body, attributes={ diff --git a/nbmail/compose/inline_utils.py b/nbmail/compose/inline_utils.py index d0d5a2a..7eece1c 100644 --- a/nbmail/compose/inline_utils.py +++ b/nbmail/compose/inline_utils.py @@ -144,7 +144,6 @@ def _read_local_file_as_data_uri(file: str) -> str: with open(file_path, "rb") as f: file_bytes = f.read() - # Encode to base64 b64_string = base64.b64encode(file_bytes).decode("utf-8") # Guess MIME type