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
- Variables
- func Bindings(c *ent.Contact, vars map[string]any) map[string]any
- func Detail(reason string) string
- func FindByProviderMessageID(ctx context.Context, s *ent.Scoped, providerMessageID string) (*ent.OutboundMessage, error)
- func StampDelivery(ctx context.Context, s *ent.Scoped, id int64, result DeliveryResult, ...) error
- type Asker
- type DeliveryResult
- type Module
- func (m *Module) AsSystem(s *ent.Scoped) *ent.Scoped
- func (m *Module) FailRecipient(ctx context.Context, s *ent.Scoped, rec *ent.BroadcastRecipient, cause error) (Result, error)
- func (m *Module) ListTransactional(ctx context.Context, s *ent.Scoped, p pagination.Params) (pagination.Page[*ent.OutboundMessage], error)
- func (m *Module) MarkFailed(ctx context.Context, s *ent.Scoped, key string, cause error) error
- func (m *Module) Preflight(ctx context.Context, s *ent.Scoped, fromEmail string, integrationID *int64) (hold string, err error)
- func (m *Module) Send(ctx context.Context, s *ent.Scoped, req Request) (Result, error)
- func (m *Module) SendBroadcastTest(ctx context.Context, s *ent.Scoped, b *ent.Broadcast, to string, asker *Asker) TestReport
- func (m *Module) SendTest(ctx context.Context, s *ent.Scoped, req TestRequest) (Result, error)
- type Option
- type Outcome
- type Ref
- type Request
- type Result
- type Retry
- type Senders
- type TestReport
- type TestRequest
- type TestSendBudget
- type TransactionalMessage
Constants ¶
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.
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.
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).
const DefaultLease = 5 * time.Minute
DefaultLease is how long a pending claim blocks other attempts before a retry may take it over.
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).
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.
const TestSubjectPrefix = "[Test] "
TestSubjectPrefix marks a preview send so it is never mistaken for the real mailing.
Variables ¶
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.
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 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 ¶
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 ¶
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.
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 ¶
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.