Templates
Manage notification templates with slug-based lookup, i18n versioning, variable definitions, and Go template rendering.
Herald's template system provides reusable, localizable notification content. Templates are identified by slug (e.g., auth.welcome), support multiple locale-specific versions, declare their expected variables, and render using Go's text/template and html/template engines.
Template Entity
The template.Template struct holds the metadata for a notification template:
type Template struct {
ID id.TemplateID `json:"id"`
AppID string `json:"app_id"`
Slug string `json:"slug"` // unique per app+channel
Name string `json:"name"`
Channel string `json:"channel"` // "email", "sms", "push", "inapp"
Category string `json:"category"`
Variables []Variable `json:"variables"`
Versions []Version `json:"versions"`
IsSystem bool `json:"is_system"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}Templates are identified by TypeIDs with the htpl prefix.
Slug-Based Lookup
Templates are resolved at send time by their Slug combined with AppID and Channel. This allows you to use the same slug across different channels (e.g., auth.welcome for both email and in-app):
tmpl, err := store.GetTemplateBySlug(ctx, appID, "auth.welcome", "email")Slugs follow a dotted naming convention grouping templates by domain:
auth.welcome-- Welcome email sent after registrationauth.email-verification-- Email verification linkauth.password-reset-- Password reset linkauth.mfa-code-- MFA verification code (SMS)auth.invitation-- Organization invitation
Categories
Templates are organized into categories for management:
const (
CategoryAuth = "auth"
CategoryTransactional = "transactional"
CategoryMarketing = "marketing"
CategorySystem = "system"
)i18n Versions
Each template can have multiple locale-specific versions. A version contains the actual content for a particular locale:
type Version struct {
ID id.TemplateVersionID `json:"id"`
TemplateID id.TemplateID `json:"template_id"`
Locale string `json:"locale"` // "en", "fr", "de", "es", "" (default)
Subject string `json:"subject"` // email subject line
HTML string `json:"html"` // HTML body (email)
Text string `json:"text"` // plain text body
Title string `json:"title"` // notification title (push/inapp)
Active bool `json:"active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}Version IDs use the htpv prefix.
Locale Resolution
When rendering a template, Herald resolves the best matching version through a fallback chain:
- Exact locale match -- e.g.,
fr-CA - Language-only match -- e.g.,
fr(stripped fromfr-CA) - Default version -- the version with an empty locale
""
// The renderer walks versions to find the best match.
func (r *Renderer) findVersion(tmpl *Template, locale string) *Version {
// 1. Exact match
// 2. Language prefix match (e.g., "en" from "en-US")
// 3. Default version (locale == "")
}Only active versions (Active: true) are considered.
Variable Definitions
Templates declare expected variables with type information and validation:
type Variable struct {
Name string `json:"name"`
Type string `json:"type"` // "string", "url", etc.
Required bool `json:"required"`
Default string `json:"default"`
Description string `json:"description"`
}Before rendering, the renderer validates that all required variables are present in the data map. Variables with a Default value are not flagged as missing:
// Validation runs before rendering
for _, v := range tmpl.Variables {
if v.Required {
if _, ok := data[v.Name]; !ok && v.Default == "" {
return fmt.Errorf("%w: %s", ErrMissingRequiredVariable, v.Name)
}
}
}Example Variable Definitions
Variables: []template.Variable{
{Name: "user_name", Type: "string", Required: true, Description: "User's display name"},
{Name: "app_name", Type: "string", Required: true, Description: "Application name"},
{Name: "reset_url", Type: "url", Required: true, Description: "Password reset URL"},
{Name: "expires_in", Type: "string", Required: false, Default: "1 hour", Description: "Link expiry"},
}Go Template Rendering
Herald uses Go's standard text/template for plain text and subject lines, and html/template (which auto-escapes HTML) for HTML bodies. Each field is rendered independently:
rendered, err := renderer.Render(tmpl, "en", map[string]any{
"user_name": "Alice",
"app_name": "MyApp",
"reset_url": "https://myapp.com/reset?token=abc123",
})
// rendered.Subject = "Reset your MyApp password"
// rendered.HTML = "<h2>Reset Your Password</h2><p>Hi Alice,</p>..."
// rendered.Text = "Hi Alice,\n\nWe received a request to reset your MyApp password..."Built-in Template Functions
The renderer includes helper functions available in all templates:
| Function | Description | Example |
|---|---|---|
upper | Uppercase string | {{upper .name}} |
lower | Lowercase string | {{lower .email}} |
title | Title-case string | {{title .name}} |
truncate | Truncate to N characters | {{truncate .body 100}} |
default | Return default if value is nil/empty | {{default "Guest" .name}} |
now | Current UTC time (RFC3339) | {{now}} |
formatDate | Format a time.Time | {{formatDate .created "Jan 2, 2006"}} |
Template Syntax Examples
Subject: Welcome to {{.app_name}}!
Body:
Hi {{.user_name}},
Welcome to {{.app_name}}! We're excited to have you on board.
{{if .login_url}}Log in at: {{.login_url}}{{end}}
{{default "1 hour" .expires_in}} until your link expires.System Templates vs Custom Templates
Templates with IsSystem: true are seeded automatically by Herald when SeedDefaultTemplates is called. System templates cover common authentication and transactional flows out of the box:
| Slug | Channel | Description |
|---|---|---|
auth.welcome | email, inapp | Welcome notification |
auth.email-verification | Email verification link | |
auth.password-reset | Password reset link | |
auth.password-changed | email, inapp | Password change confirmation |
auth.invitation | email, inapp | Organization invitation |
auth.new-device-login | email, inapp | New device login alert |
auth.mfa-code | sms | MFA verification code |
auth.phone-verification | sms | Phone verification code |
System templates are only seeded if they do not already exist, so customizations are preserved:
func (h *Herald) SeedDefaultTemplates(ctx context.Context, appID string) error {
defaults := template.DefaultTemplates(appID)
for _, tmpl := range defaults {
existing, _ := h.store.GetTemplateBySlug(ctx, appID, tmpl.Slug, tmpl.Channel)
if existing != nil {
continue // already seeded
}
_ = h.store.CreateTemplate(ctx, tmpl)
// ... seed versions
}
return nil
}Custom templates can be created through the API with IsSystem: false for any application-specific notification.
Store Interface
The template.Store interface covers both templates and their versions:
type Store interface {
// Template CRUD
CreateTemplate(ctx context.Context, t *Template) error
GetTemplate(ctx context.Context, templateID id.TemplateID) (*Template, error)
GetTemplateBySlug(ctx context.Context, appID, slug, channel string) (*Template, error)
UpdateTemplate(ctx context.Context, t *Template) error
DeleteTemplate(ctx context.Context, templateID id.TemplateID) error
ListTemplates(ctx context.Context, appID string) ([]*Template, error)
ListTemplatesByChannel(ctx context.Context, appID, channel string) ([]*Template, error)
// Version CRUD
CreateVersion(ctx context.Context, v *Version) error
GetVersion(ctx context.Context, versionID id.TemplateVersionID) (*Version, error)
UpdateVersion(ctx context.Context, v *Version) error
DeleteVersion(ctx context.Context, versionID id.TemplateVersionID) error
ListVersions(ctx context.Context, templateID id.TemplateID) ([]*Version, error)
}API Endpoints
Templates and versions are managed via the /templates route group:
| Method | Path | Operation |
|---|---|---|
POST | /templates | Create a template |
GET | /templates | List templates (filter by app_id, channel) |
GET | /templates/:id | Get template with all versions |
PUT | /templates/:id | Update a template |
DELETE | /templates/:id | Delete template and all versions |
POST | /templates/:id/versions | Add a locale version |
GET | /templates/:id/versions | List versions for a template |
PUT | /templates/:id/versions/:versionId | Update a version |
DELETE | /templates/:id/versions/:versionId | Delete a version |
Duplicate Protection
Herald enforces uniqueness on the combination of (app_id, slug, channel) for templates and (template_id, locale) for versions. Attempting to create a duplicate returns ErrDuplicateSlug or ErrDuplicateLocale.