Help us shape the future of Go! Take the 2026 Go Developer Survey

messaging

package
v0.3.5 Latest Latest
Warning

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

Go to latest
Published: Oct 27, 2026 License: AGPL-3.0 Imports: 13 Imported by: 0

Documentation

Overview

Package messaging is the channel-agnostic management layer for workspace sending providers. It defines the per-channel send contracts (EmailSender today; SmsSender later), a provider catalog that maps a (channel, provider) pair to its validation + construction logic, and a resolver that turns a workspace's stored integration into a ready-to-use sender.

The split is deliberate: storage, encryption, CRUD, default-selection and the catalog are generic across channels, while the send interface is channel specific. Adding a provider = register a descriptor + an impl. Adding a channel = a new send interface + descriptors, reusing all of the above.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrBusy: the provider says we are sending too fast (SES Throttling, an SMTP
	// "too many messages" / "rate limit" 4xx reply). Back off briefly.
	ErrBusy = errors.New("provider is busy: sending too fast")
	// ErrQuotaExceeded: the provider's daily (24 hour) sending quota is spent. Capacity
	// returns gradually, so the backoff is long.
	ErrQuotaExceeded = errors.New("provider daily sending quota exceeded")
)

Provider replies that say the provider is busy, not that the message is bad (ADR 0023). An adapter wraps one of these around the provider's own error; the Outbound send turns it into a Deferral instead of a retryable failure.

View Source
var ErrNoProvider = errors.New("no default provider configured")

ErrNoProvider is returned when a workspace has no enabled default provider for the requested channel.

View Source
var ErrUnverifiedSendingDomain = errors.New("sender domain is not a verified sending domain")

ErrUnverifiedSendingDomain is returned by the send path when a workspace message's From domain is not a verified sending domain (ADR 0010 slice 3): a verified domain is required to send. Callers translate it — RFC 7807 on the sync transactional API, a failed recipient/run on the async broadcast and automation surfaces.

Functions

func BuildMIME

func BuildMIME(msg EmailMessage) (*mail.Msg, error)

BuildMIME turns an EmailMessage into a go-mail *mail.Msg. It is the single MIME builder shared by every email provider (smtp sends it directly; ses serializes it to raw bytes for SendRawEmail), so message structure — multipart/alternative, headers, encoding — is identical across providers.

From/FromName must already be resolved (msg-level value or provider default) by the caller. When both HTML and Text are present the message is multipart/alternative with text/plain first and text/html as the richer alternative (clients render the last part they understand). A single body is sent as-is.

func BuildSignedMIME

func BuildSignedMIME(ctx context.Context, msg EmailMessage, signer Signer) (*mail.Msg, error)

BuildSignedMIME builds the message via BuildMIME and attaches a native DKIM signer for msg.From's verified sending domain (ADR 0010). go-mail signs during WriteTo, so the same Msg signs identically whether the provider writes it directly (SES raw bytes) or through the SMTP DATA stream.

The gate (slice 3): when signer is non-nil (a workspace send) and From's domain is not a verified sending domain, the send is rejected with ErrUnverifiedSendingDomain — a verified domain is required to send. A nil signer (platform/system mail) is exempt and always sends unsigned.

func ClassifyReply

func ClassifyReply(text string) error

ClassifyReply tells whether a provider reply is a "too fast" or "quota exceeded" one. text is the provider's message. It returns ErrQuotaExceeded, ErrBusy, or nil when the reply says nothing of the kind. Providers differ only in how they phrase it, so the wording heuristics live in one place; each adapter decides whether the reply is a transient one before asking.

func DefaultEmailIntegration

func DefaultEmailIntegration(ctx context.Context, s *ent.Scoped) (*ent.Integration, error)

DefaultEmailIntegration is the Workspace's default, enabled email Integration: the one Outbound send goes through. It returns ErrNoProvider when there is none.

func DeliversOutside

func DeliversOutside(sender EmailSender) bool

DeliversOutside reports whether sender's provider delivers outside, looking through the metrics decorator Catalog.BuildEmail wraps every sender in. A sender that says nothing delivers outside.

