What it is for
Section titled “What it is for”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.
Install
Section titled “Install”go get github.com/gasmod/gas/emailWiring
Section titled “Wiring”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.
Sending
Section titled “Sending”// 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.
Templated sends
Section titled “Templated sends”// 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}Local development
Section titled “Local development”Point the endpoint at LocalStack and nothing else changes. With the environment provider:
EMAIL__REGION=us-east-1EMAIL__FROMEMAIL=noreply@example.comEMAIL__ENDPOINT=http://localhost:4566EMAIL__ACCESSKEYID=testEMAIL__SECRETACCESSKEY=testConfiguration
Section titled “Configuration”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.
Errors
Section titled “Errors”| 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.
Health and readiness
Section titled “Health and readiness”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.
Testing
Section titled “Testing”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.
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.