cf_resend

package module
v0.0.9 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: 18 Imported by: 0

README

caerus-framework-resend

CI codecov License

Caerus Framework Resend Component.

New products should use caerus-framework-mail instead (provider: resend in config/mail.json). This module stays for binaries that already import cf_resend. Do not add features here.

A thin framework wrapper around the resend-go SDK so components and apps can send email without importing the SDK directly: framework-owned lifecycle, configuration (file + env + flags), live config reload with last-good semantics, logging through the framework logs component, and observability health/metrics.

Wiring

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

App-owned consumer (golden — demoapp pattern)

main declares resend as chassis and runs the app class; it never touches resend 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_resend.New(cf_resend.WithConfigSource("resend", "config/resend.json")),
		app.New(app.Options{}),
	},
})
if err := fw.RunWithSignals(context.Background()); err != nil {
	log.Fatal(err)
}

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

type App struct {
	email *cf_resend.CFResend
}

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

func (a *App) Init(ctx context.Context, fw *cf.CaerusFramework) error {
	email, ok := cf.Get[*cf_resend.CFResend](fw)
	if !ok {
		return errors.New("app: resend component missing")
	}
	a.email = email
	return nil
}
Simple main-level wiring

For a one-off binary, register the components directly and use cf.MustGet to reach the component:

fw := cf.New()

logs := cf_logs.New(cf_logs.WithWriter(os.Stdout))
resend := cf_resend.New(cf_resend.WithConfigSource("resend", "config/resend.json"))
fw.AddComponent(logs)
fw.AddComponent(resend) // GetDependencies() -> [logs configuration]

In both shapes the component is cf.ConfigSourceRegistrar-self-sufficient: WithConfigSource registers the Source[ResendConfig] with the configuration component during argv absorption, so main never touches os.Getenv/ParseFlags. The --resend path flag and per-field flags come from the source declaration.

Sending

Send takes a Caerus Mail value (SES-simple: From, To, Subject, HTML/Text, ReplyTo, Tags, optional IdempotencyKey). Apps do not import resend-go for ordinary mail. 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: from_address / WithFromAddress is a soft default. Empty Mail.From uses it. Non-empty Mail.From overrides that send. If both 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.

Attachments, Cc/Bcc, and scheduled send stay on Client() (that path imports the SDK on purpose).

// in the app's Init — store the component pointer
a.email, _ = cf.Get[*cf_resend.CFResend](fw)

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

// per-call From override
id, err = a.email.Send(ctx, cf_resend.Mail{
	From:    "brand@example.com",
	To:      []string{"user@example.com"},
	Subject: "Welcome",
	Text:    "Hi",
})

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

Options

Option Description
WithConfig(ResendConfig) static config snapshot; non-zero fields override option-set defaults
WithConfigSource(name, path, …) bind a configuration source for Init + OnConfigReload; the module registers the Source[ResendConfig] itself (declares configuration dep)
WithAPIKey(key) set the Resend API key directly (tests, embedded use)
WithFromAddress(from) soft-default sender; empty Mail.From uses it; must be a valid address when used
WithBaseURL(url) override the Resend API endpoint (self-hosted / test stub)
WithTimeout(d) per-send HTTP timeout (default 10s)
WithHTTPClient(*http.Client) override the HTTP client (stub RoundTripper in tests)
WithName(name) custom component name for multiple instances (default "resend")
WithLogger(*slog.Logger) explicit logger override; defaults to the framework logs component's logger (re-delivered on logs Reconfigure), falling back to slog.Default()

Configuration

Load ResendConfig through the configuration component. The default EnvPrefix is RESEND_ (from the source name); env tags map RESEND_API_KEY, RESEND_FROM_ADDRESS, RESEND_BASE_URL, RESEND_TIMEOUT_SEC.

{
  "api_key": "…",
  "from_address": "noreply@example.com"
}

Files are canonical in Kubernetes, including the API key — mount a Secret/ConfigMap and let fsnotify + OnConfigReload rotate the client without a restart. api_key is tagged secret:"redact": reload logs api_key_set, never the key. Do not log the config struct. On reload failure the previous client stays live (last-good). Resend is a stateless HTTP wrapper, so Client() and Send keep working through a swap.

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

