cf_mail

package module
v0.0.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 19, 2026 License: Apache-2.0 Imports: 26 Imported by: 0

README

caerus-framework-mail

CI codecov License

Caerus Framework Mail Component. One chassis for transactional email: apps call Send(ctx, Mail) and never import a provider SDK. The active backend is a config setting (provider), not a second component type.

Supported providers:

provider What it is What it is not
resend Resend transactional API
ses Amazon SES v2 SendEmail SNS, SESv1, campaign tools
unisender_go Unisender Go email/send Unisender.com campaign API (provider: unisender is rejected)

This module replaces caerus-framework-resend for new products. The old resend repo stays tagged for existing binaries until they bump.

flowchart TD
  A[App Send Mail] --> B[CFMail]
  B --> C{provider}
  C -->|resend| D[resend-go]
  C -->|ses| E[AWS SES v2]
  C -->|unisender_go| F[Unisender Go HTTP email/send]

Wiring

Two wiring shapes are supported. Prefer the app-owned shape (demoapp golden path): main declares only the chassis (mail alongside postgres / valkey) and the app class; product machinery that sends email lives under the app and resolves mail as a peer at Init. Use the simple main-level shape for one-off binaries.

App-owned consumer (golden — demoapp pattern)

main declares mail as chassis and runs the app class; it never touches mail itself:

fw := cf.New(&cf.FrameworkOptions{
	Logs: &cf.LogsSettings{Format: "json", Level: "info", ConfigSource: "logs"},
	Observability: &cf.ObservabilitySettings{Bind: ":9090", ConfigSource: "observability"},
	Components: []cf.CaerusComponent{
		cf_postgres.New(cf_postgres.WithConfigSource("postgresql", "config/postgresql.json")),
		cf_mail.New(cf_mail.WithConfigSource("mail", "config/mail.json")),
		app.New(app.Options{}),
	},
})
if err := fw.RunWithSignals(context.Background()); err != nil {
	log.Fatal(err)
}

The app resolves the mail component pointer once at Init (never a client snapshot), declares it in GetDependencies, and calls Send per use:

type App struct {
	email *cf_mail.CFMail
}

func (a *App) GetDependencies() []string {
	return []string{cf_mail.ComponentName} // + logs, chassis peers
}

func (a *App) Init(ctx context.Context, fw *cf.CaerusFramework) error {
	email, ok := cf.Get[*cf_mail.CFMail](fw)
	if !ok {
		return errors.New("app: mail component missing")
	}
	a.email = email
	return nil
}
Simple main-level wiring
fw := cf.New()
logs := cf_logs.New(cf_logs.WithWriter(os.Stdout))
mailer := cf_mail.New(cf_mail.WithConfigSource("mail", "config/mail.json"))
fw.AddComponent(logs)
fw.AddComponent(mailer) // GetDependencies() -> [logs configuration]

In both shapes the component is cf.ConfigSourceRegistrar-self-sufficient: WithConfigSource registers Source[MailConfig]. main never touches os.Getenv / ParseFlags. The --mail path flag comes from the source name.

Sending

Send takes a Caerus Mail value (SES-simple: From, To, Subject, HTML/Text, ReplyTo, Tags, optional IdempotencyKey). One extra attempt runs on HTTP 429 or 5xx (honor Retry-After, wait capped at 1s, stop if ctx is done). 422 and network errors are not retried.

From: when the component uses WithConfigSource, from_address is required on the source (startup and reload validation). It is the soft default sender: empty Mail.From uses it; non-empty Mail.From overrides that send only. Without a config source, WithFromAddress or Mail.From per send still apply. If both defaults are empty, or the resolved address or any To does not parse as an email (net/mail.ParseAddress), Send fails. At least one of HTML or Text is required.

IdempotencyKey is sent to Resend and Unisender Go. SES v2 SendEmail has no matching field — it is ignored there.

Attachments, Cc/Bcc, and scheduled send stay on the provider escape hatches (ResendClient(), SESClient()). Those return nil when the active provider is a different backend. Unisender Go has no SDK accessor; extra fields stay off Send on purpose (transactional body only).

a.email, _ = cf.Get[*cf_mail.CFMail](fw)

id, err := a.email.Send(ctx, cf_mail.Mail{
	To:      []string{"user@example.com"},
	Subject: "Welcome",
	HTML:    "<p>Hi!</p>",
})
if err != nil {
	if cf_mail.HTTPStatus(err) == 429 {
		// rate limited
	}
	return err
}

