Localization (i18n)

View Source

AshDispatch provides built-in internationalization (i18n) support for sending notifications in the recipient's preferred language. This guide covers how to configure locale-aware templates and dynamic locale resolution.

Overview

The localization system supports:

  • Resource-level locale configuration - Set default locales for all events in a resource
  • Event-level locale override - Customize locales per event
  • Channel-level locale override - Set static or dynamic locale per channel
  • Template fallback chain - Graceful degradation when locale-specific templates don't exist
  • Delivery receipt tracking - Track which locale was used for each delivery

Quick Start

1. Configure Locales in Resource DSL

defmodule MyApp.Leads.Lead do
  use Ash.Resource,
    extensions: [AshDispatch.Resource]

  dispatch do
    # Resource-level: applies to ALL events
    locales ["sv", "en"], locale_from: :visitor_locale

    event :created, trigger_on: :create do
      channels [
        # Customer email uses visitor_locale from record
        [transport: :email, audience: :customer],
        # Admin email always in Swedish
        [transport: :email, audience: :admin, locale: "sv"]
      ]
    end
  end

  attributes do
    # The field used for locale resolution
    attribute :visitor_locale, :string do
      constraints max_length: 10
    end
  end
end

2. Generate Templates

Run the code generator to create locale-specific templates:

mix ash.codegen create_lead_templates

This generates:

priv/ash_dispatch/leads/events/created/templates/
├── email.html.heex        # Default fallback
├── email.sv.html.heex     # Swedish
├── email.en.html.heex     # English
├── email.text.eex         # Default fallback (text)
├── email.sv.text.eex      # Swedish (text)
└── email.en.text.eex      # English (text)

3. Write Locale-Specific Templates

<%# email.sv.html.heex - Swedish %>
<h1>Hej <%= @contact_name %>!</h1>
<p>Tack för att du kontaktade oss. Vi återkommer snart.</p>
<%# email.en.html.heex - English %>
<h1>Hello <%= @contact_name %>!</h1>
<p>Thank you for contacting us. We'll get back to you soon.</p>

Configuration Options

Resource-Level Configuration

Configure locales for all events in a resource:

dispatch do
  locales ["sv", "en", "no"],
    default_locale: "sv",
    locale_from: :visitor_locale
end
OptionDescription
localesList of locale codes to generate templates for
default_localeFallback when locale can't be determined
locale_fromField on record to read runtime locale from

Event-Level Configuration

Override resource settings for a specific event:

event :created,
  trigger_on: :create,
  locales: ["sv", "en", "de"],  # Override resource locales
  locale_from: :preferred_language  # Different field

Channel-Level Configuration

Override per channel for fine-grained control:

channels [
  # Static locale (always use Swedish)
  [transport: :email, audience: :admin, locale: "sv"],

  # Dynamic locale from record field
  [transport: :email, audience: :customer, locale_from: :visitor_locale],

  # Generate templates for specific locales
  [transport: :email, audience: :customer, locales: ["sv", "en"]]
]

Locale Resolution Priority

At runtime, locale is resolved in this order (highest to lowest):

  1. Channel-level locale - Static locale on channel (e.g., locale: "sv")
  2. Channel-level locale_from - Dynamic from record field
  3. Recipient :locale (since 0.4.5) - When the recipient struct has a non-empty :locale field, it takes precedence over the event/resource/auto-detect fallback chain. Typically this is User.locale for audience: :user — letting one channel render different locales for different recipients in the same dispatch with no per-recipient code in the worker.
  4. Event-level locale_from - Field configured on event
  5. Resource-level locale_from - Field configured on resource
  6. Common field names - Auto-detected: visitor_locale, locale
  7. Config default - config :ash_dispatch, default_locale: "sv"

Per-recipient example (0.4.5+)

# User schema has a :locale attribute
defmodule MyApp.Accounts.User do
  attributes do
    attribute :locale, :string, default: "en"
  end
end

# Event fans out to two recipients (seller + admin), each with their
# own locale on their User record.
defmodule MyApp.Events.OrderShipped do
  use AshDispatch.Event

  def channels(_) do
    [
      %Channel{transport: :email, audience: :user, time: {:in, 0}},
      %Channel{transport: :email, audience: :admin, time: {:in, 0}}
    ]
  end

  # notification_title/2 + prepare_template_assigns/2 use the macro
  # form of `dgettext` — locale is set by the dispatcher per recipient
  # so each one sees their own language.
  def notification_title(_, _), do: dgettext("default", "Order shipped")
end

# Worker just dispatches — no per-recipient locale plumbing:
AshDispatch.Dispatcher.dispatch("orders.shipped", %{order: order})

A Swedish seller and an English admin will each receive the email in their own language from one dispatch/2 call.

Template Fallback Chain

When resolving templates, AshDispatch tries these in order:

  1. email.admin.sv.html.heex (variant + locale)
  2. email.admin.html.heex (variant only)
  3. email.sv.html.heex (locale only)
  4. email.html.heex (base template)
  5. default.sv.html.heex (default + locale)
  6. default.html.heex (ultimate fallback)

This ensures graceful degradation - you only need to create locale-specific templates for languages where the content differs.

Global Configuration

Set global defaults in your config:

# config/config.exs
config :ash_dispatch,
  default_locale: "sv",  # Default when no locale found (defaults to "en")
  gettext_backend: MyAppWeb.Gettext  # Optional: auto-translate content strings

