Skip to content

Generate email templates from a spec file - #21

Merged
jbedient-kizen merged 4 commits into
feat/email-template-commands-and-drift-checksfrom
feat/email-template-builder
Sep 1, 2026
Merged

Generate email templates from a spec file#21
jbedient-kizen merged 4 commits into
feat/email-template-commands-and-drift-checksfrom
feat/email-template-builder

Conversation

@jbedient-kizen

Copy link
Copy Markdown
Contributor

Stacked on #19. Review that one first; this diff is against it, not main.

Builds a complete email template — craft_json and compiled content together,
from one pass over one node tree — out of a JSON spec file:

kizen messages templates create --spec-file newsletter.json
kizen messages templates craft-config --spec-file newsletter.json --out-html preview.html

craft-config is fully offline: no network call, so you can iterate on a design
without touching an environment.

Read this before reviewing the diff

Eight defects were found and fixed here before release, and every one of them
passed the test suite at the time.
They are all the same shape: craft_json
said one thing and compiled content said another, and the drift checks from #19
reported green because node ids and text still matched on both sides.

  • content carried no font-family at all, so any template without hand-inlined
    font styles rendered in the client's serif fallback
  • content carried no Section/Row padding declaration on any template ever
    produced, so text rendered flush against the canvas edge regardless of
    craft_json
  • the mobile breakpoint was hardcoded at 480px while Root.props.mobileBreak said
    414
  • Section.container_width reached craft_json but not content, so a template
    rendered at one width in the builder and another in the inbox
  • a float-formatting artifact printed 880.0px-style widths, including in the mso
    per-column <td> widths
  • the .moz-text-html rule Gecko clients key column-stacking off was missing
  • the MJML reset block was missing
  • an omitted image width silently meant 150px rather than fill-to-container

The last one is a default-behaviour change: an omitted width now means
fill-to-container. Fixed-width images are unaffected.

The general lesson, and the thing worth reviewing hardest: any check that
inspects only one of the two stored fields is blind by construction.
New tests
here assert against both.

Layout props

Section/Row width, container_width, and padding are spec-settable and
never computed from one another — confirmed live that a real template's rows are
not uniform (containerWidth of both 580 and 600, one row with asymmetric
padding, one with all-zero, one with width: '75'). Padding is four independent
strings because the wire format has four independent keys; a CSS-style shorthand
would be lossy over that.

Compiled row width derives as Section.max_width - (paddingLeft + paddingRight)
then scales by Row.width — verified against a live template where a width: '75'
row on a 580 container compiles to 435px.

One commit is a byte-identical no-op

Split email_craft.py into email_craft, email_html, and email_images is pure code
motion: 1421 lines into three modules. Verified two ways — a before/after byte
comparison of compiled content across all four block kinds and every layout
variant (identical once node ids are normalised out), and an AST-level diff of
every top-level function and constant, which found only the intended
_assemble_email_block extraction and two docstring rewordings. Review it as
motion; if anything in it looks like behaviour, that's a bug worth flagging.

Reviewing

19 files, +5267/-70, 4 commits. Commit-by-commit is much easier than the combined
diff.

Still unverified, and stated as such in the code: no test send has been opened
in classic Outlook for Windows — the Word rendering engine, the only one that cares
about the mso/VML markup here. Outlook for Mac is WebKit and would render this
correctly whether or not the mso path is right, so it can't stand in. The section
wrapper also deliberately skips the VML <v:rect>/<v:fill> fallback Kizen emits
alongside solid background colours; this emitter has no background-image concept.
Nothing offline can close either gap.

`kizen messages templates create --spec-file <f>` builds a complete
email template — `craft_json` and the compiled, Outlook-safe `content`
HTML — from one declarative spec; `update <tmpl> --spec-file <f>`
rewrites an existing one the same way, as an alternative to that
command's raw `--craft-json-file`/`--content-file` PATCH path. Both
fields come from one pass over one node tree, so a spec can never
describe one without the other — no flag and no spec key accepts a raw
`craft_json` or `content` value.

