Files
summercms/docs/services/mail.md
Jakub Zych efb35a2d35 feat(11.1-04): add the Database section and the core Services pages
- docs/database: models, migrations, queries and pagination, relations,
  casts and validation, attachments and transactions (lagoon.Transaction,
  lagoon.AfterCommit, nested savepoints, lagoon.OnDatabase)
- docs/services: configuration, events, routing with auth groups, rate
  limiting, authentication, the OAuth server, mail and localization
- runnable Examples for lagoon, attach, compass, surf, wire, bouncer,
  wristband, postcard, phrasebook and festival; lagoon TestDocs* regions
  run on the package's Postgres harness through DocsDB
- 15 new required pages
2026-09-30 22:59:25 +02:00

5.0 KiB

title, description, section, order
title description section order
Mail Ship WinterCMS-style mail templates in a plugin, send them through postcard, and deliver them with the memory, log or SMTP driver. services 70

Mail

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:

// 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.

Warning

Use starttls or none only against a local development mail catcher. In production, keep the default mandatory.