gsxmail

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 17, 2026 License: MIT Imports: 15 Imported by: 0

README

gsxmail

Write email templates as gosx components. Get pixel-targeted HTML plus a matched plain-text part from one source. Every template is validated against a real client-support matrix before it can render.

gsxmail is a Go library (m31labs.dev/gsxmail) and a CLI (gsxmail). It compiles .gsx templates through the public gosx compiler, lowers them through its own email pipeline, and writes both parts from one tree — so the text part can never drift from the HTML part.

Guarantees

  • Fail-closed. Load rejects a template that mail clients would break.
  • Deterministic. The same props always render the same bytes.
  • Paired by construction. The text part cannot drift from the HTML part; both come from the same tree.

What gsxmail refuses to do

  • No sending. gsxmail renders; your own mailer (SMTP, Resend, a queue) delivers.
  • No JavaScript. Mail clients do not run it, so gsxmail never emits it.
  • No forms. Mail clients cannot submit them reliably; use a CTA link.
  • No list management. Recipient lists, preferences, and unsubscribe tokens stay with your application.
  • No Word-engine emulation in preview. The dev preview approximates webmail; it does not emulate Outlook's Word rendering engine.

The stdlib now covers Shell, Signal, Headline, Panel/PanelRow, CTA, Button, Columns/Column, Hero, Spacer, Badge, PickList/Item, Footer, Note, Divider, and StatTable/StatRow. Templates can loop over a slice with <Each> and branch on a bool with <If>; a raw-element Custom subtree stays available as an escape hatch for markup the stdlib does not express. It ships the gsxmail render, gsxmail check, and gsxmail import CLI verbs, gsxmail matrix refresh, and the full Load/Render/Check library API. Render's HTML part is hardened, bulletproof markup by default, mechanically proven by a structural verification pass — see "Output contracts" below. A Theme can declare a dark-mode strategy, and a Shell can set a preheader and its own output-contract override — see "Dark mode", "Preheader", and "Shell options" below. Two named themes, TerminalTheme() and LedgerTheme(), ship alongside the neutral DefaultTheme() — see "Named themes" below. The examples/gallery directory holds five complete, golden-tested templates — see "The template gallery" below. gsxmail import reverse-maps an email you already send onto these same components — see "Import from existing HTML" below.

Load runs the full email lint catalog (EM001 through EM112) before it lowers anything. A missing props field, an expression outside the email dialect, a disallowed HTML element, or an unsupported style property all fail Load closed. Each failure carries an exact diagnostic, not a runtime surprise. Render adds its own render-time check: the rendered HTML part must fit the Gmail-clip size budget (see "Size budget" below).

gsxmail dev (the live preview server) lands in a later release. Until then, validate a template with gsxmail check and by rendering it.

Dynamic data: Each, If, and StatTable

<Each of={props.Field} as="name"> iterates a slice props path (or, inside another <Each>, a slice field of the current loop binding) and binds name to the current element for its body. An empty slice renders nothing. <If cond={props.Field}> renders its children only when the bool expression is true; a bare-text child is a check-time error (EM031), so the text twin always has an element to place.

func DraftRecap(props RecapProps) Node {
    return <email.Shell wordmark={props.League} shortCode={props.Code}
        tagline={props.Tagline} title={props.League} lang="en">
        <email.StatTable title="YOUR HAUL //" header={props.HaulHeader}>
            <Each of={props.Haul} as="row">
                <email.StatRow cells={row.Cells} mark={row.IsKeystone} />
            </Each>
        </email.StatTable>
        <If cond={props.HasAutoPicks}>
            <email.Note text={props.AutoPickNote} />
        </If>
        <email.CTA label="SEE THE FULL BOARD →" href={props.BoardURL} />
    </email.Shell>
}

row.Cells and row.IsKeystone read fields off the loop-bound element itself, not off props — the same expression grammar as a props.Field read, resolved against whichever struct <Each> bound. StatTable's header attribute, and StatRow's cells attribute, are both a bare slice-valued path for the same reason a computed slice is not: <Each of={...}>, header={...}, and cells={...} all require one, never a concatenation or a helper call.

A StatRow's mark attribute (a bool expression, defaulting to false when omitted) selects the one row that renders with the accent color in HTML and a leading * in text — the same 1-based "one marked row, or none" semantics internal/emailkit's MarkRow established, derived here from whichever row's mark resolves true first.

A registered helper (Options.Helpers) can appear in any expression hole, including a StatRow's cells/mark or an <If>'s cond: Load checks its registration and arity (EM014/EM015); Render invokes it by reflection against the same map.

Output contracts

Render's HTML part is hardened, bulletproof markup by default: an Outlook ghost table around the 600px card, xmlns:v/xmlns:o and the o:PixelsPerInch DPI fix, MJML's reset <style> block, a role="presentation" invariant on every layout table, and doubled width attributes/CSS on sized elements. Per component:

  • Shell: the ghost table, DPI namespaces, and reset styles above, plus a role="article" accessible wrapper around the card.
  • Headline: the title renders as a semantic <h1> (margins zeroed), not a plain <div>.
  • Panel: each row is a two-cell table row, not two <span>s sharing one cell — Outlook Windows has no display:inline-block.
  • CTA: the button face carries mso-padding-alt, so Outlook draws the visual box the padding gives every other client.
  • StatTable: a real data table (no role="presentation", <th scope="col"> headers) — it holds facts, not layout.
  • Note: a border-left accent bar and a tinted background, marking the aside structurally, never by color alone.
  • Divider: the spacer technique (font-size:0;line-height:0;mso-line-height-rule:exactly), which pins an exact-height rule across clients that a bare border-top div does not.

Signal, PickList, and Footer needed no change: their WP1 markup already met the contract.