Metric Type Labels
resend_info gauge 1 component, from, base_url
resend_config_reloads_total counter component
resend_emails_sent_total counter component, from
resend_send_retries_total counter component, from
resend_send_duration_seconds_sum counter component, from
resend_send_duration_seconds_count counter component, from

resend_info is a snapshot descriptor (the "component is initialized" marker); its labels always reflect the live configured sender identity. Error codes are the real HTTP statuses (e.g. 429, 422, 500), or network for transport errors — recorded at the transport layer because the resend SDK swallows status codes in its errors. Send wraps those failures as *SendError: cf_resend.HTTPStatus(err) is the same number (0 = network / never an HTTP response). errors.As to *SendError works. Direct Client().Emails calls are not wrapped. Average send latency is resend_send_duration_seconds_sum / resend_send_duration_seconds_count; the send error rate is rate(resend_emails_failed_total[5m]) / rate(resend_emails_sent_total[5m]) grouped by error_code.

The from label on the traffic counters is the actual sender of each email — the resolved req.From, or the configured default when the request leaves it empty. Sends are bucketed per sender at send time, so overrides and a from_address change never relabel history. Sends made directly through Client().Emails (bypassing Send) are attributed to from="unknown". Before any traffic, the counters appear at zero for the configured default sender.

Tests

Unit tests cover config layering, the Init contract, reload last-good, and health/metrics transitions using a stub http.RoundTripper — no external service.

License

Apache License 2.0 — see LICENSE.

Documentation

Index

Constants

View Source
const (
	// ComponentName is the framework component name for the resend component.
	// It is the identifier other components use in GetDependencies to require
	// resend.
	ComponentName = "resend"

	// ComponentStage is the stage data-layer components initialize in. It is
	// not a built-in bootstrap stage; AddComponent registers it automatically
	// the first time a component declares it.
	ComponentStage = cf.Stage("data")
)

Variables

This section is empty.

Functions

func HTTPStatus added in v0.0.6

func HTTPStatus(err error) int

HTTPStatus returns the Resend 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 CFResend

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

CFResend is the caerus-framework-resend component. It wraps the Resend SDK client and hands out live accessors (Client, From) to peers.

func New

func New(opts ...Option) *CFResend

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

func (*CFResend) BaseURL

func (c *CFResend) BaseURL() string

BaseURL returns the configured Resend API endpoint (empty = the SDK default).

func (*CFResend) Client

func (c *CFResend) Client() *resend.Client

Client returns the live Resend SDK client. It is non-nil after a successful Init and nil before Init or after Shutdown. Call it per use rather than caching the pointer; the component swaps the client on config reload.

func (*CFResend) From

func (c *CFResend) From() string

From returns the configured soft-default sender (from_address / WithFromAddress). Empty means every Send must set Mail.From.

func (*CFResend) GetDependencies

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

GetDependencies implements cf.Dependencies. The component logs through the framework logs component, and depends on configuration when WithConfigSource is set.

func (*CFResend) GetInitOrderStage

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

GetInitOrderStage implements cf.CaerusComponent.

func (*CFResend) Health

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

Health implements cf.HealthProvider. Resend exposes no liveness endpoint, so health reflects that a client is initialized (nil before Init or after Shutdown). Connectivity is verified on each Send error.

func (*CFResend) Init

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

Init implements cf.CaerusComponent. It builds the Resend client from the option-set or configuration-source credentials. An empty API key fails startup (fail-fast) so a misconfigured mailer never silently swallows sends.

func (*CFResend) Metrics

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

Metrics implements cf_observability.MetricsProvider. It reports the resend client's identity, per-sender send traffic and latency; before Init or after Shutdown it returns nil, so the observability component skips it (lazy pickup).

resend_info is a snapshot descriptor gauge (value 1) carrying the live configured sender identity; it is the "component is initialized" marker. Traffic counters are bucketed by the actual sender of each email (the resolved req.From, or the configured default), so the from label is accurate per send and a from_address change never relabels history.

func (*CFResend) Name

func (c *CFResend) Name() string

Name implements cf.CaerusComponent. Returns the custom name set via WithName, or the default ComponentName ("resend") if no custom name was set.

func (*CFResend) OnConfigReload

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

OnConfigReload implements cf.ConfigReloader. It rebuilds the client from the bound configuration source. The fresh value is delivered as cfg but the client is rebuilt from the source so the translation stays in one place. On failure the previous client is kept.

func (*CFResend) RegisterConfigSources

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

