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

outbound

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: 29 Imported by: 0

Documentation

Overview

Package outbound is the one place an email leaves sphericon on a Workspace's behalf (ADR 0015). Every surface — Broadcast, Automation step, Transactional — hands it a Request and gets back a typed Outcome; none re-implements any part of what lies between "this message is wanted" and "the provider accepted it":

  • replay: an already-recorded send returns its recorded Outcome (idempotency);
  • holds: Workspace freeze, no Integration, unverified Sending domain — reversible, per-source, never consumed as a per-recipient failure;
  • claim: the Outbound message row is taken before the provider is called, under the Request's idempotency key, with a lease against racing retries;
  • Send-eligibility, per message, fail-closed (a Skipped is final for that destination);
  • render, the unsubscribe footer and RFC 8058 headers (marketing only), DKIM signing (inside the provider adapter), the provider call;
  • the record of the result and the email.sent Event, in one transaction.

The provider is the seam: messaging.EmailSender has an SMTP and an SES adapter in production and an in-memory one in tests.

Index

Constants

View Source
const (
	DeferralBackoff = 30 * time.Second
	QuotaBackoff    = time.Hour
)

Backoffs for a provider reply that says it is busy (ADR 0023). The core does not auto-tune the Send rate limit; it only waits. A daily quota refills gradually, so its wait is long.

View Source
const (
	HoldNoIntegration    = "no_integration"
	HoldUnverifiedDomain = "unverified_domain"
)

Reasons an Outbound send is Held by its own source-level checks. The Workspace-level reasons (suspended, pending deletion, Staff hold) are declared by the holds package.

View Source
const (
	// RefuseOtherRecipient: the Workspace has no verified Sending domain, so a Test send
	// may only go to the address of the User who asks.
	RefuseOtherRecipient = "other_recipient"
	// RefuseNoSystemSender: the Instance has no outbound mail, so there is no System sender
	// (or one that names no From to send from).
	RefuseNoSystemSender = "no_system_sender"
)

Reasons a Test send from the System sender is Refused (ADR 0041).

View Source
const DefaultLease = 5 * time.Minute

DefaultLease is how long a pending claim blocks other attempts before a retry may take it over.

View Source
const HoldBackoff = 15 * time.Minute

HoldBackoff is how long a Hold waits before asking again. A Hold (Workspace suspension, an unverified Sending domain, no Integration) is reversible and not the recipient's fault, so the caller waits without spending an attempt (ADR 0015).

View Source
const SkipContactErased = "contact_erased"

SkipContactErased is the Skipped reason of a send whose Contact was erased after the send was queued (ADR 0021). Nothing is recorded for it: the message row would carry the erased person's address.

View Source
const TestSubjectPrefix = "[Test] "

TestSubjectPrefix marks a preview send so it is never mistaken for the real mailing.

Variables

View Source
var ErrInProgress = errors.New("outbound: a send with this key is already in progress")

ErrInProgress means another attempt holds a live claim on this idempotency key. It is retryable; a stale claim (older than the lease) is taken over instead.

View Source
var ErrNoTracker = errors.New("outbound: marketing send requires an unsubscribe tracker")

ErrNoTracker means a marketing send (a Request with a Source) was attempted without the Tracker that builds its unsubscribe link. A marketing message must never leave without one (ADR 0012), so this is a configuration error, never a silent downgrade.

Functions

func Bindings

func Bindings(c *ent.Contact, vars map[string]any) map[string]any

Bindings builds the Liquid merge-tag context: the Contact's custom fields first, then its core fields (so a custom field can never shadow email/first_name/ last_name), then the Request's own variables, which win.

func Detail

func Detail(reason string) string

Detail words a Hold or Refuse reason for a human (an API problem detail). The single place the wording lives, so every surface that reports a Held or Refused outcome agrees.

func FindByProviderMessageID

func FindByProviderMessageID(ctx context.Context, s *ent.Scoped, providerMessageID string) (*ent.OutboundMessage, error)

FindByProviderMessageID is the Outbound message a Delivery notice names by the provider's message id (ADR 0053), nil when there is none: the notice then stays at the level of the address. Several messages with the same id (a provider that reuses ids) resolve to the newest.

func StampDelivery

