# postcard Transactional mail from plugin-owned Markdown templates and layouts, delivered through a memory, log or SMTP driver. `import "git.golem15.com/golem15/summercms/modules/postcard"` ## Overview postcard is the mail layer of SummerCMS. Plugins that implement `pact.HasMailTemplates` ship WinterCMS-shaped mail files under `views/mail/`; at boot, `postcard.Activate` publishes one app-scoped `postcard.Mailer` built from the compass `mail.*` config, and `postcard.BootPlugin` registers each plugin's templates and layouts as the plugin boots. Callers then send a template by its dotted name with a map of variables. It is the counterpart of WinterCMS's `Mail::send` with `views/mail/*.htm` templates and mail layouts. ## Features - Templates named `::mail.` and loaded from `views/mail/.htm` (dots become directories). A plugin may only register names in its own namespace; missing files, duplicates and unknown layout aliases fail at boot. - WinterCMS file format: an INI header (`subject`, `layout`, `description`), a `==` separator, then a Markdown body rendered with Go `html/template` variables such as `{{ .name }}`. - Layouts with a header, a text wrapper and an HTML wrapper, referenced from templates by a short alias (`pact.HasMailTemplates` maps aliases to full names). A neutral default layout is built in. - Every message gets an HTML part (Markdown converted with goldmark) and a plain-text part. `mail.css` and `mail.brandCss` are inlined into the layout's style block. - Safety checks: rendered HTML with script, iframe, object or embed tags, inline event handlers or `javascript:`/`vbscript:`/`data:` URLs is rejected; CR/LF in the subject or address headers is rejected; addresses are parsed with `net/mail`. - Drivers behind the `postcard.Driver` interface: `postcard.MemoryDriver` (keeps messages for tests), `postcard.LogDriver` (logs headers and the text part through `log/slog`, never the HTML or credentials), `postcard.SMTPDriver` (go-mail with an explicit TLS policy) and `postcard.FailDriver` (a deterministic failure for tests). - No locale selection: callers pass the full template name, including any locale suffix. ## Usage A plugin ships `views/mail/welcome.htm`: ```text subject = "Welcome, {{ .name }}" description = "Sent after registration" == Hi **{{ .name }}**, thanks for joining the blog. ``` and sends it through the mailer the runtime published: ```go package blog import ( "context" "errors" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/postcard" ) func SendWelcome(ctx context.Context, app *backpack.App, email, name string) error { mailer, ok := app.Lookup[postcard.Mailer]() if !ok { return errors.New("mailer not published") } return mailer.Send(ctx, postcard.Message{ Template: "acme.blog::mail.welcome", To: []string{email}, Vars: map[string]any{"name": name}, }) } ``` In tests, build the catalog and mailer directly and inspect what was sent: ```go package blog import ( "context" "testing" "testing/fstest" "git.golem15.com/golem15/summercms/modules/postcard" ) func TestWelcomeMail(t *testing.T) { mailFS := fstest.MapFS{"views/mail/welcome.htm": {Data: []byte("subject = \"Welcome, {{ .name }}\"\n==\nHi **{{ .name }}**.\n")}} cat := postcard.NewCatalog() if err := cat.Register("acme.blog", mailFS, []string{"acme.blog::mail.welcome"}, nil); err != nil { t.Fatal(err) } driver := postcard.NewMemoryDriver() mailer := postcard.NewMailer(cat, driver, postcard.Options{From: "blog@example.com"}) msg := postcard.Message{Template: "acme.blog::mail.welcome", To: []string{"ada@example.com"}, Vars: map[string]any{"name": "Ada"}} if err := mailer.Send(context.Background(), msg); err != nil { t.Fatal(err) } if got := driver.Messages()[0].Subject; got != "Welcome, Ada" { t.Fatalf("subject = %q", got) } } ``` ## API reference | Identifier | Description | |------------|-------------| | `postcard.Activate` | Builds the driver from config and publishes the app-scoped `postcard.Mailer` before plugins boot. | | `postcard.BootPlugin` | Registers a booting plugin's declared templates and layouts with the published mailer. | | `postcard.Mailer` | Sends a registered template through the configured driver. | | `postcard.NewMailer` | Builds a mailer from a catalog, a driver and `postcard.Options`. | | `postcard.Message` | What callers send: template name, recipients, reply-to, variables and an optional subject override. | | `postcard.Options` | Mailer settings: sender address, CSS and brand CSS. | | `postcard.Catalog` | Registered templates and layouts, including the built-in default layout. | | `postcard.NewCatalog` | Returns a catalog holding only the default layout. | | `postcard.Catalog.Register` | Loads a plugin's templates and layout aliases from an `fs.FS`. | | `postcard.Driver` | Delivers a `postcard.RenderedMessage`. | | `postcard.RenderedMessage` | The validated payload a driver transmits: headers, HTML part and text part. | | `postcard.NewMemoryDriver` | In-memory driver; `postcard.MemoryDriver.Messages` returns what was sent. | | `postcard.NewLogDriver` | Driver that logs through a `*slog.Logger` (the default logger when nil). | | `postcard.NewSMTPDriver` | SMTP driver built from a `postcard.SMTPConfig`. | | `postcard.SMTPConfig` | Host, port, credentials, TLS policy and timeout for the SMTP driver. | | `postcard.FailDriver` | Driver whose `postcard.FailDriver.Send` always returns `postcard.FailDriver.Err`. | ## Configuration `postcard.Activate` reads these keys from the app's [compass](../compass/README.md) config: | Key | Default | Controls | |-----|---------|----------| | `mail.driver` | `memory` | Delivery driver: `memory`, `log` or `smtp`. Any other value fails at boot. | | `mail.from` | empty | Sender address. Required by the SMTP driver. | | `mail.css` | empty | CSS inlined into the layout. | | `mail.brandCss` | empty | Brand CSS inlined before `mail.css`. | | `mail.smtp.host` | none | SMTP host. Required when `mail.driver` is `smtp`. | | `mail.smtp.port` | `587` | SMTP port. | | `mail.smtp.username` | empty | SMTP user. When set, PLAIN authentication is used. | | `mail.smtp.password` | empty | SMTP password. | | `mail.smtp.tls` | `mandatory` | TLS policy: `mandatory` (or `tls`), `starttls` (or `opportunistic`), `none` (or `notls`, `off`). Plain connections are never inferred. | | `mail.smtp.timeout` | `10s` | Connection timeout, as a Go duration or a number of seconds. | ```yaml mail: driver: smtp from: blog@example.com smtp: host: smtp.example.com port: 587 username: blog password: tls: mandatory ``` ## Dependencies - SummerCMS modules: [backpack](../backpack/README.md), [compass](../compass/README.md), [pact](../pact/README.md). - Third-party: `github.com/wneessen/go-mail` (SMTP), `github.com/yuin/goldmark` (Markdown). - Standard library: `bytes`, `context`, `embed`, `fmt`, `html/template`, `io/fs`, `log/slog`, `net/mail`, `regexp`, `strings`, `sync`, `text/template`, `time`. - Tests additionally use `github.com/testcontainers/testcontainers-go` (Mailpit container). ## Testing ```sh go test ./modules/postcard/... ``` The SMTP integration test starts a Mailpit container through testcontainers-go and needs Docker. Run `go test -short ./modules/postcard/...` to skip it; the remaining tests use the memory and fail drivers and need no external services.