RegisterConfigSources implements cf.ConfigSourceRegistrar. The framework calls it during argv absorption; it registers this component's configuration source (name, path, env prefix, format, Owner) with the configuration component. No-op when no source is bound.

func (*CFResend) Send

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

Send sends m through Resend and returns the provider message 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 (*CFResend) Shutdown

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

Shutdown implements cf.CaerusComponent. It unsubscribes the logs subscription and releases the client. Further use of Client() after shutdown returns nil.

type Mail added in v0.0.6

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 Resend metadata (name → value). Empty names are skipped.
	Tags map[string]string
	// IdempotencyKey is sent as the Idempotency-Key header when non-empty.
	IdempotencyKey string
}

Mail is the Caerus send DTO (SES-simple shape). Apps pass this to Send without importing resend-go. Attachments, Cc/Bcc, headers, and scheduled send stay on Client() for callers that opt into the SDK.

type Option

type Option func(*options)

Option configures the resend component at construction time.

func WithAPIKey

func WithAPIKey(apiKey string) Option

WithAPIKey sets the Resend API key directly (tests, embedded use). Prefer WithConfigSource for production so the key rotates via config reload.

func WithBaseURL

func WithBaseURL(baseURL string) Option

WithBaseURL overrides the Resend API endpoint. Empty uses the SDK default.

func WithConfig

func WithConfig(cfg ResendConfig) 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). The module owns the Source: the config type, the default EnvPrefix and its Owner (Name(), so named instances reload correctly). main only points the instance at where the config lives.

cf_resend.New(cf_resend.WithConfigSource("resend", "config/resend.json"))
cf_resend.New(cf_resend.WithConfigSource("mailer", "/etc/app/mailer.yaml",
    cf_resend.WithSourceFormat(cf_configuration.FormatYAML)))

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. Both empty, or a value that does not parse as an email address, is an error.

func WithHTTPClient

func WithHTTPClient(hc *http.Client) Option

WithHTTPClient overrides the HTTP client used for Resend calls. Useful for tests (a stub RoundTripper) and for sharing a process-wide transport. 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 (declared in GetDependencies); WithLogger is an explicit override for tests and embedded use and wins over the framework logger. slog.Default() remains the fallback only when neither is available.

func WithName

func WithName(name string) Option

WithName sets a custom component name, allowing multiple resend instances in the same process. The default name is "resend" (ComponentName). Use this when you need several mail senders (e.g. branded vs system) in one binary. Retrieve named instances with GetByName[*CFResend](fw, "mailer").

func WithTimeout

func WithTimeout(d time.Duration) Option

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

type ResendConfig

type ResendConfig struct {
	// APIKey is the Resend API key (resend.com, or a self-hosted instance).
	APIKey string `json:"api_key" yaml:"api_key" env:"API_KEY" secret:"redact"`
	// FromAddress is the soft-default sender (e.g. "noreply@example.com"
	// or `Name <noreply@example.com>`). Send uses it when Mail.From is empty.
	FromAddress string `json:"from_address" yaml:"from_address" env:"FROM_ADDRESS"`
	// BaseURL overrides the Resend API endpoint. Empty uses the SDK default.
	// Useful for self-hosted instances or tests that stub the API.
	BaseURL string `json:"base_url,omitempty" yaml:"base_url,omitempty" env:"BASE_URL"`
	// TimeoutSec bounds each Resend HTTP call (default 10s).
	TimeoutSec float64 `json:"timeout_sec,omitempty" yaml:"timeout_sec,omitempty" env:"TIMEOUT_SEC"`
}

ResendConfig is the file/env-drivable configuration. Load it through the configuration component (caerus-framework-configuration) and pass it via WithConfigSource; both JSON and YAML tags are provided.

type SendError added in v0.0.6

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

SendError is returned by Send when the Resend HTTP call failed. The SDK error string does not carry a status code; HTTPStatus is filled from the component transport (the same source as resend_emails_failed_total).

Status 0 means a transport/network failure or an unknown status. Client() bypass (Emails.Send without going through Send) is not wrapped.

func (*SendError) Error added in v0.0.6

func (e *SendError) Error() string

func (*SendError) HTTPStatus added in v0.0.6

func (e *SendError) HTTPStatus() int

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

func (*SendError) Unwrap added in v0.0.6

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).

Jump to

Keyboard shortcuts

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