Services

Mail

Ship WinterCMS-style mail templates in a plugin, send them through postcard, and deliver them with the memory, log or SMTP driver.

On this page

WinterCMS plugins ship mail templates in views/mail and send them with Mail::send. SummerCMS keeps the file format and the dotted template names; postcard loads them at boot and sends them through a configured driver.

Templates and layouts#

A plugin implements pact.HasMailTemplates: it returns its embedded views/mail files, the template names it ships and short aliases for its layouts. A template named acme.blog::mail.welcome lives in views/mail/welcome.htm (dots in the name become directories), and a plugin may only register names in its own namespace. A missing file, a duplicate name or an unknown layout alias fails the start-up.

The file format is WinterCMS's: an INI header with the subject, the layout alias and a description, a == line, then a Markdown body with Go template variables such as {{ .name }}. A layout has a header, a text wrapper and an HTML wrapper, separated by == lines, each with {{ .Content }} where the message goes. A neutral default layout is built in.

Each message gets an HTML part, rendered from the Markdown, and a plain-text part. mail.css and mail.brandCss are inlined into the layout's style block.

Sending#

At boot, postcard.Activate publishes one postcard.Mailer on the application, and postcard.BootPlugin registers each plugin's templates as it boots. Look the mailer up with app.Lookup[postcard.Mailer]() and send a postcard.Message with the template name, the recipients and the variables. Tests build the catalog and a memory driver directly, and read back what was sent:

modules/postcard/example_test.go#ExampleMailer_Send
// At boot, postcard.BootPlugin registers what pact.HasMailTemplates
// declares; a test registers the same thing directly.
cat := postcard.NewCatalog()
err := cat.Register("acme.blog", mailFS,
	[]string{"acme.blog::mail.welcome"},
	map[string]string{"blog": "acme.blog::mail.layouts.blog"})
if err != nil {
	fmt.Println(err)
	return
}
driver := postcard.NewMemoryDriver() // mail.driver: memory
mailer := postcard.NewMailer(cat, driver, postcard.Options{From: "blog@example.com"})

err = mailer.Send(context.Background(), postcard.Message{
	Template: "acme.blog::mail.welcome",
	To:       []string{"ada@example.com"},
	Vars:     map[string]any{"name": "Ada"},
})
if err != nil {
	fmt.Println(err)
	return
}
sent := driver.Messages()[0]
fmt.Println(sent.From, sent.To, sent.Subject)
fmt.Println(sent.Text)
fmt.Println(strings.Contains(sent.HTML, `<div class="blog-mail"><p>Hi <strong>Ada</strong>`))

// Header injection is refused before any driver sees the message.
err = mailer.Send(context.Background(), postcard.Message{
	Template: "acme.blog::mail.welcome",
	To:       []string{"ada@example.com\r\nBcc: all@example.com"},
	Vars:     map[string]any{"name": "Ada"},
})
fmt.Println(err != nil, len(driver.Messages()))
// Output:
// blog@example.com [ada@example.com] Welcome, Ada
// Hi **Ada**, thanks for joining the blog.
//
// -- The Acme blog
// true
// true 1

postcard does not pick a locale. For a per-language template, register one name per language (acme.blog::mail.welcome_pl) and pass the full name.

Before a driver sees a message, postcard refuses a subject or address with a line break, parses every address with net/mail, and rejects rendered HTML that contains script, iframe, object or embed tags, inline event handlers, or javascript:, vbscript: or data: URLs. Variables are escaped by Go's html/template.

postcard.Mailer.Send delivers before it returns. To keep a request fast, send from a queued job, and send after the write that triggered the mail has committed; see Queued jobs and Transactions.

Drivers#

mail.driver selects the driver:

Driver Delivers
memory (default) Nowhere: messages are kept in the process, for tests.
log To the log: headers and the text part, never the HTML part or credentials. For development.
smtp Through an SMTP server with the configured TLS policy.

The SMTP settings go in config/mail.yaml, with the password in the environment (SUMMER_MAIL__SMTP__PASSWORD):

driver: smtp
from: blog@example.com
smtp:
  host: smtp.example.com
  port: 587
  username: blog
  password: <secret>
  tls: mandatory

mail.smtp.tls defaults to mandatory: the connection must upgrade with STARTTLS, and sending fails if the server does not offer it. postcard never infers a plain connection. The other two values exist for local mail catchers only:

  • starttls (or opportunistic) uses TLS when the server offers it and sends in plain text when it does not, so an attacker on the network can strip the upgrade.
  • none sends in plain text.