Localization (i18n)
View SourceAshDispatch 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
end2. 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| Option | Description |
|---|---|
locales | List of locale codes to generate templates for |
default_locale | Fallback when locale can't be determined |
locale_from | Field 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 fieldChannel-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):
- Channel-level
locale- Static locale on channel (e.g.,locale: "sv") - Channel-level
locale_from- Dynamic from record field - Recipient
:locale(since 0.4.5) - When the recipient struct has a non-empty:localefield, it takes precedence over the event/resource/auto-detect fallback chain. Typically this isUser.localeforaudience: :user— letting one channel render different locales for different recipients in the same dispatch with no per-recipient code in the worker. - Event-level
locale_from- Field configured on event - Resource-level
locale_from- Field configured on resource - Common field names - Auto-detected:
visitor_locale,locale - 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:
email.admin.sv.html.heex(variant + locale)email.admin.html.heex(variant only)email.sv.html.heex(locale only)email.html.heex(base template)default.sv.html.heex(default + locale)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 stringsGettext 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
endAt dispatch time:
- Locale is resolved via the locale chain (channel → event → resource → config)
"Task Completed"is looked up viaGettext.dgettext(backend, "notifications", "Task Completed")- If a translation exists for the resolved locale, it's used
{{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
| Option | Default | Description |
|---|---|---|
gettext_backend | nil | Gettext 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
endInternal-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
endMigration Guide
From Hardcoded Templates
If you have existing templates without locale support:
- Add
localesconfiguration to your resource - Run
mix ash.codegento generate locale-specific templates - Copy content from existing templates to locale-specific versions
- Translate content as needed
Adding New Locale
To add support for a new language:
- Add the locale code to your
localeslist - Run
mix ash.codegento generate new template files - 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
- Configuration - All configuration options
- Code Generation - Template generation details
- Phoenix Integration - Real-time updates