senderidentity

package
v1.8.9 Latest Latest
Warning

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

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

Documentation

Overview

Package senderidentity manages the per-domain SES sending identity that lets outbound mail use the agent's OWN address as the From header (decision 4 / Slice 4). Verification is asynchronous: a domain moves none → pending → verified|failed, driven by a River-backed provision job + a periodic reconciler. The own-address From is used ONLY when the domain reaches `verified` (fail-closed); every other state falls back to the relay From, so the whole subsystem is behavior-neutral until a Provider actually verifies a domain.

The Provider abstraction keeps the AWS SES SDK at the edge: the workers, store, and handlers speak this interface, and tests use the in-memory fake. The real sesv2 implementation (ses.go) is exercised only against live AWS; everything else is unit/integration tested with the fake.

Index

Constants

View Source
const (
	DeploymentProd    = "prod"
	DeploymentStaging = "staging"
)

Deployment names recognized for the e2a-env tag. This is the closed vocabulary; internal/config mirrors these two literals to normalize operator input, but this package is the authority and re-screens whatever it is given, so a name that reaches here unrecognized drops the tag rather than writing an unknown value the reaper would then have to interpret.

View Source
const DefaultMaxReconcileAttempts = 12

DefaultMaxReconcileAttempts bounds how long a domain may sit in `pending` before the reconciler gives up and marks it `failed` (the design's "no infinite poll" TTL). The wall-clock TTL is the sum of River's retry backoffs across this many attempts.

Variables

View Source
var ErrIdentityNotFound = errors.New("senderidentity: identity not found")

ErrIdentityNotFound is what Status returns when the provider has no identity for the domain (e.g. it was deprovisioned out of band). Callers treat it as "drop back to none/failed", never as a hard error.

View Source
var ErrIdentityNotOwned = errors.New("senderidentity: identity is not managed by e2a")

ErrIdentityNotOwned means an identity exists for the domain but lacks the provider-side ownership marker written by e2a, AND does not qualify for adoption (see Provider.Provision). Callers must never update or delete it: an SES account can be shared with other applications.

Functions

This section is empty.

Types

type AccountClassFunc added in v1.8.5

type AccountClassFunc func(ctx context.Context, userID string) (string, error)

AccountClassFunc resolves a user's usage account class ("standard", "internal", "system", "demo"). *usage.Store's GetAccountClass satisfies it after a one-line string conversion at the wiring site — kept as a plain func over plain strings for the same reason RawStore is, so this package does not import internal/usage.

type AdoptionEvidence added in v1.7.9

type AdoptionEvidence struct {
	Selector      string
	HasPrivateKey bool
}

AdoptionEvidence is what a caller has on file for a domain, used to judge whether an untagged existing provider identity is provably e2a's own (see the SES implementation's canAdoptIdentity for the precise criteria). Selector and HasPrivateKey must be JOINTLY consistent — a caller's stored state can carry a selector with no private key on file (e.g. mid a domain reclaim), and reporting HasPrivateKey=true in that case would make Status tag an identity e2a cannot actually sign for. Bundling the two into one value (batch C finding 5) replaces what used to be two independent parameters that existed only to feed a single joint decision — a caller could pass them inconsistently (e.g. a non-empty Selector with HasPrivateKey hardcoded true) and nothing but code review would catch it, since both are the same primitive type.

type Config

type Config struct {
	// MaxReconcileAttempts bounds the pending→failed TTL. 0 → default.
	MaxReconcileAttempts int
	// ReaperInterval overrides the orphan-sweep cadence. 0 → default.
	ReaperInterval time.Duration
	// LegacyJobCompat is phase 1 of the two-phase blue/green job-lane rollout
	// (config sender_identity.legacy_job_compat).
	// When true, mutation and reconcile jobs are PRODUCED as the legacy kinds
	// on the default queue — consumable by the previous release — while this
	// binary still CONSUMES both lanes. Deploy this release with the flag on;
	// once it is the stable rollback target, flip the flag off (a config-only
	// deploy) to switch producers to the versioned v2 lane. A rollback of
	// that second deploy lands on a binary that consumes v2, so nothing
	// strands. This only makes the River lanes rollback-compatible: operators
	// must still freeze sender-identity mutations while a pre-ownership worker
	// overlaps or is a possible rollback target (see the deployment design).
	// Default false: single-instance/self-host deployments have no blue/green
	// overlap and want the v2 semantics immediately.
	LegacyJobCompat bool
	// Reclaim is the orphan-identity reclaim policy (config
	// sender_identity.reap_orphans and friends). Its zero value — what every
	// self-host and every deployment that omits the block gets — reclaims
	// nothing, so the reaper's orphan phase stays alert-only exactly as before.
	Reclaim ReclaimConfig
}

Config tunes the Manager. Zero values get sane defaults.

type DNSRecord

type DNSRecord struct {
	Type  string `json:"type"`  // "TXT" | "CNAME" | "MX"
	Name  string `json:"name"`  // record host
	Value string `json:"value"` // record value
}

DNSRecord is a single record the customer must publish for the sending identity. With BYODKIM the customer already published the per-domain DKIM record during register/verify; this now carries the custom MAIL FROM subdomain's MX + SPF records (Return-Path alignment — see ses.go mailFromRecords) and surfaces anything SES reports as still-required.

