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
This commit is contained in:
102
docs/services/mail.md
Normal file
102
docs/services/mail.md
Normal file
@@ -0,0 +1,102 @@
|
||||
---
|
||||
title: Mail
|
||||
description: Ship WinterCMS-style mail templates in a plugin, send them through postcard, and deliver them with the memory, log or SMTP driver.
|
||||
section: services
|
||||
order: 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](../../modules/postcard/README.md) 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:
|
||||
|
||||
```go src=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](jobs.md) and [Transactions](../database/transactions.md).
|
||||
|
||||
## 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`):
|
||||
|
||||
```yaml
|
||||
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`.
|
||||
Reference in New Issue
Block a user