Email in the reader's language

You need: email and i18n composed.

An email is rendered twice, in two places

Sending a transactional email is the call, serving a second language is the setup this assumes, and scheduling by locale is the neighbouring per-recipient decision.

The subject at enqueue, inside a request that knows the reader. The body at send, inside a Workflow with no request on it at all.

So the locale is stored on the job row — and null there means what it means on a user row: nobody chose, render the English.

The shell follows the job. The payload is yours

Seven of the kit’s templates are its own copy and are translated with it: magic link, one-time code, welcome, security alert, invite, password changed, lead capture.

Five are not, because their words arrive as payload: tester nudge, support reply, operational notice, newsletter, marketing campaign.

The surprise, stated out loud

An operational notice enqueued in Spanish builds its subject as a label plus your summary. It takes the label from the Spanish catalog and the summary straight from your payload.

So it delivers a Spanish label in front of an English sentence.

That is correct behavior — the half the kit wrote is translated and the half you wrote is not — and it is a surprise unless somebody says it out loud.

The answer

Localize your own copy before you enqueue.

const summary = c.var.t.t("app/key_rotation.failed", { key });
await enqueue(env, { template: "operationalNotice", locale, payload: { summary } });

A key under your own domain, translated in your own messages, with the same locale you are stamping on the job.

The kit cannot do this for you: a catalog cannot translate a sentence it has never seen.

The subject comes back, so nobody renders it twice

The enqueue result carries the rendered subject — the sentence the job row was written with.

A surface with an administrative trail has to record what it queued, and re-rendering to write it down means restating the theme and the layer stack. A trail that mirrors the kit’s English by hand agrees with the row only until a locale is passed, at which point the template renders the reader’s catalog and the mirror keeps restating English.

But it is the enqueue-time render. The send renders once more at the moment the message leaves and rewrites the column from that — so a scheduled job whose catalog was retranslated in between delivers the new sentence while the recorded value keeps the old one.

Record what you queued. A trail that must reflect delivery reads the row back afterwards.

How the words reach the send Worker

The send Workflow runs in its own Worker, deployed by provisioning. It has no request and no access to your config, so anything it does not carry in its own bundle has to be stamped into it as configuration.

It is built with the kit’s own email copy, in every language the kit is written in. So adding a locale the kit ships costs you a package upgrade and no configuration at all.

What still travels is your diff against it: the email sentences you changed, one variable per locale, and nothing if you changed none.

Why that split exists

A Worker variable holds 5120 bytes.

Held as configuration, the kit’s own Spanish filled 61% of one on its own — static data crossing a config channel on every provision run.

Held in the bundle, the ceiling stops being reachable by anything the kit ships. What remains is a guard on a single override set outgrowing a variable: provisioning refuses that, naming the language and the byte count rather than truncating — because a shortened catalog is a letter half in Spanish and half in English with nothing failing to say so.

Nothing else is bounded this way. Screens are negotiated in the browser and fetched one locale at a time. Errors carry their English on the wire and are translated by the client from a catalog it already holds.

No markup in a catalog value

An email’s subject and its plain-text part are precompiled with escaping off, so a value substituted there goes in verbatim.

Per-timezone sends carry a locale too

Send at 9am wherever they are is one row per recipient with different absolute times — and each row carries its own locale.

The two facts travel together because they are both facts about the reader rather than about the job.

Check it worked

  • A magic link enqueued with a locale arrives in that language
  • A payload-carrying template shows a translated shell and your words — translate them yourself and it is right end to end
  • The enqueue result’s subject matches what the row was written with
  • Adding a kit locale needs no re-provision beyond the package upgrade
  • An oversized override set is refused at provision rather than truncated
ESC