func StampDelivery(ctx context.Context, s *ent.Scoped, id int64, result DeliveryResult, at time.Time) error

StampDelivery sets the message's delivery result of the given kind to at, only while it is empty: the first notice of each kind stamps once and a stamp is never replaced or cleared, so a redelivered notice changes nothing and an old report does not shrink (ADR 0053).

Types

type Asker

type Asker struct {
	UserID int64
	// Email is the User's own address.
	Email string
}

Asker is the User who asks for a Test send. Only they may receive one from the System sender (ADR 0041).

type DeliveryResult

type DeliveryResult string

DeliveryResult is one of the stamps of a message's delivery result.

const (
	ResultDelivered  DeliveryResult = "delivered"
	ResultBounced    DeliveryResult = "bounced"
	ResultComplained DeliveryResult = "complained"
)

type Module

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

Module is the Outbound send module.

func New

func New(bus *events.Bus, hs *holds.Holds, senders Senders, tracker *tracking.Tracker, opts ...Option) *Module

New builds the module. tracker may be nil only if no marketing Request is sent.

func (*Module) AsSystem

func (m *Module) AsSystem(s *ent.Scoped) *ent.Scoped

AsSystem returns s acting as the `system` actor (ADR 0022), for a job or automation step that changes an audited entity in the normal course of work.

func (*Module) FailRecipient

func (m *Module) FailRecipient(ctx context.Context, s *ent.Scoped, rec *ent.BroadcastRecipient, cause error) (Result, error)

FailRecipient ends a Broadcast recipient's send as Failed with cause, for a recipient that can never go out: its Contact has no address, or its last attempt failed. Every recipient ends with exactly one Outbound message (ADR 0015), so this records one even when no attempt ever claimed the key. It is idempotent: a message already final is replayed, not rewritten (a send that reached the provider stays Sent).

A live claim is another attempt that may still reach the provider, so nothing is failed and the error is ErrInProgress: the caller waits out the lease and asks again.

func (*Module) ListTransactional

func (m *Module) ListTransactional(ctx context.Context, s *ent.Scoped, p pagination.Params) (pagination.Page[*ent.OutboundMessage], error)

ListTransactional returns one page of the Workspace's Transactional Outbound messages, most recent first.

func (*Module) MarkFailed

func (m *Module) MarkFailed(ctx context.Context, s *ent.Scoped, key string, cause error) error

MarkFailed gives up on a pending claim for good (a queue worker calls it when the final retry is spent), recording cause. It is a no-op for a message that already reached a final status or whose claim another attempt currently holds.

func (*Module) Preflight

func (m *Module) Preflight(ctx context.Context, s *ent.Scoped, fromEmail string, integrationID *int64) (hold string, err error)

