arcature 0.1.1

Arcature: an opinionated full-stack Rust web framework. One package, batteries included.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
# Views

Server-rendered HTML from templates that are Rust code by the time the binary
exists.

`#[derive(Template)]` reads the `.html` file when the crate compiles and emits
the `write!` calls that produce the page. What ships is a function. There is
no template text in the process, no loader, and no evaluator.

## Turning it on

The feature is `views`, and it is off in the framework's default set:

```toml
arcature = { version = "0.1", features = ["views"] }
```

`views = ["dep:askama"]`. It pulls nothing else.

A generated application already has it on -- `arc new` writes `app/views/`,
`templates/layout.html` and `templates/welcome.html`, and lists `"views"` in
the app's `Cargo.toml`. If every screen in the application is an
[Inertia](inertia.md) page, remove the feature and those two directories
together.

| Where | State |
| --- | --- |
| framework `default` | off |
| framework `fullstack` | on |
| generated application | on |

Askama is pinned at `0.16.0` with `default-features = false` and only two of
its features enabled: `derive` and `std`. The omissions have consequences you
can see from application code.

| Askama feature | Consequence of it being off |
| --- | --- |
| `config` | No `askama.toml` is read. The template directory, the syntax and the escaper table are the defaults. |
| `urlencode` | There is no `urlencode` or `urlencode_strict` filter. |
| `serde_json` | There is no `json` or `json_pretty` filter. |
| `code-in-doc` | `#[template(in_doc = true)]` is unavailable. |

`config` and `urlencode` are two of the four askama defaults, so this is a
narrowing, not an unchanged baseline. `config` would add `basic-toml`, `glob`,
`serde` and `serde_derive` to the build-time graph to read a file the framework
does not ship; `urlencode` would add `percent-encoding` for a filter an HTML
template does not need, since escaping is the autoescaper's job.

`askama_axum` is deliberately absent as well. It was folded into askama and
then dropped; the `IntoResponse` impl lives in `src/view/response.rs`, where
it can answer a render failure the way the rest of the framework answers one.

