mailrender

package
v0.0.0-...-169de98 Latest Latest
Warning

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

Go to latest
Published: Sep 4, 2026 License: AGPL-3.0 Imports: 17 Imported by: 0

Documentation

Overview

Package mailrender turns a message model into the two body parts an email carries: sanitized, CSS-inlined HTML and a plain-text alternative.

It is a leaf, in the sense internal/calfeed is: pure model to bytes, holding no state, reading nothing from a store, and importing no storage, metamodel or application types. Callers assemble the model from data they have already read (and, from TKT-U2R7GU, already gated); this package only formats it.

The pipeline order is load-bearing

Rendering runs in exactly this order, and the order is a security property, not a convenience:

markdown -> goldmark -> bluemonday(CONTENT ONLY) -> trusted template -> douceur inline

Two verified library behaviors force it (TKT-332QZY design review):

  • bluemonday strips style attributes unconditionally, and AllowStyling does not restore them. Sanitizing after inlining therefore deletes every inlined declaration and produces unstyled mail. Sanitizing the assembled document additionally strips the cellpadding/cellspacing/border/role attributes that table-based email layout depends on, and drops cid: image sources, which would break the embedded logo.
  • douceur performs no CSS value validation whatsoever. It will happily materialize url('javascript:...'), behavior:url(...) and expression(...) into style attributes. Because it runs last, nothing sanitizes its output, so only trusted CSS may reach it.

The consequences for anyone editing this package: sanitize the untrusted fragment, never the assembled document; keep the <style> block operator- and template-authored; and validate every value interpolated into CSS. Reversing any of those is a silent downgrade — mail still sends, it is merely unstyled or unsafe — which is why this is written down rather than left to be rediscovered.

The logo is embedded, and raster only

The operator logo is referenced as cid:<LogoCID> and travels with the message as an attached part, never as a URL: rela serves it from an authenticated endpoint, so a mail client could not fetch it.

Callers must supply raster bytes (PNG/JPEG/WebP) and must NOT pass an SVG. SVG has near-zero support across mail clients — Gmail, Outlook and Apple Mail strip or fail it — and it is an active-content format that can carry a script element, so embedding operator-uploaded SVG would ship script-capable bytes into inboxes for no rendering benefit. A message renders without a logo rather than with an unsafe one.

Trust

Content passed in is UNTRUSTED: it originates from entity bodies and properties. The template and its stylesheet are TRUSTED: they ship with rela. Palette tokens sit in between — operator-supplied, but they land in CSS, so they are validated as colors and rejected otherwise (see ValidatePalette).

Nil: Renderer.Render rejects a nil Message; New rejects a nil Options.

Index

Constants

This section is empty.

Variables

View Source
var ErrNilMessage = errors.New("mailrender: nil message")

ErrNilMessage is returned by Render when handed a nil message.

Functions

func ValidateLang

func ValidateLang(v string) error

ValidateLang checks that v is shaped like a BCP-47 language tag.

The empty string is accepted and means "fall back to the default"; callers resolve that before rendering, so an empty tag never reaches the attribute.

Like ValidatePalette this REJECTS rather than sanitizes. A language tag lands in an HTML attribute, and escaping a malformed one would yield a well-formed document that lies about its language — the failure is worth surfacing to whoever wrote the config or the script.

func ValidatePalette

func ValidatePalette(p map[string]string) error

ValidatePalette checks that every value is a color, and returns an error naming the first key that is not.

This is not defensive tidiness. Palette values are interpolated into the stylesheet, and douceur — which runs last and validates nothing — will materialize whatever it finds into a style attribute. A token of url('javascript:alert(1)') reaches the recipient's mail client verbatim. So values are checked against an ALLOWLIST and REJECTED; they are never escaped or silently replaced with a default, because a caller that supplied a bad color has a bug worth surfacing.

Types

type Message

type Message struct {
	// Subject is the mail subject. Header-safety (no CR/LF) is the sending
	// package's concern, not the renderer's.
	Subject string

	// Intro is optional markdown shown above the sections. UNTRUSTED.
	Intro string

	// Sections are rendered in order.
	Sections []Section

	// Footer is optional markdown shown below the sections. UNTRUSTED.
	Footer string

	// Lang is the BCP-47 language tag of this message's content, emitted as
	// the <html lang="..."> attribute.
	//
	// It lives on Message rather than Options because language is CONTENT, not
	// branding: one deployment sends a Dutch digest and an English one from the
	// same Renderer, so a renderer-scoped value would mislabel every message
	// but one. Options.DefaultLang supplies the fallback when this is empty.
	//
	// UNTRUSTED — it reaches an HTML attribute and is validated as a language
	// tag (see [ValidateLang]), never escaped into place.
	Lang string
}

Message is the model a caller assembles and this package formats.

type Options

type Options struct {
	// Palette maps CSS custom-property names (e.g. "--accent-color") to color
	// values. Values MUST be colors; see [ValidatePalette]. Keys absent from
	// the map fall back to the built-in defaults.
	Palette map[string]string

	// LogoCID, when non-empty, is the Content-ID of a logo part the caller has
	// attached to the message. The template references it as cid:<LogoCID>.
	//
	// Raster only. See "The logo is embedded, and raster only" in the package
	// doc for why an SVG must never be passed here.
	LogoCID string

	// LogoAlt is the logo's alt text. Defaults to "logo".
	LogoAlt string

	// BaseURL prefixes relative links in rendered output. Mail is read outside
	// the app, so a relative href is dead; callers that emit links should set
	// this.
	BaseURL string

	// LogoWidth and LogoHeight are the logo's intrinsic pixel dimensions,
	// emitted as width/height attributes on the <img>.
	//
	// Attributes rather than CSS because Outlook Windows ignores max-height,
	// so a large logo renders at full size there with no other constraint.
	// Zero omits the attribute rather than emitting width="0".
	LogoWidth, LogoHeight int

	// DefaultLang is the language tag used when a Message does not carry its
	// own. Defaults to "en". This is the only language value that is
	// deployment-scoped, and it is a FALLBACK — see [Message.Lang].
	DefaultLang string
}

Options configures a Renderer. The zero value is usable: it renders with rela's default palette and no logo.

type Renderer

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

Renderer formats messages. It is immutable after construction and safe for concurrent use.

func New

func New(opts *Options) (*Renderer, error)

New returns a Renderer.

Nil: rejected — pass a zero Options rather than nil, so "defaults" is a deliberate choice at the call site rather than an accident.

func (*Renderer) Render

func (r *Renderer) Render(m *Message) (html, text []byte, err error)

Render produces the HTML and plain-text parts of m.

Nil: a nil message is rejected with ErrNilMessage.

type Section

type Section struct {
	// Title is an optional heading rendered above the section.
	Title string

	// Body is markdown. It is UNTRUSTED and is sanitized during rendering.
	Body string

	// Columns are table header labels. Empty means no table.
	Columns []string

	// Rows are table cells, each row aligned to Columns. Cell text is escaped,
	// not markdown-rendered — a table cell is a value, not a document.
	Rows [][]string

	// Links, when set, gives each row an href. A nil or short slice leaves the
	// corresponding rows unlinked.
	Links []string
}

Section is one block of a message body. A Section renders either as a paragraph of prose (Body only) or as a table (Rows non-empty); a Section with both renders the prose above the table.

Jump to

Keyboard shortcuts

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