In the shop sample app, the sendOrderConfirmation listener runs in the worker and sends an order confirmation rendered from MJML. Business code (the listener) sees only the mail.Mailer interface; whether SMTP, log output, or a test memory mailer sits behind it is decided by the wiring.
The second deliberate abstraction
The kit's design principle is "centralize, don't abstract": mail is, next to storage, the other exception where the interface is deliberate:
- The actual sending is done by the
mail/smtpsubpackage (via wneessen/go-mail), the MJML compilation bymail/mjml. Themailroot package imports neither. Your service layer compiles without an SMTP client or a WASM runtime. - In tests,
mail.NewMemory()replaces the driver in one line, so sent messages can be asserted on without a real SMTP connection.
The interface
import "github.com/gp-system/gpsystem/mail"
type Mailer interface {
Send(ctx context.Context, msg *Message) error
}
Send is synchronous and calls Validate() first: no recipient returns ErrNoRecipient, no body returns ErrNoBody.
Building a Message
You build a Message fluently; construction never fails outright, omissions surface at send time:
msg := mail.NewMessage().
WithFromNamed("Shop", "noreply@acme.test"). // optional: the driver's default sender steps in
WithTo("user@example.com").
WithSubject("Your order confirmation").
WithText("Thanks for your order!")
The full builder surface:
| Method | What it does |
|---|---|
WithFrom(addr) / WithFromNamed(name, addr) | sender; when omitted, the driver's configured default (MAIL_FROM_ADDRESS/MAIL_FROM_NAME) applies |
WithTo(addrs...) / WithToNamed(name, addr) | To recipient(s) |
WithCc(addrs...) / WithCcNamed(name, addr) | Cc recipient(s) |
WithBcc(addrs...) / WithBccNamed(name, addr) | Bcc recipient(s) |
WithReplyTo(addr) | Reply-To |
WithSubject(s) | subject |
WithHeader(key, value) | custom header (e.g. List-Unsubscribe) |
WithText(s) / WithHTML(s) | shorthand for WithBody(mail.Text(s)) / WithBody(mail.HTML(s)) |
WithBody(b mail.Body) | any body (the last call wins) |
WithAttachment(filename, data, contentType) | attachment from memory (empty contentType: the driver sniffs it) |
WithAttachmentReader(filename, r, contentType) | streamed attachment (r is read once, at send time) |
WithEmbed(filename, data, contentType) | inline resource, referenced from HTML as cid:<filename> |
Body types
Every body implements the mail.Body interface (Render(ctx) (Content, error); drivers render inside the send span, so template errors are traced too):
// 1. plain text
msg.WithText("Your account is ready.")
// 2. raw HTML, with an optional text alternative
msg.WithBody(mail.HTML("<p>Your account is ready.</p>").WithText("Your account is ready."))
// 3. html/template + text/template alternative from the same fs.FS
//go:embed templates
var tmpls embed.FS
msg.WithBody(mail.Template(tmpls, "templates/invoice.html.tmpl", inv).
WithTextTemplate("templates/invoice.txt.tmpl"))
// 4. pre-parsed templates, for consumers with their own cache
msg.WithBody(mail.Parsed(htmlTpl, textTpl, data))
mail.Template can be extended with WithFuncs(template.FuncMap) before parsing.
.WithText()/.WithTextTemplate(), and an HTML-only message stays HTML-only.MJML
import "github.com/gp-system/gpsystem/mail/mjml"
The mjml package is a mail.Body too: the source is first rendered as an html/template (interpolated data is escaped), then compiled from MJML into responsive, email-client-friendly HTML. It runs in pure Go (Boostport/mjml-go, wazero WASM runtime). No Node.js needed for build or runtime.
body := mjml.Template(tmpls, "templates/order_confirmation.mjml.tmpl", data).
WithTextTemplate("templates/order_confirmation.txt.tmpl")
// or from an inline source:
body := mjml.String(`<mjml><mj-body>...</mj-body></mjml>`, data)
Options:
mjml.Minify(bool): minify the compiled HTML; defaulttrue.mjml.ValidationLevel("strict" | "soft" | "skip"): MJML validation strictness; defaultsoft.
mail/mjml links a WASM runtime into the binary, and the first render pays for loading the WASM module. Subsequent calls are cheaper. Only the binary that actually imports the package carries that cost: the mail root and the smtp driver know nothing about mjml. Typically only cmd/worker imports it.Drivers
smtp
import "github.com/gp-system/gpsystem/mail/smtp"
mailer, err := smtp.New(ctx, cfg.Mail) // errors on empty Host or FromAddress
mailer, err := smtp.NewIfConfigured(ctx, cfg.Mail) // empty Host -> mail.NewDiscard(), no error
mailer := smtp.MustNewIfConfigured(ctx, cfg.Mail) // panics on error; the usual main() constructor
New verifies the connection with a dial+close at construction, the same convention as pg.NewPool/queue.NewClient's ping: a broken config fails at startup, not at the first email. Every Send opens its own OTel span (smtp.send) with a recipient count; never with addresses.
Host and FromAddress are not required env tags: an empty Host is how a project signals "no mail transport configured" (usually in dev, or in a service that never actually sends mail). NewIfConfigured (and its panicking counterpart MustNewIfConfigured) turns that into mail.NewDiscard() (see below) instead of an error; New still errors on an empty FromAddress once Host is set, since that would silently mis-address every message. NewIfConfigured is what add mail wires into a generated project.
Compose smtp.Config into your project config under a MAIL_ prefix:
type Config struct {
// ...
Mail smtp.Config `envPrefix:"MAIL_"`
}
| Variable | Default | Meaning |
|---|---|---|
MAIL_HOST | (empty = no transport) | SMTP server address |
MAIL_PORT | 587 | SMTP port |
MAIL_USERNAME / MAIL_PASSWORD | empty | empty: no auth (Mailpit, internal relays) |
MAIL_AUTH | auto | auto | plain | login | cram-md5 | none |
MAIL_TLS | starttls | starttls | starttls-opportunistic (dev, TLS-less server) | tls (implicit TLS, 465) | none |
MAIL_FROM_ADDRESS | required once MAIL_HOST is set | default sender address |
MAIL_FROM_NAME | empty | default sender display name |
MAIL_TIMEOUT | 15s | dial/send timeout |
The full list lives in the configuration reference.
new project already runs a mailpit service in compose.yml (accepts SMTP, UI at MAILPIT_HOST), and .env.example ships the matching MAIL_* lines, but wiring smtp.Config into your own project config, and calling smtp.NewIfConfigured/MustNewIfConfigured, happens once, on the first add mail call.memory, log, and discard: for tests, development, and no-op transport
mem := mail.NewMemory() // renders and captures, no real sending
mailer := mail.NewLog(slog.Default()) // renders and logs instead of sending (dev)
mailer = mail.NewDiscard() // renders (so template bugs still surface) then drops
Memory renders every message and captures it as []mail.SentMessage (Messages(), Reset()); it is safe for concurrent use. The Log driver logs only the subject, recipient count, and rendered size at Info; the full body only at Debug. No PII in logs by default. Discard is what smtp.NewIfConfigured returns when MAIL_HOST is empty: it still validates and renders (so a broken template fails the same way it would with a real driver), it just never transmits or logs; the quietest possible "mail is off" state, distinct from Log's "mail is off, but tell me about it".
In the shop: the sendOrderConfirmation listener
The listener running in the worker subscribes to the orderPlaced event and sends via the mail.Mailer it gets from Dependencies; it knows nothing about the driver:
import (
"github.com/acme/shop/internal/modules/shop"
shopevents "github.com/acme/shop/internal/modules/shop/events"
)
//go:embed templates
var tmpls embed.FS
func sendOrderConfirmation(deps shop.Dependencies) func(context.Context, shopevents.OrderPlaced) error {
return func(ctx context.Context, ev shopevents.OrderPlaced) error {
return deps.Mailer.Send(ctx, mail.NewMessage().
WithTo(ev.CustomerEmail).
WithSubject("Your order confirmation: "+ev.OrderID).
WithBody(mjml.Template(tmpls, "templates/order_confirmation.mjml.tmpl", ev).
WithTextTemplate("templates/order_confirmation.txt.tmpl")))
}
}
The registration lives in listeners/register.go, routed to the mail queue (generated by add listener shop orderPlaced sendOrderConfirmation --queue mail):
func Register(reg *events.Registry, deps shop.Dependencies) {
events.Listen(reg, "sendOrderConfirmation", sendOrderConfirmation(deps), queue.OnQueue("mail"))
}
Delivery is at-least-once, so the listener should be idempotent: the Meta.ID read from events.MetaFromContext(ctx) is a stable idempotency key across retries.
The test: Memory mailer
func TestSendOrderConfirmation(t *testing.T) {
mem := mail.NewMemory()
handle := sendOrderConfirmation(shop.Dependencies{Mailer: mem})
err := handle(t.Context(), shopevents.OrderPlaced{
OrderID: "o-1001", CustomerEmail: "user@example.com",
})
require.NoError(t, err)
sent := mem.Messages()
require.Len(t, sent, 1)
require.Equal(t, "user@example.com", sent[0].To[0].Address)
require.Contains(t, sent[0].Content.HTML, "o-1001") // Memory really renders
}
Because Memory actually renders, the test catches template errors too: a misspelled field fails on the Content assertion, not in production.
Queued (asynchronous) sending
The Mailer stays synchronous; asynchronous sending follows the pattern above: the event goes to the queue through the outbox, and the listener sends the email in the worker, with retries. There is no separate built-in "mail queue": the events system is it.
Where the content lives: add mail
The sendOrderConfirmation example above calls deps.Mailer.Send directly from the listener: fine for a single, simple message. Once a module sends more than one related notification, or the message composition (multiple recipients, a computed subject, several templates) is worth naming and testing on its own, generate a notifier instead:
gpsystem add mail <module> <name>
This is the same split the kit uses everywhere: the transport is infrastructure, the content is the module's business logic (see Project structure: what can live inside a module and the shared-code decision ladder). add mail generates internal/modules/<module>/mail/<name>.go: a payload struct, a <Name>Notifier composing an injected mail.Mailer with the module's own templates, and wires a Mailer mail.Mailer field into the module's Dependencies, constructed once, in every entry point, via smtp.NewIfConfigured/MustNewIfConfigured. See the add mail reference for the exact generated shape.
Worked example: the contact-email flow
A concrete, end-to-end version of this pattern: a web form that notifies both an admin inbox and the requester:
POST /web/contact: the web surface's service persists the submission and dispatches aContactSubmittedevent through the outbox, inside the same transaction.cmd/worker's outbox relay delivers the event to thesendContactEmaillistener.- The listener calls
internal/modules/contact/mail.Notifier.Notify, which renders both templates (an admin notification and a requester confirmation) and sends each through the injectedmail.Mailer. - That
mail.Maileris SMTP in production;mail.NewDiscard()in dev whenMAIL_HOSTis empty; andmail.NewMemory()in the notifier's own tests (see above).
package mail
//go:embed templates/*.tmpl
var templatesFS embed.FS
type ContactNotification struct {
Name, Email string
// ... the rest of the submitted form
}
type Notifier interface {
Notify(ctx context.Context, n ContactNotification) error
}
func New(m kitmail.Mailer, cfg Config) Notifier {
return ¬ifier{mailer: m, adminTo: cfg.To}
}
func (n *notifier) Notify(ctx context.Context, p ContactNotification) error {
adminErr := n.sendAdminNotification(ctx, p) // -> Config.To
requesterErr := n.sendRequesterConfirmation(ctx, p) // -> p.Email
return errors.Join(adminErr, requesterErr)
}
func sendContactEmail(deps contact.Dependencies) func(context.Context, contactevents.ContactSubmitted) error {
notifier := contactmail.New(deps.Mailer, deps.Mail)
return func(ctx context.Context, ev contactevents.ContactSubmitted) error {
return notifier.Notify(ctx, contactmail.ContactNotification{Name: ev.Name, Email: ev.Email /* ... */})
}
}
Both the recipient config (Config.To, module content) and the shared SMTP config (smtp.Config, kit-level transport) compose under the same MAIL_ prefix in platform config: MAIL_TO and MAIL_HOST are just two fields under one env namespace, with no naming collision:
type Config struct {
// ...
Mail smtp.Config `envPrefix:"MAIL_"` // MAIL_HOST, MAIL_FROM_ADDRESS, ...
ContactMail contactmail.Config `envPrefix:"MAIL_"` // MAIL_TO
}
add mail does not scaffold the wiring shown in sendContactEmail above by hand: the generator produces the notifier, its templates and the Dependencies/config wiring; the payload fields, the subject line, and the call from a service or listener are business logic you write, the same way service/service.go ships as a minimal seam.When a module's content is addressed to a recipient rather than a bare address, especially once it needs more than mail (a persisted inbox, a live push), see Notifications and add notification instead; the two generators compose, and a notification's ToMail reuses the same mail.Message/mail.Template builders shown here.