Markdown and Review Templates

afmail uses one MiniJinja template system for generated Markdown and Review HTML. Both outputs receive the same versioned, typed render documents. Review contains those documents directly; it does not rebuild a second HTML-shaped copy of mail, collection, draft, notes, suggestion, or attachment data.

Workspace Paths

Run:

afmail render templates

The command exports editable overrides under templates/. Language-specific entry templates live under the resolved BCP 47 language:

templates/<language>/
  i18n/catalog.j2
  message/section.md.j2
  triage/view.md.j2
  case/case.md.j2
  case/message.md.j2
  notification/notification.md.j2
  notification/message.md.j2
  status/index.md.j2
  status/message.md.j2
  draft/new.md.j2
  draft/reply.md.j2
  notes/default.md.j2
  notes/merge-section.md.j2
  workspace/AGENTS.md.j2
  workspace/DO_NOT_EDIT.txt.j2
  review/page.html.j2
  review/message.html.j2
  review/draft-editor.html.j2
  review/notes-editor.html.j2

Shared, language-independent layouts and CSS live under:

templates/shared/
  markdown/*.md.j2
  review/*-layout.html.j2
  review/style.css.j2

Built-in packs are complete for en-US, de-DE, es-ES, fr-FR, ja-JP, ko-KR, pt-BR, zh-CN, and zh-TW. workspace.language_bcp47 selects the closest pack. The locale catalog owns translated copy, punctuation, and language-specific macros; these are templates, not fields injected into render data.

A language entry template normally imports its catalog and includes a shared layout. That split is only a default composition. An override may replace the entire language entry template, replace a shared layout, introduce additional partials, change ordering and visibility, or use entirely different Markdown or HTML structure. This keeps per-language freedom without duplicating data. An additional include resolves under templates/<language>/ first and then templates/shared/; use a shared/... include path to target the shared tree explicitly. Absolute paths and parent-directory traversal are rejected.

afmail render refresh rebuilds generated Markdown after an edit. Review templates are loaded when a Review page is rendered, so they do not require a Markdown refresh. If a selected workspace override exists but is invalid, afmail returns template_render_failed; it does not silently substitute a built-in template.

Render Document Contract

The current render-document contract is schema version 2. Its Rust definitions live in src/render_document.rs.

MessageRenderDocument contains:

Message body text exists only once, at message.body_text. Quote analysis contains byte ranges into that string:

{
  "message": {
    "message_id": "message_...",
    "body_text": "Authored text\n\n> quoted text"
  },
  "analysis": {
    "direction": "inbound",
    "action": "reply",
    "body": {
      "segments": [
        {"kind": "authored", "start_byte": 0, "end_byte": 13},
        {"kind": "quoted", "start_byte": 15, "end_byte": 28}
      ]
    }
  }
}

Use the text_range filter to read a segment. Drafts follow the same rule: their full body exists once and authored_body_range identifies the editable compose text.

Collection documents contain one canonical collection plus contained MessageRenderDocument values. Triage documents contain the message document, its relationships, and canonical suggestion facts. Review then contains these same documents:

The editable wrappers add only operational state such as a base hash. They do not copy or rename the contained domain fields.

The remaining language-scoped Markdown scaffolds also receive raw facts, not pre-rendered fragments. In particular, draft/reply.md.j2 receives nullable sender and one raw quoted_body; the built-in template applies markdown_quote. notes/merge-section.md.j2 receives case_uid, other_case_uid, and raw other_body. Workspace instruction templates receive the canonical workspace paths they need.

Render data must remain output-neutral. Do not add localized copy, fallback labels, symbols, CSS classes, formatted dates or byte sizes, escaped strings, display aliases, layout rows, or pre-rendered Markdown/HTML. Templates and their filters own wording, localization, fallback, pluralization, formatting, escaping, markup, ordering, visibility, and layout.

Useful built-in filters include datetime, bytes, text_range, chronological, notification_date_desc, message_item, message_status_key, display_name, initials, asset_path, and the Markdown filters inline, plain, markdown_code, markdown_alt, markdown_fence, markdown_quote, markdown_table, and yaml.

Review HTML and JavaScript

Workspace Review overrides may contain MiniJinja, HTML, and CSS. They may not contain JavaScript, inline event handlers, JavaScript URLs, active embedded content, disabled autoescaping, or safe output. afmail validates every workspace Review template and locale catalog, then validates the composed Review HTML again after rendering. Review forces HTML autoescaping for every included partial regardless of its filename suffix.

afmail injects one trusted, behavior-only runtime after server-side rendering. It performs autosave, submit/cancel, conflict handling, required-confirmation checks, and draft-tab switching. It does not construct page markup and it gets all visible status/error copy from data-copy-* attributes rendered by the language template.

A fully custom Review page can rearrange the UI while retaining behavior by preserving the relevant data-afmail-* hooks from the built-in templates. Forms remain ordinary server-rendered HTML: autosave forms provide their action, base_hash, and canonical field names; decision controls opt into submission with data-afmail-submit-field.

Editing and Verification

  1. Export templates with afmail render templates.
  2. Edit the selected language entry, its i18n/catalog.j2, or a shared layout.
  3. Run afmail render refresh for Markdown changes.
  4. Run afmail doctor to compile and smoke-render every required workspace template.

Use afmail render templates --force only when intentionally replacing all existing workspace overrides with the current built-ins.