type DeprovisionArgs

type DeprovisionArgs struct {
	Domain string `json:"domain"`
}

func (DeprovisionArgs) Kind

func (DeprovisionArgs) Kind() string

type DeprovisionWorker

type DeprovisionWorker struct {
	river.WorkerDefaults[DeprovisionArgs]
	// contains filtered or unexported fields
}

DeprovisionWorker removes the SES sending identity on domain/account delete. Idempotent: the provider treats a missing identity as success.

func (*DeprovisionWorker) Work

type EventFirer

type EventFirer func(ctx context.Context, domain, userID string, status Status, errMsg string)

EventFirer publishes a domain.sending_verified / domain.sending_failed event. Injected as a closure so this package doesn't depend on webhookpub. userID is the domain owner; a nil firer (tests) is a no-op.

type FakeProvider

type FakeProvider struct {
	ProvisionCalls []string
	// ProvisionMetas is index-parallel to ProvisionCalls: the classification
	// metadata each Provision call carried. Recorded separately so existing
	// tests keep asserting on the plain domain list.
	ProvisionMetas   []ProvisionMeta
	StatusCalls      []StatusCall
	DeprovisionCalls []string
	ListCalls        int
	ListPageCalls    int
	InspectCalls     []string
	// contains filtered or unexported fields
}

FakeProvider is an in-memory Provider for tests. It is concurrency-safe (the workers call it from River goroutines). Configure per-domain behavior with the setters; inspect calls with the recorded slices.

Default behavior with no configuration: Provision returns StatusPending, Status returns StatusPending forever, Deprovision succeeds. Tests that want a domain to verify call SetStatusSequence or SetStatus.

func NewFakeProvider

func NewFakeProvider() *FakeProvider

NewFakeProvider returns a ready FakeProvider with default behavior.

func (*FakeProvider) Deprovision

func (f *FakeProvider) Deprovision(ctx context.Context, domain string) error

func (*FakeProvider) InspectIdentity added in v1.8.5

func (f *FakeProvider) InspectIdentity(ctx context.Context, domain string) (IdentityAudit, error)

func (*FakeProvider) List

func (f *FakeProvider) List(ctx context.Context) ([]string, error)

func (*FakeProvider) ListPage added in v1.7.8

func (f *FakeProvider) ListPage(ctx context.Context, nextToken string, limit int) ([]string, string, error)

func (*FakeProvider) Provision

func (f *FakeProvider) Provision(ctx context.Context, domain, dkimSelector string, dkimPrivateKeyDER []byte, meta ProvisionMeta) (Result, error)

func (*FakeProvider) SeedIdentity

func (f *FakeProvider) SeedIdentity(domain string)

SeedIdentity marks domain as having a provider identity (for List/reaper tests) without going through Provision.

func (*FakeProvider) SetDeprovisionErr

func (f *FakeProvider) SetDeprovisionErr(err error)

SetDeprovisionErr forces Deprovision to fail.

func (*FakeProvider) SetIdentityAudit added in v1.8.5

func (f *FakeProvider) SetIdentityAudit(domain string, audit IdentityAudit)

SetIdentityAudit fixes what InspectIdentity reports for domain (the classification tags + the verified-for-sending bit the reclaim decision reads). Domain is filled in from the key so a test cannot seed an audit whose name disagrees with the identity it describes.

func (*FakeProvider) SetInspectErr added in v1.8.5

func (f *FakeProvider) SetInspectErr(domain string, err error)

SetInspectErr makes InspectIdentity return err for domain.

func (*FakeProvider) SetProvisionErr

func (f *FakeProvider) SetProvisionErr(err error)

SetProvisionErr makes the next Provision calls return err.

func (*FakeProvider) SetProvisionResult added in v1.7.8

func (f *FakeProvider) SetProvisionResult(result Result)

SetProvisionResult overrides the default pending provisioning result.

func (*FakeProvider) SetStatus

func (f *FakeProvider) SetStatus(domain string, r Result)

SetStatus fixes the Result returned by Status for domain.

func (*FakeProvider) SetStatusErr

func (f *FakeProvider) SetStatusErr(domain string, err error)

SetStatusErr makes Status return err (a transient error, not NotFound) for domain.

func (*FakeProvider) SetStatusNotFound

func (f *FakeProvider) SetStatusNotFound(domain string)

SetStatusNotFound makes Status return ErrIdentityNotFound for domain.

func (*FakeProvider) SetStatusSequence

func (f *FakeProvider) SetStatusSequence(domain string, seq ...Result)

SetStatusSequence queues results consumed one-per-Status-call for domain; the last result repeats once the sequence drains. Lets a test drive pending → pending → verified.

func (*FakeProvider) Status

func (f *FakeProvider) Status(ctx context.Context, domain string, evidence AdoptionEvidence) (Result, error)

type IdentityAudit added in v1.8.5

type IdentityAudit struct {
	// Domain is the provider's identity NAME. It is the value matched against
	// the reclaim zones, not a value derived from e2a's database — the whole
	// point is to decide without trusting a join that may not exist.
	Domain string
	Tags   map[string]string
	// VerifiedForSending mirrors SES's VerifiedForSendingStatus: true means
	// this identity can send mail RIGHT NOW.
	VerifiedForSending bool
}

