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:
- 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.
- 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.
- 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 ¶
const Version = "0.1.0"
Version is gsxmail's own release version (semver, no leading "v").
Variables ¶
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.
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 ¶
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) Render ¶
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. |