navree

Views and templates

MiniJinja templates for mail and other text: where they live, how modules embed them, escaping, filters and reloading.

On this page

Featureview Navree apps are JSON APIs, so they render no web pages. They still need templates: the HTML and plain-text bodies of emails, CSV headers, the text of a notification. navree-view provides them with MiniJinja, a Rust implementation of the Jinja2 template language. If you know Jinja, Twig or Nunjucks, you already know the syntax.

resources/views/mail/invoice.html

Configuration

Add ViewModule to your app. It is global: every module can inject Arc<Views>.

src/app.rs
config/app.toml
KeyDefaultWhat it does
view.dirs["resources/views"]Template directories, searched in order. A directory that doesn't exist is skipped. Relative paths start from the working directory.
view.reloadon in debug builds, off in release buildsPick up changed, new and deleted template files without a restart.

From the environment: APP_VIEW__DIRS=resources/views,vendor/views (comma-separated) and APP_VIEW__RELOAD=false.

In code, pass ViewOptions or a single directory:

Rust

MailModule needs ViewModule in the app, since every mail is rendered from templates.

Where templates live

A template's name is its path relative to a template directory, with / separators: resources/views/mail/invoice.html is mail/invoice.html. Files count as templates when their extension is html, htm, xml, txt, md, j2 or jinja; hidden files and directories are ignored.

Templates can also be embedded in a module's code, so the module works without template files. Register them in the module with m.templates(..), usually with include_str!. The CRM's activities module ships its reminder mail this way:

crates/crm-activities/src/reminders.rs
crates/crm-activities/src/lib.rs

Name embedded templates with their extension (mail/welcome.html): the extension decides the escaping and lets lookups without an extension find them.

When the same name is defined more than once:

  1. the first directory in view.dirs holding the name wins;
  2. any file wins over an embedded template, so an app overrides a module's mail by dropping a file with the same name into resources/views;
  3. among embedded templates, a module's template replaces one with the same name from a module it imports.

Every template is read and compiled when the app boots, off the async workers. A syntax error fails the boot and names the template.

Rendering templates

Inject Arc<Views> and call render(name, context). The context is any Serialize value: a struct, a map or serde_json::json!. Its fields are the template's variables.

src/modules/billing/invoice_text.rs

A name without a template extension tries .html, .txt and .j2, in that order, then the bare name: render("mail/invoice", ..) finds mail/invoice.html. Use the full name to pick one (mail/invoice.txt). views.exists(name) tells you whether a name resolves, for example to send a plain-text part only when one exists.

{% extends %}, {% include %} and {% import %} take full names:

resources/views/mail/invoice.html

A name can't leave its directory: .., absolute paths and backslashes find nothing.

Escaping

Templates whose name ends in .html, .htm or .xml (also followed by .j2) escape HTML in every printed value, so Ada & Co prints as Ada &amp; Co. Mark HTML you trust with |safe. Other templates, .txt included, print values as they are.

Undefined variables

Printing, iterating or filtering a variable the context doesn't define is an error, not an empty string, so a typo can't send a blank email. Testing one is fine and is false:

Jinja

Define every printed field in the context, using null for "no value" (none in the template).

Errors

render returns a ViewError:

  • NotFound lists every name it tried;
  • Template names the template that failed to compile or render, with MiniJinja's message;
  • Io names a template directory or file that can't be read.

Messages never contain context values, since a context may hold tokens or other secrets.

Filters

Every MiniJinja built-in filter is available (upper, default, join, tojson, urlencode, ...), as are {% break %} and {% continue %} in loops. Navree adds two filters.

money

{{ cents|money("USD") }} formats an integer amount in minor units (cents) with the currency's symbol, thousands separators and its number of decimals:

Jinja

USD, EUR, GBP, JPY and INR get their symbol; other currencies get their ISO code. The number of decimals follows ISO 4217 (0 for JPY or KRW, 3 for KWD or BHD, 2 otherwise). none prints nothing. A non-integer amount or a currency that isn't three letters is a render error. There is no locale-aware formatting.

date

{{ value|date("%d %b %Y") }} formats a date with a chrono strftime format, %Y-%m-%d by default. The value can be an RFC 3339 date-time (kept in its own offset), a date-time without an offset (2026-10-25T14:30:00), a date (2026-10-25) or a Unix timestamp in seconds (UTC). none prints nothing.

Jinja

How mail uses templates

Mail renders through the same Views. The quickest mail is a TemplateMail: give it a template name without an extension, and it renders {name}.html and {name}.txt, whichever exist, with your context.

Rust

For more control, implement Mailable yourself: its build(&self, views: &Views) method renders whatever it needs. navree make:mail <module> <name> generates one, with its .html and .txt templates next to it, embedded under <module>/mail/<name>.html and registered in the module (see Console commands and the CLI).

The CRM's daily reminder is a TemplateMail on mail/crm/tasks-due-today, whose text version is:

crates/crm-activities/templates/tasks-due-today.txt

Reloading during development

With view.reload on (the default in debug builds), each lookup checks the template file's modification time and size and picks up changed, new and deleted files, so you can edit a mail's wording without restarting. Templates are still compiled at boot, so syntax errors surface early. This costs a file-system check per lookup, which is why release builds turn it off: there, every template is read once at boot and rendering never touches the disk.

navree dev doesn't rebuild when files under resources/ change, because reloading covers them; embedded templates live in src/, so changing one triggers a rebuild.

Testing

Views::from_templates builds views from (name, source) pairs, with no directories and no app, which keeps template tests fast:

src/modules/billing/tests/mod.rs

To test a module's real templates, pass its TEMPLATES constant: Views::from_templates(TEMPLATES.iter().copied()). To check the mails your code sends, use the mail module's array transport; see Mail.

Popular pages

  • Installation
  • Your first app
  • Routing and controllers
  • Validation
  • Authentication
  • Benchmarks