httpapi

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

Documentation

Overview

Package httpapi is the e2a v1 HTTP contract layer, built on Huma + chi.

It exists to make the OpenAPI 3.1 spec the single source of truth: every operation is declared with typed Go input/output structs, and Huma emits the spec *and* validates requests from those same definitions, so the handler is the contract and the spec cannot drift by construction (api-v1-redesign §6). This package is the foundation slice (Slice 1): the canonical error envelope, cursor pagination, idempotency, and shared middleware that every ported operation reuses.

chi owns the `/v1` prefix and falls back to the legacy gorilla/mux for the remaining non-v1 routes (OAuth, session auth, health/feedback, the magic-link approve/reject pages). The `/api/v1` surface this strangler replaced is fully retired — no `/api/v1` route is registered anymore.

Index

Constants

View Source
const APIVersion = "1.0.0"

APIVersion is the public /v1 contract version. Single source for both the OpenAPI document version (huma.DefaultConfig) and the GET /v1/info `version` field; tracks the repo-root VERSION file.

Variables

View Source
var ErrInvalidCursor = errors.New("invalid pagination cursor")

ErrInvalidCursor is returned when a cursor fails to decode. Handlers map it to a 400 with code "invalid_cursor".

Functions

func DecodeCursor

func DecodeCursor(secrets []string, cursor string, dst any) error

DecodeCursor verifies a cursor's HMAC and reverses it into dst. A malformed, tampered, or wrong-secret cursor yields ErrInvalidCursor rather than a generic error so callers branch on a stable sentinel. An empty cursor is treated as "start from the beginning" — dst is left untouched and nil is returned.

secrets is tried in order and the signature is compared in constant time (hmac.Equal, mirroring approvaltoken.Verify). Accepting a slice supports HMAC-secret rotation: a cursor signed under an old secret keeps verifying until that secret is retired. Today callers pass a single-element slice.