func DomainOf

func DomainOf(email string) string

DomainOf returns the lower-cased domain of an email address, or "" when there is no single "@".

func EmailIntegration

func EmailIntegration(ctx context.Context, s *ent.Scoped, id *int64) (*ent.Integration, error)

EmailIntegration is the enabled email Integration a send goes through: the one named by id, or the Workspace's default when id is nil. A named Integration that is disabled or discarded is ErrNoProvider, never the default in its place.

func FormatSender

func FormatSender(addr, name string) string

FormatSender renders an email sender as "Name <addr>" when a display name is present, otherwise just the address.

func HasVerifiedSendingDomain

func HasVerifiedSendingDomain(ctx context.Context, s *ent.Scoped, fromEmail string) (bool, error)

HasVerifiedSendingDomain reports whether fromEmail's domain is a verified sending domain in the workspace. It is the plan-time counterpart to the send-time gate in BuildSignedMIME, letting callers (e.g. broadcast planning) reject up front instead of failing every recipient. An empty/malformed fromEmail reports false.

func IntegrationKept

func IntegrationKept(ctx context.Context, s *ent.Scoped, id int64) (bool, error)

IntegrationKept reports whether the Workspace has a kept Integration with the id: new work may name it. An archived one leaves new work (ADR 0049), though work already naming it keeps it as history, and one of another Workspace is not there. It says nothing of whether the Integration can send: a send checks that itself (EmailIntegration).

func RecordSendOutcome

func RecordSendOutcome(ctx context.Context, provider Provider, status SendStatus)

RecordSendOutcome counts one send outcome for a provider on the global meter provider (a no-op when telemetry is disabled).

Types

type Captured

type Captured struct {
	Message   EmailMessage
	MessageID string
	Headers   []Header
	Links     []string
}

Captured is a message as a provider that delivers nowhere outside keeps it: the message, the Message-ID it gave it, the header lines it would carry on the wire in order, and the URLs of the links of its HTML part.

type Capturer

type Capturer interface {
	Capture(ctx context.Context, c Captured) error
}

Capturer keeps a message a provider accepted instead of sending it out. It is bound to one Workspace and one Integration.

type Catalog

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

Catalog holds the registered provider descriptors keyed by (channel, provider).

func NewCatalog

func NewCatalog(descriptors ...ProviderDescriptor) *Catalog

NewCatalog builds a catalog from the given descriptors.

func (*Catalog) BuildEmail

func (c *Catalog) BuildEmail(provider Provider, config []byte, deps Deps) (EmailSender, error)

BuildEmail builds an EmailSender for an email-channel provider from deps.

func (*Catalog) ChannelOf

func (c *Catalog) ChannelOf(provider Provider) (Channel, bool)

ChannelOf reports which channel a provider belongs to; ok is false for an unregistered provider.

func (*Catalog) Descriptor

func (c *Catalog) Descriptor(channel Channel, provider Provider) (ProviderDescriptor, bool)

Descriptor looks up a registered provider; ok is false for unknown pairs.

func (*Catalog) Validate

func (c *Catalog) Validate(channel Channel, provider Provider, config []byte) error

Validate runs the provider's semantic validation, or reports an unknown provider.

type Channel

type Channel string

Channel identifies a delivery medium. Values mirror the ent Integration.channel enum.

const (
	ChannelEmail Channel = "email"
	// ChannelSMS is reserved; no SMS provider is implemented yet.
	ChannelSMS Channel = "sms"
)

type DefaultFromer

type DefaultFromer interface {
	DefaultFrom() (addr, name string)
}

DefaultFromer is implemented by senders that fall back to the integration's configured From when a message carries none. Outbound send reads it to know the effective From domain before sending, so the verified-domain gate and the Sending-domain stamp use the address that will really go on the wire.

type Deps

type Deps struct {
	// Signer DKIM-signs outbound mail per ADR 0010; may be nil.
	Signer Signer
	// Capturer keeps the messages of a provider that delivers nowhere outside (the
	// Test inbox); may be nil, and a provider that delivers outside ignores it.
	Capturer Capturer
}

