// 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 //", 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 }