A spec's rows pick one of 4 column layouts by name and cells hold
text/image/button/divider blocks, both closed sets, so an unsupported
layout or block kind is a clear error rather than a silent partial
template. An image block names a local PNG/JPEG file; it's uploaded
publicly readable (`is_public=true` on `POST /api/s3/success`) so
recipients can actually load it, and its pixel dimensions are read from
the file's own header bytes, no new dependency. `--dry-run` resolves
images offline instead of uploading, so it never writes.
`messages templates craft-config` previews the `{craft_json, content}`
pair offline, with `--out-html` to drop the compiled body somewhere a
browser can open it.

Reuses `tools/form_ui.py`'s Root/Section/Row/Cell assembly via two new,
additive hooks (`cell_props`, `block_assembler`); forms/layouts output
is unchanged.

Fixed during review: uploaded images were coming back non-public and
404ing for real recipients (`upload_file()` now takes `is_public`); the
compiled CSS's column-width media query was inverted, so every non-1-
column layout rendered stacked instead of side-by-side in most mail
clients (`.mj-column-per-N` width is now a base rule, with the
mobile-collapse moved into the media query, matching MJML's own
convention); `--dry-run` was performing a real upload with no signal
to the user (dry-run now resolves images offline, same as
`craft-config`).

A real test send opened in Outlook is still the only way to fully
confirm rendering — nothing offline substitutes for that, and it
wasn't done here.
Email template specs can now set the layout knobs a designed
newsletter needs instead of landing at this emitter's fixed defaults:
`Section`/`Row` `max_width`/`container_width`/`padding`, `Divider`
`size`, and `Button` `border_radius`/`padding_left`/`padding_right`/
`alignment`. `padding` is a new `PaddingDef` with four independent
top/right/bottom/left strings, matching the wire format's four
independent `containerPadding*` keys rather than a lossy shorthand —
the reference template shows real asymmetric padding. `form_ui.py`
gains `section_props`/`row_props` hooks mirroring the existing
`cell_props` shape; every new field defaults to today's exact
hardcoded value, so an all-defaults spec's `craft_json` and `content`
are unchanged.

The harder part was `content`, the compiled HTML actually sent: it
had never read any of these values. Row/section width was frozen at a
module-level 880px constant regardless of what `craft_json` said;
`Section`/`Row` padding never rendered at all, on any template; and
`Button.alignment`/`Image.position` had no effect on the compiled
markup. Each was the same failure mode — a field lands correctly in
`craft_json` and never reaches `content` — so `craft_json` and
`content` disagreed about what the template actually looks like,
exactly the two-fields-must-agree failure this surface exists to
prevent. `_row_content_width_px` now derives a row's real pixel width
from its own `containerWidth` or its parent Section's `maxWidth` minus
padding, scaled by `Row.width`; `_padding_css` renders each Section's
and Row's own padding onto its wrapper div; `_render_button` and
`_render_image` now read `alignment`/`position` instead of ignoring
them. A systematic test walks every field this item added and asserts
its effect on `content` directly, with named exemptions only for the
props confirmed to have no rendered representation in Kizen's real
compiler.

