mailrender

package
v0.0.0-...-41aae8c Latest Latest
Warning

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

Go to latest
Published: Aug 27, 2026 License: AGPL-3.0 Imports: 16 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 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
}

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
}

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