Peers resolve the component once at Init (declare cf_mail.ComponentName in GetDependencies) and call Send per use — never snapshot a provider client, since config reload swaps it.

Configuration

Shared settings sit at the top of the file. Each provider has a nested object. Only the nested blob for the active provider is required.

The configuration env overlay does not walk nested structs, so local go run uses flat MAIL_* aliases (same idea as valkey-state RATE_LIMIT_*). In Kubernetes the nested JSON/YAML file is canonical.

{
  "provider": "resend",
  "from_address": "noreply@example.com",
  "timeout_sec": 10,
  "resend": {
    "api_key": "re_…",
    "base_url": ""
  },
  "ses": {
    "region": "eu-central-1",
    "access_key_id": "",
    "secret_access_key": "",
    "endpoint": ""
  },
  "unisender_go": {
    "api_key": "",
    "base_url": "https://goapi.unisender.ru/en/transactional/api/v1",
    "skip_unsubscribe": true
  }
}

Default EnvPrefix is MAIL_ (from the source name mail).

Setting File Env (prefix MAIL_)
Provider provider MAIL_PROVIDER
Soft-default From from_address MAIL_FROM_ADDRESS
HTTP timeout seconds timeout_sec MAIL_TIMEOUT_SEC
Resend API key resend.api_key MAIL_RESEND_API_KEY
Resend base URL resend.base_url MAIL_RESEND_BASE_URL
SES region ses.region MAIL_SES_REGION
SES access key ses.access_key_id MAIL_SES_ACCESS_KEY_ID
SES secret ses.secret_access_key MAIL_SES_SECRET_ACCESS_KEY
SES endpoint (LocalStack) ses.endpoint MAIL_SES_ENDPOINT
Unisender Go API key unisender_go.api_key MAIL_UNISENDER_GO_API_KEY
Unisender Go base URL unisender_go.base_url MAIL_UNISENDER_GO_BASE_URL
Skip unsubscribe block unisender_go.skip_unsubscribe MAIL_UNISENDER_GO_SKIP_UNSUBSCRIBE

Wrong vs right:

Wrong: provider "unisender"  → campaign Unisender.com API (not implemented)
Right: provider "unisender_go" → transactional email/send on goapi.unisender.ru
SES credentials (two exclusive paths)

Path A — file keys (local / explicit): set both access_key_id and secret_access_key in the nested ses object (or the matching MAIL_SES_* env aliases). One without the other is an Init error.

Path B — default AWS chain (recommended in cluster): leave both keys empty. The AWS SDK uses IRSA / instance role / shared config. region is still required.

Unisender Go

Default base URL is https://goapi.unisender.ru/en/transactional/api/v1. Override base_url for the go1/go2 datacenter your account is on. Auth is the X-API-KEY header. skip_unsubscribe defaults on (value 1) so transactional mail does not get a campaign unsubscribe footer; set skip_unsubscribe: false only if Unisender support enabled that for you.

Send returns the Unisender Go job_id. If every To address is in failed_emails, Send fails.

Files are canonical in Kubernetes, including API keys — mount a Secret/ConfigMap and let fsnotify + OnConfigReload rotate the sender without a restart. Nested api_key / secret_access_key are tagged secret:"redact". On reload failure the previous sender stays live (last-good).

Health reports initialized/uninitialized (no provider liveness probe; send failures surface per call). Metrics emits the following while initialized, nil before Init/after Shutdown:

Metric Type Labels
mail_info gauge 1 component, provider, from
mail_config_reloads_total counter component
mail_emails_sent_total counter component, from
mail_send_retries_total counter component, from
mail_send_duration_seconds_sum counter component, from
mail_send_duration_seconds_count counter component, from
mail_emails_failed_total counter component, from, error_code

The from label on traffic counters is the actual sender of each email. error_code is the HTTP status or network.

Options

Option Description
WithConfig(MailConfig) static snapshot; non-zero fields override option-set defaults
WithConfigSource(name, path, …) bind a configuration source for Init + OnConfigReload
WithProvider(name) resend, ses, or unisender_go
WithFromAddress(from) soft-default sender
WithTimeout(d) per-send HTTP timeout (default 10s)
WithHTTPClient(*http.Client) stub RoundTripper in tests; used by every provider
WithResendAPIKey / WithResendBaseURL Resend construct-time
WithSESRegion / WithSESCredentials / WithSESEndpoint SES construct-time
WithUnisenderGoAPIKey / WithUnisenderGoBaseURL Unisender Go construct-time
WithName(name) custom component name (default "mail")
WithLogger(*slog.Logger) explicit logger override

