Vault

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 registration
  • auth.email-verification -- Email verification link
  • auth.password-reset -- Password reset link
  • auth.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:

  1. Exact locale match -- e.g., fr-CA
  2. Language-only match -- e.g., fr (stripped from fr-CA)
  3. 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:

FunctionDescriptionExample
upperUppercase string{{upper .name}}
lowerLowercase string{{lower .email}}
titleTitle-case string{{title .name}}
truncateTruncate to N characters{{truncate .body 100}}
defaultReturn default if value is nil/empty{{default "Guest" .name}}
nowCurrent UTC time (RFC3339){{now}}
formatDateFormat 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:

SlugChannelDescription
auth.welcomeemail, inappWelcome notification
auth.email-verificationemailEmail verification link
auth.password-resetemailPassword reset link
auth.password-changedemail, inappPassword change confirmation
auth.invitationemail, inappOrganization invitation
auth.new-device-loginemail, inappNew device login alert
auth.mfa-codesmsMFA verification code
auth.phone-verificationsmsPhone 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:

MethodPathOperation
POST/templatesCreate a template
GET/templatesList templates (filter by app_id, channel)
GET/templates/:idGet template with all versions
PUT/templates/:idUpdate a template
DELETE/templates/:idDelete template and all versions
POST/templates/:id/versionsAdd a locale version
GET/templates/:id/versionsList versions for a template
PUT/templates/:id/versions/:versionIdUpdate a version
DELETE/templates/:id/versions/:versionIdDelete 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.

On this page