Old unsigned cursors (plain base64url(json) with no "." signature segment, as emitted before issue #144 M2) no longer verify and are hard-rejected with ErrInvalidCursor. Cursors are ephemeral, so a client mid-pagination simply restarts the query.

func EncodeCursor

func EncodeCursor(secret string, payload any) (string, error)

EncodeCursor serializes an arbitrary cursor payload (the position + filter snapshot a resource needs to resume) into the opaque, URL-safe, tamper-evident string clients echo back.

The cursor is HMAC-signed (issue #144, finding M2): a client can no longer decode the base64, edit a field, and re-encode it — any such edit breaks the signature and DecodeCursor rejects it. The cursor remains opaque to clients; the payload shape stays private to each resource.

Format:

base64url(json_payload) + "." + base64url(hmac_sha256(secret, base64url(json_payload)))

The MAC is computed over the LITERAL emitted base64url(json) segment (not a re-marshaled struct) so encode and verify are byte-canonical and cannot drift. secret is the deployment HMAC secret (config.Signing.HMACSecret) — the same deployment key used by approval tokens, so there is no new key to manage.

func RequestFromContext

func RequestFromContext(ctx context.Context) *http.Request

RequestFromContext recovers the raw request stashed by withRawRequest.

func RequestIDFromContext

func RequestIDFromContext(ctx context.Context) string

RequestIDFromContext returns the request id stamped by the requestID middleware, or "" if none is present.

func WriteError

func WriteError(w http.ResponseWriter, r *http.Request, status int, code, message string)

WriteError writes the canonical v1 error envelope to a raw ResponseWriter for handlers OUTSIDE this package that bypass Huma — specifically the WebSocket upgrade handshake (internal/ws), which authenticates and authorizes BEFORE the upgrade and so rejects a bad handshake with a normal HTTP response. Routing those rejections through here makes the WS handshake body byte-for-byte consistent with every /v1 REST endpoint: {error:{code,message,request_id}} + X-Request-Id. The caller supplies the status and a code from the REST vocabulary (unauthorized / forbidden / not_found / invalid_request); status codes are the caller's to choose so this never rewrites them.

Types

type APIKeyView

type APIKeyView struct {
	ID        string `json:"id"`
	Name      string `json:"name"`
	KeyPrefix string `json:"key_prefix" doc:"Non-secret visible prefix (e.g. e2a_acct_… / e2a_agt_…)."`
	// Scope is an OPEN set on this response view (evolving vocabulary); the
	// REQUEST-side CreateAPIKeyRequest.scope keeps its closed enum, which is
	// where validation belongs.
	Scope      string     `` /* 203-byte string literal not displayed */
	Agent      string     `json:"agent_email,omitempty" doc:"Bound inbox email for agent-scoped keys; omitted for account scope."`
	CreatedAt  time.Time  `json:"created_at"`
	LastUsedAt *time.Time `json:"last_used_at,omitempty"`
	ExpiresAt  *time.Time `json:"expires_at,omitempty"`
}

APIKeyView is the non-secret metadata for an API key (list + create). The plaintext secret is never in this shape — it appears once, in CreateAPIKeyResponse.Key.

type AccountUserView

type AccountUserView struct {
	ID    string `json:"id"`
	Email string `json:"email"`
}

AccountUserView is the authenticated principal's identity (A-1). Returned by GET /v1/account (MCP `whoami`) so an agent/operator can answer "who am I" without a follow-up call.

type AccountView

type AccountView struct {
	User AccountUserView `json:"user"`
	// Scope is an OPEN set on this response view (evolving vocabulary), not a
	// closed enum — see docs/api.md "Versioning & stability".
	Scope        string          `` /* 166-byte string literal not displayed */
	AgentAddress string          `json:"agent_email,omitempty"`
	PlanCode     string          `json:"plan_code"`
	Limits       LimitsCapsView  `json:"limits"`
	Usage        LimitsUsageView `json:"usage"`
	UpgradeURL   string          `json:"upgrade_url"`
}

AccountView is the authenticated account: identity + plan + limits + usage (A-2, formerly LimitsView). `whoami` maps here. It is scope-aware: `scope` is always set, and `agent_email` is populated ONLY for agent-scoped credentials (where the credential *is* a single agent) — omitted for account scope, which spans many agents.

type AddressParam

type AddressParam struct {
	Address string `path:"email" doc:"The agent's full email address, e.g. support@acme.com."`
}

AddressParam is the shared path input for per-agent operations. The address is the agent's full email and the resource identifier (api-v1-redesign decision 1); Huma URL-decodes it from the path.

type AgentCreateEnforcer

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

AgentCreateEnforcer mirrors enforcer.CheckAgentCreate; returns a limits.LimitExceededError when the per-user cap is hit.

type AgentCreator

type AgentCreator func(ctx context.Context, email, domain, name, webhookURL, agentMode, userID string) (*identity.AgentIdentity, error)

AgentCreator mirrors store.CreateAgent. The webhookURL/agentMode params are retained for signature compatibility with the store but are ignored — the legacy columns were dropped (migration 029). Handlers pass "".

type AgentDeleter

type AgentDeleter func(ctx context.Context, agentID, userID string) (messagesDeleted int64, err error)

AgentDeleter deletes an agent, returning the number of message rows removed by the cascade (surfaced in the DeleteAgentResult receipt).

type AgentGetter

type AgentGetter func(ctx context.Context, address string) (*identity.AgentIdentity, error)

AgentGetter loads a single agent by its full email address (the identifier). Ownership is checked by the caller against the resolved agent's UserID.

type AgentLister

type AgentLister func(ctx context.Context, userID string, limit int, afterCreatedAt time.Time, afterID string) ([]identity.AgentIdentity, error)

AgentLister returns one page of the agents owned by a user, keyset-paginated on (created_at, id): limit is the page size (the handler passes limit+1 to detect a further page; limit<=0 returns every agent, which the webhook filter-ownership validation relies on), and afterCreatedAt/afterID is the position from the previous page's last row (zero afterCreatedAt = first page). Injected as a narrow function so the foundation slice doesn't depend on the whole store.

type AgentSuppressionView

type AgentSuppressionView struct {
	AgentEmail string    `json:"agent_email"`
	Address    string    `json:"address"`
	Reason     string    `json:"reason,omitempty"`
	Source     string    `json:"source" doc:"How the block was created. Known values: unsubscribe, manual."`
	CreatedAt  time.Time `json:"created_at"`
}

AgentSuppressionView is one recipient block scoped to an exact sending agent. AgentEmail keeps list items self-describing outside their route.

type AgentTrashOp

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

AgentTrashOp moves an agent into or out of trash without deleting messages.

type AgentView

type AgentView struct {
	Domain           string    `json:"domain" doc:"The exact domain in the agent email address."`
	RegisteredDomain string    `` /* 170-byte string literal not displayed */
	Email            string    `json:"email"`
	Name             string    `json:"name"`
	DomainVerified   bool      `json:"domain_verified"`
	CreatedAt        time.Time `json:"created_at"`
	// DeletedAt marks an agent in the trash (soft-deleted): set when the row
	// was moved there, omitted on live agents. Trashed agents appear only via
	// GET /v1/agents?deleted=true and restore/permanent-delete operations; they
	// are purged ~30 days after deletion (docs/design/trash-soft-delete.md).
	DeletedAt *time.Time `` /* 278-byte string literal not displayed */
}

AgentView is the public representation of an agent. The legacy webhook_url and agent_mode fields were dropped (migration 029). The per-agent screening/HITL config moved to the account-scoped /v1/agents/{email}/protection sub-resource (design 2026-06-22), so AgentView is identity + status only — an agent-scoped credential reading its own agent no longer learns its detection tuning (closes audit #13). The redundant `id` field was dropped: an agent's id IS its email, so `email` is the sole identity key (mirroring DomainView keyed on `domain` and Suppression on `address`).

type AttachmentMetaView

type AttachmentMetaView = eventpayload.AttachmentMetaView

AttachmentMetaView is metadata for one attachment of a message — never the bytes. `index` is the stable 0-based attachment index (document order) used to fetch the bytes via the attachment endpoint. SizeBytes is the DECODED payload size (Content-Transfer-Encoding undone) — the size of the file a download yields, NOT the encoded size inside the raw MIME and NOT the message-level size_bytes (raw MIME length of the whole message).

Alias of the canonical eventpayload.AttachmentMetaView so the REST message views, the stable event payloads, and the account export publish ONE public attachment-metadata component schema.

type AttachmentStore

type AttachmentStore interface {
	// DownloadURL returns a short-lived URL the caller (or a sandboxed tool) can
	// GET for the bytes, plus its expiry.
	DownloadURL(agentEmail, messageID string, index int, ttl time.Duration) (downloadURL string, expiresAt time.Time, err error)
	// VerifyDownload reports whether `token` authorizes downloading attachment
	// `index` of `messageID` (and is unexpired). Identity is NOT in the token;
	// the download handler additionally binds the message to the path agent.
	VerifyDownload(token, messageID string, index int) bool
}

AttachmentStore mints (and verifies) a short-lived download for one attachment. The native adapter (below) returns a capability-token URL to e2a's own streaming route; a future object-storage adapter could return a presigned URL instead — the metadata/contract shape ({download_url, expires_at}) is the same.

func NewNativeAttachmentStore

func NewNativeAttachmentStore(secret, publicURL string) AttachmentStore

NewNativeAttachmentStore returns the default (zero-dependency) attachment store.

type AttachmentView

type AttachmentView struct {
	Index       int       `json:"index"`
	Filename    string    `json:"filename,omitempty"`
	ContentType string    `json:"content_type,omitempty"`
	ContentID   string    `` /* 182-byte string literal not displayed */
	SizeBytes   int       `` /* 229-byte string literal not displayed */
	DownloadURL string    `json:"download_url"`
	ExpiresAt   time.Time `json:"expires_at" format:"date-time"`
	// Data is the base64 bytes, present ONLY when inline=true and the attachment
	// is within the inline size cap.
	Data string `json:"data,omitempty"`
}

AttachmentView is the metadata + short-lived download for one attachment. Bytes are NOT included unless inline was requested for a small file. SizeBytes is the DECODED payload size (Content-Transfer-Encoding undone) — exactly the byte count download_url serves and the count the inline cap is checked against. NOT the encoded size inside the raw MIME, and NOT the message-level size_bytes (raw MIME length of the whole message).

type Authenticator

type Authenticator func(r *http.Request) (*identity.User, error)

Authenticator resolves the calling user from the raw request. It is injected so this package reuses the *single* auth path that already lives in the agent layer (API key, OAuth bearer, session cookie) instead of forking a second one — there is exactly one place credentials are checked.

type ConversationDetailView

type ConversationDetailView struct {
	ConversationSummaryView
	Participants []string             `json:"participants" nullable:"false"`
	Labels       []string             `json:"labels" nullable:"false"`
	Messages     []MessageSummaryView `json:"messages" nullable:"false"`
}

ConversationDetailView is the single-conversation shape: the summary fields (flattened via embedding, matching the legacy top-level layout) plus participants, labels, and the member message summaries.

type ConversationGetter

type ConversationGetter func(ctx context.Context, agentID, conversationID string) (*identity.ConversationDetail, error)

ConversationGetter mirrors store.GetConversationByID(agentID, conversationID).

type ConversationIDParam

type ConversationIDParam struct {
	Address        string `path:"email"`
	ConversationID string `path:"id"`
}

ConversationIDParam is the path input for the single-conversation read.

type ConversationLister

type ConversationLister func(ctx context.Context, filter identity.ConversationListFilter) ([]identity.ConversationSummary, error)

ConversationLister mirrors store.ListConversationsByAgent(filter).

type ConversationSummaryView

type ConversationSummaryView struct {
	ID             string    `json:"id"`
	LastMessageAt  time.Time `json:"last_message_at" format:"date-time"`
	FirstMessageAt time.Time `json:"first_message_at" format:"date-time"`
	MessageCount   int       `json:"message_count"`
	InboundCount   int       `json:"inbound_count"`
	OutboundCount  int       `json:"outbound_count"`
	HasUnread      bool      `json:"has_unread"`
	LatestSubject  string    `json:"latest_subject"`
	LatestSender   string    `json:"latest_from"`
}

ConversationSummaryView is one conversation in the list. Timestamps are typed time.Time with format date-time (C-1) — consistent with every other timestamp in the surface (was previously plain strings, which generated an untyped `string` in the SDKs and risked a .getTime()/parse crash).

type CoveringDomainLookup

type CoveringDomainLookup func(ctx context.Context, sub, userID string) (*identity.Domain, error)

CoveringDomainLookup mirrors store.LookupCoveringDomain(sub, userID): the create-time fallback that finds the most-specific registered parent domain the user owns which covers an agent's subdomain (label-boundary match). The caller checks its Verified state so a pending child cannot be masked. Nil is tolerated (feature disabled ⇒ exact-match-only behavior).

type CreateAPIKeyRequest

type CreateAPIKeyRequest struct {
	Name      string `json:"name,omitempty" maxLength:"200" doc:"Human label for the key. At most 200 characters (Unicode code points)."`
	ExpiresAt string `` /* 139-byte string literal not displayed */
	Scope     string `json:"scope,omitempty" enum:"account,agent" doc:"account = workspace admin (default); agent = bound to a single inbox."`
	Agent     string `json:"agent_email,omitempty" doc:"Inbox email to bind the key to; required when scope=agent."`
}

CreateAPIKeyRequest mirrors the legacy /api/keys body. scope defaults to account; scope=agent requires `agent_email` (an owned inbox email).

type CreateAPIKeyResponse

type CreateAPIKeyResponse struct {
	APIKeyView
	Key string `json:"key" doc:"The secret key. Shown once; store it now — it cannot be retrieved later."`
}

CreateAPIKeyResponse is APIKeyView plus the one-time plaintext key — shown once at creation and never retrievable again.

type CreateAgentRequest

type CreateAgentRequest struct {
	Email string `` /* 129-byte string literal not displayed */
	Name  string `` /* 197-byte string literal not displayed */
}

CreateAgentRequest is the /v1 agent-create body. The legacy agent_mode and webhook_url fields were dropped (migration 029): push is delivered solely via the /v1/webhooks subscriber resource and WebSocket is open to all agents, so per-agent mode/webhook no longer exist. Fields are schema-optional (omitempty) so validation is handler-owned and uniform — the legacy 400 business-rule messages, not Huma's 422 (email itself can't be schema-required since the slug path derives it). CreateAgentRequest is the create-agent body (AG-1/AG-2). `email` is required and is the single create path: a custom-domain agent uses an email on a verified domain the caller owns; a shared-domain agent is just an email on the deployment's shared domain (e.g. xyz@agents.e2a.dev) — detected by the domain, not a separate `slug` field. The legacy `slug` field is dropped.

type CreateAgentSuppressionRequest

type CreateAgentSuppressionRequest struct {
	Address string `` /* 137-byte string literal not displayed */
	Reason  string `` /* 135-byte string literal not displayed */
}

type CreateTemplateRequest

type CreateTemplateRequest struct {
	Name        string `json:"name,omitempty" doc:"Human-readable template name. Required unless from_starter supplies the default."`
	Alias       string `` /* 126-byte string literal not displayed */
	Subject     string `` /* 139-byte string literal not displayed */
	Body        string `json:"text,omitempty" doc:"Plain-text body template source (no HTML escaping). Required unless from_starter is set."`
	HTMLBody    string `json:"html,omitempty" doc:"Optional HTML body template source ({{x}} is HTML-escaped, {{{x}}} is raw)."`
	FromStarter string `` /* 392-byte string literal not displayed */
}

CreateTemplateRequest — either literal source (name, subject and text required; alias and html optional) or from_starter (a starter alias copied verbatim; name/alias default to the starter's). All template parts must parse. The required-ness of name/subject/text is enforced in the handler (validateTemplateFields), not the schema, because from_starter supplies them.

type CreateWebhookRequest

type CreateWebhookRequest struct {
	URL         string                 `json:"url" doc:"Required, non-empty webhook delivery URL."`
	Events      []string               `` /* 492-byte string literal not displayed */
	Filters     *WebhookFiltersRequest `json:"filters,omitempty"`
	Description string                 `json:"description,omitempty"`
}

CreateWebhookRequest — url + events are required (WH-1). events items are constrained to the canonical vocabulary (WH-2; keep in sync with webhookpub.AllEventTypes).

type CreateWebhookResponse

type CreateWebhookResponse struct {
	WebhookView
	SigningSecret string `json:"signing_secret"`
}

CreateWebhookResponse is WebhookView plus the one-time signing secret (WH-3), returned only by createWebhook. Persist the secret on receipt — it is never shown again (use rotate-secret to mint a new one).

type DNSRecord

type DNSRecord struct {
	Type     string `json:"type" doc:"DNS record type. MX or TXT."`
	Name     string `json:"name" doc:"Record name (host). The apex domain for ownership/inbound_mx; an FQDN for dkim/mail_from records."`
	Value    string `json:"value" doc:"Record value. For MX records this is the mail-server host only; the priority is in the priority field."`
	Priority *int   `json:"priority" doc:"MX priority. Null for non-MX records."`
	Purpose  string `` /* 402-byte string literal not displayed */
	Status   string `` /* 1431-byte string literal not displayed */
}

DNSRecord is one row in the unified DomainView.DNSRecords array. It supersedes the legacy split of `dns_records` (an mx/txt/dkim object) + `sending_dns_records` (an array): every record the customer must publish — inbound and sending — now carries its own `purpose` and `status`, mirroring how peers (Resend/SendGrid) model DNS setup. MX records carry their priority in the dedicated `priority` field (TXT records leave it null) rather than embedding it in `value`.

type DeleteAgentResult

type DeleteAgentResult struct {
	Deleted         bool   `` /* 126-byte string literal not displayed */
	Email           string `json:"email" doc:"Email address of the deleted agent."`
	MessagesDeleted int64  `json:"messages_deleted" doc:"Number of messages permanently removed by the cascade; zero when the agent is moved to trash."`

} // @name DeleteAgentResult

DeleteAgentResult confirms an agent delete. For a soft delete the agent is inactive in trash and messages_deleted is zero. For a permanent delete it is the number of message rows removed with the agent (webhook-delivery rows cascade from those and are not counted separately).

type DeleteApiKeyResult

type DeleteApiKeyResult struct {
	Deleted bool   `json:"deleted" doc:"Always true — the key is revoked. A failed delete is an error envelope, never deleted:false."`
	ID      string `json:"id" doc:"ID of the revoked API key."`

} // @name DeleteApiKeyResult

DeleteApiKeyResult confirms an API-key revocation.

type DeleteConfirm

type DeleteConfirm struct {
	Confirm string `query:"confirm" enum:"DELETE" required:"true" doc:"Must be the literal DELETE — this action is irreversible."`
}

DeleteConfirm is the uniform destructive-delete guard embedded in every DELETE input across the /v1 surface (agents, domains, account, account suppressions, api-keys, templates, webhooks).

Modeling it as a required query param with a single-value enum makes Huma validate it declaratively: a request missing `confirm` — or carrying any value other than the literal "DELETE" — is rejected with 422 by the framework before the handler runs, and the constraint surfaces in the generated OpenAPI (required + enum: [DELETE]) so SDK/CLI codegen and the API reference show it. The guard exists to stop accidental raw-HTTP deletes; the hand-written SDK/CLI `delete(...)` layers auto-send `confirm=DELETE`, so explicit `.delete()` calls are never burdened with it.

This replaces the previous per-endpoint optional-string `confirm` that was checked in the handler with a bespoke error — that enforcement is now owned by Huma (422 invalid_request) so the gate is declared once and is identical on every delete op. The one op where confirm is only conditionally required (permanent message deletion — the default trash delete takes no confirm, so the param can't be schema-required) enforces the same contract in its handler and emits the identical 422 invalid_request.

type DeleteDomainResult

type DeleteDomainResult struct {
	Deleted bool   `json:"deleted" doc:"Always true — the domain no longer exists. A failed delete is an error envelope, never deleted:false."`
	Domain  string `json:"domain" doc:"The deleted domain."`

} // @name DeleteDomainResult

DeleteDomainResult confirms a domain delete.

type DeleteMessageResult

type DeleteMessageResult struct {
	Deleted bool   `` /* 146-byte string literal not displayed */
	ID      string `json:"id" doc:"ID of the deleted message."`

} // @name DeleteMessageResult

DeleteMessageResult confirms a message delete.

type DeleteSuppressionResult

type DeleteSuppressionResult struct {
	Deleted bool   `` /* 132-byte string literal not displayed */
	Address string `json:"address" doc:"The un-suppressed recipient address."`

} // @name DeleteSuppressionResult

DeleteSuppressionResult confirms a suppression-list removal.

type DeleteTemplateResult

type DeleteTemplateResult struct {
	Deleted bool   `` /* 126-byte string literal not displayed */
	ID      string `json:"id" doc:"ID of the deleted template."`

} // @name DeleteTemplateResult

DeleteTemplateResult confirms a template delete.

type DeleteWebhookResult

type DeleteWebhookResult struct {
	Deleted bool   `json:"deleted" doc:"Always true — the webhook no longer exists. A failed delete is an error envelope, never deleted:false."`
	ID      string `json:"id" doc:"ID of the deleted webhook."`

} // @name DeleteWebhookResult

DeleteWebhookResult confirms a webhook delete.

type DeploymentInfoView

type DeploymentInfoView struct {
	Version                 string `json:"version"`
	SharedDomain            string `json:"shared_domain"`
	SlugRegistrationEnabled bool   `json:"slug_registration_enabled"`
	PublicURL               string `json:"public_url,omitempty"`
}

DeploymentInfoView is the public, unauthenticated deployment-discovery body. `version` (I-1) lets clients detect the API contract version pre-auth — the cheapest forward-compatibility lever.

type Deps

type Deps struct {
	Authenticator          Authenticator
	PrincipalAuthenticator PrincipalAuthenticator
	// AuthChallenge builds the RFC 6750 §3 WWW-Authenticate header value for a
	// request that failed authentication. Injected so the v1 surface advertises
	// the Bearer scheme (and OAuth error params) on every 401 exactly like the
	// legacy mux did, from the same definition (agent.API.WWWAuthenticateChallenge).
	// Optional — nil disables the challenge header.
	AuthChallenge func(r *http.Request) string
	ListAgents    AgentLister
	GetAgent      AgentGetter
	GetMessage    MessageGetter
	ListMessages  MessageLister
	// ModifyMessageLabels applies a labels delta to a message scoped to an
	// agent, returning the post-update set. Mirrors store.ModifyMessageLabels.
	ModifyMessageLabels func(ctx context.Context, messageID, agentID string, add, remove []string) ([]string, error)

	ListConversations ConversationLister
	GetConversation   ConversationGetter

	CreateAgent          AgentCreator
	LookupDomain         DomainLookup
	LookupCoveringDomain CoveringDomainLookup
	// ResolveMX backs the required create-time subdomain MX gate.
	ResolveMX          MXResolver
	EnforceAgentCreate AgentCreateEnforcer
	// UpdateAgentName updates an agent's display name (the only mutable field on
	// the agent PATCH after the screening config moved to /protection).
	UpdateAgentName func(ctx context.Context, agentID, userID, name string) error
	// UpdateAgentProtection writes the full per-agent protection posture (gate +
	// scan sensitivity + holds) for the /v1/agents/{email}/protection resource.
	// Returns a validation error for an invalid posture, which the handler maps
	// to 400 invalid_request.
	UpdateAgentProtection func(ctx context.Context, agentID, userID string, cfg identity.ProtectionConfig) error
	// DeleteAgent is the DEFAULT delete: soft (move to trash, restorable for
	// identity.TrashRetention, docs/design/trash-soft-delete.md).
	// PermanentDeleteAgent is the irreversible hard delete behind
	// ?permanent=true; RestoreAgent brings a trashed agent back.
	DeleteAgent          AgentTrashOp
	PermanentDeleteAgent AgentDeleter
	RestoreAgent         AgentTrashOp
	// GetAgentAnyState loads an agent regardless of trash state (DeletedAt set
	// when trashed) — the resolution path for restore / permanent delete, which
	// must find agents the live GetAgent treats as nonexistent.
	GetAgentAnyState AgentGetter
	// ListDeletedAgents is the account's agent trash (GET /v1/agents?deleted=true).
	ListDeletedAgents AgentLister

	// Message trash ops (DELETE / POST restore on
	// /v1/agents/{email}/messages/{id}): soft delete, restore, and the
	// trash-only permanent purge.
	DeleteMessage  MessageTrashOp
	RestoreMessage MessageTrashOp
	PurgeMessage   MessageTrashOp

	// domains. ListDomains is keyset-paginated on (created_at, domain): the
	// handler passes limit+1 to detect a further page (limit<=0 = all), and the
	// after-key from the previous page's last row (zero afterCreatedAt = first
	// page).
	ListDomains         func(ctx context.Context, userID string, limit int, afterCreatedAt time.Time, afterDomain string) ([]identity.Domain, error)
	SendingRampSnapshot func(ctx context.Context, userID, domain string, now time.Time) (sendramp.Snapshot, error)
	ClaimDomain         func(ctx context.Context, domain, userID string) (*identity.Domain, error)
	EnforceDomainCreate func(ctx context.Context, userID string) error
	DeleteDomain        func(ctx context.Context, domain, userID string) error
	HasAgentsOnDomain   func(ctx context.Context, domain, userID string) (bool, error)

	// SMTPDomain is the relay's MX host, surfaced in the DNS records a
	// domain must publish (config smtp.domain).
	SMTPDomain string

	// SESRegion is the AWS region of the SES sending identity
	// (config sender_identity.ses_region). Non-empty ⇒ the sending feature is
	// enabled: domainView emits the deterministic mail_from_* records. Empty ⇒
	// sending is off and those records are omitted.
	SESRegion string

	// CursorSecret is the deployment HMAC secret (config.Signing.HMACSecret)
	// used to sign/verify pagination cursors so they are tamper-evident
	// (issue #144 M2). The same deployment key used by approvaltoken — no new
	// key. Handlers pass it to EncodeCursor and wrap it
	// in a 1-element slice for DecodeCursor (whose verify loop supports N for
	// a future secret rotation). Empty in minimal test setups, which is fine:
	// encode and verify stay consistent under the same (empty) key.
	CursorSecret string

	// Idempotency is the retry-safety store for unsafe writes (send/reply/
	// forward/redeliver). Optional — nil disables the Idempotency-Key path.
	Idempotency IdemStore

	// outbound (the shared live delivery path extracted from agent.API)
	DeliverOutbound func(ctx context.Context, user *identity.User, ag *identity.AgentIdentity, req outbound.SendRequest, msgType, replyToEmailMessageID string, referenced *identity.Message, idemCompleteTx agent.AcceptIdemCompleter) (*agent.OutboundResult, *agent.OutboundError)
	SendTest        func(ctx context.Context, ag *identity.AgentIdentity) (*agent.OutboundResult, *agent.OutboundError)
	// PollSendOutcome reads an async send's current delivery_status for wait=sent.
	// Optional — nil disables the wait valve (accepted is returned immediately).
	PollSendOutcome func(ctx context.Context, messageID string) (identity.SendOutcome, error)
	// HITL approve/reject (the held-draft decision)
	ApprovePending     func(ctx context.Context, userID, messageID, expectedAgentEmail string, ovr agent.ApproveOverrides, idemCompleteTx agent.ApproveIdemCompleter) (*identity.Message, *agent.OutboundError)
	RejectPending      func(ctx context.Context, userID, messageID, expectedAgentEmail, reason string) (*identity.Message, *agent.OutboundError)
	EnforceMessageSend func(ctx context.Context, userID string) error
	// Inbound review release — the held-screening decision (design 2026-06-22 §5).
	// GetReviewMessage resolves a held message's direction so /approve+/reject can
	// branch (it intentionally sees held inbound statuses, scoped to the resolved
	// owned agent — account-scope only). ApproveInboundReview releases the message
	// to the agent's inbox; RejectInboundReview drops it. Both fire the unified
	// review_approved/review_rejected events. Optional — nil leaves the endpoints
	// outbound-only (pre-slice-3 behavior).
	GetReviewMessage     func(ctx context.Context, messageID, agentID string) (*identity.ReviewMessageMeta, error)
	ApproveInboundReview func(ctx context.Context, userID string, msg *identity.ReviewMessageMeta) *agent.OutboundError
	RejectInboundReview  func(ctx context.Context, userID, reason string, msg *identity.ReviewMessageMeta) *agent.OutboundError

	// Review queue (account-scoped /v1/reviews). ListReviews returns all holds
	// (both directions) across the user's agents; GetReviewWithContent loads one
	// held message (ownership-scoped) for the detail view + approve/reject
	// resolution. Both intentionally include held inbound — operator surface only.
	// ListReviews is keyset-paginated on (created_at, id): the handler passes
	// limit+1 to detect a further page and the after-key from the previous page's
	// last row (zero afterCreatedAt = first page).
	ListReviews          func(ctx context.Context, userID string, limit int, afterCreatedAt time.Time, afterID string) ([]identity.ReviewListItem, error)
	GetReviewWithContent func(ctx context.Context, userID, messageID string) (*identity.Message, error)
	// ListProtectionEventsByMessage returns the per-message screening audit
	// rows (gate + scan producers) behind a hold — the source of the detector
	// rationale/categories shown on the review detail. Optional; when nil the
	// detail omits the `protection` breakdown and clients fall back to the coded
	// review_reason. Callers must have already proven ownership of the message
	// (the review detail handler does, via GetReviewWithContent).
	ListProtectionEventsByMessage func(ctx context.Context, messageID string) ([]identity.ProtectionEvent, error)
	// SendLimit is the per-agent outbound rate limiter (mirrors
	// sendLimit.AllowWithRetryAfter; key = agent id). Optional.
	SendLimit func(key string) (ok bool, retryAfter time.Duration)
	// PollLimit is the per-user read limiter (key = user id) and RegLimit is
	// the per-IP agent-registration limiter (key = client ip). Both return
	// the IETF RateLimit snapshot so the middleware can set the headers.
	// Optional — nil disables that limiter on the /v1 surface.
	PollLimit RateSnapshot
	RegLimit  RateSnapshot
	// Raw capability routes sit outside Huma's authenticated middleware and use
	// separate per-IP budgets so traffic to one surface cannot starve another.
	// Optional — nil disables the corresponding limiter.
	DownloadLimit    RateSnapshot
	UnsubscribeLimit RateSnapshot
	// GetRepliableMessage loads a message that can be replied to or forwarded —
	// either an inbound the agent received or an outbound the agent sent — as
	// long as it is live (not expired) and not held/rejected in review. The
	// reply/forward handlers use this so an agent can continue a thread off its
	// own sent message (Gmail-style), which GetInboundMessage's direction filter
	// forbids.
	GetRepliableMessage func(ctx context.Context, messageID string) (*identity.Message, error)

	// AttachmentStore mints/verifies short-lived attachment downloads (§6a #5).
	// Native by default; when nil, the attachment endpoints are unavailable.
	AttachmentStore AttachmentStore

	// account
	GetLimits      func(ctx context.Context, userID string) (limits.Limits, error)
	GetUsage       func(ctx context.Context, userID string) LimitsUsageView
	ExportUserData func(ctx context.Context, userID string) (*identity.UserExport, error)

	// Suppression list (decision 9 / Slice 4b). Optional — nil deployments
	// return 501 from the /v1/account/suppressions endpoints.
	ListSuppressions  func(ctx context.Context, userID string, limit int, afterCreatedAt time.Time, afterAddress string) ([]identity.Suppression, error)
	RemoveSuppression func(ctx context.Context, userID, address string) (bool, error)
	// Agent suppressions are recipient consent blocks scoped to one exact
	// sending identity. Management is account-admin-only; the handler resolves
	// live ownership before calling these tenant-bound store methods.
	AddAgentSuppression    func(ctx context.Context, userID, agentID, address, reason, source string, onAdded identity.AgentSuppressionTxHook) (identity.AgentSuppression, bool, error)
	ListAgentSuppressions  func(ctx context.Context, userID, agentID string, limit int, afterCreatedAt time.Time, afterAddress string) ([]identity.AgentSuppression, error)
	RemoveAgentSuppression func(ctx context.Context, userID, agentID, address string) (bool, error)
	// Public managed-unsubscribe capabilities. Resolve accepts only a token hash;
	// the write capability accepts only the exact scope returned by that lookup,
	// so the unauthenticated route cannot choose an account, agent, or recipient.
	ResolveUnsubscribeToken           func(ctx context.Context, tokenHash []byte) (*identity.UnsubscribeScope, error)
	AddAgentSuppressionFromTokenScope func(ctx context.Context, scope identity.UnsubscribeScope, onAdded identity.AgentSuppressionTxHook) (identity.AgentSuppression, bool, error)
	// Shared transaction hook for both authenticated manual creation and the
	// public recipient flow; the store invokes it only for a newly inserted row.
	AgentSuppressionAddedHook identity.AgentSuppressionTxHook
	DeleteUserData            func(ctx context.Context, user *identity.User) (*identity.DeleteUserDataResult, error)

	// events (delivery log). EventQuery carries the filters + cursor
	// position; the closures bind the events pool in main.
	ListEvents func(ctx context.Context, q EventQuery) ([]agent.EventView, error)
	GetEvent2  func(ctx context.Context, userID, eventID string) (*agent.EventView, error)
	// redeliver
	LoadReplayEvent      func(ctx context.Context, userID, eventID string) (*agent.ReplayEvent, error)
	InsertReplayDelivery func(ctx context.Context, eventID, webhookID, eventType string, messageID *string, envelope []byte) (string, error)

	// EventsEnabled reflects whether the durable event log (the webhook_events
	// outbox) is populated on this deployment. Now unconditional in production;
	// the events handlers still gate on it so a deployment that ever disables the
	// outbox returns 501 events_log_disabled from list/get/redeliver instead of
	// masquerading as "no events". Webhook delivery is unaffected either way.
	EventsEnabled bool

	// webhooks
	// CreateWebhook mirrors identity.Store.CreateWebhookIdem: when the request
	// carries an Idempotency-Key the handler passes a completer that the store
	// runs INSIDE the insert transaction, committing the webhook row and the
	// cached replay response atomically (same-tx pattern as the send/approve
	// paths). idemCompleteTx is nil for unkeyed creates.
	CreateWebhook func(ctx context.Context, userID, url, description string, events []string, filters identity.WebhookFilters, idemCompleteTx identity.WebhookIdemCompleter) (*identity.Webhook, error)
	// ListWebhooks is keyset-paginated on (created_at, id): the handler passes
	// limit+1 to detect a further page (limit<=0 = all) and the after-key from the
	// previous page's last row (zero afterCreatedAt = first page).
	ListWebhooks  func(ctx context.Context, userID string, limit int, afterCreatedAt time.Time, afterID string) ([]identity.Webhook, error)
	GetWebhook    func(ctx context.Context, webhookID, userID string) (*identity.Webhook, error)
	UpdateWebhook func(ctx context.Context, webhookID, userID string, u identity.WebhookUpdate) (*identity.Webhook, error)
	DeleteWebhook func(ctx context.Context, webhookID, userID string) error
	RotateSecret  func(ctx context.Context, webhookID, userID string) (string, time.Time, error)
	// TestWebhookInsert schedules a synthetic delivery (subscriberStore.
	// InsertPendingForTest). ListDeliveries reads the per-webhook delivery
	// log (subscriberStore.ListDeliveriesByWebhook).
	TestWebhookInsert func(ctx context.Context, webhookID, eventType string, envelope []byte) (string, error)
	// ListDeliveries is keyset-paginated on (created_at, id): the handler passes
	// limit+1 to detect a further page and the after-key from the previous page's
	// last row (zero afterCreatedAt = first page). status optionally restricts to
	// pending|delivered|failed.
	ListDeliveries func(ctx context.Context, webhookID, status string, limit int, afterCreatedAt time.Time, afterID string) ([]webhook.SubscriberDelivery, error)
	// EnqueueDelivery enqueues a River webhook_deliver job for a
	// webhook_subscriber_deliveries row that was inserted directly — the /test
	// endpoint and the event-redelivery API. Those two surfaces bypass the outbox
	// drain (which enqueues in-tx), so without this call their rows carry no River
	// job and, now that River is the sole delivery engine, would never deliver.
	// Wired unconditionally in production. Optional — nil in minimal test setups
	// with no River client, where a test drains delivery rows by other means.
	EnqueueDelivery func(ctx context.Context, deliveryID string) error

	// templates (beta). Mirror the like-named identity.Store methods; every
	// lookup is scoped to the owning user (cross-user reads behave as
	// not-found). GetTemplate/GetTemplateByAlias also serve the send path's
	// template_id/template_alias resolution.
	CreateTemplate     func(ctx context.Context, userID string, in identity.TemplateCreate) (*identity.Template, error)
	ListTemplates      func(ctx context.Context, userID string, limit int, afterCreatedAt time.Time, afterID string) ([]identity.TemplateSummary, error)
	GetTemplate        func(ctx context.Context, templateID, userID string) (*identity.Template, error)
	GetTemplateByAlias func(ctx context.Context, alias, userID string) (*identity.Template, error)
	UpdateTemplate     func(ctx context.Context, templateID, userID string, u identity.TemplateUpdate) (*identity.Template, error)
	DeleteTemplate     func(ctx context.Context, templateID, userID string) error

	// API keys (account-scope management). CreateScopedAPIKey returns the
	// minted key including its one-time plaintext; agentID is "" for account
	// scope and a resolved agent id for agent scope.
	CreateScopedAPIKey func(ctx context.Context, userID, name, scope, agentID string, expiresAt *time.Time) (*identity.APIKey, error)
	ListAPIKeys        func(ctx context.Context, userID string, limit int, afterCreatedAt time.Time, afterID string) ([]identity.APIKey, error)
	DeleteAPIKey       func(ctx context.Context, keyID, userID string) error

	// domain verification
	TouchDomainChecked func(ctx context.Context, domain, userID string) error
	VerifyDomain       func(ctx context.Context, domain, userID string) error
	// EnqueueSenderProvision (decision 4 / Slice 4) schedules SES sending-
	// identity provisioning for a verified domain. Called on every successful
	// verify check (newly OR already verified), so POST /domains/{domain}/verify
	// doubles as the forced sending re-check. Optional — nil when SES is not
	// configured (dev/self-host), leaving sending_status at none (relay From).
	EnqueueSenderProvision func(ctx context.Context, domain string)
	// VerifyProbe runs the live DNS check for a domain's published records.
	// Injected so it is fakeable in tests (the real one wraps
	// agent.CheckDomainRecords).
	VerifyProbe func(domain, verificationToken, dkimSelector, dkimPublicKey string) DomainCheckResult

	// Deployment info surfaced by GET /v1/info.
	SharedDomain string
	PublicURL    string

	// WSHandle serves the WebSocket upgrade for an agent address (the real-
	// time inbound transport). Injected so httpapi need not depend on the ws
	// package; the real one is ws.Handler.ServeWithEmail.
	WSHandle func(w http.ResponseWriter, r *http.Request, address string)

	// Legacy is the existing gorilla/mux handler. The chi root falls back
	// to it for every route not yet ported onto Huma (the strangler), so
	// the service stays fully functional through the multi-sub-slice port.
	Legacy http.Handler
}

Deps are the collaborators the v1 layer needs. Everything is injected so the package has no hidden globals and is straightforward to test.

type DomainCheckResult

type DomainCheckResult struct {
	TXTFound bool
	MX       string
	SPF      string
	DKIM     string
}

DomainCheckResult is the live-DNS diagnostic surfaced by verify.

type DomainLookup

type DomainLookup func(ctx context.Context, domain, userID string) (*identity.Domain, error)

DomainLookup mirrors store.LookupDomain(domain, userID) — the create-time ownership guard.

type DomainParam

type DomainParam struct {
	Domain string `path:"domain"`
}

DomainParam is the path input. The domain segment is matched raw; chi/Huma URL-decode it.

type DomainView

type DomainView struct {
	Domain            string `json:"domain"`
	Verified          bool   `json:"verified"`
	VerificationToken string `json:"verification_token"`
	// DNSRecords is the unified, purpose-tagged set of records the customer must
	// publish. ALL applicable records are returned at register time (they are
	// deterministic), so onboarding is a single paste — sending records do not
	// wait for the domain to verify.
	DNSRecords    []DNSRecord `json:"dns_records" nullable:"false"`
	CreatedAt     time.Time   `json:"created_at"`
	VerifiedAt    *time.Time  `json:"verified_at,omitempty"`
	LastCheckedAt *time.Time  `json:"last_checked_at,omitempty"`
	AgentCount    int         `json:"agent_count"`
	// Sender identity (decision 4 / Slice 4). Independent of `verified`
	// (inbound ownership): the async SES sending identity that lets agents
	// on this domain send as their own address. This is the rollup over the
	// dkim + mail_from_* records' per-record status; poll GET /domains/{domain}
	// to watch it go pending → verified|failed.
	SendingStatus        string     `` /* 152-byte string literal not displayed */
	SendingError         string     `json:"sending_error,omitempty"`
	SendingLastCheckedAt *time.Time `json:"sending_last_checked_at,omitempty"`
	// SendingRamp is platform-managed and deliberately read-only. Customers can
	// observe why accepted mail is waiting but cannot weaken the reputation guard.
	SendingRamp SendingRampView `json:"sending_ramp"`
}

type ErrorBody

type ErrorBody struct {
	Code      string `` /* 3510-byte string literal not displayed */
	Message   string `json:"message" doc:"Human-readable explanation. Not for branching — use code."`
	Details   any    `` /* 167-byte string literal not displayed */
	RequestID string `json:"request_id" doc:"Echoes the X-Request-Id response header so a failing call is greppable in logs."`
}

ErrorBody is the inner object of the envelope.

func (ErrorBody) TransformSchema

func (ErrorBody) TransformSchema(r huma.Registry, s *huma.Schema) *huma.Schema

TransformSchema publishes known-code metadata without closing either code or details. A generic client sees a string and open object; tooling that understands the extensions can offer stable typed views for known codes.

type ErrorEnvelope

type ErrorEnvelope struct {
	Err ErrorBody `json:"error"`
	// contains filtered or unexported fields
}

ErrorEnvelope is the one error shape across every v1 endpoint (api-v1-redesign §4 decision 6):

{ "error": { "code": "machine_branchable", "message": "human text",
             "details": {…}, "request_id": "req_…" } }

`code` is the stable, machine-branchable discriminator agents switch on; `message` is human-facing; `details` is optional structured context; and `request_id` echoes the per-request id (also on the X-Request-Id header) so a failing call is greppable in logs without correlation guesswork.

It implements huma.StatusError so it can be returned directly from a handler and is installed as the global huma.NewError constructor, which means Huma's own validation/automatic errors render in this envelope too.

func NewError

func NewError(status int, code, message string) *ErrorEnvelope

NewError builds an envelope with an explicit machine-branchable code. Prefer this over the status-only helpers when the caller should be able to branch on something more specific than the HTTP status (e.g. "domain_not_verified" vs a bare 400).

func (*ErrorEnvelope) Code

func (e *ErrorEnvelope) Code() string

Code returns the machine-branchable code (used by tests and middleware).

func (*ErrorEnvelope) Error

func (e *ErrorEnvelope) Error() string

Error implements the error interface (huma.StatusError embeds error).

func (*ErrorEnvelope) GetStatus

func (e *ErrorEnvelope) GetStatus() int

GetStatus implements huma.StatusError so Huma writes the right status.

func (*ErrorEnvelope) WithDetails

func (e *ErrorEnvelope) WithDetails(details any) *ErrorEnvelope

WithDetails attaches structured details and returns the envelope for fluent construction.

func (*ErrorEnvelope) WithRetryAfter

func (e *ErrorEnvelope) WithRetryAfter(seconds int) *ErrorEnvelope

WithRetryAfter records a Retry-After delay (seconds) for a handler-returned error; stampRequestID copies it into the Retry-After response header. Use it on 429s raised inside a handler (the per-agent send limiter) — the middleware-enforced limiters set the header themselves.

type EventEnvelope

type EventEnvelope struct {
	Type          string         `json:"type" doc:"Open event type; clients must tolerate unknown values."`
	ID            string         `json:"id" doc:"Stable across retries and push channels; use it to deduplicate at-least-once delivery."`
	SchemaVersion string         `json:"schema_version" doc:"Open envelope-version string; the current server emits 1."`
	CreatedAt     time.Time      `json:"created_at" format:"date-time"`
	Data          map[string]any `` /* 142-byte string literal not displayed */
}

EventEnvelope is the documentation schema for the push wire object shared by webhook deliveries and WebSocket frames. The runtime publisher uses webhookpub.Envelope; this map-typed data field is intentional so the published base schema stays open to unknown event types.

type EventQuery

type EventQuery struct {
	UserID                        string
	Type, AgentID, ConversationID string
	MessageID                     string
	Since, Until                  *time.Time
	CursorCreatedAt               time.Time
	CursorID                      string
	Limit                         int
}

EventQuery is the resolved filter + cursor position passed to the events query closure.

type FieldError

type FieldError struct {
	Location string `` /* 222-byte string literal not displayed */
	Message  string `json:"message" doc:"Human-readable reason this field is invalid."`
}

FieldError is one per-field validation failure inside ValidationErrorDetails. It deliberately omits the raw offending value (Huma's ErrorDetail.Value) from the public contract so the API never echoes bad input back to the client.

type ForwardRequest

type ForwardRequest struct {
	To []string `` // required (MSG-3)
	/* 227-byte string literal not displayed */
	CC             []string              ``                                /* 232-byte string literal not displayed */
	BCC            []string              ``                                /* 234-byte string literal not displayed */
	Body           string                `json:"text" maxLength:"1048576"` // required (MSG-3); subject derived as "Fwd:"
	HTMLBody       string                `json:"html,omitempty" maxLength:"1048576"`
	ConversationID string                `` /* 361-byte string literal not displayed */
	ReplyTo        string                `` /* 282-byte string literal not displayed */
	Attachments    []outbound.Attachment `` /* 402-byte string literal not displayed */
	Unsubscribe    UnsubscribeOptions    `` /* 149-byte string literal not displayed */
}

ForwardRequest mirrors the legacy forward body.

type HoldReasonView

type HoldReasonView struct {
	Type       string   `json:"type" doc:"Producer that caused the hold. Open set; tolerate unknown values. Known values: gate, scan, send, unknown."`
	Code       string   `json:"code" doc:"Stable machine-readable hold code. Open set; tolerate unknown values."`
	Summary    string   `json:"summary" doc:"Plain-language explanation suitable for showing directly to a reviewer."`
	Category   string   `json:"category,omitempty" doc:"Top screening category when a scan hold has detail enrichment."`
	Detail     string   `json:"detail,omitempty" doc:"Validated plain-language detector rationale when available."`
	Confidence *float64 `` /* 150-byte string literal not displayed */
}

HoldReasonView is the product-facing explanation for why a message is in the review queue. Code is an open set; clients should render Summary rather than translating Code themselves.

type IdemStore

type IdemStore interface {
	Claim(ctx context.Context, userID, key, path, bodyHash string) (idempotency.ClaimResult, error)
	Complete(ctx context.Context, userID, key string, resp idempotency.CachedResponse) error
	// CompleteTx records the completion inside the caller's transaction — used by
	// the async accept path to commit the idempotency key atomically with the
	// message insert + send-job enqueue. See idempotency.Store.CompleteTx.
	CompleteTx(ctx context.Context, tx pgx.Tx, userID, key string, resp idempotency.CachedResponse) error
	Release(ctx context.Context, userID, key string) error
}

IdemStore is the subset of *idempotency.Store the v1 layer needs. Declared as an interface so handlers are unit-testable without Postgres.

type LimitExceededDetails

type LimitExceededDetails struct {
	// Resource is an OPEN set (evolving response-side vocabulary): a new
	// capped resource means a new value here, and that must not break
	// spec-generated clients.
	Resource   string `` /* 297-byte string literal not displayed */
	Limit      int64  `json:"limit" doc:"The cap that was hit (matches limits.max_<resource>)."`
	Current    int64  `json:"current" doc:"The account's usage at the time the cap was hit (matches usage.<resource>)."`
	PlanCode   string `json:"plan_code,omitempty" doc:"The account's plan label."`
	UpgradeURL string `json:"upgrade_url,omitempty" doc:"An upgrade affordance URL, when the operator has configured one."`
}

LimitExceededDetails is the typed `error.details` payload carried by a 402 limit_exceeded response. `resource` is one of the AccountView usage/limits field stems, so a client can key the error straight to the usage/cap field: usage.<resource> for the current value and limits.max_<resource> for the cap (e.g. resource "messages_month" → usage.messages_month / limits.max_messages_month). `limit` and `current` echo the cap that was hit and the account's usage at the time. `plan_code`/`upgrade_url` are the account's plan label and any upgrade affordance the operator configured.

type LimitExceededEnvelope

type LimitExceededEnvelope struct {
	Err LimitExceededErrorBody `json:"error"`
}

LimitExceededEnvelope is the 402 error envelope with typed details. It is the declared schema for the 402 response on the cap-enforcing operations (create agent, register domain, send/reply/forward/test); the runtime envelope is the generic ErrorEnvelope whose `details` is populated with a LimitExceededDetails value, so the wire shape matches this schema byte-for-byte.

type LimitExceededErrorBody

type LimitExceededErrorBody struct {
	Code      string               `json:"code" enum:"limit_exceeded" doc:"Always limit_exceeded for this response."`
	Message   string               `json:"message"`
	Details   LimitExceededDetails `json:"details"`
	RequestID string               `json:"request_id"`
}

LimitExceededErrorBody mirrors ErrorBody but with typed limit_exceeded details, so codegen surfaces a concrete detail shape for the 402 case instead of `any`.

type LimitsCapsView

type LimitsCapsView struct {
	MaxAgents        int   `json:"max_agents"`
	MaxDomains       int   `json:"max_domains"`
	MaxMessagesMonth int   `json:"max_messages_month"`
	MaxStorageBytes  int64 `` /* 129-byte string literal not displayed */
}

type LimitsUsageView

type LimitsUsageView struct {
	Agents        int   `json:"agents"`
	Domains       int   `json:"domains"`
	MessagesMonth int   `json:"messages_month"`
	StorageBytes  int64 `` /* 228-byte string literal not displayed */
}

LimitsUsageView is the current consumption snapshot.

StorageBytes is the storage-quota accounting basis: per stored message it sums the RAW MIME byte length (raw_message — the message-level size_bytes) PLUS retained outbound draft body columns (body_text, body_html, and the draft attachments blob). These survive terminal transitions and can coexist with canonical sent MIME. A DB trigger on the messages table (migrations 016/039) maintains the total transactionally.

type ListConversationsInput

type ListConversationsInput struct {
	Address string `path:"email"`
	Since   string `query:"since" doc:"RFC3339."`
	Until   string `query:"until" doc:"RFC3339."`
	Cursor  string `` /* 136-byte string literal not displayed */
	Limit   int    `query:"limit" minimum:"1" maximum:"100" default:"100"`
}

ListConversationsInput — since/until + cursor/limit. Cursor continuation IS supported: the handler fetches limit+1, and when there are more it keyset- encodes the last row's (last_message_at, conversation_id) into next_cursor (see handleListConversations: hasMore → EncodeCursor(conversationsCursor{...})). next_cursor is null only on the last page.

type ListDeliveriesInput

type ListDeliveriesInput struct {
	ID     string `path:"id"`
	Status string `query:"status" enum:"pending,delivered,failed"`
	Cursor string `` /* 142-byte string literal not displayed */
	Limit  int    `query:"limit" minimum:"1" maximum:"100" default:"100"`
}

ListDeliveriesInput — the per-webhook delivery log, keyset-paginated on (created_at, id) like every other v1 list. The delivery log grows unbounded on a busy webhook, so a cursor (not a fixed cap) is what keeps the whole log reachable; the limit is generous (default 100, up to 100) so a recent view is rarely more than one page. `status` restricts to pending|delivered|failed and is pinned into the cursor (a continuation must not change it).

type ListEventsInput

type ListEventsInput struct {
	Type           string `query:"type"`
	AgentID        string `query:"agent_email"`
	ConversationID string `query:"conversation_id"`
	MessageID      string `query:"message_id"`
	Since          string `query:"since" doc:"RFC3339."`
	Until          string `query:"until" doc:"RFC3339."`
	Cursor         string `query:"cursor"`
	Limit          int    `query:"limit" minimum:"1" maximum:"100" default:"100"`
}

ListEventsInput — filters + the standardized cursor/limit (replacing the legacy page_size/token).

type ListMessagesInput

type ListMessagesInput struct {
	Address         string   `path:"email"`
	Direction       string   `query:"direction" enum:"inbound,outbound,all" doc:"Defaults to inbound."`
	Status          string   `` /* 146-byte string literal not displayed */
	Sort            string   `query:"sort" enum:"asc,desc" doc:"Defaults to desc (newest first)."`
	From            string   `query:"from" doc:"Case-insensitive substring match on sender."`
	SubjectContains string   `query:"subject_contains" doc:"Case-insensitive substring match on subject."`
	ConversationID  string   `query:"conversation_id"`
	Labels          []string `query:"labels" doc:"Repeatable; AND-matched."`
	Since           string   `query:"since" doc:"RFC3339; created_at >= since."`
	Until           string   `query:"until" doc:"RFC3339; created_at < until."`
	Cursor          string   `query:"cursor"`
	Limit           int      `query:"limit" minimum:"1" maximum:"100" default:"100"`
	Deleted         bool     `` /* 211-byte string literal not displayed */
}

ListMessagesInput is the typed query surface for the message list. Cursor pagination (cursor/limit) replaces the legacy page_size/token (§4 decision 7); the filters preserve legacy semantics.

type MXResolver

type MXResolver func(ctx context.Context, name string) ([]string, error)

MXResolver returns the effective MX hosts published for a name. Used for the create-time subdomain MX gate: a single lookup on the subdomain FQDN answers both the explicit-subdomain-MX and the wildcard-MX-on-parent cases, because a resolver synthesizes the wildcard for the queried name. Injected so unit tests mock the resolver and the httpapi layer stays off the network.

type MessageBodyView

type MessageBodyView struct {
	Text string `json:"text,omitempty"`
	HTML string `json:"html,omitempty"`
}

MessageBodyView is the held-draft body (see MessageView.Body).

type MessageGetter

type MessageGetter func(ctx context.Context, messageID, agentID string) (*identity.Message, error)

MessageGetter loads a single message (with content) scoped to an agent. Mirrors store.GetMessageWithContent(messageID, agentID).

type MessageIDParam

type MessageIDParam struct {
	Address   string `path:"email" doc:"The agent's full email address."`
	MessageID string `path:"id" doc:"The message id, e.g. msg_abc123."`
}

MessageIDParam is the path input for single-message operations.

type MessageLister

type MessageLister func(ctx context.Context, filter identity.MessageListFilter) ([]identity.Message, error)

MessageLister returns a filtered page of message summaries for an agent. Mirrors store.GetMessagesByAgent(filter).

type MessageParsedView

type MessageParsedView struct {
	// Text is the injection-reduced plain body: text/plain preferred (else
	// HTML→text), quoted reply/forward chains stripped, length-capped. This is
	// what an agent feeds a model by default.
	Text string `json:"text"`
	// Truncated is true when the length cap cut `text`.
	Truncated bool `json:"truncated"`
	// HTML is the decoded text/html part for display, present only when the
	// message carries an HTML part. Full fidelity (NOT quote-stripped, unlike
	// `text`) — render it sanitized/sandboxed; it is untrusted sender content.
	// Omitted for text-only messages; `raw_message` stays the canonical copy.
	HTML string `json:"html,omitempty"`
}

MessageParsedView is the parsed-body payload (see MessageView.Parsed).

type MessageSummaryView

type MessageSummaryView struct {
	ID string `json:"id"`
	// Deliberately a CLOSED enum despite being response-side: direction is a
	// binary invariant of the model, not an evolving vocabulary.
	Direction      string   `json:"direction" enum:"inbound,outbound"`
	HeaderFrom     *string  `` /* 182-byte string literal not displayed */
	EnvelopeFrom   *string  `` /* 167-byte string literal not displayed */
	VerifiedDomain *string  `` /* 483-byte string literal not displayed */
	To             []string `json:"to" nullable:"false"`
	CC             []string `json:"cc,omitempty" nullable:"false"`
	ReplyTo        []string `` /* 236-byte string literal not displayed */
	Recipient      string   `` /* 187-byte string literal not displayed */
	Subject        string   `json:"subject"`
	ConversationID string   `json:"conversation_id,omitempty"`
	// Status is the inbox read-state, exposed as `read_status` (MSG-1).
	Status        string `json:"read_status"`
	HITLStatus    string `` /* 541-byte string literal not displayed */
	WebhookStatus string `json:"webhook_status,omitempty"`
	WebhookError  string `json:"webhook_error,omitempty"`
	// DeliveryStatus / DeliveryDetail / SentAs are the outbound delivery
	// rollup (migration 031). Outbound-only; omitted on inbound rows.
	DeliveryStatus string `` /* 388-byte string literal not displayed */
	DeliveryDetail string `json:"delivery_detail,omitempty"`
	SentAs         string `` /* 156-byte string literal not displayed */
	// Flagged + FlagReason are the beta inbound ingestion verdict. They remain in
	// list projections so polling agents can identify delivered flag outcomes
	// without a per-message drill-down.
	Flagged    bool   `json:"flagged,omitempty"`
	FlagReason string `json:"flag_reason,omitempty"`
	// SizeBytes is the RAW MIME byte length of the whole stored message —
	// same semantics as MessageView.SizeBytes (see there for the full note,
	// including its role as the dominant term of storage-quota accounting).
	SizeBytes int      `` /* 313-byte string literal not displayed */
	Labels    []string `json:"labels" nullable:"false"`
	// CreatedAt is the keyset pagination ordering key, emitted at full RFC3339Nano
	// precision (time.Time) so sub-second ordering is visible on the wire.
	CreatedAt time.Time `json:"created_at" format:"date-time"`
	// DeletedAt marks a message in the trash — set on rows of the deleted=true
	// list view, omitted on live messages. See MessageView.DeletedAt.
	DeletedAt *time.Time `` /* 284-byte string literal not displayed */
}

MessageSummaryView is the lightweight list representation. It mirrors the legacy messageSummary json shape field-for-field (Slice 1 keeps the item shape; only the *pagination envelope* changes to the standardized items/next_cursor — §4 decision 7). Replicated here rather than imported from the legacy agent package so the new layer carries no backwards dependency on the surface it replaces; it moves home when legacy is deleted at the 1Z cutover.

type MessageTrashOp

type MessageTrashOp func(ctx context.Context, messageID, agentID string) error

MessageTrashOp mirrors the store's per-message trash mutations (SoftDeleteMessage / RestoreMessage / PurgeMessage): scoped to (messageID, agentID), returning the sentinel errors ErrMessageHeld / ErrNotInTrash / ErrMessageNotFound for the handler to map.

type MessageView

type MessageView struct {
	ID             string                    `json:"id"`
	HeaderFrom     *string                   `` /* 182-byte string literal not displayed */
	EnvelopeFrom   *string                   `` /* 167-byte string literal not displayed */
	VerifiedDomain *string                   `` /* 554-byte string literal not displayed */
	Authentication *emailauth.Authentication `` /* 326-byte string literal not displayed */
	To             []string                  `json:"to" nullable:"false"`
	CC             []string                  `json:"cc" nullable:"false"`
	ReplyTo        []string                  `` /* 257-byte string literal not displayed */
	Recipient      string                    `` /* 187-byte string literal not displayed */
	Subject        string                    `json:"subject"`
	ConversationID string                    `json:"conversation_id"`
	// Direction (inbound|outbound) — mirrors MessageSummaryView so a client
	// fetching a single message keeps the full trust-axis context (review F1).
	// Deliberately a CLOSED enum despite being response-side: direction is a
	// binary invariant of the model, not an evolving vocabulary.
	Direction string `json:"direction" enum:"inbound,outbound"`
	// Status is the inbox read-state (unread|read; "" for outbound). Exposed as
	// `read_status` (MSG-1) to disambiguate from hitl_status/delivery_status/
	// webhook_status — the conflation that caused bug B2. Left open (not an enum)
	// because outbound rows carry "".
	Status string `json:"read_status"`
	// HITLStatus is the review-hold lifecycle (e.g. pending_review) — outbound
	// only, mirroring MessageSummaryView. Exposed as `review_status` (the holds
	// vocabulary unified on `review` in migration 044). Distinct from read_status,
	// delivery_status, and webhook_status (each a separate axis).
	HITLStatus string `` /* 541-byte string literal not displayed */
	// WebhookStatus / WebhookError mirror MessageSummaryView so the detail view
	// is a strict superset of the list item (a client fetching one message keeps
	// the webhook delivery context). Apply to both directions; omitempty hides
	// the empty case.
	WebhookStatus string `json:"webhook_status,omitempty"`
	WebhookError  string `json:"webhook_error,omitempty"`
	// SizeBytes is the RAW MIME byte length of the whole stored message
	// (octet length of raw_message) — headers + bodies + encoded attachments as
	// transported. Mirrors MessageSummaryView. Distinct from the per-attachment
	// size_bytes in attachments[], which is the DECODED payload size of one
	// attachment. This raw length is also the dominant term of storage-quota
	// accounting: usage.storage_bytes sums raw_message plus any retained
	// held-draft body columns per message (see AccountView).
	SizeBytes int `` /* 324-byte string literal not displayed */
	// DeliveryStatus is the outbound delivery rollup (migration 031:
	// 'sent', 'delivered', 'bounced', …) — the worst recipient status by
	// precedence. Outbound-only; omitted on inbound messages.
	DeliveryStatus string `` /* 388-byte string literal not displayed */
	// DeliveryDetail is the human-readable diagnostic for the delivery
	// rollup (e.g. bounce sub-type / SMTP response). Outbound-only.
	DeliveryDetail string `json:"delivery_detail,omitempty"`
	// SentAs is the From identity actually used at relay accept time.
	// Outbound-only; omitted on inbound messages.
	SentAs string `` /* 156-byte string literal not displayed */
	// Flagged + FlagReason carry the beta inbound ingestion verdict: true when
	// the agent's inbound-policy gate flagged this message on arrival while still
	// delivering it. Polling agents need this signal because no review item is
	// created for the flag action.
	Flagged    bool   `json:"flagged,omitempty"`
	FlagReason string `json:"flag_reason,omitempty"`
	// HoldReason is populated only by GET /v1/reviews/{id}. The shared
	// messageViewFromIdentity constructor deliberately leaves it nil so review
	// context never leaks onto agent-facing message APIs.
	HoldReason *HoldReasonView `json:"hold_reason,omitempty"`
	// Protection is the per-producer screening breakdown behind the hold (the
	// detector categories + rationale that explain hold_reason). Review surface
	// only; populated by the review-detail handler, never on the agent /messages
	// path. Beta.
	Protection []ProtectionFindingView `` /* 134-byte string literal not displayed */
	Labels     []string                `json:"labels" nullable:"false"`
	// CreatedAt is emitted as a full-precision RFC3339Nano date-time (time.Time),
	// consistent with every other timestamp in the surface. This is the keyset
	// pagination ORDERING key, so it must NOT be truncated to whole seconds —
	// two messages in the same second would otherwise be indistinguishable on the
	// wire even though the cursor orders them at finer granularity.
	CreatedAt time.Time `json:"created_at" format:"date-time"`
	// DeletedAt marks a message in the trash (soft-deleted): set when it was
	// moved there, omitted on live messages. Trashed messages appear only in
	// the deleted=true list view and this single-message get; they are purged
	// ~30 days after deletion (docs/design/trash-soft-delete.md).
	DeletedAt  *time.Time `` /* 284-byte string literal not displayed */
	RawMessage []byte     `` /* 278-byte string literal not displayed */
	// Parsed is the derived view (decision 9 / Slice 4b-3): the raw message
	// rendered to text (`text`, quoted chains stripped + length-capped, for the
	// agent to feed a model by default) plus the decoded HTML part (`html`, for
	// display). Present on any message carrying raw MIME — inbound and sent
	// outbound. A CONVENIENCE — when raw_message is non-null, it is canonical;
	// the security decision is made on `auth` + provenance, never on this derived
	// body. Held outbound drafts have raw_message=null and use Body below.
	Parsed *MessageParsedView `json:"parsed,omitempty"`
	// Body is the mutable draft body for a held outbound message
	// (status=pending_review), which has no raw_message yet. This is the
	// second representation the unified read exposes (decision 9): held drafts
	// carry body_text/body_html, sent/inbound carry raw_message. Omitted when
	// empty (sent/inbound rows).
	Body *MessageBodyView `json:"body,omitempty"`
	// Attachments is per-attachment METADATA (never bytes) parsed server-side
	// from raw_message — the authoritative, stable attachment index (§6a #5).
	// Fetch the bytes via GET …/messages/{id}/attachments/{index}. Always
	// present (empty when none); held drafts (no raw_message) carry [].
	Attachments []AttachmentMetaView `json:"attachments" nullable:"false"`
}

MessageView is the full single-message representation: a strict superset of MessageSummaryView plus body/parsed/raw_message. The inbox read-state is exposed as `read_status` (MSG-1); the four status axes are read_status / hitl_status / delivery_status / webhook_status.

type Page

type Page[T any] struct {
	Items      []T     `json:"items" nullable:"false"`
	NextCursor *string `json:"next_cursor"`
}

Page is the one list-response shape across every v1 collection (api-v1-redesign §4 decision 7): an items array plus an opaque continuation cursor that is null on the last page.

{ "items": [...], "next_cursor": "…" | null }

It is generic over the item view type so each resource reuses the same envelope without redefining it.

func NewPage

func NewPage[T any](items []T, nextCursor string) Page[T]

NewPage builds a Page. A nil/empty nextCursor renders as JSON null, signalling "no more pages"; items is normalized to a non-nil empty slice so the field is always `[]`, never `null`.

type PageParams

type PageParams struct {
	Cursor string `` /* 142-byte string literal not displayed */
	Limit  int    `query:"limit" minimum:"1" maximum:"100" default:"100" doc:"Maximum number of items to return (1-100)."`
}

PageParams is the embeddable Huma input fragment for cursor pagination. Every list operation embeds it so `cursor` + `limit` are declared, typed, and validated identically across the surface.

type PayloadTooLargeDetails

type PayloadTooLargeDetails struct {
	// Scope is an OPEN set (evolving response-side vocabulary): a new byte
	// budget (e.g. a future batch or template cap) means a new value here,
	// and that must not break spec-generated clients.
	Scope       string `` /* 227-byte string literal not displayed */
	ActualBytes int64  `` /* 195-byte string literal not displayed */
	MaxBytes    int64  `json:"max_bytes" minimum:"1" doc:"Maximum bytes accepted for this scope."`
	Filename    string `json:"filename,omitempty" doc:"Attachment filename when scope is attachment."`
}

type PrincipalAuthenticator

type PrincipalAuthenticator func(r *http.Request) (*identity.Principal, error)

PrincipalAuthenticator is the scope-aware seam (Slice 5a): the same single auth path, but returning the full principal (user + credential scope + bound agent) so the v1 handlers can enforce the hard scope ceiling (design §5). When set it supersedes Authenticator; when nil the server wraps Authenticator and treats every caller as account-scoped (pre-Slice-5a behavior).

type ProtectionConfigRequest

type ProtectionConfigRequest struct {
	Inbound  ProtectionDirectionRequest `json:"inbound"`
	Outbound ProtectionDirectionRequest `json:"outbound"`
	Holds    ProtectionHoldsRequest     `json:"holds"`
}

ProtectionConfigRequest is the PUT body (full replace). The three top-level keys are required (no silent section reset); leaves are optional and fill from defaults.

type ProtectionConfigView

type ProtectionConfigView struct {
	Inbound  ProtectionDirectionView `json:"inbound"`
	Outbound ProtectionDirectionView `json:"outbound"`
	Holds    ProtectionHoldsView     `json:"holds"`
}

ProtectionConfigView is the GET (and PUT echo) RESPONSE. The PUT body is the separate ProtectionConfigRequest below — see its comment for why the two are distinct types despite the identical shape.

type ProtectionDirectionRequest

type ProtectionDirectionRequest struct {
	Gate ProtectionGateRequest `json:"gate"`
	Scan ProtectionScanRequest `json:"scan"`
}

ProtectionDirectionRequest mirrors ProtectionDirectionView for the PUT body.

type ProtectionDirectionView

type ProtectionDirectionView struct {
	Gate ProtectionGateView `json:"gate"`
	Scan ProtectionScanView `json:"scan"`
}

ProtectionDirectionView pairs the gate and scan for one direction.

type ProtectionFindingView

type ProtectionFindingView struct {
	Source     string               `json:"source" doc:"Which producer flagged the message. Open set; tolerate unknown values. Known values: gate, scan."`
	Action     string               `json:"action,omitempty" doc:"Applied action for this finding. Open set. Known values: flag, review, block."`
	Detector   string               `json:"detector,omitempty" doc:"Content-scan detector(s) that contributed (scan findings only), e.g. \"gemini\"."`
	Score      *float64             `json:"score,omitempty" doc:"Aggregate content-scan score 0..1 (scan findings only)."`
	Categories []ThreatCategoryView `json:"categories,omitempty" doc:"Detected threat categories, highest-confidence first (scan findings only)."`
	// Summary is the detector's short natural-language rationale (e.g. "instructs
	// the agent to wire funds"). Curated from the per-detector verdict — the raw
	// provider payload is never exposed.
	Summary string `json:"summary,omitempty" doc:"Short natural-language rationale for a scan finding (curated; never the raw provider payload)."`
}

ProtectionFindingView is one screening producer's verdict on a held message — the review-detail breakdown behind hold_reason. A scan finding (source=scan) carries the content-detector's categories + one-line rationale; a gate finding (source=scan's counterpart, source=gate) carries only its source + action today (the address that tripped the policy is intentionally not surfaced here — the reviewer already sees sender/recipient on the message). Review surface only (GET /v1/reviews/{id}); the agent /messages read paths never return holds. Beta: the shape may change.

type ProtectionGateRequest

type ProtectionGateRequest struct {
	Policy    string   `` /* 403-byte string literal not displayed */
	Allowlist []string `` /* 349-byte string literal not displayed */
	Action    string   `` /* 146-byte string literal not displayed */
}

ProtectionGateRequest / ProtectionScanRequest / ProtectionDirectionRequest / ProtectionHoldsRequest / ProtectionConfigRequest mirror the *View shapes field-for-field as the PUT body. They are dedicated INPUT types (not the Views) because the spec's forward-compat stance is asymmetric: request schemas stay `additionalProperties: false` (strict validation — an unknown key is a 422, not a silent no-op on a security posture), while response schemas are open (`additionalProperties: true`) so clients tolerate additive fields. One shared schema cannot carry both; the stability pass in stability.go panics if a schema is reachable from both sides. Keep the validation tags here in lockstep with the View tags above — except the enum tags, which live ONLY on these request types: the Views document the same vocabularies as open sets (response-side vocabularies that can evolve are open; see docs/api.md "Versioning & stability"), while an unknown value a client SENDS is still validated and rejected here.

type ProtectionGateView

type ProtectionGateView struct {
	// Policy and Action are OPEN sets on this response view (evolving config
	// vocabularies — a new gate posture or disposition must not break
	// spec-generated clients); the mirroring ProtectionGateRequest keeps the
	// closed enums, which is where validation belongs.
	Policy    string   `` /* 513-byte string literal not displayed */
	Allowlist []string `` /* 349-byte string literal not displayed */
	Action    string   `` /* 256-byte string literal not displayed */
}

ProtectionGateView is one direction's trust gate: who may send, and what a non-match does. policy is a monotonic trust ladder (open < domain < allowlist). Leaves are optional (omitempty) with defaults, so a caller may send an empty gate/scan object and get the safe-permissive default. The three TOP-LEVEL keys (inbound/outbound/holds) stay required (design D3) — a missing section is a 422, not a silent reset.

INBOUND gate identity note (#299): the inbound allowlist/domain gate matches on the message's From address, which only carries a specific per-agent identity for senders on a sending-verified domain. Mail from agents NOT yet sending-verified is relayed under the shared "via e2a" address (internal/outbound/sender.go) — it authenticates but is the same address for every such agent. The relay treats that shared sender as unresolvable (see senderResolvable), so it can never satisfy an allowlist/domain gate and is flagged (fail closed); under "open" it still passes. Net: per-agent inbound allowlisting is reliable for sending-verified senders; unverified intra-system senders are uniformly gated, not silently admitted.

Inbound allowlist/domain gates fail closed unless DMARC passes, then match the aligned RFC 5322 From address. Open policy remains intentionally ungated.

type ProtectionHoldsRequest

type ProtectionHoldsRequest struct {
	TTLSeconds            int    `json:"ttl_seconds,omitempty" minimum:"0" default:"604800" doc:"How long a held item waits before its on_expiry action fires."`
	OnExpiry              string `json:"on_expiry,omitempty" enum:"approve,reject" default:"reject" doc:"What happens to a held item when its TTL expires."`
	SuppressNotifications bool   `` /* 135-byte string literal not displayed */
}

ProtectionHoldsRequest mirrors ProtectionHoldsView for the PUT body.

type ProtectionHoldsView

type ProtectionHoldsView struct {
	TTLSeconds int `json:"ttl_seconds,omitempty" minimum:"0" default:"604800" doc:"How long a held item waits before its on_expiry action fires."`
	// OnExpiry is an OPEN set on this response view: today's values happen to
	// be two, but expiry disposition is an evolving config vocabulary (e.g. a
	// future extend/escalate), not a binary invariant of the model like
	// message direction. ProtectionHoldsRequest keeps the closed enum.
	OnExpiry              string `` /* 230-byte string literal not displayed */
	SuppressNotifications bool   `` /* 135-byte string literal not displayed */
}

ProtectionHoldsView is the shared review-queue mechanism for held items.

type ProtectionScanRequest

type ProtectionScanRequest struct {
	Sensitivity string `` /* 156-byte string literal not displayed */
}

ProtectionScanRequest mirrors ProtectionScanView for the PUT body.

type ProtectionScanView

type ProtectionScanView struct {
	// Sensitivity is an OPEN set on this response view (evolving vocabulary —
	// a new level must not break clients); ProtectionScanRequest keeps the
	// closed enum for validation.
	Sensitivity string `` /* 267-byte string literal not displayed */
}

ProtectionScanView is one direction's content scan, as a semantic sensitivity level. off disables it; low|medium|high tune how aggressively flagged content is held/blocked.

type RateLimitedDetails

type RateLimitedDetails struct {
	RetryAfterSeconds int `json:"retry_after_seconds" doc:"Seconds to wait before retrying; mirrors the Retry-After response header. Always ≥ 1."`
}

RateLimitedDetails is the typed `error.details` payload carried by a 429 rate_limited response. `retry_after_seconds` is the seconds a client should wait before retrying — it mirrors the Retry-After response header, so a client that can only read the body still gets the backoff hint.

This is the THROUGHPUT/request-RATE arm of the contract, distinct from the 402 limit_exceeded (stock/flow QUOTA) arm: a 429 is a short-lived, retry-able signal (wait retry_after_seconds and the same request succeeds), whereas a 402 is a persistent cap that a retry alone will not clear (see LimitExceededDetails). Clients MUST branch on the HTTP status: 429 → back off and retry; 402 → surface a quota/upgrade path, do not hammer-retry.

type RateLimitedEnvelope

type RateLimitedEnvelope struct {
	Err RateLimitedErrorBody `json:"error"`
}

RateLimitedEnvelope is the 429 error envelope with typed details. It is the declared schema for the 429 response on the throughput-limited write operations (send/reply/forward/test, create agent, approve review); the runtime envelope is the generic ErrorEnvelope whose `details` is populated with a RateLimitedDetails value (or the equivalent map), so the wire shape matches this schema byte-for-byte. It is the request-RATE counterpart to the 402 LimitExceededEnvelope (stock/flow QUOTA) — the two are the permanent GA split clients branch on by HTTP status.

type RateLimitedErrorBody

type RateLimitedErrorBody struct {
	Code      string             `json:"code" enum:"rate_limited" doc:"Always rate_limited for this response."`
	Message   string             `json:"message"`
	Details   RateLimitedDetails `json:"details"`
	RequestID string             `json:"request_id"`
}

RateLimitedErrorBody mirrors ErrorBody but with typed rate_limited details, so codegen surfaces a concrete detail shape for the 429 case instead of `any`.

type RateSnapshot

type RateSnapshot func(key string) (ok bool, retryAfter time.Duration, limit, remaining, resetSeconds int)

RateSnapshot records an attempt for key and reports whether it is allowed along with the IETF RateLimit header values (quota, remaining-after-this, seconds-until-reset) and the Retry-After delay when blocked. It mirrors ratelimit.Limiter.AllowSnapshot so httpapi shares the exact buckets the legacy surface uses without importing the limiter directly.

type RedeliverDelivery

type RedeliverDelivery struct {
	WebhookID  string `json:"webhook_id"`
	DeliveryID string `json:"delivery_id,omitempty"`
	// Status is "pending" when the replay was scheduled, or "skipped" when this
	// subscriber's delivery could not be enqueued (see Reason).
	Status string `` /* 140-byte string literal not displayed */
	Reason string `json:"reason,omitempty"`
}

type RedeliverEventInput

type RedeliverEventInput struct {
	ID   string `path:"id"`
	Body RedeliverEventRequest
}

type RedeliverEventRequest

type RedeliverEventRequest struct {
	WebhookID string `json:"webhook_id,omitempty"`
}

RedeliverEventRequest is the redeliver body (WH-6 naming).

func (*RedeliverEventRequest) UnmarshalJSON

func (r *RedeliverEventRequest) UnmarshalJSON(data []byte) error

UnmarshalJSON preserves the contract distinction between an omitted optional webhook_id (bulk fan-out) and an explicitly null, non-nullable webhook_id.

type RedeliverView

type RedeliverView struct {
	DeliveryID string `json:"delivery_id,omitempty"`
	EventID    string `json:"event_id"`
	WebhookID  string `json:"webhook_id,omitempty"`
	// Status is "pending" for a single-webhook replay (one scheduled delivery)
	// or "scheduled" for a bulk fan-out (see Deliveries for per-subscriber state).
	Status     string              `` /* 127-byte string literal not displayed */
	Deliveries []RedeliverDelivery `json:"deliveries,omitempty" nullable:"false"`
}

RedeliverView mirrors the legacy redeliverResponse.

type RegisterDomainRequest

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

RegisterDomainRequest is the create-domain body. `domain` is required (D-4): it is the only field, so leaving it optional generated an SDK signature that compiled with no domain and failed at runtime.

type RejectRequest

type RejectRequest struct {
	Reason string `` /* 197-byte string literal not displayed */
}

RejectRequest is the reject body (MSG-10, was the inline RejectInputBody).

type RejectResultView

type RejectResultView struct {
	Status          string `json:"status"`
	MessageID       string `json:"message_id"`
	RejectionReason string `json:"rejection_reason,omitempty"`
}

RejectResultView is the reject response. Reject is not a send, so it keeps its own shape (status + rejection_reason).

type RenderedTemplateView

type RenderedTemplateView struct {
	Subject  string `json:"subject"`
	Body     string `json:"text"`
	HTMLBody string `json:"html,omitempty"`
}

RenderedTemplateView is the rendered preview (present only when valid).

type ReplyRequest

type ReplyRequest struct {
	Body           string                `json:"text" maxLength:"1048576"` // required (MSG-3); to/subject derived from the original
	HTMLBody       string                `json:"html,omitempty" maxLength:"1048576"`
	ReplyAll       bool                  `json:"reply_all,omitempty"`
	CC             []string              `` /* 249-byte string literal not displayed */
	BCC            []string              `` /* 251-byte string literal not displayed */
	ConversationID string                `` /* 361-byte string literal not displayed */
	ReplyTo        string                `` /* 282-byte string literal not displayed */
	Attachments    []outbound.Attachment `` /* 275-byte string literal not displayed */
	Unsubscribe    UnsubscribeOptions    `` /* 149-byte string literal not displayed */
}

ReplyRequest mirrors the legacy reply body.

type RetryAfterDetails

type RetryAfterDetails struct {
	RetryAfterSeconds int `json:"retry_after_seconds" minimum:"1" doc:"Seconds to wait before retrying; mirrors the Retry-After response header."`
}

RetryAfterDetails is the open transient-availability retry hint used by limits_unavailable. It intentionally matches the rate-limit field name so generic clients can share one backoff parser across 429 and 503 responses.

type ReviewView

type ReviewView struct {
	ID    string `` /* 229-byte string literal not displayed */
	Agent string `json:"agent_email" doc:"The inbox (agent address) the held message belongs to."`
	// Direction: outbound = a draft awaiting send approval; inbound = a screened
	// incoming message awaiting release. Deliberately a CLOSED enum despite
	// being response-side: direction is a binary invariant of the model, not
	// an evolving vocabulary.
	Direction      string          `json:"direction" enum:"inbound,outbound"`
	HeaderFrom     *string         `` /* 182-byte string literal not displayed */
	EnvelopeFrom   *string         `` /* 167-byte string literal not displayed */
	VerifiedDomain *string         `` /* 483-byte string literal not displayed */
	To             []string        `json:"to" nullable:"false"`
	Subject        string          `json:"subject"`
	ConversationID string          `json:"conversation_id,omitempty"`
	ReviewStatus   string          `` /* 154-byte string literal not displayed */
	Flagged        bool            `json:"flagged,omitempty"`
	FlagReason     string          `json:"flag_reason,omitempty"`
	HoldReason     *HoldReasonView `` /* 168-byte string literal not displayed */
	CreatedAt      time.Time       `json:"created_at"`
}

ReviewView is one item in the review queue — non-secret summary of a held message of either direction.

type SendEmailRequest

type SendEmailRequest struct {
	To             []string              `` /* 227-byte string literal not displayed */
	CC             []string              `` /* 232-byte string literal not displayed */
	BCC            []string              `` /* 234-byte string literal not displayed */
	Subject        string                `` /* 163-byte string literal not displayed */
	Body           string                `` /* 171-byte string literal not displayed */
	HTMLBody       string                `json:"html,omitempty" maxLength:"1048576" doc:"Literal HTML body. Mutually exclusive with template_id/template_alias."`
	TemplateID     string                `` /* 280-byte string literal not displayed */
	TemplateAlias  string                `` /* 264-byte string literal not displayed */
	TemplateData   TemplateData          `` /* 249-byte string literal not displayed */
	ConversationID string                `` /* 352-byte string literal not displayed */
	ReplyTo        string                `` /* 320-byte string literal not displayed */
	Attachments    []outbound.Attachment `` /* 275-byte string literal not displayed */
	Unsubscribe    UnsubscribeOptions    `` /* 149-byte string literal not displayed */
}

SendEmailRequest is the new-thread send body. `to` is required (RFC 5321 requires ≥1 recipient; From/Date are server-set). Content comes in one of two mutually exclusive shapes: literal subject + text (+ optional html) — both required at the handler for a usable new email (MSG-3) — or a template reference (template_id XOR template_alias, + template_data), which the server renders into subject/text/html before any further processing. subject/text moved from schema-required to handler-enforced so the template shape can omit them.

type SendResultView

type SendResultView struct {
	// review_approved is the inbound-release outcome of POST .../approve (an
	// inbound hold released to the agent's inbox — no send). sent/pending_review
	// are the send/outbound-approve outcomes.
	Status            string     `` /* 391-byte string literal not displayed */
	MessageID         string     `json:"message_id"`
	ProviderMessageID string     `` /* 182-byte string literal not displayed */
	SentAs            string     `json:"sent_as,omitempty" doc:"From identity used. Open set; tolerate unknown values. Known values: own_address, relay."`
	Method            string     `json:"method,omitempty" doc:"Send transport. Open set; tolerate unknown values. Known values: smtp, loopback."`
	ApprovalExpiresAt *time.Time `json:"approval_expires_at,omitempty"`
	// Edited is set only by approve (true/false = did the reviewer edit the
	// draft before sending); omitted on the plain send path.
	Edited *bool `json:"edited,omitempty"`
}

SendResultView is the single outbound result for send/reply/forward/approve/ test (MSG-9). Per scenario:

  • sent: status="sent" + message_id (the e2a msg_ id) + provider_message_id (SES id) + sent_as + method.
  • held: status="pending_review" + message_id + approval_expires_at.
  • approved+sent: the "sent" set + edited (reviewer edited the draft).

message_id is always the e2a message id (GET-able), never the provider id — the SES id is provider_message_id. `reject` keeps its own RejectResultView (it is not a send).

type SendingRampView

type SendingRampView struct {
	Status                string     `json:"status" doc:"Platform-managed sending-ramp state. Open set; known values: inactive, ramping, complete, exempt."`
	DailyRecipientLimit   int        `json:"daily_recipient_limit" doc:"Current UTC-day recipient allowance. Zero means no ramp cap applies."`
	RecipientsUsedToday   int        `` /* 150-byte string literal not displayed */
	ResetsAt              *time.Time `json:"resets_at,omitempty"`
	ActiveDays            int        `json:"active_days" doc:"UTC days that reached the provider-accepted volume threshold."`
	RampDays              int        `json:"ramp_days"`
	EstimatedCompletionAt *time.Time `` /* 157-byte string literal not displayed */
}

type Server

type Server struct {
	Router chi.Router
	API    huma.API
	// contains filtered or unexported fields
}

Server is the v1 HTTP surface: a chi root router with the Huma API mounted on it and the legacy handler wired as the fallback.

func New

func New(deps Deps) *Server

New builds the v1 server. It installs the e2a error envelope globally, stands up the Huma API on a chi router under the `/v1` documentation paths, registers the ported operations, and points chi's not-found/ method-not-allowed handlers at the legacy surface.

func (*Server) OpenAPIYAML

func (s *Server) OpenAPIYAML() ([]byte, error)

OpenAPIYAML renders the generated spec as YAML. Used by the codegen step and the drift test — the spec is emitted from the live handlers, never hand-authored.

func (*Server) ServeHTTP

func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP makes Server a drop-in http.Handler.

type StarterTemplateAliasParam

type StarterTemplateAliasParam struct {
	Alias string `path:"alias" doc:"The starter template's alias, e.g. welcome."`
}

StarterTemplateAliasParam is the path input for single-starter ops.

type StarterTemplateDetailView

type StarterTemplateDetailView struct {
	StarterTemplateView
	Body     string `json:"text" doc:"The plain-text part's template source."`
	HTMLBody string `json:"html" doc:"The HTML part's template source."`
}

StarterTemplateDetailView is the single-starter shape: the list fields (flattened via embedding) plus the full body sources.

type StarterTemplateVariableView

type StarterTemplateVariableView struct {
	Name        string `json:"name"`
	Required    bool   `json:"required" doc:"Required variables should always be supplied; optional ones render as empty strings when absent."`
	Raw         bool   `` /* 141-byte string literal not displayed */
	Description string `json:"description"`
	Example     string `json:"example" doc:"A realistic sample value, usable as template_data for previews."`
}

StarterTemplateVariableView describes one interpolation slot a starter template declares.

type StarterTemplateView

type StarterTemplateView struct {
	Alias       string                        `json:"alias"`
	Name        string                        `json:"name"`
	Description string                        `json:"description"`
	Version     string                        `json:"version"`
	Subject     string                        `json:"subject" doc:"Subject template source ({{variable}} interpolation)."`
	Variables   []StarterTemplateVariableView `json:"variables" nullable:"false"`
}

StarterTemplateView is one starter template in the list — catalog metadata only. The (large) body sources are returned by the get-by-alias endpoint.

type SuppressionView

type SuppressionView struct {
	Address         string    `json:"address"`
	Reason          string    `json:"reason,omitempty"`
	Source          string    `json:"source"` // bounce | complaint | manual
	SourceMessageID string    `json:"source_message_id,omitempty"`
	CreatedAt       time.Time `json:"created_at"`
}

SuppressionView is the wire shape of one (user, address) entry on the per-tenant suppression list — the HTTP DTO over the identity.Suppression storage model. Field-for-field identical JSON; mapped via suppressionView().

type TemplateData

type TemplateData map[string]any

TemplateData is a free-form JSON object carrying template variables (template_data on send, test_data on validate). It decodes with json.Decoder.UseNumber so numeric values arrive as json.Number and render digit-exact — plain encoding/json would decode every number as float64, corrupting integers beyond 2^53 (123456789012345678 → …680). The OpenAPI schema is unchanged: reflection sees an ordinary map → free-form object.

func (*TemplateData) UnmarshalJSON

func (d *TemplateData) UnmarshalJSON(b []byte) error

type TemplateIDParam

type TemplateIDParam struct {
	ID string `path:"id"`
}

TemplateIDParam is the path input for single-template read/update ops.

type TemplatePartError

type TemplatePartError struct {
	Part    string `json:"part" doc:"Which part failed. Known values: subject, text, html."`
	Message string `json:"message"`
}

TemplatePartError is one per-part validation failure.

type TemplateSummaryView

type TemplateSummaryView struct {
	ID        string    `json:"id"`
	Name      string    `json:"name"`
	Alias     string    `json:"alias,omitempty" doc:"Optional per-user unique handle usable as template_alias on send."`
	Subject   string    `json:"subject"`
	CreatedAt time.Time `json:"created_at" format:"date-time"`
	UpdatedAt time.Time `json:"updated_at" format:"date-time"`
}

TemplateSummaryView is the list-item shape: metadata only, NO body/ html_body sources (a full library of maximal templates would ship megabytes per list call; every list consumer needs metadata). Fetch one by id for the sources — same split as the starter-templates list/detail.

type TemplateView

type TemplateView struct {
	ID       string `json:"id"`
	Name     string `json:"name"`
	Alias    string `json:"alias,omitempty" doc:"Optional per-user unique handle usable as template_alias on send."`
	Subject  string `json:"subject"`
	Body     string `json:"text" doc:"The plain-text part's template source."`
	HTMLBody string `json:"html,omitempty" doc:"The optional HTML part's template source."`
	// Read-only starter provenance, set only on from_starter creates.
	FromStarterAlias   string    `` /* 214-byte string literal not displayed */
	FromStarterVersion string    `` /* 215-byte string literal not displayed */
	CreatedAt          time.Time `json:"created_at" format:"date-time"`
	UpdatedAt          time.Time `json:"updated_at" format:"date-time"`
}

TemplateView is the full template resource (create/get/update responses; the list returns TemplateSummaryView).

type TestWebhookRequest

type TestWebhookRequest struct {
	Event string         `` /* 304-byte string literal not displayed */
	Data  map[string]any `json:"data,omitempty"`
}

TestWebhookRequest mirrors the legacy body.

type TestWebhookResponse

type TestWebhookResponse struct {
	DeliveryID string `json:"delivery_id"`
}

TestWebhookResponse is the test-delivery result (WH-6 naming).

type ThreatCategoryView

type ThreatCategoryView struct {
	Name  string  `json:"name"`
	Score float64 `json:"score,omitempty"`
}

ThreatCategoryView is a single detected threat class + its confidence.

type TooManyRecipientsDetails

type TooManyRecipientsDetails struct {
	MaxRecipients int `json:"max_recipients" minimum:"1" doc:"Maximum recipients allowed across to, cc, and bcc."`
	Provided      int `json:"provided" minimum:"1" doc:"Combined recipient count supplied by the caller."`
}

type UnsubscribeOptions

type UnsubscribeOptions struct {
	Mode    string `` /* 141-byte string literal not displayed */
	Present bool   `json:"-"`
}

UnsubscribeOptions is the beta per-message opt-in to e2a-managed unsubscribe handling. It is presence-aware because omission is the compatibility path: it means only that managed unsubscribe was not requested and does not classify the message as transactional. This type may change before stable.

func (*UnsubscribeOptions) UnmarshalJSON

func (o *UnsubscribeOptions) UnmarshalJSON(data []byte) error

type UpdateAgentRequest

type UpdateAgentRequest struct {
	Name *string `` /* 201-byte string literal not displayed */
}

UpdateAgentRequest is the /v1 agent PATCH body. The per-agent screening/HITL config moved to the /v1/agents/{email}/protection sub-resource (design 2026-06-22), so the only mutable field left on the agent itself is the display name. Pointer so absent != "" (an empty name is a valid clear).

type UpdateMessageRequest

type UpdateMessageRequest struct {
	AddLabels    []string `json:"add_labels,omitempty" nullable:"false"`
	RemoveLabels []string `json:"remove_labels,omitempty" nullable:"false"`
}

UpdateMessageRequest is the labels-delta body for PATCH …/messages/{id}. A label in both add and remove is removed (remove wins, per the store).

type UpdateMessageResultView

type UpdateMessageResultView struct {
	MessageID string   `json:"message_id"`
	Labels    []string `json:"labels" nullable:"false"`
}

UpdateMessageResultView echoes the post-update label set so callers can reflect state without a follow-up fetch.

type UpdateTemplateRequest

type UpdateTemplateRequest struct {
	Name     *string `json:"name,omitempty"`
	Alias    *string `json:"alias,omitempty" doc:"Set to \"\" to clear the alias."`
	Subject  *string `json:"subject,omitempty"`
	Body     *string `json:"text,omitempty"`
	HTMLBody *string `json:"html,omitempty" doc:"Set to \"\" to remove the HTML part."`
}

UpdateTemplateRequest is the PATCH body — pointer fields so absent != zero. Setting alias or html to "" clears it. Changed parts are re-parsed.

type UpdateWebhookRequest

type UpdateWebhookRequest struct {
	URL         *string                `json:"url,omitempty" doc:"When present, must be a non-empty webhook delivery URL; omit to leave the URL unchanged."`
	Events      *[]string              `` /* 485-byte string literal not displayed */
	Filters     *WebhookFiltersRequest `json:"filters,omitempty"`
	Description *string                `json:"description,omitempty"`
	Enabled     *bool                  `json:"enabled,omitempty"`
}

UpdateWebhookRequest mirrors the legacy PATCH body — pointer fields so absent != zero; url/events/filters are full-replace when present.

type ValidateTemplateRequest

type ValidateTemplateRequest struct {
	Subject  string       `json:"subject,omitempty"`
	Body     string       `json:"text,omitempty"`
	HTMLBody string       `json:"html,omitempty"`
	TestData TemplateData `json:"test_data,omitempty" doc:"Sample template_data to render the preview with. Missing variables render as empty strings."`
}

ValidateTemplateRequest carries template source to dry-run. Parts may be empty (an empty part parses trivially and renders empty).

type ValidateTemplateResponse

type ValidateTemplateResponse struct {
	Valid         bool                  `json:"valid"`
	Errors        []TemplatePartError   `json:"errors" nullable:"false"`
	Rendered      *RenderedTemplateView `json:"rendered,omitempty" doc:"Rendered preview against test_data (or empty data). Present only when valid."`
	SuggestedData map[string]any        `` /* 235-byte string literal not displayed */
}

ValidateTemplateResponse is the dry-run report.

type ValidationErrorDetails

type ValidationErrorDetails struct {
	Fields []FieldError `` /* 145-byte string literal not displayed */
}

ValidationErrorDetails is the typed error.details payload for the invalid_request code: the list of per-field validation failures that made the request invalid. It is the validation arm of the polymorphic-by-code details contract (other codes carry their own typed detail object — e.g. a limit_exceeded detail for the 402 quota path), so codegen emits a concrete model clients can read after branching on code == "invalid_request".

type VerifyDomainView

type VerifyDomainView struct {
	Domain     string     `json:"domain"`
	Verified   bool       `json:"verified"`
	VerifiedAt *time.Time `json:"verified_at,omitempty"`
	MX         string     `` /* 591-byte string literal not displayed */
	SPF        string     `` /* 719-byte string literal not displayed */
	DKIM       string     `` /* 1071-byte string literal not displayed */
}

VerifyDomainView mirrors the legacy VerifyDomainResponse.

The mx/spf/dkim fields report the LIVE DNS probe outcome of this verification attempt in probe vocabulary (found/missing/deferred/mismatch). This is deliberately a different axis — and a different word set — from the PERSISTED per-record state in DomainView.dns_records[].status (verified/pending/missing/failed): a probe answers "what did DNS return just now", the persisted status answers "what has the platform recorded about this record". Keep the two vocabularies distinct; see domains_vocab_test.go.

type WebhookDeliveryView

type WebhookDeliveryView struct {
	ID             string     `json:"id"`
	EventType      string     `` /* 271-byte string literal not displayed */
	Status         string     `json:"status" doc:"Delivery state. Open set; tolerate unknown values. Known values: pending, delivered, failed."`
	Attempts       int        `json:"attempts"`
	LastError      string     `json:"last_error,omitempty"`
	LastStatusCode *int       `json:"last_status_code,omitempty"`
	LastAttemptAt  *time.Time `json:"last_attempt_at,omitempty" format:"date-time"`
	NextRetryAt    time.Time  `json:"next_retry_at" format:"date-time"`
	CreatedAt      time.Time  `json:"created_at" format:"date-time"`
}

WebhookDeliveryView mirrors the legacy per-delivery wire shape.

type WebhookFiltersRequest

type WebhookFiltersRequest struct {
	AgentIDs        []string `json:"agent_emails,omitempty" nullable:"false"`
	ConversationIDs []string `json:"conversation_ids,omitempty" nullable:"false"`
	Labels          []string `json:"labels,omitempty" nullable:"false"`
}

WebhookFiltersRequest is WebhookFiltersView's request-side twin (create/ update bodies). Dedicated input type because the spec's forward-compat stance is asymmetric: request schemas stay `additionalProperties: false` (an unknown filter key is a 422, not a silently ignored no-op filter) while response schemas are open so clients tolerate additive fields; one shared schema cannot carry both (stability.go panics if one tries). Keep the fields in lockstep with the View. Convertible via WebhookFiltersView(f) — the compiler enforces the mirror.

type WebhookFiltersView

type WebhookFiltersView struct {
	AgentIDs        []string `json:"agent_emails,omitempty" nullable:"false"`
	ConversationIDs []string `json:"conversation_ids,omitempty" nullable:"false"`
	Labels          []string `json:"labels,omitempty" nullable:"false"`
}

WebhookFiltersView mirrors identity.WebhookFilters (response side).

type WebhookIDParam

type WebhookIDParam struct {
	ID string `path:"id"`
}

WebhookIDParam is the path input for single-webhook read/update ops.

type WebhookView

type WebhookView struct {
	ID              string             `json:"id"`
	URL             string             `json:"url"`
	Description     string             `json:"description"`
	Events          []string           `` /* 628-byte string literal not displayed */
	Filters         WebhookFiltersView `json:"filters"`
	Enabled         bool               `json:"enabled"`
	AutoDisabledAt  *time.Time         `json:"auto_disabled_at,omitempty" format:"date-time"`
	CreatedAt       time.Time          `json:"created_at" format:"date-time"`
	LastDeliveredAt *time.Time         `json:"last_delivered_at,omitempty" format:"date-time"`
}

WebhookView is the webhook resource as returned by get/list/update. It carries NO signing secret (WH-3): the secret is shown once, only in the create response (CreateWebhookResponse) and on rotate (rotateSecretResponse).

Jump to

Keyboard shortcuts

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