Framework

Email

The pkg/email package provides a simple client for sending transactional emails via the Resend HTTP API. It is built entirely on Go's standard library — no external dependencies.

import "github.com/stanza-go/framework/pkg/email"

Creating a client

Create a client with your Resend API key and a default sender address:

client := email.New("re_your_api_key",
    email.WithFrom("noreply@example.com"),
)

Options

OptionDefaultDescription
WithFrom(addr)Default sender address for all messages
WithEndpoint(url)https://api.resend.com/emailsOverride the API endpoint (useful for testing)
WithTimeout(d)10sHTTP request timeout

Sending an email

result, err := client.Send(ctx, email.Message{
    To:      []string{"user@example.com"},
    Subject: "Welcome to the app",
    HTML:    "<h1>Hello!</h1><p>Welcome aboard.</p>",
    Text:    "Hello! Welcome aboard.",
})
if err != nil {
    // handle error
}
// result.ID is the Resend message ID

Message fields

FieldRequiredDescription
ToYesList of recipient email addresses
SubjectYesEmail subject line
HTMLOne of HTML/TextHTML body
TextOne of HTML/TextPlain-text body
FromNoOverrides the client-level default sender
ReplyToNoReply-To addresses

Checking configuration

Use Configured() to skip email-sending when no API key is set (e.g., local development):

if client.Configured() {
    _, err := client.Send(ctx, msg)
    // ...
} else {
    logger.Warn("email not configured, skipping send")
}

Error handling

Send returns typed errors for validation issues and API failures:

// Validation errors (returned before making the API call)
email.ErrNoRecipient  // no To addresses
email.ErrNoSubject    // empty subject
email.ErrNoBody       // neither HTML nor Text provided
email.ErrNoFrom       // no sender (neither client default nor message-level)
email.ErrNoAPIKey     // empty API key

// API errors (non-2xx response from Resend)
var apiErr *email.APIError
if errors.As(err, &apiErr) {
    log.Printf("Resend API error (status %d): %s", apiErr.StatusCode, apiErr.Body)
}

Wiring in a Stanza app

In the standalone app, the email client is created via a provider function and injected into modules that need it:

func provideEmail(cfg *config.Config) *email.Client {
    apiKey := cfg.String("email.resend_api_key", "")
    from := cfg.String("email.from", "noreply@stanza.dev")

    return email.New(apiKey, email.WithFrom(from))
}

Configuration is set via environment variables:

Env VarConfig KeyDescription
STANZA_EMAIL_RESEND_API_KEYemail.resend_api_keyResend API key
STANZA_EMAIL_FROMemail.fromDefault sender address

Testing

Use WithEndpoint to point the client at an httptest.Server in tests:

func TestSendEmail(t *testing.T) {
    srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(200)
        w.Write([]byte(`{"id": "test-id"}`))
    }))
    defer srv.Close()

    client := email.New("test-key",
        email.WithFrom("test@example.com"),
        email.WithEndpoint(srv.URL),
    )

    result, err := client.Send(context.Background(), email.Message{
        To:      []string{"user@example.com"},
        Subject: "Test",
        HTML:    "<p>Hello</p>",
    })
    if err != nil {
        t.Fatal(err)
    }
    if result.ID != "test-id" {
        t.Errorf("got ID %q, want %q", result.ID, "test-id")
    }
}

Client stats

The client tracks cumulative email delivery counters using atomic operations. Call Stats() for a thread-safe snapshot:

stats := client.Stats()
fmt.Println(stats.Sent, stats.Errors)
FieldTypeDescription
Sentint64Total emails successfully delivered to the API
Errorsint64Total failed send attempts (transport errors, non-2xx responses, or decode failures)

All counters are cumulative since the client was created. Stats() is safe to call concurrently from any goroutine.


Tips

  • Always provide both HTML and Text. Some email clients prefer plain text, and it improves deliverability.
  • Verify your domain. Resend's onboarding@resend.dev sender works for testing but will have poor deliverability. Verify your domain at resend.com/domains for production.
  • Email is best-effort. In features like notifications and password reset, the standalone app logs email failures but never blocks the primary operation.
Previous
Validation
Next
Cache