Files
summercms/modules/postcard/README.md

153 lines
7.3 KiB
Markdown

# 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 `<plugin>::mail.<name>` and loaded from `views/mail/<name>.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: <secret>
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.