Tests

Unit tests cover config layering, the Init contract, all three providers (stub http.RoundTripper), reload last-good, and health/metrics — no external service.

License

Apache License 2.0 — see LICENSE.

Documentation

Index

Constants

View Source
const (
	// ProviderResend is the Resend transactional API (resend.com).
	ProviderResend = "resend"
	// ProviderSES is Amazon SES v2 SendEmail (transactional).
	ProviderSES = "ses"
	// ProviderUnisenderGo is Unisender Go transactional email/send
	// (goapi.unisender.ru). It is not the Unisender.com campaign API.
	ProviderUnisenderGo = "unisender_go"
)
View Source
const (
	// ComponentName is the framework component name for the mail component.
	ComponentName = "mail"

	// ComponentStage is the stage data-layer components initialize in.
	ComponentStage = cf.Stage("data")
)

Variables

This section is empty.

Functions

func HTTPStatus

func HTTPStatus(err error) int

HTTPStatus returns the provider HTTP status from err when err is (or wraps) a SendError. It returns 0 for nil, validation errors, network failures, and SDK errors that did not go through Send.

Types

type CFMail

type CFMail struct {
	// contains filtered or unexported fields
}

CFMail is the caerus-framework-mail component. Apps hold this pointer and call Send per use. The active provider is a config setting, not a second component type.

func New

func New(opts ...Option) *CFMail

New creates a mail component. The provider client is built at Init, not here.

func (*CFMail) From

func (c *CFMail) From() string

From returns the configured soft-default sender.

func (*CFMail) GetDependencies

func (c *CFMail) GetDependencies() []string

GetDependencies implements cf.Dependencies.

func (*CFMail) GetInitOrderStage

func (c *CFMail) GetInitOrderStage() cf.Stage

GetInitOrderStage implements cf.CaerusComponent.

func (*CFMail) Health

func (c *CFMail) Health(ctx context.Context) error

Health implements cf.HealthProvider. Providers expose no liveness endpoint; health reflects that a sender is initialized.

func (*CFMail) Init

func (c *CFMail) Init(ctx context.Context, fw *cf.CaerusFramework) error

Init implements cf.CaerusComponent.

func (*CFMail) Metrics

func (c *CFMail) Metrics() []cf_observability.Metric

Metrics implements cf_observability.MetricsProvider.

func (*CFMail) Name

func (c *CFMail) Name() string

Name implements cf.CaerusComponent.

func (*CFMail) OnConfigReload

func (c *CFMail) OnConfigReload(source string, cfg any)

OnConfigReload implements cf.ConfigReloader. On failure the previous sender is kept (last-good).

func (*CFMail) Provider

func (c *CFMail) Provider() string

Provider returns the normalized provider name after Init (empty before).

func (*CFMail) RegisterConfigSources

func (c *CFMail) RegisterConfigSources(conf any) error

RegisterConfigSources implements cf.ConfigSourceRegistrar.

func (*CFMail) ResendClient

func (c *CFMail) ResendClient() *resend.Client

ResendClient returns the live Resend SDK client, or nil when the active provider is not resend (or before Init / after Shutdown).

func (*CFMail) SESClient

func (c *CFMail) SESClient() *sesv2.Client

SESClient returns the live SES v2 client, or nil when the active provider is not ses (or before Init / after Shutdown).

func (*CFMail) Send

func (c *CFMail) Send(ctx context.Context, m Mail) (string, error)

Send sends m through the configured provider and returns the provider message id (Resend id, SES MessageId, Unisender Go job_id).

From: from_address / WithFromAddress is a soft default when Mail.From is empty. If both are empty, or the resolved From or any To address does not parse (`net/mail.ParseAddress`), Send fails. HTML and Text may both be set; at least one must be non-empty.

If the first HTTP status is 429 or 5xx, Send waits (Retry-After, capped at 1s) and tries once more while ctx is live. 4xx other than 429 and network errors are not retried.

func (*CFMail) Shutdown

func (c *CFMail) Shutdown(ctx context.Context) error

Shutdown implements cf.CaerusComponent.

type Mail