Deps is what the catalog hands every provider builder, bound to one Workspace's scoped handle (zero fields when a sender is built outside a send, e.g. for a quota lookup).

type EmailMessage

type EmailMessage struct {
	From     string
	FromName string
	To       string
	Subject  string
	HTML     string
	Text     string
	// ReplyTo, when set, is the address a reply goes to instead of From. A provider fills it
	// from its configured default when the message carries none.
	ReplyTo string
	// ListUnsubscribeURL, when set, emits the RFC 8058 one-click unsubscribe
	// headers (List-Unsubscribe + List-Unsubscribe-Post). Set only on marketing
	// surfaces (Broadcast, Automation); never on Transactional (ADR 0012).
	ListUnsubscribeURL string
	// Origin says which send the message belongs to. A provider that delivers outside ignores
	// it; the Test inbox records it on the Captured message. Nil for a message with no send behind it.
	Origin *Origin
}

EmailMessage is the channel-specific payload for email sends. HTML is used when set, otherwise Text (the provider libraries take a single body).

type EmailSender

type EmailSender interface {
	Send(ctx context.Context, msg EmailMessage) (Receipt, error)
}

EmailSender is implemented by every email provider (smtp, ses, …).

type Header struct {
	Name  string
	Value string
}

Header is one header line of a message.

type Origin

type Origin struct {
	OutboundMessageID int64
	Surface           Surface
	Source            string
}

Origin is where a message came from: the Outbound message of its send (zero for a Test send, which records none), the surface that asked for it and, for a marketing send, its Sending source. A message with no Origin is a Test send.

type OutsideDeliverer

type OutsideDeliverer interface {
	DeliversOutside() bool
}

OutsideDeliverer is implemented by a sender that says whether its provider delivers outside sphericon. A sender that does not implement it delivers outside (SMTP, SES). Outbound send reads it through DeliversOutside, never by the provider's name: the Sending-domain gate and DKIM signing apply only to a sender that delivers outside.

type Provider

type Provider string

Provider identifies a concrete provider implementation. Values mirror the ent Integration.provider enum.

const (
	ProviderSMTP Provider = "smtp"
	ProviderSES  Provider = "ses"
	// ProviderInbox is the Test inbox: it keeps what it is sent as Captured messages and
	// delivers nothing outside (ADR 0058).
	ProviderInbox Provider = "inbox"
)

type ProviderDescriptor

type ProviderDescriptor struct {
	Channel  Channel
	Provider Provider

	// Validate runs semantic checks on a decrypted, structurally-valid config
	// blob (the typed API union already enforces shape and required fields).
	Validate func(config []byte) error

	// Build constructs the live sender from a decrypted config blob and the
	// dependencies bound to the Workspace it sends for. A provider uses what it needs
	// and ignores the rest.
	Build func(config []byte, deps Deps) (any, error)
}

ProviderDescriptor is the extensibility seam: it teaches the catalog how to validate and build one provider. Build returns a channel-specific sender as `any`; callers type-assert to the channel interface (e.g. EmailSender).

type Quota

type Quota struct {
	PerSecond *int
	PerDay    *int
}

Quota is what a provider reports about its own sending limits (ADR 0023). A nil field is a window the provider does not bound.

type QuotaReader

type QuotaReader interface {
	SendQuota(ctx context.Context) (Quota, error)
}

QuotaReader is implemented by a sender whose provider can report its quota (SES GetSendQuota). Providers that cannot (SMTP) do not implement it and have no provider ceiling.

func AsQuotaReader

func AsQuotaReader(sender EmailSender) (QuotaReader, bool)

