package templates import ( "bytes" "encoding/json" html "html/template" "io" "os" "strings" text "text/template" "git.sendico.io/tech/sxchnge/notifications/model" merrors "git.sendico.io/tech/sxchnge/shared/errors" ) type Content struct { Subject string `json:"subject"` HTML string `json:"html"` Text string `json:"text"` } type Definition struct { ID string `json:"id"` Version string `json:"version"` DefaultLocale string `json:"default_locale"` Parameters []string `json:"parameters"` Locales map[string]Content `json:"locales"` } type compiled struct { definition Definition locales map[string]renderers } type renderers struct { subject, text *text.Template html *html.Template } type Catalog struct{ entries map[string]compiled } func Load(path string) (*Catalog, error) { f, err := os.Open(path) if err != nil { return nil, merrors.OpaqueWrap(merrors.ErrInvalidArg, err, "cannot open template catalog") } defer f.Close() var definitions []Definition dec := json.NewDecoder(io.LimitReader(f, 4<<20)) dec.DisallowUnknownFields() if err = dec.Decode(&definitions); err != nil { return nil, merrors.OpaqueWrap(merrors.ErrInvalidArg, err, "invalid template catalog") } if err = dec.Decode(new(any)); err != io.EOF { return nil, merrors.InvalidArgument("unexpected template catalog content") } return New(definitions) } func New(definitions []Definition) (*Catalog, error) { c := &Catalog{entries: map[string]compiled{}} if len(definitions) == 0 { return nil, merrors.InvalidArgument("empty template catalog") } for _, d := range definitions { key := d.ID + "\x00" + d.Version if d.ID == "" || d.Version == "" || strings.ContainsRune(d.ID+d.Version, 0) { return nil, merrors.InvalidArgument("invalid template identity") } if _, ok := c.entries[key]; ok { return nil, merrors.InvalidArgument("duplicate template revision") } if _, ok := d.Locales[d.DefaultLocale]; !ok || d.DefaultLocale == "" { return nil, merrors.InvalidArgument("missing default locale") } params := map[string]string{} for _, p := range d.Parameters { if p == "" { return nil, merrors.InvalidArgument("empty parameter") } if _, ok := params[p]; ok { return nil, merrors.InvalidArgument("duplicate parameter") } params[p] = "sample" } entry := compiled{definition: d, locales: map[string]renderers{}} for locale, content := range d.Locales { s, err := text.New("subject").Option("missingkey=error").Parse(content.Subject) if err != nil { return nil, merrors.InvalidArgument("invalid subject template") } t, err := text.New("text").Option("missingkey=error").Parse(content.Text) if err != nil { return nil, merrors.InvalidArgument("invalid text template") } h, err := html.New("html").Option("missingkey=error").Parse(content.HTML) if err != nil { return nil, merrors.InvalidArgument("invalid HTML template") } entry.locales[locale] = renderers{s, t, h} } c.entries[key] = entry for locale := range d.Locales { if _, err := c.Render(model.Request{TemplateID: d.ID, TemplateVersion: d.Version, Locale: locale, Parameters: params}); err != nil { return nil, err } } } return c, nil } type boundedBuffer struct{ bytes.Buffer } func (b *boundedBuffer) Write(p []byte) (int, error) { if b.Len()+len(p) > 256<<10 { return 0, merrors.InvalidArgument("rendered content too large") } return b.Buffer.Write(p) } func (c *Catalog) Render(r model.Request) (model.Email, error) { entry, ok := c.entries[r.TemplateID+"\x00"+r.TemplateVersion] if !ok { return model.Email{}, merrors.NoData("template revision not found") } if len(r.Parameters) != len(entry.definition.Parameters) { return model.Email{}, merrors.InvalidArgument("template parameters do not match") } for _, p := range entry.definition.Parameters { if _, ok := r.Parameters[p]; !ok { return model.Email{}, merrors.InvalidArgument("missing template parameter") } } locale := r.Locale if locale == "" { locale = entry.definition.DefaultLocale } render, ok := entry.locales[locale] if !ok { return model.Email{}, merrors.InvalidArgument("unsupported template locale") } var subject, body, plain boundedBuffer for _, run := range []func() error{func() error { return render.subject.Execute(&subject, r.Parameters) }, func() error { return render.html.Execute(&body, r.Parameters) }, func() error { return render.text.Execute(&plain, r.Parameters) }} { if err := run(); err != nil { return model.Email{}, merrors.OpaqueWrap(merrors.ErrInvalidArg, err, "template rendering failed") } } if subject.Len() == 0 || subject.Len() > 998 || strings.ContainsAny(subject.String(), "\r\n") || (body.Len() == 0 && plain.Len() == 0) { return model.Email{}, merrors.InvalidArgument("invalid rendered email") } return model.Email{Address: r.Address, Subject: subject.String(), HTML: body.String(), Text: plain.String()}, nil }