type Mail struct {
	// From overrides from_address when non-empty. Empty (or whitespace)
	// uses the configured soft default. The resolved value must parse as
	// an RFC 5322 address (`net/mail.ParseAddress`).
	From    string
	To      []string
	Subject string
	HTML    string
	Text    string
	ReplyTo string
	// Tags are provider metadata (name → value). Empty names are skipped.
	// Resend and SES send name/value pairs. Unisender Go uses the names as
	// its string tags (max 4) and the pairs as global_metadata (max 10).
	Tags map[string]string
	// IdempotencyKey is forwarded when the provider supports it (Resend
	// Idempotency-Key header; Unisender Go idempotence_key). SES v2 SendEmail
	// has no matching field; it is ignored there.
	IdempotencyKey string
}

Mail is the Caerus send DTO (SES-simple shape). Apps pass this to Send without importing a provider SDK. Attachments, Cc/Bcc, headers, and scheduled send stay on the provider escape hatches (ResendClient / SESClient) for callers that opt into an SDK.

type MailConfig

type MailConfig struct {
	// Provider selects the sender: resend, ses, or unisender_go.
	Provider string `json:"provider" yaml:"provider" env:"PROVIDER"`
	// FromAddress is the soft-default sender. Send uses it when Mail.From is empty.
	FromAddress string `json:"from_address" yaml:"from_address" env:"FROM_ADDRESS"`
	// TimeoutSec bounds each provider HTTP call (default 10s).
	TimeoutSec float64 `json:"timeout_sec,omitempty" yaml:"timeout_sec,omitempty" env:"TIMEOUT_SEC"`

	Resend      ResendSettings      `json:"resend,omitempty" yaml:"resend,omitempty" env:"-"`
	SES         SESSettings         `json:"ses,omitempty" yaml:"ses,omitempty" env:"-"`
	UnisenderGo UnisenderGoSettings `json:"unisender_go,omitempty" yaml:"unisender_go,omitempty" env:"-"`

	// Flat env aliases (nested JSON/YAML still wins when both are set after
	// merge: env overlay fills these, applyConfig copies them into the nested
	// structs when the nested field is still empty).
	ResendAPIKey         string `json:"-" yaml:"-" env:"RESEND_API_KEY" secret:"redact"`
	ResendBaseURL        string `json:"-" yaml:"-" env:"RESEND_BASE_URL"`
	SESRegion            string `json:"-" yaml:"-" env:"SES_REGION"`
	SESAccessKeyID       string `json:"-" yaml:"-" env:"SES_ACCESS_KEY_ID"`
	SESSecretAccessKey   string `json:"-" yaml:"-" env:"SES_SECRET_ACCESS_KEY" secret:"redact"`
	SESEndpoint          string `json:"-" yaml:"-" env:"SES_ENDPOINT"`
	UnisenderGoAPIKey    string `json:"-" yaml:"-" env:"UNISENDER_GO_API_KEY" secret:"redact"`
	UnisenderGoBaseURL   string `json:"-" yaml:"-" env:"UNISENDER_GO_BASE_URL"`
	UnisenderGoSkipUnsub *bool  `json:"-" yaml:"-" env:"UNISENDER_GO_SKIP_UNSUBSCRIBE"`
}

MailConfig is the file/env-drivable configuration. Shared settings sit at the top; each provider has a nested object. The configuration overlay does not walk nested structs for env, so the flat MAIL_* aliases below exist for local/`go run` (same pattern as valkey-state RATE_LIMIT_*).

type Option

type Option func(*options)

Option configures the mail component at construction time.

func WithConfig

func WithConfig(cfg MailConfig) Option

WithConfig sets a static configuration snapshot. Non-zero fields of cfg override the values set by the convenience options. Prefer WithConfigSource when using caerus-framework-configuration with hot-reload.

func WithConfigSource

func WithConfigSource(name, path string, opts ...SourceOption) Option

