From 1e515971c974d57c68994f543afb844e2d15c9a9 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 18 Sep 2026 13:59:52 +0200 Subject: [PATCH] feat(04-03): register plugin mail layouts and publish the mailer at boot - Map short layout names onto dotted layout files and wrap text/HTML - party.Activate publishes postcard.Mailer before Boot and names missing files - Hello plugin sends through the app-scoped mailer Co-authored-by: Cursor --- examples/hello/hello_test.go | 30 ++++++++ examples/hello/plugins/base/plugin.go | 21 +++++- .../plugins/base/views/mail/hello-en.htm | 2 +- .../hello/plugins/base/views/mail/hello.htm | 2 +- .../plugins/base/views/mail/layouts/hello.htm | 14 ++++ party/registry.go | 7 ++ party/registry_test.go | 63 ++++++++++++++++ postcard/mailer.go | 45 ++++++++++++ postcard/mailer_test.go | 71 +++++++++++++++++++ 9 files changed, 251 insertions(+), 4 deletions(-) create mode 100644 examples/hello/plugins/base/views/mail/layouts/hello.htm diff --git a/examples/hello/hello_test.go b/examples/hello/hello_test.go index cbb68e2..a019943 100644 --- a/examples/hello/hello_test.go +++ b/examples/hello/hello_test.go @@ -16,6 +16,7 @@ import ( "git.golem15.com/golem15/summercms/pact" "git.golem15.com/golem15/summercms/party" "git.golem15.com/golem15/summercms/phrasebook" + "git.golem15.com/golem15/summercms/postcard" "git.golem15.com/golem15/summercms/surf" ) @@ -142,6 +143,35 @@ func TestHelloPluginResolvesLang(t *testing.T) { } } +func TestHelloPluginSendsMail(t *testing.T) { + cfg, err := compass.Load("config") + if err != nil { + t.Fatal(err) + } + app := backpack.New(cfg) + if _, err := party.Activate(app, []string{"golem15.hello"}); err != nil { + t.Fatal(err) + } + mail, ok := app.Lookup[postcard.Mailer]() + if !ok { + t.Fatal("mailer not published") + } + if err := mail.Send(t.Context(), postcard.Message{ + Template: "golem15.hello::mail.hello", + To: []string{"ada@example.test"}, + Vars: map[string]any{"Name": "Ada", "URL": "https://example.test"}, + }); err != nil { + t.Fatal(err) + } + if err := mail.Send(t.Context(), postcard.Message{ + Template: "golem15.hello::mail.hello-en", + To: []string{"ada@example.test"}, + Vars: map[string]any{"Name": "Ada", "URL": "https://example.test"}, + }); err != nil { + t.Fatal(err) + } +} + func TestTypedItemRoute(t *testing.T) { cfg, err := compass.Load("config") if err != nil { diff --git a/examples/hello/plugins/base/plugin.go b/examples/hello/plugins/base/plugin.go index 137db2f..7e58d6c 100644 --- a/examples/hello/plugins/base/plugin.go +++ b/examples/hello/plugins/base/plugin.go @@ -10,8 +10,9 @@ import ( ) var ( - _ pact.HasConfig = (*Plugin)(nil) - _ pact.HasLang = (*Plugin)(nil) + _ pact.HasConfig = (*Plugin)(nil) + _ pact.HasLang = (*Plugin)(nil) + _ pact.HasMailTemplates = (*Plugin)(nil) ) //go:embed config @@ -20,6 +21,9 @@ var configFS embed.FS //go:embed lang var langFS embed.FS +//go:embed views/mail +var mailFS embed.FS + // Plugin is the golem15.hello plugin. type Plugin struct{} @@ -32,6 +36,19 @@ func (p *Plugin) Boot(*backpack.App) error { return nil } func (p *Plugin) ConfigFS() fs.FS { return configFS } func (p *Plugin) LangFS() fs.FS { return langFS } +func (p *Plugin) MailTemplatesFS() fs.FS { return mailFS } +func (p *Plugin) MailTemplates() []string { + return []string{ + "golem15.hello::mail.hello", + "golem15.hello::mail.hello-en", + } +} +func (p *Plugin) MailLayouts() map[string]string { + return map[string]string{ + "hello": "golem15.hello::mail.layouts.hello", + } +} + func init() { party.Register(&Plugin{}) } diff --git a/examples/hello/plugins/base/views/mail/hello-en.htm b/examples/hello/plugins/base/views/mail/hello-en.htm index 33db1cb..878d504 100644 --- a/examples/hello/plugins/base/views/mail/hello-en.htm +++ b/examples/hello/plugins/base/views/mail/hello-en.htm @@ -1,6 +1,6 @@ subject = "Hello {{ .Name }}" description = "English hello greeting" -layout = "default" +layout = "hello" == Hello **{{ .Name }}**. diff --git a/examples/hello/plugins/base/views/mail/hello.htm b/examples/hello/plugins/base/views/mail/hello.htm index 9a28c78..8508b5d 100644 --- a/examples/hello/plugins/base/views/mail/hello.htm +++ b/examples/hello/plugins/base/views/mail/hello.htm @@ -1,6 +1,6 @@ subject = "Witaj {{ .Name }}" description = "Hello greeting" -layout = "default" +layout = "hello" == Witaj **{{ .Name }}**. diff --git a/examples/hello/plugins/base/views/mail/layouts/hello.htm b/examples/hello/plugins/base/views/mail/layouts/hello.htm new file mode 100644 index 0000000..a6747b8 --- /dev/null +++ b/examples/hello/plugins/base/views/mail/layouts/hello.htm @@ -0,0 +1,14 @@ +name = "Hello" +== +-- {{ .Content }} -- +== + + + + + + + +
{{ .Content }}
+ + diff --git a/party/registry.go b/party/registry.go index 54e9be6..524d6be 100644 --- a/party/registry.go +++ b/party/registry.go @@ -8,6 +8,7 @@ import ( "git.golem15.com/golem15/summercms/backpack" "git.golem15.com/golem15/summercms/pact" "git.golem15.com/golem15/summercms/phrasebook" + "git.golem15.com/golem15/summercms/postcard" ) // Plugin is the required descriptor every compiled plugin implements. @@ -120,7 +121,13 @@ func activate(registered []Plugin, app *backpack.App, ids []string) ([]Plugin, e if err := phrasebook.Activate(app, ordered); err != nil { return nil, fmt.Errorf("party: lang: %w", err) } + if err := postcard.Activate(app, ordered); err != nil { + return nil, fmt.Errorf("party: mail: %w", err) + } for _, p := range ordered { + if err := postcard.BootPlugin(app, p); err != nil { + return nil, fmt.Errorf("party: boot %s: %w", p.ID(), err) + } if err := p.Boot(app); err != nil { return nil, fmt.Errorf("party: boot %s: %w", p.ID(), err) } diff --git a/party/registry_test.go b/party/registry_test.go index 43fda14..5273435 100644 --- a/party/registry_test.go +++ b/party/registry_test.go @@ -14,6 +14,7 @@ import ( "git.golem15.com/golem15/summercms/compass" "git.golem15.com/golem15/summercms/pact" "git.golem15.com/golem15/summercms/phrasebook" + "git.golem15.com/golem15/summercms/postcard" ) type tracePlugin struct { @@ -455,3 +456,65 @@ func TestActivateMalformedLangFailsWithPluginContext(t *testing.T) { t.Fatalf("error %q should name the plugin and path problem", msg) } } + +type mailPlugin struct { + id string + fsys fs.FS + templates []string + layouts map[string]string + sawMailer *bool +} + +func (p *mailPlugin) ID() string { return p.id } +func (p *mailPlugin) Requires() []string { return nil } +func (p *mailPlugin) Register(*backpack.App) error { return nil } +func (p *mailPlugin) MailTemplatesFS() fs.FS { return p.fsys } +func (p *mailPlugin) MailTemplates() []string { return p.templates } +func (p *mailPlugin) MailLayouts() map[string]string { return p.layouts } + +func (p *mailPlugin) Boot(app *backpack.App) error { + if p.sawMailer == nil { + return nil + } + _, ok := app.Lookup[postcard.Mailer]() + *p.sawMailer = ok + return nil +} + +func TestActivatePublishesMailerBeforeBoot(t *testing.T) { + var saw bool + p := &mailPlugin{ + id: "golem15.hello", + sawMailer: &saw, + templates: []string{"golem15.hello::mail.hello"}, + layouts: map[string]string{"hello": "golem15.hello::mail.layouts.hello"}, + fsys: fstest.MapFS{ + "views/mail/hello.htm": {Data: []byte("subject = \"Hi\"\nlayout = \"hello\"\n==\nHi.\n")}, + "views/mail/layouts/hello.htm": {Data: []byte("name = \"Hello\"\n==\n{{ .Content }}\n==\n
{{ .Content }}
\n")}, + }, + } + app := backpack.New(&compass.Config{}) + if _, err := activate([]Plugin{p}, app, []string{"golem15.hello"}); err != nil { + t.Fatalf("activate: %v", err) + } + if !saw { + t.Fatal("mailer must be published before Boot") + } +} + +func TestActivateMissingMailTemplateFailsBootWithName(t *testing.T) { + p := &mailPlugin{ + id: "golem15.hello", + templates: []string{"golem15.hello::mail.hello"}, + fsys: fstest.MapFS{}, + } + app := backpack.New(&compass.Config{}) + _, err := activate([]Plugin{p}, app, []string{"golem15.hello"}) + if err == nil { + t.Fatal("expected missing template error") + } + msg := err.Error() + if !strings.Contains(msg, "party: boot golem15.hello") || !strings.Contains(msg, "golem15.hello::mail.hello") { + t.Fatalf("error %q should name the boot plugin and missing template", msg) + } +} diff --git a/postcard/mailer.go b/postcard/mailer.go index 4c13441..c675be9 100644 --- a/postcard/mailer.go +++ b/postcard/mailer.go @@ -3,6 +3,10 @@ package postcard import ( "context" "fmt" + "strings" + + "git.golem15.com/golem15/summercms/backpack" + "git.golem15.com/golem15/summercms/pact" ) // Message is the Send contract for plugin callers. @@ -70,3 +74,44 @@ func (m *mailer) Send(ctx context.Context, msg Message) error { } return nil } + +// Activate publishes an app-scoped Mailer before plugin Boot. Catalog files +// are validated later at each plugin's Boot transition. +func Activate[P interface{ ID() string }](app *backpack.App, _ []P) error { + if app == nil { + return fmt.Errorf("postcard: app is nil") + } + opts := Options{} + if app.Config != nil { + opts.From = strings.TrimSpace(app.Config.String("mail.from")) + opts.CSS = app.Config.String("mail.css") + opts.BrandCSS = app.Config.String("mail.brandCss") + } + m := &mailer{ + catalog: NewCatalog(), + driver: NewMemoryDriver(), + opts: opts, + } + if err := app.Publish[Mailer](m); err != nil { + return fmt.Errorf("postcard: %w", err) + } + return nil +} + +// BootPlugin registers a plugin's declared mail templates and layouts. +// Missing files and unknown layout aliases fail with the dotted name. +func BootPlugin[P interface{ ID() string }](app *backpack.App, p P) error { + hm, ok := any(p).(pact.HasMailTemplates) + if !ok { + return nil + } + got, ok := app.Lookup[Mailer]() + if !ok { + return fmt.Errorf("postcard: mailer not published") + } + m, ok := got.(*mailer) + if !ok || m == nil || m.catalog == nil { + return fmt.Errorf("postcard: mailer not published") + } + return m.catalog.Register(p.ID(), hm.MailTemplatesFS(), hm.MailTemplates(), hm.MailLayouts()) +} diff --git a/postcard/mailer_test.go b/postcard/mailer_test.go index 01e4b01..fb8d28e 100644 --- a/postcard/mailer_test.go +++ b/postcard/mailer_test.go @@ -102,3 +102,74 @@ Hello **{{ .Name }}**. } }) } + +func TestMailLayoutSmoke(t *testing.T) { + t.Parallel() + + fsys := fstest.MapFS{ + "views/mail/note.htm": {Data: []byte(`subject = "Hi {{ .Name }}" +layout = "hello" +== +Body **{{ .Name }}**. +`)}, + "views/mail/layouts/hello.htm": {Data: []byte(`name = "Hello" +== +-- {{ .Content }} -- +== +
{{ .Content }}
+ +`)}, + } + cat := NewCatalog() + if err := cat.Register("golem15.hello", fsys, []string{"golem15.hello::mail.note"}, map[string]string{ + "hello": "golem15.hello::mail.layouts.hello", + }); err != nil { + t.Fatalf("Register: %v", err) + } + drv := NewMemoryDriver() + mail := NewMailer(cat, drv, Options{CSS: "p{color:red}", BrandCSS: ".brand{color:blue}"}) + if err := mail.Send(context.Background(), Message{ + Template: "golem15.hello::mail.note", + To: []string{"ada@example.test"}, + Vars: map[string]any{"Name": "Ada"}, + }); err != nil { + t.Fatalf("Send: %v", err) + } + got := drv.Messages() + if len(got) != 1 { + t.Fatalf("stored %d messages", len(got)) + } + if !strings.Contains(got[0].Text, "-- Body **Ada**. --") { + t.Fatalf("text wrapper = %q", got[0].Text) + } + if !strings.Contains(got[0].HTML, `class="layout-hello"`) || !strings.Contains(got[0].HTML, "Ada") { + t.Fatalf("HTML wrapper = %q", got[0].HTML) + } + if !strings.Contains(got[0].HTML, "p{color:red}") || !strings.Contains(got[0].HTML, ".brand{color:blue}") { + t.Fatalf("shared css missing: %q", got[0].HTML) + } + + t.Run("missing template fails with name", func(t *testing.T) { + err := NewCatalog().Register("golem15.hello", fsys, []string{"golem15.hello::mail.missing"}, nil) + if err == nil || !strings.Contains(err.Error(), "golem15.hello::mail.missing") { + t.Fatalf("missing template error = %v", err) + } + }) + t.Run("unknown layout fails with name", func(t *testing.T) { + bad := fstest.MapFS{ + "views/mail/note.htm": {Data: []byte("subject = \"Hi\"\nlayout = \"plytarium\"\n==\nHi.\n")}, + } + err := NewCatalog().Register("golem15.hello", bad, []string{"golem15.hello::mail.note"}, nil) + if err == nil || !strings.Contains(err.Error(), "plytarium") { + t.Fatalf("unknown layout error = %v", err) + } + }) + t.Run("missing layout file fails with full name", func(t *testing.T) { + err := NewCatalog().Register("golem15.hello", fsys, nil, map[string]string{ + "hello": "golem15.hello::mail.layouts.missing", + }) + if err == nil || !strings.Contains(err.Error(), "golem15.hello::mail.layouts.missing") { + t.Fatalf("missing layout error = %v", err) + } + }) +}