Storage & Mail

Email

Email sending: fluent Message builder, MJML templates, a memory mailer for tests.

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/smtp subpackage (via wneessen/go-mail), the MJML compilation by mail/mjml. The mail root 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:

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

There is no automatic text/plain derivation from HTML or MJML: when you need a text alternative, set it explicitly with .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; default true.
  • mjml.ValidationLevel("strict" | "soft" | "skip"): MJML validation strictness; default soft.
Importing 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_"`
}
VariableDefaultMeaning
MAIL_HOST(empty = no transport)SMTP server address
MAIL_PORT587SMTP port
MAIL_USERNAME / MAIL_PASSWORDemptyempty: no auth (Mailpit, internal relays)
MAIL_AUTHautoauto | plain | login | cram-md5 | none
MAIL_TLSstarttlsstarttls | starttls-opportunistic (dev, TLS-less server) | tls (implicit TLS, 465) | none
MAIL_FROM_ADDRESSrequired once MAIL_HOST is setdefault sender address
MAIL_FROM_NAMEemptydefault sender display name
MAIL_TIMEOUT15sdial/send timeout

The full list lives in the configuration reference.

A project generated with 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:

internal/modules/shop/listeners/send_order_confirmation.go
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):

internal/modules/shop/listeners/register.go
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:

  1. POST /web/contact: the web surface's service persists the submission and dispatches a ContactSubmitted event through the outbox, inside the same transaction.
  2. cmd/worker's outbox relay delivers the event to the sendContactEmail listener.
  3. 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 injected mail.Mailer.
  4. That mail.Mailer is SMTP in production; mail.NewDiscard() in dev when MAIL_HOST is empty; and mail.NewMemory() in the notifier's own tests (see above).
internal/modules/contact/mail/notifier.go
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 &notifier{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)
}
internal/modules/contact/listeners/sendcontactemail.go
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:

internal/platform/config/config.go
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.

Copyright © 2026