WithConfigSource binds this component to a named configuration source and registers that source with the configuration component (via the framework's ConfigSourceRegistrar pass during argv absorption).

cf_mail.New(cf_mail.WithConfigSource("mail", "config/mail.json"))

A path of "" registers an env-only (fileless) source when the EnvPrefix is non-empty. The path CLI override stays --<source-name> (ParseFlags). Declares a dependency on "configuration".

func WithFromAddress

func WithFromAddress(from string) Option

WithFromAddress sets the soft-default sender. Send uses it when Mail.From is empty. A non-empty Mail.From overrides this call only.

func WithHTTPClient

func WithHTTPClient(hc *http.Client) Option

WithHTTPClient overrides the HTTP client used by every provider. Useful for tests (a stub RoundTripper). The component does not close a client it did not create.

func WithLogger

func WithLogger(logger *slog.Logger) Option

WithLogger overrides the logger used for component diagnostics. By default the component logs through the framework logs component.

func WithName

func WithName(name string) Option

WithName sets a custom component name, allowing multiple mail instances in the same process. The default name is "mail" (ComponentName).

func WithProvider

func WithProvider(provider string) Option

WithProvider selects the sender (resend, ses, unisender_go). Required unless the bound config file sets provider.

func WithResendAPIKey

func WithResendAPIKey(apiKey string) Option

WithResendAPIKey sets the Resend API key (tests, embedded use).

func WithResendBaseURL

func WithResendBaseURL(baseURL string) Option

WithResendBaseURL overrides the Resend API endpoint.

func WithSESCredentials

func WithSESCredentials(accessKeyID, secretAccessKey string) Option

WithSESCredentials sets static AWS keys. Leave unset to use the default credential chain (IRSA / shared config).

func WithSESEndpoint

func WithSESEndpoint(endpoint string) Option

WithSESEndpoint overrides the SES v2 endpoint (LocalStack / tests).

func WithSESRegion

func WithSESRegion(region string) Option

WithSESRegion sets the AWS region for SES v2.

func WithTimeout

func WithTimeout(d time.Duration) Option

WithTimeout sets the per-send HTTP timeout (default 10s).

func WithUnisenderGoAPIKey

func WithUnisenderGoAPIKey(apiKey string) Option

WithUnisenderGoAPIKey sets the Unisender Go API key.

func WithUnisenderGoBaseURL

func WithUnisenderGoBaseURL(baseURL string) Option

WithUnisenderGoBaseURL overrides the Unisender Go transactional base URL.

type ResendSettings

type ResendSettings struct {
	APIKey  string `json:"api_key" yaml:"api_key" env:"-" secret:"redact"`
	BaseURL string `json:"base_url,omitempty" yaml:"base_url,omitempty" env:"-"`
}

ResendSettings is the nested Resend blob.

type SESSettings

type SESSettings struct {
	Region          string `json:"region" yaml:"region" env:"-"`
	AccessKeyID     string `json:"access_key_id,omitempty" yaml:"access_key_id,omitempty" env:"-"`
	SecretAccessKey string `json:"secret_access_key,omitempty" yaml:"secret_access_key,omitempty" env:"-" secret:"redact"`
	Endpoint        string `json:"endpoint,omitempty" yaml:"endpoint,omitempty" env:"-"`
}

SESSettings is the nested Amazon SES v2 blob.

Credentials: set both AccessKeyID and SecretAccessKey in the file (K8s Secret mount). Leave both empty to use the default AWS chain (IRSA in cluster, shared config on a laptop). Setting only one is an Init error.

type SendError

type SendError struct {
	// contains filtered or unexported fields
}

SendError is returned by Send when the provider HTTP call failed. HTTPStatus is filled from the component transport (the same source as mail_emails_failed_total).

Status 0 means a transport/network failure or an unknown status.

func (*SendError) Error

func (e *SendError) Error() string

func (*SendError) HTTPStatus

func (e *SendError) HTTPStatus() int

HTTPStatus is the provider API status for this send, or 0 if the request never got an HTTP response.

func (*SendError) Unwrap

func (e *SendError) Unwrap() error

Unwrap returns the SDK (or transport) error so errors.Is / errors.As keep working on the inner value.

type SourceOption

type SourceOption func(*sourceOptions)

SourceOption configures the self-registered configuration source created by WithConfigSource.

func WithSourceEnvPrefix

func WithSourceEnvPrefix(prefix string) SourceOption

WithSourceEnvPrefix sets the environment overlay prefix for the source (default: the uppercase source name with "-" replaced by "_", plus "_"). An empty prefix disables env overlay.

func WithSourceFormat

func WithSourceFormat(f cf_configuration.Format) SourceOption

WithSourceFormat forces the file format instead of inferring it from the path extension (".yaml"/".yml" → YAML; anything else JSON).

type UnisenderGoSettings

type UnisenderGoSettings struct {
	APIKey          string `json:"api_key" yaml:"api_key" env:"-" secret:"redact"`
	BaseURL         string `json:"base_url,omitempty" yaml:"base_url,omitempty" env:"-"`
	SkipUnsubscribe *bool  `json:"skip_unsubscribe,omitempty" yaml:"skip_unsubscribe,omitempty" env:"-"`
}

UnisenderGoSettings is the nested Unisender Go transactional blob. Default BaseURL is https://goapi.unisender.ru/en/transactional/api/v1 (override for go1/go2 datacenters). This is not api.unisender.com.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL