hitlnotify

package
v1.1.0 Latest Latest
Warning

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

Go to latest
Published: Jul 21, 2026 License: Apache-2.0 Imports: 16 Imported by: 0

Documentation

Overview

Package hitlnotify sends the approval notification email that fires whenever a new outbound message enters pending_review.

The notification is the reviewer's primary touchpoint with HITL — it arrives in the account owner's inbox with a preview of the held message and one-click approve / reject magic links, plus a link back to the dashboard for edit-before-approve.

Delivery is durable, on River: the hold accept-tx enqueues a hitl_notify job (QueueNotify) in the same transaction as the pending_review row, and the NotifyWorker (worker.go) recomposes and submits the email ONCE off the request path — River owns the retry envelope (docs/design/hitl-notify-river.md). This replaced the earlier detached, best-effort goroutine, which lost the notification on a crash or SMTP outage between the 202 response and the send.

worker.go is Layer 3 of the durable HITL approval-notification pipeline (docs/design/hitl-notify-river.md): the River execution stage that takes a pending_review message, recomposes the reviewer's approve/reject email, and submits it ONCE off the request path. It mirrors internal/outboundsend — a River Worker on the shared `notify` queue, with River owning claim/retry/rescue.

At-least-once from the 202 response: the hitl_notify job is enqueued in the same tx as the pending_review row (the hold accept-tx) before the API answers, so an accepted hold's notification is never lost. The worker's notified_at stamp (written only AFTER a successful send) makes a crash-after-send re-drive a no-op; loss is impossible, a rare duplicate "please review" email is benign.

Index

Constants

View Source
const MaxNotifyAttempts = 6

MaxNotifyAttempts caps the retry tail before River discards the job.

Variables

This section is empty.

Functions

This section is empty.

Types

type DeliverOutcome

type DeliverOutcome struct {
	Err       error
	Permanent bool // 5xx / validation — no retry
	Outage    bool // relay unreachable — snooze without spending an attempt
}

DeliverOutcome is the classified result of one notification send. Permanent and Outage split the retry decision exactly as the outbound worker's does, using the shared internal/outbound SMTP classifiers.

type Deliverer

type Deliverer interface {
	Deliver(ctx context.Context, pn *identity.PendingNotify) DeliverOutcome
}

Deliverer composes and sends the approval email for one held message. Implemented by *Notifier (compose + SMTPRelay.SendOnce + classify).

type HITLNotifyArgs

type HITLNotifyArgs struct {
	MessageID string `json:"message_id"`
}

HITLNotifyArgs drives one approval-notification job. Args carry only the message id; the worker re-reads the message + agent (the source of truth) each attempt, so the job row stays tiny and always reflects the current hold state.

func (HITLNotifyArgs) Kind

func (HITLNotifyArgs) Kind() string

type Jobs

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

Jobs is the HITL-notification integration on the shared River client: a jobs.Registrar (contributes NotifyWorker) plus the transactional enqueue entry point the hold accept-tx calls. Both the shared client and the concrete Deliverer are injected AFTER construction (two-phase wiring) — the client via SetEnqueuer, the Deliverer (the Notifier, which needs the relay + signer resolved) via SetDeliverer. Jobs itself is the worker's Deliverer, late-binding to the concrete one, mirroring inboundprocess's late-bound Processor.

func NewJobs

func NewJobs(store Store) *Jobs

NewJobs builds the integration with just its store (no client, no deliverer yet).

func (*Jobs) Deliver

Deliver makes Jobs itself the worker's Deliverer, delegating to the concrete one set via SetDeliverer. Until that is wired (the brief startup window before the notifier is built) it returns a retryable outcome, so a pending job simply retries rather than dropping on a nil deliverer.

func (*Jobs) EnqueueNotifyTx

func (j *Jobs) EnqueueNotifyTx(ctx context.Context, tx pgx.Tx, messageID string) (int64, error)

EnqueueNotifyTx inserts the hitl_notify job in the caller's hold accept-tx (the same tx as the pending_review insert), returning the River job id to stamp on the message so a committed pending_review row always has its notification job.

func (*Jobs) ReconcilePending

func (j *Jobs) ReconcilePending(ctx context.Context, pool *pgxpool.Pool) (int, error)

