Each module README now says where the module sits in the dependency graph, why it is its own module, and lists use cases for application developers.
7.9 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"
Name: What you send home in summer; it delivers the application's mail.
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.
Role in the framework
A service module on backpack, compass and pact; party registers the templates plugins contribute. It is separate so mail rendering and delivery can be tested and swapped (memory, log, SMTP) without touching the code that sends.
Use cases:
- Send account mail (welcome, password reset) from Markdown templates owned by the plugin.
- Share one branded layout across all of an application's mail.
- Capture sent mail in memory in tests, or log it in development instead of delivering it.
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.