Files
galaxy-game/mail/internal/adapters/templates/catalog.go
T
2026-04-17 18:39:16 +02:00

575 lines
16 KiB
Go

// Package templates provides the filesystem-backed template catalog used by
// Mail Service.
package templates
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
htmltemplate "html/template"
"os"
"path/filepath"
"sort"
"strings"
texttemplate "text/template"
"text/template/parse"
"galaxy/mail/internal/domain/common"
templatedomain "galaxy/mail/internal/domain/template"
)
const (
subjectTemplateFile = "subject.tmpl"
textTemplateFile = "text.tmpl"
htmlTemplateFile = "html.tmpl"
)
var (
// ErrTemplateNotFound reports that no template family exists for the
// requested template identifier.
ErrTemplateNotFound = errors.New("template catalog template not found")
// ErrFallbackMissing reports that the requested locale is unavailable and
// the mandatory `en` fallback variant is also missing.
ErrFallbackMissing = errors.New("template catalog fallback locale missing")
// ErrTemplateParseFailed reports that one filesystem template file could
// not be parsed into the in-memory registry.
ErrTemplateParseFailed = errors.New("template catalog template parse failed")
requiredStartupTemplate = templateKey{
TemplateID: common.TemplateID("auth.login_code"),
Locale: common.Locale("en"),
}
)
// Catalog stores the immutable in-memory template registry built at process
// startup.
type Catalog struct {
rootDir string
templates map[templateKey]*compiledTemplate
availableLocales map[common.TemplateID][]common.Locale
}
// ResolvedTemplate stores one resolved template variant together with lookup
// metadata such as locale fallback usage and required variable paths.
type ResolvedTemplate struct {
record templatedomain.Template
resolvedLocale common.Locale
localeFallbackUsed bool
requiredVariablePaths []string
subject *texttemplate.Template
text *texttemplate.Template
html *htmltemplate.Template
}
type templateKey struct {
TemplateID common.TemplateID
Locale common.Locale
}
type compiledTemplate struct {
record templatedomain.Template
requiredVariablePaths []string
subject *texttemplate.Template
text *texttemplate.Template
html *htmltemplate.Template
}
type templateSources struct {
TemplateID common.TemplateID
Locale common.Locale
Subject string
Text string
HTML string
}
// NewCatalog constructs Catalog for rootDir, parses the full template
// registry, and validates the mandatory auth login-code fallback template.
func NewCatalog(rootDir string) (*Catalog, error) {
if strings.TrimSpace(rootDir) == "" {
return nil, fmt.Errorf("new template catalog: root dir must not be empty")
}
cleanRootDir := filepath.Clean(rootDir)
info, err := os.Stat(cleanRootDir)
if err != nil {
return nil, fmt.Errorf("new template catalog: stat root dir %q: %w", cleanRootDir, err)
}
if !info.IsDir() {
return nil, fmt.Errorf("new template catalog: root dir %q must be a directory", cleanRootDir)
}
registry, availableLocales, err := loadRegistry(cleanRootDir)
if err != nil {
return nil, fmt.Errorf("new template catalog: %w", err)
}
if _, ok := registry[requiredStartupTemplate]; !ok {
return nil, fmt.Errorf(
"new template catalog: required template %q locale %q is missing",
requiredStartupTemplate.TemplateID,
requiredStartupTemplate.Locale,
)
}
return &Catalog{
rootDir: cleanRootDir,
templates: registry,
availableLocales: availableLocales,
}, nil
}
// RootDir returns the configured template catalog root directory.
func (catalog *Catalog) RootDir() string {
if catalog == nil {
return ""
}
return catalog.rootDir
}
// Lookup resolves one template family for locale, applying the frozen exact
// match followed by `en` fallback rule.
func (catalog *Catalog) Lookup(templateID common.TemplateID, locale common.Locale) (ResolvedTemplate, error) {
if catalog == nil {
return ResolvedTemplate{}, errors.New("lookup template: nil catalog")
}
if err := templateID.Validate(); err != nil {
return ResolvedTemplate{}, fmt.Errorf("lookup template: template id: %w", err)
}
if err := locale.Validate(); err != nil {
return ResolvedTemplate{}, fmt.Errorf("lookup template: locale: %w", err)
}
exactKey := templateKey{TemplateID: templateID, Locale: locale}
if compiled, ok := catalog.templates[exactKey]; ok {
return compiled.resolve(false), nil
}
fallbackKey := templateKey{TemplateID: templateID, Locale: common.Locale("en")}
if compiled, ok := catalog.templates[fallbackKey]; ok {
return compiled.resolve(true), nil
}
if _, ok := catalog.availableLocales[templateID]; ok {
return ResolvedTemplate{}, fmt.Errorf(
"lookup template %q locale %q: %w",
templateID,
locale,
ErrFallbackMissing,
)
}
return ResolvedTemplate{}, fmt.Errorf(
"lookup template %q locale %q: %w",
templateID,
locale,
ErrTemplateNotFound,
)
}
// Template returns the resolved logical template record.
func (resolved ResolvedTemplate) Template() templatedomain.Template {
return resolved.record
}
// ResolvedLocale returns the filesystem locale variant that will actually be
// executed.
func (resolved ResolvedTemplate) ResolvedLocale() common.Locale {
return resolved.resolvedLocale
}
// LocaleFallbackUsed reports whether lookup fell back from the requested
// locale to `en`.
func (resolved ResolvedTemplate) LocaleFallbackUsed() bool {
return resolved.localeFallbackUsed
}
// RequiredVariablePaths returns the sorted list of dot-path variables used by
// the resolved template variant.
func (resolved ResolvedTemplate) RequiredVariablePaths() []string {
return append([]string(nil), resolved.requiredVariablePaths...)
}
// ExecuteSubject executes the resolved subject template with data.
func (resolved ResolvedTemplate) ExecuteSubject(data any) (string, error) {
return executeTextTemplate("subject", resolved.subject, data)
}
// ExecuteText executes the resolved plaintext body template with data.
func (resolved ResolvedTemplate) ExecuteText(data any) (string, error) {
return executeTextTemplate("text", resolved.text, data)
}
// ExecuteHTML executes the resolved HTML body template with data. The second
// return value reports whether the resolved variant contains HTML content.
func (resolved ResolvedTemplate) ExecuteHTML(data any) (string, bool, error) {
if resolved.html == nil {
return "", false, nil
}
rendered, err := executeHTMLTemplate("html", resolved.html, data)
if err != nil {
return "", true, err
}
return rendered, true, nil
}
func loadRegistry(rootDir string) (map[templateKey]*compiledTemplate, map[common.TemplateID][]common.Locale, error) {
sourceBundles := make(map[templateKey]*templateSources)
if err := filepath.WalkDir(rootDir, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
relativePath, err := filepath.Rel(rootDir, path)
if err != nil {
return err
}
if relativePath == "." {
return nil
}
relativePath = filepath.ToSlash(relativePath)
if entry.IsDir() {
return nil
}
parts := strings.Split(relativePath, "/")
if len(parts) != 3 {
return fmt.Errorf("invalid template path %q: expected <template_id>/<locale>/<file>", relativePath)
}
templateID := common.TemplateID(parts[0])
if err := templateID.Validate(); err != nil {
return fmt.Errorf("invalid template path %q: %w", relativePath, err)
}
locale, err := common.ParseLocale(parts[1])
if err != nil {
return fmt.Errorf("invalid template path %q: %w", relativePath, err)
}
contentsBytes, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read template file %q: %w", path, err)
}
key := templateKey{TemplateID: templateID, Locale: locale}
bundle := sourceBundles[key]
if bundle == nil {
bundle = &templateSources{
TemplateID: templateID,
Locale: locale,
}
sourceBundles[key] = bundle
}
switch parts[2] {
case subjectTemplateFile:
if bundle.Subject != "" {
return fmt.Errorf("duplicate template subject for %q locale %q", templateID, locale)
}
bundle.Subject = string(contentsBytes)
case textTemplateFile:
if bundle.Text != "" {
return fmt.Errorf("duplicate template text body for %q locale %q", templateID, locale)
}
bundle.Text = string(contentsBytes)
case htmlTemplateFile:
if bundle.HTML != "" {
return fmt.Errorf("duplicate template html body for %q locale %q", templateID, locale)
}
bundle.HTML = string(contentsBytes)
default:
return fmt.Errorf("invalid template path %q: unsupported file name %q", relativePath, parts[2])
}
return nil
}); err != nil {
return nil, nil, err
}
registry := make(map[templateKey]*compiledTemplate, len(sourceBundles))
availableLocales := make(map[common.TemplateID][]common.Locale)
for key, bundle := range sourceBundles {
compiled, err := compileTemplate(*bundle)
if err != nil {
return nil, nil, err
}
registry[key] = compiled
availableLocales[key.TemplateID] = append(availableLocales[key.TemplateID], key.Locale)
}
for templateID := range availableLocales {
sort.Slice(availableLocales[templateID], func(left int, right int) bool {
return availableLocales[templateID][left].String() < availableLocales[templateID][right].String()
})
}
return registry, availableLocales, nil
}
func compileTemplate(source templateSources) (*compiledTemplate, error) {
if source.Subject == "" {
return nil, fmt.Errorf("template %q locale %q is missing %s", source.TemplateID, source.Locale, subjectTemplateFile)
}
if source.Text == "" {
return nil, fmt.Errorf("template %q locale %q is missing %s", source.TemplateID, source.Locale, textTemplateFile)
}
subject, err := parseText(source.TemplateID, source.Locale, "subject", source.Subject)
if err != nil {
return nil, err
}
textBody, err := parseText(source.TemplateID, source.Locale, "text", source.Text)
if err != nil {
return nil, err
}
var htmlBody *htmltemplate.Template
if source.HTML != "" {
htmlBody, err = parseHTML(source.TemplateID, source.Locale, "html", source.HTML)
if err != nil {
return nil, err
}
}
record := templatedomain.Template{
TemplateID: source.TemplateID,
Locale: source.Locale,
SubjectTemplate: source.Subject,
TextTemplate: source.Text,
HTMLTemplate: source.HTML,
Version: computeVersion(source),
}
if err := record.Validate(); err != nil {
return nil, fmt.Errorf("compile template %q locale %q: %w", source.TemplateID, source.Locale, err)
}
requiredVariablePaths := collectRequiredVariablePaths(subject.Tree, textBody.Tree)
if htmlBody != nil {
requiredVariablePaths = mergeRequiredVariablePaths(requiredVariablePaths, collectRequiredVariablePaths(htmlBody.Tree))
}
return &compiledTemplate{
record: record,
requiredVariablePaths: requiredVariablePaths,
subject: subject,
text: textBody,
html: htmlBody,
}, nil
}
func parseText(templateID common.TemplateID, locale common.Locale, part string, source string) (*texttemplate.Template, error) {
parsed, err := texttemplate.New(part).Option("missingkey=error").Parse(source)
if err != nil {
return nil, fmt.Errorf(
"parse template %q locale %q part %q: %w: %v",
templateID,
locale,
part,
ErrTemplateParseFailed,
err,
)
}
return parsed, nil
}
func parseHTML(templateID common.TemplateID, locale common.Locale, part string, source string) (*htmltemplate.Template, error) {
parsed, err := htmltemplate.New(part).Option("missingkey=error").Parse(source)
if err != nil {
return nil, fmt.Errorf(
"parse template %q locale %q part %q: %w: %v",
templateID,
locale,
part,
ErrTemplateParseFailed,
err,
)
}
return parsed, nil
}
func computeVersion(source templateSources) string {
sum := sha256.New()
for _, part := range []string{
source.TemplateID.String(),
source.Locale.String(),
source.Subject,
source.Text,
source.HTML,
} {
_, _ = sum.Write([]byte(part))
_, _ = sum.Write([]byte{0})
}
return "sha256:" + hex.EncodeToString(sum.Sum(nil))
}
func (compiled *compiledTemplate) resolve(localeFallbackUsed bool) ResolvedTemplate {
return ResolvedTemplate{
record: compiled.record,
resolvedLocale: compiled.record.Locale,
localeFallbackUsed: localeFallbackUsed,
requiredVariablePaths: append([]string(nil), compiled.requiredVariablePaths...),
subject: compiled.subject,
text: compiled.text,
html: compiled.html,
}
}
func executeTextTemplate(name string, tmpl *texttemplate.Template, data any) (string, error) {
if tmpl == nil {
return "", fmt.Errorf("execute %s template: nil template", name)
}
var builder strings.Builder
if err := tmpl.Execute(&builder, data); err != nil {
return "", fmt.Errorf("execute %s template: %w", name, err)
}
return builder.String(), nil
}
func executeHTMLTemplate(name string, tmpl *htmltemplate.Template, data any) (string, error) {
if tmpl == nil {
return "", fmt.Errorf("execute %s template: nil template", name)
}
var builder strings.Builder
if err := tmpl.Execute(&builder, data); err != nil {
return "", fmt.Errorf("execute %s template: %w", name, err)
}
return builder.String(), nil
}
func collectRequiredVariablePaths(trees ...*parse.Tree) []string {
paths := make(map[string]struct{})
for _, tree := range trees {
if tree == nil || tree.Root == nil {
continue
}
collectNodePaths(tree.Root, nil, paths)
}
collected := make([]string, 0, len(paths))
for path := range paths {
collected = append(collected, path)
}
sort.Strings(collected)
return collected
}
func mergeRequiredVariablePaths(existing []string, additional []string) []string {
merged := make(map[string]struct{}, len(existing)+len(additional))
for _, path := range existing {
merged[path] = struct{}{}
}
for _, path := range additional {
merged[path] = struct{}{}
}
combined := make([]string, 0, len(merged))
for path := range merged {
combined = append(combined, path)
}
sort.Strings(combined)
return combined
}
func collectNodePaths(node parse.Node, scope []string, paths map[string]struct{}) {
switch typed := node.(type) {
case *parse.ListNode:
if typed == nil {
return
}
for _, child := range typed.Nodes {
collectNodePaths(child, scope, paths)
}
case *parse.ActionNode:
collectPipePaths(typed.Pipe, scope, paths)
case *parse.IfNode:
collectPipePaths(typed.Pipe, scope, paths)
collectNodePaths(typed.List, scope, paths)
collectNodePaths(typed.ElseList, scope, paths)
case *parse.RangeNode:
collectPipePaths(typed.Pipe, scope, paths)
collectNodePaths(typed.List, scopeForPipe(typed.Pipe, scope), paths)
collectNodePaths(typed.ElseList, scope, paths)
case *parse.WithNode:
collectPipePaths(typed.Pipe, scope, paths)
collectNodePaths(typed.List, scopeForPipe(typed.Pipe, scope), paths)
collectNodePaths(typed.ElseList, scope, paths)
case *parse.TemplateNode:
collectPipePaths(typed.Pipe, scope, paths)
}
}
func collectPipePaths(pipe *parse.PipeNode, scope []string, paths map[string]struct{}) {
if pipe == nil {
return
}
for _, command := range pipe.Cmds {
for _, arg := range command.Args {
path, ok := nodePath(arg, scope)
if !ok || len(path) == 0 {
continue
}
paths[strings.Join(path, ".")] = struct{}{}
}
}
}
func scopeForPipe(pipe *parse.PipeNode, scope []string) []string {
if pipe == nil || len(pipe.Cmds) != 1 || len(pipe.Cmds[0].Args) != 1 {
return nil
}
path, ok := nodePath(pipe.Cmds[0].Args[0], scope)
if !ok {
return nil
}
return path
}
func nodePath(node parse.Node, scope []string) ([]string, bool) {
switch typed := node.(type) {
case *parse.FieldNode:
return appendPath(scope, typed.Ident), true
case *parse.ChainNode:
prefix, ok := nodePath(typed.Node, scope)
if !ok {
return nil, false
}
return appendPath(prefix, typed.Field), true
case *parse.DotNode:
if len(scope) == 0 {
return nil, false
}
return append([]string(nil), scope...), true
default:
return nil, false
}
}
func appendPath(prefix []string, suffix []string) []string {
combined := make([]string, 0, len(prefix)+len(suffix))
combined = append(combined, prefix...)
combined = append(combined, suffix...)
return combined
}