Files
summercms/modules/postcard/README.md

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 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:

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.