IdentityAudit is everything the reaper needs from the provider to judge ONE orphan candidate: the classification tags stamped at creation (tags.go) and whether the provider currently considers the identity able to SEND. Both come from a single GetEmailIdentity call (Provider.InspectIdentity), so a candidate costs one round trip and only orphans ever pay it.

Tags is the raw key→value map exactly as the provider reports it: this type deliberately does no interpretation of its own, so every judgement lives in orphanReclaimable where it can be exhaustively table-tested.

type LegacyReapWorker added in v1.7.8

type LegacyReapWorker struct {
	river.WorkerDefaults[ReapArgs]
	// contains filtered or unexported fields
}

func (*LegacyReapWorker) Work added in v1.7.8

type Manager

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

Manager owns the sender-identity job lifecycle on the SHARED River client (internal/jobs), instead of a private client. It is a jobs.Registrar — it contributes rollout-compatible legacy workers, v2 sync/reconcile workers, and the periodic managed-identity reaper — plus the app's enqueue entry point: EnqueueProvision on domain verify, EnqueueDeprovisionTx in the domain-delete tx. The shared client is injected via SetEnqueuer after jobs.New has built it (which needs this Manager as a Registrar first — the standard two-phase wiring).

func NewManager

func NewManager(store Store, provider Provider, fire EventFirer, cfg Config) *Manager

NewManager builds the manager with its dependencies. It does NOT build a River client — call jobs.New with this Manager as a Registrar, then SetEnqueuer with the resulting client. fire may be nil (no events).

func (*Manager) EnqueueDeprovisionTx

func (m *Manager) EnqueueDeprovisionTx(ctx context.Context, tx pgx.Tx, domain string) error

EnqueueDeprovisionTx enqueues sending-identity teardown WITHIN the caller's delete transaction, so the job is committed atomically with the domain-row delete — it can never be lost if SES is unreachable at delete time.

func (*Manager) EnqueueProvision

func (m *Manager) EnqueueProvision(ctx context.Context, domain string) error

EnqueueProvision schedules sending-identity provisioning for a domain (called when a domain becomes verified, or on a forced re-check via POST /domains/{domain}/verify).

Intentionally NOT unique: River's job uniqueness can't drop `completed` from its state set (only `retryable` is safely removable), so a completed job would block a legitimate re-provision — e.g. POST /verify retrying a `failed` domain — for the ~24h completed-job retention window. (Where dedupe IS wanted despite that constraint, scope uniqueness in time instead — see PostDrainAuditArgs.Window.) Instead we always enqueue and rely on desired-state convergence: the current incarnation's BYODKIM + MAIL FROM replaces any prior provider state. Provider mutations are serialized per process and per domain across replicas, so concurrent duplicate enqueues are harmless.

func (*Manager) EnqueueProvisionTx added in v1.7.8

func (m *Manager) EnqueueProvisionTx(ctx context.Context, tx pgx.Tx, domain string) error

EnqueueProvisionTx is the verify-time atomic-outbox variant.

func (*Manager) RegisterJobs

func (m *Manager) RegisterJobs(w *river.Workers) []*river.PeriodicJob

RegisterJobs adds both legacy drain workers and rollout-safe v2 workers to the shared client, and returns the v2 periodic reaper. Implements jobs.Registrar. The workers run on the default queue (nil InsertOpts.Queue), preserving prior behavior.

func (*Manager) SetEnqueuer

func (m *Manager) SetEnqueuer(e jobs.Enqueuer)

SetEnqueuer injects the shared client so the Enqueue* methods can insert jobs. Must be called (once, at startup) before EnqueueProvision/EnqueueDeprovisionTx.

func (*Manager) TryDeprovisionNow added in v1.7.8

func (m *Manager) TryDeprovisionNow(ctx context.Context, domain string) (confirmed bool, err error)

