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), "