7.3 KiB
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 fromviews/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 Gohtml/templatevariables such as{{ .name }}. - Layouts with a header, a text wrapper and an HTML wrapper, referenced from templates by a short alias (
pact.HasMailTemplatesmaps 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.cssandmail.brandCssare 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 withnet/mail. - Drivers behind the
postcard.Driverinterface:postcard.MemoryDriver(keeps messages for tests),postcard.LogDriver(logs headers and the text part throughlog/slog, never the HTML or credentials),postcard.SMTPDriver(go-mail with an explicit TLS policy) andpostcard.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:
subject = "Welcome, {{ .name }}"
description = "Sent after registration"
==
Hi **{{ .name }}**, thanks for joining the blog.
and sends it through the mailer the runtime published:
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:
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 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. |
mail:
driver: smtp
from: blog@example.com
smtp:
host: smtp.example.com
port: 587
username: blog
password: <secret>
tls: mandatory
Dependencies
- SummerCMS modules: backpack, compass, pact.
- 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
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.