`views` does not imply `observe`. That default matters and it is covered under
[Render failures](#render-failures).

## Why the templates are compiled

This is the decision the feature exists to make, so it is worth arguing rather
than asserting.

A runtime template engine -- minijinja, tera, handlebars -- is two programs
shipped as a library: a parser that turns template text into a tree, and an
evaluator that walks the tree against a context and produces output. Both of
them run inside the request path, because that is when the template is
rendered.

That arrangement is what server-side template injection is. SSTI is not a
parsing bug; it is the engine doing exactly its job on input that reached it
from the wrong direction. If any string a request controls is handed to the
parser -- a template chosen by name from a query parameter, a page fragment
stored in a database and rendered as a template, a subject line assembled with
`format!` and then passed through the engine -- then the evaluator will
evaluate it. An expression language with attribute access and method calls is
one hop from the host process, which is why SSTI is the shortest route there
is from a form field to remote code execution.

The usual answer is discipline: never render user input as a template, audit
the places templates are loaded from, keep the sandbox on. That is a defence,
and a defence is a thing that can be forgotten in one commit.

Askama makes the class of bug unreachable instead. The parser runs in the
proc-macro at build time. The output is Rust. At runtime there is no parser to
reach, no evaluator to abuse, and no template text in the binary to be
substituted -- only the statements the compiler emitted. There is nothing to
forget, because there is nothing there.

The same property has a second effect, which is smaller but is felt daily. A
runtime engine binds names when it renders, so a name the template uses and
the data does not supply is discovered when a page is served. A compiled
template resolves names against the struct's fields when the crate compiles: a
`{{ subtitle }}` with no `subtitle` field is a build failure, not a blank space
on a page nobody looked at.

| | Runtime engine | Compiled templates |
| --- | --- | --- |
| Parser in the request path | yes | no |
| Expression evaluator in the request path | yes | no |
| SSTI | defended against | structurally absent |
| Unknown name in a template | render time | compile time |
| Template must be on disk at runtime | yes | no |
| Editing a template | reload | rebuild |

The last row is the price, and it is a real one. It is paid in full under
[Costs](#costs).

Localization is the one place the framework accepted a runtime parser anyway,
and `src/i18n/mod.rs` states the boundary: a Fluent catalog is a file a
developer wrote and a request never names, supplies or selects. That is a
different input from a template rendering attacker-supplied values.

## Writing a view

A view is a struct whose fields are the values its template names.

```rust,ignore
use arcature::view::Template;

/// `templates/welcome.html`.
#[derive(Template)]
#[template(path = "welcome.html", askama = arcature::askama)]
pub struct WelcomeView {
    pub title: String,
    pub message: String,
}
```

`path` resolves against `templates/` in the crate root -- askama's default
directory, and with the `config` feature off there is no `askama.toml` that
could move it.

`askama = arcature::askama` is not decoration. `#[derive(Template)]` writes
code that says `askama::`, which does not resolve in a crate that depends only
on Arcature. Pointing the derive at the re-export means the application
compiles against the askama the framework pins and cannot drift to a second
version of it. An application that would rather write a bare
`#[derive(Template)]` can add `askama` to its own `Cargo.toml`; the price is a
version number to keep in step by hand.

For a template short enough to read in place, `source` and `ext` replace
`path`:

```rust
use arcature::view::{Template, view};

#[derive(Template)]
#[template(
    source = "<h1>{{ title }}</h1>",
    ext = "html",
    askama = arcature::askama
)]
struct Welcome {
    title: String,
}

let html = view(Welcome { title: "Hello".into() }).render().unwrap();
assert_eq!(html, "<h1>Hello</h1>");
```

The `#[template(..)]` keys this chapter relies on:

| Key | Meaning |
| --- | --- |
| `path` | template file, resolved under `templates/` |
| `source` | template text written inline; requires `ext` |
| `ext` | the extension `source` should be treated as having |
| `escape` | override the escaper the extension would select |
| `askama` | the path to the askama crate the derive should name |

### The template

The scaffold ships a base and one page that extends it. `templates/layout.html`:

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>{% block title %}Acme{% endblock %}</title>
  </head>
  <body>
    {% block content %}{% endblock %}
  </body>
</html>
```

`templates/welcome.html`:

```html
{% extends "layout.html" %}

{% block title %}{{ title }}{% endblock %}

{% block content %}
<main>
  <h1>{{ title }}</h1>
  <p>{{ message }}</p>
</main>
{% endblock %}
```

Template syntax, inheritance and filters are askama's, and `arcature::view`
puts nothing in front of them. It is a seam, not a wrapper. The askama crate
is re-exported as `arcature::askama`, and `Template` -- both the trait and the
derive macro, which share one name -- comes from `arcature::view::Template` or
from the prelude.

## arc make:view

```sh
arc make:view admin/receipt
```

writes two files:

| Path | Contents | Registered in a `mod.rs` |
| --- | --- | --- |
| `app/views/admin/receipt_view.rs` | `pub struct ReceiptView` with `#[template(path = "admin/receipt.html", askama = arcature::askama)]` | yes -- `pub mod receipt_view;` |
| `templates/admin/receipt.html` | a template extending `layout.html` | no |

The struct is the file stem plus `View`; the template keeps the base name,
because `path` names a template and nothing in askama makes it a type's name.
The generated struct has two fields, `title: String` and `message: String`, and
the generated template uses both.

Only the Rust half is declared to rustc. There is no `templates/admin/mod.rs`,
because `templates/` is read by the askama derive rather than walked by the
compiler, and a `mod.rs` there would be a Rust file in a directory that has no
Rust in it.

Two files rather than one is the point of this generator. Askama reads the
template when the crate compiles, so a view struct whose `path` names a file
that is not there is not a scaffold with a gap in it -- it is a compile error,
and the project stops building until somebody writes the half the generator
declined to. The pair is the artifact.

## Rendering a view as a response

`view(template)` wraps a template value; `View::new(template)` is the same
thing with a name that is easier to use in generic code.

```rust,ignore
use arcature::prelude::*;

use crate::app::views::WelcomeView;
use crate::bootstrap::AppState;

pub struct HomeController;

#[controller]
impl HomeController {
    /// `GET /welcome`
    pub async fn welcome(State(state): State<AppState>) -> Result<Response> {
        Ok(view(WelcomeView {
            title: state.app_name.clone(),
            message: "Rendered on the server.".to_string(),
        })
        .into_response())
    }
}
```

The return type is `Response`, not `Page<..>`: there is no client component
behind a view, and the HTML is finished when it leaves the server.

A fresh `View` carries three things a compiled template does not know:

| Property | Default | Set with |
| --- | --- | --- |
| status | `200 OK` | `.status(StatusCode)` |
| content type | `text/html; charset=utf-8` | `.content_type(HeaderValue)` |
| `Content-Language` | **absent** | `.in_locale(&Locale)` (feature `i18n`) |

HTML is the default rather than a guess from the template's extension because
askama 0.16 does not keep the extension on the compiled type -- `Template` has
no `MIME_TYPE` to read. A view over a `.txt` or `.xml` template has to say so:

```rust
use arcature::axum::http::HeaderValue;
use arcature::prelude::*;
use arcature::view::{Template, view};

#[derive(Template)]
#[template(
    source = "User-agent: *\nDisallow: {{ path }}\n",
    ext = "txt",
    askama = arcature::askama
)]
struct Robots {
    path: String,
}

let response = view(Robots { path: "/admin".into() })
    .content_type(HeaderValue::from_static("text/plain; charset=utf-8"))
    .into_response();

assert_eq!(response.headers()["content-type"], "text/plain; charset=utf-8");
```

The rest of the surface is small: `.render()` produces a `String`,
`.template()` borrows the wrapped value, and `.into_template()` gives it back.
`View<T>` is `Debug + Clone` and `#[non_exhaustive]`.

`arcature::view` exports `View`, `ViewError`, `view` and `Template`; the crate
root re-exports `View`, `ViewError` and `view`; the prelude carries `Template`,
`View` and `view` under the `views` feature, which is why the controller above
imports nothing else.

### Render failures

`ViewError` has one variant, `Render { source: askama::Error }`. That is the
whole runtime failure surface: askama resolved the parse and the names at build
time, so what is left is a value whose `Display` impl returned `Err`, or a
writer that refused the bytes.

When `IntoResponse` hits one, the response is a plain `500` with the
framework's ordinary internal error body. It says nothing, and it is worth
being precise about what "nothing" excludes, because the obvious
implementation leaks all three:

* the template's own text, which is application source, and on an error page
  is often the half somebody was mid-edit;
* the template's path, which is a map of the source tree and of the filesystem
  the process runs on;
* the value that would not format -- whatever the failing `Display` had
  already written before it gave up, plausibly a session token or a database
  row.

The conversion `From<ViewError> for Error` produces `Error::Other("view
rendering failed")`, which answers status `500` with code `internal_error`.
There is no development-mode variant that shows more, because there is no
build in which a template's contents are a reasonable thing to send to a
browser. A `Content-Language` a handler declared does not survive onto the
failure response either: the body is the framework's error document, not the
page that failed.

The askama message goes to `tracing::error!` -- and this is where the feature
graph bites. `tracing` arrives with `observe`, and `views` does not imply it.
**In a build with `views` and without `observe`, the askama message is
discarded with nothing recorded anywhere.** The client still gets its
uninformative `500`; the operator gets silence. If you enable `views`, enable
`observe`.

### Declaring a language

`in_locale` exists only under the `i18n` feature and sends `Content-Language`:

```rust,ignore
let response = view(Greeting { locale: locale.clone() })
    .in_locale(&locale)
    .into_response();
```

The framework does not infer this header, in either direction. A compiled
template carries no language -- askama resolved it to `write!` calls -- and the
locale `LocaleLayer` negotiated is what the request *asked* for, which is not
the same claim as what the bytes in the response are actually in. A handler
that renders a French template says so; one that renders a template it did not
translate says nothing, which is better than an untrue header.

Translation itself stays in the template: give the struct a `Locale` field and
call it. There is no filter and no `{{ t("key") }}` syntax, because adding one
would mean a lookup the compiler cannot check -- the opposite of the reason
this module exists.

## Mail bodies from the same templates

With `views` on, the [Mail](mail.md) builder grows two terminators that take
compiled templates instead of strings.

```rust
use arcature::mail::Email;
use arcature::view::Template;

#[derive(Template)]
#[template(
    source = "Hello {{ name }}, your invoice is ready.",
    ext = "txt",
    askama = arcature::askama
)]
struct InvoiceText {
    name: String,
}

#[derive(Template)]
#[template(
    source = "<p>Hello {{ name }}, your invoice is ready.</p>",
    ext = "html",
    askama = arcature::askama
)]
struct InvoiceHtml {
    name: String,
}

let message = Email::builder()
    .from("Billing <billing@example.com>".parse().unwrap())
    .to("ada@example.com".parse().unwrap())
    .subject("Your invoice")
    .templated(
        &InvoiceText { name: "Ada".into() },
        &InvoiceHtml { name: "Ada".into() },
    )
    .unwrap();

let raw = String::from_utf8(message.formatted()).unwrap();
assert!(raw.contains("multipart/alternative"));
```

| Terminator | Produces |
| --- | --- |
| `templated(plain, html)` | `multipart/alternative` from both templates |
| `templated_with_attachments(plain, html, attachments)` | the same, inside `multipart/mixed` |

Plain first, matching `Email::alternative`.

Both halves are taken in one call on purpose. A `multipart/alternative` mail
carries the same message twice, and the two copies drifting apart is the
ordinary way mail templating goes wrong: the HTML half gets the new wording,
the plain half keeps the old, and only the readers on the text client ever see
it. Taking the pair together makes a change to a message a change to a pair.

They are two templates rather than one because escaping is chosen by
extension. The `.html` template escapes its values; the `.txt` one does not.
Rendering a text body through an HTML template would send `&#38;` to somebody
reading plain text.

Both halves render before the message is assembled, so a template that cannot
render stops before a `Message` exists. The error is `MailViewError`:

| Variant | Cause | Becomes |
| --- | --- | --- |
| `Render { source: ViewError }` | either template failed to render | the same generic `500`, through `From<ViewError> for Error` |
| `Build { source: EmailError }` | lettre could not assemble the message | `Error::Mail(..)` |

The render path deliberately goes through the view conversion, so a template's
text cannot reach a response body by way of the mail subsystem either.

### These terminators do not fit `Mailable`

`Mailable::build` is declared `fn build(&self, email: Email) -> Result<Message,
EmailError>`. `templated` returns `Result<Message, MailViewError>`, and there
is no `From<MailViewError> for EmailError` -- `EmailError` is not
`#[non_exhaustive]`, so it cannot grow a variant without breaking every
downstream match.

The consequence is concrete: **a `Mailable` implementation cannot call
`templated` and use `?`.** This does not compile, and no import fixes it.

`arc make:mail` writes a plain-text `Mailable` for that reason, with a comment
saying so. To send a templated mail, build the `Message` outside the trait and
hand it to the mailer:

```rust,ignore
let message = Email::builder()
    .from(from_mailbox)
    .to(to_mailbox)
    .subject("Your invoice")
    .templated(&InvoiceText { name }, &InvoiceHtml { name })?;

mail.mailer().send(&message).await?;
```

`Mail::mailer()` borrows the transport and `Mailer::send(&Message)` takes a
finished message, so this path keeps the configured transport and the capture
mailers used in tests. What it gives up is `Mail::to(..).send(..)` setting
`From` and `To` for you.

## How escaping works, and on what basis

Escaping is selected by the template's extension, at compile time, from a fixed
table. It is not a runtime decision and not a per-value one.

| Extension | Escaper | Effect |
| --- | --- | --- |
| `askama`, `html`, `htm`, `j2`, `jinja`, `jinja2`, `rinja`, `svg`, `xml` | `Html` | five characters replaced with entities |
| `md`, `none`, `txt`, `yml`, and no extension | `Text` | nothing is escaped |
| anything else | none exists | **compile error** |

That last row is worth reading twice. An extension in neither list is not a
silent fall-through to "no escaping" -- the derive fails with `no escaper
defined for extension '...'`. A `.rss` or `.csv` template does not build until
you say what it is, with `escape = "html"` or `escape = "none"`.

The HTML escaper replaces exactly five characters:

| Character | Output |
| --- | --- |
| `"` | `&#34;` |
| `&` | `&#38;` |
| `'` | `&#39;` |
| `<` | `&#60;` |
| `>` | `&#62;` |

So `{{ value }}` in an `.html` template with `<script>alert(1)</script>` in it
produces text on the page and not markup. There is a test in `src/view/mod.rs`
asserting exactly that, and a matching one asserting that a `.txt` template
leaves `a < b` alone, so neither half is taken on trust.

`{{ value|safe }}` opts out. Write it for markup you produced yourself, never
for a value that arrived on a request.

### Escaping is not context-aware

This is the part that a five-character replacement table cannot do, and it is
the same limitation every non-contextual autoescaper has.

The escaper does not know where in the document a value lands. It escapes the
same five characters whether the value is body text, an attribute value, a URL,
or the inside of a `<script>` block. Three consequences follow, and all three
are the template author's to handle:

* **URLs.** `<a href="{{ url }}">` with `url` set to `javascript:alert(1)`
  contains none of the five characters. It is emitted unchanged and it runs.
  Check the scheme in Rust before the value reaches the template.
* **Script blocks.** Inside `<script>`, HTML entities are not decoded the way
  they are in markup, and the five-character table is not a JavaScript string
  escape. Do not interpolate request data into a `<script>` body. Askama's
  `json` filter would be the tool for handing data to JavaScript, and this
  build does not have it -- `serde_json` is one of the askama features
  Arcature leaves off. Encode the value in Rust, or use an Inertia page, which
  is what the prop channel is for.
* **Unquoted attributes.** `<div class={{ value }}>` is injectable through a
  space, since space, `/`, `=` and backtick are all left alone. Quote every
  attribute.

Nothing here is specific to Arcature and nothing here is a defect in askama.
It is the boundary of what "the template escapes its values" means, and a
chapter that did not say so would be lying by omission.

## Costs

Three, and none of them is hypothetical.

### Editing a template means rebuilding

There is no reload. The template is in the binary as emitted code, so a change
to `templates/welcome.html` reaches a running process only after the crate is
compiled again.

`cargo build` does notice. Askama emits a
`const _: &[u8] = include_bytes!("<template path>")` for every template file it
reads, which makes rustc track the file as a dependency of the crate: touching
the `.html` alone is enough to make the next `cargo build` recompile.

**`arc dev` does not notice.** The supervisor's watcher -- see
[The dev loop](dev-loop.md) -- classifies a change, and only three kinds of
file mean anything to it:

| Change | Action |
| --- | --- |
| any `*.rs` | rebuild, then restart |
| `Cargo.toml`, `Cargo.lock` | rebuild, then restart |
| `.env`, `.env.*` | restart, no compile |
| everything else, templates included | nothing |

That filter is deliberate for the frontend -- a `.tsx` or `.css` edit is Vite's
business and costs no Rust rebuild -- and an askama template falls on the same
side of it. Saving a template during `arc dev` produces no rebuild and no
visible change on refresh. Touch any `.rs` file, or run `cargo build`, to pick
it up. (The comment in the generated `app/views/mod.rs` says `arc dev` already
rebuilds on save. It does not.)

### A Dockerfile has to COPY templates before cargo build

`templates/` is a source directory, in the same sense `src/` and `app/` are. It
is read by the compiler, not by the process.

This is the failure mode worth knowing before you meet it: the local build
succeeds, because the templates are on the developer's disk, and the image
build fails on a template it cannot find. The error names a path that plainly
exists, which is what makes it confusing rather than obvious.

The generated `Dockerfile` -- described in full under
[Deployment](deployment.md) -- copies it alongside the rest of the sources:

```dockerfile
COPY src ./src
COPY app ./app
COPY bootstrap ./bootstrap
COPY config ./config
COPY database ./database
COPY routes ./routes
# Askama reads the templates at *build* time and compiles them into the
# binary, so this is a source directory like the ones above, not runtime data.
COPY templates ./templates
RUN cargo build --release --locked
```

The runtime stage copies the binary, `public/` and `storage/`, and no
templates. Nothing at runtime reads them back, and a template shipped into a
production image is dead weight at best.

### Views and Inertia are both on, and that is fine

A generated application serves both. Inertia renders the application -- the
screens behind sign-in, where the client is already loaded and a JSON page
object is the cheap answer. Views render the pages that have to work with no
JavaScript at all: an unsubscribe confirmation, an emailed receipt, an RSS
feed, a marketing page, a fallback error page.

They do not interact. A view is a plain `Response` and never carries a page
object; an Inertia page never goes through `View`. The scaffold demonstrates
the split in one controller: `GET /` returns `Page<HomePage>` and `GET /welcome`
returns `Response` from `view(WelcomeView { .. })`.

The one shared cost is the two directory trees, `app/pages/` plus
`resources/js/pages/` for one and `app/views/` plus `templates/` for the other.
If an application never serves HTML from the server, delete `app/views/` and
`templates/` and drop `"views"` from its feature list; if it never serves an
SPA, the same applies to `inertia`. Keeping both is a choice, not a default you
are stuck with.

## What views do not do

Collected, because a chapter that lists only what works is useless to somebody
deciding whether to depend on this.

* **No runtime template loading.** There is no `render("name", context)` taking
  a template chosen at runtime, and there cannot be one. That is the feature.
* **No hot reload**, and no rebuild from `arc dev` on a template edit.
* **No `askama.toml`.** The `config` feature is off, so the template directory,
  the syntax and the escaper table are the defaults.
* **No `urlencode` filter, and no `json` filter.** Those askama features are
  off too.
* **No content-type inference.** A `.txt` or `.xml` view answers
  `text/html; charset=utf-8` until you call `.content_type(..)`.
* **No `Content-Language` unless you declare one**, and no inference from the
  negotiated locale.
* **No translation filter.** No `{{ t("key") }}`; put a `Locale` on the struct.
* **No context-aware escaping.** Five characters, everywhere, regardless of
  where the value lands.
* **No render-failure detail anywhere without `observe`.** The message is
  dropped, not logged.
* **No `Mailable` integration.** `templated` returns an error type
  `Mailable::build` cannot return.