AsQuotaReader reports the QuotaReader behind sender, looking through the metrics decorator Catalog.BuildEmail wraps every sender in (a type assertion on the decorator alone would never see the provider's own interface).

type Receipt

type Receipt struct {
	// MessageID is the provider's id for the accepted message (SES MessageId, or the
	// MIME Message-ID for SMTP). Recorded on the Outbound message for correlation.
	MessageID string
	// DeliveryConfirmed is set by a provider that confirmed delivery when it accepted the
	// message (a Test inbox keeps it, so nothing later reports it). Recording the send then
	// stamps the delivery result. SMTP and SES never set it: their Delivery notices arrive
	// later from the provider.
	DeliveryConfirmed bool
}

Receipt is what a provider returns for a message it accepted.

type Resolver

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

Resolver turns a workspace's stored integration into a live sender. It is the only in-scope consumer of the catalog's Build path; the marketing send engine that actually dispatches campaigns is a separate, future component.

func NewResolver

func NewResolver(cipher *secrets.Cipher, catalog *Catalog, opts ...ResolverOption) *Resolver

NewResolver wires the resolver.

func (*Resolver) EmailSender

func (r *Resolver) EmailSender(_ context.Context, s *ent.Scoped, row *ent.Integration) (EmailSender, error)

EmailSender decrypts the config of row, an Integration the caller resolved with EmailIntegration, and builds a ready sender. Returns ErrNoProvider when row is nil.

type ResolverOption

type ResolverOption func(*Resolver)

ResolverOption configures a Resolver.

func WithCapturers

func WithCapturers(bind func(s *ent.Scoped, integrationID int64) Capturer) ResolverOption

WithCapturers binds the Capturer a provider builder receives to the Workspace's scoped handle and to the Integration the sender is built for.

type SendStatus

type SendStatus string

SendStatus is the outcome label of the email.send.outcomes counter. The set is closed: the counter carries only provider and status, never a tenant or recipient identifier (ADR 0018).

const (
	// SendAccepted: the provider accepted the message.
	SendAccepted SendStatus = "accepted"
	// SendError: the provider call failed.
	SendError SendStatus = "error"
	// SendBusy: the provider said we are sending too fast; the message is deferred
	// and retried, not failed (ADR 0023). Kept out of the error ratio.
	SendBusy SendStatus = "busy"
	// SendQuotaExceeded: the provider's daily quota is spent; the message is deferred
	// (ADR 0023). Kept out of the error ratio.
	SendQuotaExceeded SendStatus = "quota_exceeded"
	// SendBounce: the provider reported a bounce for a message it had accepted.
	SendBounce SendStatus = "bounce"
	// SendComplaint: the provider reported a spam complaint.
	SendComplaint SendStatus = "complaint"
)

type Signer

type Signer interface {
	// DKIMSigner returns a go-mail DKIM signer for mail sent from fromEmail, or
	// (nil, nil) when fromEmail's domain has no verified sending domain in scope
	// (slice 2 signs when possible; the send gate for unverified From is slice 3).
	DKIMSigner(ctx context.Context, fromEmail string) (*mail.DKIMSigner, error)
}

Signer resolves the native DKIM signer for an outbound message (ADR 0010). Signing is sphericon-native and transport-independent: the same signer applies identically across providers because both serialize the message via go-mail's WriteTo, which signs when a DKIM signer is set.

func NewDKIMSigner

func NewDKIMSigner(s *ent.Scoped, cipher *secrets.Cipher) Signer

NewDKIMSigner builds a Signer for the Workspace the scoped client is confined to.

type Surface

type Surface string

Surface is the part of the product that asked for a send. Its values are those of the Outbound message kinds (broadcast, automation, transactional), plus SurfaceTest.

const SurfaceTest Surface = "test"

SurfaceTest is a Test send of a Broadcast or a template, which has no Outbound message.

Directories

Path Synopsis
Package inbox implements the Test inbox provider (ADR 0058): it delivers nothing outside and keeps each message it is sent as a Captured message through the Capturer the catalog hands its builder.
Package inbox implements the Test inbox provider (ADR 0058): it delivers nothing outside and keeps each message it is sent as a Captured message through the Capturer the catalog hands its builder.
Package registry assembles the default provider catalog.
Package registry assembles the default provider catalog.
Package ses implements the Amazon SES email provider.
Package ses implements the Amazon SES email provider.
Package smtp implements the SMTP email provider on top of wneessen/go-mail.
Package smtp implements the SMTP email provider on top of wneessen/go-mail.

Jump to

Keyboard shortcuts

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