docs/specs/email-templates.md and CHANGELOG.md are updated to match.
Compiled `content` (the HTML actually sent) now matches Kizen's own
compiler on every point measured against a real captured reference
template: text blocks carry a real `font-family` via the same
`kizen-text-styles` wrapper Kizen uses (previously `content` had none
at all, so any template without hand-inlined font styles rendered in
the client's serif fallback); the `.moz-text-html` rule Gecko clients
key column-stacking off; the MJML reset block; the mobile breakpoint
now reads `Root.props.mobileBreak` (414, not the hardcoded 480);
`<body>`'s background colour, plus an outer background-table wrapper
for `Section.container_width` (deferred here from the layout-props
change); and `Image` blocks gain a genuine auto-sizing mode — omitting
`width` now fills the parent Section's `containerWidth` instead of
silently defaulting to 150px — with markup matching Kizen's own
attribute/style set exactly. A float-formatting artifact that printed
`880.0px`-style widths, including the mso `<td>` per-column widths,
now prints `880px`.

Auto-mode image sizing is a real default-behaviour change: an omitted
`width` used to mean a fixed 150px image; it now means fill-to-container.
Fixed-width images are unaffected.

Deliberately left open: the outer Section wrapper skips the VML
`<v:rect>`/`<v:fill>` fallback Kizen always emits alongside a solid
background colour, not only for background images — this emitter has
no background-image concept at all, so the risk deferred here is
Outlook's Word rendering engine handling `background-color`
unreliably in general, not just "no background-image support." That
can't be closed by any offline check; it needs a real test send opened
in Outlook desktop, a gap open since the first commit on this surface
and still unmet. Linked-image markup is pinned by a new test but
unverified against a real captured template — the one worked reference
example has no link. The drift test's stale hardcoded 480px breakpoint
is also fixed, but it's `@pytest.mark.drift` and deselected by
default, so the fix itself hasn't been run.
Pure code motion, no behaviour change: `email_craft.py` (1421 lines)
becomes three modules — `email_craft.py` (craft-tree assembly, prop
shapes, spec resolution, the public entry point), `email_html.py`
(everything that compiles `content`, including `ColumnLayout`/
`COLUMN_LAYOUTS`), and `email_images.py` (upload + header-byte
dimension reading). Done now, ahead of the structured-text model that
lands on the same file next, so that change is one clean diff instead
of a refactor tangled with new behaviour.

`ColumnLayout`/`COLUMN_LAYOUTS` live in `email_html.py` even though
`email_craft.py`'s `row()` also reads `.columns` off them: the reverse
placement would create a cycle, since `email_craft.py` already needs
`_compile_html` from `email_html.py` for `build_email_content`.
`email_craft.py` imports `COLUMN_LAYOUTS`/`_compile_html`/
`upload_email_image`/`read_image_dimensions` back by name, so callers
of `email_craft.COLUMN_LAYOUTS` etc. keep resolving with no re-export
shim. `email_html.py` and `email_images.py` are genuine leaves — no
import of `form_ui`, `email_craft`, or each other — so id minting
stays exactly where it already was, `form_ui._new_id()` called from
one place in `email_craft.py`.

`_assemble_email_block`, the largest function in the file at 121
lines, is reduced to a thin dispatcher over four new per-kind helpers
(`_assemble_text_block`/`_assemble_image_block`/`_assemble_button_block`/
`_assemble_divider_block`), each just the extracted branch body with
no logic change. Gives the structured-text change a clean seam to add
a fifth branch instead of growing the dispatcher past 150 lines under
the pressure of also shipping new behaviour.

Only `tests/test_email_craft.py` changes outside the split itself:
four private compile-side symbols it reaches via `ec.` moved to
`email_html.py`, so those 13 call sites now import and use `eh.`
instead. No assertion changed.

Verified with two independent passes: a before/after byte comparison
of compiled `content` across all four block kinds and layout variants
(identical once node ids are normalized out), and an AST-level diff of
every top-level function/constant between the pre-split file and the
union of the three post-split files, which found only the
`_assemble_email_block` extraction and two docstring wording changes
differ — everything else is byte-identical. Full suite matches
baseline exactly: 1336 passed, 4 skipped, 77 deselected, lint/format/
typecheck/extra_checks all clean.
@jbedient-kizen
jbedient-kizen merged commit a6012ca into feat/email-template-commands-and-drift-checks Sep 1, 2026
4 checks passed
@jbedient-kizen
jbedient-kizen deleted the feat/email-template-builder branch September 1, 2026 14:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants