@nxgt/mail-i18n
| Install | |
|---|---|
npm install @nxgt/mail-i18n |
|
| Latest Version: | 1.0.2 |
| Vue: | 3 |
| License: | MIT |
| Last Updated: | Oct 14, 2026 |
| Links: | GitHub · npm |
@nxgt/mail-i18n
i18n for a normal Maizzle 6 project of transactional
e-mails: write one template per e-mail, its text as keys into
ICU
catalogues, and one maizzle build writes it in every locale. The build also
writes a manifest of each e-mail's placeholders and subjects. A catalogue or a
template that cannot be right fails the build, and the error names the
locale and the key.
// maizzle.config.ts
import { defineMailConfig } from '@nxgt/mail-config';
import { i18n } from '@nxgt/mail-i18n';
export default defineMailConfig({
plugins: [i18n({ locales: ['en', 'fr'], fallbackLocale: 'en' })],
});
Your project stays a Maizzle project: emails/, components/, public/,
maizzle serve, maizzle build. The plugin adds locales/, and gives each
template t, locale, dir and placeholder.
1.x. Semantic versioning: a breaking change waits for the next major, and the changelog says what each release changes.
Install
bun add @nxgt/mail-i18n @nxgt/mail-config @maizzle/framework @maizzle/tailwindcss
Peers:
@maizzle/framework(^6.1.7), required — Maizzle itself.@nxgt/mail-config, required — the i18n plugin is a plugin for itsdefineMailConfig. It needs@maizzle/tailwindcss(^1.5.6) as a direct dependency of your project; see Setup.typescript(6), required. Bundler resolution ("moduleResolution": "bundler") is what is supported and tested;nodenextis out of contract.vue(^3.5), optional — Maizzle already brings it. List it in your ownpackage.jsonwhen you wantt,locale,dirandplaceholdertyped in templates (Editor and type checking).
Runs wherever maizzle build does: Node ^22.22.3, ^24.15.0 or >=26 — the
range Maizzle 6's own dependencies require — or Bun. CI runs the tests on
Bun, and builds the starter under Node 22.22.3.
Setup
# .gitignore — the official starter already has it
.maizzle/
The plugin writes one generated file per template and locale under
.maizzle/emails/. They are rewritten on every run, never edited, and never
committed.
/* in the <style> your layout imports Tailwind from */
@import "@maizzle/tailwindcss";
That import is resolved from your project. Under an isolated install (Bun
workspaces, pnpm), it fails silently unless @maizzle/tailwindcss is in
your own package.json, as the bun add above makes it: the build succeeds,
and no Tailwind utility is generated.
// tsconfig.json — the official starter's include; keep .maizzle/*.d.ts in it
{ "include": ["**/*.vue", ".maizzle/*.d.ts"] }
// package.json — the starter's postinstall
{ "scripts": { "postinstall": "maizzle prepare" } }
Each time the config loads (maizzle prepare, serve, build), the plugin
writes .maizzle/nxgt-mail-i18n.d.ts: the types of t, from your catalogues.
The starter's tsconfig.json does not include maizzle.config.ts, so this
file is how the editor learns them. See
Editor and type checking.
Exports
| Export | What it is |
|---|---|
i18n(options) |
The plugin, for defineMailConfig({ plugins }) |
createTranslator(catalogues, getLanguage) |
t(key, args?, language?) outside templates, shaped like @nxgt/i18n, but it throws on a missing key |
emailKey(email) |
Where an e-mail's messages live: auth/reset-password → auth.reset-password |
MANIFEST_FILE |
'mail-manifest.json', the manifest's name in the output folder |
MANIFEST_FORMAT |
1, the manifest format this version writes as formatVersion: it changes only with the manifest's shape, and @nxgt/mail's renderer reads every format up to its own within 0.x |
WRAPPERS_DIR |
'.maizzle/emails', where the generated files go |
I18nOptions, Layout, TemplateSource |
The plugin's options, 'nested' | 'flat', and a package's folder of templates for templates |
Catalogue, Catalogues, ArgumentKind |
A catalogue as written, catalogues by locale, and 'string' | 'number' | 'date' |
Translate, LanguageProvider, MessageArgs |
What createTranslator answers and takes |
Manifest, ManifestEmail |
The shape of dist/mail-manifest.json |
TemplateMessages, TemplateKey, TemplateArgs |
The keys of t in templates and their arguments, filled from your catalogues by the generated .maizzle/nxgt-mail-i18n.d.ts |
localeDirection(locale), Direction |
The direction ('ltr' | 'rtl') a BCP 47 tag reads in — what the plugin gives each template as dir, usable outside a template too |
Usage
A project in two languages
A catalogue per locale, nested, camelCase or kebab-case keys:
// locales/en.json — locales/fr.json has the same keys
{
"verify-email": {
"subject": "Confirm your e-mail address, {name}",
"title": "Confirm your e-mail address",
"greeting": "Hello {name},",
"expires": "The link expires in {minutes, plural, one {# minute} other {# minutes}}.",
"action": "Confirm my address"
}
}
One template, every language:
<!-- emails/verify-email.vue -->
<template>
<Html :lang="locale" :dir="dir">
<Body>
<Container>
<Heading>{{ t('verify-email.title') }}</Heading>
<Text>{{ t('verify-email.greeting', { name: placeholder('name') }) }}</Text>
<Text>{{ t('verify-email.expires', { minutes: 15 }) }}</Text>
<Button :href="placeholder('link')">{{ t('verify-email.action') }}</Button>
</Container>
</Body>
</Html>
</template>
t(key, args?)formats the message in the locale being built.localeis that locale,'en'or'fr'.diris that locale's direction,'ltr'or'rtl'— derived from the runtime's ownIntl.Locale(Bun'sgetTextInfo(), Node'stextInfo), with a fallback list of right-to-left scripts for a runtime or a locale it cannot answer.@nxgt/mail-ui'sNxLayoutwrites it on<html>and its wrapper table already; see Right-to-left languages.placeholder('name')writes{{ name }}in the built file, for a value that is only known at send time. Write it in text, in an attribute, or pass it as an argument.
maizzle serve lists the template once per locale. maizzle build writes:
dist/
en/verify-email.html <p>Hello {{ name }},</p> … <a href="https://github.com/softistx/nxgt-mail/blob/HEAD/{{ link }}">
en/verify-email.txt
fr/verify-email.html <p>Bonjour {{ name }},</p> … Le lien expire dans 15 minutes.
fr/verify-email.txt
mail-manifest.json
The .txt files come from @nxgt/mail-config's base config. The e-mail is
named after its template's path, without .vue: emails/auth/reset-password.vue
is auth/reset-password, written to dist/<locale>/auth/reset-password.html.
See Templates.
Options
| Option | Type | Default | Effect |
|---|---|---|---|
locales |
readonly string[] |
— (required) | Every locale to build, as BCP 47 tags: ['en', 'fr', 'pt-BR'] |
fallbackLocale |
string |
the first of locales |
The reference catalogue: every other locale must have its keys, and may not add an argument |
dir |
string |
'locales' |
The folder of <locale>.json catalogues (and <locale>/**/*.json, split into folders), relative to where maizzle runs. Not with messages |
messages |
string |
— | A module whose default export is the resources object ({ en: {...}, fr: {...} }), instead of dir. Answers i18n() as a Promise — see Messages from a module |
emails |
string |
'emails' |
The folder of templates |
layout |
'nested' | 'flat' |
'nested' |
nested writes dist/en/verify-email.html; flat writes dist/verify-email.en.html |
catalogues |
readonly Catalogues[] |
[] |
Catalogues a package ships, merged in order under your <locale>.json, key by key |
templates |
readonly TemplateSource[] |
[] |
Folders of templates a package ships, built with yours; your template of the same name wins |
rendererTypes |
string | false |
'generated/mail.ts' |
The .ts module, relative to where maizzle runs, that each build writes MailEmails to, for createMailRenderer<MailEmails>. false writes none |
// maizzle.config.ts — every file of one e-mail side by side
export default defineMailConfig({
plugins: [i18n({ locales: ['en', 'fr'], layout: 'flat' })],
});
A wrong option is a bare TypeError when the config loads:
i18n: fallbackLocale must be one of locales.
Catalogues from a package
A package can ship messages your templates share, such as @nxgt/mail-ui's
common.greeting. List them in catalogues. Your own
locales/<locale>.json is still required for every locale, and wins key by
key:
// maizzle.config.ts
import { defineMailConfig } from '@nxgt/mail-config';
import { i18n } from '@nxgt/mail-i18n';
import { uiCatalogues } from '@nxgt/mail-ui';
export default defineMailConfig({
plugins: [i18n({ locales: ['en', 'fr'], catalogues: [uiCatalogues] })],
});
// locales/en.json — common.footer.why and common.footer.ignore stay the package's
{ "common": { "greeting": "Hi {name}," } }
Splitting catalogues
locales/<locale>.json does not have to hold every message. A file at
<dir>/<locale>/**/*.json is read too, and its path is a key prefix:
locales/
en.json # the keys it always had
en/
mails.json # mails.*
auth/
sign-in.json # auth.sign-in.*
// locales/en/mails.json
{ "welcome": { "subject": "Welcome to the app" } }
is exactly as if locales/en.json held { "mails": { "welcome": { "subject": "Welcome to the app" } } }. Every locale needs the same files, at
the same paths, as the fallback locale. See
Splitting catalogues.
Messages from a module
Instead of dir, messages names a module whose default export is the
resources object, or a function that returns one:
// i18n/messages.ts
import en from './locales/en.json';
import fr from './locales/fr.json';
export default { en, fr };
// maizzle.config.ts — `messages` is read asynchronously
export default defineMailConfig({
plugins: [await i18n({ locales: ['en', 'fr'], messages: './i18n/messages.ts' })],
});
The merged catalogues are checked like your own. A source's locale your project does not build is left out. See Catalogues.
Templates from a package
A package can ship whole e-mails, such as @nxgt/mail-presets. List its
folders in templates, and its messages in catalogues:
// maizzle.config.ts
import { defineMailConfig } from '@nxgt/mail-config';
import { i18n } from '@nxgt/mail-i18n';
import { presets } from '@nxgt/mail-presets';
import { ui, uiCatalogues } from '@nxgt/mail-ui';
const mails = presets({ only: ['verify-email', 'reset-password'] });
// mails.templates → { dir: '/…/node_modules/@nxgt/mail-presets/emails', emails: ['verify-email', 'reset-password'] }
export default defineMailConfig({
plugins: [
ui({ brand: { name: 'Acme' } }),
i18n({
locales: ['en', 'fr'],
catalogues: [uiCatalogues, mails.catalogues],
templates: [mails.templates],
}),
],
});
A TemplateSource is { dir, emails? }: dir an absolute folder that holds
templates, emails a non-empty list of the ones to build (every one when left
out). Your emails/ is read first: emails/verify-email.vue replaces the
package's verify-email. Two packages that ship the same e-mail are refused
when the config loads. See
Templates.
The subject
Every e-mail needs a subject, in every locale: the message
<emailKey>.subject. emailKey turns the template's path into a key:
import { emailKey } from '@nxgt/mail-i18n';
emailKey('verify-email'); // 'verify-email' → verify-email.subject
emailKey('auth/reset-password'); // 'auth.reset-password' → auth.reset-password.subject
The template does not write the subject. The build formats it once per locale
into the manifest, and turns each argument into a placeholder:
"Confirm your e-mail address, {name}" becomes
"Confirm your e-mail address, {{ name }}". A template with no subject fails
the build: i18n: welcome has no subject — add welcome.subject to the catalogues. See Catalogues.
The manifest
dist/mail-manifest.json is written by maizzle build, for the code that
sends. It starts with its format; then, for each e-mail, it records its
placeholders, the ones a link or an image URL starts with, its subject in each
locale, and its files:
{
"formatVersion": 1,
"locales": ["en", "fr"],
"fallbackLocale": "en",
"emails": {
"verify-email": {
"variables": ["link", "name"],
"urlVariables": ["link"],
"subject": {
"en": "Confirm your e-mail address, {{ name }}",
"fr": "Confirmez votre adresse e-mail, {{ name }}"
},
"files": {
"en": { "html": "en/verify-email.html", "text": "en/verify-email.txt" },
"fr": { "html": "fr/verify-email.html", "text": "fr/verify-email.txt" }
}
}
}
}
createMailRenderer from @nxgt/mail/renderer reads it at send time:
createMailRenderer({ dir: 'dist' }).render('verify-email', { name, link })
answers the subject, HTML and text, every value escaped. A newer
@nxgt/mail reads a build from any earlier @nxgt/mail-i18n: see
The manifest — formatVersion.
After the manifest, each build writes generated/mail.ts, in the project — the manifest's e-mails
and variables as a type, rewritten only when they change. Git-ignore it
and build before type-checking — "typecheck": "maizzle build && tsc --noEmit" — as you do for dist/: it is output of the build, like the
e-mails it describes.
// generated/mail.ts — never edited
export interface MailEmails {
"verify-email": { readonly link: string; readonly name: string | number };
}
// in the code that sends
import { createMailRenderer } from '@nxgt/mail/renderer';
import type { MailEmails } from './generated/mail';
const mails = createMailRenderer<MailEmails>({ dir: 'dist' });
mails.render('verify-email', { name: 'Ada', link: 'https://app.example.com/v' });
// mails.render('verify-emial', …) and a missing `link` no longer compile
A URL variable is string, any other string | number. Set
rendererTypes to write it elsewhere, or false for none. See
The manifest and its
renderer's types.
Outside templates — createTranslator
The same catalogues, from your application's code:
import { pickLocale } from '@nxgt/mail';
import { createTranslator } from '@nxgt/mail-i18n';
import en from './locales/en.json';
import fr from './locales/fr.json';
declare const user: { locale: string | null };
const t = createTranslator({ en, fr }, () => pickLocale(user.locale, ['en', 'fr'], 'en'));
t('verify-email.expires', { minutes: 15 }); // in the user's locale
t('verify-email.expires', { minutes: 15 }, 'fr'); // 'Le lien expire dans 15 minutes.'
pickLocale comes from @nxgt/mail (bun add @nxgt/mail); getLanguage
can be any function that returns one of the catalogues' locales.
createTranslator(catalogues, getLanguage) and t(key, args, language?)
have the shape of @nxgt/i18n, with one difference: this t throws
where @nxgt/i18n would answer the key. It throws on a key the catalogue
does not have, on a language with no catalogue, and on a message that does
not format. An e-mail never goes out with verify-email.title or {link} in
it. See Translating outside templates.
Editor and type checking
With the Setup above, Vue's language tools (the Vue - Official
extension in the editor, vue-tsc in CI) complete the keys of t and flag a
call the build would refuse:
<!-- emails/verify-email.vue, with the catalogue above -->
<template>
<p>{{ t('verify-email.titel') }}</p>
<!-- Argument of type '"verify-email.titel"' is not assignable to parameter of type 'keyof TemplateMessages'. -->
<p>{{ t('verify-email.greeting') }}</p>
<!-- Expected 2 arguments, but got 1. -->
<p>{{ t('verify-email.expires', { minutes: placeholder('minutes') }) }}</p>
<!-- Type 'string' is not assignable to type 'number'. -->
</template>
bun add -d vue-tsc vue
bunx vue-tsc --noEmit # after maizzle prepare, as in CI
The types come from the fallback locale's catalogue, merged with the
catalogues option, and refresh on the next config load: saving a catalogue
under maizzle serve, or running maizzle prepare or maizzle build.
Without the generated file, the template checker does not know t,
locale or placeholder at all (Property 't' does not exist); the build is
not affected. See Editor and type checking.
Traps
Leave content to the plugin. It points Maizzle at the generated files. A
content in your config replaces that, and each template then fails with
… is not built through the i18n plugin.
Run maizzle from the project root. dir, emails and .maizzle/emails
are resolved against the directory maizzle runs in.
Nest keys; never dot them. { "verify-email": { "title": "…" } }, not
{ "verifyEmail.title": "…" }, which fails the build as not camelCase or kebab-case.
Never write {{ name }} by hand in a template. Vue would evaluate it.
Write {{ placeholder('name') }}.
Never branch on a placeholder. The template is built before the value
exists: v-if="link.startsWith('https:')" would test the string {{ link }}.
Keep url.base off for links. It would prefix {{ link }} in every
href.
A subject's arguments are plain {name}. They become placeholders, filled
as strings at send time. {count, number} or a select in a subject fails
the build.
List the plugin that brings a template's components. A tag that
resolves to no component renders nothing, and the build fails with
i18n: fr/welcome.html is empty — a tag of its template resolved to no component; list the plugin that brings it, as ui(). With ui() listed, a
tag that resolves to no component fails the build first, nested or not:
ui: <NxButon> in emails/welcome.vue is no component — ….
Leave the output paths to the plugin. An output.path set in a
template, or a plaintext.destination, moves a file out of the plugin's
layout, and the build fails when the manifest is written.
Type safety, counted
20 plausible mistakes, 20 refused at compile time. Each one is measured by
a @ts-expect-error in
test/types/refusals.ts,
which fails the typecheck the moment it stops holding:
- The plugin called without
locales. localesgiven one locale as a string rather than a list.- A
layoutother than'nested'or'flat'. - A catalogue with a leaf that is not a message (
expires: 15). createTranslatorgiven a language that is neither a locale nor a function that answers one.- An argument that is an object: only a string, a number or a
Date. placeholdergiven something other than a name.- A template that assigns
locale. - A
fallbackLocalethat is not a string. - A folder option (
dir,emails) that is not a string. tcalled in a template with a key that is not a string.createTranslator'stgiven a language, for one call, that is neither a locale nor a function that answers one.cataloguesgiven one catalogue by locale rather than a list of them.cataloguesholding a locale whose value is a message, not a catalogue.templatesgiven one folder rather than a list of them.- A template folder's
emailsgiven as one name rather than a list. - A template folder's
emailsgiven as an empty list. rendererTypesgiven astruerather than a path.messagesgiven as something that is not a module path.messagesset, and the plugin used as if it answered the plugin directly, not aPromise<MailPlugin>.
The same file holds the calls that must keep compiling: a refusal that refuses the correct call is a bug.
8 template mistakes, 8 refused by the types generated from the
catalogues, each measured by a @vue-expect-error in
test/fixture/types/refusals.vue,
checked by vue-tsc after maizzle prepare (bun run typecheck:templates):
- A key the catalogues do not have (
t('verify-email.titel')). - A message's argument left out.
- An argument the message does not use.
- A plural's count given as a placeholder, which is text.
- A key written in
snake_case(t('verify_email.title')). - A key that may be a message without arguments or one with
(
t(ok ? 'verify-email.title' : 'verify-email.expires')). - The same, given the arguments of only one of them.
- A folder file's own key, called without its prefix.
The same file holds the template calls that must keep compiling. The build still checks every call against every locale's catalogue; the types report the same mistakes earlier.
Documentation
- The guides — one page per area, with every option and error.
- Troubleshooting — an error message, its cause and its fix.
- Roadmap — what is next, and what is deliberately not planned.
- Vocabulary — the words these pages use, defined once.
Licence
MIT
Related Packages
Ready transactional e-mails for a Maizzle project built with @nxgt/mail-ui and @...
i18n for a Vue or Nuxt app on @nxgt/i18n's conventions: ICU catalogues checked f...
E-mail components for a Maizzle project in the style of @nxgt/material-vue: NxLa...
Nordbeam Locale runtime: locale loading and the React, Vue, Svelte, Solid, Next....
Glossa for JavaScript, in one package: the runtime, web components, Vue, React a...
Version History
| Version | Released | License |
|---|---|---|
| 1.0.2 | MIT | |
| 1.0.1 | MIT | |
| 1.0.0 | MIT | |
| 0.6.1 | MIT | |
| 0.6.0 | MIT | |
| 0.5.0 | MIT | |
| 0.4.0 | MIT | |
| 0.3.1 | MIT | |
| 0.3.0 | MIT | |
| 0.2.0 | MIT | |
| 0.1.0 | MIT |