Aviary
Concepts

Localization (i18n)

Translate each notification per recipient. Resolve a locale off the notifiable, look strings up in a catalog (or your own translator), and render channel payloads in the recipient's language with localization.t().

Notifications go to people who don't all speak the same language. Localization resolves a locale per recipient and gives each channel payload method a translator, so one notification renders as English for one user and Portuguese for another — the call site never changes. It's opt-in and backward compatible: a payload method that ignores the translator keeps working.

Configure a catalog

The simplest setup passes a translation catalog to forRoot. The default in-memory translator looks keys up in it, and the default resolver reads the recipient's locale off the notifiable:

app.module.ts
NotificationsModule.forRoot({
  localization: {
    defaultLocale: 'en',
    catalog: {
      en: {
        'invoice.paid.subject': 'Invoice {id} paid',
        'invoice.paid.body': 'Thanks for your payment!',
      },
      'pt-BR': {
        'invoice.paid.subject': 'Fatura {id} paga',
        'invoice.paid.body': 'Obrigado pelo seu pagamento!',
      },
    },
  },
});

LocalizationOptions is { defaultLocale?, resolver?, translator?, catalog? } — all optional.

Translate in a payload method

Every to<Channel>() method receives a context object carrying the resolved localization (alongside the notifiable and tenant). Destructure what you need and call localization.t(key, params) — it resolves the key in the recipient's locale and interpolates {placeholders}:

invoice-paid.notification.ts
@Notification()
export class InvoicePaid implements MailNotification {
  constructor(private invoiceId: string) {}

  @Mail()
  toMail({ localization }: ChannelContext): MailMessage {
    return new MailMessage()
      .subject(localization.t('invoice.paid.subject', { id: this.invoiceId }))
      .line(localization.t('invoice.paid.body'));
  }
}

Send to a pt-BR recipient and the subject is "Fatura 42 paga"; to an en recipient, "Invoice 42 paid". Localization is { locale: string; t(key, params?): string }, and params is a Record<string, string | number>.

Where the locale comes from

By default PropertyLocaleResolver reads the first present of locale, preferredLocale, lang, or language off the notifiable, falling back to defaultLocale:

class User implements Notifiable {
  constructor(public id: string, public locale = 'en') {} // 'pt-BR', 'es', …
}

Need the locale from somewhere else — a DB row, a request header, a preferences table? Bind a custom LocaleResolver:

interface LocaleResolver {
  resolve(notifiable: Notifiable): string | undefined | Promise<string | undefined>;
}

NotificationsModule.forRoot({
  localization: {
    resolver: { resolve: async (u) => await prefs.localeFor(u.id) },
  },
});

Bring your own translator

The default catalog translator is deliberately minimal. To use i18next, ICU messages, or a remote catalog, implement Translator and pass it (or bind it under NOTIFICATION_TRANSLATOR):

interface Translator {
  translate(key: string, locale: string, params?: TranslateParams): string;
}

NotificationsModule.forRoot({
  localization: {
    translator: { translate: (key, locale, params) => i18next.t(key, { lng: locale, ...params }) },
  },
});

Localization is resolved once per delivery, per recipient, so a single send() to many notifiables renders each in its own language. The resolver and translator can also be supplied via DI under NOTIFICATION_LOCALE_RESOLVER / NOTIFICATION_TRANSLATOR if you'd rather wire them as providers than inline options.

The moving parts

Everything above is driven by a few concrete exports you can also use directly — in a test, a custom resolver, or your own rendering code.

InMemoryTranslator is the zero-dependency default translator. It looks up catalog[locale][key], falling back to the base locale (pt-BRpt), then the default locale, then the raw key, and interpolates {placeholder} tokens. It's what backs the catalog option, but you can construct one yourself:

import { InMemoryTranslator } from '@dudousxd/nestjs-notifications-core';

const t = new InMemoryTranslator(
  { en: { hi: 'Hi {name}' }, 'pt-BR': { hi: 'Olá {name}' } },
  'en', // default locale
);
t.translate('hi', 'pt-BR', { name: 'Ana' }); // "Olá Ana"
t.translate('hi', 'fr', { name: 'Ana' });    // "Hi Ana"  (falls back to the default locale)

makeLocalization(translator, locale) binds a translator to a resolved locale, producing the Localization object ({ locale, t }) your payload methods receive — the shorthand t(key, params) is just translator.translate(key, locale, params). Reach for it when you render a message outside a channel (a preview, a digest email) and want the same .t() ergonomics:

import { makeLocalization } from '@dudousxd/nestjs-notifications-core';

const l10n = makeLocalization(t, 'pt-BR');
l10n.t('hi', { name: 'Ana' }); // "Olá Ana"

LocalizationService is the injectable that the ChannelRunner uses to resolve a Localization per delivery — forNotifiable(notifiable) runs the bound LocaleResolver (default: PropertyLocaleResolver) and wraps the resolved locale with the bound Translator via makeLocalization. It's provided by NotificationsModule, so you can inject it if you need the same resolution the channels get:

@Injectable()
export class DigestBuilder {
  constructor(private readonly l10n: LocalizationService) {}

  async subjectFor(user: User) {
    const localization = await this.l10n.forNotifiable(user);
    return localization.t('digest.subject', { count: 3 });
  }
}

On this page