Preflight reports whether a Workspace could send right now from fromEmail ("" uses the Integration's configured sender) through the Integration integrationID names (nil is the default), without sending or recording anything. Planning code (Broadcast) calls it to fail or pause fast instead of finding out per recipient; the answer is the same gate Send applies.

func (*Module) Send

func (m *Module) Send(ctx context.Context, s *ent.Scoped, req Request) (Result, error)

Send performs one Outbound send. A returned error is a retryable infrastructure failure (provider unreachable, database error, ErrInProgress); every decided outcome — including Skipped, Failed and Held — is a Result with a nil error.

func (*Module) SendBroadcastTest

func (m *Module) SendBroadcastTest(ctx context.Context, s *ent.Scoped, b *ent.Broadcast, to string, asker *Asker) TestReport

SendBroadcastTest sends the Broadcast to one address with sample merge data for an author to preview it. The report's Detail is "" when the message was sent, otherwise the human-readable reason it was not (a send error, a Hold, a refusal or content that did not render), so every surface words a refused preview the same way.

func (*Module) SendTest

func (m *Module) SendTest(ctx context.Context, s *ent.Scoped, req TestRequest) (Result, error)

SendTest sends one message for an author to preview. The author names the address, so it skips Send-eligibility and carries no unsubscribe or tracking, and it records no Outbound message and no email.sent Event — but it still passes the Workspace freeze and the Sending-domain gate and is DKIM-signed like any send: a suspended Workspace sends nothing, test or not (ADR 0015). Outcomes are Sent, Failed (the content did not render) or Held; there is no replay.

With an Asker, no verified Sending domain in the Workspace and no default Integration that delivers nowhere outside (a Test inbox) it goes from the System sender instead (ADR 0041, ADR 0058): after the same freeze check, only to the Asker's own address, within the per-User and per-Workspace caps, and Refused when the Instance has no System sender. A Test inbox takes the Test send itself and the Result is Captured. The gate stays here: no other module decides which sender a Test send uses.

type Option

type Option func(*Module)

Option customizes a Module.

func WithLease

func WithLease(d time.Duration) Option

WithLease overrides DefaultLease (tests use a tiny lease to exercise takeover).

func WithSystemSender

func WithSystemSender(s messaging.EmailSender) Option

WithSystemSender gives the module the Instance's System sender, which a Test send falls back to while the Workspace has no verified Sending domain (ADR 0041).

func WithTestSendBudget

func WithTestSendBudget(b TestSendBudget) Option

WithTestSendBudget caps the Test sends made from the System sender.

func WithTrackingDomains

func WithTrackingDomains() Option

WithTrackingDomains turns on Tracking domains (SPHERICON_TRACKING_CNAME_TARGET is set): a message from a Sending domain whose Tracking domain is serving gets its links on track.<domain>. Off, every link uses the Instance's host (ADR 0038).

type Outcome

type Outcome string

Outcome is what an Outbound send did. Sent, Skipped and Failed are final for the destination; Held and Deferral are reversible per-source Outcomes — nothing was consumed, the caller should try the same Request again later (a Held after its own delay, a Deferral after Result.Wait).

const (
	// Sent: the provider accepted the message (not "delivered" — see GLOSSARY).
	Sent Outcome = "sent"
	// Skipped: Send-eligibility forbids this destination. Reason is an eligibility.Reason*.
	Skipped Outcome = "skipped"
	// Failed: this one message can never be sent (e.g. its template failed to render).
	Failed Outcome = "failed"
	// Held: the source cannot send right now. Reason is a Hold* constant.
	Held Outcome = "held"
	// Deferral: the Integration's Send rate limit is spent (ADR 0023). Nothing is wrong
	// with the source, it is busy: no hold reason, no claim, no attempt consumed.
	// Result.Wait says when capacity returns.
	Deferral Outcome = "deferral"
	// Refused: a Test send the System sender will not make (ADR 0041). Reason is a Refuse*
	// constant; unlike a Held it does not clear by waiting.
	Refused Outcome = "refused"
)

type Ref

type Ref struct {
	BroadcastID        int64
	BroadcastRecipient int64
	AutomationID       int64
	AutomationRunID    int64
	AutomationStep     *int
	TemplateID         int64
}

Ref ties a Request to the surface's own records. They are stored on the Outbound message as plain ids and stamped on the email.sent Event.

type Request

type Request struct {
	Kind outboundmessage.Kind
	// Key is unique per logical send: a retry of the same send repeats it. Required.
	Key string

	// Destination is the email address. Contact, when the destination resolves to
	// one, supplies merge tags and attribution; ContactID alone attributes only.
	Destination string
	Contact     *ent.Contact
	ContactID   int64

	// Source is the unsubscribe Sending source ("broadcasts", "automation:<id>");
	// "" is a Transactional send (Suppression only, no unsubscribe).
	Source string

	Subject string
	Body    string // HTML
	// Variables are extra merge tags; they win over Contact fields.
	Variables map[string]any

	// FromEmail/FromName override the Integration's configured sender.
	FromEmail string
	FromName  string

	// IntegrationID is the Integration the message goes through; nil is the Workspace's
	// default. One that cannot send holds the message (no_integration): it never falls
	// back to the default.
	IntegrationID *int64

	// Track turns on open/click tracking for this message: the tracking token names the
	// Outbound message (ADR 0053).
	Track bool

	Ref Ref
}

Request describes one email to send. Content is the already-bound Message content (a marketing surface passes its own copy, Transactional the live Template's current content — see ADR 0003, 0005); the module never looks it up.

func AutomationStep

func AutomationStep(a *ent.Automation, run *ent.AutomationRun, step int, c *ent.Contact, subject, body string) Request

AutomationStep is the Request for the email at index step of an Automation run. The Contact must have an email address. Each Automation is its own unsubscribe Sending source; the unsubscribe click exits the enrollment (ADR 0001).

func BroadcastRecipient

func BroadcastRecipient(b *ent.Broadcast, rec *ent.BroadcastRecipient, c *ent.Contact) Request

BroadcastRecipient is the Request for one Broadcast recipient's email. The Contact must have an email address.

func Transactional

func Transactional(m TransactionalMessage) Request

Transactional is the Request for a Transactional message (no unsubscribe Source).

type Result

type Result struct {
	// Refusal is, for a Deferral, how long until capacity returns (Wait) and, for a Test
	// send Deferred by the System sender's caps, the cap that was hit (Limit).
	ratelimit.Refusal

	Outcome Outcome
	// Reason explains Skipped (eligibility reason), Failed (cause) and Held (hold).
	Reason string
	// MessageID is the Outbound message row; zero when Held (nothing was recorded).
	MessageID int64
	// Replayed is true when the Outcome was read back from an earlier attempt.
	Replayed bool
	// From is, for a Test send that was Sent, the From address it went out from.
	From string
	// Captured is true for a Test send that went through a sender that delivers nowhere
	// outside: the message was kept, not sent to the address named (ADR 0058).
	Captured bool
}

Result is the outcome of a Send.

func (Result) Retry

func (r Result) Retry(backlog int) Retry

Retry is the retry policy for this Result. backlog is how many sends of the same source are awake and ahead of this one (each needs a token of its own first; 0 for a source with no queue). The jitter of a Deferral is drawn here, the one place it is used.

A Deferral waits the wait until capacity returns scaled by the backlog, floored at deferralFloor and capped at deferralCeiling, plus up to deferralJitter of random spread. A Hold waits HoldBackoff.

type Retry

type Retry struct {
	// Wanted is true for a Hold and a Deferral, the reversible Outcomes; every other
	// Outcome is final.
	Wanted bool
	// After is how long to wait before asking Outbound send again; zero when not Wanted.
	After time.Duration
}

Retry says whether an Outbound Outcome wants another attempt and after how long. It is the one retry policy: a queue adapter turns After into its own wait (a River snooze, an Automation run's resume time) and keeps no delay of its own.

func HoldRetry

func HoldRetry() Retry

HoldRetry is the retry of a Hold: HoldBackoff, whatever the source.

type Senders

type Senders interface {
	EmailSender(ctx context.Context, s *ent.Scoped, integ *ent.Integration) (messaging.EmailSender, error)
}

Senders builds the email provider adapter for an Integration the caller already resolved (messaging.EmailIntegration); a nil one is ErrNoProvider. *messaging.Resolver satisfies it.

type TestReport

type TestReport struct {
	Result

	Detail string
}

TestReport is what a Broadcast's Test send did, worded for a human: the Result, plus Detail, "" when the message was sent and otherwise why it was not.

type TestRequest

type TestRequest struct {
	To        string
	Subject   string
	Body      string // HTML
	Variables map[string]any
	FromEmail string
	FromName  string
	// Asker is the User who asks. Set, it lets a Workspace with no verified Sending domain
	// send from the System sender, to Asker.Email only (ADR 0041). Nil (an API token) keeps
	// the Sending-domain gate for every Test send.
	Asker *Asker
}

TestRequest is a user-initiated test send of a message to an address the author names explicitly (e.g. a Broadcast preview).

type TestSendBudget

type TestSendBudget interface {
	TakeTestSend(ctx context.Context, userID, workspaceID int64) (ratelimit.Refusal, error)
}

TestSendBudget meters Test sends from the System sender, per User and per Workspace (ADR 0041, ADR 0025). TakeTestSend reserves one slot of each and returns the zero Refusal when it was granted; otherwise the Refusal says how long until a slot frees and which cap was hit.

type TransactionalMessage

type TransactionalMessage struct {
	ClientKey   string
	Destination string
	ContactID   int64
	TemplateID  int64
	Subject     string
	Body        string
	Variables   map[string]any
	// Track opts the send into open and click tracking; it stays Transactional (ADR 0053).
	Track bool
}

TransactionalMessage is what a Transactional send is made of. ClientKey is the caller's own idempotency token: a repeated one replays the recorded send. Empty, the send is its own every time.

Jump to

Keyboard shortcuts

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