diff --git a/examples/hello/plugins/base/views/mail/hello-en.htm b/examples/hello/plugins/base/views/mail/hello-en.htm new file mode 100644 index 0000000..33db1cb --- /dev/null +++ b/examples/hello/plugins/base/views/mail/hello-en.htm @@ -0,0 +1,7 @@ +subject = "Hello {{ .Name }}" +description = "English hello greeting" +layout = "default" +== +Hello **{{ .Name }}**. + +[Site]({{ .URL }}) diff --git a/examples/hello/plugins/base/views/mail/hello.htm b/examples/hello/plugins/base/views/mail/hello.htm new file mode 100644 index 0000000..9a28c78 --- /dev/null +++ b/examples/hello/plugins/base/views/mail/hello.htm @@ -0,0 +1,7 @@ +subject = "Witaj {{ .Name }}" +description = "Hello greeting" +layout = "default" +== +Witaj **{{ .Name }}**. + +[Strona]({{ .URL }}) diff --git a/go.mod b/go.mod index 0e09420..6fe51d9 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/spf13/cobra v1.10.2 github.com/testcontainers/testcontainers-go v0.44.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 + github.com/yuin/goldmark v1.8.6 golang.org/x/term v0.46.0 golang.org/x/text v0.40.0 gorm.io/driver/postgres v1.6.3 diff --git a/go.sum b/go.sum index d687a63..c37e881 100644 --- a/go.sum +++ b/go.sum @@ -160,6 +160,8 @@ github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRU github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= +github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4= +github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= diff --git a/postcard/assets/default.htm b/postcard/assets/default.htm new file mode 100644 index 0000000..dbb6c36 --- /dev/null +++ b/postcard/assets/default.htm @@ -0,0 +1,14 @@ +name = "Default" +== +{{ .Content }} +== + + + + + + + +
{{ .Content }}
+ + diff --git a/postcard/drivers.go b/postcard/drivers.go new file mode 100644 index 0000000..812741b --- /dev/null +++ b/postcard/drivers.go @@ -0,0 +1,67 @@ +package postcard + +import ( + "context" + "fmt" + "sync" +) + +// Driver delivers a fully rendered message. +type Driver interface { + Send(ctx context.Context, msg RenderedMessage) error +} + +// RenderedMessage is the validated payload a driver transmits. +type RenderedMessage struct { + To []string + Cc []string + Bcc []string + From string + ReplyTo string + Subject string + HTML string + Text string +} + +// MemoryDriver stores rendered messages for tests. It is concurrency-safe. +type MemoryDriver struct { + mu sync.Mutex + messages []RenderedMessage +} + +// NewMemoryDriver returns an empty in-memory driver. +func NewMemoryDriver() *MemoryDriver { + return &MemoryDriver{} +} + +// Send appends msg to the in-memory log. +func (d *MemoryDriver) Send(_ context.Context, msg RenderedMessage) error { + if d == nil { + return fmt.Errorf("postcard: memory driver is nil") + } + d.mu.Lock() + defer d.mu.Unlock() + d.messages = append(d.messages, cloneRendered(msg)) + return nil +} + +// Messages returns a copy of stored messages in send order. +func (d *MemoryDriver) Messages() []RenderedMessage { + if d == nil { + return nil + } + d.mu.Lock() + defer d.mu.Unlock() + out := make([]RenderedMessage, len(d.messages)) + for i, msg := range d.messages { + out[i] = cloneRendered(msg) + } + return out +} + +func cloneRendered(msg RenderedMessage) RenderedMessage { + msg.To = append([]string(nil), msg.To...) + msg.Cc = append([]string(nil), msg.Cc...) + msg.Bcc = append([]string(nil), msg.Bcc...) + return msg +} diff --git a/postcard/mailer.go b/postcard/mailer.go new file mode 100644 index 0000000..4c13441 --- /dev/null +++ b/postcard/mailer.go @@ -0,0 +1,72 @@ +package postcard + +import ( + "context" + "fmt" +) + +// Message is the Send contract for plugin callers. +type Message struct { + Template string + To []string + Cc []string + Bcc []string + ReplyTo string + Vars map[string]any + Subject string +} + +// Mailer sends a registered template through the configured driver. +type Mailer interface { + Send(ctx context.Context, msg Message) error +} + +// Options configure an app-scoped mailer. +type Options struct { + From string + CSS string + BrandCSS string +} + +type mailer struct { + catalog *Catalog + driver Driver + opts Options +} + +// NewMailer returns a mailer that renders from cat and delivers through driver. +func NewMailer(cat *Catalog, driver Driver, opts Options) Mailer { + if cat == nil { + cat = NewCatalog() + } + return &mailer{catalog: cat, driver: driver, opts: opts} +} + +// Send renders msg.Template and delivers it. It performs no locale selection; +// the caller must pass the full dotted name, including any -en suffix. +func (m *mailer) Send(ctx context.Context, msg Message) error { + if m == nil { + return fmt.Errorf("postcard: mailer is nil") + } + if m.driver == nil { + return fmt.Errorf("postcard: driver is nil") + } + if msg.Template == "" { + return fmt.Errorf("postcard: template is empty") + } + if len(msg.To) == 0 && len(msg.Cc) == 0 && len(msg.Bcc) == 0 { + return fmt.Errorf("postcard: no recipients") + } + rendered, err := m.catalog.render(msg, renderOptions{ + css: m.opts.CSS, + brandCSS: m.opts.BrandCSS, + }) + if err != nil { + return err + } + rendered.From = m.opts.From + if err := m.driver.Send(ctx, rendered); err != nil { + return fmt.Errorf("postcard: %w", err) + } + return nil +} diff --git a/postcard/mailer_test.go b/postcard/mailer_test.go new file mode 100644 index 0000000..01e4b01 --- /dev/null +++ b/postcard/mailer_test.go @@ -0,0 +1,104 @@ +package postcard + +import ( + "context" + "strings" + "testing" + "testing/fstest" +) + +func TestMailRenderSmoke(t *testing.T) { + t.Parallel() + + fsys := fstest.MapFS{ + "views/mail/hello.htm": {Data: []byte(`subject = "Witaj {{ .Name }}" +description = "Hello greeting" +layout = "default" +== +Witaj **{{ .Name }}**. + +[Strona]({{ .URL }}) +`)}, + "views/mail/hello-en.htm": {Data: []byte(`subject = "Hello {{ .Name }}" +description = "English hello greeting" +layout = "default" +== +Hello **{{ .Name }}**. + +[Site]({{ .URL }}) +`)}, + } + cat := NewCatalog() + err := cat.Register("golem15.hello", fsys, []string{ + "golem15.hello::mail.hello", + "golem15.hello::mail.hello-en", + }, nil) + if err != nil { + t.Fatalf("Register: %v", err) + } + drv := NewMemoryDriver() + mail := NewMailer(cat, drv, Options{}) + ctx := context.Background() + vars := map[string]any{"Name": "Ada", "URL": "https://example.test"} + + if err := mail.Send(ctx, Message{ + Template: "golem15.hello::mail.hello", + To: []string{"ada@example.test"}, + Vars: vars, + }); err != nil { + t.Fatalf("Send hello: %v", err) + } + if err := mail.Send(ctx, Message{ + Template: "golem15.hello::mail.hello-en", + To: []string{"ada@example.test"}, + Vars: vars, + }); err != nil { + t.Fatalf("Send hello-en: %v", err) + } + + got := drv.Messages() + if len(got) != 2 { + t.Fatalf("stored %d messages, want 2", len(got)) + } + pl, en := got[0], got[1] + if pl.Subject != "Witaj Ada" { + t.Fatalf("default locale subject = %q", pl.Subject) + } + if en.Subject != "Hello Ada" { + t.Fatalf("-en subject = %q", en.Subject) + } + if !strings.Contains(pl.Text, "Witaj **Ada**.") || strings.Contains(pl.Text, "Hello **Ada**.") { + t.Fatalf("default locale text = %q", pl.Text) + } + if !strings.Contains(en.Text, "Hello **Ada**.") || strings.Contains(en.Text, "Witaj **Ada**.") { + t.Fatalf("-en text = %q", en.Text) + } + if !strings.Contains(pl.HTML, "Ada") || !strings.Contains(pl.HTML, `href="https://example.test"`) { + t.Fatalf("default locale HTML = %q", pl.HTML) + } + if !strings.Contains(en.HTML, "Ada") || !strings.Contains(en.HTML, `href="https://example.test"`) { + t.Fatalf("-en HTML = %q", en.HTML) + } + if !strings.Contains(pl.HTML, `class="content-body"`) || !strings.Contains(en.HTML, `class="content-body"`) { + t.Fatalf("neutral default layout missing from HTML") + } + + t.Run("unsafe vars stay out of HTML", func(t *testing.T) { + drv := NewMemoryDriver() + mail := NewMailer(cat, drv, Options{}) + if err := mail.Send(ctx, Message{ + Template: "golem15.hello::mail.hello-en", + To: []string{"ada@example.test"}, + Vars: map[string]any{ + "Name": ``, + "URL": "javascript:alert(1)", + }, + }); err != nil { + t.Fatalf("Send: %v", err) + } + html := drv.Messages()[0].HTML + if strings.Contains(strings.ToLower(html), " short name -> full name +} + +type parsedTemplate struct { + name string + pluginID string + subject string + description string + layout string // short name from header; empty means default + body string +} + +type parsedLayout struct { + name string + pluginID string + headerName string + textWrap string + htmlWrap string +} + +// NewCatalog returns an empty catalog that already contains postcard's +// neutral default layout. +func NewCatalog() *Catalog { + c := &Catalog{ + templates: make(map[string]*parsedTemplate), + layouts: make(map[string]*parsedLayout), + aliases: make(map[string]map[string]string), + } + layout, err := parseLayout(defaultLayoutName, "postcard", defaultLayoutSource) + if err != nil { + panic("postcard: default layout: " + err.Error()) + } + c.layouts[defaultLayoutName] = layout + return c +} + +// Register loads declared dotted template and layout names from fsys. +// Each name must be owned by pluginID and map to views/mail/.htm. +func (c *Catalog) Register(pluginID string, fsys fs.FS, templates []string, layouts map[string]string) error { + if c == nil { + return fmt.Errorf("postcard: catalog is nil") + } + if pluginID == "" { + return fmt.Errorf("postcard: plugin id is empty") + } + c.mu.Lock() + defer c.mu.Unlock() + + alias := make(map[string]string, len(layouts)) + for short, full := range layouts { + if short == "" || full == "" { + return fmt.Errorf("postcard: empty layout alias for %s", pluginID) + } + if short == defaultLayoutAlias { + return fmt.Errorf("postcard: layout alias %q is reserved", short) + } + if err := assertOwner(pluginID, full); err != nil { + return err + } + if _, exists := c.layouts[full]; exists { + return fmt.Errorf("postcard: duplicate layout %s", full) + } + path, err := assetPath(full) + if err != nil { + return err + } + raw, err := fs.ReadFile(fsys, path) + if err != nil { + return fmt.Errorf("postcard: missing layout %s", full) + } + parsed, err := parseLayout(full, pluginID, string(raw)) + if err != nil { + return err + } + c.layouts[full] = parsed + alias[short] = full + } + if len(alias) > 0 { + if c.aliases[pluginID] == nil { + c.aliases[pluginID] = make(map[string]string, len(alias)) + } + for short, full := range alias { + if _, exists := c.aliases[pluginID][short]; exists { + return fmt.Errorf("postcard: duplicate layout alias %s for %s", short, pluginID) + } + c.aliases[pluginID][short] = full + } + } + + for _, name := range templates { + if err := assertOwner(pluginID, name); err != nil { + return err + } + if _, exists := c.templates[name]; exists { + return fmt.Errorf("postcard: duplicate template %s", name) + } + path, err := assetPath(name) + if err != nil { + return err + } + raw, err := fs.ReadFile(fsys, path) + if err != nil { + return fmt.Errorf("postcard: missing template %s", name) + } + parsed, err := parseTemplateFile(name, pluginID, string(raw)) + if err != nil { + return err + } + if err := c.resolveLayoutLocked(parsed); err != nil { + return err + } + c.templates[name] = parsed + } + return nil +} + +func (c *Catalog) resolveLayoutLocked(t *parsedTemplate) error { + short := strings.TrimSpace(t.layout) + if short == "" || short == defaultLayoutAlias { + t.layout = defaultLayoutAlias + return nil + } + full, ok := c.aliases[t.pluginID][short] + if !ok { + return fmt.Errorf("postcard: unknown layout %s", short) + } + if _, ok := c.layouts[full]; !ok { + return fmt.Errorf("postcard: missing layout %s", full) + } + t.layout = full + return nil +} + +func (c *Catalog) lookupTemplate(name string) (*parsedTemplate, error) { + c.mu.RLock() + defer c.mu.RUnlock() + t, ok := c.templates[name] + if !ok { + return nil, fmt.Errorf("postcard: unknown template %s", name) + } + return t, nil +} + +func (c *Catalog) lookupLayout(name string) (*parsedLayout, error) { + c.mu.RLock() + defer c.mu.RUnlock() + if name == "" || name == defaultLayoutAlias { + name = defaultLayoutName + } + l, ok := c.layouts[name] + if !ok { + return nil, fmt.Errorf("postcard: missing layout %s", name) + } + return l, nil +} + +func assertOwner(pluginID, fullName string) error { + prefix := pluginID + "::" + if !strings.HasPrefix(fullName, prefix) { + return fmt.Errorf("postcard: template %s is not owned by %s", fullName, pluginID) + } + return nil +} + +func assetPath(fullName string) (string, error) { + _, rest, ok := strings.Cut(fullName, "::") + if !ok { + return "", fmt.Errorf("postcard: invalid mail name %s", fullName) + } + after, ok := strings.CutPrefix(rest, mailPrefix) + if !ok || after == "" { + return "", fmt.Errorf("postcard: invalid mail name %s", fullName) + } + if strings.Contains(after, "..") || strings.ContainsAny(after, `/\`) { + return "", fmt.Errorf("postcard: invalid mail name %s", fullName) + } + return assetRoot + strings.ReplaceAll(after, ".", "/") + ".htm", nil +} + +func parseTemplateFile(name, pluginID, src string) (*parsedTemplate, error) { + header, body, err := splitTwo(src) + if err != nil { + return nil, fmt.Errorf("postcard: parse %s: %w", name, err) + } + fields, err := parseINI(header) + if err != nil { + return nil, fmt.Errorf("postcard: parse %s: %w", name, err) + } + return &parsedTemplate{ + name: name, + pluginID: pluginID, + subject: fields["subject"], + description: fields["description"], + layout: fields["layout"], + body: strings.TrimSuffix(body, "\n"), + }, nil +} + +func parseLayout(name, pluginID, src string) (*parsedLayout, error) { + header, textWrap, htmlWrap, err := splitThree(src) + if err != nil { + return nil, fmt.Errorf("postcard: parse %s: %w", name, err) + } + fields, err := parseINI(header) + if err != nil { + return nil, fmt.Errorf("postcard: parse %s: %w", name, err) + } + return &parsedLayout{ + name: name, + pluginID: pluginID, + headerName: fields["name"], + textWrap: strings.TrimSuffix(textWrap, "\n"), + htmlWrap: strings.TrimSuffix(htmlWrap, "\n"), + }, nil +} + +func splitTwo(src string) (header, body string, err error) { + parts, err := splitEQ(src, 2) + if err != nil { + return "", "", err + } + return parts[0], parts[1], nil +} + +func splitThree(src string) (header, textWrap, htmlWrap string, err error) { + parts, err := splitEQ(src, 3) + if err != nil { + return "", "", "", err + } + return parts[0], parts[1], parts[2], nil +} + +func splitEQ(src string, n int) ([]string, error) { + src = strings.ReplaceAll(src, "\r\n", "\n") + lines := strings.Split(src, "\n") + parts := make([]string, 0, n) + start := 0 + for i, line := range lines { + if strings.TrimSpace(line) != "==" { + continue + } + parts = append(parts, strings.Join(lines[start:i], "\n")) + start = i + 1 + if len(parts) == n-1 { + parts = append(parts, strings.Join(lines[start:], "\n")) + return parts, nil + } + } + return nil, fmt.Errorf("missing == separator") +} + +func parseINI(header string) (map[string]string, error) { + out := make(map[string]string) + for _, line := range strings.Split(header, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, ";") || strings.HasPrefix(line, "#") { + continue + } + key, val, ok := strings.Cut(line, "=") + if !ok { + return nil, fmt.Errorf("invalid header line %q", line) + } + key = strings.ToLower(strings.TrimSpace(key)) + val = strings.TrimSpace(val) + if len(val) >= 2 { + if q := val[0]; (q == '"' || q == '\'') && val[len(val)-1] == q { + val = val[1 : len(val)-1] + } + } + if key == "" { + return nil, fmt.Errorf("invalid header line %q", line) + } + out[key] = val + } + return out, nil +} + +type renderOptions struct { + css string + brandCSS string +} + +func (c *Catalog) render(msg Message, opts renderOptions) (RenderedMessage, error) { + var out RenderedMessage + t, err := c.lookupTemplate(msg.Template) + if err != nil { + return out, err + } + vars := msg.Vars + if vars == nil { + vars = map[string]any{} + } + subjectSrc := t.subject + if strings.TrimSpace(msg.Subject) != "" { + subjectSrc = msg.Subject + } + subject, err := execHTML(t.name+".subject", subjectSrc, vars) + if err != nil { + return out, err + } + if strings.ContainsAny(subject, "\r\n") { + return out, fmt.Errorf("postcard: subject contains CR/LF") + } + markdownBody, err := execHTML(t.name+".body", t.body, vars) + if err != nil { + return out, err + } + htmlBody, err := markdownHTML(markdownBody) + if err != nil { + return out, err + } + if err := validateHTML(htmlBody); err != nil { + return out, err + } + layout, err := c.lookupLayout(t.layout) + if err != nil { + return out, err + } + text, err := execText(layout.name+".text", layout.textWrap, map[string]any{ + "Content": markdownBody, + "Subject": subject, + "css": opts.css, + "brandCss": opts.brandCSS, + }) + if err != nil { + return out, err + } + html, err := execHTML(layout.name+".html", layout.htmlWrap, map[string]any{ + "Content": template.HTML(htmlBody), + "Subject": subject, + "css": template.CSS(opts.css), + "brandCss": template.CSS(opts.brandCSS), + }) + if err != nil { + return out, err + } + if err := validateHTML(html); err != nil { + return out, err + } + out = RenderedMessage{ + To: append([]string(nil), msg.To...), + Cc: append([]string(nil), msg.Cc...), + Bcc: append([]string(nil), msg.Bcc...), + ReplyTo: msg.ReplyTo, + Subject: subject, + HTML: html, + Text: text, + } + return out, nil +} + +func markdownHTML(src string) (string, error) { + var buf bytes.Buffer + if err := markdown.Convert([]byte(src), &buf); err != nil { + return "", fmt.Errorf("postcard: markdown: %w", err) + } + return buf.String(), nil +} + +func validateHTML(html string) error { + if rawUnsafeTag.MatchString(html) { + return fmt.Errorf("postcard: rendered HTML contains raw unsafe tags") + } + if eventHandler.MatchString(html) { + return fmt.Errorf("postcard: rendered HTML contains event handlers") + } + if dangerousScheme.MatchString(html) { + return fmt.Errorf("postcard: rendered HTML contains a dangerous URL scheme") + } + return nil +} + +func execHTML(name, src string, data any) (string, error) { + tmpl, err := template.New(name).Option("missingkey=zero").Parse(src) + if err != nil { + return "", fmt.Errorf("postcard: parse %s: %w", name, err) + } + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", fmt.Errorf("postcard: execute %s: %w", name, err) + } + return buf.String(), nil +} + +func execText(name, src string, data any) (string, error) { + tmpl, err := texttemplate.New(name).Option("missingkey=zero").Parse(src) + if err != nil { + return "", fmt.Errorf("postcard: parse %s: %w", name, err) + } + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", fmt.Errorf("postcard: execute %s: %w", name, err) + } + return buf.String(), nil +}