diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2462605f..66a0de1a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,26 @@
All notable changes to this project will be documented in this file.
+## [28.0] - 2026-XX-XX
+
+### New Features
+
+- **Template i18n**: Auto-select email content based on contact language (#268)
+ - **Liquid `t` filter**: Use `{{ "key" | t }}` in templates to reference translation keys
+ - **Placeholder support**: Pass dynamic values with `{{ "greeting" | t: name: contact.first_name }}`
+ - **Nested keys**: Dot-separated key paths (e.g., `welcome.heading`, `cta.button`)
+ - **Per-template translations**: Store translation key-value maps per locale as part of the template
+ - **Workspace translations**: Shared translation catalog available to all templates in a workspace
+ - **Automatic locale resolution**: Fallback chain from `contact.language` → base language → template default → workspace default
+ - **Translations panel**: Manage translation keys and per-locale values in the template editor
+ - **Import/Export**: Bulk upload/download translations as JSON files per locale
+- **Workspace language settings**: Configure default language and supported languages in workspace settings
+
+### Database Migration
+
+- Added `translations` JSONB column and `default_language` VARCHAR column to `templates` table (workspace migration)
+- Created `workspace_translations` table for workspace-level shared translations (workspace migration)
+
## [27.2] - 2026-02-21
- **Contacts**: Fixed panic (502) when calling `/api/contacts.list` without the `limit` parameter (#264)
diff --git a/CLAUDE.md b/CLAUDE.md
index 3bcb6786..197ac9eb 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -128,6 +128,19 @@ func (m *V6Migration) UpdateSystem(ctx context.Context, config *config.Config, d
- **MJML Support**: gomjml v0.10.0 for email rendering
- **HTML Parsing**: PuerkitoBio/goquery v1.10.3
+#### Translation Filter (v28.0+)
+
+Templates can reference translatable strings using the Liquid `t` filter:
+
+```liquid
+{{ "welcome.heading" | t }}
+{{ "welcome.greeting" | t: name: contact.first_name }}
+```
+
+Translations are stored per-locale as nested JSON on the template's `translations` field. The system resolves the best locale from `contact.language` with a fallback chain: exact match → base language → template default → workspace default.
+
+Workspace-level shared translations (in the `workspace_translations` table) serve as a fallback when a key is not found in the template's own translations. Template translations take priority over workspace translations.
+
### Observability & Monitoring
- **Logging**: Zerolog v1.33.0 (structured logging)
diff --git a/config/config.go b/config/config.go
index 31a6bbae..d3c20d82 100644
--- a/config/config.go
+++ b/config/config.go
@@ -14,7 +14,7 @@ import (
"github.com/spf13/viper"
)
-const VERSION = "27.2"
+const VERSION = "28.0"
type Config struct {
Server ServerConfig
diff --git a/console/src/components/settings/LanguageSettings.tsx b/console/src/components/settings/LanguageSettings.tsx
new file mode 100644
index 00000000..6f3b8dba
--- /dev/null
+++ b/console/src/components/settings/LanguageSettings.tsx
@@ -0,0 +1,182 @@
+import { useEffect, useState } from 'react'
+import { Button, Form, Select, App, Descriptions } from 'antd'
+import { useLingui } from '@lingui/react/macro'
+import { Workspace } from '../../services/api/types'
+import { workspaceService } from '../../services/api/workspace'
+import { SettingsSectionHeader } from './SettingsSectionHeader'
+
+const LANGUAGE_OPTIONS = [
+ { value: 'en', label: 'English' },
+ { value: 'fr', label: 'French' },
+ { value: 'de', label: 'German' },
+ { value: 'es', label: 'Spanish' },
+ { value: 'pt', label: 'Portuguese' },
+ { value: 'pt-BR', label: 'Portuguese (Brazil)' },
+ { value: 'it', label: 'Italian' },
+ { value: 'nl', label: 'Dutch' },
+ { value: 'ja', label: 'Japanese' },
+ { value: 'ko', label: 'Korean' },
+ { value: 'zh', label: 'Chinese' },
+ { value: 'ru', label: 'Russian' },
+ { value: 'ar', label: 'Arabic' },
+ { value: 'hi', label: 'Hindi' },
+ { value: 'tr', label: 'Turkish' },
+ { value: 'pl', label: 'Polish' },
+ { value: 'sv', label: 'Swedish' },
+ { value: 'da', label: 'Danish' },
+ { value: 'fi', label: 'Finnish' },
+ { value: 'nb', label: 'Norwegian' }
+]
+
+interface LanguageSettingsProps {
+ workspace: Workspace | null
+ onWorkspaceUpdate: (workspace: Workspace) => void
+ isOwner: boolean
+}
+
+export function LanguageSettings({ workspace, onWorkspaceUpdate, isOwner }: LanguageSettingsProps) {
+ const { t } = useLingui()
+ const [savingSettings, setSavingSettings] = useState(false)
+ const [formTouched, setFormTouched] = useState(false)
+ const [form] = Form.useForm()
+ const { message } = App.useApp()
+
+ useEffect(() => {
+ if (!isOwner) return
+
+ form.setFieldsValue({
+ default_language: workspace?.settings.default_language || 'en',
+ supported_languages: workspace?.settings.supported_languages || ['en']
+ })
+ setFormTouched(false)
+ }, [workspace, form, isOwner])
+
+ const handleSaveSettings = async (values: {
+ default_language: string
+ supported_languages: string[]
+ }) => {
+ if (!workspace) return
+
+ // Ensure the default language is included in supported languages
+ let supportedLanguages = values.supported_languages || []
+ if (!supportedLanguages.includes(values.default_language)) {
+ supportedLanguages = [values.default_language, ...supportedLanguages]
+ }
+
+ setSavingSettings(true)
+ try {
+ await workspaceService.update({
+ ...workspace,
+ settings: {
+ ...workspace.settings,
+ default_language: values.default_language,
+ supported_languages: supportedLanguages
+ }
+ })
+
+ // Refresh the workspace data
+ const response = await workspaceService.get(workspace.id)
+
+ // Update the parent component with the new workspace data
+ onWorkspaceUpdate(response.workspace)
+
+ setFormTouched(false)
+ message.success(t`Language settings updated successfully`)
+ } catch (error: unknown) {
+ console.error('Failed to update language settings', error)
+ const errorMessage = (error as Error)?.message || t`Failed to update language settings`
+ message.error(errorMessage)
+ } finally {
+ setSavingSettings(false)
+ }
+ }
+
+ const handleFormChange = () => {
+ setFormTouched(true)
+ }
+
+ const getLabelForLanguage = (code: string) => {
+ const option = LANGUAGE_OPTIONS.find((o) => o.value === code)
+ return option ? option.label : code
+ }
+
+ if (!isOwner) {
+ const defaultLang = workspace?.settings.default_language || 'en'
+ const supportedLangs = workspace?.settings.supported_languages || ['en']
+
+ return (
+ <>
+
+
+
+
+ {getLabelForLanguage(defaultLang)}
+
+
+
+ {supportedLangs.map((lang) => getLabelForLanguage(lang)).join(', ')}
+
+
+ >
+ )
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/console/src/components/settings/SettingsSidebar.tsx b/console/src/components/settings/SettingsSidebar.tsx
index 4f0deb18..07ca33b9 100644
--- a/console/src/components/settings/SettingsSidebar.tsx
+++ b/console/src/components/settings/SettingsSidebar.tsx
@@ -4,7 +4,8 @@ import {
TagsOutlined,
SettingOutlined,
ExclamationCircleOutlined,
- MailOutlined
+ MailOutlined,
+ GlobalOutlined
} from '@ant-design/icons'
import { useLingui } from '@lingui/react/macro'
@@ -15,6 +16,7 @@ export type SettingsSection =
| 'custom-fields'
| 'smtp-relay'
| 'general'
+ | 'languages'
| 'blog'
| 'danger-zone'
@@ -107,6 +109,11 @@ export function SettingsSidebar({ activeSection, onSectionChange, isOwner }: Set
icon: ,
label: t`SMTP Relay`
},
+ {
+ key: 'languages',
+ icon: ,
+ label: t`Languages`
+ },
{
key: 'general',
icon: ,
diff --git a/console/src/pages/WorkspaceSettingsPage.tsx b/console/src/pages/WorkspaceSettingsPage.tsx
index 379d301e..fe547281 100644
--- a/console/src/pages/WorkspaceSettingsPage.tsx
+++ b/console/src/pages/WorkspaceSettingsPage.tsx
@@ -13,6 +13,7 @@ import { BlogSettings } from '../components/settings/BlogSettings'
import { WebhooksSettings } from '../components/settings/WebhooksSettings'
import { useAuth } from '../contexts/AuthContext'
import { DeleteWorkspaceSection } from '../components/settings/DeleteWorkspace'
+import { LanguageSettings } from '../components/settings/LanguageSettings'
import { SettingsSidebar, SettingsSection } from '../components/settings/SettingsSidebar'
const { Sider, Content } = Layout
@@ -37,6 +38,7 @@ export function WorkspaceSettingsPage() {
'custom-fields',
'smtp-relay',
'general',
+ 'languages',
'blog',
'danger-zone'
]
@@ -144,6 +146,14 @@ export function WorkspaceSettingsPage() {
isOwner={isOwner}
/>
)
+ case 'languages':
+ return (
+
+ )
case 'blog':
return (
settings?: Record
+ translations?: Record> // locale → nested key-value
+ default_language?: string
created_at: string
updated_at: string
}
diff --git a/console/src/services/api/workspace-translations.ts b/console/src/services/api/workspace-translations.ts
new file mode 100644
index 00000000..bf78b5aa
--- /dev/null
+++ b/console/src/services/api/workspace-translations.ts
@@ -0,0 +1,43 @@
+import { api } from './client'
+
+export interface WorkspaceTranslation {
+ locale: string
+ content: Record
+ created_at: string
+ updated_at: string
+}
+
+export interface UpsertWorkspaceTranslationRequest {
+ workspace_id: string
+ locale: string
+ content: Record
+}
+
+export interface ListWorkspaceTranslationsResponse {
+ translations: WorkspaceTranslation[]
+}
+
+export interface DeleteWorkspaceTranslationRequest {
+ workspace_id: string
+ locale: string
+}
+
+export interface WorkspaceTranslationsApi {
+ list: (workspaceId: string) => Promise
+ upsert: (params: UpsertWorkspaceTranslationRequest) => Promise
+ delete: (params: DeleteWorkspaceTranslationRequest) => Promise
+}
+
+export const workspaceTranslationsApi: WorkspaceTranslationsApi = {
+ list: async (workspaceId: string) => {
+ return api.get(
+ `/api/workspace_translations.list?workspace_id=${workspaceId}`
+ )
+ },
+ upsert: async (params: UpsertWorkspaceTranslationRequest) => {
+ return api.post('/api/workspace_translations.upsert', params)
+ },
+ delete: async (params: DeleteWorkspaceTranslationRequest) => {
+ return api.post('/api/workspace_translations.delete', params)
+ }
+}
diff --git a/docs/plans/2026-02-24-template-i18n-design.md b/docs/plans/2026-02-24-template-i18n-design.md
new file mode 100644
index 00000000..91f473bc
--- /dev/null
+++ b/docs/plans/2026-02-24-template-i18n-design.md
@@ -0,0 +1,298 @@
+# Template-level i18n — Design Document
+
+**Issue**: [#268](https://github.com/Notifuse/notifuse/issues/268)
+**Branch**: `feat/template-i18n`
+**Date**: 2026-02-24
+
+## Problem
+
+Contacts have a `language` field (synced via notification center since v27.2), but there is no built-in way to send email content in that language. Current workarounds — duplicating templates per language or using verbose `{% if contact.language == "fr" %}` blocks — are manual, error-prone, and don't scale.
+
+## Solution
+
+Translation keys with a string catalog, using a Liquid `t` filter. Templates reference translatable strings via `{{ "key" | t }}`, and translations are stored as nested JSON per locale alongside the template. The system resolves the correct locale at render time based on `contact.language`.
+
+## Design Decisions
+
+| Decision | Choice |
+|---|---|
+| Approach | Translation keys with string catalog (Option A from issue) |
+| Scope | Both template-level and workspace-level translations |
+| Resolution | Same `{{ "key" \| t }}` syntax; template-first, then workspace fallback |
+| Key format | Nested, dot-separated (e.g., `welcome.heading`) |
+| Syntax | Liquid filter: `{{ "key" \| t }}` with placeholder support via named args |
+| Language config | Workspace default + per-template override |
+| Storage | Inline JSONB on existing `templates` table + new `workspace_translations` table |
+| Editor UX (v1) | Translations panel alongside the visual editor |
+
+## 1. Liquid `t` Filter
+
+### Syntax
+
+```liquid
+
+{{ "welcome.heading" | t }}
+
+
+{{ "welcome.greeting" | t: name: contact.first_name }}
+
+
+Subject: {{ "welcome.subject" | t }}
+```
+
+### Translation JSON (per locale)
+
+```json
+{
+ "welcome": {
+ "heading": "Welcome!",
+ "greeting": "Hello {{ name }}!",
+ "subject": "Welcome to Notifuse"
+ }
+}
+```
+
+Placeholder values like `{{ name }}` inside translation strings are interpolated with the named arguments passed to the filter.
+
+### Implementation
+
+Register a `TranslationFilters` struct on the `SecureLiquidEngine` via `env.RegisterFilter()`:
+
+```go
+type TranslationFilters struct {
+ translations map[string]interface{} // merged: template (priority) + workspace
+ locale string
+}
+
+func (tf *TranslationFilters) T(key interface{}, args ...interface{}) interface{} {
+ keyStr := fmt.Sprintf("%v", key)
+
+ // 1. Resolve nested key via dot-path traversal
+ value := resolveNestedKey(tf.translations, keyStr)
+ if value == "" {
+ return "[Missing translation: " + keyStr + "]"
+ }
+
+ // 2. Interpolate placeholders if named args provided
+ if len(args) > 0 {
+ value = interpolatePlaceholders(value, args)
+ }
+
+ return value
+}
+```
+
+### Locale Resolution (fallback chain)
+
+```
+1. contact.language exact match (e.g., "pt-BR")
+2. contact.language base match (e.g., "pt")
+3. template.default_language (if set, overrides workspace)
+4. workspace.default_language (e.g., "en")
+```
+
+### Translation Merging at Render Time
+
+```
+1. Load workspace translations for resolved locale
+2. Load template translations for resolved locale
+3. Deep-merge: template translations override workspace translations
+4. Pass merged map to TranslationFilters
+```
+
+A template key `welcome.heading` shadows a workspace key `welcome.heading`, but a workspace key `common.footer` is accessible if the template doesn't define it.
+
+## 2. Data Model & Storage
+
+### Modified: `Template` struct
+
+```go
+type Template struct {
+ // ... existing fields ...
+ Translations map[string]map[string]interface{} `json:"translations"` // locale → nested key-value
+ DefaultLanguage *string `json:"default_language"` // nullable, overrides workspace
+}
+```
+
+### Modified: `WorkspaceSettings` struct (inside Workspace.Settings JSONB)
+
+```go
+type WorkspaceSettings struct {
+ // ... existing fields ...
+ DefaultLanguage string `json:"default_language,omitempty"` // e.g., "en"
+ SupportedLanguages []string `json:"supported_languages,omitempty"` // e.g., ["en", "fr", "de"]
+}
+```
+
+### New: `WorkspaceTranslation` entity
+
+```go
+type WorkspaceTranslation struct {
+ Locale string `json:"locale"`
+ Content map[string]interface{} `json:"content"` // nested key-value
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+```
+
+## 3. Database Migration (V28)
+
+Non-breaking, additive migration. Existing templates get empty `{}` translations and `NULL` default_language (inheriting workspace default).
+
+### System database
+
+No schema changes needed. `default_language` and `supported_languages` are added to the `WorkspaceSettings` Go struct. Since `WorkspaceSettings` is stored as JSONB in the existing `workspaces.settings` column, the new fields are automatically handled — existing workspaces will have these fields absent in JSON, and Go will deserialize them as zero values (falling back to `"en"` and `["en"]` via helper methods).
+
+### Workspace database
+
+```sql
+ALTER TABLE templates
+ ADD COLUMN IF NOT EXISTS translations JSONB NOT NULL DEFAULT '{}'::jsonb;
+ALTER TABLE templates
+ ADD COLUMN IF NOT EXISTS default_language VARCHAR(10);
+
+CREATE TABLE IF NOT EXISTS workspace_translations (
+ locale VARCHAR(10) NOT NULL PRIMARY KEY,
+ content JSONB NOT NULL DEFAULT '{}'::jsonb,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+);
+```
+
+## 4. API Surface
+
+### New endpoints (workspace translations)
+
+```
+POST /api/workspace_translations.upsert — create/update translations for a locale
+GET /api/workspace_translations.list — list all workspace translations
+POST /api/workspace_translations.delete — delete translations for a locale
+POST /api/workspace_translations.import — bulk import JSON per locale
+GET /api/workspace_translations.export — export all translations as JSON
+```
+
+### Modified endpoints (templates)
+
+Template translations are part of the existing `templates.create` and `templates.update` payloads via the new `translations` field. No new template-specific endpoints needed.
+
+### Transactional API (no changes)
+
+Language resolution is automatic from `contact.language`:
+
+```json
+{
+ "template_id": "welcome_email",
+ "contact": { "email": "user@example.com", "language": "fr" }
+}
+```
+
+### Send flow
+
+```
+SendNotification()
+ → resolve template
+ → resolve locale from contact.language + fallback chain
+ → load workspace translations for locale
+ → merge template translations (priority) over workspace translations
+ → register TranslationFilters with merged map
+ → render Liquid (subject + body) — t filter resolves keys
+ → compile MJML → HTML
+ → send
+```
+
+Broadcasts follow the same flow, but per-contact in the batch loop. Template + workspace translations are loaded once; only locale resolution changes per contact.
+
+## 5. Frontend — Translations Panel
+
+New component `console/src/components/templates/TranslationsPanel.tsx`, integrated into the existing template editor drawer.
+
+### Behavior
+
+- A "Translations" tab/button in the template editor
+- Collapsible list of translation keys grouped by nested prefix
+- Each key expands to show input fields per supported language (from workspace `supported_languages`)
+- Default language value is required, others optional
+- "Add Key" button for creating new keys with dot-path input
+- Import/Export buttons for bulk JSON per locale
+
+### Wireframe
+
+```
+┌─ Translations Panel ──────────────────────────┐
+│ │
+│ Language: [en ▾] (preview selector) │
+│ │
+│ ▼ welcome │
+│ heading │
+│ en: [Welcome! ] ✓ │
+│ fr: [Bienvenue ! ] │
+│ de: [Willkommen! ] │
+│ │
+│ greeting │
+│ en: [Hello {{ name }}! ] ✓ │
+│ fr: [Bonjour {{ name }} ! ] │
+│ de: [ ] ⚠ │
+│ │
+│ ▼ cta │
+│ button │
+│ en: [Get Started ] ✓ │
+│ fr: [Commencer ] │
+│ de: [Loslegen ] │
+│ │
+│ [+ Add Key] [Import JSON] [Export JSON] │
+└────────────────────────────────────────────────┘
+
+✓ = default language (required)
+⚠ = missing translation (will fall back to default)
+```
+
+### Workspace translations UI
+
+Not in v1 scope. Workspace-level translations are managed via API (import/export JSON). A dedicated settings page can come later.
+
+## 6. Import/Export Format
+
+Per-locale JSON files with nested structure, matching internal storage:
+
+```json
+// en.json
+{
+ "welcome": {
+ "heading": "Welcome!",
+ "greeting": "Hello {{ name }}!"
+ },
+ "cta": {
+ "button": "Get Started"
+ }
+}
+```
+
+Export produces one JSON file per locale. Import uses upsert semantics: new keys are added, existing keys are overwritten, absent keys are untouched. Both template-level and workspace-level translations use the same format.
+
+## 7. Testing Strategy
+
+| Layer | What to test |
+|---|---|
+| Domain | `Template.Validate()` with translations, locale fallback resolution, nested key resolution, placeholder interpolation |
+| Service | Translation merging (template over workspace), `CompileTemplate` with translations, language resolution from contact |
+| Repository | CRUD with translations JSONB, `workspace_translations` table operations |
+| HTTP | New `workspace_translations` endpoints, template create/update with translations |
+| Liquid filter | `T` filter: simple key, nested key, missing key fallback, placeholders with named args, locale resolution chain |
+| Migration | V28 idempotency |
+| Frontend | TranslationsPanel component, import/export flow |
+
+### Edge cases
+
+- Contact with no `language` set → falls back to workspace default
+- Contact with `pt-BR` when only `pt` translations exist → base language match
+- Translation key exists in workspace but not template → workspace value used
+- Translation value contains Liquid (`{{ contact.first_name }}`) → rendered correctly after `t` filter resolves
+- Empty translations `{}` → template renders with fallback markers (`[Missing translation: key]`)
+
+## Prior Art
+
+- **Novu**: Translation keys (`{{t.key}}`) with i18next under the hood. Enterprise feature. Uses preprocessing trick to protect keys from Liquid rendering. No placeholder support in filter syntax.
+- **Shopify**: `{{ "key" | t: name: value }}` filter with named args for placeholders. Nested JSON locale files. Our approach is closest to this.
+- **Symfony/Twig**: `{{ "key" | trans({"%name%": value}) }}` filter. Similar concept, different placeholder syntax.
+
+Our design takes Shopify's filter approach (most natural for Liquid) with Novu's dual-scope model (template + workspace translations) and clean fallback chain.
diff --git a/docs/plans/2026-02-24-template-i18n-docs.md b/docs/plans/2026-02-24-template-i18n-docs.md
new file mode 100644
index 00000000..b0531a56
--- /dev/null
+++ b/docs/plans/2026-02-24-template-i18n-docs.md
@@ -0,0 +1,513 @@
+# Template i18n Documentation Update Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Update all project documentation to reflect the template i18n feature (v28.0).
+
+**Architecture:** Update OpenAPI specs (schemas + paths + root), CHANGELOG, and CLAUDE.md. No external docs site changes (docs.notifuse.com is maintained separately).
+
+**Tech Stack:** YAML (OpenAPI 3.0.3), Markdown.
+
+**Design doc:** `docs/plans/2026-02-24-template-i18n-design.md`
+
+---
+
+## Task 1: Update OpenAPI Template Schema
+
+Add `translations` and `default_language` fields to the Template schema, and update Create/Update/Compile request types.
+
+**Files:**
+- Modify: `openapi/components/schemas/template.yaml`
+
+### Step 1: Add fields to Template schema
+
+After the `settings` property (line 64), add:
+
+```yaml
+ translations:
+ type: object
+ additionalProperties:
+ type: object
+ additionalProperties: true
+ description: |
+ Per-locale translation key-value maps. Keys are dot-separated paths (e.g., "welcome.heading").
+ Values are strings that may contain {{ placeholder }} syntax for named arguments.
+ Outer keys are locale codes (e.g., "en", "fr", "pt-BR").
+ example:
+ en:
+ welcome:
+ heading: "Welcome!"
+ greeting: "Hello {{ name }}!"
+ fr:
+ welcome:
+ heading: "Bienvenue !"
+ greeting: "Bonjour {{ name }} !"
+ default_language:
+ type: string
+ nullable: true
+ description: Override the workspace default language for this template. When null, inherits from workspace settings.
+ example: en
+ maxLength: 10
+```
+
+### Step 2: Add to CreateTemplateRequest properties
+
+After `settings` (line 199), add:
+
+```yaml
+ translations:
+ type: object
+ additionalProperties:
+ type: object
+ additionalProperties: true
+ description: Per-locale translation key-value maps
+ default_language:
+ type: string
+ nullable: true
+ description: Override the workspace default language for this template
+ maxLength: 10
+```
+
+### Step 3: Add to UpdateTemplateRequest properties
+
+Same addition as CreateTemplateRequest, after `settings` (line 260).
+
+### Step 4: Add `translations` to CompileTemplateRequest
+
+After `channel` (line 310), add:
+
+```yaml
+ translations:
+ type: object
+ additionalProperties: true
+ description: Merged translations map for a specific locale, used by the Liquid `t` filter during compilation
+```
+
+### Step 5: Verify YAML is valid
+
+Run: `cd /var/www/forks/notifuse && python3 -c "import yaml; yaml.safe_load(open('openapi/components/schemas/template.yaml'))" 2>&1 || echo "YAML invalid"`
+Expected: No errors.
+
+### Step 6: Commit
+
+```bash
+git add openapi/components/schemas/template.yaml
+git commit -m "docs: add translations fields to OpenAPI template schema"
+```
+
+---
+
+## Task 2: Create OpenAPI Workspace Translations Schema
+
+New schema file for the workspace translations API types.
+
+**Files:**
+- Create: `openapi/components/schemas/workspace-translation.yaml`
+
+### Step 1: Create the schema file
+
+```yaml
+WorkspaceTranslation:
+ type: object
+ properties:
+ locale:
+ type: string
+ description: Locale code (e.g., "en", "fr", "pt-BR")
+ example: en
+ maxLength: 10
+ content:
+ type: object
+ additionalProperties: true
+ description: |
+ Nested key-value translation map. Keys use dot-separated paths.
+ Values are strings, optionally containing {{ placeholder }} syntax.
+ example:
+ common:
+ greeting: "Hello"
+ footer: "Unsubscribe from our emails"
+ created_at:
+ type: string
+ format: date-time
+ description: When the translation was created
+ updated_at:
+ type: string
+ format: date-time
+ description: When the translation was last updated
+ required:
+ - locale
+ - content
+
+UpsertWorkspaceTranslationRequest:
+ type: object
+ required:
+ - workspace_id
+ - locale
+ - content
+ properties:
+ workspace_id:
+ type: string
+ description: The ID of the workspace
+ example: ws_1234567890
+ locale:
+ type: string
+ description: Locale code
+ example: fr
+ maxLength: 10
+ content:
+ type: object
+ additionalProperties: true
+ description: Nested key-value translation map
+ example:
+ common:
+ greeting: "Bonjour"
+ footer: "Se désabonner de nos emails"
+
+DeleteWorkspaceTranslationRequest:
+ type: object
+ required:
+ - workspace_id
+ - locale
+ properties:
+ workspace_id:
+ type: string
+ description: The ID of the workspace
+ example: ws_1234567890
+ locale:
+ type: string
+ description: Locale code to delete
+ example: fr
+```
+
+### Step 2: Commit
+
+```bash
+git add openapi/components/schemas/workspace-translation.yaml
+git commit -m "docs: add OpenAPI schema for workspace translations"
+```
+
+---
+
+## Task 3: Create OpenAPI Workspace Translations Paths
+
+New paths file for the workspace translations API endpoints.
+
+**Files:**
+- Create: `openapi/paths/workspace-translations.yaml`
+
+### Step 1: Create the paths file
+
+```yaml
+/api/workspace_translations.list:
+ get:
+ summary: List workspace translations
+ description: Retrieves all workspace-level translations. Returns one entry per locale with its nested key-value content.
+ operationId: listWorkspaceTranslations
+ security:
+ - BearerAuth: []
+ parameters:
+ - name: workspace_id
+ in: query
+ required: true
+ schema:
+ type: string
+ description: The ID of the workspace
+ example: ws_1234567890
+ responses:
+ '200':
+ description: List of workspace translations retrieved successfully
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ translations:
+ type: array
+ items:
+ $ref: '../components/schemas/workspace-translation.yaml#/WorkspaceTranslation'
+ '400':
+ description: Bad request - validation failed
+ content:
+ application/json:
+ schema:
+ $ref: '../components/schemas/common.yaml#/ErrorResponse'
+ '401':
+ description: Unauthorized
+ content:
+ application/json:
+ schema:
+ $ref: '../components/schemas/common.yaml#/ErrorResponse'
+
+/api/workspace_translations.upsert:
+ post:
+ summary: Create or update workspace translation
+ description: |
+ Creates or updates translations for a specific locale at the workspace level.
+ If translations for the locale already exist, they are replaced.
+ Workspace translations are shared across all templates and resolved when a template
+ uses `{{ "key" | t }}` and the key is not found in the template's own translations.
+ operationId: upsertWorkspaceTranslation
+ security:
+ - BearerAuth: []
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '../components/schemas/workspace-translation.yaml#/UpsertWorkspaceTranslationRequest'
+ responses:
+ '200':
+ description: Translation upserted successfully
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ success:
+ type: boolean
+ example: true
+ '400':
+ description: Bad request - validation failed
+ content:
+ application/json:
+ schema:
+ $ref: '../components/schemas/common.yaml#/ErrorResponse'
+ '401':
+ description: Unauthorized
+ content:
+ application/json:
+ schema:
+ $ref: '../components/schemas/common.yaml#/ErrorResponse'
+
+/api/workspace_translations.delete:
+ post:
+ summary: Delete workspace translation
+ description: Deletes all translations for a specific locale at the workspace level.
+ operationId: deleteWorkspaceTranslation
+ security:
+ - BearerAuth: []
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '../components/schemas/workspace-translation.yaml#/DeleteWorkspaceTranslationRequest'
+ responses:
+ '200':
+ description: Translation deleted successfully
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ success:
+ type: boolean
+ example: true
+ '400':
+ description: Bad request - validation failed
+ content:
+ application/json:
+ schema:
+ $ref: '../components/schemas/common.yaml#/ErrorResponse'
+ '401':
+ description: Unauthorized
+ content:
+ application/json:
+ schema:
+ $ref: '../components/schemas/common.yaml#/ErrorResponse'
+```
+
+### Step 2: Commit
+
+```bash
+git add openapi/paths/workspace-translations.yaml
+git commit -m "docs: add OpenAPI paths for workspace translations API"
+```
+
+---
+
+## Task 4: Update OpenAPI Root File
+
+Register the new schemas and paths in the root `openapi.yaml`.
+
+**Files:**
+- Modify: `openapi/openapi.yaml`
+
+### Step 1: Add workspace translation paths
+
+After the templates paths block (after line 75: `/api/templates.compile`), add:
+
+```yaml
+ /api/workspace_translations.list:
+ $ref: './paths/workspace-translations.yaml#/~1api~1workspace_translations.list'
+ /api/workspace_translations.upsert:
+ $ref: './paths/workspace-translations.yaml#/~1api~1workspace_translations.upsert'
+ /api/workspace_translations.delete:
+ $ref: './paths/workspace-translations.yaml#/~1api~1workspace_translations.delete'
+```
+
+### Step 2: Add workspace translation schema refs
+
+After the template schema refs in the `components.schemas` section (after line 220: `TrackingSettings`), add:
+
+```yaml
+ WorkspaceTranslation:
+ $ref: './components/schemas/workspace-translation.yaml#/WorkspaceTranslation'
+ UpsertWorkspaceTranslationRequest:
+ $ref: './components/schemas/workspace-translation.yaml#/UpsertWorkspaceTranslationRequest'
+ DeleteWorkspaceTranslationRequest:
+ $ref: './components/schemas/workspace-translation.yaml#/DeleteWorkspaceTranslationRequest'
+```
+
+### Step 3: Commit
+
+```bash
+git add openapi/openapi.yaml
+git commit -m "docs: register workspace translations in OpenAPI root"
+```
+
+---
+
+## Task 5: Update CHANGELOG
+
+Add v28.0 entry to the changelog.
+
+**Files:**
+- Modify: `CHANGELOG.md`
+
+### Step 1: Add v28.0 entry at the top (after line 3)
+
+```markdown
+## [28.0] - 2026-XX-XX
+
+### New Features
+
+- **Template i18n**: Auto-select email content based on contact language (#268)
+ - **Liquid `t` filter**: Use `{{ "key" | t }}` in templates to reference translation keys
+ - **Placeholder support**: Pass dynamic values with `{{ "greeting" | t: name: contact.first_name }}`
+ - **Nested keys**: Dot-separated key paths (e.g., `welcome.heading`, `cta.button`)
+ - **Per-template translations**: Store translation key-value maps per locale as part of the template
+ - **Workspace translations**: Shared translation catalog available to all templates in a workspace
+ - **Automatic locale resolution**: Fallback chain from `contact.language` → base language → template default → workspace default
+ - **Translations panel**: Manage translation keys and per-locale values in the template editor
+ - **Import/Export**: Bulk upload/download translations as JSON files per locale
+- **Workspace language settings**: Configure default language and supported languages in workspace settings
+
+### Database Migration
+
+- Added `translations` JSONB column and `default_language` VARCHAR column to `templates` table (workspace migration)
+- Created `workspace_translations` table for workspace-level shared translations (workspace migration)
+```
+
+Use the actual release date when shipping. The `XX-XX` placeholder should be replaced at release time.
+
+### Step 2: Commit
+
+```bash
+git add CHANGELOG.md
+git commit -m "docs: add v28.0 changelog entry for template i18n"
+```
+
+---
+
+## Task 6: Update CLAUDE.md — Migration Section
+
+Update the migration documentation section in CLAUDE.md to reflect V28 as the latest migration and add template i18n context.
+
+**Files:**
+- Modify: `CLAUDE.md`
+
+### Step 1: Update the migration example
+
+In the CLAUDE.md section "Creating Database Migrations", the example shows V7. Update the comment about the current version number. Find the text:
+
+```
+2. **Create Migration File**: Create a new file in `internal/migrations/` (e.g., `v7.go`)
+```
+
+No change needed here — this is a generic example and doesn't reference a specific current version. But ensure the VERSION constant reference is accurate. Search for any mention of `VERSION = "27.2"` or similar — there shouldn't be one, as CLAUDE.md references it generically.
+
+### Step 2: Add template i18n to the "Available Data Structure" context
+
+In the CLAUDE.md section about templates or wherever the available template variables are documented, note that the `t` filter is now available:
+
+This may not be explicitly documented in CLAUDE.md. If there's a section about Liquid templating or available filters, add:
+
+```markdown
+#### Translation Filter (v28.0+)
+
+Templates can reference translatable strings using the Liquid `t` filter:
+
+```liquid
+{{ "welcome.heading" | t }}
+{{ "welcome.greeting" | t: name: contact.first_name }}
+```
+
+Translations are stored per-locale as nested JSON on the template's `translations` field. The system resolves the best locale from `contact.language` with a fallback chain: exact match → base language → template default → workspace default.
+```
+
+### Step 3: Commit
+
+```bash
+git add CLAUDE.md
+git commit -m "docs: update CLAUDE.md with template i18n documentation"
+```
+
+---
+
+## Task 7: Update Design Doc with Migration Correction
+
+Fix the design doc migration section — `WorkspaceSettings` is stored as JSONB in the `settings` column, so `default_language` and `supported_languages` go into the struct, not as separate table columns. The design doc currently shows `ALTER TABLE workspaces ADD COLUMN` which is incorrect.
+
+**Files:**
+- Modify: `docs/plans/2026-02-24-template-i18n-design.md`
+
+### Step 1: Fix Section 3 (Database Migration)
+
+Replace the "System database" SQL block with:
+
+```markdown
+### System database
+
+No schema changes needed. `default_language` and `supported_languages` are added to the `WorkspaceSettings` Go struct. Since `WorkspaceSettings` is stored as JSONB in the existing `workspaces.settings` column, the new fields are automatically handled — existing workspaces will have these fields absent in JSON, and Go will deserialize them as zero values (falling back to `"en"` and `["en"]` via helper methods).
+```
+
+### Step 2: Update Section 2 (Data Model)
+
+Replace the "Modified: `Workspace` struct" subsection to clarify these fields go on `WorkspaceSettings`, not `Workspace`:
+
+```markdown
+### Modified: `WorkspaceSettings` struct (inside Workspace.Settings JSONB)
+
+```go
+type WorkspaceSettings struct {
+ // ... existing fields ...
+ DefaultLanguage string `json:"default_language,omitempty"` // e.g., "en"
+ SupportedLanguages []string `json:"supported_languages,omitempty"` // e.g., ["en", "fr", "de"]
+}
+```
+```
+
+### Step 3: Commit
+
+```bash
+git add docs/plans/2026-02-24-template-i18n-design.md
+git commit -m "docs: fix design doc migration section — language settings go in WorkspaceSettings JSONB"
+```
+
+---
+
+## Summary
+
+| Task | File(s) | What changes |
+|------|---------|-------------|
+| 1 | `openapi/components/schemas/template.yaml` | Add `translations`, `default_language` to Template + request schemas |
+| 2 | `openapi/components/schemas/workspace-translation.yaml` (new) | WorkspaceTranslation + request/response types |
+| 3 | `openapi/paths/workspace-translations.yaml` (new) | `.list`, `.upsert`, `.delete` endpoint definitions |
+| 4 | `openapi/openapi.yaml` | Register new paths and schemas |
+| 5 | `CHANGELOG.md` | v28.0 entry with feature list + migration notes |
+| 6 | `CLAUDE.md` | Add `t` filter documentation to templates/Liquid section |
+| 7 | `docs/plans/2026-02-24-template-i18n-design.md` | Fix migration section (WorkspaceSettings JSONB, not new columns) |
+
+Tasks 1-4 are the OpenAPI updates (sequential — schemas before paths before root).
+Task 5-6 are independent markdown updates.
+Task 7 is a correction to an existing doc.
diff --git a/docs/plans/2026-02-24-template-i18n-external-docs-design.md b/docs/plans/2026-02-24-template-i18n-external-docs-design.md
new file mode 100644
index 00000000..48d0639c
--- /dev/null
+++ b/docs/plans/2026-02-24-template-i18n-external-docs-design.md
@@ -0,0 +1,52 @@
+# Template i18n External Docs Update — Design Document
+
+**Related**: `docs/plans/2026-02-24-template-i18n-design.md` (feature design), `docs/plans/2026-02-24-template-i18n-docs.md` (internal docs plan)
+**Repo**: https://github.com/Notifuse/docs (cloned to `/var/www/forks/notifuse-docs`)
+**Framework**: Mintlify (MDX pages, `docs.json` nav, `openapi.json` API spec)
+**Date**: 2026-02-24
+
+## Goal
+
+Update the public docs site (docs.notifuse.com) to document the template i18n feature (v28.0). Users need to understand how to use the `t` filter, manage translations, and configure language settings.
+
+## Scope
+
+### New page
+
+**`features/template-translations.mdx`** — Dedicated guide for template internationalization.
+
+Sections:
+1. **Overview** — What it does and why (auto-select email content based on contact language)
+2. **The `t` Filter** — Liquid syntax with examples: simple key lookup, placeholder interpolation, usage in subject lines
+3. **Translation Keys** — Dot-separated key paths, nested JSON structure, example per-locale JSON
+4. **Per-Template Translations** — Translations panel in the template editor, managing keys and values per locale
+5. **Workspace Translations** — Shared translation catalog available to all templates, API-managed, acts as fallback
+6. **Locale Resolution** — Fallback chain: contact.language exact → base language → template default → workspace default
+7. **Import/Export** — JSON format per locale, upsert semantics for import, one file per locale on export
+8. **Best Practices** — Start with default language, use workspace translations for repeated strings (footers, CTAs), keep key naming consistent
+
+### Existing page updates
+
+| Page | What to add |
+|------|-------------|
+| `features/templates.mdx` | New "Translations" section after Liquid Syntax: brief `t` filter example + link to `features/template-translations` |
+| `features/workspaces.mdx` | New "Language Settings" section: default language, supported languages, how they affect template rendering |
+| `features/contacts.mdx` | Expand `language` field row description to mention it drives automatic locale resolution for translated templates |
+| `features/transactional-api.mdx` | Add "Multi-Language Support" note: language is auto-resolved from `contact.language`, no API changes needed |
+| `features/broadcast-campaigns.mdx` | Add "Multi-Language Support" note: per-contact language resolution happens automatically in the batch loop |
+
+### Navigation & API spec
+
+| File | What to change |
+|------|----------------|
+| `docs.json` | Add `features/template-translations` to Features group (after `features/templates`). Add "Workspace Translations" group to API Reference tab with list/upsert/delete endpoints. |
+| `openapi.json` | Add 3 workspace_translations endpoints (GET list, POST upsert, POST delete). Update Template schema + CreateTemplateRequest + UpdateTemplateRequest with `translations` and `default_language` fields. Update CompileTemplateRequest with `translations` field. |
+
+## Design Decisions
+
+| Decision | Choice | Rationale |
+|----------|--------|-----------|
+| Dedicated page vs. expand templates.mdx | Dedicated page | Templates page is already 133 lines; i18n is a self-contained feature with enough depth for its own page |
+| Where to place in nav | After "Templates" in Features | Natural reading order — learn about templates first, then translations |
+| openapi.json updates | Yes | Keeps API Reference tab current; workspace_translations endpoints need to be discoverable |
+| Existing page updates | Brief mentions + links | Avoids duplicating content; each page acknowledges the feature exists and links to the dedicated guide |
diff --git a/docs/plans/2026-02-24-template-i18n-external-docs.md b/docs/plans/2026-02-24-template-i18n-external-docs.md
new file mode 100644
index 00000000..71b0d481
--- /dev/null
+++ b/docs/plans/2026-02-24-template-i18n-external-docs.md
@@ -0,0 +1,839 @@
+# Template i18n External Docs Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Update the public docs site (docs.notifuse.com) to document the template i18n feature (v28.0).
+
+**Architecture:** Add a dedicated `features/template-translations.mdx` page as the primary guide, with brief mentions and links from existing pages (templates, workspaces, contacts, transactional API, broadcasts). Update `openapi.json` with workspace_translations endpoints and template schema changes. Update `docs.json` navigation.
+
+**Tech Stack:** Mintlify (MDX), OpenAPI 3.0.3 JSON.
+
+**Design doc:** `docs/plans/2026-02-24-template-i18n-external-docs-design.md`
+
+**Repo:** `/var/www/forks/notifuse-docs` (cloned from https://github.com/Notifuse/docs)
+
+---
+
+## Task 1: Create the dedicated template translations page
+
+The main deliverable. New MDX page covering the full i18n feature.
+
+**Files:**
+- Create: `features/template-translations.mdx`
+
+### Step 1: Create the page
+
+Create `features/template-translations.mdx` with this content:
+
+```mdx
+---
+title: Template Translations
+description: 'Send emails in your contacts'' preferred language using translation keys. Define translations per template or share them across your workspace, and Notifuse automatically selects the right language at send time.'
+---
+
+## Overview
+
+Template translations let you send email content in each contact's preferred language without duplicating templates. Instead of creating separate templates per language, you write a single template using **translation keys** and provide translations for each supported locale.
+
+When an email is sent, Notifuse automatically resolves the correct locale based on the contact's `language` field and renders the template with the matching translations.
+
+## The `t` Filter
+
+Use the Liquid `t` filter to reference translation keys in your templates:
+
+### Simple Key Lookup
+
+```liquid
+{{ "welcome.heading" | t }}
+```
+
+If the contact's language is `fr`, this renders the French translation for the key `welcome.heading`.
+
+### Placeholders
+
+Pass dynamic values into translation strings using named arguments:
+
+```liquid
+{{ "welcome.greeting" | t: name: contact.first_name }}
+```
+
+With the translation string `"Hello {{ name }}!"` and a contact named Sarah, this renders: **Hello Sarah!**
+
+### Subject Lines
+
+The `t` filter works in email subject lines too:
+
+```liquid
+{{ "welcome.subject" | t }}
+```
+
+### Full Example
+
+**Template:**
+
+```liquid
+{{ "welcome.heading" | t }}
+
+{{ "welcome.greeting" | t: name: contact.first_name }}
+
+{{ "welcome.body" | t }}
+
+{{ "cta.button" | t }}
+```
+
+**English translations:**
+
+```json
+{
+ "welcome": {
+ "heading": "Welcome!",
+ "greeting": "Hello {{ name }}!",
+ "body": "Thanks for joining us."
+ },
+ "cta": {
+ "button": "Get Started"
+ }
+}
+```
+
+**French translations:**
+
+```json
+{
+ "welcome": {
+ "heading": "Bienvenue !",
+ "greeting": "Bonjour {{ name }} !",
+ "body": "Merci de nous avoir rejoints."
+ },
+ "cta": {
+ "button": "Commencer"
+ }
+}
+```
+
+## Translation Keys
+
+Keys use **dot-separated paths** that map to a nested JSON structure. This keeps translations organized:
+
+| Key | JSON Path |
+|-----|-----------|
+| `welcome.heading` | `{ "welcome": { "heading": "..." } }` |
+| `welcome.greeting` | `{ "welcome": { "greeting": "..." } }` |
+| `cta.button` | `{ "cta": { "button": "..." } }` |
+| `footer.unsubscribe` | `{ "footer": { "unsubscribe": "..." } }` |
+
+If a key is missing for the resolved locale, the template renders `[Missing translation: key.name]` so you can spot untranslated strings.
+
+## Per-Template Translations
+
+Each template has its own `translations` field — a JSON object keyed by locale code, containing the nested key-value translations for that locale.
+
+### Managing Translations in the Editor
+
+The template editor includes a **Translations panel** where you can:
+
+- Add, edit, and remove translation keys
+- Provide values for each supported language
+- See which keys are missing translations (shown with a warning indicator)
+- Preview the template in different languages
+
+### Import / Export
+
+You can bulk-manage translations using JSON files:
+
+- **Export**: Downloads one JSON file per locale (e.g., `en.json`, `fr.json`)
+- **Import**: Upload a JSON file for a specific locale. New keys are added, existing keys are overwritten, absent keys are left untouched.
+
+The JSON format matches the nested key structure:
+
+```json
+{
+ "welcome": {
+ "heading": "Welcome!",
+ "greeting": "Hello {{ name }}!"
+ },
+ "cta": {
+ "button": "Get Started"
+ }
+}
+```
+
+## Workspace Translations
+
+Workspace translations are a **shared translation catalog** available to all templates in a workspace. They act as a fallback — if a template doesn't define a key, the workspace translation is used instead.
+
+This is useful for strings that appear across many templates:
+
+- Footer text (`footer.unsubscribe`, `footer.company_name`)
+- Common CTAs (`cta.learn_more`, `cta.contact_us`)
+- Legal text (`legal.privacy`, `legal.terms`)
+
+Workspace translations are managed via the API:
+
+- `GET /api/workspace_translations.list` — List all workspace translations
+- `POST /api/workspace_translations.upsert` — Create or update translations for a locale
+- `POST /api/workspace_translations.delete` — Delete translations for a locale
+
+See the [API Reference](/api-reference) for full endpoint documentation.
+
+### Resolution Priority
+
+When a template uses `{{ "key" | t }}`, the system looks for the key in this order:
+
+1. **Template translations** for the resolved locale
+2. **Workspace translations** for the resolved locale
+
+Template translations always take priority. A template key `welcome.heading` shadows a workspace key `welcome.heading`, but a workspace key `footer.unsubscribe` is accessible if the template doesn't define it.
+
+## Locale Resolution
+
+When an email is sent, Notifuse determines which locale to use with this fallback chain:
+
+| Priority | Source | Example |
+|----------|--------|---------|
+| 1 | Contact's `language` (exact match) | `pt-BR` → uses `pt-BR` translations |
+| 2 | Contact's `language` (base language) | `pt-BR` → falls back to `pt` if no `pt-BR` |
+| 3 | Template's `default_language` | If set, overrides the workspace default |
+| 4 | Workspace's default language | Configured in workspace settings (e.g., `en`) |
+
+**Examples:**
+
+- Contact has `language: "fr"` → French translations are used
+- Contact has `language: "pt-BR"`, no `pt-BR` translations exist, but `pt` does → Portuguese translations are used
+- Contact has no `language` set → Falls back to the template default, then the workspace default
+- Template has `default_language: "de"` → German is used as the fallback instead of the workspace default
+
+### Setting the Template Default Language
+
+Each template can optionally set a `default_language` that overrides the workspace default. This is useful when a template is primarily written in a specific language that differs from the workspace default.
+
+## Workspace Language Settings
+
+Configure language defaults in your workspace settings:
+
+- **Default Language**: The fallback language used when a contact has no `language` field set (e.g., `en`)
+- **Supported Languages**: The list of languages your workspace supports (e.g., `["en", "fr", "de", "es"]`). This determines which locale columns appear in the translations panel.
+
+## Best Practices
+
+- **Start with your default language**: Always provide complete translations for your workspace's default language first. This ensures every contact sees content, even if their language isn't supported yet.
+- **Use workspace translations for shared strings**: Footer text, legal disclaimers, and common CTAs should live in workspace translations to avoid duplication across templates.
+- **Keep key names consistent**: Use a predictable naming convention like `section.element` (e.g., `welcome.heading`, `cta.button`, `footer.unsubscribe`).
+- **Set `contact.language` on your contacts**: The i18n system relies on the contact's `language` field. Set it via the API, CSV import, or let the [Notification Center](/features/notification-center) auto-detect it.
+- **Test with preview**: Use the template editor's language preview selector to check how your email looks in each supported language before sending.
+```
+
+### Step 2: Commit
+
+```bash
+cd /var/www/forks/notifuse-docs
+git add features/template-translations.mdx
+git commit -m "docs: add template translations feature page"
+```
+
+---
+
+## Task 2: Update templates.mdx with translation mention
+
+Add a brief section linking to the dedicated translations page.
+
+**Files:**
+- Modify: `features/templates.mdx`
+
+### Step 1: Add Translations section
+
+After the "Available Data Structure" section (after line 133, at the end of the file), add:
+
+```mdx
+
+## Translations
+
+Templates support built-in internationalization using the Liquid `t` filter. Instead of duplicating templates per language, define translation keys and provide per-locale values:
+
+```liquid
+{{ "welcome.heading" | t }}
+{{ "welcome.greeting" | t: name: contact.first_name }}
+```
+
+Notifuse automatically selects the right language based on the contact's `language` field. For the full guide, see [Template Translations](/features/template-translations).
+```
+
+### Step 2: Commit
+
+```bash
+cd /var/www/forks/notifuse-docs
+git add features/templates.mdx
+git commit -m "docs: add translations section to templates page"
+```
+
+---
+
+## Task 3: Update workspaces.mdx with language settings
+
+Add a Language Settings section to the workspaces page.
+
+**Files:**
+- Modify: `features/workspaces.mdx`
+
+### Step 1: Add Language Settings section
+
+After the "Multi-Tenant Architecture" section (after line 34, at the end of the file), add:
+
+```mdx
+
+## Language Settings
+
+Each workspace can configure language defaults that apply to all templates:
+
+- **Default Language**: The fallback language used when a contact has no `language` field set (e.g., `en`). All templates will use this as their final fallback.
+- **Supported Languages**: The list of languages your workspace supports (e.g., English, French, German). This determines which locale columns appear in the template translations panel.
+
+These settings work with [Template Translations](/features/template-translations) to automatically send emails in each contact's preferred language.
+```
+
+### Step 2: Commit
+
+```bash
+cd /var/www/forks/notifuse-docs
+git add features/workspaces.mdx
+git commit -m "docs: add language settings section to workspaces page"
+```
+
+---
+
+## Task 4: Update contacts.mdx language field description
+
+Expand the `language` field description to mention its role in i18n.
+
+**Files:**
+- Modify: `features/contacts.mdx`
+
+### Step 1: Update the language field row
+
+In the Contact Fields table (line 25), find:
+
+```
+| `language` | String | Preferred language |
+```
+
+Replace with:
+
+```
+| `language` | String | Preferred language (drives [automatic locale resolution](/features/template-translations#locale-resolution) for translated templates) |
+```
+
+### Step 2: Commit
+
+```bash
+cd /var/www/forks/notifuse-docs
+git add features/contacts.mdx
+git commit -m "docs: expand language field description with i18n link"
+```
+
+---
+
+## Task 5: Update transactional-api.mdx with multi-language note
+
+Add a note about automatic language resolution.
+
+**Files:**
+- Modify: `features/transactional-api.mdx`
+
+### Step 1: Add Multi-Language Support section
+
+After the "Key Features" section's last subsection (after "Email Delivery Options", before "## API Endpoint" at line 57), add:
+
+```mdx
+
+### Multi-Language Support
+
+If your templates use [translation keys](/features/template-translations), Notifuse automatically selects the right language based on `contact.language`. No API changes are needed — just make sure your contacts have a `language` field set:
+
+```json
+{
+ "notification": {
+ "contact": {
+ "email": "user@example.com",
+ "language": "fr"
+ }
+ }
+}
+```
+
+The contact's language is resolved through a [fallback chain](/features/template-translations#locale-resolution): exact match → base language → template default → workspace default.
+```
+
+### Step 2: Commit
+
+```bash
+cd /var/www/forks/notifuse-docs
+git add features/transactional-api.mdx
+git commit -m "docs: add multi-language support note to transactional API page"
+```
+
+---
+
+## Task 6: Update broadcast-campaigns.mdx with multi-language note
+
+Add a note about per-contact language resolution in broadcasts.
+
+**Files:**
+- Modify: `features/broadcast-campaigns.mdx`
+
+### Step 1: Add Multi-Language Support section
+
+Before the "## Best Practices" section (before line 358), add:
+
+```mdx
+
+## Multi-Language Support
+
+If your broadcast template uses [translation keys](/features/template-translations), each recipient automatically receives the email in their preferred language based on their `language` field.
+
+The template and workspace translations are loaded once per broadcast. For each recipient, only the locale resolution changes — selecting the right translation set based on the contact's language. This means multi-language broadcasts have no significant performance overhead.
+
+See [Template Translations](/features/template-translations) for how to set up translation keys and manage per-locale content.
+
+```
+
+### Step 2: Commit
+
+```bash
+cd /var/www/forks/notifuse-docs
+git add features/broadcast-campaigns.mdx
+git commit -m "docs: add multi-language support note to broadcast campaigns page"
+```
+
+---
+
+## Task 7: Update docs.json navigation
+
+Register the new page and API endpoints in the Mintlify navigation.
+
+**Files:**
+- Modify: `docs.json`
+
+### Step 1: Add template-translations to Features nav
+
+In the `docs.json` file, find the Features group pages array. After `"features/templates"` (line 43), add `"features/template-translations"` as the next entry.
+
+Before:
+```json
+ "features/templates",
+ "features/broadcast-campaigns",
+```
+
+After:
+```json
+ "features/templates",
+ "features/template-translations",
+ "features/broadcast-campaigns",
+```
+
+### Step 2: Add Workspace Translations group to API Reference tab
+
+In the API Reference tab's groups array, after the Templates group (after line 153), add a new group:
+
+```json
+ {
+ "group": "Workspace Translations",
+ "openapi": "openapi.json",
+ "pages": [
+ "GET /api/workspace_translations.list",
+ "POST /api/workspace_translations.upsert",
+ "POST /api/workspace_translations.delete"
+ ]
+ },
+```
+
+### Step 3: Commit
+
+```bash
+cd /var/www/forks/notifuse-docs
+git add docs.json
+git commit -m "docs: add template translations to navigation"
+```
+
+---
+
+## Task 8: Update openapi.json — Template schemas
+
+Add `translations` and `default_language` fields to template-related schemas.
+
+**Files:**
+- Modify: `openapi.json`
+
+### Step 1: Add fields to Template schema
+
+In the `Template` schema (components → schemas → Template → properties), after the `settings` property, add:
+
+```json
+ "translations": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "object",
+ "additionalProperties": true
+ },
+ "description": "Per-locale translation key-value maps. Keys are dot-separated paths (e.g., \"welcome.heading\"). Values are strings that may contain {{ placeholder }} syntax for named arguments. Outer keys are locale codes (e.g., \"en\", \"fr\", \"pt-BR\").",
+ "example": {
+ "en": {
+ "welcome": {
+ "heading": "Welcome!",
+ "greeting": "Hello {{ name }}!"
+ }
+ },
+ "fr": {
+ "welcome": {
+ "heading": "Bienvenue !",
+ "greeting": "Bonjour {{ name }} !"
+ }
+ }
+ }
+ },
+ "default_language": {
+ "type": "string",
+ "nullable": true,
+ "description": "Override the workspace default language for this template. When null, inherits from workspace settings.",
+ "example": "en",
+ "maxLength": 10
+ },
+```
+
+### Step 2: Add fields to CreateTemplateRequest schema
+
+In the `CreateTemplateRequest` schema (components → schemas → CreateTemplateRequest → properties), after the `settings` property, add:
+
+```json
+ "translations": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "object",
+ "additionalProperties": true
+ },
+ "description": "Per-locale translation key-value maps"
+ },
+ "default_language": {
+ "type": "string",
+ "nullable": true,
+ "description": "Override the workspace default language for this template",
+ "maxLength": 10
+ },
+```
+
+### Step 3: Add fields to UpdateTemplateRequest schema
+
+Same as CreateTemplateRequest — add `translations` and `default_language` after `settings`.
+
+### Step 4: Add translations to CompileTemplateRequest schema
+
+In the `CompileTemplateRequest` schema, after the `channel` property, add:
+
+```json
+ "translations": {
+ "type": "object",
+ "additionalProperties": true,
+ "description": "Merged translations map for a specific locale, used by the Liquid t filter during compilation"
+ },
+```
+
+### Step 5: Verify JSON is valid
+
+Run: `cd /var/www/forks/notifuse-docs && python3 -c "import json; json.load(open('openapi.json'))" 2>&1 || echo "JSON invalid"`
+
+### Step 6: Commit
+
+```bash
+cd /var/www/forks/notifuse-docs
+git add openapi.json
+git commit -m "docs: add translations fields to OpenAPI template schemas"
+```
+
+---
+
+## Task 9: Update openapi.json — Workspace Translations endpoints
+
+Add the three workspace translation API endpoints and their schemas.
+
+**Files:**
+- Modify: `openapi.json`
+
+### Step 1: Add WorkspaceTranslation schema
+
+In the `components.schemas` section, add a new `WorkspaceTranslation` schema:
+
+```json
+ "WorkspaceTranslation": {
+ "type": "object",
+ "properties": {
+ "locale": {
+ "type": "string",
+ "description": "Locale code (e.g., \"en\", \"fr\", \"pt-BR\")",
+ "example": "en",
+ "maxLength": 10
+ },
+ "content": {
+ "type": "object",
+ "additionalProperties": true,
+ "description": "Nested key-value translation map. Keys use dot-separated paths. Values are strings, optionally containing {{ placeholder }} syntax.",
+ "example": {
+ "common": {
+ "greeting": "Hello",
+ "footer": "Unsubscribe from our emails"
+ }
+ }
+ },
+ "created_at": {
+ "type": "string",
+ "format": "date-time",
+ "description": "When the translation was created"
+ },
+ "updated_at": {
+ "type": "string",
+ "format": "date-time",
+ "description": "When the translation was last updated"
+ }
+ },
+ "required": ["locale", "content"]
+ },
+ "UpsertWorkspaceTranslationRequest": {
+ "type": "object",
+ "required": ["workspace_id", "locale", "content"],
+ "properties": {
+ "workspace_id": {
+ "type": "string",
+ "description": "The ID of the workspace",
+ "example": "ws_1234567890"
+ },
+ "locale": {
+ "type": "string",
+ "description": "Locale code",
+ "example": "fr",
+ "maxLength": 10
+ },
+ "content": {
+ "type": "object",
+ "additionalProperties": true,
+ "description": "Nested key-value translation map",
+ "example": {
+ "common": {
+ "greeting": "Bonjour",
+ "footer": "Se désabonner de nos emails"
+ }
+ }
+ }
+ }
+ },
+ "DeleteWorkspaceTranslationRequest": {
+ "type": "object",
+ "required": ["workspace_id", "locale"],
+ "properties": {
+ "workspace_id": {
+ "type": "string",
+ "description": "The ID of the workspace",
+ "example": "ws_1234567890"
+ },
+ "locale": {
+ "type": "string",
+ "description": "Locale code to delete",
+ "example": "fr"
+ }
+ }
+ },
+```
+
+### Step 2: Add workspace_translations.list path
+
+In the `paths` section, add:
+
+```json
+ "/api/workspace_translations.list": {
+ "get": {
+ "summary": "List workspace translations",
+ "description": "Retrieves all workspace-level translations. Returns one entry per locale with its nested key-value content.",
+ "operationId": "listWorkspaceTranslations",
+ "security": [{ "BearerAuth": [] }],
+ "parameters": [
+ {
+ "name": "workspace_id",
+ "in": "query",
+ "required": true,
+ "schema": { "type": "string" },
+ "description": "The ID of the workspace",
+ "example": "ws_1234567890"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "List of workspace translations retrieved successfully",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "translations": {
+ "type": "array",
+ "items": { "$ref": "#/components/schemas/WorkspaceTranslation" }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad request - validation failed",
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/ErrorResponse" }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/ErrorResponse" }
+ }
+ }
+ }
+ }
+ }
+ },
+```
+
+### Step 3: Add workspace_translations.upsert path
+
+```json
+ "/api/workspace_translations.upsert": {
+ "post": {
+ "summary": "Create or update workspace translation",
+ "description": "Creates or updates translations for a specific locale at the workspace level. If translations for the locale already exist, they are replaced. Workspace translations are shared across all templates and resolved when a template uses {{ \"key\" | t }} and the key is not found in the template's own translations.",
+ "operationId": "upsertWorkspaceTranslation",
+ "security": [{ "BearerAuth": [] }],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/UpsertWorkspaceTranslationRequest" }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Translation upserted successfully",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "success": { "type": "boolean", "example": true }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad request - validation failed",
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/ErrorResponse" }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/ErrorResponse" }
+ }
+ }
+ }
+ }
+ }
+ },
+```
+
+### Step 4: Add workspace_translations.delete path
+
+```json
+ "/api/workspace_translations.delete": {
+ "post": {
+ "summary": "Delete workspace translation",
+ "description": "Deletes all translations for a specific locale at the workspace level.",
+ "operationId": "deleteWorkspaceTranslation",
+ "security": [{ "BearerAuth": [] }],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/DeleteWorkspaceTranslationRequest" }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Translation deleted successfully",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "success": { "type": "boolean", "example": true }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad request - validation failed",
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/ErrorResponse" }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/ErrorResponse" }
+ }
+ }
+ }
+ }
+ }
+ },
+```
+
+### Step 5: Verify JSON is valid
+
+Run: `cd /var/www/forks/notifuse-docs && python3 -c "import json; json.load(open('openapi.json'))" 2>&1 || echo "JSON invalid"`
+
+### Step 6: Commit
+
+```bash
+cd /var/www/forks/notifuse-docs
+git add openapi.json
+git commit -m "docs: add workspace translations API to OpenAPI spec"
+```
+
+---
+
+## Summary
+
+| Task | File(s) | What changes |
+|------|---------|-------------|
+| 1 | `features/template-translations.mdx` (new) | Full i18n guide: t filter, keys, locale resolution, workspace translations, import/export, best practices |
+| 2 | `features/templates.mdx` | Brief Translations section with `t` filter example + link |
+| 3 | `features/workspaces.mdx` | Language Settings section (default language, supported languages) |
+| 4 | `features/contacts.mdx` | Expand `language` field description with i18n link |
+| 5 | `features/transactional-api.mdx` | Multi-Language Support note under Key Features |
+| 6 | `features/broadcast-campaigns.mdx` | Multi-Language Support section before Best Practices |
+| 7 | `docs.json` | Add page to Features nav + Workspace Translations API group |
+| 8 | `openapi.json` | Add translations/default_language to Template schemas |
+| 9 | `openapi.json` | Add workspace_translations endpoints + schemas |
+
+Tasks 1-6 are MDX page changes (Task 1 is the big one, 2-6 are small additions).
+Task 7 is navigation config.
+Tasks 8-9 are OpenAPI spec updates.
+
+All tasks are independent except: Task 7 depends on Task 1 (page must exist before adding to nav), and Task 9 depends on Task 8 (both modify the same file sequentially).
diff --git a/docs/plans/2026-02-24-template-i18n-implementation.md b/docs/plans/2026-02-24-template-i18n-implementation.md
new file mode 100644
index 00000000..caf1c83a
--- /dev/null
+++ b/docs/plans/2026-02-24-template-i18n-implementation.md
@@ -0,0 +1,1949 @@
+# Template i18n Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Add template-level i18n using a Liquid `t` filter so emails are automatically sent in the contact's language.
+
+**Architecture:** Translation keys (`{{ "key" | t }}`) stored as nested JSON per locale in the template's `translations` JSONB column. Workspace-level shared translations in a new `workspace_translations` table. A custom Liquid filter resolves keys at render time with a locale fallback chain (contact.language → base language → template default → workspace default). No changes to the transactional API — language selection is automatic.
+
+**Tech Stack:** Go 1.25 (backend), liquidgo (Liquid engine), PostgreSQL JSONB, React 18 + Ant Design + TypeScript (frontend), Vitest (frontend tests), Go standard testing + testify + gomock (backend tests).
+
+**Design doc:** `docs/plans/2026-02-24-template-i18n-design.md`
+
+---
+
+## Task 1: Translation Utility Functions (Domain Layer)
+
+Core helper functions for locale resolution, nested key lookup, placeholder interpolation, and translation merging. These are pure functions with no dependencies — the foundation for everything else.
+
+**Files:**
+- Create: `internal/domain/translation.go`
+- Create: `internal/domain/translation_test.go`
+
+### Step 1: Write failing tests for `ResolveNestedKey`
+
+This function traverses a nested `map[string]interface{}` using a dot-separated key path and returns the string value.
+
+```go
+// internal/domain/translation_test.go
+package domain
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestResolveNestedKey(t *testing.T) {
+ translations := map[string]interface{}{
+ "welcome": map[string]interface{}{
+ "heading": "Welcome!",
+ "greeting": "Hello {{ name }}!",
+ },
+ "cta": map[string]interface{}{
+ "button": "Get Started",
+ },
+ "flat_key": "Flat value",
+ }
+
+ tests := []struct {
+ name string
+ data map[string]interface{}
+ key string
+ expected string
+ }{
+ {"nested key", translations, "welcome.heading", "Welcome!"},
+ {"deeper nested", translations, "welcome.greeting", "Hello {{ name }}!"},
+ {"different group", translations, "cta.button", "Get Started"},
+ {"flat key", translations, "flat_key", "Flat value"},
+ {"missing key", translations, "welcome.missing", ""},
+ {"missing group", translations, "nonexistent.key", ""},
+ {"empty key", translations, "", ""},
+ {"nil data", nil, "welcome.heading", ""},
+ {"empty data", map[string]interface{}{}, "welcome.heading", ""},
+ {"key pointing to map not string", translations, "welcome", ""},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ result := ResolveNestedKey(tc.data, tc.key)
+ assert.Equal(t, tc.expected, result)
+ })
+ }
+}
+```
+
+### Step 2: Run tests to verify they fail
+
+Run: `cd /var/www/forks/notifuse && go test ./internal/domain/ -run TestResolveNestedKey -v`
+Expected: Compilation error — `ResolveNestedKey` undefined.
+
+### Step 3: Implement `ResolveNestedKey`
+
+```go
+// internal/domain/translation.go
+package domain
+
+import (
+ "strings"
+)
+
+// ResolveNestedKey traverses a nested map using a dot-separated key path
+// and returns the string value. Returns empty string if key not found or value is not a string.
+func ResolveNestedKey(data map[string]interface{}, key string) string {
+ if data == nil || key == "" {
+ return ""
+ }
+
+ parts := strings.Split(key, ".")
+ var current interface{} = data
+
+ for _, part := range parts {
+ m, ok := current.(map[string]interface{})
+ if !ok {
+ return ""
+ }
+ current, ok = m[part]
+ if !ok {
+ return ""
+ }
+ }
+
+ if str, ok := current.(string); ok {
+ return str
+ }
+ return ""
+}
+```
+
+### Step 4: Run tests to verify they pass
+
+Run: `cd /var/www/forks/notifuse && go test ./internal/domain/ -run TestResolveNestedKey -v`
+Expected: All PASS.
+
+### Step 5: Write failing tests for `InterpolatePlaceholders`
+
+This function replaces `{{ name }}` style placeholders in a translation value with provided key-value arguments.
+
+```go
+// Append to internal/domain/translation_test.go
+
+func TestInterpolatePlaceholders(t *testing.T) {
+ tests := []struct {
+ name string
+ value string
+ args map[string]interface{}
+ expected string
+ }{
+ {
+ "single placeholder",
+ "Hello {{ name }}!",
+ map[string]interface{}{"name": "John"},
+ "Hello John!",
+ },
+ {
+ "multiple placeholders",
+ "{{ greeting }} {{ name }}, welcome to {{ site }}!",
+ map[string]interface{}{"greeting": "Hello", "name": "Jane", "site": "Notifuse"},
+ "Hello Jane, welcome to Notifuse!",
+ },
+ {
+ "no placeholders",
+ "Hello World!",
+ map[string]interface{}{"name": "John"},
+ "Hello World!",
+ },
+ {
+ "placeholder without matching arg",
+ "Hello {{ name }}!",
+ map[string]interface{}{},
+ "Hello {{ name }}!",
+ },
+ {
+ "nil args",
+ "Hello {{ name }}!",
+ nil,
+ "Hello {{ name }}!",
+ },
+ {
+ "no spaces in placeholder",
+ "Hello {{name}}!",
+ map[string]interface{}{"name": "John"},
+ "Hello John!",
+ },
+ {
+ "extra spaces in placeholder",
+ "Hello {{ name }}!",
+ map[string]interface{}{"name": "John"},
+ "Hello John!",
+ },
+ {
+ "numeric value",
+ "You have {{ count }} items",
+ map[string]interface{}{"count": 5},
+ "You have 5 items",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ result := InterpolatePlaceholders(tc.value, tc.args)
+ assert.Equal(t, tc.expected, result)
+ })
+ }
+}
+```
+
+### Step 6: Run tests to verify they fail
+
+Run: `cd /var/www/forks/notifuse && go test ./internal/domain/ -run TestInterpolatePlaceholders -v`
+Expected: Compilation error.
+
+### Step 7: Implement `InterpolatePlaceholders`
+
+```go
+// Append to internal/domain/translation.go
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+)
+
+var placeholderRegex = regexp.MustCompile(`\{\{\s*(\w+)\s*\}\}`)
+
+// InterpolatePlaceholders replaces {{ key }} placeholders in a translation value
+// with the corresponding values from the args map.
+func InterpolatePlaceholders(value string, args map[string]interface{}) string {
+ if args == nil || len(args) == 0 {
+ return value
+ }
+
+ return placeholderRegex.ReplaceAllStringFunc(value, func(match string) string {
+ submatch := placeholderRegex.FindStringSubmatch(match)
+ if len(submatch) < 2 {
+ return match
+ }
+ key := submatch[1]
+ if val, ok := args[key]; ok {
+ return fmt.Sprintf("%v", val)
+ }
+ return match // leave unresolved placeholders as-is
+ })
+}
+```
+
+### Step 8: Run tests to verify they pass
+
+Run: `cd /var/www/forks/notifuse && go test ./internal/domain/ -run TestInterpolatePlaceholders -v`
+Expected: All PASS.
+
+### Step 9: Write failing tests for `ResolveLocale`
+
+The locale fallback chain: exact match → base language → template default → workspace default.
+
+```go
+// Append to internal/domain/translation_test.go
+
+func TestResolveLocale(t *testing.T) {
+ tests := []struct {
+ name string
+ contactLanguage string
+ availableLocales []string
+ templateDefault *string
+ workspaceDefault string
+ expected string
+ }{
+ {
+ "exact match",
+ "fr",
+ []string{"en", "fr", "de"},
+ nil,
+ "en",
+ "fr",
+ },
+ {
+ "exact match with region",
+ "pt-BR",
+ []string{"en", "pt-BR", "pt"},
+ nil,
+ "en",
+ "pt-BR",
+ },
+ {
+ "base language fallback",
+ "pt-BR",
+ []string{"en", "pt"},
+ nil,
+ "en",
+ "pt",
+ },
+ {
+ "template default fallback",
+ "ja",
+ []string{"en", "fr"},
+ strPtr("fr"),
+ "en",
+ "fr",
+ },
+ {
+ "workspace default fallback",
+ "ja",
+ []string{"en", "fr"},
+ nil,
+ "en",
+ "en",
+ },
+ {
+ "empty contact language uses workspace default",
+ "",
+ []string{"en", "fr"},
+ nil,
+ "en",
+ "en",
+ },
+ {
+ "case insensitive match",
+ "FR",
+ []string{"en", "fr"},
+ nil,
+ "en",
+ "fr",
+ },
+ {
+ "workspace default when no locales available",
+ "fr",
+ []string{},
+ nil,
+ "en",
+ "en",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ result := ResolveLocale(tc.contactLanguage, tc.availableLocales, tc.templateDefault, tc.workspaceDefault)
+ assert.Equal(t, tc.expected, result)
+ })
+ }
+}
+
+func strPtr(s string) *string {
+ return &s
+}
+```
+
+### Step 10: Run tests to verify they fail
+
+Run: `cd /var/www/forks/notifuse && go test ./internal/domain/ -run TestResolveLocale -v`
+Expected: Compilation error.
+
+### Step 11: Implement `ResolveLocale`
+
+```go
+// Append to internal/domain/translation.go
+
+// ResolveLocale determines the best locale to use given a contact's language preference,
+// available translation locales, and fallback defaults.
+// Fallback chain: exact match → base language → template default → workspace default.
+func ResolveLocale(contactLanguage string, availableLocales []string, templateDefault *string, workspaceDefault string) string {
+ if contactLanguage == "" {
+ if templateDefault != nil && *templateDefault != "" {
+ return *templateDefault
+ }
+ return workspaceDefault
+ }
+
+ contactLang := strings.ToLower(contactLanguage)
+
+ // 1. Exact match (case-insensitive)
+ for _, locale := range availableLocales {
+ if strings.ToLower(locale) == contactLang {
+ return locale
+ }
+ }
+
+ // 2. Base language match (e.g., "pt-BR" → "pt")
+ if idx := strings.Index(contactLang, "-"); idx > 0 {
+ baseLang := contactLang[:idx]
+ for _, locale := range availableLocales {
+ if strings.ToLower(locale) == baseLang {
+ return locale
+ }
+ }
+ }
+
+ // 3. Template default language
+ if templateDefault != nil && *templateDefault != "" {
+ return *templateDefault
+ }
+
+ // 4. Workspace default language
+ return workspaceDefault
+}
+```
+
+### Step 12: Run tests to verify they pass
+
+Run: `cd /var/www/forks/notifuse && go test ./internal/domain/ -run TestResolveLocale -v`
+Expected: All PASS.
+
+### Step 13: Write failing tests for `MergeTranslations`
+
+Deep-merges two translation maps. Template translations take priority over workspace translations.
+
+```go
+// Append to internal/domain/translation_test.go
+
+func TestMergeTranslations(t *testing.T) {
+ tests := []struct {
+ name string
+ base map[string]interface{}
+ override map[string]interface{}
+ expected map[string]interface{}
+ }{
+ {
+ "override wins",
+ map[string]interface{}{"welcome": map[string]interface{}{"heading": "Base"}},
+ map[string]interface{}{"welcome": map[string]interface{}{"heading": "Override"}},
+ map[string]interface{}{"welcome": map[string]interface{}{"heading": "Override"}},
+ },
+ {
+ "deep merge adds missing keys",
+ map[string]interface{}{"welcome": map[string]interface{}{"heading": "Base"}},
+ map[string]interface{}{"welcome": map[string]interface{}{"body": "Override body"}},
+ map[string]interface{}{"welcome": map[string]interface{}{"heading": "Base", "body": "Override body"}},
+ },
+ {
+ "nil base",
+ nil,
+ map[string]interface{}{"key": "value"},
+ map[string]interface{}{"key": "value"},
+ },
+ {
+ "nil override",
+ map[string]interface{}{"key": "value"},
+ nil,
+ map[string]interface{}{"key": "value"},
+ },
+ {
+ "both nil",
+ nil,
+ nil,
+ map[string]interface{}{},
+ },
+ {
+ "disjoint keys",
+ map[string]interface{}{"a": "1"},
+ map[string]interface{}{"b": "2"},
+ map[string]interface{}{"a": "1", "b": "2"},
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ result := MergeTranslations(tc.base, tc.override)
+ assert.Equal(t, tc.expected, result)
+ })
+ }
+}
+```
+
+### Step 14: Run tests, verify fail, implement, verify pass
+
+Run: `cd /var/www/forks/notifuse && go test ./internal/domain/ -run TestMergeTranslations -v`
+
+```go
+// Append to internal/domain/translation.go
+
+// MergeTranslations deep-merges two translation maps. Values in override take priority.
+func MergeTranslations(base, override map[string]interface{}) map[string]interface{} {
+ result := make(map[string]interface{})
+
+ // Copy base
+ for k, v := range base {
+ result[k] = v
+ }
+
+ // Merge override
+ for k, v := range override {
+ if baseVal, exists := result[k]; exists {
+ // If both are maps, deep merge
+ baseMap, baseIsMap := baseVal.(map[string]interface{})
+ overrideMap, overrideIsMap := v.(map[string]interface{})
+ if baseIsMap && overrideIsMap {
+ result[k] = MergeTranslations(baseMap, overrideMap)
+ continue
+ }
+ }
+ result[k] = v
+ }
+
+ return result
+}
+```
+
+Run: `cd /var/www/forks/notifuse && go test ./internal/domain/ -run TestMergeTranslations -v`
+Expected: All PASS.
+
+### Step 15: Commit
+
+```bash
+git add internal/domain/translation.go internal/domain/translation_test.go
+git commit -m "feat(i18n): add translation utility functions
+
+Locale resolution, nested key lookup, placeholder interpolation,
+and translation merging — pure functions with full test coverage."
+```
+
+---
+
+## Task 2: Liquid `t` Filter
+
+Register a custom `T` filter on the `SecureLiquidEngine` that resolves translation keys during Liquid rendering.
+
+**Files:**
+- Create: `pkg/notifuse_mjml/translation_filter.go`
+- Create: `pkg/notifuse_mjml/translation_filter_test.go`
+- Modify: `pkg/notifuse_mjml/liquid_secure.go` (add `RegisterTranslations` method)
+
+### Step 1: Write failing tests for the translation filter
+
+```go
+// pkg/notifuse_mjml/translation_filter_test.go
+package notifuse_mjml
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestTranslationFilter_SimpleKey(t *testing.T) {
+ engine := NewSecureLiquidEngine()
+ translations := map[string]interface{}{
+ "welcome": map[string]interface{}{
+ "heading": "Welcome!",
+ },
+ }
+ engine.RegisterTranslations(translations)
+
+ result, err := engine.Render(`{{ "welcome.heading" | t }}`, map[string]interface{}{})
+ require.NoError(t, err)
+ assert.Equal(t, "Welcome!", result)
+}
+
+func TestTranslationFilter_MissingKey(t *testing.T) {
+ engine := NewSecureLiquidEngine()
+ translations := map[string]interface{}{}
+ engine.RegisterTranslations(translations)
+
+ result, err := engine.Render(`{{ "missing.key" | t }}`, map[string]interface{}{})
+ require.NoError(t, err)
+ assert.Equal(t, "[Missing translation: missing.key]", result)
+}
+
+func TestTranslationFilter_WithPlaceholders(t *testing.T) {
+ engine := NewSecureLiquidEngine()
+ translations := map[string]interface{}{
+ "welcome": map[string]interface{}{
+ "greeting": "Hello {{ name }}, welcome to {{ site }}!",
+ },
+ }
+ engine.RegisterTranslations(translations)
+
+ // The liquidgo filter receives named keyword args as a map
+ result, err := engine.Render(
+ `{{ "welcome.greeting" | t: name: "John", site: "Notifuse" }}`,
+ map[string]interface{}{},
+ )
+ require.NoError(t, err)
+ assert.Equal(t, "Hello John, welcome to Notifuse!", result)
+}
+
+func TestTranslationFilter_WithContactVariable(t *testing.T) {
+ engine := NewSecureLiquidEngine()
+ translations := map[string]interface{}{
+ "welcome": map[string]interface{}{
+ "greeting": "Hello {{ name }}!",
+ },
+ }
+ engine.RegisterTranslations(translations)
+
+ result, err := engine.Render(
+ `{{ "welcome.greeting" | t: name: contact.first_name }}`,
+ map[string]interface{}{
+ "contact": map[string]interface{}{
+ "first_name": "Alice",
+ },
+ },
+ )
+ require.NoError(t, err)
+ assert.Equal(t, "Hello Alice!", result)
+}
+
+func TestTranslationFilter_FlatKey(t *testing.T) {
+ engine := NewSecureLiquidEngine()
+ translations := map[string]interface{}{
+ "flat_key": "Flat value",
+ }
+ engine.RegisterTranslations(translations)
+
+ result, err := engine.Render(`{{ "flat_key" | t }}`, map[string]interface{}{})
+ require.NoError(t, err)
+ assert.Equal(t, "Flat value", result)
+}
+
+func TestTranslationFilter_NoRegistration(t *testing.T) {
+ // When no translations registered, t filter should return missing translation marker
+ engine := NewSecureLiquidEngine()
+
+ result, err := engine.Render(`{{ "some.key" | t }}`, map[string]interface{}{})
+ require.NoError(t, err)
+ assert.Equal(t, "[Missing translation: some.key]", result)
+}
+```
+
+### Step 2: Run tests to verify they fail
+
+Run: `cd /var/www/forks/notifuse && go test ./pkg/notifuse_mjml/ -run TestTranslationFilter -v`
+Expected: Compilation error.
+
+### Step 3: Implement the translation filter
+
+```go
+// pkg/notifuse_mjml/translation_filter.go
+package notifuse_mjml
+
+import (
+ "fmt"
+
+ "github.com/Notifuse/notifuse/internal/domain"
+)
+
+// TranslationFilters provides the Liquid `t` filter for resolving translation keys.
+// Register with SecureLiquidEngine.RegisterTranslations().
+type TranslationFilters struct {
+ translations map[string]interface{}
+}
+
+// T is the Liquid filter: {{ "welcome.heading" | t }}
+// With placeholders: {{ "welcome.greeting" | t: name: "John" }}
+//
+// liquidgo calls this method with:
+// - input: the piped value (the translation key string)
+// - args: variadic positional args (unused for now)
+//
+// liquidgo passes keyword args (name: value) as the last element
+// in args if it's a map[string]interface{}.
+func (tf *TranslationFilters) T(input interface{}, args ...interface{}) interface{} {
+ keyStr := fmt.Sprintf("%v", input)
+
+ value := domain.ResolveNestedKey(tf.translations, keyStr)
+ if value == "" {
+ return "[Missing translation: " + keyStr + "]"
+ }
+
+ // Check if last arg is a keyword args map
+ var kwargs map[string]interface{}
+ if len(args) > 0 {
+ if m, ok := args[len(args)-1].(map[string]interface{}); ok {
+ kwargs = m
+ }
+ }
+
+ if len(kwargs) > 0 {
+ value = domain.InterpolatePlaceholders(value, kwargs)
+ }
+
+ return value
+}
+```
+
+### Step 4: Add `RegisterTranslations` to `SecureLiquidEngine`
+
+Modify `pkg/notifuse_mjml/liquid_secure.go`. Add this method after the existing methods:
+
+```go
+// RegisterTranslations registers translation data for the Liquid t filter.
+// Must be called before Render. Translations should be a merged map (template + workspace).
+func (s *SecureLiquidEngine) RegisterTranslations(translations map[string]interface{}) {
+ if translations == nil {
+ translations = map[string]interface{}{}
+ }
+ filter := &TranslationFilters{translations: translations}
+ s.env.RegisterFilter(filter)
+}
+```
+
+### Step 5: Run tests to verify they pass
+
+Run: `cd /var/www/forks/notifuse && go test ./pkg/notifuse_mjml/ -run TestTranslationFilter -v`
+Expected: All PASS. (Note: the keyword args test may need adjustment based on how liquidgo passes them — see Step 6.)
+
+### Step 6: Debug and fix keyword args if needed
+
+liquidgo's filter invocation passes keyword args differently depending on the parsing mode. Check how they arrive in the `T` method by adding a temporary debug log. The `laxParseFilterExpressions` function in `liquidgo/liquid/variable.go:272` shows: `result = []interface{}{filterName, filterArgs}` where `keywordArgs` is appended as element [2] if present. At invocation time (`variable.go:360-390`), positional args are passed as separate params and keyword args as the final map. Adjust the `T` method signature if the args arrive differently.
+
+### Step 7: Run all existing Liquid tests to verify no regressions
+
+Run: `cd /var/www/forks/notifuse && go test ./pkg/notifuse_mjml/ -v`
+Expected: All existing tests still pass.
+
+### Step 8: Commit
+
+```bash
+git add pkg/notifuse_mjml/translation_filter.go pkg/notifuse_mjml/translation_filter_test.go pkg/notifuse_mjml/liquid_secure.go
+git commit -m "feat(i18n): add Liquid t filter for translation key resolution
+
+Registers a TranslationFilters struct on the Liquid engine that resolves
+nested keys with {{ \"key\" | t }} syntax and supports placeholders
+via named args: {{ \"key\" | t: name: contact.first_name }}"
+```
+
+---
+
+## Task 3: Domain Model Changes
+
+Add `Translations` and `DefaultLanguage` fields to the `Template` struct, and `DefaultLanguage`/`SupportedLanguages` to `WorkspaceSettings`. Add `WorkspaceTranslation` entity.
+
+**Files:**
+- Modify: `internal/domain/template.go` (Template struct, Validate, scan helpers, request types)
+- Modify: `internal/domain/template_test.go` (update tests)
+- Modify: `internal/domain/workspace.go` (WorkspaceSettings struct)
+- Create: `internal/domain/workspace_translation.go`
+- Create: `internal/domain/workspace_translation_test.go`
+
+### Step 1: Add fields to `Template` struct
+
+In `internal/domain/template.go`, add two fields to the `Template` struct (after `Settings`):
+
+```go
+type Template struct {
+ // ... existing fields through Settings ...
+ Settings MapOfAny `json:"settings"`
+ Translations MapOfInterfaces `json:"translations"` // locale → nested key-value map
+ DefaultLanguage *string `json:"default_language"` // overrides workspace default if set
+ CreatedAt time.Time `json:"created_at"`
+ // ... rest unchanged ...
+}
+```
+
+Note: `Translations` needs a custom type that implements `sql.Scanner` and `driver.Valuer` for JSONB storage. Define `MapOfInterfaces` as `map[string]map[string]interface{}` with scanner methods, or reuse the existing `MapOfAny` pattern and cast at usage sites. The simplest approach: store as `MapOfAny` (which is `map[string]interface{}` with JSONB scan/value support already implemented) and cast the inner values at read time.
+
+Actually, the cleanest approach is to store `Translations` as `MapOfAny` since JSONB deserialization produces `map[string]interface{}` naturally:
+
+```go
+Translations MapOfAny `json:"translations"` // {locale: {nested key-value}}
+DefaultLanguage *string `json:"default_language"` // overrides workspace default
+```
+
+### Step 2: Add `Translations` to `EmailTemplate` scan/serialization
+
+The `Translations` field uses `MapOfAny` which already has `Scan()` and `Value()` methods. The `DefaultLanguage` is a nullable `*string` which maps to `sql.NullString` in the scanner.
+
+### Step 3: Update `Template.Validate()` to validate translations
+
+Add validation in the `Validate()` method:
+
+```go
+// Validate translations if provided
+if w.Translations != nil {
+ for locale, content := range w.Translations {
+ if locale == "" {
+ return fmt.Errorf("invalid template: translation locale cannot be empty")
+ }
+ if len(locale) > 10 {
+ return fmt.Errorf("invalid template: translation locale '%s' exceeds max length of 10", locale)
+ }
+ if content == nil {
+ return fmt.Errorf("invalid template: translation content for locale '%s' cannot be nil", locale)
+ }
+ }
+}
+
+// Validate default_language if set
+if w.DefaultLanguage != nil && *w.DefaultLanguage != "" {
+ if len(*w.DefaultLanguage) > 10 {
+ return fmt.Errorf("invalid template: default_language exceeds max length of 10")
+ }
+}
+```
+
+### Step 4: Update `CreateTemplateRequest` and `UpdateTemplateRequest`
+
+These request types include the `Template` field, so `Translations` and `DefaultLanguage` flow through automatically via JSON deserialization. No changes needed to request types.
+
+### Step 5: Add language fields to `WorkspaceSettings`
+
+In `internal/domain/workspace.go`, add to the `WorkspaceSettings` struct (after `BlogSettings`):
+
+```go
+type WorkspaceSettings struct {
+ // ... existing fields ...
+ BlogSettings *BlogSettings `json:"blog_settings,omitempty"`
+ DefaultLanguage string `json:"default_language,omitempty"` // e.g., "en"
+ SupportedLanguages []string `json:"supported_languages,omitempty"` // e.g., ["en", "fr", "de"]
+
+ // decoded secret key, not stored in the database
+ SecretKey string `json:"-"`
+}
+```
+
+Since `WorkspaceSettings` is stored as JSONB in the `workspaces` table, existing workspaces will have these fields absent in JSON. Go will deserialize them as zero values (`""` and `nil`). Add a helper to get the effective default language:
+
+```go
+// GetDefaultLanguage returns the workspace's default language, defaulting to "en" if not set.
+func (ws *WorkspaceSettings) GetDefaultLanguage() string {
+ if ws.DefaultLanguage != "" {
+ return ws.DefaultLanguage
+ }
+ return "en"
+}
+
+// GetSupportedLanguages returns the workspace's supported languages, defaulting to ["en"] if not set.
+func (ws *WorkspaceSettings) GetSupportedLanguages() []string {
+ if len(ws.SupportedLanguages) > 0 {
+ return ws.SupportedLanguages
+ }
+ return []string{"en"}
+}
+```
+
+### Step 6: Create `WorkspaceTranslation` entity
+
+```go
+// internal/domain/workspace_translation.go
+package domain
+
+import (
+ "context"
+ "fmt"
+ "time"
+)
+
+// WorkspaceTranslation represents translations for a single locale at the workspace level.
+type WorkspaceTranslation struct {
+ Locale string `json:"locale"`
+ Content MapOfAny `json:"content"` // nested key-value translation map
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// Validate validates the workspace translation.
+func (wt *WorkspaceTranslation) Validate() error {
+ if wt.Locale == "" {
+ return fmt.Errorf("locale is required")
+ }
+ if len(wt.Locale) > 10 {
+ return fmt.Errorf("locale exceeds max length of 10")
+ }
+ if wt.Content == nil {
+ return fmt.Errorf("content is required")
+ }
+ return nil
+}
+
+// WorkspaceTranslationRepository defines the data access interface for workspace translations.
+type WorkspaceTranslationRepository interface {
+ Upsert(ctx context.Context, workspaceID string, translation *WorkspaceTranslation) error
+ GetByLocale(ctx context.Context, workspaceID string, locale string) (*WorkspaceTranslation, error)
+ List(ctx context.Context, workspaceID string) ([]*WorkspaceTranslation, error)
+ Delete(ctx context.Context, workspaceID string, locale string) error
+}
+
+// Request/Response types for workspace translations API
+type UpsertWorkspaceTranslationRequest struct {
+ WorkspaceID string `json:"workspace_id"`
+ Locale string `json:"locale"`
+ Content MapOfAny `json:"content"`
+}
+
+func (r *UpsertWorkspaceTranslationRequest) Validate() error {
+ if r.WorkspaceID == "" {
+ return fmt.Errorf("workspace_id is required")
+ }
+ if r.Locale == "" {
+ return fmt.Errorf("locale is required")
+ }
+ if len(r.Locale) > 10 {
+ return fmt.Errorf("locale exceeds max length of 10")
+ }
+ if r.Content == nil {
+ return fmt.Errorf("content is required")
+ }
+ return nil
+}
+
+type ListWorkspaceTranslationsRequest struct {
+ WorkspaceID string `json:"workspace_id"`
+}
+
+type DeleteWorkspaceTranslationRequest struct {
+ WorkspaceID string `json:"workspace_id"`
+ Locale string `json:"locale"`
+}
+```
+
+### Step 7: Write tests for new domain types
+
+```go
+// internal/domain/workspace_translation_test.go
+package domain
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestWorkspaceTranslation_Validate(t *testing.T) {
+ tests := []struct {
+ name string
+ wt WorkspaceTranslation
+ expectErr bool
+ }{
+ {"valid", WorkspaceTranslation{Locale: "en", Content: MapOfAny{"key": "value"}}, false},
+ {"empty locale", WorkspaceTranslation{Locale: "", Content: MapOfAny{"key": "value"}}, true},
+ {"locale too long", WorkspaceTranslation{Locale: "12345678901", Content: MapOfAny{"key": "value"}}, true},
+ {"nil content", WorkspaceTranslation{Locale: "en", Content: nil}, true},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ err := tc.wt.Validate()
+ if tc.expectErr {
+ assert.Error(t, err)
+ } else {
+ assert.NoError(t, err)
+ }
+ })
+ }
+}
+
+func TestWorkspaceSettings_GetDefaultLanguage(t *testing.T) {
+ ws := &WorkspaceSettings{}
+ assert.Equal(t, "en", ws.GetDefaultLanguage())
+
+ ws.DefaultLanguage = "fr"
+ assert.Equal(t, "fr", ws.GetDefaultLanguage())
+}
+
+func TestWorkspaceSettings_GetSupportedLanguages(t *testing.T) {
+ ws := &WorkspaceSettings{}
+ assert.Equal(t, []string{"en"}, ws.GetSupportedLanguages())
+
+ ws.SupportedLanguages = []string{"en", "fr", "de"}
+ assert.Equal(t, []string{"en", "fr", "de"}, ws.GetSupportedLanguages())
+}
+```
+
+### Step 8: Run all domain tests
+
+Run: `cd /var/www/forks/notifuse && go test ./internal/domain/ -v`
+Expected: All PASS (new and existing tests).
+
+### Step 9: Commit
+
+```bash
+git add internal/domain/translation.go internal/domain/translation_test.go internal/domain/template.go internal/domain/template_test.go internal/domain/workspace.go internal/domain/workspace_translation.go internal/domain/workspace_translation_test.go
+git commit -m "feat(i18n): add translation fields to domain models
+
+Template: translations (JSONB) + default_language.
+WorkspaceSettings: default_language + supported_languages.
+New WorkspaceTranslation entity with repository interface."
+```
+
+---
+
+## Task 4: Database Migration V28
+
+Add `translations` and `default_language` columns to the workspace `templates` table. Create `workspace_translations` table. No system database changes needed (workspace language settings are in the existing JSONB `settings` column).
+
+**Files:**
+- Create: `internal/migrations/v28.go`
+- Create: `internal/migrations/v28_test.go`
+- Modify: `config/config.go` (bump VERSION to "28.0")
+- Modify: `internal/database/schema/` (update workspace table schema for new installs)
+
+### Step 1: Create V28 migration
+
+```go
+// internal/migrations/v28.go
+package migrations
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/Notifuse/notifuse/config"
+ "github.com/Notifuse/notifuse/internal/domain"
+)
+
+// V28Migration adds template i18n support.
+//
+// This migration adds:
+// - translations: JSONB column on templates for per-locale translation key-value maps
+// - default_language: VARCHAR column on templates for per-template language override
+// - workspace_translations: new table for workspace-level shared translations
+type V28Migration struct{}
+
+func (m *V28Migration) GetMajorVersion() float64 {
+ return 28.0
+}
+
+func (m *V28Migration) HasSystemUpdate() bool {
+ return false
+}
+
+func (m *V28Migration) HasWorkspaceUpdate() bool {
+ return true
+}
+
+func (m *V28Migration) ShouldRestartServer() bool {
+ return false
+}
+
+func (m *V28Migration) UpdateSystem(ctx context.Context, cfg *config.Config, db DBExecutor) error {
+ return nil
+}
+
+func (m *V28Migration) UpdateWorkspace(ctx context.Context, cfg *config.Config, workspace *domain.Workspace, db DBExecutor) error {
+ // Add translations column to templates table
+ _, err := db.ExecContext(ctx, `
+ ALTER TABLE templates
+ ADD COLUMN IF NOT EXISTS translations JSONB NOT NULL DEFAULT '{}'::jsonb
+ `)
+ if err != nil {
+ return fmt.Errorf("failed to add translations column: %w", err)
+ }
+
+ // Add default_language column to templates table
+ _, err = db.ExecContext(ctx, `
+ ALTER TABLE templates
+ ADD COLUMN IF NOT EXISTS default_language VARCHAR(10) DEFAULT NULL
+ `)
+ if err != nil {
+ return fmt.Errorf("failed to add default_language column: %w", err)
+ }
+
+ // Create workspace_translations table
+ _, err = db.ExecContext(ctx, `
+ CREATE TABLE IF NOT EXISTS workspace_translations (
+ locale VARCHAR(10) NOT NULL PRIMARY KEY,
+ content JSONB NOT NULL DEFAULT '{}'::jsonb,
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
+ )
+ `)
+ if err != nil {
+ return fmt.Errorf("failed to create workspace_translations table: %w", err)
+ }
+
+ return nil
+}
+
+func init() {
+ Register(&V28Migration{})
+}
+```
+
+### Step 2: Write migration test
+
+Follow the existing pattern from `v27_test.go`:
+
+```go
+// internal/migrations/v28_test.go
+package migrations
+
+import (
+ "context"
+ "testing"
+
+ "github.com/DATA-DOG/go-sqlmock"
+ "github.com/Notifuse/notifuse/config"
+ "github.com/Notifuse/notifuse/internal/domain"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestV28Migration_GetMajorVersion(t *testing.T) {
+ m := &V28Migration{}
+ assert.Equal(t, 28.0, m.GetMajorVersion())
+}
+
+func TestV28Migration_HasSystemUpdate(t *testing.T) {
+ m := &V28Migration{}
+ assert.False(t, m.HasSystemUpdate())
+}
+
+func TestV28Migration_HasWorkspaceUpdate(t *testing.T) {
+ m := &V28Migration{}
+ assert.True(t, m.HasWorkspaceUpdate())
+}
+
+func TestV28Migration_UpdateWorkspace(t *testing.T) {
+ m := &V28Migration{}
+ cfg := &config.Config{}
+ workspace := &domain.Workspace{ID: "test"}
+
+ t.Run("success", func(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ assert.NoError(t, err)
+ defer db.Close()
+
+ // Expect: add translations column
+ mock.ExpectExec("ALTER TABLE templates").WillReturnResult(sqlmock.NewResult(0, 0))
+ // Expect: add default_language column
+ mock.ExpectExec("ALTER TABLE templates").WillReturnResult(sqlmock.NewResult(0, 0))
+ // Expect: create workspace_translations table
+ mock.ExpectExec("CREATE TABLE IF NOT EXISTS workspace_translations").WillReturnResult(sqlmock.NewResult(0, 0))
+
+ err = m.UpdateWorkspace(context.Background(), cfg, workspace, db)
+ assert.NoError(t, err)
+ assert.NoError(t, mock.ExpectationsWereMet())
+ })
+
+ t.Run("translations column error", func(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ assert.NoError(t, err)
+ defer db.Close()
+
+ mock.ExpectExec("ALTER TABLE templates").WillReturnError(fmt.Errorf("db error"))
+
+ err = m.UpdateWorkspace(context.Background(), cfg, workspace, db)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "failed to add translations column")
+ })
+}
+```
+
+### Step 3: Bump VERSION in config
+
+In `config/config.go` line 17, change:
+
+```go
+const VERSION = "28.0"
+```
+
+### Step 4: Update workspace DB init schema
+
+In `internal/database/schema/` (the workspace tables file), add the `translations` and `default_language` columns to the `templates` CREATE TABLE statement, and add the `workspace_translations` CREATE TABLE. This ensures new workspace databases get the correct schema on first creation.
+
+### Step 5: Run migration tests
+
+Run: `cd /var/www/forks/notifuse && go test ./internal/migrations/ -run TestV28 -v`
+Expected: All PASS.
+
+### Step 6: Commit
+
+```bash
+git add internal/migrations/v28.go internal/migrations/v28_test.go config/config.go internal/database/schema/
+git commit -m "feat(i18n): add V28 migration for template translations
+
+Adds translations JSONB and default_language columns to templates table.
+Creates workspace_translations table for shared translations."
+```
+
+---
+
+## Task 5: Repository Layer
+
+Update the template repository to read/write the new columns. Create the workspace translations repository.
+
+**Files:**
+- Modify: `internal/repository/template_postgres.go` (add new columns to INSERT/SELECT, update scanner)
+- Modify: `internal/repository/template_postgres_test.go`
+- Create: `internal/repository/workspace_translation_postgres.go`
+- Create: `internal/repository/workspace_translation_postgres_test.go`
+
+### Step 1: Update template repository — scanner
+
+In `internal/repository/template_postgres.go`, update `scanTemplate()` to scan the two new columns. Add them after `settings`:
+
+```go
+func scanTemplate(scanner interface{ Scan(dest ...interface{}) error }) (*domain.Template, error) {
+ var (
+ template domain.Template
+ templateMacroID sql.NullString
+ integrationID sql.NullString
+ defaultLanguage sql.NullString
+ )
+
+ err := scanner.Scan(
+ &template.ID,
+ &template.Name,
+ &template.Version,
+ &template.Channel,
+ &template.Email,
+ &template.Web,
+ &template.Category,
+ &templateMacroID,
+ &integrationID,
+ &template.TestData,
+ &template.Settings,
+ &template.Translations, // NEW
+ &defaultLanguage, // NEW
+ &template.CreatedAt,
+ &template.UpdatedAt,
+ )
+ // ... existing null handling ...
+ if defaultLanguage.Valid {
+ template.DefaultLanguage = &defaultLanguage.String
+ }
+ // ...
+}
+```
+
+### Step 2: Update template repository — INSERT columns
+
+Add `translations` and `default_language` to the `CreateTemplate` and `UpdateTemplate` INSERT statements. Follow the existing squirrel pattern.
+
+### Step 3: Update template repository — SELECT columns
+
+Add `translations` and `default_language` to all SELECT column lists (in `GetTemplateByID`, `GetTemplates`, etc.).
+
+### Step 4: Create workspace translations repository
+
+```go
+// internal/repository/workspace_translation_postgres.go
+package repository
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "time"
+
+ sq "github.com/Masterminds/squirrel"
+ "github.com/Notifuse/notifuse/internal/domain"
+)
+
+type WorkspaceTranslationPostgresRepository struct {
+ getWorkspaceDB func(workspaceID string) (*sql.DB, error)
+}
+
+func NewWorkspaceTranslationPostgresRepository(
+ getWorkspaceDB func(workspaceID string) (*sql.DB, error),
+) *WorkspaceTranslationPostgresRepository {
+ return &WorkspaceTranslationPostgresRepository{getWorkspaceDB: getWorkspaceDB}
+}
+
+func (r *WorkspaceTranslationPostgresRepository) Upsert(ctx context.Context, workspaceID string, translation *domain.WorkspaceTranslation) error {
+ db, err := r.getWorkspaceDB(workspaceID)
+ if err != nil {
+ return fmt.Errorf("failed to get workspace db: %w", err)
+ }
+
+ now := time.Now()
+ query, args, err := sq.Insert("workspace_translations").
+ Columns("locale", "content", "created_at", "updated_at").
+ Values(translation.Locale, translation.Content, now, now).
+ Suffix("ON CONFLICT (locale) DO UPDATE SET content = EXCLUDED.content, updated_at = EXCLUDED.updated_at").
+ PlaceholderFormat(sq.Dollar).
+ ToSql()
+ if err != nil {
+ return fmt.Errorf("failed to build query: %w", err)
+ }
+
+ _, err = db.ExecContext(ctx, query, args...)
+ if err != nil {
+ return fmt.Errorf("failed to upsert workspace translation: %w", err)
+ }
+
+ return nil
+}
+
+func (r *WorkspaceTranslationPostgresRepository) GetByLocale(ctx context.Context, workspaceID string, locale string) (*domain.WorkspaceTranslation, error) {
+ db, err := r.getWorkspaceDB(workspaceID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get workspace db: %w", err)
+ }
+
+ query, args, err := sq.Select("locale", "content", "created_at", "updated_at").
+ From("workspace_translations").
+ Where(sq.Eq{"locale": locale}).
+ PlaceholderFormat(sq.Dollar).
+ ToSql()
+ if err != nil {
+ return nil, fmt.Errorf("failed to build query: %w", err)
+ }
+
+ var wt domain.WorkspaceTranslation
+ err = db.QueryRowContext(ctx, query, args...).Scan(
+ &wt.Locale,
+ &wt.Content,
+ &wt.CreatedAt,
+ &wt.UpdatedAt,
+ )
+ if err == sql.ErrNoRows {
+ return nil, nil // not found is not an error — fallback to empty
+ }
+ if err != nil {
+ return nil, fmt.Errorf("failed to get workspace translation: %w", err)
+ }
+
+ return &wt, nil
+}
+
+func (r *WorkspaceTranslationPostgresRepository) List(ctx context.Context, workspaceID string) ([]*domain.WorkspaceTranslation, error) {
+ db, err := r.getWorkspaceDB(workspaceID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get workspace db: %w", err)
+ }
+
+ query, args, err := sq.Select("locale", "content", "created_at", "updated_at").
+ From("workspace_translations").
+ OrderBy("locale ASC").
+ PlaceholderFormat(sq.Dollar).
+ ToSql()
+ if err != nil {
+ return nil, fmt.Errorf("failed to build query: %w", err)
+ }
+
+ rows, err := db.QueryContext(ctx, query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("failed to list workspace translations: %w", err)
+ }
+ defer rows.Close()
+
+ var translations []*domain.WorkspaceTranslation
+ for rows.Next() {
+ var wt domain.WorkspaceTranslation
+ if err := rows.Scan(&wt.Locale, &wt.Content, &wt.CreatedAt, &wt.UpdatedAt); err != nil {
+ return nil, fmt.Errorf("failed to scan workspace translation: %w", err)
+ }
+ translations = append(translations, &wt)
+ }
+
+ return translations, rows.Err()
+}
+
+func (r *WorkspaceTranslationPostgresRepository) Delete(ctx context.Context, workspaceID string, locale string) error {
+ db, err := r.getWorkspaceDB(workspaceID)
+ if err != nil {
+ return fmt.Errorf("failed to get workspace db: %w", err)
+ }
+
+ query, args, err := sq.Delete("workspace_translations").
+ Where(sq.Eq{"locale": locale}).
+ PlaceholderFormat(sq.Dollar).
+ ToSql()
+ if err != nil {
+ return fmt.Errorf("failed to build query: %w", err)
+ }
+
+ _, err = db.ExecContext(ctx, query, args...)
+ if err != nil {
+ return fmt.Errorf("failed to delete workspace translation: %w", err)
+ }
+
+ return nil
+}
+```
+
+### Step 5: Write repository tests with sqlmock
+
+Follow the existing pattern in `template_postgres_test.go`. Test Upsert, GetByLocale (found + not found), List, Delete.
+
+### Step 6: Run repository tests
+
+Run: `cd /var/www/forks/notifuse && go test ./internal/repository/ -v`
+Expected: All PASS.
+
+### Step 7: Generate mocks
+
+Run `go generate` or manually create mock for `WorkspaceTranslationRepository` interface using gomock, following the existing mock patterns.
+
+### Step 8: Commit
+
+```bash
+git add internal/repository/template_postgres.go internal/repository/template_postgres_test.go internal/repository/workspace_translation_postgres.go internal/repository/workspace_translation_postgres_test.go
+git commit -m "feat(i18n): add repository layer for translations
+
+Update template repository with translations/default_language columns.
+New WorkspaceTranslationPostgresRepository with full CRUD + sqlmock tests."
+```
+
+---
+
+## Task 6: Service Layer — Workspace Translations
+
+Create the workspace translations service and wire it into the rendering pipeline.
+
+**Files:**
+- Create: `internal/service/workspace_translation_service.go`
+- Create: `internal/service/workspace_translation_service_test.go`
+
+### Step 1: Create workspace translation service
+
+```go
+// internal/service/workspace_translation_service.go
+package service
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/Notifuse/notifuse/internal/domain"
+ "github.com/Notifuse/notifuse/pkg/logger"
+)
+
+type WorkspaceTranslationService struct {
+ repo domain.WorkspaceTranslationRepository
+ authService domain.AuthService
+ logger logger.Logger
+}
+
+func NewWorkspaceTranslationService(
+ repo domain.WorkspaceTranslationRepository,
+ authService domain.AuthService,
+ logger logger.Logger,
+) *WorkspaceTranslationService {
+ return &WorkspaceTranslationService{
+ repo: repo,
+ authService: authService,
+ logger: logger,
+ }
+}
+
+func (s *WorkspaceTranslationService) Upsert(ctx context.Context, req domain.UpsertWorkspaceTranslationRequest) error {
+ if err := req.Validate(); err != nil {
+ return err
+ }
+
+ // Authenticate
+ if ctx.Value(domain.SystemCallKey) == nil {
+ var err error
+ ctx, _, _, err = s.authService.AuthenticateUserForWorkspace(ctx, req.WorkspaceID)
+ if err != nil {
+ return fmt.Errorf("authentication failed: %w", err)
+ }
+ }
+
+ now := time.Now()
+ translation := &domain.WorkspaceTranslation{
+ Locale: req.Locale,
+ Content: req.Content,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+
+ return s.repo.Upsert(ctx, req.WorkspaceID, translation)
+}
+
+func (s *WorkspaceTranslationService) List(ctx context.Context, workspaceID string) ([]*domain.WorkspaceTranslation, error) {
+ if ctx.Value(domain.SystemCallKey) == nil {
+ var err error
+ ctx, _, _, err = s.authService.AuthenticateUserForWorkspace(ctx, workspaceID)
+ if err != nil {
+ return nil, fmt.Errorf("authentication failed: %w", err)
+ }
+ }
+
+ return s.repo.List(ctx, workspaceID)
+}
+
+func (s *WorkspaceTranslationService) GetByLocale(ctx context.Context, workspaceID string, locale string) (*domain.WorkspaceTranslation, error) {
+ return s.repo.GetByLocale(ctx, workspaceID, locale)
+}
+
+func (s *WorkspaceTranslationService) Delete(ctx context.Context, workspaceID string, locale string) error {
+ if ctx.Value(domain.SystemCallKey) == nil {
+ var err error
+ ctx, _, _, err = s.authService.AuthenticateUserForWorkspace(ctx, workspaceID)
+ if err != nil {
+ return fmt.Errorf("authentication failed: %w", err)
+ }
+ }
+
+ return s.repo.Delete(ctx, workspaceID, locale)
+}
+```
+
+### Step 2: Write service tests with gomock
+
+Follow the pattern in `template_service_test.go`. Test auth, validation, and delegation to repo.
+
+### Step 3: Run service tests
+
+Run: `cd /var/www/forks/notifuse && go test ./internal/service/ -run TestWorkspaceTranslation -v`
+Expected: All PASS.
+
+### Step 4: Commit
+
+```bash
+git add internal/service/workspace_translation_service.go internal/service/workspace_translation_service_test.go
+git commit -m "feat(i18n): add workspace translation service
+
+CRUD operations for workspace-level translations with auth + validation."
+```
+
+---
+
+## Task 7: Wire Translations Into Rendering Pipeline
+
+The critical integration point. Modify `CompileTemplate`, `SendEmailForTemplate`, and broadcast senders to resolve locale, merge translations, and register the `t` filter.
+
+**Files:**
+- Modify: `pkg/notifuse_mjml/template_compilation.go` (accept translations in CompileTemplateRequest)
+- Modify: `pkg/notifuse_mjml/converter.go` (pass translations to Liquid engine)
+- Modify: `internal/domain/template.go` (add Translations to CompileTemplateRequest)
+- Modify: `internal/service/email_service.go` (resolve locale, merge translations before compilation)
+- Modify: `internal/service/broadcast/queue_message_sender.go` (same)
+
+### Step 1: Add `Translations` to `CompileTemplateRequest`
+
+In `internal/domain/template.go`, find `CompileTemplateRequest` (currently in `pkg/notifuse_mjml/template_compilation.go`) and add:
+
+```go
+type CompileTemplateRequest struct {
+ // ... existing fields ...
+ Translations map[string]interface{} // merged translations for resolved locale (optional)
+}
+```
+
+### Step 2: Pass translations through the compilation pipeline
+
+In `pkg/notifuse_mjml/template_compilation.go`, when `PreserveLiquid` is false and `req.Translations` is non-nil, create the engine with translations registered:
+
+In `ConvertJSONToMJMLWithData` (or wherever the Liquid engine is created), before rendering:
+
+```go
+if req.Translations != nil && len(req.Translations) > 0 {
+ engine.RegisterTranslations(req.Translations)
+}
+```
+
+The key integration point is in `processLiquidContent` (converter.go) which creates a new `SecureLiquidEngine`. This function needs to accept an optional translations map and register it before rendering.
+
+Update `processLiquidContent` signature:
+
+```go
+func processLiquidContent(content string, templateData map[string]interface{}, context string) (string, error)
+```
+
+to:
+
+```go
+func processLiquidContentWithTranslations(content string, templateData map[string]interface{}, context string, translations map[string]interface{}) (string, error)
+```
+
+And in the new function, after creating the engine:
+
+```go
+engine := NewSecureLiquidEngine()
+if translations != nil {
+ engine.RegisterTranslations(translations)
+}
+```
+
+Keep the original `processLiquidContent` as a wrapper that passes `nil` for translations to maintain backward compatibility.
+
+Also update `ProcessLiquidTemplate` (the public function used by email_service.go for subject rendering):
+
+```go
+func ProcessLiquidTemplateWithTranslations(content string, templateData map[string]interface{}, context string, translations map[string]interface{}) (string, error) {
+ return processLiquidContentWithTranslations(content, templateData, context, translations)
+}
+```
+
+### Step 3: Wire translations into `SendEmailForTemplate`
+
+In `internal/service/email_service.go`, in `SendEmailForTemplate()`, after getting the template (line ~258) and before building the compile request (line ~310):
+
+```go
+// Resolve locale and merge translations
+var mergedTranslations map[string]interface{}
+if template.Translations != nil && len(template.Translations) > 0 {
+ // Get contact language from the template data
+ contactLang := ""
+ if contactData, ok := request.MessageData.Data["contact"].(map[string]interface{}); ok {
+ if lang, ok := contactData["language"].(string); ok {
+ contactLang = lang
+ }
+ }
+
+ // Get workspace settings for default language
+ workspaceDefaultLang := workspace.Settings.GetDefaultLanguage()
+
+ // Get available locales from template translations
+ availableLocales := make([]string, 0, len(template.Translations))
+ for locale := range template.Translations {
+ availableLocales = append(availableLocales, locale)
+ }
+
+ // Resolve best locale
+ resolvedLocale := domain.ResolveLocale(contactLang, availableLocales, template.DefaultLanguage, workspaceDefaultLang)
+
+ // Get template translations for resolved locale
+ templateTranslations, _ := template.Translations[resolvedLocale].(map[string]interface{})
+
+ // Get workspace translations for resolved locale (best effort)
+ var workspaceTranslations map[string]interface{}
+ wsTranslation, err := s.workspaceTranslationRepo.GetByLocale(ctx, request.WorkspaceID, resolvedLocale)
+ if err == nil && wsTranslation != nil {
+ workspaceTranslations = wsTranslation.Content
+ }
+
+ // Merge: workspace base, template override
+ mergedTranslations = domain.MergeTranslations(workspaceTranslations, templateTranslations)
+}
+```
+
+Then pass `mergedTranslations` into the compile request and the subject rendering call.
+
+### Step 4: Wire translations into broadcast sender
+
+Same pattern in `internal/service/broadcast/queue_message_sender.go` in `buildQueueEntry()`. The template and workspace translations should be loaded once per batch (not per recipient). Only the locale resolution changes per contact.
+
+### Step 5: Write integration-style tests
+
+Test the full flow: template with translations → compile with contact language → verify correct translation appears in output.
+
+### Step 6: Run all tests
+
+Run: `cd /var/www/forks/notifuse && make test-unit`
+Expected: All PASS.
+
+### Step 7: Commit
+
+```bash
+git add pkg/notifuse_mjml/template_compilation.go pkg/notifuse_mjml/converter.go internal/domain/template.go internal/service/email_service.go internal/service/broadcast/queue_message_sender.go
+git commit -m "feat(i18n): wire translations into rendering pipeline
+
+Resolve locale from contact.language, merge template + workspace
+translations, register t filter on Liquid engine before compilation.
+Works for both transactional and broadcast sends."
+```
+
+---
+
+## Task 8: HTTP Handler — Workspace Translations API
+
+Add REST endpoints for workspace translation CRUD.
+
+**Files:**
+- Create: `internal/http/workspace_translation_handler.go`
+- Create: `internal/http/workspace_translation_handler_test.go`
+- Modify: `internal/http/router.go` (or wherever routes are registered)
+
+### Step 1: Create handler
+
+Follow the existing pattern from `template_handler.go`. Create handlers for:
+
+- `POST /api/workspace_translations.upsert` — JSON body: `{workspace_id, locale, content}`
+- `GET /api/workspace_translations.list` — query param: `workspace_id`
+- `POST /api/workspace_translations.delete` — JSON body: `{workspace_id, locale}`
+
+### Step 2: Register routes
+
+Add routes in the router file, following the existing pattern with auth middleware.
+
+### Step 3: Write handler tests
+
+Use `httptest.NewRecorder()` and follow the pattern in `template_handler_test.go`.
+
+### Step 4: Run handler tests
+
+Run: `cd /var/www/forks/notifuse && go test ./internal/http/ -run TestWorkspaceTranslation -v`
+Expected: All PASS.
+
+### Step 5: Commit
+
+```bash
+git add internal/http/workspace_translation_handler.go internal/http/workspace_translation_handler_test.go internal/http/router.go
+git commit -m "feat(i18n): add workspace translations API endpoints
+
+POST workspace_translations.upsert, GET .list, POST .delete"
+```
+
+---
+
+## Task 9: Dependency Wiring
+
+Wire the new repository, service, and handler into the application bootstrap.
+
+**Files:**
+- Modify: `cmd/api/main.go` (or wherever DI wiring happens)
+
+### Step 1: Find the DI wiring location
+
+Look at `cmd/api/main.go` or `cmd/api/server.go` for where repositories and services are constructed. Add:
+
+```go
+// Repository
+workspaceTranslationRepo := repository.NewWorkspaceTranslationPostgresRepository(getWorkspaceDB)
+
+// Service
+workspaceTranslationService := service.NewWorkspaceTranslationService(workspaceTranslationRepo, authService, appLogger)
+
+// Handler
+workspaceTranslationHandler := http.NewWorkspaceTranslationHandler(workspaceTranslationService)
+```
+
+Also wire `workspaceTranslationRepo` into `EmailService` (it needs it for locale resolution during send).
+
+### Step 2: Verify the application compiles and starts
+
+Run: `cd /var/www/forks/notifuse && go build ./cmd/api/`
+Expected: Builds successfully.
+
+### Step 3: Commit
+
+```bash
+git add cmd/api/
+git commit -m "feat(i18n): wire translation dependencies into application bootstrap"
+```
+
+---
+
+## Task 10: Frontend — API Types & Service
+
+Add TypeScript types and API functions for translations.
+
+**Files:**
+- Modify: `console/src/services/api/template.ts` (add translations fields to Template interface)
+- Create: `console/src/services/api/workspace-translations.ts`
+
+### Step 1: Update Template interface
+
+In `console/src/services/api/template.ts`, add to the `Template` interface:
+
+```typescript
+export interface Template {
+ // ... existing fields ...
+ translations?: Record> // locale → nested key-value
+ default_language?: string
+}
+```
+
+### Step 2: Create workspace translations API service
+
+```typescript
+// console/src/services/api/workspace-translations.ts
+import { apiClient } from './client'
+
+export interface WorkspaceTranslation {
+ locale: string
+ content: Record
+ created_at: string
+ updated_at: string
+}
+
+export async function listWorkspaceTranslations(workspaceId: string): Promise {
+ const response = await apiClient.get('/api/workspace_translations.list', {
+ params: { workspace_id: workspaceId },
+ })
+ return response.data.translations || []
+}
+
+export async function upsertWorkspaceTranslation(
+ workspaceId: string,
+ locale: string,
+ content: Record
+): Promise {
+ await apiClient.post('/api/workspace_translations.upsert', {
+ workspace_id: workspaceId,
+ locale,
+ content,
+ })
+}
+
+export async function deleteWorkspaceTranslation(
+ workspaceId: string,
+ locale: string
+): Promise {
+ await apiClient.post('/api/workspace_translations.delete', {
+ workspace_id: workspaceId,
+ locale,
+ })
+}
+```
+
+### Step 3: Commit
+
+```bash
+git add console/src/services/api/template.ts console/src/services/api/workspace-translations.ts
+git commit -m "feat(i18n): add frontend API types and service for translations"
+```
+
+---
+
+## Task 11: Frontend — Translations Panel Component
+
+Build the translations management UI panel for the template editor.
+
+**Files:**
+- Create: `console/src/components/templates/TranslationsPanel.tsx`
+- Modify: `console/src/components/templates/CreateTemplateDrawer.tsx` (integrate panel)
+
+### Step 1: Build the TranslationsPanel component
+
+Key features:
+- Displays translation keys grouped by nested prefix (collapsible tree)
+- Input fields per supported language for each key
+- Default language marked as required (checkmark indicator)
+- Missing translations shown with warning indicator
+- "Add Key" button with dot-path input
+- "Delete Key" button per key
+- Import/Export JSON buttons
+
+Use Ant Design components: `Collapse`, `Input`, `Button`, `Upload`, `Tag`, `Tooltip`, `Space`.
+
+Use the workspace's `supported_languages` to determine which locale columns to show.
+
+The component receives and updates the `translations` field on the Template object (controlled state, lifting state up to the parent drawer).
+
+### Step 2: Integrate into CreateTemplateDrawer
+
+Add a "Translations" tab alongside existing tabs in the template editor. When selected, show the `TranslationsPanel` with the current template's translations.
+
+Use `useLingui()` for all user-facing strings (following the i18n pattern established in the console).
+
+### Step 3: Test the panel
+
+Write a Vitest test for the TranslationsPanel component:
+- Renders translation keys
+- Adding a key works
+- Deleting a key works
+- Import JSON works
+- Export JSON produces correct output
+
+### Step 4: Run frontend tests
+
+Run: `cd /var/www/forks/notifuse/console && pnpm test`
+Expected: All PASS.
+
+### Step 5: Commit
+
+```bash
+git add console/src/components/templates/TranslationsPanel.tsx console/src/components/templates/CreateTemplateDrawer.tsx
+git commit -m "feat(i18n): add translations panel to template editor
+
+Collapsible key tree with per-locale inputs, add/delete keys,
+JSON import/export. Integrated as a tab in the template editor drawer."
+```
+
+---
+
+## Task 12: Frontend — Workspace Language Settings
+
+Add language configuration to the workspace settings page.
+
+**Files:**
+- Modify: `console/src/pages/WorkspaceSettingsPage.tsx` (add language settings section)
+- Create: `console/src/components/settings/LanguageSettings.tsx`
+
+### Step 1: Create LanguageSettings component
+
+A settings section that allows:
+- Setting the workspace default language (dropdown with common language codes)
+- Managing supported languages (tag-based multi-select)
+
+Uses Ant Design `Select` with predefined language options (en, fr, de, es, pt, it, nl, ja, ko, zh, ru, ar, etc.).
+
+### Step 2: Add to WorkspaceSettingsPage
+
+Add `'languages'` to the `validSections` array and render the `LanguageSettings` component when that section is active.
+
+### Step 3: Run frontend tests
+
+Run: `cd /var/www/forks/notifuse/console && pnpm test`
+Expected: All PASS.
+
+### Step 4: Extract i18n strings
+
+Run: `cd /var/www/forks/notifuse/console && pnpm run lingui:extract`
+
+### Step 5: Commit
+
+```bash
+git add console/src/pages/WorkspaceSettingsPage.tsx console/src/components/settings/LanguageSettings.tsx console/src/i18n/
+git commit -m "feat(i18n): add workspace language settings UI
+
+Default language and supported languages configuration in workspace settings."
+```
+
+---
+
+## Task 13: Final Integration Testing & Cleanup
+
+End-to-end verification that the full pipeline works.
+
+**Files:**
+- Run all backend tests
+- Run all frontend tests
+- Manual smoke test checklist
+
+### Step 1: Run full backend test suite
+
+Run: `cd /var/www/forks/notifuse && make test-unit`
+Expected: All PASS.
+
+### Step 2: Run frontend tests
+
+Run: `cd /var/www/forks/notifuse/console && pnpm test`
+Expected: All PASS.
+
+### Step 3: Run linting
+
+Run: `cd /var/www/forks/notifuse/console && pnpm run lint`
+Expected: No errors.
+
+### Step 4: Build check
+
+Run: `cd /var/www/forks/notifuse && go build ./cmd/api/`
+Run: `cd /var/www/forks/notifuse/console && pnpm run build`
+Expected: Both build successfully.
+
+### Step 5: Manual smoke test checklist
+
+- [ ] Create workspace, set default language to "en", supported languages to ["en", "fr"]
+- [ ] Create template with `{{ "welcome.heading" | t }}` in content
+- [ ] Add translations: en → "Welcome!", fr → "Bienvenue !"
+- [ ] Preview template — shows English
+- [ ] Send transactional email to contact with language "fr" — receives French content
+- [ ] Send transactional email to contact with no language — receives English (default)
+- [ ] Send transactional email to contact with language "pt-BR" — receives English (fallback)
+- [ ] Test placeholder: `{{ "greeting" | t: name: contact.first_name }}` with translation "Hello {{ name }}!"
+- [ ] Import/export JSON translations round-trip
+- [ ] Workspace translations: create shared key, reference from template, verify it resolves
+
+### Step 6: Final commit
+
+```bash
+git commit -m "feat(i18n): template-level internationalization
+
+Implements issue #268. Templates can now use {{ \"key\" | t }} syntax
+to reference translation keys. Translations stored as nested JSON
+per locale. Automatic language resolution from contact.language
+with fallback chain. Workspace-level shared translations supported."
+```
+
+---
+
+## Summary
+
+| Task | Description | Layer |
+|------|-------------|-------|
+| 1 | Translation utility functions | Domain |
+| 2 | Liquid `t` filter | Rendering engine |
+| 3 | Domain model changes | Domain |
+| 4 | V28 database migration | Migration |
+| 5 | Repository layer | Repository |
+| 6 | Workspace translation service | Service |
+| 7 | Wire into rendering pipeline | Service + Rendering |
+| 8 | HTTP handler for workspace translations | HTTP |
+| 9 | Dependency wiring | Bootstrap |
+| 10 | Frontend API types & service | Frontend |
+| 11 | Translations panel component | Frontend |
+| 12 | Workspace language settings | Frontend |
+| 13 | Integration testing & cleanup | Testing |
+
+Tasks 1-2 are independent and can be done in parallel.
+Tasks 3-6 must be sequential (domain → migration → repo → service).
+Tasks 7-9 depend on 1-6.
+Tasks 10-12 depend on 3 (types) but can start frontend work after Task 3.
+Task 13 depends on everything.
diff --git a/go.mod b/go.mod
index 82949279..6c0407af 100644
--- a/go.mod
+++ b/go.mod
@@ -117,3 +117,6 @@ require (
// Use Notifuse fork until PR is merged: https://github.com/preslavrachev/gomjml/pull/33
replace github.com/preslavrachev/gomjml => github.com/Notifuse/gomjml v0.0.0-20260130090101-a038317c31c2
+
+// Use local liquidgo with keyword args support for Liquid filters
+replace github.com/Notifuse/liquidgo => /var/www/forks/liquidgo
diff --git a/internal/database/init.go b/internal/database/init.go
index a3f6c696..5d394f6c 100644
--- a/internal/database/init.go
+++ b/internal/database/init.go
@@ -146,6 +146,8 @@ func InitializeWorkspaceDatabase(db *sql.DB) error {
integration_id VARCHAR(255),
test_data JSONB,
settings JSONB,
+ translations JSONB NOT NULL DEFAULT '{}'::jsonb,
+ default_language VARCHAR(10) DEFAULT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP WITH TIME ZONE,
@@ -469,6 +471,12 @@ func InitializeWorkspaceDatabase(db *sql.DB) error {
`CREATE INDEX IF NOT EXISTS idx_email_queue_retry ON email_queue(next_retry_at) WHERE status = 'failed' AND attempts < max_attempts`,
`CREATE INDEX IF NOT EXISTS idx_email_queue_source ON email_queue(source_type, source_id, status)`,
`CREATE INDEX IF NOT EXISTS idx_email_queue_integration ON email_queue(integration_id, status)`,
+ `CREATE TABLE IF NOT EXISTS workspace_translations (
+ locale VARCHAR(10) NOT NULL PRIMARY KEY,
+ content JSONB NOT NULL DEFAULT '{}'::jsonb,
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
+ )`,
}
// Run all table creation queries
diff --git a/internal/domain/template.go b/internal/domain/template.go
index 42834637..b445892a 100644
--- a/internal/domain/template.go
+++ b/internal/domain/template.go
@@ -77,6 +77,8 @@ type Template struct {
IntegrationID *string `json:"integration_id,omitempty"` // Set if template is managed by an integration (e.g., Supabase)
TestData MapOfAny `json:"test_data,omitempty"`
Settings MapOfAny `json:"settings,omitempty"` // Channels specific 3rd-party settings
+ Translations MapOfAny `json:"translations"` // {locale: {nested key-value}}
+ DefaultLanguage *string `json:"default_language"` // overrides workspace default
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
@@ -148,6 +150,28 @@ func (t *Template) Validate() error {
}
}
+ // Validate translations if provided
+ if t.Translations != nil {
+ for locale, content := range t.Translations {
+ if locale == "" {
+ return fmt.Errorf("invalid template: translation locale cannot be empty")
+ }
+ if len(locale) > 10 {
+ return fmt.Errorf("invalid template: translation locale '%s' exceeds max length of 10", locale)
+ }
+ if content == nil {
+ return fmt.Errorf("invalid template: translation content for locale '%s' cannot be nil", locale)
+ }
+ }
+ }
+
+ // Validate default_language if set
+ if t.DefaultLanguage != nil && *t.DefaultLanguage != "" {
+ if len(*t.DefaultLanguage) > 10 {
+ return fmt.Errorf("invalid template: default_language exceeds max length of 10")
+ }
+ }
+
return nil
}
diff --git a/internal/domain/template_test.go b/internal/domain/template_test.go
index 2cf3e161..5ab8435f 100644
--- a/internal/domain/template_test.go
+++ b/internal/domain/template_test.go
@@ -367,6 +367,61 @@ func TestTemplate_Validate(t *testing.T) {
},
wantErr: true,
},
+ {
+ name: "valid template with translations",
+ template: func() *Template {
+ t := createValidTemplate()
+ t.Translations = MapOfAny{
+ "fr": map[string]any{"subject": "Bonjour"},
+ "de": map[string]any{"subject": "Hallo"},
+ }
+ return t
+ }(),
+ wantErr: false,
+ },
+ {
+ name: "invalid template - empty translation locale",
+ template: func() *Template {
+ t := createValidTemplate()
+ t.Translations = MapOfAny{
+ "": map[string]any{"subject": "Bonjour"},
+ }
+ return t
+ }(),
+ wantErr: true,
+ },
+ {
+ name: "invalid template - translation locale too long",
+ template: func() *Template {
+ t := createValidTemplate()
+ t.Translations = MapOfAny{
+ "12345678901": map[string]any{"subject": "Bonjour"},
+ }
+ return t
+ }(),
+ wantErr: true,
+ },
+ {
+ name: "invalid template - nil translation content",
+ template: func() *Template {
+ t := createValidTemplate()
+ t.Translations = MapOfAny{
+ "fr": nil,
+ }
+ return t
+ }(),
+ wantErr: true,
+ },
+ {
+ name: "invalid template - default_language too long",
+ template: func() *Template {
+ t := createValidTemplate()
+ longLang := "12345678901"
+ t.DefaultLanguage = &longLang
+ return t
+ }(),
+ wantErr: true,
+ },
}
for _, tt := range tests {
diff --git a/internal/domain/translation.go b/internal/domain/translation.go
new file mode 100644
index 00000000..202890cb
--- /dev/null
+++ b/internal/domain/translation.go
@@ -0,0 +1,121 @@
+package domain
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+)
+
+// ResolveNestedKey traverses a nested map using a dot-separated key path
+// and returns the string value. Returns empty string if key not found or value is not a string.
+func ResolveNestedKey(data map[string]interface{}, key string) string {
+ if data == nil || key == "" {
+ return ""
+ }
+
+ parts := strings.Split(key, ".")
+ var current interface{} = data
+
+ for _, part := range parts {
+ m, ok := current.(map[string]interface{})
+ if !ok {
+ return ""
+ }
+ current, ok = m[part]
+ if !ok {
+ return ""
+ }
+ }
+
+ if str, ok := current.(string); ok {
+ return str
+ }
+ return ""
+}
+
+var placeholderRegex = regexp.MustCompile(`\{\{\s*(\w+)\s*\}\}`)
+
+// InterpolatePlaceholders replaces {{ key }} placeholders in a translation value
+// with the corresponding values from the args map.
+func InterpolatePlaceholders(value string, args map[string]interface{}) string {
+ if args == nil || len(args) == 0 {
+ return value
+ }
+
+ return placeholderRegex.ReplaceAllStringFunc(value, func(match string) string {
+ submatch := placeholderRegex.FindStringSubmatch(match)
+ if len(submatch) < 2 {
+ return match
+ }
+ key := submatch[1]
+ if val, ok := args[key]; ok {
+ return fmt.Sprintf("%v", val)
+ }
+ return match // leave unresolved placeholders as-is
+ })
+}
+
+// ResolveLocale determines the best locale to use given a contact's language preference,
+// available translation locales, and fallback defaults.
+// Fallback chain: exact match -> base language -> template default -> workspace default.
+func ResolveLocale(contactLanguage string, availableLocales []string, templateDefault *string, workspaceDefault string) string {
+ if contactLanguage == "" {
+ if templateDefault != nil && *templateDefault != "" {
+ return *templateDefault
+ }
+ return workspaceDefault
+ }
+
+ contactLang := strings.ToLower(contactLanguage)
+
+ // 1. Exact match (case-insensitive)
+ for _, locale := range availableLocales {
+ if strings.ToLower(locale) == contactLang {
+ return locale
+ }
+ }
+
+ // 2. Base language match (e.g., "pt-BR" -> "pt")
+ if idx := strings.Index(contactLang, "-"); idx > 0 {
+ baseLang := contactLang[:idx]
+ for _, locale := range availableLocales {
+ if strings.ToLower(locale) == baseLang {
+ return locale
+ }
+ }
+ }
+
+ // 3. Template default language
+ if templateDefault != nil && *templateDefault != "" {
+ return *templateDefault
+ }
+
+ // 4. Workspace default language
+ return workspaceDefault
+}
+
+// MergeTranslations deep-merges two translation maps. Values in override take priority.
+func MergeTranslations(base, override map[string]interface{}) map[string]interface{} {
+ result := make(map[string]interface{})
+
+ // Copy base
+ for k, v := range base {
+ result[k] = v
+ }
+
+ // Merge override
+ for k, v := range override {
+ if baseVal, exists := result[k]; exists {
+ // If both are maps, deep merge
+ baseMap, baseIsMap := baseVal.(map[string]interface{})
+ overrideMap, overrideIsMap := v.(map[string]interface{})
+ if baseIsMap && overrideIsMap {
+ result[k] = MergeTranslations(baseMap, overrideMap)
+ continue
+ }
+ }
+ result[k] = v
+ }
+
+ return result
+}
diff --git a/internal/domain/translation_test.go b/internal/domain/translation_test.go
new file mode 100644
index 00000000..daa4de17
--- /dev/null
+++ b/internal/domain/translation_test.go
@@ -0,0 +1,250 @@
+package domain
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestResolveNestedKey(t *testing.T) {
+ translations := map[string]interface{}{
+ "welcome": map[string]interface{}{
+ "heading": "Welcome!",
+ "greeting": "Hello {{ name }}!",
+ },
+ "cta": map[string]interface{}{
+ "button": "Get Started",
+ },
+ "flat_key": "Flat value",
+ }
+
+ tests := []struct {
+ name string
+ data map[string]interface{}
+ key string
+ expected string
+ }{
+ {"nested key", translations, "welcome.heading", "Welcome!"},
+ {"deeper nested", translations, "welcome.greeting", "Hello {{ name }}!"},
+ {"different group", translations, "cta.button", "Get Started"},
+ {"flat key", translations, "flat_key", "Flat value"},
+ {"missing key", translations, "welcome.missing", ""},
+ {"missing group", translations, "nonexistent.key", ""},
+ {"empty key", translations, "", ""},
+ {"nil data", nil, "welcome.heading", ""},
+ {"empty data", map[string]interface{}{}, "welcome.heading", ""},
+ {"key pointing to map not string", translations, "welcome", ""},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ result := ResolveNestedKey(tc.data, tc.key)
+ assert.Equal(t, tc.expected, result)
+ })
+ }
+}
+
+func TestInterpolatePlaceholders(t *testing.T) {
+ tests := []struct {
+ name string
+ value string
+ args map[string]interface{}
+ expected string
+ }{
+ {
+ "single placeholder",
+ "Hello {{ name }}!",
+ map[string]interface{}{"name": "John"},
+ "Hello John!",
+ },
+ {
+ "multiple placeholders",
+ "{{ greeting }} {{ name }}, welcome to {{ site }}!",
+ map[string]interface{}{"greeting": "Hello", "name": "Jane", "site": "Notifuse"},
+ "Hello Jane, welcome to Notifuse!",
+ },
+ {
+ "no placeholders",
+ "Hello World!",
+ map[string]interface{}{"name": "John"},
+ "Hello World!",
+ },
+ {
+ "placeholder without matching arg",
+ "Hello {{ name }}!",
+ map[string]interface{}{},
+ "Hello {{ name }}!",
+ },
+ {
+ "nil args",
+ "Hello {{ name }}!",
+ nil,
+ "Hello {{ name }}!",
+ },
+ {
+ "no spaces in placeholder",
+ "Hello {{name}}!",
+ map[string]interface{}{"name": "John"},
+ "Hello John!",
+ },
+ {
+ "extra spaces in placeholder",
+ "Hello {{ name }}!",
+ map[string]interface{}{"name": "John"},
+ "Hello John!",
+ },
+ {
+ "numeric value",
+ "You have {{ count }} items",
+ map[string]interface{}{"count": 5},
+ "You have 5 items",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ result := InterpolatePlaceholders(tc.value, tc.args)
+ assert.Equal(t, tc.expected, result)
+ })
+ }
+}
+
+func TestResolveLocale(t *testing.T) {
+ tests := []struct {
+ name string
+ contactLanguage string
+ availableLocales []string
+ templateDefault *string
+ workspaceDefault string
+ expected string
+ }{
+ {
+ "exact match",
+ "fr",
+ []string{"en", "fr", "de"},
+ nil,
+ "en",
+ "fr",
+ },
+ {
+ "exact match with region",
+ "pt-BR",
+ []string{"en", "pt-BR", "pt"},
+ nil,
+ "en",
+ "pt-BR",
+ },
+ {
+ "base language fallback",
+ "pt-BR",
+ []string{"en", "pt"},
+ nil,
+ "en",
+ "pt",
+ },
+ {
+ "template default fallback",
+ "ja",
+ []string{"en", "fr"},
+ strPtr("fr"),
+ "en",
+ "fr",
+ },
+ {
+ "workspace default fallback",
+ "ja",
+ []string{"en", "fr"},
+ nil,
+ "en",
+ "en",
+ },
+ {
+ "empty contact language uses workspace default",
+ "",
+ []string{"en", "fr"},
+ nil,
+ "en",
+ "en",
+ },
+ {
+ "case insensitive match",
+ "FR",
+ []string{"en", "fr"},
+ nil,
+ "en",
+ "fr",
+ },
+ {
+ "workspace default when no locales available",
+ "fr",
+ []string{},
+ nil,
+ "en",
+ "en",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ result := ResolveLocale(tc.contactLanguage, tc.availableLocales, tc.templateDefault, tc.workspaceDefault)
+ assert.Equal(t, tc.expected, result)
+ })
+ }
+}
+
+func strPtr(s string) *string {
+ return &s
+}
+
+func TestMergeTranslations(t *testing.T) {
+ tests := []struct {
+ name string
+ base map[string]interface{}
+ override map[string]interface{}
+ expected map[string]interface{}
+ }{
+ {
+ "override wins",
+ map[string]interface{}{"welcome": map[string]interface{}{"heading": "Base"}},
+ map[string]interface{}{"welcome": map[string]interface{}{"heading": "Override"}},
+ map[string]interface{}{"welcome": map[string]interface{}{"heading": "Override"}},
+ },
+ {
+ "deep merge adds missing keys",
+ map[string]interface{}{"welcome": map[string]interface{}{"heading": "Base"}},
+ map[string]interface{}{"welcome": map[string]interface{}{"body": "Override body"}},
+ map[string]interface{}{"welcome": map[string]interface{}{"heading": "Base", "body": "Override body"}},
+ },
+ {
+ "nil base",
+ nil,
+ map[string]interface{}{"key": "value"},
+ map[string]interface{}{"key": "value"},
+ },
+ {
+ "nil override",
+ map[string]interface{}{"key": "value"},
+ nil,
+ map[string]interface{}{"key": "value"},
+ },
+ {
+ "both nil",
+ nil,
+ nil,
+ map[string]interface{}{},
+ },
+ {
+ "disjoint keys",
+ map[string]interface{}{"a": "1"},
+ map[string]interface{}{"b": "2"},
+ map[string]interface{}{"a": "1", "b": "2"},
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ result := MergeTranslations(tc.base, tc.override)
+ assert.Equal(t, tc.expected, result)
+ })
+ }
+}
diff --git a/internal/domain/workspace.go b/internal/domain/workspace.go
index c5f56b02..a505f3bc 100644
--- a/internal/domain/workspace.go
+++ b/internal/domain/workspace.go
@@ -334,6 +334,8 @@ type WorkspaceSettings struct {
CustomFieldLabels map[string]string `json:"custom_field_labels,omitempty"`
BlogEnabled bool `json:"blog_enabled"` // Enable blog feature at workspace level
BlogSettings *BlogSettings `json:"blog_settings,omitempty"` // Blog styling and SEO settings
+ DefaultLanguage string `json:"default_language,omitempty"` // e.g., "en"
+ SupportedLanguages []string `json:"supported_languages,omitempty"` // e.g., ["en", "fr", "de"]
// decoded secret key, not stored in the database
SecretKey string `json:"-"`
@@ -455,6 +457,20 @@ func (ws *WorkspaceSettings) ValidateCustomFieldLabels() error {
return nil
}
+func (ws *WorkspaceSettings) GetDefaultLanguage() string {
+ if ws.DefaultLanguage != "" {
+ return ws.DefaultLanguage
+ }
+ return "en"
+}
+
+func (ws *WorkspaceSettings) GetSupportedLanguages() []string {
+ if len(ws.SupportedLanguages) > 0 {
+ return ws.SupportedLanguages
+ }
+ return []string{"en"}
+}
+
type Workspace struct {
ID string `json:"id"`
Name string `json:"name"`
diff --git a/internal/domain/workspace_translation.go b/internal/domain/workspace_translation.go
new file mode 100644
index 00000000..39ecc57f
--- /dev/null
+++ b/internal/domain/workspace_translation.go
@@ -0,0 +1,65 @@
+package domain
+
+import (
+ "context"
+ "fmt"
+ "time"
+)
+
+type WorkspaceTranslation struct {
+ Locale string `json:"locale"`
+ Content MapOfAny `json:"content"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+func (wt *WorkspaceTranslation) Validate() error {
+ if wt.Locale == "" {
+ return fmt.Errorf("locale is required")
+ }
+ if len(wt.Locale) > 10 {
+ return fmt.Errorf("locale exceeds max length of 10")
+ }
+ if wt.Content == nil {
+ return fmt.Errorf("content is required")
+ }
+ return nil
+}
+
+type WorkspaceTranslationRepository interface {
+ Upsert(ctx context.Context, workspaceID string, translation *WorkspaceTranslation) error
+ GetByLocale(ctx context.Context, workspaceID string, locale string) (*WorkspaceTranslation, error)
+ List(ctx context.Context, workspaceID string) ([]*WorkspaceTranslation, error)
+ Delete(ctx context.Context, workspaceID string, locale string) error
+}
+
+type UpsertWorkspaceTranslationRequest struct {
+ WorkspaceID string `json:"workspace_id"`
+ Locale string `json:"locale"`
+ Content MapOfAny `json:"content"`
+}
+
+func (r *UpsertWorkspaceTranslationRequest) Validate() error {
+ if r.WorkspaceID == "" {
+ return fmt.Errorf("workspace_id is required")
+ }
+ if r.Locale == "" {
+ return fmt.Errorf("locale is required")
+ }
+ if len(r.Locale) > 10 {
+ return fmt.Errorf("locale exceeds max length of 10")
+ }
+ if r.Content == nil {
+ return fmt.Errorf("content is required")
+ }
+ return nil
+}
+
+type ListWorkspaceTranslationsRequest struct {
+ WorkspaceID string `json:"workspace_id"`
+}
+
+type DeleteWorkspaceTranslationRequest struct {
+ WorkspaceID string `json:"workspace_id"`
+ Locale string `json:"locale"`
+}
diff --git a/internal/domain/workspace_translation_test.go b/internal/domain/workspace_translation_test.go
new file mode 100644
index 00000000..e570f4b0
--- /dev/null
+++ b/internal/domain/workspace_translation_test.go
@@ -0,0 +1,72 @@
+package domain
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestWorkspaceTranslation_Validate(t *testing.T) {
+ tests := []struct {
+ name string
+ wt WorkspaceTranslation
+ expectErr bool
+ }{
+ {"valid", WorkspaceTranslation{Locale: "en", Content: MapOfAny{"key": "value"}}, false},
+ {"empty locale", WorkspaceTranslation{Locale: "", Content: MapOfAny{"key": "value"}}, true},
+ {"locale too long", WorkspaceTranslation{Locale: "12345678901", Content: MapOfAny{"key": "value"}}, true},
+ {"nil content", WorkspaceTranslation{Locale: "en", Content: nil}, true},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ err := tc.wt.Validate()
+ if tc.expectErr {
+ assert.Error(t, err)
+ } else {
+ assert.NoError(t, err)
+ }
+ })
+ }
+}
+
+func TestWorkspaceSettings_GetDefaultLanguage(t *testing.T) {
+ ws := &WorkspaceSettings{}
+ assert.Equal(t, "en", ws.GetDefaultLanguage())
+
+ ws.DefaultLanguage = "fr"
+ assert.Equal(t, "fr", ws.GetDefaultLanguage())
+}
+
+func TestWorkspaceSettings_GetSupportedLanguages(t *testing.T) {
+ ws := &WorkspaceSettings{}
+ assert.Equal(t, []string{"en"}, ws.GetSupportedLanguages())
+
+ ws.SupportedLanguages = []string{"en", "fr", "de"}
+ assert.Equal(t, []string{"en", "fr", "de"}, ws.GetSupportedLanguages())
+}
+
+func TestUpsertWorkspaceTranslationRequest_Validate(t *testing.T) {
+ tests := []struct {
+ name string
+ req UpsertWorkspaceTranslationRequest
+ expectErr bool
+ }{
+ {"valid", UpsertWorkspaceTranslationRequest{WorkspaceID: "ws1", Locale: "en", Content: MapOfAny{"key": "value"}}, false},
+ {"empty workspace_id", UpsertWorkspaceTranslationRequest{WorkspaceID: "", Locale: "en", Content: MapOfAny{"key": "value"}}, true},
+ {"empty locale", UpsertWorkspaceTranslationRequest{WorkspaceID: "ws1", Locale: "", Content: MapOfAny{"key": "value"}}, true},
+ {"locale too long", UpsertWorkspaceTranslationRequest{WorkspaceID: "ws1", Locale: "12345678901", Content: MapOfAny{"key": "value"}}, true},
+ {"nil content", UpsertWorkspaceTranslationRequest{WorkspaceID: "ws1", Locale: "en", Content: nil}, true},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ err := tc.req.Validate()
+ if tc.expectErr {
+ assert.Error(t, err)
+ } else {
+ assert.NoError(t, err)
+ }
+ })
+ }
+}
diff --git a/internal/migrations/manager_test.go b/internal/migrations/manager_test.go
index 8829fdf5..b715f171 100644
--- a/internal/migrations/manager_test.go
+++ b/internal/migrations/manager_test.go
@@ -539,9 +539,9 @@ func TestManager_RunMigrations_AdditionalCoverage(t *testing.T) {
},
}
- // Mock GetCurrentDBVersion to return current version (27 - up to date)
+ // Mock GetCurrentDBVersion to return current version (28 - up to date)
mock.ExpectQuery("SELECT value FROM settings WHERE key = 'db_version'").
- WillReturnRows(sqlmock.NewRows([]string{"value"}).AddRow("27"))
+ WillReturnRows(sqlmock.NewRows([]string{"value"}).AddRow("28"))
err = manager.RunMigrations(context.Background(), cfg, db)
diff --git a/internal/migrations/v28.go b/internal/migrations/v28.go
new file mode 100644
index 00000000..a2f52565
--- /dev/null
+++ b/internal/migrations/v28.go
@@ -0,0 +1,60 @@
+package migrations
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/Notifuse/notifuse/config"
+ "github.com/Notifuse/notifuse/internal/domain"
+)
+
+// V28Migration adds template i18n support.
+type V28Migration struct{}
+
+func (m *V28Migration) GetMajorVersion() float64 { return 28.0 }
+func (m *V28Migration) HasSystemUpdate() bool { return false }
+func (m *V28Migration) HasWorkspaceUpdate() bool { return true }
+func (m *V28Migration) ShouldRestartServer() bool { return false }
+
+func (m *V28Migration) UpdateSystem(ctx context.Context, cfg *config.Config, db DBExecutor) error {
+ return nil
+}
+
+func (m *V28Migration) UpdateWorkspace(ctx context.Context, cfg *config.Config, workspace *domain.Workspace, db DBExecutor) error {
+ // Add translations column to templates table
+ _, err := db.ExecContext(ctx, `
+ ALTER TABLE templates
+ ADD COLUMN IF NOT EXISTS translations JSONB NOT NULL DEFAULT '{}'::jsonb
+ `)
+ if err != nil {
+ return fmt.Errorf("failed to add translations column: %w", err)
+ }
+
+ // Add default_language column to templates table
+ _, err = db.ExecContext(ctx, `
+ ALTER TABLE templates
+ ADD COLUMN IF NOT EXISTS default_language VARCHAR(10) DEFAULT NULL
+ `)
+ if err != nil {
+ return fmt.Errorf("failed to add default_language column: %w", err)
+ }
+
+ // Create workspace_translations table
+ _, err = db.ExecContext(ctx, `
+ CREATE TABLE IF NOT EXISTS workspace_translations (
+ locale VARCHAR(10) NOT NULL PRIMARY KEY,
+ content JSONB NOT NULL DEFAULT '{}'::jsonb,
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
+ )
+ `)
+ if err != nil {
+ return fmt.Errorf("failed to create workspace_translations table: %w", err)
+ }
+
+ return nil
+}
+
+func init() {
+ Register(&V28Migration{})
+}
diff --git a/internal/migrations/v28_test.go b/internal/migrations/v28_test.go
new file mode 100644
index 00000000..7221efe4
--- /dev/null
+++ b/internal/migrations/v28_test.go
@@ -0,0 +1,91 @@
+package migrations
+
+import (
+ "context"
+ "fmt"
+ "testing"
+
+ "github.com/DATA-DOG/go-sqlmock"
+ "github.com/Notifuse/notifuse/config"
+ "github.com/Notifuse/notifuse/internal/domain"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestV28Migration_GetMajorVersion(t *testing.T) {
+ m := &V28Migration{}
+ assert.Equal(t, 28.0, m.GetMajorVersion())
+}
+
+func TestV28Migration_HasSystemUpdate(t *testing.T) {
+ m := &V28Migration{}
+ assert.False(t, m.HasSystemUpdate())
+}
+
+func TestV28Migration_HasWorkspaceUpdate(t *testing.T) {
+ m := &V28Migration{}
+ assert.True(t, m.HasWorkspaceUpdate())
+}
+
+func TestV28Migration_ShouldRestartServer(t *testing.T) {
+ m := &V28Migration{}
+ assert.False(t, m.ShouldRestartServer())
+}
+
+func TestV28Migration_UpdateWorkspace(t *testing.T) {
+ m := &V28Migration{}
+ cfg := &config.Config{}
+ workspace := &domain.Workspace{ID: "test"}
+
+ t.Run("success", func(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ assert.NoError(t, err)
+ defer db.Close()
+
+ mock.ExpectExec("ALTER TABLE templates").WillReturnResult(sqlmock.NewResult(0, 0))
+ mock.ExpectExec("ALTER TABLE templates").WillReturnResult(sqlmock.NewResult(0, 0))
+ mock.ExpectExec("CREATE TABLE IF NOT EXISTS workspace_translations").WillReturnResult(sqlmock.NewResult(0, 0))
+
+ err = m.UpdateWorkspace(context.Background(), cfg, workspace, db)
+ assert.NoError(t, err)
+ assert.NoError(t, mock.ExpectationsWereMet())
+ })
+
+ t.Run("translations column error", func(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ assert.NoError(t, err)
+ defer db.Close()
+
+ mock.ExpectExec("ALTER TABLE templates").WillReturnError(fmt.Errorf("db error"))
+
+ err = m.UpdateWorkspace(context.Background(), cfg, workspace, db)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "failed to add translations column")
+ })
+
+ t.Run("default_language column error", func(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ assert.NoError(t, err)
+ defer db.Close()
+
+ mock.ExpectExec("ALTER TABLE templates").WillReturnResult(sqlmock.NewResult(0, 0))
+ mock.ExpectExec("ALTER TABLE templates").WillReturnError(fmt.Errorf("db error"))
+
+ err = m.UpdateWorkspace(context.Background(), cfg, workspace, db)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "failed to add default_language column")
+ })
+
+ t.Run("workspace_translations table error", func(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ assert.NoError(t, err)
+ defer db.Close()
+
+ mock.ExpectExec("ALTER TABLE templates").WillReturnResult(sqlmock.NewResult(0, 0))
+ mock.ExpectExec("ALTER TABLE templates").WillReturnResult(sqlmock.NewResult(0, 0))
+ mock.ExpectExec("CREATE TABLE IF NOT EXISTS workspace_translations").WillReturnError(fmt.Errorf("db error"))
+
+ err = m.UpdateWorkspace(context.Background(), cfg, workspace, db)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "failed to create workspace_translations table")
+ })
+}
diff --git a/internal/service/workspace_translation_service.go b/internal/service/workspace_translation_service.go
new file mode 100644
index 00000000..da4b9282
--- /dev/null
+++ b/internal/service/workspace_translation_service.go
@@ -0,0 +1,104 @@
+package service
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/Notifuse/notifuse/internal/domain"
+ "github.com/Notifuse/notifuse/pkg/logger"
+)
+
+type WorkspaceTranslationService struct {
+ repo domain.WorkspaceTranslationRepository
+ authService domain.AuthService
+ logger logger.Logger
+}
+
+func NewWorkspaceTranslationService(
+ repo domain.WorkspaceTranslationRepository,
+ authService domain.AuthService,
+ logger logger.Logger,
+) *WorkspaceTranslationService {
+ return &WorkspaceTranslationService{
+ repo: repo,
+ authService: authService,
+ logger: logger,
+ }
+}
+
+func (s *WorkspaceTranslationService) Upsert(ctx context.Context, req domain.UpsertWorkspaceTranslationRequest) error {
+ if err := req.Validate(); err != nil {
+ return err
+ }
+
+ if ctx.Value(domain.SystemCallKey) == nil {
+ var err error
+ var userWorkspace *domain.UserWorkspace
+ ctx, _, userWorkspace, err = s.authService.AuthenticateUserForWorkspace(ctx, req.WorkspaceID)
+ if err != nil {
+ return fmt.Errorf("failed to authenticate user: %w", err)
+ }
+ if !userWorkspace.HasPermission(domain.PermissionResourceWorkspace, domain.PermissionTypeWrite) {
+ return domain.NewPermissionError(
+ domain.PermissionResourceWorkspace,
+ domain.PermissionTypeWrite,
+ "Insufficient permissions: write access to workspace required",
+ )
+ }
+ }
+
+ now := time.Now().UTC()
+ translation := &domain.WorkspaceTranslation{
+ Locale: req.Locale,
+ Content: req.Content,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+
+ return s.repo.Upsert(ctx, req.WorkspaceID, translation)
+}
+
+func (s *WorkspaceTranslationService) List(ctx context.Context, workspaceID string) ([]*domain.WorkspaceTranslation, error) {
+ if ctx.Value(domain.SystemCallKey) == nil {
+ var err error
+ var userWorkspace *domain.UserWorkspace
+ ctx, _, userWorkspace, err = s.authService.AuthenticateUserForWorkspace(ctx, workspaceID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to authenticate user: %w", err)
+ }
+ if !userWorkspace.HasPermission(domain.PermissionResourceWorkspace, domain.PermissionTypeRead) {
+ return nil, domain.NewPermissionError(
+ domain.PermissionResourceWorkspace,
+ domain.PermissionTypeRead,
+ "Insufficient permissions: read access to workspace required",
+ )
+ }
+ }
+
+ return s.repo.List(ctx, workspaceID)
+}
+
+func (s *WorkspaceTranslationService) GetByLocale(ctx context.Context, workspaceID string, locale string) (*domain.WorkspaceTranslation, error) {
+ return s.repo.GetByLocale(ctx, workspaceID, locale)
+}
+
+func (s *WorkspaceTranslationService) Delete(ctx context.Context, workspaceID string, locale string) error {
+ if ctx.Value(domain.SystemCallKey) == nil {
+ var err error
+ var userWorkspace *domain.UserWorkspace
+ ctx, _, userWorkspace, err = s.authService.AuthenticateUserForWorkspace(ctx, workspaceID)
+ if err != nil {
+ return fmt.Errorf("failed to authenticate user: %w", err)
+ }
+ if !userWorkspace.HasPermission(domain.PermissionResourceWorkspace, domain.PermissionTypeWrite) {
+ return domain.NewPermissionError(
+ domain.PermissionResourceWorkspace,
+ domain.PermissionTypeWrite,
+ "Insufficient permissions: write access to workspace required",
+ )
+ }
+ }
+
+ return s.repo.Delete(ctx, workspaceID, locale)
+}
diff --git a/internal/service/workspace_translation_service_test.go b/internal/service/workspace_translation_service_test.go
new file mode 100644
index 00000000..f0a8af55
--- /dev/null
+++ b/internal/service/workspace_translation_service_test.go
@@ -0,0 +1,402 @@
+package service_test
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/Notifuse/notifuse/internal/domain"
+ domainmocks "github.com/Notifuse/notifuse/internal/domain/mocks"
+ "github.com/Notifuse/notifuse/internal/service"
+ pkgmocks "github.com/Notifuse/notifuse/pkg/mocks"
+ "github.com/golang/mock/gomock"
+ "github.com/stretchr/testify/assert"
+)
+
+func setupWorkspaceTranslationServiceTest(ctrl *gomock.Controller) (
+ *service.WorkspaceTranslationService,
+ *domainmocks.MockWorkspaceTranslationRepository,
+ *domainmocks.MockAuthService,
+ *pkgmocks.MockLogger,
+) {
+ mockRepo := domainmocks.NewMockWorkspaceTranslationRepository(ctrl)
+ mockAuthService := domainmocks.NewMockAuthService(ctrl)
+ mockLogger := pkgmocks.NewMockLogger(ctrl)
+
+ svc := service.NewWorkspaceTranslationService(mockRepo, mockAuthService, mockLogger)
+ return svc, mockRepo, mockAuthService, mockLogger
+}
+
+// ---------------------------------------------------------------------------
+// Upsert
+// ---------------------------------------------------------------------------
+
+func TestWorkspaceTranslationService_Upsert(t *testing.T) {
+ ctx := context.Background()
+ workspaceID := "ws-123"
+ userID := "user-1"
+
+ validReq := domain.UpsertWorkspaceTranslationRequest{
+ WorkspaceID: workspaceID,
+ Locale: "fr",
+ Content: domain.MapOfAny{"greeting": "Bonjour"},
+ }
+
+ t.Run("Success", func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ svc, mockRepo, mockAuth, _ := setupWorkspaceTranslationServiceTest(ctrl)
+
+ mockAuth.EXPECT().AuthenticateUserForWorkspace(ctx, workspaceID).Return(ctx, &domain.User{ID: userID}, &domain.UserWorkspace{
+ UserID: userID,
+ WorkspaceID: workspaceID,
+ Role: "member",
+ Permissions: domain.UserPermissions{
+ domain.PermissionResourceWorkspace: {Read: true, Write: true},
+ },
+ }, nil)
+
+ mockRepo.EXPECT().Upsert(ctx, workspaceID, gomock.Any()).Return(nil)
+
+ err := svc.Upsert(ctx, validReq)
+ assert.NoError(t, err)
+ })
+
+ t.Run("Validation error", func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ svc, _, _, _ := setupWorkspaceTranslationServiceTest(ctrl)
+
+ invalidReq := domain.UpsertWorkspaceTranslationRequest{
+ WorkspaceID: "",
+ Locale: "fr",
+ Content: domain.MapOfAny{"greeting": "Bonjour"},
+ }
+
+ err := svc.Upsert(ctx, invalidReq)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "workspace_id is required")
+ })
+
+ t.Run("Auth error", func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ svc, _, mockAuth, _ := setupWorkspaceTranslationServiceTest(ctrl)
+
+ authErr := errors.New("auth error")
+ mockAuth.EXPECT().AuthenticateUserForWorkspace(ctx, workspaceID).Return(ctx, nil, nil, authErr)
+
+ err := svc.Upsert(ctx, validReq)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "failed to authenticate user")
+ })
+
+ t.Run("Permission error", func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ svc, _, mockAuth, _ := setupWorkspaceTranslationServiceTest(ctrl)
+
+ mockAuth.EXPECT().AuthenticateUserForWorkspace(ctx, workspaceID).Return(ctx, &domain.User{ID: userID}, &domain.UserWorkspace{
+ UserID: userID,
+ WorkspaceID: workspaceID,
+ Role: "member",
+ Permissions: domain.UserPermissions{
+ domain.PermissionResourceWorkspace: {Read: true, Write: false},
+ },
+ }, nil)
+
+ err := svc.Upsert(ctx, validReq)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "Insufficient permissions")
+ })
+
+ t.Run("System call bypass", func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ svc, mockRepo, _, _ := setupWorkspaceTranslationServiceTest(ctrl)
+
+ systemCtx := context.WithValue(ctx, domain.SystemCallKey, true)
+ mockRepo.EXPECT().Upsert(systemCtx, workspaceID, gomock.Any()).Return(nil)
+
+ err := svc.Upsert(systemCtx, validReq)
+ assert.NoError(t, err)
+ })
+
+ t.Run("Repo error", func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ svc, mockRepo, mockAuth, _ := setupWorkspaceTranslationServiceTest(ctrl)
+
+ mockAuth.EXPECT().AuthenticateUserForWorkspace(ctx, workspaceID).Return(ctx, &domain.User{ID: userID}, &domain.UserWorkspace{
+ UserID: userID,
+ WorkspaceID: workspaceID,
+ Role: "member",
+ Permissions: domain.UserPermissions{
+ domain.PermissionResourceWorkspace: {Read: true, Write: true},
+ },
+ }, nil)
+
+ repoErr := errors.New("db error")
+ mockRepo.EXPECT().Upsert(ctx, workspaceID, gomock.Any()).Return(repoErr)
+
+ err := svc.Upsert(ctx, validReq)
+ assert.Error(t, err)
+ assert.Equal(t, repoErr, err)
+ })
+}
+
+// ---------------------------------------------------------------------------
+// List
+// ---------------------------------------------------------------------------
+
+func TestWorkspaceTranslationService_List(t *testing.T) {
+ ctx := context.Background()
+ workspaceID := "ws-123"
+ userID := "user-1"
+
+ t.Run("Success", func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ svc, mockRepo, mockAuth, _ := setupWorkspaceTranslationServiceTest(ctrl)
+
+ mockAuth.EXPECT().AuthenticateUserForWorkspace(ctx, workspaceID).Return(ctx, &domain.User{ID: userID}, &domain.UserWorkspace{
+ UserID: userID,
+ WorkspaceID: workspaceID,
+ Role: "member",
+ Permissions: domain.UserPermissions{
+ domain.PermissionResourceWorkspace: {Read: true, Write: false},
+ },
+ }, nil)
+
+ expected := []*domain.WorkspaceTranslation{
+ {Locale: "fr", Content: domain.MapOfAny{"greeting": "Bonjour"}},
+ {Locale: "es", Content: domain.MapOfAny{"greeting": "Hola"}},
+ }
+ mockRepo.EXPECT().List(ctx, workspaceID).Return(expected, nil)
+
+ result, err := svc.List(ctx, workspaceID)
+ assert.NoError(t, err)
+ assert.Equal(t, expected, result)
+ })
+
+ t.Run("Auth error", func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ svc, _, mockAuth, _ := setupWorkspaceTranslationServiceTest(ctrl)
+
+ authErr := errors.New("auth error")
+ mockAuth.EXPECT().AuthenticateUserForWorkspace(ctx, workspaceID).Return(ctx, nil, nil, authErr)
+
+ result, err := svc.List(ctx, workspaceID)
+ assert.Error(t, err)
+ assert.Nil(t, result)
+ assert.Contains(t, err.Error(), "failed to authenticate user")
+ })
+
+ t.Run("Permission error", func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ svc, _, mockAuth, _ := setupWorkspaceTranslationServiceTest(ctrl)
+
+ mockAuth.EXPECT().AuthenticateUserForWorkspace(ctx, workspaceID).Return(ctx, &domain.User{ID: userID}, &domain.UserWorkspace{
+ UserID: userID,
+ WorkspaceID: workspaceID,
+ Role: "member",
+ Permissions: domain.UserPermissions{
+ domain.PermissionResourceWorkspace: {Read: false, Write: false},
+ },
+ }, nil)
+
+ result, err := svc.List(ctx, workspaceID)
+ assert.Error(t, err)
+ assert.Nil(t, result)
+ assert.Contains(t, err.Error(), "Insufficient permissions")
+ })
+
+ t.Run("System call bypass", func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ svc, mockRepo, _, _ := setupWorkspaceTranslationServiceTest(ctrl)
+
+ systemCtx := context.WithValue(ctx, domain.SystemCallKey, true)
+ expected := []*domain.WorkspaceTranslation{
+ {Locale: "fr", Content: domain.MapOfAny{"greeting": "Bonjour"}},
+ }
+ mockRepo.EXPECT().List(systemCtx, workspaceID).Return(expected, nil)
+
+ result, err := svc.List(systemCtx, workspaceID)
+ assert.NoError(t, err)
+ assert.Equal(t, expected, result)
+ })
+
+ t.Run("Repo error", func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ svc, mockRepo, mockAuth, _ := setupWorkspaceTranslationServiceTest(ctrl)
+
+ mockAuth.EXPECT().AuthenticateUserForWorkspace(ctx, workspaceID).Return(ctx, &domain.User{ID: userID}, &domain.UserWorkspace{
+ UserID: userID,
+ WorkspaceID: workspaceID,
+ Role: "member",
+ Permissions: domain.UserPermissions{
+ domain.PermissionResourceWorkspace: {Read: true, Write: false},
+ },
+ }, nil)
+
+ repoErr := errors.New("db error")
+ mockRepo.EXPECT().List(ctx, workspaceID).Return(nil, repoErr)
+
+ result, err := svc.List(ctx, workspaceID)
+ assert.Error(t, err)
+ assert.Nil(t, result)
+ assert.Equal(t, repoErr, err)
+ })
+}
+
+// ---------------------------------------------------------------------------
+// GetByLocale
+// ---------------------------------------------------------------------------
+
+func TestWorkspaceTranslationService_GetByLocale(t *testing.T) {
+ ctx := context.Background()
+ workspaceID := "ws-123"
+
+ t.Run("Success", func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ svc, mockRepo, _, _ := setupWorkspaceTranslationServiceTest(ctrl)
+
+ expected := &domain.WorkspaceTranslation{
+ Locale: "fr",
+ Content: domain.MapOfAny{"greeting": "Bonjour"},
+ }
+ mockRepo.EXPECT().GetByLocale(ctx, workspaceID, "fr").Return(expected, nil)
+
+ result, err := svc.GetByLocale(ctx, workspaceID, "fr")
+ assert.NoError(t, err)
+ assert.Equal(t, expected, result)
+ })
+
+ t.Run("Repo error", func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ svc, mockRepo, _, _ := setupWorkspaceTranslationServiceTest(ctrl)
+
+ repoErr := errors.New("db error")
+ mockRepo.EXPECT().GetByLocale(ctx, workspaceID, "fr").Return(nil, repoErr)
+
+ result, err := svc.GetByLocale(ctx, workspaceID, "fr")
+ assert.Error(t, err)
+ assert.Nil(t, result)
+ assert.Equal(t, repoErr, err)
+ })
+
+ t.Run("Not found returns nil", func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ svc, mockRepo, _, _ := setupWorkspaceTranslationServiceTest(ctrl)
+
+ mockRepo.EXPECT().GetByLocale(ctx, workspaceID, "xx").Return(nil, nil)
+
+ result, err := svc.GetByLocale(ctx, workspaceID, "xx")
+ assert.NoError(t, err)
+ assert.Nil(t, result)
+ })
+}
+
+// ---------------------------------------------------------------------------
+// Delete
+// ---------------------------------------------------------------------------
+
+func TestWorkspaceTranslationService_Delete(t *testing.T) {
+ ctx := context.Background()
+ workspaceID := "ws-123"
+ userID := "user-1"
+ locale := "fr"
+
+ t.Run("Success", func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ svc, mockRepo, mockAuth, _ := setupWorkspaceTranslationServiceTest(ctrl)
+
+ mockAuth.EXPECT().AuthenticateUserForWorkspace(ctx, workspaceID).Return(ctx, &domain.User{ID: userID}, &domain.UserWorkspace{
+ UserID: userID,
+ WorkspaceID: workspaceID,
+ Role: "member",
+ Permissions: domain.UserPermissions{
+ domain.PermissionResourceWorkspace: {Read: true, Write: true},
+ },
+ }, nil)
+
+ mockRepo.EXPECT().Delete(ctx, workspaceID, locale).Return(nil)
+
+ err := svc.Delete(ctx, workspaceID, locale)
+ assert.NoError(t, err)
+ })
+
+ t.Run("Auth error", func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ svc, _, mockAuth, _ := setupWorkspaceTranslationServiceTest(ctrl)
+
+ authErr := errors.New("auth error")
+ mockAuth.EXPECT().AuthenticateUserForWorkspace(ctx, workspaceID).Return(ctx, nil, nil, authErr)
+
+ err := svc.Delete(ctx, workspaceID, locale)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "failed to authenticate user")
+ })
+
+ t.Run("Permission error", func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ svc, _, mockAuth, _ := setupWorkspaceTranslationServiceTest(ctrl)
+
+ mockAuth.EXPECT().AuthenticateUserForWorkspace(ctx, workspaceID).Return(ctx, &domain.User{ID: userID}, &domain.UserWorkspace{
+ UserID: userID,
+ WorkspaceID: workspaceID,
+ Role: "member",
+ Permissions: domain.UserPermissions{
+ domain.PermissionResourceWorkspace: {Read: true, Write: false},
+ },
+ }, nil)
+
+ err := svc.Delete(ctx, workspaceID, locale)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "Insufficient permissions")
+ })
+
+ t.Run("System call bypass", func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ svc, mockRepo, _, _ := setupWorkspaceTranslationServiceTest(ctrl)
+
+ systemCtx := context.WithValue(ctx, domain.SystemCallKey, true)
+ mockRepo.EXPECT().Delete(systemCtx, workspaceID, locale).Return(nil)
+
+ err := svc.Delete(systemCtx, workspaceID, locale)
+ assert.NoError(t, err)
+ })
+
+ t.Run("Repo error", func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ svc, mockRepo, mockAuth, _ := setupWorkspaceTranslationServiceTest(ctrl)
+
+ mockAuth.EXPECT().AuthenticateUserForWorkspace(ctx, workspaceID).Return(ctx, &domain.User{ID: userID}, &domain.UserWorkspace{
+ UserID: userID,
+ WorkspaceID: workspaceID,
+ Role: "member",
+ Permissions: domain.UserPermissions{
+ domain.PermissionResourceWorkspace: {Read: true, Write: true},
+ },
+ }, nil)
+
+ repoErr := errors.New("db error")
+ mockRepo.EXPECT().Delete(ctx, workspaceID, locale).Return(repoErr)
+
+ err := svc.Delete(ctx, workspaceID, locale)
+ assert.Error(t, err)
+ assert.Equal(t, repoErr, err)
+ })
+}
diff --git a/openapi/components/schemas/template.yaml b/openapi/components/schemas/template.yaml
index 20db8151..91792d4b 100644
--- a/openapi/components/schemas/template.yaml
+++ b/openapi/components/schemas/template.yaml
@@ -62,6 +62,30 @@ Template:
type: object
additionalProperties: true
description: Channel-specific third-party settings
+ translations:
+ type: object
+ additionalProperties:
+ type: object
+ additionalProperties: true
+ description: |
+ Per-locale translation key-value maps. Keys are dot-separated paths (e.g., "welcome.heading").
+ Values are strings that may contain {{ placeholder }} syntax for named arguments.
+ Outer keys are locale codes (e.g., "en", "fr", "pt-BR").
+ example:
+ en:
+ welcome:
+ heading: "Welcome!"
+ greeting: "Hello {{ name }}!"
+ fr:
+ welcome:
+ heading: "Bienvenue !"
+ greeting: "Bonjour {{ name }} !"
+ default_language:
+ type: string
+ nullable: true
+ description: Override the workspace default language for this template. When null, inherits from workspace settings.
+ example: en
+ maxLength: 10
created_at:
type: string
format: date-time
@@ -197,6 +221,17 @@ CreateTemplateRequest:
type: object
additionalProperties: true
description: Channel-specific settings
+ translations:
+ type: object
+ additionalProperties:
+ type: object
+ additionalProperties: true
+ description: Per-locale translation key-value maps
+ default_language:
+ type: string
+ nullable: true
+ description: Override the workspace default language for this template
+ maxLength: 10
UpdateTemplateRequest:
type: object
@@ -258,6 +293,17 @@ UpdateTemplateRequest:
type: object
additionalProperties: true
description: Channel-specific settings
+ translations:
+ type: object
+ additionalProperties:
+ type: object
+ additionalProperties: true
+ description: Per-locale translation key-value maps
+ default_language:
+ type: string
+ nullable: true
+ description: Override the workspace default language for this template
+ maxLength: 10
DeleteTemplateRequest:
type: object
@@ -308,6 +354,10 @@ CompileTemplateRequest:
- email
- web
description: Channel filter for block visibility
+ translations:
+ type: object
+ additionalProperties: true
+ description: Merged translations map for a specific locale, used by the Liquid `t` filter during compilation
CompileTemplateResponse:
type: object
diff --git a/openapi/components/schemas/workspace-translation.yaml b/openapi/components/schemas/workspace-translation.yaml
new file mode 100644
index 00000000..dbd628dc
--- /dev/null
+++ b/openapi/components/schemas/workspace-translation.yaml
@@ -0,0 +1,69 @@
+WorkspaceTranslation:
+ type: object
+ properties:
+ locale:
+ type: string
+ description: Locale code (e.g., "en", "fr", "pt-BR")
+ example: en
+ maxLength: 10
+ content:
+ type: object
+ additionalProperties: true
+ description: |
+ Nested key-value translation map. Keys use dot-separated paths.
+ Values are strings, optionally containing {{ placeholder }} syntax.
+ example:
+ common:
+ greeting: "Hello"
+ footer: "Unsubscribe from our emails"
+ created_at:
+ type: string
+ format: date-time
+ description: When the translation was created
+ updated_at:
+ type: string
+ format: date-time
+ description: When the translation was last updated
+ required:
+ - locale
+ - content
+
+UpsertWorkspaceTranslationRequest:
+ type: object
+ required:
+ - workspace_id
+ - locale
+ - content
+ properties:
+ workspace_id:
+ type: string
+ description: The ID of the workspace
+ example: ws_1234567890
+ locale:
+ type: string
+ description: Locale code
+ example: fr
+ maxLength: 10
+ content:
+ type: object
+ additionalProperties: true
+ description: Nested key-value translation map
+ example:
+ common:
+ greeting: "Bonjour"
+ footer: "Se désabonner de nos emails"
+
+DeleteWorkspaceTranslationRequest:
+ type: object
+ required:
+ - workspace_id
+ - locale
+ properties:
+ workspace_id:
+ type: string
+ description: The ID of the workspace
+ example: ws_1234567890
+ locale:
+ type: string
+ description: Locale code to delete
+ example: fr
diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml
index 5cf74f40..0915f0cc 100644
--- a/openapi/openapi.yaml
+++ b/openapi/openapi.yaml
@@ -73,6 +73,12 @@ paths:
$ref: './paths/templates.yaml#/~1api~1templates.delete'
/api/templates.compile:
$ref: './paths/templates.yaml#/~1api~1templates.compile'
+ /api/workspace_translations.list:
+ $ref: './paths/workspace-translations.yaml#/~1api~1workspace_translations.list'
+ /api/workspace_translations.upsert:
+ $ref: './paths/workspace-translations.yaml#/~1api~1workspace_translations.upsert'
+ /api/workspace_translations.delete:
+ $ref: './paths/workspace-translations.yaml#/~1api~1workspace_translations.delete'
/api/customEvents.import:
$ref: './paths/custom-events.yaml#/~1api~1customEvents.import'
/api/webhookSubscriptions.create:
@@ -218,6 +224,12 @@ components:
$ref: './components/schemas/template.yaml#/CompileTemplateResponse'
TrackingSettings:
$ref: './components/schemas/template.yaml#/TrackingSettings'
+ WorkspaceTranslation:
+ $ref: './components/schemas/workspace-translation.yaml#/WorkspaceTranslation'
+ UpsertWorkspaceTranslationRequest:
+ $ref: './components/schemas/workspace-translation.yaml#/UpsertWorkspaceTranslationRequest'
+ DeleteWorkspaceTranslationRequest:
+ $ref: './components/schemas/workspace-translation.yaml#/DeleteWorkspaceTranslationRequest'
CustomEvent:
$ref: './components/schemas/custom-event.yaml#/CustomEvent'
ImportCustomEventsRequest:
diff --git a/openapi/paths/workspace-translations.yaml b/openapi/paths/workspace-translations.yaml
new file mode 100644
index 00000000..1ece5363
--- /dev/null
+++ b/openapi/paths/workspace-translations.yaml
@@ -0,0 +1,117 @@
+/api/workspace_translations.list:
+ get:
+ summary: List workspace translations
+ description: Retrieves all workspace-level translations. Returns one entry per locale with its nested key-value content.
+ operationId: listWorkspaceTranslations
+ security:
+ - BearerAuth: []
+ parameters:
+ - name: workspace_id
+ in: query
+ required: true
+ schema:
+ type: string
+ description: The ID of the workspace
+ example: ws_1234567890
+ responses:
+ '200':
+ description: List of workspace translations retrieved successfully
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ translations:
+ type: array
+ items:
+ $ref: '../components/schemas/workspace-translation.yaml#/WorkspaceTranslation'
+ '400':
+ description: Bad request - validation failed
+ content:
+ application/json:
+ schema:
+ $ref: '../components/schemas/common.yaml#/ErrorResponse'
+ '401':
+ description: Unauthorized
+ content:
+ application/json:
+ schema:
+ $ref: '../components/schemas/common.yaml#/ErrorResponse'
+
+/api/workspace_translations.upsert:
+ post:
+ summary: Create or update workspace translation
+ description: |
+ Creates or updates translations for a specific locale at the workspace level.
+ If translations for the locale already exist, they are replaced.
+ Workspace translations are shared across all templates and resolved when a template
+ uses `{{ "key" | t }}` and the key is not found in the template's own translations.
+ operationId: upsertWorkspaceTranslation
+ security:
+ - BearerAuth: []
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '../components/schemas/workspace-translation.yaml#/UpsertWorkspaceTranslationRequest'
+ responses:
+ '200':
+ description: Translation upserted successfully
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ success:
+ type: boolean
+ example: true
+ '400':
+ description: Bad request - validation failed
+ content:
+ application/json:
+ schema:
+ $ref: '../components/schemas/common.yaml#/ErrorResponse'
+ '401':
+ description: Unauthorized
+ content:
+ application/json:
+ schema:
+ $ref: '../components/schemas/common.yaml#/ErrorResponse'
+
+/api/workspace_translations.delete:
+ post:
+ summary: Delete workspace translation
+ description: Deletes all translations for a specific locale at the workspace level.
+ operationId: deleteWorkspaceTranslation
+ security:
+ - BearerAuth: []
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '../components/schemas/workspace-translation.yaml#/DeleteWorkspaceTranslationRequest'
+ responses:
+ '200':
+ description: Translation deleted successfully
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ success:
+ type: boolean
+ example: true
+ '400':
+ description: Bad request - validation failed
+ content:
+ application/json:
+ schema:
+ $ref: '../components/schemas/common.yaml#/ErrorResponse'
+ '401':
+ description: Unauthorized
+ content:
+ application/json:
+ schema:
+ $ref: '../components/schemas/common.yaml#/ErrorResponse'
diff --git a/pkg/notifuse_mjml/liquid_secure.go b/pkg/notifuse_mjml/liquid_secure.go
index 448af9d5..752a0843 100644
--- a/pkg/notifuse_mjml/liquid_secure.go
+++ b/pkg/notifuse_mjml/liquid_secure.go
@@ -28,6 +28,9 @@ func NewSecureLiquidEngine() *SecureLiquidEngine {
env := liquid.NewEnvironment()
tags.RegisterStandardTags(env)
+ // Register default empty translation filter so the t filter is always available
+ env.RegisterFilter(&TranslationFilters{translations: map[string]interface{}{}})
+
return &SecureLiquidEngine{
timeout: DefaultRenderTimeout,
maxSize: DefaultMaxTemplateSize,
@@ -40,6 +43,9 @@ func NewSecureLiquidEngineWithOptions(timeout time.Duration, maxSize int) *Secur
env := liquid.NewEnvironment()
tags.RegisterStandardTags(env)
+ // Register default empty translation filter so the t filter is always available
+ env.RegisterFilter(&TranslationFilters{translations: map[string]interface{}{}})
+
return &SecureLiquidEngine{
timeout: timeout,
maxSize: maxSize,
@@ -95,6 +101,16 @@ func (s *SecureLiquidEngine) RenderWithTimeout(content string, data map[string]i
}
}
+// RegisterTranslations registers translation data for the Liquid t filter.
+// Must be called before Render. Translations should be a merged map (template + workspace).
+func (s *SecureLiquidEngine) RegisterTranslations(translations map[string]interface{}) {
+ if translations == nil {
+ translations = map[string]interface{}{}
+ }
+ filter := &TranslationFilters{translations: translations}
+ s.env.RegisterFilter(filter)
+}
+
// Render is a convenience method that calls RenderWithTimeout
func (s *SecureLiquidEngine) Render(content string, data map[string]interface{}) (string, error) {
return s.RenderWithTimeout(content, data)
diff --git a/pkg/notifuse_mjml/translation_filter.go b/pkg/notifuse_mjml/translation_filter.go
new file mode 100644
index 00000000..59aca6c4
--- /dev/null
+++ b/pkg/notifuse_mjml/translation_filter.go
@@ -0,0 +1,95 @@
+package notifuse_mjml
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+)
+
+// translationPlaceholderRegex matches {{ key }} placeholders in translation values.
+var translationPlaceholderRegex = regexp.MustCompile(`\{\{\s*(\w+)\s*\}\}`)
+
+// TranslationFilters provides the Liquid `t` filter for resolving translation keys.
+// Register with SecureLiquidEngine.RegisterTranslations().
+type TranslationFilters struct {
+ translations map[string]interface{}
+}
+
+// T is the Liquid filter: {{ "welcome.heading" | t }}
+// With placeholders: {{ "welcome.greeting" | t: name: "John" }}
+//
+// liquidgo calls this method with:
+// - input: the piped value (the translation key string)
+// - args: variadic positional args followed by an optional keyword args map
+//
+// liquidgo passes keyword args (name: value) as the last element
+// in args if it's a map[string]interface{}.
+func (tf *TranslationFilters) T(input interface{}, args ...interface{}) interface{} {
+ keyStr := fmt.Sprintf("%v", input)
+
+ value := resolveNestedKey(tf.translations, keyStr)
+ if value == "" {
+ return "[Missing translation: " + keyStr + "]"
+ }
+
+ // Check if last arg is a keyword args map
+ var kwargs map[string]interface{}
+ if len(args) > 0 {
+ if m, ok := args[len(args)-1].(map[string]interface{}); ok {
+ kwargs = m
+ }
+ }
+
+ if len(kwargs) > 0 {
+ value = interpolatePlaceholders(value, kwargs)
+ }
+
+ return value
+}
+
+// resolveNestedKey traverses a nested map using a dot-separated key path
+// and returns the string value. Returns empty string if key not found or value is not a string.
+func resolveNestedKey(data map[string]interface{}, key string) string {
+ if data == nil || key == "" {
+ return ""
+ }
+
+ parts := strings.Split(key, ".")
+ var current interface{} = data
+
+ for _, part := range parts {
+ m, ok := current.(map[string]interface{})
+ if !ok {
+ return ""
+ }
+ current, ok = m[part]
+ if !ok {
+ return ""
+ }
+ }
+
+ if str, ok := current.(string); ok {
+ return str
+ }
+ return ""
+}
+
+// interpolatePlaceholders replaces {{ key }} placeholders in a translation value
+// with the corresponding values from the args map.
+func interpolatePlaceholders(value string, args map[string]interface{}) string {
+ if args == nil || len(args) == 0 {
+ return value
+ }
+
+ return translationPlaceholderRegex.ReplaceAllStringFunc(value, func(match string) string {
+ submatch := translationPlaceholderRegex.FindStringSubmatch(match)
+ if len(submatch) < 2 {
+ return match
+ }
+ key := submatch[1]
+ if val, ok := args[key]; ok {
+ return fmt.Sprintf("%v", val)
+ }
+ return match // leave unresolved placeholders as-is
+ })
+}
diff --git a/pkg/notifuse_mjml/translation_filter_test.go b/pkg/notifuse_mjml/translation_filter_test.go
new file mode 100644
index 00000000..be4faa14
--- /dev/null
+++ b/pkg/notifuse_mjml/translation_filter_test.go
@@ -0,0 +1,92 @@
+package notifuse_mjml
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestTranslationFilter_SimpleKey(t *testing.T) {
+ engine := NewSecureLiquidEngine()
+ translations := map[string]interface{}{
+ "welcome": map[string]interface{}{
+ "heading": "Welcome!",
+ },
+ }
+ engine.RegisterTranslations(translations)
+
+ result, err := engine.Render(`{{ "welcome.heading" | t }}`, map[string]interface{}{})
+ require.NoError(t, err)
+ assert.Equal(t, "Welcome!", result)
+}
+
+func TestTranslationFilter_MissingKey(t *testing.T) {
+ engine := NewSecureLiquidEngine()
+ translations := map[string]interface{}{}
+ engine.RegisterTranslations(translations)
+
+ result, err := engine.Render(`{{ "missing.key" | t }}`, map[string]interface{}{})
+ require.NoError(t, err)
+ assert.Equal(t, "[Missing translation: missing.key]", result)
+}
+
+func TestTranslationFilter_WithPlaceholders(t *testing.T) {
+ engine := NewSecureLiquidEngine()
+ translations := map[string]interface{}{
+ "welcome": map[string]interface{}{
+ "greeting": "Hello {{ name }}, welcome to {{ site }}!",
+ },
+ }
+ engine.RegisterTranslations(translations)
+
+ // The liquidgo filter receives named keyword args as a map
+ result, err := engine.Render(
+ `{{ "welcome.greeting" | t: name: "John", site: "Notifuse" }}`,
+ map[string]interface{}{},
+ )
+ require.NoError(t, err)
+ assert.Equal(t, "Hello John, welcome to Notifuse!", result)
+}
+
+func TestTranslationFilter_WithContactVariable(t *testing.T) {
+ engine := NewSecureLiquidEngine()
+ translations := map[string]interface{}{
+ "welcome": map[string]interface{}{
+ "greeting": "Hello {{ name }}!",
+ },
+ }
+ engine.RegisterTranslations(translations)
+
+ result, err := engine.Render(
+ `{{ "welcome.greeting" | t: name: contact.first_name }}`,
+ map[string]interface{}{
+ "contact": map[string]interface{}{
+ "first_name": "Alice",
+ },
+ },
+ )
+ require.NoError(t, err)
+ assert.Equal(t, "Hello Alice!", result)
+}
+
+func TestTranslationFilter_FlatKey(t *testing.T) {
+ engine := NewSecureLiquidEngine()
+ translations := map[string]interface{}{
+ "flat_key": "Flat value",
+ }
+ engine.RegisterTranslations(translations)
+
+ result, err := engine.Render(`{{ "flat_key" | t }}`, map[string]interface{}{})
+ require.NoError(t, err)
+ assert.Equal(t, "Flat value", result)
+}
+
+func TestTranslationFilter_NoRegistration(t *testing.T) {
+ // When no translations registered, t filter should return missing translation marker
+ engine := NewSecureLiquidEngine()
+
+ result, err := engine.Render(`{{ "some.key" | t }}`, map[string]interface{}{})
+ require.NoError(t, err)
+ assert.Equal(t, "[Missing translation: some.key]", result)
+}