TryDeprovisionNow is the post-commit best-effort convergence attempt for a just-deleted domain: the delete transaction has already committed the row delete plus the durable teardown job, so the provider identity is usually confirmed absent before the HTTP response returns — but a failure here is for the caller to LOG, never to propagate to the client (the committed job and the hourly reaper are the guarantee). It runs the same desired-state convergence as the workers: the absent row converges to provider absence, ErrIdentityNotOwned is tolerated (a foreign identity is not e2a's to delete) but returned as confirmed=false so callers never claim provider absence or release DNS. The ledger tombstone is deliberately retained for the post-drain audit to finalize (mixed-version late-create repair). The caller bounds the wait via ctx; the mutation gate honors cancellation.

type PostDrainAuditArgs added in v1.7.8

type PostDrainAuditArgs struct {
	Domain string `json:"domain" river:"unique"`
	// Window is the audit's schedule bucket (scheduled-at unix seconds /
	// postDrainConvergenceDelay). It scopes ByArgs uniqueness in time: River's
	// unique state set cannot drop `completed`, so without it a completed
	// audit inside the ~24h retention blocked the next mutation's audit for
	// the same domain. A completed audit's bucket is strictly older than any
	// new mutation's (an audit for bucket B cannot complete before B's start,
	// and any later mutation schedules into a later bucket — modulo replica
	// clock skew, which shrinks that guarantee by the skew), so dedupe can
	// only ever match a still-scheduled job. Mutations straddling a bucket
	// edge produce two audits; the audit is idempotent, so that is noise,
	// not a bug.
	Window int64 `json:"window" river:"unique"`
}

PostDrainAuditArgs is a domain-scoped, deduplicated finalizer scheduled by mutations that can overlap a legacy blue/green slot. It runs only on the v2 queue, nominally postDrainConvergenceDelay after its mutation — but see Window: a mutation deduped into an audit scheduled earlier in the same bucket can be finalized up to one bucket early, i.e. possibly while the old slot still drains. The hourly reaper and the orphan ALERT remain the backstop for that residual window (which the pre-Window code had in an unbounded form).

func (PostDrainAuditArgs) Kind added in v1.7.8

func (PostDrainAuditArgs) Kind() string

type PostDrainAuditWorker added in v1.7.8

type PostDrainAuditWorker struct {
	river.WorkerDefaults[PostDrainAuditArgs]
	// contains filtered or unexported fields
}

func (*PostDrainAuditWorker) Work added in v1.7.8

type Provider

type Provider interface {
	// Provision registers a BYODKIM sending identity for domain, supplying
	// the per-domain DKIM selector + PKCS#1 DER private key that e2a
	// already generated (so DKIM d= aligns with the From domain). An
	// existing untagged identity is adopted in place when it provably
	// matches this selector (see Provider doc); otherwise returns
	// ErrIdentityNotOwned. Returns the initial Result — typically
	// StatusPending — or an error to retry.
	//
	// meta is best-effort classification metadata for the identity being
	// created (see ProvisionMeta and tags.go). Implementations MUST treat
	// every field as optional and MUST NOT fail a provision over it: a
	// partial or wholly empty ProvisionMeta yields a less self-describing
	// identity, never a failed verification.
	Provision(ctx context.Context, domain, dkimSelector string, dkimPrivateKeyDER []byte, meta ProvisionMeta) (Result, error)

	// Status polls the current verification state from the provider.
	// evidence is what e2a has on file for domain (see AdoptionEvidence) —
	// Status needs it to make the same adoption judgement as Provision
	// (including self-healing an ownership tag that was removed out-of-band
	// on an identity e2a otherwise still provably owns). Callers that have no
	// adoption judgement to make for this poll (no selector, or a selector
	// without key material) should pass a zero AdoptionEvidence; this never
	// affects polling an ALREADY-owned identity, whose status reporting
	// doesn't consult it. Returns ErrIdentityNotFound if no identity exists
	// for domain.
	Status(ctx context.Context, domain string, evidence AdoptionEvidence) (Result, error)

	// Deprovision removes the sending identity. A missing identity MUST be
	// reported as success (idempotent teardown).
	Deprovision(ctx context.Context, domain string) error

	// List returns all domain identities visible to the provider principal.
	// It is retained for the phase-1 compatibility worker only; the v2 reaper
	// uses ListPage so one River job cannot inventory the whole provider account.
	// Neither path treats an unledgered identity as its own.
	List(ctx context.Context) ([]string, error)

	// ListPage returns one provider-bounded page plus the opaque continuation
	// token. The v2 orphan audit uses this so one River job never inventories
	// the whole provider account.
	ListPage(ctx context.Context, nextToken string, limit int) (domains []string, followingToken string, err error)

	// InspectIdentity returns the facts the orphan-reclaim decision needs for
	// one identity: its classification tags (tags.go) and whether the provider
	// currently considers it able to SEND. Both must come from a SINGLE
	// provider round trip — a two-call version could observe the tags before
	// and the sending state after a change, and the decision would then be
	// made against a state that never existed at once. Returns
	// ErrIdentityNotFound if no identity exists for domain.
	//
	// Only orphan candidates are inspected (see reapProviderOrphanPage), so
	// the per-candidate cost is bounded by how many orphans exist, not by the
	// size of the provider account.
	InspectIdentity(ctx context.Context, domain string) (IdentityAudit, error)
}

Provider registers, polls, and removes the upstream (SES) sending identity for a domain. Implementations MUST be idempotent for identities they own: Provision on an already-managed domain refreshes desired state, and Deprovision treats a missing identity as success. An existing identity that lacks the provider ownership marker is ADOPTED (tagged and then treated as owned) when it is provably e2a's own — an untagged identity configured with e2a's own BYODKIM selector for that exact domain (see the SES implementation for the precise criteria) — and otherwise returns ErrIdentityNotOwned and must not be mutated. Adoption exists because every identity created before the ownership tag shipped is untagged, and a naive "no tag means foreign" rule would permanently strand every pre-existing customer domain.

type ProvisionArgs

type ProvisionArgs struct {
	Domain string `json:"domain"`
}

func (ProvisionArgs) Kind

func (ProvisionArgs) Kind() string

type ProvisionMeta added in v1.8.5

type ProvisionMeta struct {
	// UserID is the domain owner's user id (SendingIdentityState.Owner).
	UserID string
	// AccountClass is the owner's usage account class ("standard",
	// "internal", "system", "demo"). Empty means "not known" — not
	// "standard": see the purpose vocabulary above for why the difference
	// matters.
	AccountClass string
}

ProvisionMeta is the ledger-side context a Provider may stamp onto an identity it creates. Every field is optional: an absent value drops the tag it would have fed and nothing else. The caller resolves these best-effort (see the worker's provisionMeta), so a store lookup that fails yields a partial ProvisionMeta rather than an error.

type ProvisionWorker

type ProvisionWorker struct {
	river.WorkerDefaults[ProvisionArgs]
	// contains filtered or unexported fields
}

ProvisionWorker registers the SES sending identity (BYODKIM) for a domain and, on success, enqueues a reconcile job to poll it to verified.

func (*ProvisionWorker) Work

type RawStore

type RawStore interface {
	WithSendingIdentityMutationLock(ctx context.Context, domain string, fn func(context.Context) error) error
	LoadSendingIdentityState(ctx context.Context, domain string) (incarnation, owner string, verified bool, status, selector string, privateKeyDER []byte, appliedIncarnation string, ledgerUpdatedAt time.Time, err error)
	SetSendingStatusForIncarnation(ctx context.Context, domain, incarnation, status, dkimStatus, mailFromStatus, errMsg string, recordsJSON []byte) error
	TouchSendingCheckedForIncarnation(ctx context.Context, domain, incarnation string) error
	MarkSendingIdentityManaged(ctx context.Context, domain, incarnation string) error
	MarkSendingIdentityApplied(ctx context.Context, domain, incarnation string) error
	SendingIdentityLedgerExpired(ctx context.Context, domain, incarnation string, olderThan time.Duration) (bool, error)
	ObserveSendingIdentityProviderPending(ctx context.Context, domain, incarnation string, olderThan time.Duration) (bool, error)
	ClearSendingIdentityProviderPending(ctx context.Context, domain, incarnation string) error
	ForgetSendingIdentityManaged(ctx context.Context, domain string) error
	FinalizeSendingIdentityTombstone(ctx context.Context, domain string, olderThan time.Duration) error
	SetDomainTeardownState(ctx context.Context, domain string, state domainteardown.State) error
	ListManagedSendingIdentityDomains(ctx context.Context) ([]string, map[string]bool, error)
	ListManagedSendingIdentityDomainsPage(ctx context.Context, afterDomain string, limit int) ([]string, map[string]bool, bool, error)
	LookupManagedSendingIdentityDomain(ctx context.Context, domain string) (bool, bool, error)
	DomainExists(ctx context.Context, domain string) (bool, error)
}

RawStore is the primitive persistence surface implemented by *identity.Store. It deliberately speaks plain strings / JSON bytes so the core identity package does NOT import senderidentity (and thus does not pull River + the AWS SDK into its dependency graph). NewStoreAdapter wraps it into the typed Store the workers consume.

type ReapArgs

type ReapArgs struct{}

ReapArgs is the legacy periodic kind. It stays registered so jobs inserted by the draining release are handled safely by the new worker.

func (ReapArgs) Kind

func (ReapArgs) Kind() string

type ReapV2Args added in v1.7.8

type ReapV2Args struct {
	SweepID       int64  `json:"sweep_id,omitempty" river:"unique"`
	AfterDomain   string `json:"after_domain,omitempty" river:"unique"`
	Phase         string `json:"phase,omitempty" river:"unique"`
	ProviderToken string `json:"provider_token,omitempty" river:"unique"`
}

ReapV2Args prevents the old blue/green slot from claiming new convergence sweeps during rollout.

func (ReapV2Args) Kind added in v1.7.8

func (ReapV2Args) Kind() string

type ReapWorker

type ReapWorker struct {
	river.WorkerDefaults[ReapV2Args]
	// contains filtered or unexported fields
}

ReapWorker is the durable teardown/provisioning backstop. Normal mutation jobs retry promptly, while this hourly sweep keeps retrying the bounded managed-domain ledger after River exhausts a job's finite attempt budget. It never deletes arbitrary identities returned by SES List: only domains in e2a's durable ownership ledger are eligible.

func (*ReapWorker) Work

func (w *ReapWorker) Work(ctx context.Context, job *river.Job[ReapV2Args]) error

type ReclaimConfig added in v1.8.5

type ReclaimConfig struct {
	// Enabled arms actual deletion. When false the reaper still runs the whole
	// decision and logs what it WOULD delete, but makes no provider mutation —
	// the observe-only mode an operator runs for days before arming.
	Enabled bool
	// Deployment is this deployment's configured name ("prod" | "staging"),
	// matched against the identity's e2a-env tag. Empty (an unnamed
	// deployment, the self-host default) reclaims nothing: without a name
	// there is no way to tell this deployment's fixtures from another
	// deployment's in a shared AWS account.
	Deployment string
	// Zones bounds reclaim to identity names at or under a DNS zone e2a's own
	// test fixtures live in. This is the strongest guard — a customer domain
	// is never under the test zone — and an EMPTY list means reclaim nothing,
	// never "any zone".
	Zones []string
	// MinAge is how old an identity must be (by its e2a-created stamp) before
	// it can be reclaimed, independent of its expiry tag. <= 0 reclaims
	// nothing: an unset floor is treated as an unconfigured policy, not as a
	// waiver.
	MinAge time.Duration
	// MaxPerSweep caps deletions per REAP JOB INVOCATION (see
	// reapProviderOrphanPage — the orphan phase is paginated across River
	// jobs, so this is deliberately a per-page budget and not a global one).
	// 0 reclaims nothing.
	MaxPerSweep int
}

ReclaimConfig is the operator's orphan-reclaim policy (config sender_identity.reap_orphans / reclaim_zones / reclaim_min_age / reclaim_max_per_sweep, plus the deployment name). Its zero value reclaims NOTHING: disarmed, no zones, no minimum age, no deletion budget. That is the only safe default for a subsystem whose failure mode is deleting a paying customer's ability to send mail.

type ReconcileArgs

type ReconcileArgs struct {
	Domain      string `json:"domain"`
	Incarnation string `json:"incarnation,omitempty"`
}

func (ReconcileArgs) Kind

func (ReconcileArgs) Kind() string

type ReconcileV2Args added in v1.7.8

type ReconcileV2Args struct {
	Domain      string `json:"domain"`
	Incarnation string `json:"incarnation"`
}

ReconcileV2Args likewise keeps new incarnation-aware polls away from an old worker that would ignore the incarnation field during a blue/green overlap.

func (ReconcileV2Args) Kind added in v1.7.8

func (ReconcileV2Args) Kind() string

type ReconcileV2Worker added in v1.7.8

type ReconcileV2Worker struct {
	river.WorkerDefaults[ReconcileV2Args]
	// contains filtered or unexported fields
}

func (*ReconcileV2Worker) Work added in v1.7.8

type ReconcileWorker

type ReconcileWorker struct {
	river.WorkerDefaults[ReconcileArgs]
	// contains filtered or unexported fields
}

ReconcileWorker polls SES for a pending domain and transitions it to verified/failed. While still pending it returns errStillPending so River retries with backoff; once the attempt budget is exhausted it marks the domain failed (bounded TTL — no infinite poll).

func (*ReconcileWorker) Work

type Result

type Result struct {
	Status         Status      `json:"status"`
	DkimStatus     Status      `json:"dkim_status,omitempty"`
	MailFromStatus Status      `json:"mail_from_status,omitempty"`
	Error          string      `json:"error,omitempty"`
	DNSRecords     []DNSRecord `json:"dns_records,omitempty"`
}

Result is what a Provider reports for a domain.

Status is the all-or-nothing rollup (mapSESStatus): `verified` only when EVERY sending axis is good. DkimStatus and MailFromStatus are the per-axis breakdown SES reports independently (DkimAttributes.Status and MailFromAttributes.MailFromDomainStatus), so a domain with good DKIM but a broken custom MAIL FROM surfaces as DkimStatus=verified + MailFromStatus=failed while the rollup Status stays `failed`. They are empty ("") when the provider has no per-axis signal (e.g. Provision, which only registers the identity); consumers fall back to the rollup in that case.

type SESProvider

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

SESProvider is the real Provider backed by AWS SES v2. It registers domain sending identities with BYODKIM, reusing e2a's per-domain DKIM key so the DKIM d= aligns with the From domain (DMARC passes on DKIM alignment), and configures a custom MAIL FROM subdomain (bounce.<domain>) so the Return-Path aligns too (SPF passes on the From org-domain → no "via e2a"). Every created identity carries an e2a ownership tag. An existing untagged identity is ADOPTED (tagged, then treated as owned) only when it is provably e2a's own — see canAdoptIdentity — which is what lets a domain created before the ownership tag shipped recover instead of being permanently stranded. Every other untagged identity is never adopted or mutated; IAM independently applies the same resource-tag condition to close the client-side check/mutation race.

func NewSESProvider

func NewSESProvider(api sesAPI, region, accountID string) *SESProvider

NewSESProvider wraps a pre-built SES API (or stub) with an ALREADY-KNOWN AWS account id. region feeds the MAIL FROM MX record target; accountID feeds the identity ARN adoption's TagResource call needs. No STS call is ever made on this path, so there is no ARN to derive a partition from — the ARN is built against the "aws" commercial partition. Every current production caller (NewSESProviderFromConfig, via main.go) resolves the partition from STS instead; this constructor exists for tests and any future caller that already knows its account id out-of-band.

func NewSESProviderFromConfig

func NewSESProviderFromConfig(ctx context.Context, region string, production bool) (*SESProvider, error)

NewSESProviderFromConfig builds a provider from ambient AWS config (env/instance role) for the given region. Unlike the account id, loading the ambient config itself is local (env/file reads, no network round-trip) and stays synchronous here — a genuinely broken/missing AWS config is a legitimate reason to fail startup. The AWS account id AND partition adoption's TagResource call needs are resolved LAZILY on first adoption attempt (see identityForAdoption) rather than here: STS GetCallerIdentity is a network call, and resolving it eagerly at construction — as this used to do — let a transient STS blip, an egress allowlist covering only SES, an SCP deny, or an IMDS hiccup fail server startup entirely (main.go wraps provider construction in log.Fatalf) for a value only adoption needs.

production gates adoption itself (batch C finding 10), independent of the account-id/STS plumbing above: canAdoptIdentity's "reachability bound" — that adoption is only ever attempted for a domain e2a's own DNS probe already confirmed the caller controls — holds only when domain verification actually enforces that probe, which internal/agent/api.go's checkDomainRecords short-circuits to unconditionally-"found" whenever !production. Passing production=false (i.e. cfg.Env != "production") makes Provision/Status refuse to adopt any untagged identity outright, regardless of what canAdoptIdentity would otherwise conclude, closing that gap for any non-production deployment that nonetheless configures sender_identity.ses_region against real AWS (e.g. a misconfigured `env`, or a self-hoster testing against live SES).

func (*SESProvider) Deprovision

func (p *SESProvider) Deprovision(ctx context.Context, domain string) error

func (*SESProvider) InspectIdentity added in v1.8.5

func (p *SESProvider) InspectIdentity(ctx context.Context, domain string) (IdentityAudit, error)

InspectIdentity reads the classification tags and the verified-for-sending bit off ONE GetEmailIdentity response (see Provider.InspectIdentity for why they must share a call). It deliberately makes no judgement — not even isManagedIdentity's — so that every reclaim decision lives in the pure, exhaustively tested orphanReclaimable rather than being split across a provider that is only exercised against live AWS.

func (*SESProvider) List

func (p *SESProvider) List(ctx context.Context) ([]string, error)

func (*SESProvider) ListPage added in v1.7.8

func (p *SESProvider) ListPage(ctx context.Context, nextToken string, limit int) ([]string, string, error)

func (*SESProvider) Provision

func (p *SESProvider) Provision(ctx context.Context, domain, dkimSelector string, dkimPrivateKeyDER []byte, meta ProvisionMeta) (Result, error)

func (*SESProvider) Status

func (p *SESProvider) Status(ctx context.Context, domain string, evidence AdoptionEvidence) (Result, error)

func (*SESProvider) WithIdentityTags added in v1.8.5

func (p *SESProvider) WithIdentityTags(deploymentName, provisionerBuild string, fixtureTTL time.Duration) *SESProvider

WithIdentityTags configures the runtime-derived classification tags this provider stamps on identities it creates. Every argument is optional: deploymentName "" (or an unrecognized name) omits the env tag, provisionerBuild "" omits the provisioner tag, and fixtureTTL <= 0 omits the expiry tag. An SESProvider that never gets this call still writes the ownership anchor and the creation stamp, so the zero value is a working provider, not a broken one.

type SendingIdentityState added in v1.7.8

type SendingIdentityState struct {
	Incarnation string
	Owner       string
	Verified    bool
	Status      Status
	Selector    string
	PrivateKey  []byte
	// AppliedIncarnation is the ledger's provider-confirmed incarnation ("" if
	// none). Equal to Incarnation only when THIS registration's key was
	// confirmed installed — the gate for the healthy-recheck no-op.
	AppliedIncarnation string
	// LedgerUpdatedAt remains available for diagnostics and adapters. Timeout
	// decisions use Store.SendingIdentityLedgerExpired so clocks are not mixed.
	LedgerUpdatedAt time.Time
}

SendingIdentityState is the incarnation-consistent desired-state snapshot that both provision and deprovision jobs converge at execution time.

type Status

type Status string

Status is the verification state of a domain's sending identity. It maps 1:1 onto the domains.sending_status column.

const (
	// StatusNone — no sending identity registered. Default for every
	// domain; self-host / SES-not-configured deployments stay here, which
	// keeps outbound on the relay From (fail-closed).
	StatusNone Status = "none"
	// StatusPending — identity registered with SES (BYODKIM), awaiting
	// asynchronous verification. The reconciler polls until it resolves.
	StatusPending Status = "pending"
	// StatusVerified — SES confirmed the identity; own-address From is now
	// used for this domain's agents.
	StatusVerified Status = "verified"
	// StatusFailed — verification failed, or `pending` exceeded its TTL.
	// Carries an actionable reason; outbound stays on the relay From.
	StatusFailed Status = "failed"
)

func (Status) Valid

func (s Status) Valid() bool

Valid reports whether s is one of the four known states.

type StatusCall added in v1.7.9

type StatusCall struct {
	Domain   string
	Selector string
	HaveKey  bool
}

StatusCall records one Status invocation's full argument set. Provider.Status carries a caller-supplied AdoptionEvidence beyond the domain — Selector and HasPrivateKey — that drives canAdoptIdentity's decision in the real SES provider. Recording only the domain (as this fake used to) would let a call site regress to passing "", a stale selector, or the wrong boolean while every worker test still passed, since the compiler cannot catch a wrong-but-same-typed argument. Tests that care about adoption wiring must assert on Selector/HaveKey, not just call counts.

type Store

type Store interface {
	// WithSendingIdentityMutationLock serializes provider create/delete calls
	// for one domain across processes and passes a context pinned to the lock.
	WithSendingIdentityMutationLock(ctx context.Context, domain string, fn func(context.Context) error) error
	// LoadSendingIdentityState returns the current domain incarnation and its
	// desired provider state. pgx.ErrNoRows means the desired state is absent.
	LoadSendingIdentityState(ctx context.Context, domain string) (SendingIdentityState, error)
	// SetSendingStatus writes a terminal/transition status (+ the per-axis
	// dkim/mailFrom breakdown + error + DNS records) and stamps
	// sending_last_checked_at. dkimStatus/mailFromStatus may be empty ("")
	// when the caller has no per-axis signal (e.g. provision, or a terminal
	// failure with no SES poll); persisting empty lets the read path fall back
	// to the all-or-nothing rollup.
	SetSendingStatus(ctx context.Context, domain, incarnation string, status, dkimStatus, mailFromStatus Status, errMsg string, records []DNSRecord) error
	// TouchSendingChecked stamps sending_last_checked_at without changing the
	// status — used on a still-pending poll.
	TouchSendingChecked(ctx context.Context, domain, incarnation string) error
	// The managed-domain ledger survives domain deletion so exhausted River
	// jobs remain repairable without scanning/deleting unrelated identities in
	// the provider account.
	MarkSendingIdentityManaged(ctx context.Context, domain, incarnation string) error
	MarkSendingIdentityApplied(ctx context.Context, domain, incarnation string) error
	// SendingIdentityLedgerExpired compares entirely on the database clock so
	// application/DB clock skew cannot shorten or extend the pending TTL.
	SendingIdentityLedgerExpired(ctx context.Context, domain, incarnation string, olderThan time.Duration) (bool, error)
	// ObserveSendingIdentityProviderPending persists the first provider-pending
	// observation for a DB-verified identity and reports whether its grace has
	// expired, also using the database clock. Clear removes that drift marker
	// once the provider agrees or a terminal transition is committed.
	ObserveSendingIdentityProviderPending(ctx context.Context, domain, incarnation string, olderThan time.Duration) (bool, error)
	ClearSendingIdentityProviderPending(ctx context.Context, domain, incarnation string) error
	// ForgetSendingIdentityManaged currently has no callers in this package —
	// see the doc comment on *identity.Store's implementation for why it is
	// kept anyway, and why an ownership failure must never call it.
	ForgetSendingIdentityManaged(ctx context.Context, domain string) error
	// FinalizeSendingIdentityTombstone removes the ledger row ONLY when its
	// last mutation (updated_at) is older than olderThan. An audit or sweep
	// that runs inside a later mutation's drain window therefore cannot
	// finalize that mutation's tombstone out from under a still-draining
	// legacy slot; the reaper (or that mutation's own audit) finalizes once
	// the window has truly elapsed.
	FinalizeSendingIdentityTombstone(ctx context.Context, domain string, olderThan time.Duration) error
	SetDomainTeardownState(ctx context.Context, domain string, state domainteardown.State) error
	ListManagedSendingIdentityDomains(ctx context.Context) ([]string, map[string]bool, error)
	ListManagedSendingIdentityDomainsPage(ctx context.Context, afterDomain string, limit int) ([]string, map[string]bool, bool, error)
	LookupManagedSendingIdentityDomain(ctx context.Context, domain string) (needsProvision, found bool, err error)
	// DomainExists reports whether a live domain row exists. The reaper uses
	// it to ALERT on provider identities that are neither ledgered nor backed
	// by a row (pre-upgrade orphans the migration backfill could not see).
	DomainExists(ctx context.Context, domain string) (bool, error)
	// AccountClassForUser returns the owner's usage account class
	// ("standard" | "internal" | "system" | "demo") for the provider's
	// classification tags. An empty string means "not known" and is NOT an
	// error: a store with no account-class source wired (self-host, tests)
	// answers that way, and the provider simply omits the purpose tag.
	AccountClassForUser(ctx context.Context, userID string) (string, error)
}

Store is the narrow persistence surface the workers need. *identity.Store satisfies it. Kept minimal so the workers don't depend on the whole store.

func NewStoreAdapter

func NewStoreAdapter(raw RawStore, accountClass AccountClassFunc) Store

NewStoreAdapter bridges a RawStore (e.g. *identity.Store) to the typed Store the workers use, converting Status ↔ string and DNSRecord ↔ JSON.

accountClass is OPTIONAL and may be nil: the account class exists only to classify provisioned provider identities (see tags.go), so a deployment that does not wire it reads as "class unknown" and loses one tag, rather than erroring on a path that verifies customer domains.

type SyncArgs added in v1.7.8

type SyncArgs struct {
	Domain string `json:"domain"`
}

SyncArgs is the rollout-safe desired-state mutation kind. Old blue/green binaries do not register this kind, so they cannot claim newly enqueued create/delete work while the new binary is baking or the old slot drains.

func (SyncArgs) Kind added in v1.7.8

func (SyncArgs) Kind() string

type SyncWorker added in v1.7.8

type SyncWorker struct {
	river.WorkerDefaults[SyncArgs]
	// contains filtered or unexported fields
}

SyncWorker handles all newly enqueued provider mutations. Legacy workers remain registered only to drain jobs written by the prior release.

func (*SyncWorker) Work added in v1.7.8

func (w *SyncWorker) Work(ctx context.Context, job *river.Job[SyncArgs]) error

Jump to

Keyboard shortcuts

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