Overview
mail is a standalone Go module (github.com/gp-system/mail, +mail/smtp, +mail/mjml): a Mailer interface, a fluent message builder, and a set of body types (plain text, HTML, Go templates, MJML). It works in any Go program, with or without the kit. 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.
Install
go get github.com/gp-system/mail@v0.1.0 # the kit itself uses this tag
go get github.com/gp-system/mail@latest
The root module has no dependency of its own beyond errs. The two optional subpackages each carry their own footprint, and a binary only pays for the one it imports:
| Import path | What it adds |
|---|---|
github.com/gp-system/mail | the Mailer interface, Message, body types, the dev mailers |
github.com/gp-system/mail/smtp | SMTP sending, via wneessen/go-mail |
github.com/gp-system/mail/mjml | MJML-to-HTML compilation, via Boostport/mjml-go (a WASM runtime) |
Why Mailer is an interface
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, 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/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. For MJML-sourced HTML, see MJML: the compiled mjml.Body is a mail.Body too, and slots into WithBody the same way.
.WithText()/.WithTextTemplate(), and an HTML-only message stays HTML-only.Dev mailers: memory, log, and discard
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".
For the concrete SMTP driver, see SMTP.
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.
Related pages
- SMTP: the SMTP driver, its configuration and the
NewIfConfigureddev/prod switch. - MJML: compiling MJML templates into responsive HTML.
- Notifications: content addressed to a recipient, possibly on several channels including mail.
- Architecture: where
mailsits among the other standalone modules.