Skip to content

Email

gas/email/ses implements core’s gas.EmailProvider on AWS SES. A message is either written by the caller (Send) or rendered from named templates that a gas.TemplateProvider holds (SendFromTemplate). That is the same interface UI renders pages from, so one template store can serve both.

Services depend on gas.EmailProvider and never import this module, so a test can swap in the mock from emailtest.

Terminal window
go get github.com/gasmod/gas/email
main.go
templates := templatememory.NewStore()
if err := registerTemplates(context.Background(), templates); err != nil {
log.Fatal(err)
}
app := gas.NewApp(
gas.WithServiceInstance[gas.ConfigProvider](cfg),
gas.WithSingletonService[gas.Logger](gaslog.NewSlogLogger()),
// ses.New asks the container for a gas.TemplateProvider, a
// gas.ConfigProvider and a gas.Logger.
gas.WithServiceInstance[gas.TemplateProvider](templates),
gas.WithSingletonService[gas.EmailProvider](emailses.New()),
gas.WithSingletonService[*Orders](NewOrders),
)

ses.New() returns a constructor that the container calls with a gas.TemplateProvider, a gas.ConfigProvider and a gas.Logger. All three must be registered, even if the app only ever calls Send; without a template provider, startup fails with no registration for gas.TemplateProvider.

If the container holds a concrete store type rather than the interface, NewWithCustomProvider[T]() resolves T instead:

// NewWithCustomProvider resolves a concrete template store type instead of
// gas.TemplateProvider.
func customProvider() []gas.Option {
return []gas.Option{
gas.WithServiceInstance[*templatememory.Store](templatememory.NewStore()),
gas.WithSingletonService[gas.EmailProvider](
emailses.NewWithCustomProvider[*templatememory.Store](),
),
}
}

Init runs at startup. It binds and validates the configuration, then, unless WithClient supplied a client, builds the SES client from the AWS default configuration (environment variables and shared config files) and fetches the credentials under Init’s context, so a missing or broken credential setup fails startup. It does not contact SES: an unreachable endpoint or rejected credentials surface on the first send.

WithClient(c) injects any client with SES’s SendEmail method, for example a fake in tests. Client() returns the underlying *ses.Client for calls beyond gas.EmailProvider, or nil when the injected client is of another type.

orders.go
// Orders depends on gas.EmailProvider, not on SES.
type Orders struct {
email gas.EmailProvider
}
func NewOrders(email gas.EmailProvider) *Orders { return &Orders{email: email} }
func (o *Orders) Confirm(ctx context.Context, to string) error {
return o.email.Send(ctx, &gas.Email{
To: []string{to},
Bcc: []string{"audit@example.com"},
ReplyTo: "support@example.com",
Subject: "Order confirmed",
TextBody: "Thank you for your order.",
HTMLBody: "<p>Thank you for your order.</p>",
})
}

Send passes the message to SES SendEmail: To, Cc and Bcc as the destination, Subject, TextBody and HTMLBody as UTF-8 content, and ReplyTo as the single reply-to address. An empty From uses email.fromemail from the configuration.

// Each named template is fetched and rendered with Data. A template field
// left empty keeps the matching field of the embedded Email.
func (o *Orders) Welcome(ctx context.Context, to, name string) error {
return o.email.SendFromTemplate(ctx, &gas.TemplatedEmail{
SubjectTemplate: "welcome-subject",
TextTemplate: "welcome-text",
HTMLTemplate: "welcome-html",
Data: map[string]string{"Name": name},
Email: gas.Email{To: []string{to}},
})
}

For each template name that is set, SendFromTemplate fetches the template from the gas.TemplateProvider and executes it with Data. The subject and text templates render with text/template; the HTML template renders with html/template, so values are escaped there. The results are written into the message’s Subject, TextBody and HTMLBody fields, so the message you pass is modified, and then it is sent as Send sends it. A template field left empty keeps the literal field of the embedded Email, so you can render the body and write the subject yourself. Templates are fetched and parsed on every call; nothing is cached.

// The subject and text templates render with text/template, the HTML one
// with html/template.
func registerTemplates(ctx context.Context, store gas.TemplateProvider) error {
templates := map[string]string{
"welcome-subject": "Welcome, {{.Name}}",
"welcome-text": "Hi {{.Name}}, thanks for signing up.",
"welcome-html": "<p>Hi {{.Name}}, thanks for signing up.</p>",
}
for name, body := range templates {
if err := store.Register(ctx, name, []byte(body)); err != nil {
return err
}
}
return nil
}

Point the endpoint at LocalStack and nothing else changes. With the environment provider:

Terminal window
EMAIL__REGION=us-east-1
EMAIL__FROMEMAIL=noreply@example.com
EMAIL__ENDPOINT=http://localhost:4566
EMAIL__ACCESSKEYID=test
EMAIL__SECRETACCESSKEY=test

Bound from the gas.ConfigProvider in Init. There are no defaults.

Key Type Default Description
email.region string none, required AWS region of the SES service.
email.fromemail string none, required Sender address for messages with no From.
email.endpoint string empty Custom endpoint URL, for example LocalStack. Empty uses the AWS endpoint.
email.accesskeyid string empty Static access key ID. Used only when email.secretaccesskey is also set; otherwise the default AWS credential chain applies.
email.secretaccesskey string empty Static secret access key, paired with email.accesskeyid.

A missing email.region or email.fromemail fails Init, even when WithClient supplies the client. ses.WithConfig(cfg) replaces the whole configuration and skips the provider; a config given that way is used as given, without validation.

Error Returned when
email.ErrClosed Send, SendFromTemplate or CheckReady after Close. email.IsErrClosed(err) checks for it.

Every other error starts with the service name, gas/email/ses, and wraps the underlying SDK, template provider or template error with %w: gas/email/ses: config binding: ..., gas/email/ses: retrieve aws credentials: ..., gas/email/ses: failed to send email: ..., and gas/email/ses: render html template "welcome-html": ... for a template that cannot be fetched, parsed or executed. Validation errors name the field, for example Email.Region.

The service implements gas.ReadyReporter. CheckReady returns nil until Close runs and email.ErrClosed after, so a readiness probe takes the instance out of rotation while it drains. Close only marks the service closed; the service starts no goroutines.

It does not implement gas.HealthReporter: the SES client holds no state that a restart would repair. See Deployment for how the probes use these.

emailtest.MockEmail implements gas.EmailProvider and gas.ReadyReporter. Each method records the call in Calls and delegates to SendFn, SendFromTemplateFn or CheckReadyFn when set; otherwise it returns nil. CallCount(method) and Reset() help with assertions.

orders_test.go
func TestWelcomeSendsOneTemplatedEmail(t *testing.T) {
mock := &emailtest.MockEmail{}
orders := NewOrders(mock)
if err := orders.Welcome(context.Background(), "ada@example.com", "Ada"); err != nil {
t.Fatal(err)
}
if n := mock.CallCount("SendFromTemplate"); n != 1 {
t.Fatalf("SendFromTemplate called %d times, want 1", n)
}
msg := mock.Calls[0].Args[0].(*gas.TemplatedEmail)
if msg.To[0] != "ada@example.com" {
t.Fatalf("sent to %v", msg.To)
}
}

The mock records the *gas.TemplatedEmail as given; it renders nothing.