575 lines
16 KiB
Go
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
|
|
}
|