mail

Overview

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

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 pathWhat it adds
github.com/gp-system/mailthe Mailer interface, Message, body types, the dev mailers
github.com/gp-system/mail/smtpSMTP sending, via wneessen/go-mail
github.com/gp-system/mail/mjmlMJML-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/smtp subpackage, 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/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. For MJML-sourced HTML, see MJML: the compiled mjml.Body is a mail.Body too, and slots into WithBody the same way.

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.

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:

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.

  • SMTP: the SMTP driver, its configuration and the NewIfConfigured dev/prod switch.
  • MJML: compiling MJML templates into responsive HTML.
  • Notifications: content addressed to a recipient, possibly on several channels including mail.
  • Architecture: where mail sits among the other standalone modules.
Copyright © 2026