Implement notifications service with shared messaging and email drivers (#36)

This commit is contained in:
Stephan D committed 2026-09-12 14:41:55 +02:00
1 parent 36277b6c45
commit 5c2d36281b
54 files changed
+3746 -6

No files matched your search

+146
View File
@@ -0,0 +1,146 @@
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
}