Generate email templates from a spec file - #21
Merged
jbedient-kizen merged 4 commits intoSep 1, 2026
Merged
Conversation
`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.
annaliu-kizen
approved these changes
Sep 1, 2026
jbedient-kizen
merged commit Sep 1, 2026
a6012ca
into
feat/email-template-commands-and-drift-checks
4 checks passed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Builds a complete email template —
craft_jsonand compiledcontenttogether,from one pass over one node tree — out of a JSON spec file:
craft-configis fully offline: no network call, so you can iterate on a designwithout 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_jsonsaid one thing and compiled
contentsaid another, and the drift checks from #19reported green because node ids and text still matched on both sides.
contentcarried nofont-familyat all, so any template without hand-inlinedfont styles rendered in the client's serif fallback
contentcarried noSection/Rowpadding declaration on any template everproduced, so text rendered flush against the canvas edge regardless of
craft_jsonRoot.props.mobileBreaksaid414
Section.container_widthreachedcraft_jsonbut notcontent, so a templaterendered at one width in the builder and another in the inbox
880.0px-style widths, including in the msoper-column
<td>widths.moz-text-htmlrule Gecko clients key column-stacking off was missingwidthsilently meant 150px rather than fill-to-containerThe last one is a default-behaviour change: an omitted
widthnow meansfill-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/Rowwidth,container_width, andpaddingare spec-settable andnever computed from one another — confirmed live that a real template's rows are
not uniform (
containerWidthof both580and600, one row with asymmetricpadding, one with all-zero, one with
width: '75'). Padding is four independentstrings 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 awidth: '75'row on a
580container compiles to435px.One commit is a byte-identical no-op
Split email_craft.py into email_craft, email_html, and email_imagesis pure codemotion: 1421 lines into three modules. Verified two ways — a before/after byte
comparison of compiled
contentacross all four block kinds and every layoutvariant (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_blockextraction and two docstring rewordings. Review it asmotion; 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 emitsalongside solid background colours; this emitter has no background-image concept.
Nothing offline can close either gap.