ReconcilePending enqueues a hitl_notify job for every pending_review message that has no job yet AND was never notified (notify_job_id IS NULL AND notified_at IS NULL). Run ONCE at startup as the cutover.

Because the accept-tx is a single transaction (message insert + job enqueue + job-id stamp commit together), a committed pending_review row in steady state ALWAYS has its job — so this set is normally empty. It exists to enqueue holds created on the no-notifier plain path (notified_at NULL) if a relay is later configured, plus any row stranded by a crash between insert and enqueue.

The `notified_at IS NULL` guard is what makes the feature's very first deploy safe: every hold already pending_review at cutover was notified by the old code path, and migration 057 stamps notified_at on exactly those rows, so this scan skips them — no owner is emailed twice. Idempotent: the per-row FOR UPDATE + notify_job_id IS NULL re-check means a re-run (or a concurrent replica) never double-enqueues.

func (*Jobs) RegisterJobs

func (j *Jobs) RegisterJobs(w *river.Workers) []*river.PeriodicJob

RegisterJobs adds the NotifyWorker (with Jobs as the late-binding Deliverer). No periodics — the reconciler is a one-shot startup cutover. Implements jobs.Registrar.

func (*Jobs) SetDeliverer

func (j *Jobs) SetDeliverer(d Deliverer)

SetDeliverer injects the concrete Deliverer (the Notifier), built after the relay/signer gating resolves. Guarded so the River worker goroutines read it race-free.

func (*Jobs) SetEnqueuer

func (j *Jobs) SetEnqueuer(e jobs.Enqueuer)

SetEnqueuer injects the shared client so EnqueueNotifyTx can insert jobs.

type Notifier

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

Notifier sends approval notification emails. Construct with New, then call NotifyPendingApproval from the HITL gate right after the pending row is written. Errors are logged, never returned upstream.

func New

func New(store *identity.Store, relay *outbound.SMTPRelay, signer *approvaltoken.Signer, fromDomain, publicURL string) *Notifier

New returns a Notifier that sends mail through relay using the given public URL to build magic-link URLs. fromDomain is the platform relay's from-domain — e.g. "send.example.com" — which is combined with the fixed local-part to produce the From address.

func (*Notifier) Deliver

Deliver composes and sends the approval email for one held message, classifying the result for the River NotifyWorker: a 5xx / validation reject is Permanent (no retry), an unreachable relay is an Outage (snooze), everything else retries. Implements hitlnotify.Deliverer. The classifiers key on the SMTP code / net error preserved through NotifyPendingApproval's %w wrapping.

func (*Notifier) NotifyPendingApproval

func (n *Notifier) NotifyPendingApproval(ctx context.Context, msg *identity.Message, agent *identity.AgentIdentity) error

NotifyPendingApproval composes and sends the notification email for a held message, submitting once (SendOnce). It is the compose+send core the River NotifyWorker drives via Deliver; the returned error is classified there into retry/permanent/outage.

type NotifyWorker

type NotifyWorker struct {
	river.WorkerDefaults[HITLNotifyArgs]
	// contains filtered or unexported fields
}

NotifyWorker sends the approval notification for one pending_review message. Mirrors outboundsend.SendWorker.

func NewNotifyWorker

func NewNotifyWorker(store Store, deliverer Deliverer) *NotifyWorker

func (*NotifyWorker) NextRetry

func (w *NotifyWorker) NextRetry(job *river.Job[HITLNotifyArgs]) time.Time

NextRetry overrides River's default backoff with the decided notify envelope.

func (*NotifyWorker) Work

Work intentionally has no Timeout() override — a single SMTP submit of the approval notification fits River's 60s default JobTimeout.

type Store

type Store interface {
	// LoadPendingNotify returns the held message + owning agent, or (nil, nil) when
	// there is nothing to notify about (message or agent gone) — a no-op.
	LoadPendingNotify(ctx context.Context, messageID string) (*identity.PendingNotify, error)
	// MarkMessageNotified stamps notified_at after a successful send (the dedup marker).
	MarkMessageNotified(ctx context.Context, messageID string) error
	// StampNotifyJobIDTx records the job id on a reconciled row (accept-tx + reconciler).
	StampNotifyJobIDTx(ctx context.Context, tx pgx.Tx, messageID string, jobID int64) error
}

Store is the message surface the worker + reconciler need. Implemented over internal/identity (*identity.Store).

Jump to

Keyboard shortcuts

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