Gettext Integration (Content String Translation)

When gettext_backend is configured, all content: block strings are automatically translated via Gettext before {{variable}} interpolation. This enables content strings to work as translatable msgids.

How It Works

# In your resource DSL:
dispatch do
  event :task_completed, trigger_on: :complete do
    content: [
      notification_title: "Task Completed",
      notification_message: "\"{{title}}\" marked as done"
    ]
  end
end

At dispatch time:

  1. Locale is resolved via the locale chain (channel → event → resource → config)
  2. "Task Completed" is looked up via Gettext.dgettext(backend, "notifications", "Task Completed")
  3. If a translation exists for the resolved locale, it's used
  4. {{title}} variable interpolation happens on the translated string

Auto-Generated Gettext Catalog

When gettext_backend is set, mix ash.codegen automatically generates a Gettext catalog module from all content: block strings:

mix ash.codegen "update templates"
# creates: lib/my_app/events/i18n_catalog.ex

The generated catalog contains dgettext("notifications", "...") calls for every content string, enabling mix gettext.extract to discover them automatically. No manual catalog maintenance needed.

Translation Workflow

# 1. Add content: to your dispatch event
# 2. Run codegen (generates catalog + templates)
mix ash.codegen "add event"

# 3. Extract strings (finds them via auto-generated catalog)
mix gettext.extract --merge

# 4. Translate (using your preferred method — AI, manual, TMS)
# Strings appear in priv/gettext/*/LC_MESSAGES/notifications.po

# 5. Deploy — content strings are translated at dispatch time

Configuration

OptionDefaultDescription
gettext_backendnilGettext backend module. When set, enables content translation.

The Gettext domain used is "notifications". All content strings (titles, messages, action labels) are looked up in this domain.

Note: Gettext is not a required dependency of AshDispatch. The integration uses dynamic function calls (apply/3) so it works seamlessly when Gettext is available and is a no-op when it's not.

Delivery Receipt Tracking

When an event is dispatched, the locale used is recorded in the delivery receipt:

# Query receipts by locale
receipts = Ash.read!(MyApp.Deliveries.DeliveryReceipt,
  filter: [locale: "sv"]
)

This is useful for:

  • Debugging template issues
  • Analytics on language distribution
  • Compliance reporting

Common Patterns

Multi-Language Landing Page

For leads from a multi-language landing page:

defmodule MyApp.Leads.Lead do
  dispatch do
    # Locales match your landing page languages
    locales ["sv", "en", "no", "fi"]
    locale_from: :visitor_locale

    event :created, trigger_on: :create do
      channels [
        # Customer gets email in their language
        [transport: :email, audience: :customer],
        # Internal team always in Swedish
        [transport: :email, audience: :admin, locale: "sv"],
        [transport: :in_app, audience: :owner]
      ]
    end
  end
end

Internal-Only Events

For events that only go to internal users:

event :escalated,
  trigger_on: :escalate,
  locales: ["sv"],  # Only Swedish templates
  channels: [
    [transport: :email, audience: :admin],
    [transport: :in_app, audience: :owner]
  ]

Customer Portal with User Preferences

For events where users have saved language preferences:

event :invoice_sent,
  trigger_on: :send_invoice,
  locale_from: :customer_preferred_language,
  channels: [
    [transport: :email, audience: :customer]
  ]

Testing Locale Templates

Test templates render correctly for each locale:

defmodule MyApp.LeadTemplateTest do
  use ExUnit.Case

  test "created event renders in Swedish" do
    lead = %{id: "123", contact_name: "Erik", visitor_locale: "sv"}

    {:ok, html} = AshDispatch.TemplateResolver.render(
      template_path: "priv/ash_dispatch/leads/events/created/templates",
      format: :html,
      transport: :email,
      locale: "sv",
      assigns: %{contact_name: lead.contact_name}
    )

    assert html =~ "Tack för att du kontaktade oss"
  end

  test "created event renders in English" do
    lead = %{id: "123", contact_name: "Erik", visitor_locale: "en"}

    {:ok, html} = AshDispatch.TemplateResolver.render(
      template_path: "priv/ash_dispatch/leads/events/created/templates",
      format: :html,
      transport: :email,
      locale: "en",
      assigns: %{contact_name: lead.contact_name}
    )

    assert html =~ "Thank you for contacting us"
  end

  test "falls back to default when locale template missing" do
    {:ok, html} = AshDispatch.TemplateResolver.render(
      template_path: "priv/ash_dispatch/leads/events/created/templates",
      format: :html,
      transport: :email,
      locale: "de",  # No German template
      assigns: %{contact_name: "Hans"}
    )

    # Should fall back to base template
    assert {:ok, _} = html
  end
end

Migration Guide

From Hardcoded Templates

If you have existing templates without locale support:

  1. Add locales configuration to your resource
  2. Run mix ash.codegen to generate locale-specific templates
  3. Copy content from existing templates to locale-specific versions
  4. Translate content as needed

Adding New Locale

To add support for a new language:

  1. Add the locale code to your locales list
  2. Run mix ash.codegen to generate new template files
  3. Translate the new template files
# Before
locales ["sv", "en"]

# After (adding Norwegian)
locales ["sv", "en", "no"]

Then run:

mix ash.codegen add_norwegian_templates

Next Steps