Documentation
¶
Overview ¶
Package broadcasts owns the Broadcast send/schedule state machine (ADR 0016): draft -> scheduled -> sending and clearing a schedule; the claim and the send job it needs are one transaction. Every surface that sends or schedules a Broadcast (the /site SPA today, /api and MCP next) calls it; none re-implements a transition. Handlers stay thin adapters: scope check, call, map errors.
Every Broadcast status write goes through the transition table in status.go (ADR 0046): a stateless table with a compare-and-swap write, so an illegal move is lifecycle.ErrInvalidTransition. Delivery owns the rest of the lifecycle: plan, pace, send one recipient through Outbound send (ADR 0015), give up, finalize. The queue adapter (river) only implements its Scheduler seam and decides how to retry.
Index ¶
- Constants
- Variables
- func CancelPending(ctx context.Context, s *ent.Scoped, contactID int64, purger events.QueuePurger) error
- func Due(ctx context.Context, s *ent.Scoped, id int64, jobScheduledAt *time.Time) (bool, error)
- func Finalize(ctx context.Context, s *ent.Scoped, broadcastID int64) error
- func IsRetryLater(err error) bool
- func Pending() predicate.BroadcastRecipient
- func Ratio(num, denom int) float32
- func RecordClick(ctx context.Context, s *ent.Scoped, recipientID int64) (bool, error)
- func RecordOpen(ctx context.Context, s *ent.Scoped, recipientID int64) (bool, error)
- func RecordUnsubscribe(ctx context.Context, s *ent.Scoped, broadcastID, contactID int64) (bool, error)
- func StatsOf(ctx context.Context, s *ent.Scoped, ids ...int64) (map[int64]Stats, error)
- type DeferredError
- type Delivery
- func (d *Delivery) GiveUp(ctx context.Context, s *ent.Scoped, recipientID int64, cause error) error
- func (d *Delivery) Pace(ctx context.Context, s *ent.Scoped, id int64, n int, now time.Time) ([]time.Time, error)
- func (d *Delivery) Plan(ctx context.Context, s *ent.Scoped, id int64) ([]int64, error)
- func (d *Delivery) Run(ctx context.Context, s *ent.Scoped, id int64, sched Scheduler, now time.Time) error
- func (d *Delivery) RunClaimed(ctx context.Context, s *ent.Scoped, id int64, sched Scheduler, now time.Time) error
- func (d *Delivery) SendRecipient(ctx context.Context, s *ent.Scoped, recipientID int64) error
- type Detail
- type Fields
- type HeldError
- type Module
- func (m *Module) Archive(ctx context.Context, s *ent.Scoped, id int64) error
- func (m *Module) Create(ctx context.Context, s *ent.Scoped, f Fields) (*ent.Broadcast, error)
- func (m *Module) DeleteDraft(ctx context.Context, s *ent.Scoped, id int64) error
- func (m *Module) Get(ctx context.Context, s *ent.Scoped, id int64) (*ent.Broadcast, error)
- func (m *Module) LinkClicksOf(ctx context.Context, s *ent.Scoped, id int64) ([]linkclicks.Clicks, error)
- func (m *Module) List(ctx context.Context, s *ent.Scoped, p pagination.Params, archived bool) (pagination.Page[*ent.Broadcast], error)
- func (m *Module) Report(ctx context.Context, s *ent.Scoped, id int64) (Report, error)
- func (m *Module) Schedule(ctx context.Context, s *ent.Scoped, id int64, when time.Time) (*ent.Broadcast, error)
- func (m *Module) Send(ctx context.Context, s *ent.Scoped, id int64) (*ent.Broadcast, error)
- func (m *Module) SetAudience(ctx context.Context, s *ent.Scoped, id int64, segmentID *int64) (*ent.Broadcast, error)
- func (m *Module) Unarchive(ctx context.Context, s *ent.Scoped, id int64) error
- func (m *Module) Unschedule(ctx context.Context, s *ent.Scoped, id int64) (*ent.Broadcast, error)
- func (m *Module) Update(ctx context.Context, s *ent.Scoped, id int64, f Fields) (*ent.Broadcast, error)
- type Progress
- type Report
- type Scheduler
- type Send
- type SendArgs
- type Stats
Constants ¶
const Queue = "broadcasts"
Queue is the river queue of the send jobs. A single blast fans out into one job per recipient; keeping them off the default queue stops a large broadcast from starving automation steps and welcome emails (the same isolation jobs.QueueWebhooks gives outbound webhooks).
Variables ¶
var ( // ErrNotFound: no such Broadcast in this Workspace. ErrNotFound = validation.Refuse("broadcasts: broadcast not found", "errors.broadcast_not_found") // ErrNotSendable: the Broadcast is past draft/scheduled (sending, sent, failed). ErrNotSendable = validation.Refuse("broadcasts: broadcast is not a draft or scheduled", "errors.broadcast_not_sendable") // ErrNotArchivable: only a sent or failed Broadcast is archived; a draft is deleted and a // scheduled or sending one is still work in flight. ErrNotArchivable = validation.Refuse("broadcasts: only a sent or failed broadcast can be archived", "errors.broadcast_not_archivable") // ErrNotScheduled: the Broadcast is not scheduled, so there is no schedule to clear. ErrNotScheduled = validation.Refuse("broadcasts: broadcast is not scheduled", "errors.broadcast_not_scheduled") )
var ( // ErrNotDraft: the Broadcast is past draft (scheduled, sending, sent, failed) and // can no longer be edited, re-targeted or deleted through the authoring surface. ErrNotDraft = validation.Refuse("broadcasts: broadcast is not a draft", "errors.broadcast_not_draft") // ErrSegmentNotFound: the audience Segment does not exist in this Workspace. ErrSegmentNotFound = validation.Refuse("broadcasts: segment not found", "errors.broadcast_segment_not_found") // ErrIntegrationNotFound: the Integration does not exist in this Workspace or is archived. ErrIntegrationNotFound = validation.Refuse("broadcasts: integration not found", "errors.broadcast_integration_not_found") // ErrBodyAndDoc: a write carries both a body and a document. A client writes one of them: the // document (the server builds the body from it) or the body (which clears the document). ErrBodyAndDoc = validation.Refuse("broadcasts: write a body or a doc, not both", "errors.body_and_doc") // ErrBlankName: the Broadcast name is empty or whitespace only. ErrBlankName = validation.Refuse("broadcasts: name must not be empty", "errors.name_empty") )
var ErrPlanFailed = errors.New("broadcasts: the plan failed the broadcast")
ErrPlanFailed marks a plan that failed the Broadcast for good (a template that does not render, a broken segment): the Broadcast is failed, and running it again cannot help.
Functions ¶
func CancelPending ¶
func CancelPending(ctx context.Context, s *ent.Scoped, contactID int64, purger events.QueuePurger) error
CancelPending cancels a Contact's pending Broadcast work (Erasure, ADR 0021): the recipients whose send has not ended are removed with their queued send jobs, and each Broadcast left with nothing pending settles. A recipient whose send ended stays as an anonymous record. It runs inside the caller's transaction (s and purger come from the same one); a send job that is already running is not purged, and finds its recipient gone when it starts.
func Due ¶
Due reports whether a queued send job should still run. A Broadcast that is back in draft was unscheduled after the job was queued. A delayed job (jobScheduledAt set) is also bound to the schedule it was enqueued for: unscheduling leaves it queued, so after a reschedule the Broadcast carries a different scheduled_at (or none, once sent now) and the superseded job must not send it early.
func Finalize ¶
Finalize flips a Broadcast to "sent" once none of its recipients' sends is still pending. The finalize move (sending -> sent, a compare-and-swap) makes concurrent finalizers (one per per-recipient job, or an Erasure that removed the last pending recipient) a no-op after the first, as is a Broadcast that is not sending at all, so sent_at is set exactly once.
func IsRetryLater ¶
IsRetryLater reports whether err asks the adapter to try again rather than record a failure: a hold, a Deferral or a live claim.
func Pending ¶
func Pending() predicate.BroadcastRecipient
Pending matches the recipients whose send has not ended: no Outbound message of theirs has left pending. It is the one reading of "this recipient is still to be sent".
func RecordClick ¶
RecordClick marks the recipient as having clicked a link of the email.
func RecordOpen ¶
RecordOpen marks the recipient as having opened the email.
func RecordUnsubscribe ¶
func RecordUnsubscribe(ctx context.Context, s *ent.Scoped, broadcastID, contactID int64) (bool, error)
RecordUnsubscribe marks the Contact's recipient row of the Broadcast as having unsubscribed from it. A Contact that was never a recipient of it (a link to an erased row) matches nothing.
Types ¶
type DeferredError ¶
type DeferredError struct {
// Delay is the wait the Outbound retry policy chose for this deferral (it was recorded on
// the recipient so the backlog of later recipients can tell it is asleep).
Delay time.Duration
}
DeferredError reports that Outbound send found the Integration's Send rate limit spent (outbound.Deferral). Nothing was consumed: the caller retries the same work after Delay.
func (*DeferredError) Error ¶
func (e *DeferredError) Error() string
type Delivery ¶
type Delivery struct {
// contains filtered or unexported fields
}
Delivery is the send side of the Broadcast lifecycle: one recipient at a time through Outbound send, the hold reason and the final settling of the Broadcast. A recipient has no delivery state of its own: how its send ended is its Outbound message's (ADR 0015), and it is pending until then. It holds no client: every call receives the Workspace-scoped client.
func NewDelivery ¶
NewDelivery builds the delivery over the Outbound send module.
func (*Delivery) GiveUp ¶
GiveUp records the terminal failure of a recipient whose last attempt failed with cause: Outbound send ends its message as Failed with the cause as its reason (a send that already reached the provider stays sent), and the Broadcast settles if none is left pending. A recipient already decided keeps its outcome.
outbound.ErrInProgress means another attempt still holds a live claim and may yet reach the provider: nothing is failed, and the adapter waits out the lease and gives up again.
func (*Delivery) Pace ¶
func (d *Delivery) Pace(ctx context.Context, s *ent.Scoped, id int64, n int, now time.Time) ([]time.Time, error)
Pace spreads n recipient sends over time for a Broadcast whose Integration has a Send rate limit (ADR 0023): the i-th send is scheduled i steps after the start, a step being one over the effective rate, so the limiter is a guard and a Deferral the rare case. The start is now, or the Broadcast's scheduled time when that is later. It records the last scheduled time on the Broadcast, from which the progress estimate is derived, and returns nil (every send at once) when nothing limits the Integration. Pacing decides only when a send runs: eligibility is still checked per message at send time.
func (*Delivery) Plan ¶
Plan resolves the Broadcast's eligible audience, creates one pending recipient row per contact (idempotent through the unique (broadcast, contact) index, so a re-planned job converges) and returns the recipient ids to send. It refuses a Broadcast past its plan (lifecycle.ErrInvalidTransition) and holds one that cannot send now (*HeldError) before any row exists; a template that cannot render, or a broken segment, fails the Broadcast once, before any recipient row exists. An empty audience settles the Broadcast.
func (*Delivery) Run ¶
func (d *Delivery) Run(ctx context.Context, s *ent.Scoped, id int64, sched Scheduler, now time.Time) error
Run is the whole delivery sequence of a Broadcast: plan the audience, pace the recipients and hand each send to the scheduler. It returns *HeldError when the Broadcast is held (it keeps its status and records why, to be run again), lifecycle.ErrInvalidTransition when it is past its plan (a stale job), and a plain error on a fault. A plan that failed the Broadcast (see ErrPlanFailed) is recorded and returns nil. An empty audience settles the Broadcast and schedules nothing. Eligibility is still checked per message at send time, so an unsubscribe or Suppression that lands after planning is honoured.
func (*Delivery) RunClaimed ¶
func (d *Delivery) RunClaimed(ctx context.Context, s *ent.Scoped, id int64, sched Scheduler, now time.Time) error
RunClaimed is Run for a job queued after the Broadcast was claimed (Send or Schedule): it refuses a draft with lifecycle.ErrInvalidTransition, so an unschedule that lands after Due has answered is not undone by the plan.
func (*Delivery) SendRecipient ¶
SendRecipient hands one recipient's email to Outbound send, then settles the Broadcast if that was its last pending recipient. It is idempotent: a recipient already decided is left alone (a stale job is just that), and the Outbound idempotency key makes a retry after a committed send replay the recorded result.
A returned error is for the adapter to retry: *HeldError (the source is on hold), *DeferredError (its Send rate limit is spent), outbound.ErrInProgress (a live claim) or a fault; none consumed the recipient.
type Detail ¶
Detail is one Broadcast's figures and, while it is sending, its Progress, both from a single count of its recipients. A list of Broadcasts reads StatsOf instead: no Progress, two grouped queries for the page (ADR 0023).
type Fields ¶
type Fields struct {
Name *string
Subject *string
FromName *string
FromEmail *string
Body *string
// Doc is the editor's JSON document, empty when the Broadcast is written as HTML. The body is
// built from it in the same save (ADR 0040); a Body alone clears the stored document.
Doc json.RawMessage
SegmentID *int64
IntegrationID *int64
ClearFromName bool
ClearFromEmail bool
ClearSegment bool
ClearIntegration bool
}
Fields are the author-editable content of a Broadcast. A nil field is "not provided": Create falls back to the schema default, Update keeps the stored value. The Clear flags are JSON Merge Patch nulls for Update: they drop the stored value. SegmentID and IntegrationID reference rows of the same Workspace; the scoped client refuses a foreign one with ent.ErrNotInWorkspace.
type HeldError ¶
type HeldError struct{ Reason string }
HeldError reports that Outbound send put the source on hold (see outbound.Hold*). Nothing was consumed: the caller defers the same work.
type Module ¶
type Module struct {
// contains filtered or unexported fields
}
Module is the Broadcast send/schedule state machine. It holds no client: every call receives the Workspace-scoped client (ADR 0017) built by the entry point, so the module writes no Workspace predicate and cannot name another Workspace.
func (*Module) Archive ¶
Archive discards a sent or failed Broadcast (ADR 0049): it leaves the lists but its Recipients, report, tracking and figures stay. A draft is purged (DeleteDraft) and a scheduled or sending Broadcast is work in flight, so both are ErrNotArchivable. Archiving an archived Broadcast changes nothing.
func (*Module) Create ¶
Create makes a draft Broadcast. Nothing here schedules or sends. A blank name is ErrBlankName; a subject, body or document that cannot render is emailrender.ErrInvalidContent; the body is stored sanitized.
func (*Module) DeleteDraft ¶
DeleteDraft purges a draft Broadcast (ADR 0049). Past draft the Broadcast is history (its recipients and report) and is archived instead, never removed.
func (*Module) LinkClicksOf ¶
func (m *Module) LinkClicksOf(ctx context.Context, s *ent.Scoped, id int64) ([]linkclicks.Clicks, error)
LinkClicksOf counts a Broadcast's `email.clicked` Events by link, most clicked first. Every click is an Event (ADR 0053), so these are repeat counts, and they cover the Event retention window. Machine clicks are left out. The unique click figure (Stats.Clicked) comes from the rollup and does not expire.
func (*Module) List ¶
func (m *Module) List(ctx context.Context, s *ent.Scoped, p pagination.Params, archived bool) (pagination.Page[*ent.Broadcast], error)
List returns one page of the Workspace's Broadcasts, newest first: the kept ones, or with archived the archived ones.
func (*Module) Schedule ¶
func (m *Module) Schedule(ctx context.Context, s *ent.Scoped, id int64, when time.Time) (*ent.Broadcast, error)
Schedule moves a draft or scheduled Broadcast to scheduled at `when` and queues a delayed job in the same transaction.
func (*Module) Send ¶
Send moves a draft or scheduled Broadcast to sending (dropping any schedule) and queues its send job in the same transaction: either both happen or neither does.
func (*Module) SetAudience ¶
func (m *Module) SetAudience(ctx context.Context, s *ent.Scoped, id int64, segmentID *int64) (*ent.Broadcast, error)
SetAudience points a draft at a Segment, or at all active contacts when segmentID is nil. It never schedules or sends.
func (*Module) Unarchive ¶
Unarchive brings an archived Broadcast back to the lists. Restoring a Broadcast that is not archived changes nothing.
func (*Module) Unschedule ¶
Unschedule returns a scheduled Broadcast to draft and drops its schedule. The delayed job stays queued; when it fires it finds a draft and does nothing.
type Progress ¶
type Progress struct {
// Processed counts recipients already decided: sent, skipped or failed.
Processed int
// Remaining counts recipients still pending.
Remaining int
// EstimatedCompletion is when the last remaining recipient is expected to go out;
// nil when it cannot be known (a hold, or no Send rate limit to pace by).
EstimatedCompletion *time.Time
}
Progress is how far a sending Broadcast has got (ADR 0023). A Broadcast stays "sending" while it is paced, held or deferred, so the progress is derived from its recipients, not from a status.
type Report ¶
Report is a Broadcast's delivery report: its status, why it is held and its Stats. The rates are Stats methods, ratios in [0,1] where a zero denominator yields 0.
type Scheduler ¶
Scheduler is the seam of the Broadcast lifecycle: "schedule this recipient's send at time T". The river adapter inserts a job per send at its time; the synchronous adapter sends in order at once. The module computes the times either way, so both run one sequence.
type Send ¶
Send is one recipient's send to schedule. A zero At means as soon as possible: the Broadcast is not paced.
type SendArgs ¶
type SendArgs struct {
BroadcastID int64 `json:"broadcast_id"`
// ScheduledAt is the time a delayed job was queued for; nil for an immediate send.
ScheduledAt *time.Time `json:"scheduled_at,omitempty"`
}
SendArgs is the fan-out (plan) job payload: which Broadcast to dispatch. The worker plans the audience and queues one job per recipient, so no single job carries the whole send.
type Stats ¶
type Stats struct {
// Recipients counts the Broadcast's recipient rows.
Recipients int
// Sent, Skipped and Failed count the recipients whose message ended that way.
Sent, Skipped, Failed int
// Pending counts the recipients whose send has not ended: no message yet, or one still
// claimed. It is Recipients less the three above.
Pending int
// Opened, Clicked and Unsubscribed count the recipients with that engagement.
Opened, Clicked, Unsubscribed int
// Delivered, Bounced and Complained count the messages whose delivery result is stamped
// (ADR 0053). They are read from the stamps, never from Events, so they outlive retention.
Delivered, Bounced, Complained int
// Captured counts the messages that went through a sender delivering nowhere outside (a
// Test inbox): captured, not delivered. They are stamped delivered too, so a reader
// can tell them apart from a real delivery.
Captured int
}
Stats is a Broadcast's delivery figures. They are never stored: the audience is its recipient rows, how each send ended is its Outbound message (ADR 0015), and engagement is the timestamps on the recipient, so the figures cannot drift from what the rows say and are live while the Broadcast is still sending.