New components (WP5.3)
  • Button (variant="primary"|"secondary"|"link", default "primary"). email.CTA is Button's variant="primary" alias: both render byte-identically, in both output contracts, because renderhtml.writeButton routes the primary variant through the same writeCTA function email.CTA always used. "secondary" swaps the solid accent face for a transparent one with a 1px accent border. "link" uses goodemailcode's full-click glyph-spacing technique (an MSO-only hidden run stretched with a negative mso-font-width, so Outlook's whole box — not just the text — is clickable), with an optional width attribute; unset, Write computes an approximate width from the label's own length. A VML roundrect button is not planned: MJML itself does not ship one, and dark-mode transforms recolor VML fills unpredictably (pixel dossier section 4.4).
  • Columns/Column (fluid-hybrid, two to four columns). Each Column is an inline-block, max-width div that stacks under a 480px viewport with no <style> dependency, wrapped in an "[if mso | IE]" ghost table for Outlook, which never applies inline-block at all. Column is a leaf component — an optional image (imgSrc/imgAlt/imgWidth/imgHeight), an optional title, an optional text — not a nested block container.
  • Hero. A full-width retina <img>: src at 2x pixels, width/height at display size (both required), alt mandatory. srcset is not supported (24.39% caniemail support).
  • Spacer (height in pixels, required). An exact-height gap row (font-size:0;line-height:0;mso-line-height-rule:exactly).
  • Badge (text, optional tone="neutral"|"positive"|"warning"|"critical", default "neutral"). A small bordered status label: "positive", "warning", and "critical" are fixed, theme-independent colors (green, amber, red); "neutral" tracks the active theme's own muted token.

Every new component renders one contract regardless of Options.Outlook: none of them carry a WP1 byte stream to protect, so there is nothing for parity mode to preserve. Button's "primary" variant is the one exception, by construction — see above.

Set Options.Outlook: "off" to render the exact WP1 byte stream instead — parity mode, for a consumer with its own byte- or DOM-equivalence test pinned to the old bytes. gsxmail's own gridiron invite fixture, and its DOM-parity test against the hand-written production template, is the worked example: it loads with Outlook: "off" for exactly this reason.

The structural verification pass

gsxmail re-parses its own rendered HTML with a pure-Go tree-sitter HTML grammar (gotreesitter) and mechanically proves the contract holds: zero parse-error nodes (malformed HTML still parses to a walkable tree under tree-sitter's error recovery, so a clean parse is a real guarantee), balanced <!--[if mso]> conditional comments, layout-table nesting under a 12-level cap, and (in hardened mode) that a role="presentation" table never has a <th> descendant. The pass runs over every golden and the full stdlib block corpus in the test suite.

This is a test-layer dependency only. internal/structverify imports gotreesitter; no render-path package (renderhtml, doc, lower, gsxmail itself) and no CLI package does — a module-graph test at the repo root, TestGotreesitterIsolatedFromCorePath, proves it on every run. gotreesitter's default build embeds its full ~206-grammar registry, so linking it into a binary is not free: measure before you import it anywhere outside a test. WP5.2 extends the pass with two more checks: a configured preheader div must carry its full suppression-style stack (EM173), and an "adaptive" dark-mode style layer must carry both its Outlook-app hooks with balanced braces (EM174).

Dark mode

Set Theme.DarkMode to one of three strategies (pixel dossier section 5). Each strategy states its own honest reach: no strategy controls Gmail's forced dark transform. Every strategy is a mitigation, never a claim of control, and the README says so on purpose — state the mitigation, not pixel parity, in your own product copy too.

Parity mode disables the dark-mode style layer entirely, regardless of DarkMode (m2, launch-gate findings). Options.Outlook: "off" (or a Shell's own outlook="off") emits WP1's exact byte stream — WP1 predates every dark-mode strategy here — so a template rendered for pixel- or byte-equivalence testing never carries a "locked" :root rule or an "adaptive" @media block. Render in the hardened contract (the default) to see either one.

  • "none" (the default). Render adds no dark-mode markup at all. A Theme that sets ColorScheme still emits its own meta pair, exactly as WP5.1 shipped.
  • "locked". The Theme itself is dark-native — gridiron's own palette is this case. Render adds a :root{color-scheme:dark} rule to the <style> block and a dark/dark meta pair. Apple Mail 16+ honors the root rule; Gmail's forced transform may still lighten the theme, so keep every color a midtone, never pure black or pure white.
  • "adaptive". The Theme carries both a light presentation (its own fields) and a dark one (Theme.Dark, a DarkPalette). Render emits a light dark meta pair and an @media (prefers-color-scheme:dark) layer that swaps every one of Theme.Dark's nine tokens into its own class hook, at every site the writer emits that token's matching inline color: the page background, the card, borders, panel backgrounds, ink and body-copy text, muted labels, accent text/backgrounds/borders, and footer fine print — plus best-effort [data-ogsc]/[data-ogsb] hooks for Outlook's own app-level inversion. Apple Mail, iOS Mail, and Outlook.com switch cleanly; Gmail ignores the media query and applies its own forced transform regardless.

Load checks a Set's Theme before it renders anything:

  • EM140 (error): "adaptive" requires Theme.Dark.
  • EM141 (error): the active dark palette's ink-on-card and body-on-card pairs must clear 4.5:1 contrast (WCAG AA body text).
  • EM142 (warn): no color in the active dark palette may be pure black or pure white — a forced transform maps extremes the hardest.
  • EM143 (warn): a raw Custom element's literal color that matches neither palette's tokens, under DarkMode: "adaptive" only — it has nowhere to go when the style layer swaps in.
  • EM144 (error): an explicit ColorScheme must agree with what DarkMode implies.

Preheader

Set preheader={...} on <email.Shell> to control the text a mail client shows next to the subject line as the inbox preview. Like MJML's mj-preview, a preheader belongs to the template, not the caller: it is authored on the Shell and can read props the same way any other Shell field does.

Render writes it as a hidden <div>, first inside <body>, with react-email's own shipped suppression styles (display:none; overflow:hidden; line-height:1px; opacity:0; max-height:0; max-width:0) and an alternating &nbsp;/&zwnj; pad tail that brings the decoded text to exactly 150 characters — long enough that no supported client falls back to pulling in body copy. A Shell with no preheader attribute at all triggers EM170 (warn) at Load; a static preheader literal over 150 characters triggers EM171 (error) at Load. A dynamic preheader={props.X} preheader's own length is not known until a real props value resolves it: Render truncates it to 150 characters instead (M4, launch-gate findings) and reports EM200 (warn) in Parts.Diagnostics — visible, not silent, and never a Load-time failure, since the same template can render fine for one props value and overflow for another.

Shell options

Options.Outlook (WP5.1) still selects a Set's default output contract. WP5.2 adds a per-Shell override: set outlook="off" or outlook="ghost-tables" directly on one template's <email.Shell> to pick that template's own contract, regardless of the Set's own default. A Shell that leaves outlook unset keeps using Options.Outlook — every WP5.1 consumer keeps working with no change. outlook must be a static string literal, never a {props.X} expression: the output contract is a structural, compile-time choice, not a per-render one. EM172 (error) rejects anything else.

<email.Shell
    wordmark={props.Product}
    title={props.Product + " receipt"}
    lang="en"
    preheader={"Receipt for order " + props.OrderID}
    outlook="off">
    ...
</email.Shell>

Named themes

DefaultTheme() ("Paper") stays the neutral light default. Two more named themes ship alongside it (pixel dossier section 8.2, WP5.3):

  • TerminalTheme() — dark, mono-forward, green-on-near-black, DarkMode: "locked". Ground #0C100D, card #101611, panel #16201A, border #23402F, accent #33E68C, ink #E8F5EC, muted #7FA28D. It is not gridiron's own aqua/navy palette, which stays unshipped.
  • LedgerTheme() — warm, print-like light, DarkMode: "adaptive" with its own Dark palette. Ground #FBF7EF, card #FFFFFF, border #E7DECB, accent #B4451F, ink #26201A, muted #8A7E6C.

Terminal is dark-native by construction, so it needs no separate swapped-in presentation; Ledger is light-native, so it carries a real companion Dark palette instead. Together the two themes demonstrate both of gsxmail's non-trivial dark-mode strategies with real, shipped themes — see "Dark mode" above for what each strategy actually reaches. Both themes pass EM140-144.

examples/gallery holds five complete templates, each with typed props, a .gsx source, a fixture props JSON file, a byte-exact golden HTML/text pair, and its own README:

Template Components Theme
welcome Shell, Headline, PickList, Button, Footer Paper
magiclink Shell, Headline, Panel, Note, Button Paper
receipt Shell, Badge, Headline, StatTable, Panel, Button, Footer Paper
digest Shell, Hero, Columns, StatTable, Divider, PickList Ledger
alert Shell, Signal, Badge, Note, Button Terminal

receipt is the pixel dossier's own complete worked example (section 8.3); digest and alert render under the two named themes above, so the gallery shows off both an adaptive dark-mode style layer and a dark-native one. receipt/receipt.gsx renders its Badge and Button like this (hardened mode, Paper theme):

<span style="display:inline-block; padding:2px 8px; border:1px solid #2F9E44; border-radius:2px; color:#2F9E44; font-family:'SFMono-Regular',Consolas,Menlo,monospace; font-size:10px; letter-spacing:0.06em; text-transform:uppercase;">PAID</span>

and its text twin: [PAID]. See examples/gallery/README.md for the full table and a longer snippet.

Import from existing HTML

Every other template compiler starts from a blank file. gsxmail import starts from the email you already send:

gsxmail import newsletter.html --out emails/ --name Newsletter

It reads an existing email's rendered HTML — MJML's compiled output, react-email's rendered output, or a hand-written table-soup mail — and reverse-maps it onto the email.* components above. It writes five files into --out:

File Contents
template.gsx The best-effort .gsx source: every row it recognized becomes a named component; every row it does not recognize survives inside a raw email.Custom block instead of being dropped.
props.go The declared props struct, one field per piece of text the mapper judged likely to vary (a Panel value, a headline's lede, the Shell's own wordmark and preheader). Imports nothing beyond the standard library, so it stands alone.
theme.go ImportedTheme(), reproducing the source's own dominant colors as a gsxmail.Theme. This is the one generated file that imports m31labs.dev/gsxmail; delete it (and the gsxmail.Options{Theme: ImportedTheme()} reference at your call site) if you do not want it, or are generating props.go outside a gsxmail module entirely.
props.sample.json The literal values the mapper harvested from the source HTML, so template.gsx renders correctly the moment you load it — no placeholder data to invent first.
IMPORT-REPORT.md The honest accounting: every mapping decision and its confidence, every unmapped node and why, every synthesized props field's own source snippet, what the theme extraction did and did not recover, and a next-steps list.

Parsing never fails closed. gsxmail import parses with gotreesitter's HTML grammar, whose error tolerance is the whole point: an unclosed <td>, a stray <b> with no matching close, or a malformed comment still produces a walkable tree, never a hard parse failure. A node the mapper cannot confidently place — but that could still carry real email content — never gets dropped: it lands inside email.Custom, sanitized just enough to stay lint-clean (a non-allowlisted tag is remapped or unwrapped, class and event attributes are stripped, an unsafe href or a non-https image src is swapped for a placeholder and flagged in the report), and gets a line in IMPORT-REPORT.md explaining why. The one exception (m16, launch-gate findings): a <script>, a <style> block, and a handful of document/head tags carry no email content at all — mail clients strip or never render them — so gsxmail import drops them outright rather than preserving inert markup, and names every one it drops in IMPORT-REPORT.md's own "Dropped entirely" section.

What it recognizes, matching the output contracts above:

Source shape Maps to
A ghost-table/max-width card, one 600px table per compiled section, or a fluid <div style="max-width"> wrapping one email.Shell (+ a Theme literal extracted from its own dominant colors)
A hidden, first-child-of-<body> div with display:none/overflow:hidden The Shell's own preheader
A large or bold heading, optionally followed by one paragraph email.Headline
A padded <td> + <a> (MJML's mso-padding-alt shape), a bordered anchor, or a lone styled link email.Button (primary/secondary/link, by which signal matched)
A table with <th> cells email.StatTable, with its data rows synthesized into an <Each>
A run of two-cell rows — nested in their own sub-table, or stacked directly as the card's own rows email.Panel
2-4 sibling inline-block/table-cell divs email.Columns/email.Column
A lone, sized <img> email.Hero
An empty, fixed-height cell email.Spacer
A content-free border-top rule, or <hr> email.Divider
A border-left-accented block of plain text email.Note
A short, bordered inline span email.Badge (tone inferred from its color)
An <ol>/<ul>, or rows starting with "1.", "2." ... email.PickList
Everything else email.Custom, reported

The product promise is that it just works, then you tune it. Every imported template loads through Load and renders through Render immediately, using the harvested sample props — that guarantee is proven in CI against three checked-in foreign fixtures (importer/testdata/corpus/: an MJML-compiled shape, a react-email shape, and a deliberately crufty legacy table-soup mail with unclosed tags) and against gsxmail's own five gallery templates rendered back to HTML and re-imported, which recover their exact original component sequence. IMPORT-REPORT.md is not an apology; it is the map of what to review before you ship the result.

Build the CLI with -tags 'grammar_subset grammar_subset_html' (the recommended default, "The CLI" section above) to keep gotreesitter's own footprint small: a tagged build measures roughly 24.4 MiB (25,593,933 bytes); an untagged build embeds every one of the ~540 grammars gotreesitter ships and measures roughly 43.0 MiB (45,108,725 bytes) — a 19 MiB cost for grammars gsxmail import never asks for. gsxmail import, and the CLI it ships in, are the only places in this repository that import gotreesitter outside a test file — renderhtml, internal/doc, internal/lower, and gsxmail.go (the render path Load/Render execute) never do, proven by structural_isolation_test.go.

Size budget

Gmail clips an HTML email near 102,400 bytes and hides everything after the cut, including an unsubscribe footer. Render checks the rendered HTML part on every call:

  • Over Options.MaxHTMLBytes (0 selects the default 100,000 bytes) fails closed: Render returns a zero Parts and a *gsxmail.SizeBudgetError carrying EM120's message.
  • Over the fixed 90,000-byte warning line, but still within budget, succeeds: the returned Parts.Diagnostics carries one EM121 warning.
  • Options.MaxHTMLBytes: -1 disables both checks.

A template with an unbounded list — a StatTable fed from a large <Each> — is the shape most likely to cross either line; size it against a realistic worst-case fixture, not just your happy-path preview data.

60-second quick start

  1. Create a module and add gsxmail:

    go mod init example.com/myapp
    go get m31labs.dev/gsxmail
    
  2. Copy the examples/quickstart directory's emails/ folder into your project.

  3. Render it:

    package main
    
    import (
        "os"
    
        "m31labs.dev/gsxmail"
        "example.com/myapp/emails"
    )
    
    func main() {
        set, err := gsxmail.Load(os.DirFS("emails"), gsxmail.Options{Dir: "emails"})
        if err != nil {
            panic(err)
        }
        parts, err := set.Render("WelcomeEmail", emails.WelcomeProps{
            Name:     "Ada",
            Product:  "Acme",
            LoginURL: "https://acme.example/login",
        })
        if err != nil {
            panic(err)
        }
        os.WriteFile("welcome.html", []byte(parts.HTML), 0o644)
        os.WriteFile("welcome.txt", []byte(parts.Text), 0o644)
    }
    

    example.com/myapp/emails is this snippet's own module path plus the emails/ folder you just copied in step 2 — swap the module path for whatever go mod init step 1 actually used. readme_quickstart_test.go compiles this exact snippet, verbatim, against a copy of examples/quickstart/emails in a temporary module on every test run.

See examples/quickstart for the full runnable version, including the template and its props.

The CLI

go install -tags 'grammar_subset grammar_subset_html' m31labs.dev/gsxmail/cmd/gsxmail@latest

gsxmail render WelcomeEmail \
  --dir emails \
  --props emails/welcome.props.json \
  --out .

This writes WelcomeEmail.html and WelcomeEmail.txt. Pass --html - or --text - to stream one part to stdout instead.

The -tags flag above is the recommended default (m6, launch-gate findings): gsxmail import — the one verb that reaches gotreesitter — never asks for any grammar but HTML, so a plain go install with no tags at all pays for gotreesitter's own default of embedding all ~540 grammars it ships, for no benefit this CLI ever uses. binsize_test.go's TestCLIBinarySizeUnderBudget builds the tagged CLI and asserts it stays under 30 MB. Omit the tags only if you are debugging gotreesitter itself against a grammar other than HTML — nothing in gsxmail's own verbs needs one.

gsxmail check
gsxmail check --dir emails
gsxmail check --dir emails --format json
gsxmail check --dir emails --severity error

check runs Load and prints every finding from the email lint (design spec section 8), sorted by file, then line, then column — every finding in one file prints together, in source order. Each line shows the file, line, column, EM code, and exact message. It exits 1 if any finding is error-severity, and 0 otherwise — --severity only narrows what prints, never that exit code. --format json prints the same findings as a JSON array for CI annotations. --severity warn prints warn and error findings; --severity error prints error findings only; the default, --severity all, prints everything.

check never calls the network. It also cannot see your registered helpers: it always runs with an empty Options.Helpers. It cannot tell a genuinely missing helper from one your own program registers correctly. Treat an EM014 or EM015 finding from check as informational. Validate helper bindings with Set.Check() in your own test instead, where Options.Helpers holds your real functions.

gsxmail new
gsxmail new Welcome --dir emails --package emails

Scaffolds one starter template, so a new project has something working to edit instead of a blank directory. Writes three files under --dir (default emails): welcome.gsx (a Shell, Headline, CTA, and Footer over one props struct), welcome.go (the props struct, one field per attribute the template uses, each documented), and welcome.props.json (a sample fixture with every field filled in, ready for gsxmail render or gsxmail check). Welcome follows the same Email suffix convention as import's --name: a bare name gains the suffix, and the generated file's stem lower-cases only the name's first letter (PasswordReset becomes passwordReset.gsx). new refuses to overwrite a file that already exists, so it never clobbers your edits.

gsxmail import
gsxmail import newsletter.html --out emails/ --name Newsletter

Reverse-maps an existing email's rendered HTML onto email.* components: writes template.gsx, props.go, theme.go, props.sample.json, and IMPORT-REPORT.md into --out, and prints the report's own summary. --name sets the generated component's name (Newsletter becomes NewsletterEmail; a name that already ends in Email is kept as-is); it defaults to a name derived from the source document's own <title>. --package sets the generated Go package name (default emails). See "Import from existing HTML" above for the full contract.

gsxmail matrix refresh
gsxmail matrix refresh

This is the one gsxmail command that calls the network. It downloads caniemail's dataset, trims it to the style properties and clients EM101 and EM102 need, and prints the per-client support diffs. Then it rewrites lint/snapshot.json.

Run it from a gsxmail module checkout, and commit the result like any other reviewed change. Nothing else in gsxmail touches the network. Every test runs offline too.

The library API

package gsxmail

type Parts struct {
    HTML        string
    Text        string
    Diagnostics []Diagnostic // EM110 (dropped href), EM121 (size warning), EM200 (preheader truncated)
}

type Options struct {
    Theme        Theme
    Helpers      map[string]any
    MaxHTMLBytes int
    Outlook      string // "" / "ghost-tables" (hardened, default) | "off" (parity); a Shell's own outlook="..." attribute overrides this per template
    Dir          string // the real on-disk directory fsys is rooted at, when known — see "Props type resolution" below
}

func Load(fsys fs.FS, opts Options) (*Set, error)

func (s *Set) Render(name string, props any) (Parts, error)
func (s *Set) Names() []string
func (s *Set) Check() []Diagnostic

type Theme = renderhtml.Theme // gains DarkMode ("none"/"locked"/"adaptive") and Dark *DarkPalette (WP5.2)
type DarkPalette = renderhtml.DarkPalette
func DefaultTheme() Theme  // "Paper": DarkMode "none"
func TerminalTheme() Theme // dark, mono-forward: DarkMode "locked" (WP5.3)
func LedgerTheme() Theme   // warm, print-like light: DarkMode "adaptive" (WP5.3)

type SizeBudgetError struct{ Diagnostic Diagnostic } // Render's EM120

Load compiles every *.gsx file under fsys. It resolves each template's declared props struct with go/types, reading the sibling *.go files in the same directory. Then it runs the full email lint, EM001 through EM112, before it lowers anything.

A template with an error-severity finding makes Load fail. Load returns the complete diagnostic list, as a *LintError, and no Set. Only a template set that clears the lint gets lowered to an internal typed tree. Render then accepts a props struct, for library callers, or a map[string]any, the shape gsxmail render decodes JSON into.

Set.Check returns every finding Load collected, including warnings, for a Set that loaded successfully. Use it to surface EM102-style partial-support warnings in your own CI, without loading twice.

gsxmail still fails closed at render time. An unknown props field, an unsupported expression, or a disallowed href scheme is a returned error. It is never a silently empty or unsafe value. See "Two layers, one guarantee" below for why both checks exist.

Error taxonomy

Every error Load and Render can return wraps one of these sentinel values (polish item 6, launch-gate findings), so you can classify a failure with errors.Is instead of matching an error message's own text, which this package makes no promise to keep stable:

var (
    ErrCompile           error // a *.gsx file does not parse as gosx source at all
    ErrLower             error // a template cleared the lint, but Lower still rejects it
    ErrDuplicateTemplate error // two components across the loaded *.gsx files share one name
    ErrUnknownTemplate   error // Render was given a name Load never found
    ErrPropsMismatch     error // props is a named struct, but not the template's declared one
    ErrNilProps          error // props is a nil pointer to the template's declared props type
    ErrResolve           error // any other doc.Resolve failure at render time
)
parts, err := set.Render("Welcome", nilProps)
if errors.Is(err, gsxmail.ErrNilProps) {
    // handle a nil props pointer specifically
}

*gsxmail.LintError (Load's own fail-closed return) and *gsxmail.SizeBudgetError (Render's own EM120) are not sentinel values — both carry structured data (Diagnostics, a single Diagnostic) a caller inspects directly with errors.As instead, the same pattern the earlier examples on this page already use.

Props type resolution

Load resolves a template's declared props struct by parsing and type-checking the *.go files beside it with go/types. When a props file imports another package — this module, a third-party dependency, even the standard library — that resolution needs to find the enclosing Go module. Set Options.Dir to the same real, on-disk directory string you passed to os.DirFS to build fsys. gsxmail check and gsxmail render always do this for you; a library caller using os.DirFS directly should do the same:

dir := "emails"
set, err := gsxmail.Load(os.DirFS(dir), gsxmail.Options{Dir: dir})

Without Options.Dir, resolution falls back to interpreting the props file's path as relative to the process's own current working directory — it works when that happens to be inside the owning module and fails, with a clear EM192 finding naming the real cause, everywhere else. This matters most for a template gsxmail import generated: its theme.go imports gsxmail itself, and checking that output from a directory outside its module (a CI job whose working directory is the CI root, not the generated package) needs Options.Dir to resolve correctly. Options.Dir makes resolution work regardless of the calling process's own working directory, for any import an ordinary go build from inside that module would also resolve. It does not add general module-graph awareness (go/importer's source mode, not go/packages, is what resolves the import) — a props file reachable only through an unusual layout a plain go build also could not find would still fail, as EM192, never as a misleading EM012.

An unresolvable props type is a Go-source or environment problem, not an email-dialect violation: Load reports it as EM192, once per template, with the real underlying error, and skips the per-field "no such field" checks (EM012) that type would otherwise drive — those would only be noise once the real cause is already known.

The importer package
package importer

type Options struct {
    PackageName  string // default "emails"
    TemplateName string // default: derived from the source's own <title>
}

type Result struct {
    TemplateName    string
    TemplateGSX     string
    PropsGo         string
    SamplePropsJSON string
    Report          *Report
}

func Import(html []byte, sourceName string, opts Options) (*Result, error)

gsxmail import (above) is a thin CLI wrapper around this one function. Call it directly to drive the mapper from your own Go program — a migration script importing a whole directory of legacy templates, for instance — without shelling out. Import never returns an error for malformed or unrecognized markup; the one error case is a byte stream gotreesitter cannot parse into any tree at all. This package is the one place outside a _test.go file that gsxmail imports gotreesitter; see "Import from existing HTML" above.

Two layers, one guarantee

gsxmail checks props twice, on purpose:

  1. Load time (internal/typesafe). Load resolves each template's declared props struct with go/types. It then type-checks every expression against that struct: a missing field is EM012, and a non-scalar field interpolated as text is EM013. This catches almost every problem before a template ever renders.
  2. Render time (internal/doc's Resolve, renderhtml). Render still resolves every field by reflection, against the actual props value it receives. A map[string]any props value, or any mismatch between what Load saw and what Render receives, still fails closed. The HTML writer still re-checks every href scheme too — but a rejected href does not fail closed the way a bad props value does: it drops just that one link (the label still renders, unclickable) and reports it as an EM110 warning in Parts.Diagnostics, visible but not fatal, so one bad optional link in a batch send does not take the whole loop down (M3, launch-gate findings).

Load-time checking is the fast, precise path: it gives a real diagnostic. Render-time checking is the fallback guarantee, and it holds even when Load-time checking cannot reach a value. gsxmail render's CLI path is the clearest example: it decodes JSON into a map[string]any with no named Go type to check.

Two more trust boundaries, stated honestly (m18, m19, launch-gate findings):

  • Theme is trusted, not sanitized. Every Theme field is written straight into an HTML attribute or inline style, unescaped — the same trust level as your own Go source code, not the "checked twice" level above that covers props. Build a Theme from your own literal values or a config file you control; never from props, a request body, or anything a template's own end user could influence.
  • gsxmail is left-to-right only. Every hardened render writes dir="ltr" unconditionally, regardless of Shell.Lang — even a lang set to an RTL language's own BCP-47 tag ("ar", "he", ...) still renders dir="ltr". gsxmail has no RTL layout support today (mirrored padding/alignment, bidi-safe number and punctuation handling); writing dir="ltr" unconditionally is the honest choice, since gsxmail never actually laid out for any other direction to claim otherwise.

gosx version window

gsxmail targets m31labs.dev/gosx v0.42.2. Earlier versions are untested. A compatibility policy across gosx releases, and a CI matrix that runs against both the pinned and the latest gosx version, land with a later work package.

The caniemail snapshot

EM101 and EM102 check style properties against an embedded, dated copy of caniemail's dataset. This build's embedded snapshot is dated 2026-08-10 (m8, launch-gate findings) — read Matrix.SnapshotDate() at runtime for the figure a specific build actually ships, since this line drifts every time someone runs gsxmail matrix refresh without also updating this README. The embedded snapshot covers CSS-property features only, for one default client set:

  • Gmail: web, iOS, Android
  • Apple Mail: macOS, iOS
  • Outlook: Windows desktop, web
  • Yahoo: web

The framework owner has not yet ratified this default. It is the design spec's proposal only. Widen or narrow it with a gsxmail matrix refresh code change.

Every test runs against the embedded snapshot. Only gsxmail matrix refresh fetches fresh data. See "Prior art and attribution" below for the dataset's own license.

Prior art and attribution

gsxmail's hardened HTML contract is not invented from scratch: every technique it emits is the one at least one shipped tool already proved in production, verified against that tool's own compiled source or documentation before gsxmail's writer copied it.

  • MJML — the Outlook ghost-table wrapper, mso-padding-alt button padding, and the shared <style> reset block (#outlook a, mso-table-lspace/rspace, o:PixelsPerInch) all match MJML's own compiled output.
  • react-email — the component-catalog gallery strategy, and the hidden preheader div's suppression styles plus its 150-character whitespace padding, match react-email's shipped Preview component.
  • goodemailcode — the role="button"-aware link-button technique (mso-font-width/mso-text-raise), the role="article" accessible wrapper, and the fluid-hybrid Columns technique all match goodemailcode's own reference markup.
  • Maizzle — informed the post-processing transformer list a hardened build's own pipeline mirrors.
  • Litmus's dark-mode and retina guides — the three-behavior dark-mode client landscape, the [data-ogsc]/[data-ogsb] Outlook-app inversion hooks, and the 2x retina export convention (display-size width/height attributes, Outlook's max-width workaround) all follow Litmus's own published guidance.
  • caniemail (maintained at HTeuMeuLeu/caniemail) — EM101/EM102's client-support matrix is a trimmed, dated copy of caniemail's own dataset. caniemail's data and code are MIT licensed (Copyright (c) 2019 Rémi Parmentier — verified from that repository's own README, not assumed). lint/snapshot.json records the license and this same attribution in its own license and attribution fields, alongside the capture date.

License

MIT. See LICENSE.

Documentation

Overview

Package gsxmail compiles gosx email templates to a deterministic multipart pair: pixel-targeted HTML plus a matched 72-column plain-text twin, from one source tree. See the package README for the full pitch, the component reference, and every guarantee in detail; this file is the short overview godoc shows first.

The pipeline

Load runs three stages over every *.gsx file in an fs.FS, in order, each one gated on the last:

  1. Compile — gosx.Compile parses one *.gsx file's source into a typed IR program. A file that does not parse fails Load closed immediately (ErrCompile), before any other file's own compile even runs.
  2. Check — the email lint (EM001 through EM201) walks every compiled component's tree: disallowed elements and attributes, an expression outside the email dialect, a style property no target client supports, an unknown or missing email.* attribute, and more. A template's declared props struct is resolved with go/types here too, so a missing field or a non-scalar interpolation is a Load-time diagnostic, not a render-time surprise. Every finding across every template accumulates before Load decides anything; an error-severity finding anywhere fails Load closed with a *LintError carrying the complete list, and nothing lowers.
  3. Lower — only once every template has cleared the lint does Lower convert each one's IR into gsxmail's own EmailDoc tree: email.* stdlib tags resolved, <Each>/<If> builtins inlined, a raw-element Custom subtree carried through unmodified. Lower is not itself a fail-closed gate (its own errors, wrapped in ErrLower, are a backstop for a shape the lint's own rules do not yet police, not a second lint pass).

Render then evaluates one EmailDoc against one concrete props value — pure, no clock, no network, no unordered map iteration — and writes both parts from the same resolved tree, so the text part can never drift from the HTML part.

Two check layers, one guarantee

gsxmail checks a template's props twice, on purpose: internal/typesafe resolves the declared props struct with go/types at Load, so most mistakes surface as a precise EM012/EM013 diagnostic before anything ever renders; internal/doc's Resolve re-checks every field by reflection at Render, against the actual value received, so a map[string]any props value (gsxmail render's own CLI path, decoded from JSON with no static Go type to check) or any Load/Render mismatch still fails closed instead of rendering a silently empty or unsafe value. See "Two layers, one guarantee" in the README for the worked example.

Two output contracts

renderhtml.Write (reached through Render) emits one of two HTML contracts, selected by Options.Outlook or a template's own Shell outlook attribute: the hardened, bulletproof default (an Outlook ghost table, doubled DPI-fix widths, a real StatTable data-table contract, and every other per-component rule), or parity mode ("off"), which emits the original byte stream unchanged for a consumer whose own equivalence test pins those exact bytes. See "Output contracts" in the README for the full per-component table.

Package map

The root gsxmail package is the whole public surface: Load, Set, Options, Parts, Diagnostic, Theme, and the sentinel errors below. Its own render path — internal/doc, internal/lower, internal/typesafe, internal/lint, renderhtml, rendertext — is internal, reachable only through Load/Render/Check; none of it is a promise this module keeps across a minor version. importer (m31labs.dev/gsxmail/importer) is the one consumer-facing package outside the root: it reverse-maps existing HTML onto gsxmail's own email.* components for the `gsxmail import` verb, and is the only place besides the CLI that imports gotreesitter.

Index

Examples

Constants

View Source
const Version = "0.1.0"

Version is gsxmail's own release version (semver, no leading "v").

Variables

View Source
var (
	// ErrCompile wraps a gosx compile failure: a *.gsx file under fsys
	// does not parse as valid gosx source at all. Load returns this
	// before ever running the email lint.
	ErrCompile = errors.New("gsxmail: compile error")

	// ErrLower wraps a lower.Lower failure: a template cleared the email
	// lint, but Lower still rejects it (an unsupported root, or a
	// construct — such as <If>/<Each> — the lint recognizes as valid
	// dialect but this release cannot yet render). Load returns this
	// after every template in fsys has already cleared the lint.
	ErrLower = errors.New("gsxmail: lower error")

	// ErrDuplicateTemplate wraps Load's own "template already declared in
	// another file" failure: two components across the loaded *.gsx
	// files share one name.
	ErrDuplicateTemplate = errors.New("gsxmail: duplicate template name")

	// ErrUnknownTemplate wraps Render's own "no template named %q"
	// failure: name does not match any template Load found in fsys.
	ErrUnknownTemplate = errors.New("gsxmail: unknown template")

	// ErrPropsMismatch wraps Render's own props-type-mismatch failure:
	// props is a struct (or a pointer to one) whose own type name differs
	// from the template's declared props type. A map[string]any props
	// value is exempt — it has no named Go type to compare in the first
	// place (the render CLI's own path, since it decodes JSON with no
	// static Go type to target).
	ErrPropsMismatch = errors.New("gsxmail: props type mismatch")

	// ErrNilProps wraps Render's own nil-pointer-props failure: props is
	// a nil pointer to the template's declared props type.
	ErrNilProps = errors.New("gsxmail: nil props")

	// ErrResolve wraps every other doc.Resolve failure at render time:
	// props is neither a struct nor a map[string]any, a field the
	// template reads is unset, an interpolated value is not a string,
	// integer, float, or bool, or an internal resolution error. Most of
	// these are also provable at Load time by the email lint (EM012,
	// EM013) for a named Go props type; ErrResolve is the render-time
	// backstop for the cases Load-time checking cannot reach — a
	// map[string]any props value (no named Go type to check) chief among
	// them. See "Two layers, one guarantee" in the README.
	ErrResolve = errors.New("gsxmail: resolve error")
)

Sentinel errors: every error Load and Render can return wraps one of these, so a caller can classify a failure with errors.Is instead of matching an error message's own text (which this package makes no promise to keep stable). Each sentinel's own doc comment names exactly which call sites wrap it.

Functions

This section is empty.

Types

type DarkPalette

type DarkPalette = renderhtml.DarkPalette

DarkPalette carries the dark-presentation color tokens a Theme's DarkMode "adaptive" strategy swaps to under prefers-color-scheme.

type Diagnostic

type Diagnostic = lint.Diagnostic

Diagnostic is one check-time finding. It is a type alias for lint.Diagnostic, so gsxmail's public API never requires a caller to import package lint directly.

type LintError

type LintError struct {
	Diagnostics []Diagnostic
}

LintError is the error Load returns when the email lint finds at least one error-severity finding in any loaded template: Load fails closed, returning no Set. Diagnostics carries every finding gathered across every template in the fs.FS, including any warnings found alongside the errors — the same list gsxmail check prints.

func (*LintError) Error

func (e *LintError) Error() string

Error lists every error-severity Diagnostic, one per line, in "file:line:col: CODE: message" form.

type Options

type Options struct {
	// Theme supplies the palette, fonts, and metrics the HTML writer
	// inlines. The zero value is replaced with DefaultTheme().
	Theme Theme

	// Helpers registers pure functions callable from templates. Load's
	// lint pass validates every helper call against this map: an
	// unregistered name is EM014, and a registered helper called with the
	// wrong number of arguments is EM015. Render invokes the same map by
	// reflection for every ExprCall hole a template's Load already proved
	// registered and arity-checked.
	Helpers map[string]any

	// MaxHTMLBytes is the Gmail-clip size budget (EM120/EM121): 0 selects
	// the default 100,000 bytes; -1 disables both the error and the
	// warning check. Any other negative value fails Load closed with
	// EM201 instead of making every subsequent Render call fail with a
	// confusing "budget: -5 bytes" EM120. Render enforces the budget on
	// every call's rendered HTML part: over budget is a returned
	// *SizeBudgetError with no Parts; over the warning line but still
	// within budget is a returned Parts with one EM121 entry in
	// Diagnostics. The warning line is normally the fixed 90,000 bytes,
	// but scales to 90% of MaxHTMLBytes when MaxHTMLBytes itself is set
	// below that — otherwise a budget tighter than 90,000 bytes made
	// EM121 permanently unreachable, since EM120 would always fire
	// first.
	MaxHTMLBytes int

	// Outlook selects the HTML output contract every template in this Set
	// renders with, unless a template's own <email.Shell outlook="..."
	// attribute overrides it. "" and "ghost-tables" (the default) emit
	// the hardened, bulletproof markup: an Outlook ghost table, doubled
	// DPI-fix widths, td-pair Panel rows, an <h1> Headline title,
	// mso-padding-alt on the CTA, a real StatTable data-table contract,
	// and the border-left Note / spacer-technique Divider. "off" emits
	// the original byte stream unchanged — the parity mode a consumer's
	// own byte- or DOM-equivalence test can pin.
	//
	// This field is the Set-wide default/fallback: a template whose
	// Shell sets its own outlook attribute always wins over this field
	// for that one template; a Shell that leaves outlook unset keeps
	// using this field.
	Outlook string

	// Dir is the real, on-disk directory fsys is rooted at, when fsys is
	// backed by one (typically the same dir string a caller passed to
	// os.DirFS(dir) to build fsys). Set it whenever you can: without it, a
	// template's declared props type that imports another package — this
	// module, a third-party dependency, even the standard library — only
	// resolves that import correctly when the process's current working
	// directory happens to make a relative-path lookup land on the right
	// place (see typesafe.NewResolverAt's own doc comment for the full
	// explanation). Leave it empty for an in-memory or embedded fs.FS
	// with no corresponding real directory — Load then falls back to
	// that CWD-relative resolution.
	Dir string
}

Options configures a Set.

type Parts

type Parts struct {
	HTML string
	Text string

	// Diagnostics carries any warning Render itself produced for this one
	// call: EM110 (a CTA/Button href failed the allowed-scheme check —
	// the link drops, the label still renders), EM200 (a dynamic
	// preheader over 150 runes was truncated), and EM121 (the HTML part
	// crossed the 90,000-byte warning line but stayed under budget). It
	// is empty on every Render call that has nothing to report. An
	// error-severity finding never lands here: it makes Render return an
	// error instead (see SizeBudgetError), with a zero Parts.
	Diagnostics []Diagnostic
}

Parts is one rendered multipart email.

type Set

type Set struct {
	// contains filtered or unexported fields
}

Set is an immutable, goroutine-safe collection of compiled templates.

func Load

func Load(fsys fs.FS, opts Options) (*Set, error)

Load compiles every *.gsx file under fsys, runs the email lint over the compiled programs, and — only once every template clears the lint — lowers each declared component to an EmailDoc. Load fails closed at either stage: a component gosx cannot compile is a plain compile error; an error-severity lint finding in any template makes Load return the full diagnostic list, as a *LintError, and no Set, without ever lowering anything. A component that clears the lint but that lower.Lower still rejects (an unsupported root, or a construct — such as <If>/<Each> — the lint recognizes as valid dialect but this release cannot yet render) fails Load with that plain error.

Example

ExampleLoad compiles the quickstart example's one template directory and lists every template name it found.

package main

import (
	"fmt"
	"os"

	"m31labs.dev/gsxmail"
)

func main() {
	set, err := gsxmail.Load(os.DirFS("examples/quickstart/emails"), gsxmail.Options{Dir: "examples/quickstart/emails"})
	if err != nil {
		fmt.Println("load error:", err)
		return
	}
	fmt.Println(set.Names())
}
Output:
[WelcomeEmail]

func (*Set) Check

func (s *Set) Check() []Diagnostic

Check returns every finding the email lint produced while loading s, without rendering anything. A successfully loaded Set carries only warning-severity findings: Load already fails closed on every error-severity one, so a Set that exists never has an outstanding error. Check does not see EM014/EM015 findings the standalone gsxmail check CLI could not: both Load and Check see whatever Options.Helpers s was loaded with — that split of responsibilities is a CLI limitation, not a library one; see the README.

Example

ExampleSet_Check runs the email lint over the quickstart example's template and prints how many findings it reported — modeling the practice the README's own quickstart section recommends: run Check in your own CI, without loading twice.

package main

import (
	"fmt"
	"os"

	"m31labs.dev/gsxmail"
)

func main() {
	set, err := gsxmail.Load(os.DirFS("examples/quickstart/emails"), gsxmail.Options{Dir: "examples/quickstart/emails"})
	if err != nil {
		fmt.Println("load error:", err)
		return
	}
	fmt.Println(len(set.Check()))
}
Output:
0

func (*Set) Names

func (s *Set) Names() []string

Names lists every loaded template name, sorted.

func (*Set) Render

func (s *Set) Render(name string, props any) (Parts, error)

Render renders one named template. props must be assignable to the template's declared props type; a mismatch is an error, never a zero. Rendering is pure: no clock, no network, no maps iterated in order. Same Set + same props => same bytes.

Render also enforces the Gmail-clip size budget on the rendered HTML part (Options.MaxHTMLBytes; EM120/EM121): over budget returns a zero Parts and a *SizeBudgetError; over the fixed 90,000-byte warning line but still within budget returns the rendered Parts with one EM121 entry in Parts.Diagnostics.

Example

ExampleSet_Render renders the quickstart example's WelcomeEmail template with a typed props value and confirms both parts carry the recipient's name.

package main

import (
	"fmt"
	"os"
	"strings"

	"m31labs.dev/gsxmail"
	"m31labs.dev/gsxmail/examples/quickstart/emails"
)

func main() {
	set, err := gsxmail.Load(os.DirFS("examples/quickstart/emails"), gsxmail.Options{Dir: "examples/quickstart/emails"})
	if err != nil {
		fmt.Println("load error:", err)
		return
	}
	parts, err := set.Render("WelcomeEmail", emails.WelcomeProps{
		Name:     "Ada",
		Product:  "Acme",
		LoginURL: "https://acme.example/login",
	})
	if err != nil {
		fmt.Println("render error:", err)
		return
	}
	fmt.Println(strings.Contains(parts.HTML, "Ada"))
	fmt.Println(strings.Contains(parts.Text, "Ada"))
}
Output:
true
true

type SizeBudgetError

type SizeBudgetError struct {
	Diagnostic Diagnostic
}

SizeBudgetError is the error Render returns when the rendered HTML part exceeds Options.MaxHTMLBytes (EM120). Unlike a Load-time LintError finding, Diagnostic carries no source position: the budget is a property of one Render call's resolved output, not of template source.

func (*SizeBudgetError) Error

func (e *SizeBudgetError) Error() string

type Theme

type Theme = renderhtml.Theme

Theme carries the palette, fonts, and metrics the HTML writer inlines into every element's style attribute. Themes have no effect on the text part.

func DefaultTheme

func DefaultTheme() Theme

DefaultTheme returns a neutral light theme: the OSS quick start's default, so a fresh gsxmail project does not carry any one product's brand. Its dark-mode strategy is "none".

func LedgerTheme

func LedgerTheme() Theme

LedgerTheme returns a warm, print-like light named theme, DarkMode "adaptive" with its own companion Dark palette. See the README's "Named themes" section for the full palette.

func TerminalTheme

func TerminalTheme() Theme

TerminalTheme returns a dark, mono-forward named theme: green-on-near-black, DarkMode "locked". It is deliberately not any one product's own private brand palette. See the README's "Named themes" section for the full palette and its EM140-144 proof.

Example

ExampleTerminalTheme prints TerminalTheme's own dark-mode strategy and accent color — a dark, mono-forward named theme that needs no Theme.Dark palette, since it is dark-native (DarkMode "locked").

package main

import (
	"fmt"

	"m31labs.dev/gsxmail"
)

func main() {
	theme := gsxmail.TerminalTheme()
	fmt.Println(theme.DarkMode)
	fmt.Println(theme.ColorAccent)
}
Output:
locked
#33E68C

Directories

Path Synopsis
cmd
gsxmail command
Command gsxmail is the gsxmail CLI.
Command gsxmail is the gsxmail CLI.
examples
gallery/alert
Package alert is the gallery's notification template: Shell, Signal, Badge, Note, Button.
Package alert is the gallery's notification template: Shell, Signal, Badge, Note, Button.
gallery/digest
Package digest is the gallery's weekly-digest template: Shell, Hero, Columns, StatTable, Divider, PickList — fluid-hybrid columns and a retina hero, its fixture highlights.
Package digest is the gallery's weekly-digest template: Shell, Hero, Columns, StatTable, Divider, PickList — fluid-hybrid columns and a retina hero, its fixture highlights.
gallery/magiclink
Package magiclink is the gallery's sign-in-code template: Shell, Headline, Panel (mono OTP row), Note (expiry), Button.
Package magiclink is the gallery's sign-in-code template: Shell, Headline, Panel (mono OTP row), Note (expiry), Button.
gallery/receipt
Package receipt is the gallery's complete worked example: Shell, Badge, Headline, StatTable (+Each), Panel (totals), Button, Footer.
Package receipt is the gallery's complete worked example: Shell, Badge, Headline, StatTable (+Each), Panel (totals), Button, Footer.
gallery/welcome
Package welcome is the gallery's onboarding template: Shell, Headline, PickList, Button, Footer.
Package welcome is the gallery's onboarding template: Shell, Headline, PickList, Button, Footer.
quickstart command
Command quickstart is gsxmail's 60-second walkthrough: load one template, render it with typed props, and write both parts to disk.
Command quickstart is gsxmail's 60-second walkthrough: load one template, render it with typed props, and write both parts to disk.
quickstart/emails
Package emails holds the quickstart example's one template.
Package emails holds the quickstart example's one template.
Package importer implements `gsxmail import`: it parses an existing email HTML file — MJML compiled output, react-email output, or hand-written table soup — and reverse-maps it onto gsxmail's shipped email.* components, emitting a best-effort .gsx template, a typed props struct, a sample props JSON fixture, and an honest report of everything it could not place.
Package importer implements `gsxmail import`: it parses an existing email HTML file — MJML compiled output, react-email output, or hand-written table soup — and reverse-maps it onto gsxmail's shipped email.* components, emitting a best-effort .gsx template, a typed props struct, a sample props JSON fixture, and an honest report of everything it could not place.
internal
doc
Package doc defines EmailDoc: the typed block tree gsxmail lowers every template to.
Package doc defines EmailDoc: the typed block tree gsxmail lowers every template to.
lint
Package lint runs the email dialect's check-time rule catalog, EM001 through EM112, over a compiled gosx program, and answers caniemail client-support questions from an embedded, dated snapshot (EM101/EM102).
Package lint runs the email dialect's check-time rule catalog, EM001 through EM112, over a compiled gosx program, and answers caniemail client-support questions from an embedded, dated snapshot (EM101/EM102).
lower
Package lower converts a compiled gosx ir.Program into a gsxmail doc.EmailDoc: it resolves email.* stdlib tags, the <Each>/<If> builtins, and a raw-element Custom subtree escape hatch, inlining every attribute expression into a doc.Expr or doc.FieldPath value.
Package lower converts a compiled gosx ir.Program into a gsxmail doc.EmailDoc: it resolves email.* stdlib tags, the <Each>/<If> builtins, and a raw-element Custom subtree escape hatch, inlining every attribute expression into a doc.Expr or doc.FieldPath value.
structverify
Package structverify re-parses gsxmail's own rendered HTML with gotreesitter's HTML grammar and proves the output contract holds mechanically: zero parse-error nodes, balanced conditional comments, layout-table nesting under the cap, (for hardened-mode output) the role="presentation" / data-table split the contract states, a preheader div (when the rendered HTML has one) that carries its full suppression-style stack, and a dark-mode adaptive style layer (when present) that carries its own required selectors.
Package structverify re-parses gsxmail's own rendered HTML with gotreesitter's HTML grammar and proves the output contract holds mechanically: zero parse-error nodes, balanced conditional comments, layout-table nesting under the cap, (for hardened-mode output) the role="presentation" / data-table split the contract states, a preheader div (when the rendered HTML has one) that carries its full suppression-style stack, and a dark-mode adaptive style layer (when present) that carries its own required selectors.
typesafe
Package typesafe resolves a gsxmail template's declared props struct with go/types and type-checks the email dialect's expression grammar against the resolved fields.
Package typesafe resolves a gsxmail template's declared props struct with go/types and type-checks the email dialect's expression grammar against the resolved fields.
Package renderhtml writes a Resolved EmailDoc to the pixel-targeted HTML part: theme tokens become inline styles, entities decoded by gosx at compile time are re-escaped minimally, and attribute order follows source order.
Package renderhtml writes a Resolved EmailDoc to the pixel-targeted HTML part: theme tokens become inline styles, entities decoded by gosx at compile time are re-escaped minimally, and attribute order follows source order.
Package rendertext writes a Resolved EmailDoc to its 72-column plain-text twin, using the emailkit wrap/column rules: every stdlib block derives its text form from the same resolved values the HTML writer sees, so the two parts cannot drift by construction.
Package rendertext writes a Resolved EmailDoc to its 72-column plain-text twin, using the emailkit wrap/column rules: every stdlib block derives its text form from the same resolved values the HTML writer sees, so the two parts cannot drift by construction.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL