Documentation
¶
Index ¶
- Constants
- Variables
- func ContentScanEnabled() bool
- func DeterministicProtectionEventID(messageID, source, reason, detector string) string
- func NewConversationID() string
- func NewInboundIntakeID() string
- func NewMessageID() string
- func NewProtectionEventID() string
- func NormalizeEmail(email string) string
- func NormalizeMailboxAddress(value string) string
- func RunMigrations(ctx context.Context, pool *pgxpool.Pool, migrationsFS fs.FS, ...) error
- func ValidScope(s string) bool
- func ValidateAgentName(name string) error
- func ValidateHITLConfig(ttlSeconds int, expirationAction string) error
- func ValidateProtectionConfig(c ProtectionConfig) error
- func ValidateScanConfig(c ScanConfig) error
- type APIKey
- type APIKeyExportEntry
- type AcceptedSend
- type AgentIdentity
- type AgentSuppression
- type AgentSuppressionHookScope
- type AgentSuppressionTxHook
- type ConversationDetail
- type ConversationListFilter
- type ConversationSummary
- type DKIMCipher
- type DashboardPendingStats
- type DashboardStats
- type DashboardTodayStats
- type DeleteUserDataResult
- type Domain
- type ExpirationCandidate
- type InboundAuth
- type InboundIntake
- type InboundScreening
- type LocalDeliveryTxHook
- type Message
- type MessageListFilter
- type MigrationMode
- type OAuthConnectionEntry
- type OutboundSendPayload
- type OutboundSentInfo
- type PendingApprovalEdit
- type PendingNotify
- type Principal
- type ProtectionConfig
- type ProtectionEvent
- type ProtectionEventExportEntry
- type ReviewListItem
- type ReviewMessageMeta
- type ScanConfig
- type SendAttemptOutcome
- type SendAttemptResult
- type SendOutcome
- type SendResult
- type Store
- func (s *Store) AddAgentSuppression(ctx context.Context, userID, agentID, address, reason, source string, ...) (sp AgentSuppression, added bool, err error)
- func (s *Store) AddAgentSuppressionFromTokenScope(ctx context.Context, scope UnsubscribeScope, onAdded AgentSuppressionTxHook) (AgentSuppression, bool, error)
- func (s *Store) AddSuppression(ctx context.Context, userID, address, reason, source, sourceMessageID string) (bool, error)
- func (s *Store) AdoptSharedDomain(ctx context.Context, domain, userID string) (*Domain, error)
- func (s *Store) ApproveAndAccept(ctx context.Context, messageID, reviewedByUserID, targetStatus string, ...) (*Message, error)
- func (s *Store) ApproveAndDeliverLocal(ctx context.Context, messageID, userID string, edits PendingApprovalEdit, ...) (*Message, error)
- func (s *Store) ApproveAndSend(ctx context.Context, messageID, userID string, edits PendingApprovalEdit, ...) (*Message, error)
- func (s *Store) ApproveInboundReview(ctx context.Context, messageID, agentID, reviewerID string) error
- func (s *Store) AutoDisableFailingWebhooks(ctx context.Context) (int, error)
- func (s *Store) BootstrapUser(ctx context.Context, email string) (*User, error)
- func (s *Store) ClaimOrCreateDomain(ctx context.Context, domain, userID string) (*Domain, error)
- func (s *Store) ClaimOutboundForSend(ctx context.Context, messageID string, jobID int64) (*OutboundSendPayload, error)
- func (s *Store) ClaimSendAttempt(ctx context.Context, messageID string) (SendAttemptResult, error)
- func (s *Store) ClearExpiredPrevSecrets(ctx context.Context) (int64, error)
- func (s *Store) CorrelateByE2AMessageID(ctx context.Context, e2aMessageID string) (*delivery.CorrelatedMessage, bool, error)
- func (s *Store) CorrelateBySESMessageID(ctx context.Context, sesMessageID string) (*delivery.CorrelatedMessage, bool, error)
- func (s *Store) CountTemplatesByUser(ctx context.Context, userID string) (int, error)
- func (s *Store) CountWebhooksByUser(ctx context.Context, userID string) (int, error)
- func (s *Store) CreateAPIKey(ctx context.Context, userID, name string, expiresAt *time.Time) (*APIKey, error)
- func (s *Store) CreateAgent(ctx context.Context, ...) (*AgentIdentity, error)
- func (s *Store) CreateAgentTx(ctx context.Context, tx pgx.Tx, ...) (*AgentIdentity, error)
- func (s *Store) CreateInboundMessage(ctx context.Context, ...) (*Message, error)
- func (s *Store) CreateInboundMessageAuthenticated(ctx context.Context, id, agentID string, auth InboundAuth, ...) (*Message, error)
- func (s *Store) CreateInboundMessageAuthenticatedInTx(ctx context.Context, tx pgx.Tx, id, agentID string, auth InboundAuth, ...) (*Message, error)
- func (s *Store) CreateInboundMessageInTx(ctx context.Context, tx pgx.Tx, ...) (*Message, error)
- func (s *Store) CreateOrGetUser(ctx context.Context, email, name, googleSub string) (*User, error)
- func (s *Store) CreateOutboundMessage(ctx context.Context, agentID string, toRecipients []string, cc []string, ...) (*Message, error)
- func (s *Store) CreateOutboundMessageTx(ctx context.Context, tx pgx.Tx, agentID string, toRecipients, cc, bcc []string, ...) (*Message, error)
- func (s *Store) CreatePendingOutboundMessage(ctx context.Context, agentID string, toRecipients, cc, bcc []string, ...) (*Message, error)
- func (s *Store) CreatePendingOutboundMessageManaged(ctx context.Context, agentID string, toRecipients, cc, bcc []string, ...) (*Message, error)
- func (s *Store) CreatePendingOutboundMessageManagedTx(ctx context.Context, tx pgx.Tx, agentID string, toRecipients, cc, bcc []string, ...) (*Message, error)
- func (s *Store) CreatePendingOutboundMessageTx(ctx context.Context, tx pgx.Tx, agentID string, toRecipients, cc, bcc []string, ...) (*Message, error)
- func (s *Store) CreateProtectionEvent(ctx context.Context, ev ProtectionEvent) error
- func (s *Store) CreateScopedAPIKey(ctx context.Context, userID, name, scope, agentID string, expiresAt *time.Time) (*APIKey, error)
- func (s *Store) CreateTemplate(ctx context.Context, userID string, in TemplateCreate) (*Template, error)
- func (s *Store) CreateUserSession(ctx context.Context, userID string) (string, error)
- func (s *Store) CreateWebhook(ctx context.Context, userID, url, description string, events []string, ...) (*Webhook, error)
- func (s *Store) CreateWebhookIdem(ctx context.Context, userID, url, description string, events []string, ...) (*Webhook, error)
- func (s *Store) DeferOutboundTerminalFailure(ctx context.Context, messageID string, jobID int64, detail string) error
- func (s *Store) DeleteAPIKey(ctx context.Context, keyID, userID string) error
- func (s *Store) DeleteAgent(ctx context.Context, agentID, userID string) (messagesDeleted int64, err error)
- func (s *Store) DeleteDomain(ctx context.Context, domain, userID string) error
- func (s *Store) DeleteDomainTx(ctx context.Context, domain, userID string, ...) error
- func (s *Store) DeleteExpiredMessages(ctx context.Context) (int64, error)
- func (s *Store) DeleteExpiredUserSessions(ctx context.Context) (int64, error)
- func (s *Store) DeleteTemplate(ctx context.Context, templateID, userID string) error
- func (s *Store) DeleteUserData(ctx context.Context, userID string) (*DeleteUserDataResult, error)
- func (s *Store) DeleteUserDataTx(ctx context.Context, userID string, ...) (*DeleteUserDataResult, error)
- func (s *Store) DeleteUserSession(ctx context.Context, token string) error
- func (s *Store) DeleteWebhook(ctx context.Context, webhookID, userID string) error
- func (s *Store) DomainExists(ctx context.Context, domain string) (bool, error)
- func (s *Store) DomainOwner(ctx context.Context, domain string) (string, error)
- func (s *Store) EffectiveSuppressions(ctx context.Context, userID, agentID string, addresses []string) ([]string, error)
- func (s *Store) EncryptLegacyDKIMKeys(ctx context.Context) (int, error)
- func (s *Store) EnsureSharedDomain(ctx context.Context, domain string) error
- func (s *Store) ExpireAndDeliverLocal(ctx context.Context, messageID string, ...) (*Message, error)
- func (s *Store) ExpireApproveAndSend(ctx context.Context, messageID string, ...) (*Message, error)
- func (s *Store) ExpireApproveReview(ctx context.Context, messageID string) error
- func (s *Store) ExpireReject(ctx context.Context, messageID, reason string) (*Message, error)
- func (s *Store) ExpireRejectReview(ctx context.Context, messageID, reason string) error
- func (s *Store) ExportUserData(ctx context.Context, userID string) (*UserExport, error)
- func (s *Store) GetAgentByEmail(ctx context.Context, email string) (*AgentIdentity, error)
- func (s *Store) GetAgentByID(ctx context.Context, id string) (*AgentIdentity, error)
- func (s *Store) GetAgentByIDAnyState(ctx context.Context, id string) (*AgentIdentity, error)
- func (s *Store) GetConversationByID(ctx context.Context, agentID, conversationID string) (*ConversationDetail, error)
- func (s *Store) GetDKIMKeyInternal(ctx context.Context, domain string) (string, []byte, error)
- func (s *Store) GetDashboardStats(ctx context.Context, userID string, windowDays int) (*DashboardStats, error)
- func (s *Store) GetEventEnvelope(ctx context.Context, messageID, eventType string) ([]byte, error)
- func (s *Store) GetInboundByEmailMessageID(ctx context.Context, agentID, emailMessageID string) (*Message, error)
- func (s *Store) GetInboundMessage(ctx context.Context, id string) (*Message, error)
- func (s *Store) GetMessageByEmailMessageID(ctx context.Context, agentID, messageID string) (*Message, error)
- func (s *Store) GetMessageWithContent(ctx context.Context, messageID, agentID string) (*Message, error)
- func (s *Store) GetMessagesByAgent(ctx context.Context, f MessageListFilter) ([]Message, error)
- func (s *Store) GetOutboundMessageForUser(ctx context.Context, messageID, userID string) (*Message, error)
- func (s *Store) GetPrincipalByAPIKey(ctx context.Context, apiKey string) (*Principal, error)
- func (s *Store) GetRepliableMessage(ctx context.Context, id string) (*Message, error)
- func (s *Store) GetReviewMessage(ctx context.Context, messageID, agentID string) (*ReviewMessageMeta, error)
- func (s *Store) GetReviewWithContent(ctx context.Context, userID, messageID string) (*Message, error)
- func (s *Store) GetSendOutcome(ctx context.Context, messageID string) (SendOutcome, error)
- func (s *Store) GetSendingStatus(ctx context.Context, domain string) (string, error)
- func (s *Store) GetTemplateByAlias(ctx context.Context, alias, userID string) (*Template, error)
- func (s *Store) GetTemplateByID(ctx context.Context, templateID, userID string) (*Template, error)
- func (s *Store) GetUserByAPIKey(ctx context.Context, apiKey string) (*User, error)
- func (s *Store) GetUserByID(ctx context.Context, id string) (*User, error)
- func (s *Store) GetUserSession(ctx context.Context, token string) (*User, error)
- func (s *Store) GetWebhookByID(ctx context.Context, webhookID, userID string) (*Webhook, error)
- func (s *Store) GetWebhookByIDInternal(ctx context.Context, webhookID string) (*Webhook, error)
- func (s *Store) HasAgentsOnDomain(ctx context.Context, domain, userID string) (bool, error)
- func (s *Store) InsertInboundIntakeTx(ctx context.Context, tx pgx.Tx, ...) (inserted bool, err error)
- func (s *Store) ListAPIKeys(ctx context.Context, userID string, limit int, afterCreatedAt time.Time, ...) ([]APIKey, error)
- func (s *Store) ListActivityByAgent(ctx context.Context, agentID string, limit int) ([]Message, error)
- func (s *Store) ListAgentSuppressions(ctx context.Context, userID, agentID string, limit int, after time.Time, ...) ([]AgentSuppression, error)
- func (s *Store) ListAgentsByUser(ctx context.Context, userID string, limit int, afterCreatedAt time.Time, ...) ([]AgentIdentity, error)
- func (s *Store) ListConversationsByAgent(ctx context.Context, f ConversationListFilter) ([]ConversationSummary, error)
- func (s *Store) ListDeletedAgentsByUser(ctx context.Context, userID string, limit int, afterCreatedAt time.Time, ...) ([]AgentIdentity, error)
- func (s *Store) ListDomainsByUser(ctx context.Context, userID string, limit int, afterCreatedAt time.Time, ...) ([]Domain, error)
- func (s *Store) ListEnabledWebhooksForRouting(ctx context.Context, userID, eventType string) ([]Webhook, error)
- func (s *Store) ListExpiredPending(ctx context.Context, limit int) ([]ExpirationCandidate, error)
- func (s *Store) ListExpiredReviews(ctx context.Context, limit int) ([]ExpirationCandidate, error)
- func (s *Store) ListPendingOutboundForUser(ctx context.Context, userID string, limit int) ([]Message, error)
- func (s *Store) ListProtectionEventsByAgent(ctx context.Context, agentID string, limit int) ([]ProtectionEvent, error)
- func (s *Store) ListProtectionEventsByMessage(ctx context.Context, messageID string) ([]ProtectionEvent, error)
- func (s *Store) ListReviews(ctx context.Context, userID string, limit int, afterCreatedAt time.Time, ...) ([]ReviewListItem, error)
- func (s *Store) ListSuppressions(ctx context.Context, userID string, limit int, afterCreatedAt time.Time, ...) ([]Suppression, error)
- func (s *Store) ListTemplatesByUser(ctx context.Context, userID string, limit int, afterCreatedAt time.Time, ...) ([]TemplateSummary, error)
- func (s *Store) ListWebhooksByUser(ctx context.Context, userID string, limit int, afterCreatedAt time.Time, ...) ([]Webhook, error)
- func (s *Store) LoadInboundIntake(ctx context.Context, intakeID string) (*InboundIntake, error)
- func (s *Store) LoadOutboundDraft(ctx context.Context, messageID string) (*Message, error)
- func (s *Store) LoadOutboundForSend(ctx context.Context, messageID string) (*OutboundSendPayload, error)
- func (s *Store) LoadPendingNotify(ctx context.Context, messageID string) (*PendingNotify, error)
- func (s *Store) LookupConversationID(ctx context.Context, agentID string, messageIDs []string) (string, error)
- func (s *Store) LookupCoveringDomain(ctx context.Context, sub, userID string) (*Domain, error)
- func (s *Store) LookupDomain(ctx context.Context, domain, userID string) (*Domain, error)
- func (s *Store) MarkInboundIntakeFailed(ctx context.Context, intakeID, detail string) error
- func (s *Store) MarkInboundIntakeProcessedTx(ctx context.Context, tx pgx.Tx, intakeID, messageFK string) error
- func (s *Store) MarkMessageNotified(ctx context.Context, messageID string) error
- func (s *Store) MarkMessageSent(ctx context.Context, messageID, sentAs string, to, cc, bcc []string) error
- func (s *Store) MarkOutboundFailedTx(ctx context.Context, tx pgx.Tx, messageID, detail string, ...) (*OutboundSentInfo, error)
- func (s *Store) MarkOutboundSentTx(ctx context.Context, tx pgx.Tx, messageID, providerMessageID string) (*OutboundSentInfo, error)
- func (s *Store) MarkSendFailed(ctx context.Context, messageID, errMsg string) error
- func (s *Store) MarkSendSucceeded(ctx context.Context, messageID string, r SendResult) error
- func (s *Store) MarkSendSucceededWithRetry(messageID string, r SendResult) error
- func (s *Store) MaxTemplatesForUser(ctx context.Context, userID string) (int, error)
- func (s *Store) MaxWebhooksForUser(ctx context.Context, userID string) (int, error)
- func (s *Store) ModifyMessageLabels(ctx context.Context, messageID, agentID string, add, remove []string) ([]string, error)
- func (s *Store) PruneProcessedIntake(ctx context.Context, olderThan time.Duration) (int64, error)
- func (s *Store) PurgeDeletedAgents(ctx context.Context) (int64, error)
- func (s *Store) PurgeMessage(ctx context.Context, messageID, agentID string) error
- func (s *Store) PutUnsubscribeToken(ctx context.Context, tokenHash []byte, userID, agentID, address string) error
- func (s *Store) RecordDeliveryOutcome(ctx context.Context, messageID, address string, status delivery.Status, ...) error
- func (s *Store) RecordProviderAcceptEvidence(ctx context.Context, messageID, sesMessageID string) error
- func (s *Store) RejectInboundReview(ctx context.Context, messageID, agentID, reviewerID, reason string) error
- func (s *Store) RejectPending(ctx context.Context, messageID, userID, reason string) (*Message, error)
- func (s *Store) ReleaseOutboundSendClaim(ctx context.Context, messageID string, jobID int64) error
- func (s *Store) RemoveAgentSuppression(ctx context.Context, userID, agentID, address string) (bool, error)
- func (s *Store) RemoveSuppression(ctx context.Context, userID, address string) (bool, error)
- func (s *Store) ResolveOutboundOwner(ctx context.Context, messageID string) (userID, agentID string, err error)
- func (s *Store) ResolveOutboundProviderAcceptedTx(ctx context.Context, tx pgx.Tx, messageID string) (*OutboundSentInfo, string, error)
- func (s *Store) ResolveUnsubscribeToken(ctx context.Context, tokenHash []byte) (*UnsubscribeScope, error)
- func (s *Store) RestoreAgent(ctx context.Context, agentID, userID string) error
- func (s *Store) RestoreMessage(ctx context.Context, messageID, agentID string) error
- func (s *Store) RotateSecret(ctx context.Context, webhookID, userID string) (newPlaintext string, prevExpiresAt time.Time, err error)
- func (s *Store) SendingProvisionInputs(ctx context.Context, domain string) (selector string, privateKeyDER []byte, ok bool, err error)
- func (s *Store) SetAccountClass(ctx context.Context, userID, class string) error
- func (s *Store) SetDKIMCipher(c *DKIMCipher)
- func (s *Store) SetMessageScreening(ctx context.Context, messageID, agentID, reviewReason string, ...) error
- func (s *Store) SetSendingStatus(ctx context.Context, domain, status, dkimStatus, mailFromStatus, errMsg string, ...) error
- func (s *Store) SoftDeleteAgent(ctx context.Context, agentID, userID string) error
- func (s *Store) SoftDeleteMessage(ctx context.Context, messageID, agentID string) error
- func (s *Store) StampInboundIntakeJobIDTx(ctx context.Context, tx pgx.Tx, intakeID string, jobID int64) error
- func (s *Store) StampNotifyJobIDTx(ctx context.Context, tx pgx.Tx, messageID string, jobID int64) error
- func (s *Store) StampSendJobIDTx(ctx context.Context, tx pgx.Tx, messageID string, jobID int64) error
- func (s *Store) SuppressedAddresses(ctx context.Context, userID string, addrs []string) ([]string, error)
- func (s *Store) TouchDomainLastChecked(ctx context.Context, domain, userID string) error
- func (s *Store) TouchSendingChecked(ctx context.Context, domain string) error
- func (s *Store) UpdateAgentHITL(ctx context.Context, agentID, userID string, ttlSeconds int, ...) error
- func (s *Store) UpdateAgentInboundPolicy(ctx context.Context, agentID, userID, policy string, allowlist []string) error
- func (s *Store) UpdateAgentName(ctx context.Context, agentID, userID, name string) error
- func (s *Store) UpdateAgentProtection(ctx context.Context, agentID, userID string, c ProtectionConfig) error
- func (s *Store) UpdateAgentScanConfig(ctx context.Context, agentID, userID string, c ScanConfig) error
- func (s *Store) UpdateMessageDeliveryStatus(ctx context.Context, messageID, agentID, status string) error
- func (s *Store) UpdateTemplate(ctx context.Context, templateID, userID string, u TemplateUpdate) (*Template, error)
- func (s *Store) UpdateUserName(ctx context.Context, userID, name string) (*User, error)
- func (s *Store) UpdateWebhook(ctx context.Context, webhookID, userID string, u WebhookUpdate) (*Webhook, error)
- func (s *Store) VerifyDomain(ctx context.Context, domain, userID string) error
- func (s *Store) WithTx(ctx context.Context, fn func(tx pgx.Tx) error) error
- type Suppression
- type SuppressionExportEntry
- type Template
- type TemplateCreate
- type TemplateSummary
- type TemplateUpdate
- type UnsubscribeScope
- type UsageEventEntry
- type User
- type UserExport
- type UserExportUser
- type Webhook
- type WebhookFilters
- type WebhookIdemCompleter
- type WebhookUpdate
Constants ¶
const ( IntakeStatusAccepted = "accepted" IntakeStatusProcessed = "processed" IntakeStatusFailed = "failed" )
IntakeStatus values (mirrors migration 056's CHECK).
const ( SensitivityOff = "off" SensitivityLow = "low" SensitivityMedium = "medium" SensitivityHigh = "high" )
Scan sensitivity levels — the public protection API's content-scan knob (design 2026-06-22-agent-protection-config.md §4.2). A level maps to the engine's (review, block) threshold pair; `off` disables the scan.
const ( ScanActionFlag = "flag" ScanActionReview = "review" ScanActionBlock = "block" ScanOff = "off" ScanOn = "on" OutboundPolicyOpen = "open" OutboundPolicyAllowlist = "allowlist" OutboundPolicyDomain = "domain" )
Screening config enum values (migration 038 / Slice 3).
const ( ScreeningSourceGate = "gate" ScreeningSourceScan = "scan" )
Screening source values (protection_events.source).
const ( ReviewReasonSenderGate = "sender_gate" ReviewReasonRecipientGate = "recipient_gate" ReviewReasonInboundScan = "inbound_scan" ReviewReasonOutboundScan = "outbound_scan" ReviewReasonOutboundSend = "outbound_send" )
Review reason values (messages.review_reason and protection_events.reason).
const ( // WebhookStatusNone: no webhook subscriber matches this agent. WebhookStatusNone = "none" // WebhookStatusHealthy: at least one enabled webhook matches, and no // matching enabled webhook has a terminally-failed delivery in the // last 24h. WebhookStatusHealthy = "healthy" // WebhookStatusFailing: at least one enabled webhook matches, but a // matching enabled webhook had a terminally-failed delivery (retries // exhausted) in the last 24h. WebhookStatusFailing = "failing" // WebhookStatusDisabled: webhooks match this agent but every one is // disabled, all by hand (no auto_disabled_at). WebhookStatusDisabled = "disabled" // WebhookStatusAutoDisabled: webhooks match this agent, every one is // disabled, and at least one was tripped by the chronic-failure sweep // (AutoDisableFailingWebhooks). WebhookStatusAutoDisabled = "auto_disabled" )
WebhookStatus* are the known AgentIdentity.WebhookStatus values. The set is open — API consumers must tolerate unknown values — but the server only emits these five today. Precedence when several webhooks match one agent: any enabled webhook wins (healthy/failing per recent deliveries); among all-disabled matches, an auto-disabled one wins over a manual disable.
const ( HITLMaxTTLSeconds = 604800 // 7 days HITLDefaultTTLSeconds = 604800 HITLExpirationApprove = "approve" HITLExpirationReject = "reject" HITLDefaultExpirationAct = HITLExpirationReject )
HITL constants mirror the CHECK constraints in migration 003_hitl.sql.
const ( MessageStatusSent = "sent" // Unified review-hold statuses (direction-aware — design 2026-06-22). A held // message is one primitive regardless of direction; on resolution, approve = // send (outbound) / deliver to the agent (inbound), reject = drop. Outbound's // "approved" terminal is MessageStatusSent (the approve triggers the send), so // there is no separate outbound approved-but-unsent state. MessageStatusPendingReview = "pending_review" MessageStatusReviewApproved = "review_approved" MessageStatusReviewRejected = "review_rejected" MessageStatusReviewExpiredApproved = "review_expired_approved" MessageStatusReviewExpiredRejected = "review_expired_rejected" )
Message status values mirror the CHECK constraint in migration 044_unify_holds.sql.
const ( // ScopeAccount is account-wide admin: agent/domain/key management, account // settings. The pre-redesign default; what an `e2a_acct_…` key holds. ScopeAccount = "account" // ScopeAgent is bound to a single agent (runtime/inbox tier): the credential // IS the agent. Pinned to one agent_id and barred from account-only ops. ScopeAgent = "agent" )
Credential scope (Slice 5a / design §5). The scope a credential carries — not the auth method — determines its blast radius.
const ( AutoDisableThreshold = 10 AutoDisableWindow = 72 * time.Hour )
AutoDisableThreshold is the consecutive-failed-events count over AutoDisableWindow that trips a webhook into the auto-disabled state. Tuned per design decision #12 (10 / 72h). The reviewer can re-enable via PATCH after the 5-min cooldown.
const ConversationListHardCap = 100
ConversationListHardCap is the maximum number of conversations a single list call returns. Higher requests are silently clamped. 100 covers the inbox-style use case; a deployment that needs more can either ask for higher (we'll bump it) or paginate (slice 2).
const DashboardDefaultWindowDays = 7
DashboardDefaultWindowDays is the lookback for the dashboard strip when the caller doesn't request a specific window.
const DashboardMaxWindowDays = 90
DashboardMaxWindowDays caps the lookback to keep the underlying SQL scan bounded. 90 days is generous for any UI surface we currently have and remains efficient given the per-user index on usage_summaries.
const DefaultMaxTemplates = 10
DefaultMaxTemplates is the per-user cap fallback for users without an account_limits row — mirrors the column DEFAULT in migration 050, same pattern as DefaultMaxWebhooks.
const DefaultMaxWebhooks = 50
MaxWebhooksForUser returns the per-user cap from account_limits. Users without an account_limits row default to 50 — the column DEFAULT, mirrored here as a fallback so the cap works on dev installs that haven't seeded an account_limits row.
const MaxAPIKeyNameLen = 200
MaxAPIKeyNameLen bounds the API-key display name (a human label, not an identifier). One source of truth for every entry path: the /v1 createApiKey schema tag AND the legacy dashboard POST /api/keys handler (internal/auth). Counted in Unicode code points, matching OpenAPI maxLength semantics.
const MaxAgentNameLen = 200
MaxAgentNameLen bounds the agent display name (a UI label, not an identifier). It is the single source of truth shared by the create and update paths: the /v1 request schemas declare it as maxLength (validated by Huma in Unicode code points) and ValidateAgentName enforces the same rune-count semantics here, so the spec and the runtime always agree.
const MaxLabelsPerMessage = 100
MaxLabelsPerMessage is the post-add cap on the labels[] column. The per-operation cap (max items in add_labels / remove_labels) is enforced earlier at the handler. The two together bound the array at a size where GIN containment + JSON marshalling stay cheap.
const MaxRejectReasonLen = 2000
MaxRejectReasonLen bounds a reviewer-supplied rejection reason. One source of truth for every entry path: the /v1 rejectReview schema tag AND the magic-link reject form (internal/agent), which clamps rather than fails a human's rejection. Counted in Unicode code points.
const OutboundSendClaimStaleWindow = 10 * time.Minute
OutboundSendClaimStaleWindow exceeds River's one-minute worker timeout and bounds how long a crashed worker can prevent permanent trash deletion.
const SendAttemptStaleWindow = 10 * time.Minute
SendAttemptStaleWindow is how long an 'attempting' send_attempts row stays "owned" by the original worker before the next caller is allowed to take it over. Bounded above by outbound.SMTPRelay's worst-case retry envelope (~6.5min) plus headroom — kept tighter would risk concurrent SES sends if a real upstream stall happened.
const SessionTTL = 7 * 24 * time.Hour
Variables ¶
var ( ErrTemplateNotFound = errors.New("template not found") ErrTemplateAliasTaken = errors.New("template alias already in use") ErrTemplateLimitReached = errors.New("template count limit reached for this user") )
Sentinel errors so API handlers can map error → HTTP status with errors.Is rather than string-matching.
var ( ErrWebhookNotFound = errors.New("webhook not found") ErrWebhookCapReached = errors.New("webhook count limit reached for this user") )
Sentinel errors so API handlers can map error → HTTP status with errors.Is rather than string-matching.
var ErrAPIKeyNotFound = errors.New("api key not found")
ErrAPIKeyNotFound is returned by DeleteAPIKey when no live key matched the (id, user) — i.e. it doesn't exist, isn't owned by the caller, or was already revoked. Distinct from a DB/connection error so the HTTP layer can map it to 404 while surfacing real failures as 500 (mirrors ErrWebhookNotFound).
var ErrAgentNotFound = fmt.Errorf("agent not found or not owned by user")
ErrAgentNotFound is returned by agent trash operations (soft delete / restore / permanent delete) when the agent row isn't there for the caller — either it never existed, belongs to another user, or was hard-deleted between resolution and the mutation (a race the handler maps to 404 instead of a generic 500). Mirrors ErrMessageNotFound / ErrDomainNotFound.
var ErrDomainHasAgents = fmt.Errorf("cannot delete domain: agents still exist")
ErrDomainHasAgents is returned when a domain delete is blocked by existing agents.
var ErrDomainNotFound = fmt.Errorf("domain not found or not owned by user")
ErrDomainNotFound is returned when a domain is not found or not owned by the user.
var ErrDomainTaken = fmt.Errorf("domain not available: already claimed by another account")
ErrDomainTaken is returned by ClaimOrCreateDomain when the domain row exists and is owned by a different user (verified, or an unverified claim that must not be squatted). The API layer maps it to 409 conflict, distinct from the 400 used for malformed input.
var ErrIntakeAlreadyProcessed = errors.New("inbound intake already processed")
ErrIntakeAlreadyProcessed signals that the intake was not in 'accepted' state when the worker tried to flip it — another attempt already processed it. The caller rolls back its persist tx (no duplicate message/event) and treats it as done.
var ErrLabelLimitExceeded = errors.New("label limit exceeded")
ErrLabelLimitExceeded reports that an add operation would push a message past MaxLabelsPerMessage. Mapped to HTTP 400 at the handler.
var ErrMessageHeld = fmt.Errorf("message is held for review")
ErrMessageHeld is returned when a trash operation targets a message that is held for review (status pending_review) — the review queue is its resolution surface; approve or reject it first.
var ErrMessageNotFound = fmt.Errorf("message not found")
ErrMessageNotFound is returned when a message is not found for the given user (either the ID doesn't exist or the message belongs to another user's agent). Handlers map this to HTTP 404.
var ErrNotInTrash = fmt.Errorf("resource is not in the trash")
ErrNotInTrash is returned by restore/purge operations that target a resource that exists but is not soft-deleted.
var ErrNotPendingApproval = fmt.Errorf("message is not pending approval")
ErrNotPendingApproval is returned when an approve or reject operation targets a message that is not (or is no longer) in pending_review status. Handlers map this to HTTP 409 Conflict.
var ErrNotPendingReview = fmt.Errorf("message is not pending review")
ErrNotPendingReview is returned when an approve/reject/expire targets an inbound message that is not (or is no longer) in pending_review.
var ErrRecipientGone = errors.New("recipient no longer resolves to an agent")
ErrRecipientGone signals that the recipient no longer resolves to an agent (deleted between accept and processing). It is NOT a transient error — the async worker marks the intake terminally (so it doesn't linger 'accepted' forever) and the sync path skips the recipient. Distinct sentinel so callers don't retry.
var ErrReservedDomain = fmt.Errorf("domain is reserved for managed infrastructure")
ErrReservedDomain is returned when a claim falls inside infrastructure e2a manages beneath an owned domain (currently the bounce.<domain> MAIL FROM subtree). The API maps it to reserved_domain.
var ErrSendInProgress = errors.New("send already in progress for this message")
ErrSendInProgress is returned by ApproveAndSend (and the underlying ClaimSendAttempt) when a concurrent attempt for the same message is still in-flight at the SES layer. Callers should treat this as transient — the in-flight caller will either commit (the next retry sees status='sent' and replays) or time out (the row goes stale and the next retry takes over).
var ErrWebhookCooldown = errors.New("webhook was auto-disabled within the last 5 minutes; wait before re-enabling")
ErrWebhookCooldown is returned when a PATCH would re-enable a webhook that was auto-disabled within the last 5 minutes. Slice 4 adds the auto-disable worker; this error type lands now so the handler doesn't need to map magic strings later.
var TrashRetention = 30 * 24 * time.Hour
TrashRetention is how long a soft-deleted resource (agent inbox or message) stays in the trash before the janitor purges it permanently — the Gmail-style 30-day window (docs/design/trash-soft-delete.md). A var (not a const): cmd/e2a/main.go assigns it at startup from the deployment config (trash.retention_days / E2A_TRASH_RETENTION_DAYS, validated ≥1 day), and tests may tune it directly. Default 30 days — the number the stable API contract documents ("30 days by default, deployment-configurable"). Every consumer reads it at query time, so the startup assignment (before any janitor/worker starts) governs the whole process.
Functions ¶
func ContentScanEnabled ¶
func ContentScanEnabled() bool
ContentScanEnabled reports whether the piguard content scan is turned on for this deployment. The detector ships as a single heuristic lexicon that is near-zero-false-positive but paraphrase-evadable, so it is gated OFF by default for GA and only runs where an operator has explicitly opted in via E2A_CONTENT_SCAN_ENABLED=true. When off: the two screening paths skip the scan entirely (the recipient/sender gate, HITL review holds, suppression and allowlists are unaffected), and the protection API clamps scan_sensitivity to "off" so a caller never sets a knob that silently does nothing. Read at call time so tests (and a live config flip) take effect without a restart.
func DeterministicProtectionEventID ¶
DeterministicProtectionEventID derives a stable id from the dedupe key (message + source + reason + detector) so that re-screening the same message — e.g. an MTA-retried inbound delivery — inserts the SAME row via ON CONFLICT DO NOTHING instead of a duplicate. Mirrors webhookpub.DeterministicEventID.
func NewConversationID ¶
func NewConversationID() string
NewConversationID returns a fresh conversation (thread) ID. An outbound send that omits a conversation_id gets one minted here so the message becomes a thread anchor: external replies reference this message's Message-ID, and the relay's In-Reply-To lookup recovers the conversation_id from it. Without an anchor the lookup finds an empty id and the thread fragments (#328).
func NewInboundIntakeID ¶
func NewInboundIntakeID() string
NewInboundIntakeID mints an intake row id.
func NewMessageID ¶
func NewMessageID() string
NewMessageID returns a fresh internal message ID. Callers can use this to generate the ID up-front when they need it before storing — for example, the SMTP relay generates the ID before signing auth headers so the ID is part of the canonical string fed to HMAC.
func NewProtectionEventID ¶
func NewProtectionEventID() string
NewProtectionEventID returns a fresh random screening-event id.
func NormalizeEmail ¶
NormalizeEmail returns the canonical lookup form of an email address: lower-cased, with surrounding whitespace stripped. Every external-input email used as a lookup key (path vars, form fields, OAuth consent choices, WebSocket subscriptions) must funnel through this so case variants ("Alice@x.com" vs "alice@x.com") resolve to the same row.
Per RFC 5321 §2.4 the local-part is technically case-sensitive — a small number of providers (most famously ProtonMail) preserve case. We collapse it anyway because consistency across HTTP path, JSON body, SMTP envelope, and dashboard URL is more important than spec purity for the agent-inbox use case. Document this if a user complains.
func NormalizeMailboxAddress ¶
NormalizeMailboxAddress returns the canonical addr-spec from an RFC 5322 mailbox value. Suppression checks receive both bare addresses and display- name forms from outbound request shapes, but storage keys contain only the addr-spec. Invalid values fall back to NormalizeEmail so callers that have their own validation keep the historical lookup behavior.
func RunMigrations ¶
func RunMigrations(ctx context.Context, pool *pgxpool.Pool, migrationsFS fs.FS, mode MigrationMode) error
RunMigrations applies every embedded migration that isn't yet recorded in the schema_migrations table. Migrations run in filename-sorted order, each in its own transaction (unless tagged with the "e2a:no-transaction" directive); on the first error the function returns without applying later migrations.
A Postgres session advisory lock serializes concurrent invocations from rolling restarts and multi-instance deploys. The lock is held for the duration of the function and auto-released on session disconnect (so a crashed binary doesn't leave the DB stuck).
All migrations should be written idempotent (CREATE/ALTER ... IF NOT EXISTS) so even-without-the-lock re-runs are harmless. The tracker is the source of truth for "should we attempt to run this one again"; idempotence + the lock are layered safety nets.
func ValidScope ¶
ValidScope reports whether s is a known credential scope.
func ValidateAgentName ¶
ValidateAgentName checks the display-name bound. The length is counted in Unicode code points (runes), NOT bytes, to match the OpenAPI maxLength semantics of the /v1 request schemas (JSON Schema counts code points).
func ValidateHITLConfig ¶
ValidateHITLConfig returns an error if the TTL or expiration action is invalid. The DB CHECK constraints are the final guard; this mirrors them for a clean, pre-query error path.
func ValidateProtectionConfig ¶
func ValidateProtectionConfig(c ProtectionConfig) error
ValidateProtectionConfig checks the effective posture before any write, so a caller gets a clean error rather than a raw CHECK-constraint violation. Levels are valid-by-construction (they map to a valid threshold ladder), so no threshold-ladder check is needed here.
func ValidateScanConfig ¶
func ValidateScanConfig(c ScanConfig) error
ValidateScanConfig mirrors the migration-038 CHECK constraints so callers get a clean pre-query error rather than a raw constraint violation.
Types ¶
type APIKey ¶
type APIKey struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Name string `json:"name"`
KeyPrefix string `json:"key_prefix"`
PlaintextKey string `json:"key,omitempty"` // only set once at creation, never stored
CreatedAt time.Time `json:"created_at"`
// Scope is the credential's blast radius (ScopeAccount | ScopeAgent).
// Backfilled to ScopeAccount for pre-Slice-5a keys.
Scope string `json:"scope"`
// AgentID is the bound agent for ScopeAgent keys; nil for account keys.
AgentID *string `json:"agent_id,omitempty"`
// LastUsedAt is updated by GetUserByAPIKey on every successful
// AuthenticateRequest. NULL on keys that have never been used.
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
// ExpiresAt is the optional hard expiry. AuthenticateRequest rejects
// keys whose expires_at has passed. NULL means "never expires"
// (the backward-compatible default).
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
type APIKeyExportEntry ¶
type APIKeyExportEntry struct {
ID string `json:"id"`
Name string `json:"name"`
KeyPrefix string `json:"key_prefix"`
CreatedAt time.Time `json:"created_at"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
} // @name APIKeyExportEntry
APIKeyExportEntry is the API-key shape included in the export. We expose only metadata; the hash itself stays internal because (a) it's not useful to the user, (b) it's a credential equivalent for offline dictionary attacks if leaked.
type AcceptedSend ¶
type AcceptedSend struct {
To, CC, BCC []string
Subject string
Method string
EnvelopeFrom string
SentAs string
Raw []byte
}
AcceptedSend carries the composed, ready-to-submit values for an approved HITL message being handed to the async outbound queue (QueueOutbound) instead of being sent inline. Populated by the caller from outbound.ComposeResult.
type AgentIdentity ¶
type AgentIdentity struct {
// ID is the agent's full email address and its identifier (id == email;
// EmailAddress() returns it). It is never serialized: every API surface
// keys an agent on `email`, and the #436 rename dropped the redundant
// `id` from the public contract. Kept as a field for internal use only.
ID string `json:"-"`
Domain string `json:"domain"`
RegisteredDomain string `json:"registered_domain"`
Email string `json:"email"`
Name string `json:"name"`
DomainVerified bool `json:"domain_verified"`
Public bool `json:"public"`
CreatedAt time.Time `json:"created_at"`
UserID string `json:"user_id"`
// HITL review-queue mechanism. The producer policies hitl_enabled/hitl_mode
// were retired (Slice 5b/5c, columns dropped in migration 043) — outbound_policy
// + outbound_scan own holds now. These two knobs govern how the review queue
// behaves (TTL + expiry action) for both directions.
HITLTTLSeconds int `json:"ttl_seconds"`
HITLExpirationAction string `json:"on_expiry"`
SuppressNotifications bool `json:"suppress_notifications"`
// Dashboard enrichment fields. Computed at read
// time by ListAgentsByUser via correlated subqueries — other load
// paths (GetAgentByID / GetAgentByEmail) leave them at zero values,
// same pattern as Domain.AgentCount. Switch to denormalized columns
// if the read cost ever bites.
Inbound7d int `json:"inbound_7d"`
Outbound7d int `json:"outbound_7d"`
PendingCount int `json:"pending_count"`
LastDeliveryAt *time.Time `json:"last_delivery_at,omitempty"`
// WebhookStatus summarizes the webhook posture serving this agent,
// derived from the /v1/webhooks subscriber resource: which of the
// account's webhooks match this agent (an empty agent filter matches
// every agent) and how their recent deliveries have fared. One of the
// WebhookStatus* constants below; open set. Computed only on enriched
// read paths (ListAgentsByUser, the account export) — other load paths
// leave it empty, and omitempty keeps the un-computed zero value off
// the wire. Replaces the pre-GA webhook_healthy bool, which could not
// distinguish "no webhook configured" from "healthy".
WebhookStatus string `` /* 804-byte string literal not displayed */
// DeletedAt is non-nil while the agent is in the trash (soft-deleted,
// migration 063): hidden from every live lookup, restorable until the
// janitor purges it after TrashRetention. Populated only by the
// any-state / trash load paths; live lookups filter it out entirely.
DeletedAt *time.Time `json:"deleted_at,omitempty"`
// InboundPolicy is the per-agent inbound ingestion gate (migration 033 /
// Slice 7): one of inboundpolicy.{Open,Allowlist,Domain}.
// Defaults to 'open' (the column default). InboundAllowlist holds the
// exact addresses (allowlist policy) or domains (domain policy) the gate
// trusts; empty for open.
InboundPolicy string `json:"inbound_policy"`
InboundAllowlist []string `json:"inbound_allowlist,omitempty"`
// Screening config (migration 038 / Slice 3). The producer-policy actions
// decide what a gate/scan violation does (flag|review|block); outbound_policy +
// outbound_allowlist are the egress recipient gate (open|allowlist|domain);
// inbound_scan/outbound_scan toggle the content scan with a review/block
// threshold ladder. See docs/design/2026-06-20-agent-screening-hitl.md §4.1.
InboundPolicyAction string `json:"inbound_policy_action"`
OutboundPolicy string `json:"outbound_policy"`
OutboundAllowlist []string `json:"outbound_allowlist,omitempty"`
OutboundPolicyAction string `json:"outbound_policy_action"`
InboundScan string `json:"inbound_scan"`
InboundScanReviewThreshold float64 `json:"inbound_scan_review_threshold"`
InboundScanBlockThreshold float64 `json:"inbound_scan_block_threshold"`
OutboundScan string `json:"outbound_scan"`
OutboundScanReviewThreshold float64 `json:"outbound_scan_review_threshold"`
OutboundScanBlockThreshold float64 `json:"outbound_scan_block_threshold"`
// Scan sensitivity (migration 045) is the protection API's content-scan knob
// (off|low|medium|high). It is the read-back source of truth; the float
// thresholds above are derived from it on write and are what the piguard
// engine consumes. See docs/design/2026-06-22-agent-protection-config.md.
InboundScanSensitivity string `json:"inbound_scan_sensitivity"`
OutboundScanSensitivity string `json:"outbound_scan_sensitivity"`
// AssertionVersion is the auth.md kill-switch counter (migration 035 /
// Slice 5b-2): stamped into minted identity_assertion/access_token JWTs and
// re-checked at the token endpoint; a bump invalidates prior tokens.
AssertionVersion int `json:"-"`
}
func (*AgentIdentity) ActualDomain ¶
func (a *AgentIdentity) ActualDomain() string
ActualDomain returns the exact domain present in the agent's email address. It may differ from RegisteredDomain for an inherited subdomain agent.
func (*AgentIdentity) EmailAddress ¶
func (a *AgentIdentity) EmailAddress() string
EmailAddress returns the agent's email address (always the ID).
func (*AgentIdentity) IsSharedDomain ¶
func (a *AgentIdentity) IsSharedDomain(sharedDomain string) bool
IsSharedDomain returns true if the agent's domain matches the configured shared domain (the host that backs slug-based registration). When sharedDomain is empty, the deployment has slug registration disabled and no agent can be on the shared domain.
func (*AgentIdentity) RegisteredDomainName ¶
func (a *AgentIdentity) RegisteredDomainName() string
RegisteredDomainName returns the explicitly registered domain identity that authorizes this agent. It is the DNS/DKIM/sending-state and lifecycle parent. The fallback preserves callers that construct exact-domain identities in memory without populating RegisteredDomain.
type AgentSuppression ¶
type AgentSuppression struct {
AgentEmail string `json:"agent_email"`
Address string `json:"address"`
Reason string `json:"reason,omitempty"`
Source string `json:"source"`
CreatedAt time.Time `json:"created_at"`
}
AgentSuppression is one recipient block scoped to one sending agent.
type AgentSuppressionHookScope ¶
AgentSuppressionHookScope carries the complete tenant and consent routing key for a newly inserted agent suppression. Event hooks must use UserID directly rather than trying to infer account ownership from an agent address.
type AgentSuppressionTxHook ¶
AgentSuppressionTxHook runs in the insertion transaction after a new row is created and before commit. It is not called for an existing row.
type ConversationDetail ¶
type ConversationDetail struct {
ConversationSummary
Participants []string `json:"participants"`
Labels []string `json:"labels"`
Messages []Message `json:"messages"`
}
ConversationDetail extends the summary with member messages and computed aggregates (participants set, label union). Messages are returned chronologically (oldest first) — the rendering convention for a thread view.
type ConversationListFilter ¶
type ConversationListFilter struct {
AgentID string
Limit int
// Since / Until bracket the conversation's last_message_at —
// "show me conversations that had activity in this window".
// Zero values disable each bound.
Since time.Time
Until time.Time
// After* is the keyset cursor position (CV-3): the previous page's last
// row's (last_message_at, conversation_id). Zero AfterLastMessageAt = first
// page. Pass Limit+1 to detect a further page.
AfterLastMessageAt time.Time
AfterConversationID string
}
ConversationListFilter is the input to ListConversationsByAgent. Limit is capped to ConversationListHardCap at the storage layer regardless of what the caller passes; pagination is intentionally not in this slice (most agents have dozens of conversations, not thousands) and can be added cursor-style if a deployment needs it.
type ConversationSummary ¶
type ConversationSummary struct {
ID string `json:"conversation_id"`
LastMessageAt time.Time `json:"last_message_at"`
FirstMessageAt time.Time `json:"first_message_at"`
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_sender"`
}
ConversationSummary is one row in the list endpoint. Aggregated counts + the "latest message" preview fields are enough to render an inbox-style conversation list without a per-row drill-down.
HasUnread is true iff at least one INBOUND member is in inbox_status='unread'. Outbound pending_review doesn't count — the conversation list is the agent's mailbox view, not the reviewer's HITL queue.
type DKIMCipher ¶
type DKIMCipher struct {
// contains filtered or unexported fields
}
DKIMCipher envelope-encrypts DKIM private keys at rest. Construct one with NewDKIMCipher and install it on the Store via SetDKIMCipher.
func NewDKIMCipher ¶
func NewDKIMCipher(master []byte) (*DKIMCipher, error)
NewDKIMCipher derives a dedicated 32-byte KEK from the master secret via HKDF-SHA256 (mirroring oauth.deriveOAuthSigningKey: a per-purpose label so the DKIM key can rotate independently of header/OAuth/HITL signing) and returns an AES-256-GCM cipher. It fails closed when the master is shorter than 32 bytes — the config layer only enforces that in production, so a weak dev secret must not silently produce weak at-rest encryption.
type DashboardPendingStats ¶
type DashboardStats ¶
type DashboardStats struct {
Today DashboardTodayStats `json:"today"`
Pending DashboardPendingStats `json:"pending"`
DeliverySuccessPct float64 `json:"delivery_success_pct"`
SampleWindowDays int `json:"sample_window_days"`
// InboundWindow / OutboundWindow are the totals over the same
// SampleWindowDays as DeliverySuccessPct. The dashboard at-a-glance
// strip uses Today.*; the settings page uses these window totals
// at a 30-day window (?window=30). Sum over usage_summaries rows
// in the lookback period.
InboundWindow int `json:"inbound_window"`
OutboundWindow int `json:"outbound_window"`
}
DashboardStats is the workspace-level summary returned by GetDashboardStats. Each section corresponds to one of the cards on the redesigned dashboard's stats strip; null/zero values render as "—" in the UI, so deployments without E2A_USAGE_TRACKING enabled degrade gracefully.
type DashboardTodayStats ¶
type DeleteUserDataResult ¶
type DeleteUserDataResult struct {
Deleted bool `json:"deleted" doc:"Always true — the account no longer exists. A failed delete is an error envelope, never deleted:false."`
UsageEventsDeleted int64 `json:"usage_events_deleted"`
UsageSummariesDeleted int64 `json:"usage_summaries_deleted"`
MessagesDeleted int64 `json:"messages_deleted"`
AgentsDeleted int64 `json:"agents_deleted"`
DomainsDeleted int64 `json:"domains_deleted"`
APIKeysDeleted int64 `json:"api_keys_deleted"`
SessionsDeleted int64 `json:"sessions_deleted"`
AgentSuppressionsDeleted int64 `json:"agent_suppressions_deleted"`
AgentUnsubscribeTokensDeleted int64 `json:"agent_unsubscribe_tokens_deleted"`
OAuthAuthCodesDeleted int64 `json:"oauth_auth_codes_deleted,omitempty"`
OAuthAccessTokensDeleted int64 `json:"oauth_access_tokens_deleted,omitempty"`
OAuthRefreshTokensDeleted int64 `json:"oauth_refresh_tokens_deleted,omitempty"`
UserDeleted bool `json:"user_deleted"`
} // @name DeleteUserDataResult
DeleteUserDataResult breaks out per-table row counts for audit logs. Operators receiving a deletion request often have to attest to what was removed; returning structured counts beats parsing a log line.
Deleted is the uniform delete-object marker (every /v1 delete returns 200 + {deleted:true, ...}); the /v1 handler sets it. It is always true on a response — a failed delete is an error envelope, never deleted:false.
type Domain ¶
type Domain struct {
Domain string `json:"domain"`
UserID *string `json:"user_id,omitempty"`
Verified bool `json:"verified"`
VerificationToken string `json:"verification_token"`
CreatedAt time.Time `json:"created_at"`
VerifiedAt *time.Time `json:"verified_at,omitempty"`
// IsPrimary marks the user's default domain. At most one TRUE per
// user (enforced by a partial unique index in migration 013).
IsPrimary bool `json:"primary"`
// LastCheckedAt is updated whenever the verification probe runs,
// successful or not. NULL until the first probe — distinct from
// "probed and failed" which is captured by `verified=false` + a
// non-null LastCheckedAt.
LastCheckedAt *time.Time `json:"last_checked_at,omitempty"`
// AgentCount is computed at read time by ListDomainsByUser and is
// not a persisted column. Single-domain LookupDomain leaves it at
// the zero value — callers that need the count call the list path
// (this column-versus-aggregate split avoids changing every store
// signature to thread an agent-counter through).
AgentCount int `json:"agent_count"`
// DKIM keypair fields. The selector + public key
// are user-facing — the dashboard shows them so users can copy the
// DNS TXT record. The private key is intentionally NOT in the JSON
// shape; it's only read by the outbound signer via
// GetDKIMKey(domain). Domains created before migration 014 ran
// keep all three NULL until the next ClaimOrCreate or backfill.
DKIMSelector string `json:"dkim_selector,omitempty"`
DKIMPublicKey string `json:"dkim_public_key,omitempty"`
// Sender identity (decision 4 / Slice 4). Independent of `Verified`
// (inbound ownership): SendingStatus tracks the async SES sending
// identity that lets outbound use the agent's own address as From.
// SendingStatus ∈ {none,pending,verified,failed}; own-address From is
// used ONLY when "verified" (fail-closed). SendingDNSRecordsJSON is the
// raw JSONB (nil when unset) — the API layer unmarshals it for display.
SendingStatus string `json:"sending_status"`
SendingError string `json:"sending_error,omitempty"`
SendingDNSRecordsJSON []byte `json:"-"`
SendingLastCheckedAt *time.Time `json:"sending_last_checked_at,omitempty"`
// Per-axis SES sending status (migration 049). SES verifies DKIM and the
// custom MAIL FROM independently; these persist that breakdown so the API
// can show each sending DNS record its OWN status instead of the
// all-or-nothing SendingStatus rollup. Empty string ("") when no per-axis
// signal has been recorded (pre-migration / pre-provision / terminal
// failure) — the read path falls back to SendingStatus in that case. ∈
// {"",none,pending,verified,failed}.
//
// json:"-" (like SendingDNSRecordsJSON): these are internal read-model
// fields consumed by httpapi.domainView via Go field access to derive each
// DNSRecord.status. They are deliberately NOT serialized, so they stay out
// of the API/export shape — the fix only makes DNSRecord.status VALUES more
// accurate, it does not add API fields.
SendingDkimStatus string `json:"-"`
SendingMailFromStatus string `json:"-"`
}
Domain represents a verified or unverified domain registered by a user.
type ExpirationCandidate ¶
type ExpirationCandidate struct {
MessageID string
AgentID string
ExpirationAction string // 'approve' or 'reject'
}
ExpirationCandidate is the minimal row the expiration worker needs to decide how to finalize an expired pending message.
type InboundAuth ¶
type InboundAuth struct {
HeaderFrom string
EnvelopeFrom string
Authentication *emailauth.Authentication
// StoredSender preserves internal reply-routing compatibility where it must
// differ from the public RFC 5322 header identity (providerless loopback).
// External SMTP callers leave it empty and persist HeaderFrom as before.
StoredSender string
}
InboundAuth is the canonical authentication evidence captured once during SMTP intake and persisted atomically with the message.
type InboundIntake ¶
type InboundIntake struct {
ID string
Recipient string
EnvelopeFrom string
HELODomain string
RemoteIP string
Raw []byte
MessageID string // sender's RFC 5322 Message-ID
ContentHash string
Status string
CreatedAt time.Time
}
InboundIntake is the worker's view of an accepted inbound message — the raw MIME plus the connection facts (envelope + remote IP) it needs to run SPF/DKIM and screening, which cannot be recomputed outside the SMTP session.
type InboundScreening ¶
type InboundScreening struct {
ReviewReason string
ScanScore *float64
ScanAction string
Status string
ApprovalExpiresAt *time.Time
}
InboundScreening carries the applied screening verdict denormalized onto the inbound message row (migration 037). Zero value = no screening (delivered normally as status 'sent'). When Status is set to a review-hold status (pending_review / review_rejected) the message is persisted but NOT delivered; ApprovalExpiresAt sets the review TTL deadline for the expiry worker.
type LocalDeliveryTxHook ¶
type LocalDeliveryTxHook func(ctx context.Context, tx pgx.Tx, outbound, inbound *Message, result SendResult) error
LocalDeliveryTxHook runs after both sides of a providerless local delivery are visible in tx and before commit. Callers use it for durable outcome events and idempotency completion so none can diverge from the Sent/Inbox pair.
type Message ¶
type Message struct {
ID string `json:"id"`
AgentID string `json:"agent_email"`
Direction string `json:"direction"`
Sender string `json:"-"`
// HeaderFrom and EnvelopeFrom are the canonical inbound identities. They
// remain separate from ReplyTo and from the legacy Sender projection.
HeaderFrom string `` /* 139-byte string literal not displayed */
EnvelopeFrom string `` /* 165-byte string literal not displayed */
VerifiedDomain *string `` /* 471-byte string literal not displayed */
Authentication *emailauth.Authentication `` /* 326-byte string literal not displayed */
Recipient string `json:"delivered_to"`
Subject string `json:"subject"`
EmailMessageID string `json:"email_message_id,omitempty"`
ProviderMessageID string `json:"provider_message_id,omitempty"`
Method string `json:"method,omitempty"`
Type string `json:"type,omitempty"`
RawMessage []byte `json:"raw_message,omitempty"`
AuthHeaders map[string]string `json:"-"`
// Auth carries the parsed inbound authentication verdict
// (messages.auth_verdict from migration 032): SPF/DKIM/DMARC each with
// a status and detail. Populated on inbound read paths when the column
// is non-null; nil on outbound rows (which never have a verdict).
Auth *emailauth.AuthVerdict `json:"-"`
ConversationID string `json:"conversation_id,omitempty"`
// DeliveryStatus is overloaded by direction. On inbound rows it carries
// the inbox read/unread status (messages.inbox_status) under this legacy
// JSON key. On outbound rows it carries the outbound delivery rollup
// (messages.delivery_status from migration 031: 'sent', 'delivered',
// 'bounced', …) — the worst recipient status by precedence. A message is
// either inbound or outbound, so the two sources never collide per-row.
DeliveryStatus string `json:"delivery_status,omitempty"`
// DeliveryDetail is the human-readable diagnostic for the outbound
// delivery rollup (e.g. an SES bounce sub-type / SMTP response).
// Outbound-only; empty on inbound rows. Source: messages.delivery_detail.
DeliveryDetail string `json:"delivery_detail,omitempty"`
// SentAs is the From identity actually used when the outbound message was
// accepted by the relay. Outbound-only; empty on inbound rows. Source:
// messages.sent_as.
SentAs string `json:"sent_as,omitempty"`
CreatedAt time.Time `json:"created_at"`
// ExpiresAt is nil for indefinitely retained messages. It remains on the
// model for compatibility with account exports and legacy database rows.
ExpiresAt *time.Time `json:"expires_at" nullable:"true" format:"date-time" doc:"Message expiry. Null means the message is retained indefinitely."`
// DeletedAt is non-nil while the message is in the trash (soft-deleted,
// migration 063): hidden from every agent-facing read path except the
// single-message get (so the trash view can open it), restorable until
// the janitor purges it after TrashRetention. Live data otherwise remains
// indefinitely retained.
DeletedAt *time.Time `json:"deleted_at,omitempty"`
WebhookStatus string `json:"webhook_status,omitempty"`
WebhookError string `json:"webhook_error,omitempty"`
WebhookAttempts int `json:"webhook_attempts,omitempty"`
// SizeBytes is the RAW MIME byte length of the whole stored message —
// the octet length of raw_message (headers + bodies + encoded attachments
// as transported). NOT a decoded-attachment size: the per-attachment
// size_bytes (eventpayload.AttachmentMetaView / httpapi.AttachmentMetaView)
// is the DECODED payload of one attachment. This raw length is also the
// dominant term of storage-quota accounting — the messages storage
// trigger (migrations 016/039) sums octet_length(raw_message) plus the
// held-draft body columns into account_usage.storage_bytes.
// Populated by load paths that compute it (e.g. GetMessagesByAgent for
// the dashboard inbox). Zero on load paths that don't — the inbox
// renders "—" in that case.
SizeBytes int `` /* 243-byte string literal not displayed */
// InboxStatus mirrors messages.inbox_status ('unread' | 'read') for
// inbound rows. Kept separate from DeliveryStatus (which currently
// carries the same value under a confusing JSON key — see line 161)
// so the dashboard's inbox can read it under a non-overloaded key.
// Empty on outbound rows. Populated by GetMessagesByAgent.
InboxStatus string `json:"read_status,omitempty"`
// Multi-recipient fields. For outbound, these are the addressed
// To/Cc/Bcc recipients of the send. For inbound, ToRecipients and CC
// are the parsed To: and Cc: headers of the original message (the
// per-delivery target for this row is in Recipient). BCC is
// outbound-only.
ToRecipients []string `json:"to,omitempty"`
CC []string `json:"cc,omitempty"`
BCC []string `json:"bcc,omitempty"`
// ReplyTo is the parsed Reply-To: header on inbound messages — empty
// when the header was absent. Distinct from Sender so consumers can
// recover the original From: of forwarded / notification mail whose
// Reply-To points at a different mailbox. Outbound-irrelevant.
ReplyTo []string `json:"reply_to,omitempty"`
// Labels are user-applied string tags (`urgent`, `follow-up`, …).
// Always lowercase, charset `[a-z0-9:_-]+`, ≤ 64 chars per label,
// capped at 100 per message. Empty slice means no labels — the DB
// default is `'{}'` so this is never null on read. Labels with the
// `e2a:` prefix are reserved for server-applied system labels;
// caller writes that try to set them are rejected at the API layer.
Labels []string `json:"labels,omitempty"`
// HITL approval fields. Body and attachments are retained through terminal
// transitions so outbound history remains complete.
Status string `json:"status,omitempty"`
ApprovalExpiresAt *time.Time `json:"approval_expires_at,omitempty"`
ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
// ReviewedByUserID identifies the human reviewer who approved or
// rejected this message. NULL on worker-triggered transitions
// (TTL auto-approve / auto-reject) — operator-visible signal "no
// human looked at this." Set by ApproveAndSend and RejectPending,
// left null by ExpireApproveAndSend / ExpireReject.
ReviewedByUserID *string `json:"reviewed_by_user_id,omitempty"`
// ReviewedByName is the JOIN'd display name from the reviewer's
// users row, populated only by GetOutboundMessageForUser. List
// endpoints leave this empty to avoid a join-per-row cost — the
// pending-detail page is where reviewer attribution matters.
ReviewedByName *string `json:"reviewed_by_name,omitempty"`
RejectionReason string `json:"rejection_reason,omitempty"`
Edited bool `json:"edited,omitempty"`
BodyText string `json:"text,omitempty"`
BodyHTML string `json:"html,omitempty"`
// AttachmentsJSON is the INTERNAL storage blob for a held draft's
// attachments (messages.attachments_json): the []outbound.Attachment
// shape {filename, content_type, data} with data as base64 bytes. It
// is populated for reviewed outbound drafts and retained after terminal
// transitions. It is what the approve path recomposes the send from.
// Never serialized — the wire representation is Attachments below.
AttachmentsJSON json.RawMessage `json:"-"`
ManagedUnsubscribe bool `json:"-"`
// Attachments is the typed per-attachment METADATA for the wire (the
// user-data export's Message schema) — the same AttachmentMetaView shape
// {filename, content_type, size_bytes (DECODED), index} the live API
// (MessageView.attachments, email.received) uses. Populated at export
// time: parsed from raw_message when present (inbound + sent outbound),
// else mapped from the held-draft AttachmentsJSON blob. Bytes are never
// inlined — for sent/inbound messages they are inside the exported
// raw_message.
Attachments []eventpayload.AttachmentMetaView `json:"attachments,omitempty"`
// Flagged + FlagReason carry the inbound ingestion verdict (migration 033 /
// Slice 7): true when the agent's inbound_policy gate flagged this message
// on arrival (still delivered, never dropped). FlagReason is the
// human-readable reason. Inbound-relevant; outbound rows read false/”.
Flagged bool `json:"flagged,omitempty"`
FlagReason string `json:"flag_reason,omitempty"`
// ReviewReason / ScanScore / ScanAction carry the applied screening verdict
// (migration 037 / Slice 2), denormalized onto the row for fast review-queue
// rendering. ReviewReason is one of sender_gate|recipient_gate|inbound_scan|
// outbound_scan|outbound_send; ScanAction is the applied action
// (flag|review|block); ScanScore is the aggregate 0..1 score (nil for gate-only
// holds). The full per-detector breakdown lives in protection_events.
ReviewReason string `json:"review_reason,omitempty"`
ScanScore *float64 `json:"scan_score,omitempty"`
ScanAction string `json:"scan_action,omitempty"`
}
func (Message) MarshalJSON ¶
MarshalJSON preserves the database-friendly string representation while exposing unavailable inbound identity fields as JSON null. These fields are required-but-nullable in the public export contract.
func (*Message) ThreadMessageID ¶
ThreadMessageID returns the RFC 5322 Message-ID to anchor a reply's In-Reply-To / References on. An inbound message carries the sender's Message-ID in email_message_id; an outbound message the agent sent has no email_message_id (the composer omits Message-ID — see internal/outbound/compose.go) and instead carries the relay/SES-assigned Message-ID, angle-bracketed, in provider_message_id. Threading off the wrong field forks the recipient's mail thread, so callers replying to their own outbound must use this rather than EmailMessageID directly.
type MessageListFilter ¶
type MessageListFilter struct {
AgentID string
Status string // "unread" | "read" | "all"
Direction string // "inbound" | "outbound" | "all"
Descending bool
Limit int
AfterTime time.Time
AfterID string
// Optional search filters. Empty / zero means "no constraint".
// From / SubjectContains are case-insensitive substring matches
// (Postgres ILIKE) and bound to 200 chars at the handler layer.
From string
SubjectContains string
ConversationID string // exact match
Since time.Time // created_at >= Since
Until time.Time // created_at < Until
// Labels filters rows where ALL given labels are present on the
// message (AND-match via Postgres @> array containment). Empty slice
// means "no label constraint" — matches both labelled and unlabelled
// rows. Handler-layer validates each entry against the same charset
// rule used on writes so callers can't smuggle SQL through here.
Labels []string
// Deleted flips the query to the TRASH view: only soft-deleted rows.
// False (default) lists indefinitely retained live rows only.
Deleted bool
}
MessageListFilter bundles the params for GetMessagesByAgent. Zero values on the optional substring / time / ID filters mean "no constraint" — callers omit what they don't want to filter on.
type MigrationMode ¶
type MigrationMode string
MigrationMode controls how RunMigrations handles pending migrations.
Set via E2A_MIGRATION_MODE env var. Default is ModeAuto.
- ModeAuto: apply pending migrations in order. Fail (return error) if any apply errors. This is what the hosted deployment uses.
- ModeVerify: do not apply anything. Return an error listing pending migrations. For cautious operators who want a separate manual migration step before binary rollout.
- ModeSkip: do not apply or check. Log and proceed. For emergency surgery — deploy a binary that won't touch schema.
const ( ModeAuto MigrationMode = "auto" ModeVerify MigrationMode = "verify" ModeSkip MigrationMode = "skip" )
func ParseMigrationMode ¶
func ParseMigrationMode(s string) (MigrationMode, error)
ParseMigrationMode reads a string (typically from env) and returns the matching mode, defaulting to ModeAuto on empty input. Returns an error for unknown values so a typo is loud, not silent.
type OAuthConnectionEntry ¶
type OAuthConnectionEntry struct {
ClientID string `json:"client_id"`
ClientName string `json:"client_name"`
AgentEmail string `json:"agent_email"`
Scope string `json:"scope"`
IssuedAt time.Time `json:"issued_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
} // @name OAuthConnectionEntry
OAuthConnectionEntry is one OAuth/MCP client connection. The underlying token signatures are intentionally excluded — they are credential-equivalent. The agent_email is the per-grant binding captured at consent time.
type OutboundSendPayload ¶
type OutboundSendPayload struct {
ID string
// UserID is the owning account (agent_identities.user_id) — the tenant
// scope for the worker's pre-provider suppression guard.
UserID string
AgentID string
Domain string
MessageType string
DeliveryStatus string
EnvelopeFrom string
SentAs string
Recipients []string
Raw []byte
CreatedAt time.Time // accept time — the outage-tail retry-horizon clock
// ProviderAccepted is true when the SNS consumer recorded provider-accept
// evidence for this message (provider_accepted_at): the provider already
// has it, so the worker must settle it as sent instead of re-submitting
// (the SMTP-accept↔mark-sent crash window's duplicate residual).
ProviderAccepted bool
// ProviderMessageID is the evidence-repaired provider id (” when none).
ProviderMessageID string
}
OutboundSendPayload is the async worker's view of an accepted outbound message (the LoadOutboundForSend result). Recipients is the SMTP envelope (to+cc+bcc); Raw is the persisted Sent-folder bytes the worker submits via Sender.SubmitOnce.
type OutboundSentInfo ¶
OutboundSentInfo carries the fields the async worker's MarkSent/MarkFailed adapters need to build the email.sent / email.failed event and meter usage, resolved from the row + its owning agent in one transaction.
type PendingApprovalEdit ¶
type PendingApprovalEdit struct {
Subject *string
BodyText *string
BodyHTML *string
To []string
CC []string
BCC []string
AttachmentsJSON []byte
// AttachmentsSet must be true when the caller intends to override
// AttachmentsJSON, since nil and empty [] are both valid overrides
// (empty [] clears attachments; nil preserves).
AttachmentsSet bool
}
PendingApprovalEdit holds optional overrides a reviewer can apply when approving a pending message. Pointer-typed strings distinguish "not provided" (nil) from "explicitly empty" (pointer to ""). Slice fields distinguish "unset" (nil) from "empty list" (non-nil zero-length slice).
func (PendingApprovalEdit) Apply ¶
func (e PendingApprovalEdit) Apply(msg *Message) bool
Apply mutates msg to reflect any fields the reviewer changed. Returns true if any field was actually different from what msg already held (signals the edited flag should be set).
type PendingNotify ¶
type PendingNotify struct {
Message *Message
Agent *AgentIdentity
Notified bool
}
PendingNotify is what the HITL approval-notification worker re-reads for one message: the held message (the fields the notifier composes from), its owning agent, and whether it was already notified.
type Principal ¶
type Principal struct {
User *User
Scope string // ScopeAccount | ScopeAgent
AgentID string // non-empty only when Scope == ScopeAgent
}
Principal is the authenticated caller resolved from a credential: the owning user plus the credential's scope and (for agent-scoped credentials) the agent it is bound to. The scope/agent binding is what the v1 handlers enforce the hard scope ceiling against (design §5 / decision 10).
type ProtectionConfig ¶
type ProtectionConfig struct {
InboundGatePolicy string
InboundAllowlist []string
InboundGateAction string
InboundScanSensitivity string
OutboundGatePolicy string
OutboundAllowlist []string
OutboundGateAction string
OutboundScanSensitivity string
HITLTTLSeconds int
HITLExpirationAction string
SuppressNotifications bool
}
ProtectionConfig is the full per-agent protection posture as the public /protection resource models it: a gate (trust policy + allowlist + action) and a content-scan sensitivity per direction, plus the shared hold-queue mechanism. Concrete values (the PUT body is a full replace), so UpdateAgentProtection validates and writes the effective posture atomically.
type ProtectionEvent ¶
type ProtectionEvent struct {
ID string `json:"id"`
MessageID string `json:"message_id"`
AgentID string `json:"agent_id"`
Direction string `json:"direction"` // inbound | outbound
Source string `json:"source"` // gate | scan
Reason string `json:"reason"` // sender_gate | recipient_gate | inbound_scan | outbound_scan | outbound_send
Action string `json:"action"` // flag | review | block
SubjectAddr string `json:"subject_addr,omitempty"`
Detector string `json:"detector,omitempty"`
Score *float64 `json:"score,omitempty"`
Categories json.RawMessage `json:"categories,omitempty"`
Spans json.RawMessage `json:"spans,omitempty"`
Raw json.RawMessage `json:"raw,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
ProtectionEvent is one row of the durable, append-only screening audit log (created in migration 040, renamed from screening_events in 046). It records a single producer's verdict on a message — a gate violation (source=gate; the scan-only columns are nil) or a scan detection (source=scan; detector/score/categories/spans/raw populated). message_id is a soft reference: the trail outlives the message's 30-day TTL.
type ProtectionEventExportEntry ¶
type ProtectionEventExportEntry struct {
ID string `json:"id"`
MessageID string `json:"message_id"`
AgentID string `json:"agent_email"`
Direction string `json:"direction"`
Source string `json:"source"`
Reason string `json:"reason"`
Action string `json:"action"`
SubjectAddr string `json:"peer_address,omitempty"`
Detector string `json:"detector,omitempty"`
Score *float64 `json:"scan_score,omitempty"`
CreatedAt time.Time `json:"created_at"`
} // @name ProtectionEventExportEntry
ProtectionEventExportEntry is one row of the protection_events audit log for the user's agents — a metadata projection. The provider-forensics `raw` blob and the `spans`/`categories` detector internals are omitted (internal, noisy, and not user-meaningful); the disposition (source/reason/action) and the counterparty address that tripped a gate are included.
type ReviewListItem ¶
type ReviewListItem struct {
ID string
AgentID string
Direction string // inbound | outbound
Sender string
HeaderFrom string
EnvelopeFrom string
Authentication *emailauth.Authentication
To []string
Subject string
ConversationID string
Status string // review lifecycle (pending_review)
CreatedAt time.Time
Flagged bool
FlagReason string
// ReviewReason is the coded screening verdict that held this message
// (sender_gate|recipient_gate|inbound_scan|outbound_scan|outbound_send).
// Unlike FlagReason (inbound ingestion-gate only) it is populated for every
// hold path — both directions, gate and scan — so it is the reason a reviewer
// should see. See identity.Message.ReviewReason (migration 037/040).
ReviewReason string
}
ReviewListItem is one row of the human review queue (GET /v1/reviews): non-secret summary of a held message of either direction. Bodies are fetched per-item via GetReviewWithContent.
type ReviewMessageMeta ¶
type ReviewMessageMeta struct {
ID string
AgentID string
Direction string // inbound | outbound
Status string
Sender string
Recipient string
Subject string
Type string
}
ReviewMessageMeta is the minimal dispatch view of a held message returned by GetReviewMessage: enough to branch the /approve+/reject endpoints on direction and to populate the resolution webhook (review_approved / review_rejected).
type ScanConfig ¶
type ScanConfig struct {
InboundPolicyAction string
OutboundPolicy string
OutboundAllowlist []string
OutboundPolicyAction string
InboundScan string
InboundScanReviewThreshold float64
InboundScanBlockThreshold float64
OutboundScan string
OutboundScanReviewThreshold float64
OutboundScanBlockThreshold float64
}
ScanConfig is the full per-agent content-screening config. All fields are concrete (already merged over the current values by the caller, per the additive-PATCH model) so UpdateAgentScanConfig can validate the EFFECTIVE posture and write it atomically.
type SendAttemptOutcome ¶
type SendAttemptOutcome int
SendAttemptOutcome describes the result of trying to reserve a (message_id) slot for an upstream SES send.
const ( // SendAttemptAcquired — caller now owns the slot and must follow // up with MarkSendSucceeded or MarkSendFailed. SendAttemptAcquired SendAttemptOutcome = iota // SendAttemptAlreadySent — a prior attempt for this message // already succeeded at SES; SendResult is populated with the // recorded provider id and recipient lists. Callers must NOT // re-invoke the upstream send. SendAttemptAlreadySent // SendAttemptInFlight — a concurrent caller holds the slot and // the row is not stale. Callers should surface ErrSendInProgress. SendAttemptInFlight )
type SendAttemptResult ¶
type SendAttemptResult struct {
Outcome SendAttemptOutcome
Sent SendResult
}
SendAttemptResult bundles the outcome with the cached SendResult when the outcome is SendAttemptAlreadySent.
type SendOutcome ¶
type SendOutcome struct {
DeliveryStatus string
ProviderMessageID string
SentAs string
DeliveryDetail string
}
SendOutcome is the current terminal-ish state of an async send, for wait=sent polling. DeliveryStatus is "" when the row is gone.
type SendResult ¶
type SendResult struct {
ProviderMessageID string
Method string
To []string
CC []string
BCC []string
// Sender is the inbound-facing From value for providerless local delivery.
// It prefers an explicit Reply-To while authenticated identity remains the
// owning agent address in the event envelope.
Sender string
// Raw is the composed MIME that was sent, retained as the message's
// "Sent folder" copy (raw_message). Local loopback retains the same bytes on
// both Sent and Inbox rows; it is empty only on already-sent replay.
Raw []byte
}
SendResult carries the outcome of a sender.Send invocation back to the store for final persistence. Handlers wrap their sender.Send call in a closure that returns this type.
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
func (*Store) AddAgentSuppression ¶
func (s *Store) AddAgentSuppression(ctx context.Context, userID, agentID, address, reason, source string, onAdded AgentSuppressionTxHook) (sp AgentSuppression, added bool, err error)
AddAgentSuppression idempotently adds an agent-scoped recipient block.
func (*Store) AddAgentSuppressionFromTokenScope ¶
func (s *Store) AddAgentSuppressionFromTokenScope(ctx context.Context, scope UnsubscribeScope, onAdded AgentSuppressionTxHook) (AgentSuppression, bool, error)
AddAgentSuppressionFromTokenScope records consent from an already resolved bearer-token scope. Unlike AddAgentSuppression, it intentionally does not require a live agent: a token issued while the agent existed remains valid after hard deletion. Callers must obtain scope from ResolveUnsubscribeToken; source and reason are fixed so this bypass cannot masquerade as a manual row.
func (*Store) AddSuppression ¶
func (s *Store) AddSuppression(ctx context.Context, userID, address, reason, source, sourceMessageID string) (bool, error)
AddSuppression idempotently inserts a (user, address) suppression. added is false when it already existed, so the caller fires domain.suppression_added at most once per address.
func (*Store) AdoptSharedDomain ¶
AdoptSharedDomain assigns ownership of the server-seeded shared-domain row to userID. The server seeds that row on every boot via EnsureSharedDomain (user_id NULL, verified true, ON CONFLICT DO NOTHING); ClaimOrCreateDomain cannot claim it — it only upserts an unverified, same-user row — yet the probe/system identity that seeds itself lives on the shared domain by design.
The UPDATE is guarded to a VERIFIED, currently-unowned row (or one already owned by userID, so re-seeding is idempotent). Requiring verified=true — the signature EnsureSharedDomain stamps — keeps this method from ever adopting some other ownerless row, so its safety does not rely on the domains schema never producing another NULL-owner row nor on the caller passing only a trusted domain. A row owned by a different account, a nonexistent row, or an ownerless-but-unverified row all match nothing and return ErrDomainTaken. The verified flag and DKIM columns are not modified.
func (*Store) ApproveAndAccept ¶
func (s *Store) ApproveAndAccept( ctx context.Context, messageID, reviewedByUserID, targetStatus string, edited bool, acc AcceptedSend, enqueue func(ctx context.Context, tx pgx.Tx, messageID string) (int64, error), completeIdempotency func(ctx context.Context, tx pgx.Tx, approved *Message) error, ) (*Message, error)
ApproveAndAccept resolves a pending_review outbound hold to an APPROVED, ASYNC-QUEUED state in one transaction, mirroring the API's async accept-tx: it flips status to targetStatus (review_approved for a human approve, review_expired_approved for the TTL sweep) AND delivery_status to 'accepted', persists the composed bytes + envelope, then enqueues the outbound_send job and stamps its id. The existing SendWorker picks the row up by id and performs the actual SMTP submit + email.sent/failed + metering; this method does NOT send and does NOT use the send_attempts gate (async idempotency is the accept-tx atomicity + the worker's delivery_status/alreadyDone guard). reviewedByUserID is "" (→ NULL) for the sweep. When completeIdempotency is non-nil, it runs after the send job is stamped but before commit; an error rolls back the approval, job, and key.
The WHERE status='pending_review' is the compare-and-set guard: RETURNING no row means a human/other worker already resolved the hold → ErrNotPendingApproval (a no-op for the caller). The agent-not-trashed guard sits in the same WHERE so it is atomic with the CAS: a hold whose agent was trashed after the caller's pre-checks (the TTL sweep's candidate SELECT, or a human path's ownership load) must NOT resolve — trashed holds stay pending_review with their clock paused until RestoreAgent shifts approval_expires_at (or the trash purge drops them). Draft body and attachment columns remain retained after approval.
func (*Store) ApproveAndDeliverLocal ¶
func (s *Store) ApproveAndDeliverLocal( ctx context.Context, messageID, userID string, edits PendingApprovalEdit, compose func(msg *Message) (SendResult, error), beforeCommit LocalDeliveryTxHook, ) (*Message, error)
ApproveAndDeliverLocal atomically resolves a pending outbound review hold whose only recipient is a mailbox owned by this service. Unlike ApproveAndSend, compose must be a local, side-effect-free operation: the outbound update, recipient-side insert, events, and idempotency completion all commit or roll back together, so the SES-oriented send_attempts journal is neither needed nor appropriate.
func (*Store) ApproveAndSend ¶
func (s *Store) ApproveAndSend( ctx context.Context, messageID, userID string, edits PendingApprovalEdit, send func(msg *Message) (SendResult, error), ) (*Message, error)
ApproveAndSend finalizes a pending_review message by running it through a caller-supplied send function inside a transaction that holds a row lock on the pending row. If send returns an error the transaction rolls back and the message remains pending. On success the row is updated to 'sent' with the provider-assigned Message-ID while body and attachment columns remain retained.
edits, if any fields are populated, are applied to the in-memory message before send is called and the 'edited' column is set to true when any field differs from what was stored. Approval-via-magic-link callers pass the zero edits value.
Ownership is enforced by the agent -> user join. Messages owned by another user return ErrMessageNotFound. Messages whose status is not 'pending_review' return ErrNotPendingApproval. If another worker is already mid-send for this message (rare; only possible after the approval row lock was released without status changing — e.g. a pool drop mid-send), this returns ErrSendInProgress.
Concurrency / failure mode notes:
The row-level FOR NO KEY UPDATE lock is held on the messages row for the duration of the send callback. In practice that is bounded by outbound.SMTPRelay's per-attempt deadline (2min) plus its internal retry backoff (1s/5s/15s) — worst case ~6.5min of lock on this single row. Other rows are unaffected; deadlock is not possible because only one row is ever locked per call.
Why NO KEY UPDATE rather than the stricter FOR UPDATE: the send_attempts INSERT below runs on a SEPARATE pool connection and needs a KEY SHARE lock on this messages row for FK enforcement. FOR UPDATE blocks KEY SHARE; FOR NO KEY UPDATE allows it. The downgrade is safe because nothing in this codebase mutates messages.id (the only key column) after creation — all UPDATEs touch non-key columns, which NO KEY UPDATE serializes against itself exactly like FOR UPDATE.
The old crash window where send() succeeded at SES but the subsequent UPDATE/Commit failed (DB blip, pool exhaustion) is now closed by the send_attempts table. Around send() we run two small auxiliary transactions that outlive the surrounding approval transaction: ClaimSendAttempt before send(), MarkSendSucceeded (or MarkSendFailed) after. If the approval tx rolls back AFTER send() succeeded, the next retry of ApproveAndSend reads send_attempts.status='sent', reuses the recorded SendResult, and skips the upstream send entirely.
func (*Store) ApproveInboundReview ¶
func (s *Store) ApproveInboundReview(ctx context.Context, messageID, agentID, reviewerID string) error
ApproveInboundReview releases a held inbound message to the agent (status review_approved → visible in the inbox). Scoped to agentID for tenant isolation; reviewerID identifies the human. Held content is retained (the message is now delivered).
func (*Store) AutoDisableFailingWebhooks ¶
AutoDisableFailingWebhooks scans for webhooks whose recent delivery history exceeds the failure threshold and flips them to enabled=false with auto_disabled_at = now(). Returns the count of webhooks newly disabled. Designed to be called periodically (e.g. every 5 minutes) from a janitor goroutine.
"Consecutive failed events" is interpreted as: in the last AutoDisableWindow, at least AutoDisableThreshold rows in webhook_subscriber_deliveries reached status='failed' AND zero rows reached status='delivered'. The zero-delivered guard prevents a noisy webhook that's still mostly working from being disabled.
func (*Store) BootstrapUser ¶
BootstrapUser finds a user by email, or creates one with a synthetic google_subject if none exists. Used by the -bootstrap-email CLI flag for self-host first-run, where there's no Google OAuth flow yet.
func (*Store) ClaimOrCreateDomain ¶
ClaimOrCreateDomain atomically claims a DNS namespace. A row reserves its exact name plus every ancestor/descendant against other accounts; the same account may explicitly register a child. The verification_token and DKIM keypair are minted on first INSERT and remain stable across re-claims — a caller that has already published the TXT record on DNS (or has mail in flight signed with the DKIM key) isn't silently invalidated by a second call. A different user cannot take over an unverified row; that closes a squatting window where the new owner could verify against a TXT record the original owner already published. The managed bounce.<domain> subtree is reserved once an account owns the parent because SES custom MAIL FROM uses that namespace.
func (*Store) ClaimOutboundForSend ¶
func (s *Store) ClaimOutboundForSend(ctx context.Context, messageID string, jobID int64) (*OutboundSendPayload, error)
ClaimOutboundForSend atomically moves the stamped River job's live outbound message into delivery_status='sending' and returns its provider payload. A retry of the same job may reclaim a row already in sending (River does not run two attempts of one job concurrently); a duplicate job id cannot claim it.
The short transaction is the send/trash linearization point. If trash commits first, the claim misses and the queued send is canceled. If this claim commits first, trash may proceed while provider I/O runs, and MarkOutboundSentTx or MarkOutboundFailedTx still records the terminal result on the trashed row.
func (*Store) ClaimSendAttempt ¶
ClaimSendAttempt atomically reserves the send_attempts row for messageID. Concurrency model mirrors internal/idempotency.Claim: one UPSERT with a stale-takeover WHERE clause; the loser does a follow-up SELECT to classify the existing row.
Allowed transitions:
(no row) → acquired (fresh INSERT) status='failed' → acquired (UPSERT path takes over) status='attempting' AND stale → acquired (UPSERT path takes over) status='attempting' AND NOT stale → InFlight (refuse) status='sent' → AlreadySent (reuse recorded result)
Called from ApproveAndSend in a SEPARATE small transaction so the claim row outlives any rollback of the surrounding approval transaction. That's what closes the documented double-send window: once status='sent' is committed here, a retry sees AlreadySent and skips the upstream send even if the messages-row UPDATE inside the approval tx never committed.
func (*Store) ClearExpiredPrevSecrets ¶
ClearExpiredPrevSecrets nulls signing_secret_prev / signing_secret_prev_expires_at on rows past their grace window. Idempotent. The worker already ignores expired prev secrets at signing time; this janitor is a hygiene pass so GET responses don't carry a meaningless prev_expires_at.
func (*Store) CorrelateByE2AMessageID ¶
func (s *Store) CorrelateByE2AMessageID(ctx context.Context, e2aMessageID string) (*delivery.CorrelatedMessage, bool, error)
CorrelateByE2AMessageID finds the outbound message + owning user/agent by the e2a message id SES echoed back from the X-E2A-Message-ID wire header (delivery.MessageIDHeader) — the §3.1 correlation fallback for the SMTP-accept↔mark-sent crash window, where the provider id from the SMTP 250 was never captured so CorrelateBySESMessageID cannot match. Same return shape as CorrelateBySESMessageID. The id is shape-validated by the caller (internal/delivery) before it reaches this lookup.
func (*Store) CorrelateBySESMessageID ¶
func (s *Store) CorrelateBySESMessageID(ctx context.Context, sesMessageID string) (*delivery.CorrelatedMessage, bool, error)
CorrelateBySESMessageID finds the outbound message + owning user by the SES-assigned provider_message_id captured at send time. found=false when the id is unknown (deleted message, or an event for a different deployment).
The message fields (subject, and the envelope/threading fields the message-level email.failed payload needs on an SES Reject) ride along — they're columns on the very row this query already reads, so including them costs no extra query (contract freeze PR-2: `subject` on delivery events).
The SNS notification carries the BARE SES id (e.g. 010f0193…-000000), but the send path stores it angle-bracketed and sometimes with an @region.amazonses.com suffix (parseMessageIDFromResponse) — same discrepancy LookupConversationID works around. Match all three stored shapes against the bare id: exact, <id>, and <id@…>. SES ids are [A-Za-z0-9-] so they carry no LIKE metacharacters.
func (*Store) CountTemplatesByUser ¶
CountTemplatesByUser returns the number of templates the user owns. Used by CreateTemplate to enforce the per-user cap.
func (*Store) CountWebhooksByUser ¶
CountWebhooksByUser returns the total number of webhooks (enabled + disabled) the user owns. Used by CreateWebhook to enforce the per-user cap from account_limits.max_webhooks.
func (*Store) CreateAPIKey ¶
func (s *Store) CreateAPIKey(ctx context.Context, userID, name string, expiresAt *time.Time) (*APIKey, error)
CreateAPIKey issues a fresh ACCOUNT-scoped API key for the user. expiresAt is the optional hard expiration; pass nil to issue a never-expiring key (the backward-compatible default). This is the account-tier convenience wrapper over CreateScopedAPIKey — the self-host default key.
func (*Store) CreateAgent ¶
func (s *Store) CreateAgent(ctx context.Context, agentEmail, domain, name, webhookURL, agentMode, userID string) (*AgentIdentity, error)
CreateAgent inserts an agent with a domain FK. Does NOT check domain ownership — that's the API handler's responsibility (shared domain skips the check).
webhookURL and agentMode are accepted for signature compatibility but are now IGNORED: the legacy per-agent webhook_url + agent_mode columns were dropped (migration 029). Push is delivered solely via the /v1/webhooks subscriber resource and WebSocket is open to all agents. The params are retained to avoid churning the ~80 call-sites that still pass them; the internal-signature cleanup is a separate follow-up.
func (*Store) CreateAgentTx ¶
func (s *Store) CreateAgentTx(ctx context.Context, tx pgx.Tx, agentEmail, domain, name, webhookURL, agentMode, userID string) (*AgentIdentity, error)
CreateAgentTx inserts an agent inside a caller-owned transaction. Used by the OAuth consent flow so the slug auto-create row and the authorization-code insert (in oauth_auth_codes) commit together — without this, a code-issue failure after the agent commit would leave a phantom inbox the user never authorized. webhookURL and agentMode are accepted but IGNORED — see CreateAgent.
func (*Store) CreateInboundMessage ¶
func (s *Store) CreateInboundMessage(ctx context.Context, id, agentID, senderEmail, recipient, emailMessageID, subject, conversationID, deliveryStatus string, rawMessage []byte, authHeaders map[string]string, authVerdict []byte, flagged bool, flagReason string, toRecipients, cc, replyTo []string, screening InboundScreening) (*Message, error)
CreateInboundMessage stores an inbound message. If id is empty a new one is generated; otherwise the caller's pre-generated ID is used so the upstream signer can bind auth headers to the same ID that gets stored. toRecipients and cc are the parsed To: and Cc: headers from the original RFC 2822 message; recipient is the per-delivery target for this row (may be one of the To: addresses, or absent from the header list when the agent was Bcc'd). replyTo is the parsed Reply-To: header (empty when absent — never silently falls back to sender).
func (*Store) CreateInboundMessageAuthenticated ¶
func (s *Store) CreateInboundMessageAuthenticated(ctx context.Context, id, agentID string, auth InboundAuth, recipient, emailMessageID, subject, conversationID, deliveryStatus string, rawMessage []byte, flagged bool, flagReason string, toRecipients, cc, replyTo []string, screening InboundScreening) (*Message, error)
CreateInboundMessageAuthenticated stores the canonical identity and authentication model. The legacy CreateInboundMessage remains temporarily available while older tests and relay call sites migrate.
func (*Store) CreateInboundMessageAuthenticatedInTx ¶
func (s *Store) CreateInboundMessageAuthenticatedInTx(ctx context.Context, tx pgx.Tx, id, agentID string, auth InboundAuth, recipient, emailMessageID, subject, conversationID, deliveryStatus string, rawMessage []byte, flagged bool, flagReason string, toRecipients, cc, replyTo []string, screening InboundScreening) (*Message, error)
func (*Store) CreateInboundMessageInTx ¶
func (s *Store) CreateInboundMessageInTx(ctx context.Context, tx pgx.Tx, id, agentID, senderEmail, recipient, emailMessageID, subject, conversationID, deliveryStatus string, rawMessage []byte, authHeaders map[string]string, authVerdict []byte, flagged bool, flagReason string, toRecipients, cc, replyTo []string, screening InboundScreening) (*Message, error)
CreateInboundMessageInTx writes the messages row inside the caller's transaction. Used by the slice-3 relay refactor (per design §4.2) so the messages INSERT and the webhook_events outbox INSERT commit together, closing the at-least-once publish-loss window.
Mirrors the CreateAgentTx pattern at store.go:596-607 — same SQL body, executed against either *pgxpool.Pool or pgx.Tx via the messageExecutor interface below.
func (*Store) CreateOrGetUser ¶
func (*Store) CreateOutboundMessage ¶
func (s *Store) CreateOutboundMessage(ctx context.Context, agentID string, toRecipients []string, cc []string, bcc []string, subject, msgType, method, providerMessageID, conversationID string, rawMessage []byte) (*Message, error)
CreateOutboundMessage stores an outbound message with multi-recipient support. The recipient param is kept for backward compat with the singular recipient column; toRecipients, cc, and bcc are the canonical outbound-only multi-recipient fields.
func (*Store) CreateOutboundMessageTx ¶
func (s *Store) CreateOutboundMessageTx(ctx context.Context, tx pgx.Tx, agentID string, toRecipients, cc, bcc []string, subject, msgType, method, providerMessageID, conversationID string, rawMessage []byte, deliveryStatus, envelopeFrom, sentAs string) (*Message, error)
CreateOutboundMessageTx is CreateOutboundMessage on the caller's transaction, for the async accept path (async-message-pipeline.md, slice C). It persists the two-column model: status=MessageStatusSent (the hold/lifecycle column — this row is not held) while delivery_status carries the send progression (the accept-tx passes 'accepted'; the send worker later advances it to 'sent'/'failed'). It also stamps envelope_from + sent_as, decided once at compose time, so the worker can submit the persisted bytes without re-composing. provider_message_id is empty until the worker records the SES id in MarkOutboundSentTx.
func (*Store) CreatePendingOutboundMessage ¶
func (s *Store) CreatePendingOutboundMessage(ctx context.Context, agentID string, toRecipients, cc, bcc []string, subject, bodyText, bodyHTML string, attachmentsJSON []byte, msgType, conversationID, replyToEmailMessageID, replyTo string, ttlSeconds int) (*Message, error)
CreatePendingOutboundMessage stores a fully composed outbound email in pending_review status, including body_text, body_html, and attachments so that approval can reconstruct the original SendRequest (or accept edits) without the caller needing to retain it. ttlSeconds sets how long the message remains pending before the expiration worker resolves it.
replyToEmailMessageID is the RFC 5322 Message-ID of the inbound being replied to (e.g. "<abc@gmail.com>"), or "" for fresh sends and test emails. It reuses the email_message_id column, which is unused for outbound rows in every other path — the column semantically carries "the Message-ID this row references" in both directions.
attachmentsJSON must be a JSON array matching the public Attachment shape ([{filename, content_type, data}, ...]) or nil. Callers that already have an []outbound.Attachment slice should json.Marshal it before passing in.
func (*Store) CreatePendingOutboundMessageManaged ¶
func (s *Store) CreatePendingOutboundMessageManaged(ctx context.Context, agentID string, toRecipients, cc, bcc []string, subject, bodyText, bodyHTML string, attachmentsJSON []byte, msgType, conversationID, replyToEmailMessageID, replyTo string, ttlSeconds int, managedUnsubscribe bool) (*Message, error)
func (*Store) CreatePendingOutboundMessageManagedTx ¶
func (s *Store) CreatePendingOutboundMessageManagedTx(ctx context.Context, tx pgx.Tx, agentID string, toRecipients, cc, bcc []string, subject, bodyText, bodyHTML string, attachmentsJSON []byte, msgType, conversationID, replyToEmailMessageID, replyTo string, ttlSeconds int, managedUnsubscribe bool) (*Message, error)
func (*Store) CreatePendingOutboundMessageTx ¶
func (s *Store) CreatePendingOutboundMessageTx(ctx context.Context, tx pgx.Tx, agentID string, toRecipients, cc, bcc []string, subject, bodyText, bodyHTML string, attachmentsJSON []byte, msgType, conversationID, replyToEmailMessageID, replyTo string, ttlSeconds int) (*Message, error)
CreatePendingOutboundMessageTx is the in-tx sibling of CreatePendingOutboundMessage, letting the HITL hold write the pending_review row and enqueue its approval-notification job (QueueNotify) in ONE transaction so neither can exist without the other (docs/design/hitl-notify-river.md). Mirrors CreateOutboundMessageTx / the send accept-tx.
func (*Store) CreateProtectionEvent ¶
func (s *Store) CreateProtectionEvent(ctx context.Context, ev ProtectionEvent) error
CreateProtectionEvent appends a screening event. The insert is idempotent on the primary key: callers that set a DeterministicProtectionEventID get exactly-once recording across retries. If ev.ID is empty a random id is assigned. A conflicting id is a no-op (returns nil).
func (*Store) CreateScopedAPIKey ¶
func (s *Store) CreateScopedAPIKey(ctx context.Context, userID, name, scope, agentID string, expiresAt *time.Time) (*APIKey, error)
CreateScopedAPIKey issues a fresh API key with an explicit scope (Slice 5a).
- ScopeAccount: account-wide admin; agentID must be empty; prefix e2a_acct_.
- ScopeAgent: bound to agentID (which must be a non-empty agent owned by the user); prefix e2a_agt_. The key can only act as that one agent.
The visible prefix makes a key's blast radius obvious at a glance, and the DB CHECK (scope='agent') == (agent_id IS NOT NULL) backstops the binding.
func (*Store) CreateTemplate ¶
func (s *Store) CreateTemplate(ctx context.Context, userID string, in TemplateCreate) (*Template, error)
CreateTemplate inserts a new template. Returns ErrTemplateLimitReached at the per-user cap and ErrTemplateAliasTaken on a per-user alias collision.
Syntax validation (Parse of all three parts) is the handler's job; the storage layer only enforces the count cap and alias uniqueness. The cap check races like the webhooks one (bounded overshoot of one row under concurrent creates) — acceptable at a cap of 10.
func (*Store) CreateUserSession ¶
func (*Store) CreateWebhook ¶
func (s *Store) CreateWebhook(ctx context.Context, userID, url, description string, events []string, filters WebhookFilters) (*Webhook, error)
CreateWebhook inserts a new row and returns it with the plaintext signing secret populated. The plaintext is only available on this response — subsequent GET/list calls scrub it.
Filters validation (charset, count caps, agent ownership) is the handler's job in slice 2; the storage layer only verifies the per-user count cap from account_limits.max_webhooks.
func (*Store) CreateWebhookIdem ¶
func (s *Store) CreateWebhookIdem(ctx context.Context, userID, url, description string, events []string, filters WebhookFilters, idemCompleteTx WebhookIdemCompleter) (*Webhook, error)
CreateWebhookIdem is CreateWebhook with an optional idempotency completer. When idemCompleteTx is non-nil the insert runs in a transaction and the completer is invoked inside it (mirroring the send/approve same-tx pattern — idempotency.Store.CompleteTx): the webhook row and the completed idempotency key commit together, so there is no window in which a webhook exists without a replayable cached response. A completer error aborts the create entirely. With a nil completer the insert stays the original single statement.
func (*Store) DeferOutboundTerminalFailure ¶
func (s *Store) DeferOutboundTerminalFailure(ctx context.Context, messageID string, jobID int64, detail string) error
DeferOutboundTerminalFailure records a final attempt's diagnostic and releases the I/O claim WITHOUT declaring the message failed. The worker calls it when its last attempt errored ambiguously (the provider may have accepted the submission before the failure — the SMTP-accept↔mark-sent crash window): the terminal reconciler then declares the outcome after the provider-evidence grace window, preferring this stored detail over its generic sweep message, or settles the row as sent if evidence arrives. Job-scoped so a stale worker can't scribble on a re-enqueued message.
func (*Store) DeleteAPIKey ¶
func (*Store) DeleteAgent ¶
func (s *Store) DeleteAgent(ctx context.Context, agentID, userID string) (messagesDeleted int64, err error)
DeleteAgent removes an agent and everything bound to it. It returns the number of message rows removed with the agent — the messages are deleted explicitly rather than left to the agent_identities → messages FK cascade. Besides providing the API's deletion receipt, this ensures the storage metering trigger can resolve the owning user while the agent row exists.
func (*Store) DeleteDomain ¶
DeleteDomain deletes a domain only if owned by the user. The handler should check for existing agents first.
func (*Store) DeleteDomainTx ¶
func (s *Store) DeleteDomainTx(ctx context.Context, domain, userID string, inTx func(ctx context.Context, tx pgx.Tx) error) error
DeleteDomainTx deletes a domain and, before committing, runs inTx within the SAME transaction. The hook is how sender-identity teardown is enqueued transactionally (decision 4): the River deprovision job is committed atomically with the domain-row delete, so it can never be lost if SES is unreachable at delete time. A nil hook is a plain delete (dev / no SES).
inTx runs only after the DELETE affected a row (the domain existed and was owned by userID); it never runs for a not-found / FK-blocked delete.
func (*Store) DeleteExpiredMessages ¶
DeleteExpiredMessages purges message rows whose TrashRetention window has lapsed. Live rows are retained indefinitely. Messages belonging to an agent in trash are removed by PurgeDeletedAgents when that agent reaches the same retention boundary.
func (*Store) DeleteExpiredUserSessions ¶
func (*Store) DeleteTemplate ¶
DeleteTemplate removes a template owned by the user. Deleting a missing or cross-user template returns ErrTemplateNotFound, never silent success.
func (*Store) DeleteUserData ¶
DeleteUserData wipes everything tied to a user in a single transaction.
Schema cascades cover most of it (user_sessions, domains, agent_identities, api_keys, usage_summaries all `ON DELETE CASCADE` from users; messages cascade through agent_identities; webhook_deliveries cascade through messages; agent_suppressions and agent_unsubscribe_tokens cascade directly from users). The one row that doesn't is usage_events: its FK is `ON DELETE SET NULL` so analytics survives, which we explicitly override here for full deletion.
Per-table counts are returned to the caller so an operator can attest to what was removed in audit / compliance contexts.
func (*Store) DeleteUserDataTx ¶
func (s *Store) DeleteUserDataTx(ctx context.Context, userID string, perDomainInTx func(ctx context.Context, tx pgx.Tx, domain string) error) (*DeleteUserDataResult, error)
DeleteUserDataTx is DeleteUserData with an optional per-domain in-tx hook run for every domain the user owns, BEFORE the cascade deletes them. It is how sender-identity teardown is enqueued for an account delete (decision 4): each owned domain's SES deprovision job commits atomically with the user delete. A nil hook is a plain account delete (dev / no SES). The DB FK cascade still removes the domain rows; the hook only schedules the remote SES cleanup that the cascade cannot do.
func (*Store) DeleteUserSession ¶
func (*Store) DeleteWebhook ¶
DeleteWebhook removes a webhook owned by the user. Idempotent: deleting a non-existent or cross-user webhook returns ErrWebhookNotFound, never silently succeeds. The ON DELETE CASCADE on webhook_subscriber_deliveries.webhook_id drops pending delivery rows automatically — no separate cleanup needed.
Slice 1 includes this method (rather than deferring to slice 2's handler work) because tests need it for setup teardown and the implementation is trivial.
func (*Store) DomainExists ¶
DomainExists reports whether a live domain row exists (orphan reaper).
func (*Store) DomainOwner ¶
DomainOwner returns the user_id owning a domain, or "" for an unowned (system) domain. pgx.ErrNoRows → ("", nil) so the caller treats a missing domain as "no owner, no event".
func (*Store) EffectiveSuppressions ¶
func (s *Store) EffectiveSuppressions(ctx context.Context, userID, agentID string, addresses []string) ([]string, error)
EffectiveSuppressions returns the deduplicated union of account-wide and exact-agent recipient blocks present in addresses.
func (*Store) EncryptLegacyDKIMKeys ¶
EncryptLegacyDKIMKeys re-encrypts any DKIM private keys still stored as plaintext DER (rows written before encryption-at-rest, #144). It is idempotent and self-terminating: only untagged rows (first byte != dkimBlobV1) are selected, so a second run is a no-op. No-op when no cipher is configured. The domains table is small, so it is safe to run at every startup. Returns the number of rows encrypted.
func (*Store) EnsureSharedDomain ¶
EnsureSharedDomain inserts a system row for the configured shared mail domain so slug-based agent registration can satisfy the agent_identities.registered_domain → domains.domain foreign key. The row is owned by no user (user_id = NULL) and pre-verified — it represents infrastructure the operator runs, not user-claimed identity.
Called once at server startup. Idempotent via ON CONFLICT DO NOTHING, and a no-op when the operator has not configured a shared domain. Without this, any deployment whose shared_domain differs from the hardcoded migration seed (`agents.e2a.dev`) gets an FK violation the first time a user tries to register a slug-based agent.
func (*Store) ExpireAndDeliverLocal ¶
func (s *Store) ExpireAndDeliverLocal( ctx context.Context, messageID string, compose func(msg *Message) (SendResult, error), beforeCommit LocalDeliveryTxHook, ) (*Message, error)
ExpireAndDeliverLocal is the TTL-worker counterpart of ApproveAndDeliverLocal. It requires an expired pending row, records the review_expired_approved lifecycle, and atomically creates the Inbox copy and outcome events without using the external-provider send journal.
func (*Store) ExpireApproveAndSend ¶
func (s *Store) ExpireApproveAndSend( ctx context.Context, messageID string, send func(msg *Message) (SendResult, error), ) (*Message, error)
ExpireApproveAndSend is the worker-side counterpart to ApproveAndSend: no user ownership check (the caller is the expiration worker, which is system-scoped), SELECT ... FOR NO KEY UPDATE SKIP LOCKED so concurrent workers don't race for the same row, and the terminal status is 'review_expired_approved' instead of 'sent'. On send failure the transaction rolls back; the worker should then call ExpireReject to move the row to a final state so the row doesn't get picked up on every sweep.
Exactly-once guarantee: like ApproveAndSend, this method runs the send() callback under a send_attempts gate so a crash between SES acceptance and the surrounding tx commit does NOT cause the next worker poll to re-send. ClaimSendAttempt / MarkSendSucceeded / MarkSendFailed run in separate small transactions that outlive the approval tx; on retry, an AlreadySent verdict reuses the cached SendResult and skips the upstream send entirely. Without this, the polling-loop nature of the worker would guarantee a re-send on any commit failure — strictly worse than the human-approval path, where a re-send needs an explicit click.
SKIP LOCKED means multiple app instances can run the worker without contending on the same row. The row-level FOR NO KEY UPDATE lock on messages is held for the duration of the send callback (bounded by SMTPRelay timeouts); FOR NO KEY UPDATE rather than FOR UPDATE so the send_attempts INSERT in a separate connection can acquire its KEY SHARE lock for FK enforcement — see ApproveAndSend's docstring for the full rationale.
If a concurrent worker is mid-send for the same row (the send_attempts row is 'attempting' and not yet stale), returns ErrSendInProgress. The worker loop should treat this like ErrNotPendingApproval — skip silently and let the next poll handle it.
func (*Store) ExpireApproveReview ¶
ExpireApproveReview is the worker-side TTL auto-approve: releases the message (status review_expired_approved) with no human reviewer. System-scoped.
func (*Store) ExpireReject ¶
ExpireReject transitions a pending_review message to review_expired_rejected while retaining its content. No user ownership check — this is the worker path. If the row is no longer pending (racing worker, already handled), or the agent was moved to the trash after the sweep listed the row (the hold must survive intact, and RestoreAgent shifts approval_expires_at so the clock resumes on restore), returns ErrNotPendingApproval; caller can treat as a no-op. The trash guard lives INSIDE the CAS so it is atomic with the transition — a separate read would reopen the sweep's TOCTOU window.
func (*Store) ExpireRejectReview ¶
ExpireRejectReview is the worker-side TTL auto-reject: drops the message (status review_expired_rejected) with no human reviewer. System-scoped.
func (*Store) ExportUserData ¶
ExportUserData gathers everything a user owns into a single struct for the right-of-access flow. Reads run inside a REPEATABLE READ transaction so the snapshot is internally consistent even if writes arrive while the export is being assembled.
func (*Store) GetAgentByEmail ¶
GetAgentByEmail looks up an agent by email address (same as GetAgentByID since ID = email).
func (*Store) GetAgentByID ¶
GetAgentByID looks up an agent by its ID (full email) with domain verification status.
func (*Store) GetAgentByIDAnyState ¶
GetAgentByIDAnyState loads an agent regardless of trash state (deleted_at populated when trashed). For the trash surfaces only — restore, permanent delete, trash listing detail. Every live path uses GetAgentByID, which treats a trashed agent as nonexistent.
func (*Store) GetConversationByID ¶
func (s *Store) GetConversationByID(ctx context.Context, agentID, conversationID string) (*ConversationDetail, error)
GetConversationByID returns the aggregate summary fields plus every member message, ordered oldest-first (chronological reading order). Returns ErrMessageNotFound when no live messages exist for the given (agentID, conversationID) — mirrors the "looks-like-not-found-on-cross-agent" convention used by single- message reads. The same code path handles "wrong agent" and "real non-existent": either way the agent has no business seeing it.
Participants are computed as the union of sender + recipient + each row's to_recipients / cc / bcc (when populated). Empty strings are dropped. Labels are the union of all members' labels[]; both are sorted lexicographically for stable output.
func (*Store) GetDKIMKeyInternal ¶
GetDKIMKeyInternal returns the stored selector + private key bytes for a domain. The "Internal" suffix is load-bearing: this function does NOT scope by user — it takes a domain name and returns whoever owns that domain's signing key. ONLY call from server-internal codepaths where the domain has already been resolved from a trusted source (e.g. an outbound message's sender field, after the agent layer has authenticated the owner). A handler that ever takes a user-supplied domain string and feeds it to this function becomes a "sign as anyone" primitive: don't.
Returns ("", nil, nil) when the domain has no key — callers MUST treat this as "skip signing" and fall back to whatever the relay-level fallback does.
func (*Store) GetDashboardStats ¶
func (s *Store) GetDashboardStats(ctx context.Context, userID string, windowDays int) (*DashboardStats, error)
GetDashboardStats returns workspace-level aggregates for the authenticated user, with a configurable lookback window. windowDays controls Inbound/Outbound totals AND the delivery-success ratio's sample period — passing 0 falls back to DashboardDefaultWindowDays (7), values above DashboardMaxWindowDays (90) are clamped.
Three independent reads — kept separate because the source tables have different indexes and one slow read shouldn't slow the others. All reads are O(rows-for-this-user-only) thanks to the existing per-user indexes.
Robust to missing data: deployments without usage tracking enabled (E2A_USAGE_TRACKING=false — the default for self-hosters) return zero counts rather than erroring. Same for users who have no messages yet. The UI renders zero values as "—".
Delta percentages: today vs yesterday on usage_summaries. Avoids divide-by-zero when yesterday was zero by returning 0. 100% in/de- crease maps to ±100; values clipped at ±999 for integer width.
func (*Store) GetEventEnvelope ¶
GetEventEnvelope returns the exact durable event envelope for a message. WebSocket reconnect drain uses this instead of rebuilding an event whose timestamp or attachment metadata could differ under the same event id.
func (*Store) GetInboundByEmailMessageID ¶
func (s *Store) GetInboundByEmailMessageID(ctx context.Context, agentID, emailMessageID string) (*Message, error)
GetInboundByEmailMessageID looks up an inbound message by its RFC 5322 Message-ID for the given agent. Used by HITL flows to reach the parent inbound at approval time so the References chain can be rebuilt — the pending-outbound row only stores the parent's Message-ID, not its raw message. Scoped to agent_id to prevent any cross-agent reach across shared infra. Returns sql.ErrNoRows when the inbound has expired or was never persisted; callers must tolerate that and fall back to legacy single-id threading.
This reply-threading lookup selects the stored header context needed to rebuild References, including the raw message and legacy auth-header map. It omits structured authentication evidence, which message/review detail paths load separately.
func (*Store) GetInboundMessage ¶
func (*Store) GetMessageByEmailMessageID ¶
func (s *Store) GetMessageByEmailMessageID(ctx context.Context, agentID, messageID string) (*Message, error)
GetMessageByEmailMessageID looks up a message by its RFC 5322 Message-ID for the given agent, regardless of direction. It is the direction-agnostic sibling of GetInboundByEmailMessageID: the HITL approve path uses it to rebuild the References chain of a held reply, and the reply's parent can be an outbound the agent sent (reply-to-own-message), not just a received inbound.
The id is matched against email_message_id (where inbound rows carry the sender's Message-ID) OR provider_message_id (where outbound rows carry the relay/SES-assigned Message-ID — outbound rows have no email_message_id, see ThreadMessageID), so a held reply threaded onto either kind of parent resolves. Same expiry/held exclusions apply. Returns sql.ErrNoRows when the parent has expired or was never persisted; callers must tolerate that and fall back to legacy single-id threading.
func (*Store) GetMessageWithContent ¶
func (s *Store) GetMessageWithContent(ctx context.Context, messageID, agentID string) (*Message, error)
GetMessageWithContent returns a full message including raw MIME and canonical authentication evidence. Marks the message as 'read' if it was 'unread'.
Unlike the list/reply/threading paths, this deliberately returns TRASHED rows too (DeletedAt set) so the dashboard trash can open a deleted message — Gmail's "view message in trash". Callers branch on DeletedAt when trash rows must not qualify.
func (*Store) GetMessagesByAgent ¶
GetMessagesByAgent returns messages for an agent, filtered by status, direction, and the optional search filters on the MessageListFilter struct.
- direction: "inbound" (default for SDK polling), "outbound", or "all" (used by the dashboard inbox).
- status: "unread" | "read" | "all" — only applies when direction selects inbound rows; ignored on pure outbound queries.
- descending: cursor walks newest→oldest when true; oldest→newest when false (FIFO polling).
- From / SubjectContains: case-insensitive substring (ILIKE).
- ConversationID: exact match.
- Since / Until: time-range bracket on created_at.
The SELECT includes columns both consumers need: the inbox needs `status` (outbound HITL lifecycle), `webhook_status`/`last_error` (outbound delivery), and `octet_length(raw_message)` (size column); the polling SDK ignores these fields and reads only the existing inbound-relevant ones from the Message struct. Canonical identity and authentication evidence is selected so the WS drain fallback matches live delivery; raw_message stays deliberately unselected (the blob). Legacy auth columns remain selected only for fail-closed migration fallback.
func (*Store) GetOutboundMessageForUser ¶
func (s *Store) GetOutboundMessageForUser(ctx context.Context, messageID, userID string) (*Message, error)
GetOutboundMessageForUser returns a full message row (including body, HITL fields, and attachments) if it exists and is owned by userID (via the agent the row belongs to). Inbound messages and cross-user access both return ErrMessageNotFound — the caller should not be able to distinguish "does not exist" from "belongs to someone else".
func (*Store) GetPrincipalByAPIKey ¶
GetPrincipalByAPIKey authenticates a bearer token and returns the full principal — the owning user PLUS the key's scope and bound agent (Slice 5a). Same validation/last_used semantics as GetUserByAPIKey (it delegates here). A legacy key with a NULL scope column resolves to ScopeAccount, preserving pre-redesign authority.
func (*Store) GetRepliableMessage ¶
GetRepliableMessage loads a message that can be the target of a reply or forward, regardless of direction: an inbound the agent received or an outbound the agent sent. It is the direction-agnostic sibling of GetInboundMessage — same columns (plus provider_message_id, which carries the outbound Message-ID for threading; see ThreadMessageID) — but without the `direction = 'inbound'` predicate, so an agent can continue a thread off its own sent message (mirrors how mail clients let you reply to a message in your Sent folder).
The held-status exclusion is kept for BOTH directions: a message still in review (pending/rejected/expired) has not actually been delivered, so it is not a legitimate reply/forward anchor. Callers still scope the result to the owning agent (id-only lookup here does not).
method + delivery_status are loaded (beyond GetInboundMessage's column list) because `status` is the review/hold axis, not the delivery axis: an outbound row reads status='sent' the instant it is accepted, long before the send worker submits it. The reply/forward handlers need the delivery axis to tell an outbound that is still queued for external submission (no provider_message_id yet → ThreadMessageID() would return "" and silently drop In-Reply-To/References) from one that is genuinely terminal. See httpapi.parentNotYetSubmitted.
func (*Store) GetReviewMessage ¶
func (s *Store) GetReviewMessage(ctx context.Context, messageID, agentID string) (*ReviewMessageMeta, error)
GetReviewMessage is the review-queue single-item getter: it loads a message scoped to agentID REGARDLESS of held status (unlike every agent-facing read path, which excludes heldInboundStatuses). It exists so the account-scoped /approve+/reject endpoints can resolve a held message's direction and dispatch the correct release path.
SECURITY: this deliberately bypasses the held-inbound read boundary, so it MUST only ever be reachable from an account-scoped reviewer flow that has already proven ownership of agentID (resolveOwnedAgent). It is NOT an agent read path — do not wire it onto any agent-scoped surface. The agentID filter is the tenant-isolation guard: a reviewer can only resolve a message belonging to an agent they own. Returns sql.ErrNoRows when no such message exists for the agent.
func (*Store) GetReviewWithContent ¶
func (s *Store) GetReviewWithContent(ctx context.Context, userID, messageID string) (*Message, error)
GetReviewWithContent loads one held message (either direction) with its body, scoped to userID — the detail view behind GET /v1/reviews/{id}. Like GetReviewMessage it deliberately bypasses the held-inbound exclusion, so it is account-scoped reviewer flow ONLY (the user join is the tenant guard). Unlike GetMessageWithContent it does NOT mark the message read (reviewing isn't reading) and does NOT exclude held rows. Returns sql.ErrNoRows when no such message exists for the user.
func (*Store) GetSendOutcome ¶
GetSendOutcome reads the current delivery_status + provider id + sent_as + detail for an outbound message — the wait=sent poll. Returns a zero-value outcome (empty DeliveryStatus) if the row is gone, never an error for a missing row.
func (*Store) GetSendingStatus ¶
GetSendingStatus returns the domain's sending_status. Propagates pgx.ErrNoRows when the domain row is gone.
func (*Store) GetTemplateByAlias ¶
GetTemplateByAlias resolves a template by its per-user alias. Missing or cross-user aliases return ErrTemplateNotFound.
func (*Store) GetTemplateByID ¶
GetTemplateByID returns the template iff it's owned by userID. Cross-user reads (or missing rows) return ErrTemplateNotFound.
func (*Store) GetUserByAPIKey ¶
GetUserByAPIKey authenticates a bearer token and returns the owning user. Rejects revoked keys and time-expired keys; touches last_used_at only on the success path so the column stays a real "last successful authentication" signal (rather than "last attempt").
Expiration semantics: expires_at IS NULL means the key never expires (preserves the pre-migration default). A non-null expires_at must be in the future, evaluated against now() in the same query so there's no clock skew between row read and check.
func (*Store) GetUserByID ¶
func (*Store) GetUserSession ¶
func (*Store) GetWebhookByID ¶
GetWebhookByID returns the webhook iff it's owned by userID. Cross- user reads (or missing rows) return ErrWebhookNotFound — same not-found-on-cross-user convention used elsewhere in the codebase (conversation reads, message reads).
The returned Webhook has SigningSecret populated for the delivery worker's benefit; the public API layer scrubs this field before responding to GETs.
func (*Store) GetWebhookByIDInternal ¶
GetWebhookByIDInternal returns the webhook by ID with no ownership check. INTERNAL USE ONLY — handler code MUST use GetWebhookByID which scopes by user_id. The retry worker uses this to look up the URL + signing secret for a delivery row whose ownership was already established when the publisher inserted it.
The suffix Internal mirrors the convention in dkim.GetDKIMKeyInternal: a method name that calls out "skipping the standard authorization check" so a reviewer doesn't have to read the body to know why.
func (*Store) HasAgentsOnDomain ¶
HasAgentsOnDomain checks whether the owned domain still has agents.
func (*Store) InsertInboundIntakeTx ¶
func (s *Store) InsertInboundIntakeTx(ctx context.Context, tx pgx.Tx, id, recipient, envelopeFrom, heloDomain, remoteIP, messageID, contentHash string, raw []byte) (inserted bool, err error)
InsertInboundIntakeTx writes an accepted intake row inside the accept-tx. It returns inserted=false (no error) when the row is a duplicate — the dedup unique index (recipient, message_id, content_hash) suppressed it — so the caller knows NOT to enqueue a second job and to still answer 250 (idempotent accept).
func (*Store) ListAPIKeys ¶
func (s *Store) ListAPIKeys(ctx context.Context, userID string, limit int, afterCreatedAt time.Time, afterID string) ([]APIKey, error)
ListAPIKeys returns one page of the user's live (non-revoked) API keys, newest-first, keyset-paginated on (created_at, id). limit<=0 returns every key unpaginated (the all-consumers: auth dashboard + prober seed); a positive limit fetches that many (pass limit+1 to detect a further page) starting after the (afterCreatedAt, afterID) key from the previous page's last row (zero afterCreatedAt = first page).
func (*Store) ListActivityByAgent ¶
func (*Store) ListAgentSuppressions ¶
func (s *Store) ListAgentSuppressions(ctx context.Context, userID, agentID string, limit int, after time.Time, afterAddress string) ([]AgentSuppression, error)
ListAgentSuppressions returns one newest-first keyset page for an exact account and agent.
func (*Store) ListAgentsByUser ¶
func (s *Store) ListAgentsByUser(ctx context.Context, userID string, limit int, afterCreatedAt time.Time, afterID string) ([]AgentIdentity, error)
Agents are joined with domain verification AND enriched with per-agent stats for the dashboard. Correlated subqueries compute inbound/outbound 7-day counts, pending approvals, last delivery, and webhook status in a single round-trip. Other load paths (GetAgentByID, GetAgentByEmail) intentionally don't compute these — only the dashboard and the account export need them.
ListAgentsByUser returns one page of the user's agents, newest-first, keyset-paginated on (created_at, id). limit<=0 returns every agent unpaginated (the all-consumers: auth dashboard views + webhook filter ownership validation); a positive limit fetches that many (pass limit+1 to detect a further page) starting after the (afterCreatedAt, afterID) key from the previous page's last row (zero afterCreatedAt = first page).
func (*Store) ListConversationsByAgent ¶
func (s *Store) ListConversationsByAgent(ctx context.Context, f ConversationListFilter) ([]ConversationSummary, error)
ListConversationsByAgent groups the agent's live messages by conversation_id and returns one row per conversation sorted by most-recent activity. Messages without a conversation_id are not included in any conversation — they remain individually visible via GetMessagesByAgent.
func (*Store) ListDeletedAgentsByUser ¶
func (s *Store) ListDeletedAgentsByUser(ctx context.Context, userID string, limit int, afterCreatedAt time.Time, afterID string) ([]AgentIdentity, error)
ListDeletedAgentsByUser is the trash listing: agents the user soft-deleted, newest-first, same keyset pagination as the live list. DeletedAt is populated on every row.
func (*Store) ListDomainsByUser ¶
func (s *Store) ListDomainsByUser(ctx context.Context, userID string, limit int, afterCreatedAt time.Time, afterDomain string) ([]Domain, error)
AgentCount is computed inline via a correlated subquery — one round-trip regardless of how many domains the page has, and the per-row count is supported by the existing user and registered-domain indexes. Excludes system rows.
ListDomainsByUser returns one page of the user's domains, newest-first, keyset-paginated on (created_at, domain) — domain is the table's unique key, so it is the deterministic tiebreak. limit<=0 returns every domain unpaginated; a positive limit fetches that many (pass limit+1 to detect a further page) starting after the (afterCreatedAt, afterDomain) key from the previous page's last row (zero afterCreatedAt = first page).
func (*Store) ListEnabledWebhooksForRouting ¶
func (s *Store) ListEnabledWebhooksForRouting(ctx context.Context, userID, eventType string) ([]Webhook, error)
ListEnabledWebhooksForRouting is the hot-path query used by the event publisher. Returns enabled webhooks for the user that subscribe to the given event type. The in-process publisher then applies filter matching in Go (cheaper than encoding the AND-across-types + OR-within-type rule in SQL at slice-1 scale).
The partial index idx_webhooks_user_enabled WHERE enabled = true keeps this O(log n) on the common case (a user has a small number of enabled webhooks).
func (*Store) ListExpiredPending ¶
ListExpiredPending returns pending_review messages whose approval_expires_at is in the past, joined with their agent's hitl_expiration_action. Ordered by approval_expires_at ASC so earliest-expired are handled first.
func (*Store) ListExpiredReviews ¶
ListExpiredReviews returns inbound pending_review messages whose approval_expires_at has passed, joined with their agent's hitl_expiration_action — the inbound analogue of ListExpiredPending. The expiry worker uses these to auto-resolve held messages per the agent's policy.
func (*Store) ListPendingOutboundForUser ¶
func (s *Store) ListPendingOutboundForUser(ctx context.Context, userID string, limit int) ([]Message, error)
ListPendingOutboundForUser returns pending-approval messages across all of the user's agents, sorted by approval_expires_at ASC (expiring-soonest first). Body columns are not returned from this path — callers should use GetOutboundMessageForUser for detail.
func (*Store) ListProtectionEventsByAgent ¶
func (s *Store) ListProtectionEventsByAgent(ctx context.Context, agentID string, limit int) ([]ProtectionEvent, error)
ListProtectionEventsByAgent returns an agent's most recent screening events (newest first, capped by limit) for the security/analytics view.
func (*Store) ListProtectionEventsByMessage ¶
func (s *Store) ListProtectionEventsByMessage(ctx context.Context, messageID string) ([]ProtectionEvent, error)
ListProtectionEventsByMessage returns every screening event recorded against a message, newest first — the breakdown a reviewer sees for a held item.
func (*Store) ListReviews ¶
func (s *Store) ListReviews(ctx context.Context, userID string, limit int, afterCreatedAt time.Time, afterID string) ([]ReviewListItem, error)
ListReviews returns one page of held (pending_review) messages — BOTH directions — across all of userID's agents, newest-first, keyset-paginated on (created_at, id). The caller passes limit (fetch limit+1 to detect a further page; limit<=0 returns every row unpaginated) and the after-key from the previous page's last row (zero afterCreatedAt = first page). The review queue grows unbounded with the pending-review backlog, so it needs real pagination rather than returning the whole set. This is the operator review queue: it intentionally includes held inbound (which every agent-facing read path excludes). SECURITY: account-scoped reviewer flow only; the user join is the tenant-isolation guard.
func (*Store) ListSuppressions ¶
func (s *Store) ListSuppressions(ctx context.Context, userID string, limit int, afterCreatedAt time.Time, afterAddress string) ([]Suppression, error)
ListSuppressions returns the user's suppression list, newest first. ListSuppressions returns one page of the user's suppressed addresses, newest first, keyset-paginated on (created_at, address). The caller passes limit (fetch limit+1 to detect a further page) and the after-key from the previous page's last row (zero afterCreatedAt = first page). (A-5: the suppression list auto-grows on every bounce/complaint, so it needs real pagination, not a single page.)
func (*Store) ListTemplatesByUser ¶
func (s *Store) ListTemplatesByUser(ctx context.Context, userID string, limit int, afterCreatedAt time.Time, afterID string) ([]TemplateSummary, error)
Summaries only (no body/html_body).
ListTemplatesByUser returns one page of the user's templates, newest-first, keyset-paginated on (created_at, id). limit<=0 returns every template unpaginated; a positive limit fetches that many (pass limit+1 to detect a further page) starting after the (afterCreatedAt, afterID) key from the previous page's last row (zero afterCreatedAt = first page).
func (*Store) ListWebhooksByUser ¶
func (s *Store) ListWebhooksByUser(ctx context.Context, userID string, limit int, afterCreatedAt time.Time, afterID string) ([]Webhook, error)
Storage layer surfaces enabled and disabled rows alike; filter at the handler if needed.
ListWebhooksByUser returns one page of the user's webhooks, newest-first, keyset-paginated on (created_at, id). limit<=0 returns every webhook unpaginated (the all-consumers: prober seed); a positive limit fetches that many (pass limit+1 to detect a further page) starting after the (afterCreatedAt, afterID) key from the previous page's last row (zero afterCreatedAt = first page).
func (*Store) LoadInboundIntake ¶
LoadInboundIntake reads an intake row for the worker. Returns (nil, nil) when the row is gone (pruned) so the worker treats it as a no-op.
func (*Store) LoadOutboundDraft ¶
LoadOutboundDraft loads a pending_review outbound message's full draft content (recipients, subject, body, attachments, reply-to) by id, system-scoped (no user filter, no side effects) — the TTL sweep uses it to reconstruct the SendRequest and compose before ApproveAndAccept. Returns ErrMessageNotFound if the row is gone or not an outbound message. The caller must still handle the pending_review CAS in ApproveAndAccept (a human may resolve the hold before the transition).
func (*Store) LoadOutboundForSend ¶
func (s *Store) LoadOutboundForSend(ctx context.Context, messageID string) (*OutboundSendPayload, error)
LoadOutboundForSend returns the payload the async send worker submits, or nil if the row is gone (agent delete / TTL) — the worker treats that as a no-op. A message in the trash, or one whose AGENT is in the trash, counts as gone: deleting is the user's one lever to stop a queued-but-unsent message (e.g. snoozed behind a provider-outage retry horizon), and a trashed inbox must never emit mail. Reads the envelope (to+cc+bcc) and the persisted wire bytes; does not touch message_recipients (those are written at MarkSent).
func (*Store) LoadPendingNotify ¶
LoadPendingNotify returns the row the notification worker needs, or (nil, nil) when there is nothing to notify about — the message was deleted/pruned, or its owning agent no longer exists (an orphaned hold; other paths finalize it). The worker treats a nil return as a no-op.
func (*Store) LookupConversationID ¶
func (s *Store) LookupConversationID(ctx context.Context, agentID string, messageIDs []string) (string, error)
LookupConversationID finds a conversation_id by matching In-Reply-To / References message IDs against stored messages. Checks both email_message_id (inbound) and provider_message_id (outbound). Uses prefix matching because SES bare IDs stored in provider_message_id (e.g. <010f...>) may lack the @region.amazonses.com suffix that appears in the actual email headers sent to recipients.
func (*Store) LookupCoveringDomain ¶
LookupCoveringDomain returns the MOST-SPECIFIC registered domain owned by userID that is a proper label-boundary parent of sub, or an error if none covers it. This backs subdomain-agent creation: a verified parent SES identity (e.g. team.mnexa.ai) DKIM-signs and DMARC-aligns mail from any subdomain (acme.team.mnexa.ai), so e2a lets an agent be created on the subdomain without a separate registration. The caller must inspect Verified: an explicitly registered pending child is authoritative for its subtree and must not be masked by a verified grandparent. The parent must be an exact ancestor on a DNS label boundary — see coveringParentCandidates. The returned Domain.Domain is what the caller stores in agent_identities.registered_domain so the FK, quota JOIN, DKIM signer, and sending-status lookup all resolve to the parent.
func (*Store) LookupDomain ¶
LookupDomain returns a domain if it exists and is owned by the given user.
func (*Store) MarkInboundIntakeFailed ¶
MarkInboundIntakeFailed marks an intake terminally failed (unparseable body / exhausted retries) with a diagnostic detail. Own transaction — a terminal record for ops visibility; the message is dropped (we already 250'd).
func (*Store) MarkInboundIntakeProcessedTx ¶
func (s *Store) MarkInboundIntakeProcessedTx(ctx context.Context, tx pgx.Tx, intakeID, messageFK string) error
MarkInboundIntakeProcessedTx flips the intake to 'processed' and links the created messages row, in the worker's terminal tx (same tx as the messages insert + outbox publish). This is the worker's idempotency gate: a re-drive that finds 'processed' no-ops instead of re-creating the message.
func (*Store) MarkMessageNotified ¶
MarkMessageNotified stamps notified_at after the approval-notification email is sent. Set only AFTER a successful send, so it is the send-dedup marker that makes a crash-after-send River re-drive a no-op without ever risking a lost notification (loss would require setting it before the send).
func (*Store) MarkMessageSent ¶
func (s *Store) MarkMessageSent(ctx context.Context, messageID, sentAs string, to, cc, bcc []string) error
MarkMessageSent records that an outbound message was accepted by the relay: delivery_status='sent', the From identity actually used, and one message_recipients row per recipient (to/cc/bcc) at 'sent'. Called after a successful relay accept. Idempotent on the recipient rows.
func (*Store) MarkOutboundFailedTx ¶
func (s *Store) MarkOutboundFailedTx(ctx context.Context, tx pgx.Tx, messageID, detail string, source delivery.FailureSource) (*OutboundSentInfo, error)
MarkOutboundFailedTx records, within the caller's transaction, a terminal outbound send failure: delivery_status='failed' + delivery_detail + the failure provenance (delivery_failure_source — 'provider' for an explicit provider rejection, 'local' for an inferred failure; §3.1 correction only ever applies to 'local'). Returns the row + owning user for the caller to emit email.failed, or nil if the row is gone, already left accepted/sending, or carries provider-accept evidence — the CAS's `provider_accepted_at IS NULL` arm is the terminal-failure guard's last line: a row the provider demonstrably accepted can never be declared failed, no matter which caller races in (the caller settles it via ResolveOutboundProviderAcceptedTx instead). A stored delivery_detail (a deferred final attempt's diagnostic) is preferred over the caller's generic detail. A post-accept trash does not suppress terminal bookkeeping on the hidden row.
func (*Store) MarkOutboundSentTx ¶
func (s *Store) MarkOutboundSentTx(ctx context.Context, tx pgx.Tx, messageID, providerMessageID string) (*OutboundSentInfo, error)
MarkOutboundSentTx records, within the caller's transaction, that a claimed outbound message was submitted to the provider: delivery_status='sent', provider_message_id, and one message_recipients row per recipient at 'sent' (mirrors MarkMessageSent's recipient shape). sent_as is left as the accept-time value. Returns the row + owning user/domain for the caller to emit email.sent + meter usage, or nil if the row is gone or no longer in `sending`. Trashed rows remain eligible because trash may commit after the durable send claim.
func (*Store) MarkSendFailed ¶
MarkSendFailed records that the upstream send failed for messageID, so the next ClaimSendAttempt is allowed to take over and retry. Idempotent against double-call: only updates rows still 'attempting'.
func (*Store) MarkSendSucceeded ¶
MarkSendSucceeded records the result of a successful upstream send. Idempotent against double-call: only updates rows still 'attempting' so a stray re-Mark from a buggy caller cannot overwrite a previous success or revive a failed row.
func (*Store) MarkSendSucceededWithRetry ¶
func (s *Store) MarkSendSucceededWithRetry(messageID string, r SendResult) error
MarkSendSucceededWithRetry calls MarkSendSucceeded with bounded exponential backoff. The retry runs on a fresh detached context (15s budget) so that once SES has accepted the send, request-level cancellation (client disconnect, request timeout) cannot prevent us from durably recording that fact.
Closes the C4 audit finding: if MarkSendSucceeded's single attempt failed (transient DB blip — pool exhaustion, statement_timeout, momentary leader failover), the row stayed `attempting` and the 10-minute stale takeover would later re-invoke the upstream send, duplicating the email at the recipient. Most transient DB failures resolve well within the retry budget; the residual risk window shrinks from "any single blip" to "persistent DB failure for >7s during a specific 10-minute window."
Returns nil if any attempt succeeded; the last error otherwise. Callers should treat a returned error as a loud signal: the upstream send happened, but we couldn't record it — manual reconciliation against the SES Configuration Set events log may be needed.
func (*Store) MaxTemplatesForUser ¶
MaxTemplatesForUser returns the per-user cap from account_limits, or DefaultMaxTemplates when the user has no row.
func (*Store) MaxWebhooksForUser ¶
func (*Store) ModifyMessageLabels ¶
func (s *Store) ModifyMessageLabels(ctx context.Context, messageID, agentID string, add, remove []string) ([]string, error)
ModifyMessageLabels applies a delta — add then remove — to a message's labels[] in a single atomic statement. Returns the updated labels (deduplicated, sorted) so the caller can echo them back in the response without a second round-trip.
Inputs are assumed already normalized (lowercased, charset-validated, dedup'd within each list, e2a:* gated). The store layer:
- applies adds first, then removes (so a label in both lists ends up removed)
- rejects if the post-add total would exceed MaxLabelsPerMessage
- returns ErrMessageNotFound if the row is missing / trashed / cross-agent
The whole thing runs as one UPDATE so a concurrent PATCH from a second client can't observe a partial state.
func (*Store) PruneProcessedIntake ¶
PruneProcessedIntake deletes processed intake rows whose terminal time is older than olderThan. Safe because the raw MIME also lives in messages.raw_message once processed; failed rows are deliberately retained for inspection. Returns the count pruned. Called by the inbound retention periodic (QueueMaintenance).
func (*Store) PurgeDeletedAgents ¶
PurgeDeletedAgents hard-deletes agents whose trash retention has lapsed (deleted_at older than TrashRetention). One agent per transaction, its messages deleted explicitly BEFORE the agent row (not via ON DELETE CASCADE) so the storage-metering trigger — which resolves the owning user through the agent row — still reconciles account_usage; see DeleteAgent. Idempotent and interruption-safe: each agent commits independently, so a janitor timeout mid-backlog resumes next tick.
func (*Store) PurgeMessage ¶
PurgeMessage permanently deletes a message that is already in the trash ("delete forever" — the Gmail journey is delete → trash → delete forever, so a live message must be trashed first). Returns ErrNotInTrash for a live message, ErrMessageNotFound otherwise.
func (*Store) PutUnsubscribeToken ¶
func (s *Store) PutUnsubscribeToken(ctx context.Context, tokenHash []byte, userID, agentID, address string) error
PutUnsubscribeToken idempotently records a token hash's exact scope.
func (*Store) RecordDeliveryOutcome ¶
func (s *Store) RecordDeliveryOutcome(ctx context.Context, messageID, address string, status delivery.Status, detail string) error
RecordDeliveryOutcome upserts one recipient's status monotonically (by the delivery precedence) and recomputes the message's rollup delivery_status as the worst status across its recipients. Runs in a tx with FOR UPDATE so concurrent SNS events can't race the merge. Idempotent: a duplicate or older event is a no-op for the status (detail still refreshes on an equal/higher).
Correction rule (async-send-contract §3.1): when the message-level status is a locally inferred `failed` (delivery_failure_source 'local' or legacy NULL), a recomputed rollup that proves provider acceptance CORRECTS the row — the falsely-declared terminal failure from a final-attempt crash. A provider-confirmed `failed` is never revived, and feedback for an address outside the message's own envelope is ignored on a failed row so unrelated feedback can never resurrect a genuine failure.
func (*Store) RecordProviderAcceptEvidence ¶
func (s *Store) RecordProviderAcceptEvidence(ctx context.Context, messageID, sesMessageID string) error
RecordProviderAcceptEvidence durably notes that SES reported having accepted this message's submission (the SNS consumer calls it for every correlated post-acceptance notification) and repairs a provider_message_id lost to the SMTP-accept↔mark-sent crash window. Idempotent: the first-seen timestamp and a previously captured provider id are never overwritten. The terminal-failure guards (send worker, terminal reconciler, MarkOutboundFailedTx's CAS) read this evidence before declaring an accepted/sending row failed.
func (*Store) RejectInboundReview ¶
func (s *Store) RejectInboundReview(ctx context.Context, messageID, agentID, reviewerID, reason string) error
RejectInboundReview drops a held inbound message (status review_rejected → stays hidden from the agent). Scoped to agentID for tenant isolation; reviewerID identifies the human; reason is operator-facing. The raw payload is retained (hidden) indefinitely for security forensics unless the message is deleted.
func (*Store) RejectPending ¶
func (s *Store) RejectPending(ctx context.Context, messageID, userID, reason string) (*Message, error)
RejectPending transitions a pending_review message to rejected, records the reviewer's reason (empty string allowed), and retains outbound content. Ownership checked; missing rows return ErrMessageNotFound. Non-pending rows return ErrNotPendingApproval.
func (*Store) ReleaseOutboundSendClaim ¶
ReleaseOutboundSendClaim moves a side-effect-free provider failure back to accepted so River backoff is not mistaken for an active provider call.
func (*Store) RemoveAgentSuppression ¶
func (s *Store) RemoveAgentSuppression(ctx context.Context, userID, agentID, address string) (bool, error)
RemoveAgentSuppression deletes only the exact account/agent/address row.
func (*Store) RemoveSuppression ¶
RemoveSuppression deletes a (user, address) suppression. found=false when no such entry existed.
func (*Store) ResolveOutboundOwner ¶
func (s *Store) ResolveOutboundOwner(ctx context.Context, messageID string) (userID, agentID string, err error)
ResolveOutboundOwner looks up the user_id and agent_id for an outbound message without requiring the caller to know the user_id up-front. It exists for token-authenticated paths (magic-link approve/reject) where the HMAC token itself is the authorization and the handler just needs enough context to dispatch into the existing user-scoped store methods.
Returns ErrMessageNotFound if the message doesn't exist or isn't outbound. The returned user_id is guaranteed to own the message's agent (via the agent_identities.user_id join).
func (*Store) ResolveOutboundProviderAcceptedTx ¶
func (s *Store) ResolveOutboundProviderAcceptedTx(ctx context.Context, tx pgx.Tx, messageID string) (*OutboundSentInfo, string, error)
ResolveOutboundProviderAcceptedTx settles, within the caller's transaction, a pre-terminal outbound row for which authoritative provider-accept evidence has been recorded (provider_accepted_at, written by the SNS consumer): delivery_status='sent' with the evidence-repaired provider id + the standard per-recipient 'sent' rows. This is the terminal-failure guard's positive branch (async-send-contract §3.1 / pipeline §7): a final attempt or the terminal reconciler about to declare `failed` first settles the row as sent when the provider demonstrably has the message. Returns (nil, "", nil) when the row is gone, already sent/terminal, or carries no evidence — the caller proceeds to the failure write, whose own CAS re-checks the evidence.
func (*Store) ResolveUnsubscribeToken ¶
func (s *Store) ResolveUnsubscribeToken(ctx context.Context, tokenHash []byte) (*UnsubscribeScope, error)
ResolveUnsubscribeToken resolves a token hash, returning nil for an unknown token without exposing the stored hash.
func (*Store) RestoreAgent ¶
RestoreAgent brings a trashed agent back to life, messages and config intact. Live messages never expire; for still-held drafts only approval_expires_at is shifted forward by the time spent in the trash so a hold cannot silently lapse while the inbox is trashed. Returns ErrNotInTrash when the agent exists but is live, and ErrAgentNotFound when it doesn't exist (or isn't the caller's).
func (*Store) RestoreMessage ¶
RestoreMessage brings an indefinitely retained trashed message back to the inbox. Returns ErrNotInTrash when the message exists but is live, ErrMessageNotFound otherwise.
func (*Store) RotateSecret ¶
func (s *Store) RotateSecret(ctx context.Context, webhookID, userID string) (newPlaintext string, prevExpiresAt time.Time, err error)
RotateSecret generates a new signing secret, moves the current secret into signing_secret_prev with a 24h expiry, and returns the new plaintext (shown once). During the 24h grace window the delivery worker dual-signs each request so receivers can verify with either secret while they update their handler.
func (*Store) SendingProvisionInputs ¶
func (s *Store) SendingProvisionInputs(ctx context.Context, domain string) (selector string, privateKeyDER []byte, ok bool, err error)
SendingProvisionInputs returns the per-domain DKIM selector + private key for BYODKIM provisioning. ok=false means no usable key material. Like GetDKIMKeyInternal this is unscoped — call only with a server-resolved domain.
func (*Store) SetAccountClass ¶
SetAccountClass sets a user's account_class (standard|internal|system|demo). Used by the prober's seed to mark the synthetic probe account as system so its traffic is never metered (see usage.PolicyFor). The CHECK constraint in migration 037 rejects any other value.
func (*Store) SetDKIMCipher ¶
func (s *Store) SetDKIMCipher(c *DKIMCipher)
SetDKIMCipher enables envelope encryption of DKIM private keys at rest (#144). Optional-setter (matches SetEnforcer) so NewStore's signature — and the many tests that call NewStore(pool) — stay unchanged. When unset, keys are stored as plaintext DER. cmd/e2a always sets it in production, where Signing.HMACSecret is enforced ≥32 bytes.
func (*Store) SetMessageScreening ¶
func (s *Store) SetMessageScreening(ctx context.Context, messageID, agentID, reviewReason string, scanScore *float64, scanAction string) error
SetMessageScreening denormalizes a screening verdict onto an already-created message row (review_reason / scan_score / scan_action). The inbound path sets these at INSERT time; the outbound path creates the row first (send or HITL hold) and annotates after, so it needs this UPDATE. Agent-scoped to keep the write within the owning tenant. scanScore may be nil (gate-only verdicts).
func (*Store) SetSendingStatus ¶
func (s *Store) SetSendingStatus(ctx context.Context, domain, status, dkimStatus, mailFromStatus, errMsg string, recordsJSON []byte) error
SetSendingStatus writes the sending lifecycle state for a domain and stamps sending_last_checked_at. recordsJSON may be nil (cleared). dkimStatus and mailFromStatus are the per-axis SES breakdown (migration 049); an empty string for either is written as SQL NULL so the read path falls back to the all-or-nothing sending_status rollup (and the CHECK constraint, which allows NULL but not ”, is satisfied).
func (*Store) SoftDeleteAgent ¶
SoftDeleteAgent moves a live agent to the trash (docs/design/ trash-soft-delete.md): it disappears from every live lookup — inbound mail bounces, per-agent API calls 404, its held messages leave the review queue — until restored or purged by the janitor after TrashRetention. The email address stays reserved while in the trash.
func (*Store) SoftDeleteMessage ¶
SoftDeleteMessage moves a live message to the trash. Idempotent on an already-trashed message (nil). A held message (status pending_review, either direction) cannot be trashed — the review queue is its resolution surface — and returns ErrMessageHeld. A missing message returns ErrMessageNotFound.
func (*Store) StampInboundIntakeJobIDTx ¶
func (s *Store) StampInboundIntakeJobIDTx(ctx context.Context, tx pgx.Tx, intakeID string, jobID int64) error
StampInboundIntakeJobIDTx records the River job id on the intake row, in the same accept-tx as the insert + enqueue (so a committed accepted row always has its job).
func (*Store) StampNotifyJobIDTx ¶
func (s *Store) StampNotifyJobIDTx(ctx context.Context, tx pgx.Tx, messageID string, jobID int64) error
StampNotifyJobIDTx records the River QueueNotify job id on the pending_review message, within the hold accept-tx, so the notification reconciler can find rows stranded without a job (pending_review AND notify_job_id IS NULL). Mirrors StampSendJobIDTx.
func (*Store) StampSendJobIDTx ¶
func (s *Store) StampSendJobIDTx(ctx context.Context, tx pgx.Tx, messageID string, jobID int64) error
StampSendJobIDTx records the River outbound_send job id on the accepted message, within the accept-tx, so the async-send reconciler can find stranded rows ('accepted' with send_job_id IS NULL). Mirrors the webhook_subscriber_deliveries .job_id stamp.
func (*Store) SuppressedAddresses ¶
func (s *Store) SuppressedAddresses(ctx context.Context, userID string, addrs []string) ([]string, error)
SuppressedAddresses returns the subset of addrs that are suppressed for the user — the send-time enforcement read. Empty input → empty result.
func (*Store) TouchDomainLastChecked ¶
TouchDomainLastChecked records that the verification probe ran. Call this from POST /v1/domains/{domain}/verify whether the probe succeeded or not — the LastCheckedAt column is "when did we last try", not "when did we last succeed" (the latter is verified_at).
func (*Store) TouchSendingChecked ¶
TouchSendingChecked stamps sending_last_checked_at without changing status (a still-pending poll).
func (*Store) UpdateAgentHITL ¶
func (s *Store) UpdateAgentHITL(ctx context.Context, agentID, userID string, ttlSeconds int, expirationAction string) error
UpdateAgentHITL updates all three HITL settings on an agent owned by userID. The TTL and expiration action are validated against the same rules as the DB CHECK constraints so callers get a clean error rather than a raw SQL error.
func (*Store) UpdateAgentInboundPolicy ¶
func (*Store) UpdateAgentName ¶
UpdateAgentName sets an agent's display name for an agent owned by userID. The name is a UI label only — the agent's identity is its email. Returns an error if the agent isn't found or not owned.
func (*Store) UpdateAgentProtection ¶
func (s *Store) UpdateAgentProtection(ctx context.Context, agentID, userID string, c ProtectionConfig) error
UpdateAgentProtection writes the full protection posture for an agent owned by userID in a single statement, validating first. It writes the sensitivity columns (the API source of truth) AND the derived scan toggle + float thresholds the piguard engine reads, so the two never diverge and the engine needs no change. Returns an error if the agent isn't found or not owned.
func (*Store) UpdateAgentScanConfig ¶
func (s *Store) UpdateAgentScanConfig(ctx context.Context, agentID, userID string, c ScanConfig) error
UpdateAgentScanConfig writes the full screening config for an agent owned by userID, validating the effective posture first. Returns an error if the agent isn't found or isn't owned by the user.
func (*Store) UpdateMessageDeliveryStatus ¶
func (s *Store) UpdateMessageDeliveryStatus(ctx context.Context, messageID, agentID, status string) error
UpdateMessageDeliveryStatus sets the inbox_status on a message.
func (*Store) UpdateTemplate ¶
func (s *Store) UpdateTemplate(ctx context.Context, templateID, userID string, u TemplateUpdate) (*Template, error)
UpdateTemplate applies a partial update to a template owned by the user. Only non-nil fields are touched; updated_at is always bumped. Returns the updated row, ErrTemplateNotFound for missing/cross-user rows, and ErrTemplateAliasTaken on an alias collision.
func (*Store) UpdateUserName ¶
UpdateUserName persists a new display name on the user row and returns the updated User. Input validation (length, whitespace) is the caller's responsibility — this layer only enforces that the row exists.
func (*Store) UpdateWebhook ¶
func (s *Store) UpdateWebhook(ctx context.Context, webhookID, userID string, u WebhookUpdate) (*Webhook, error)
UpdateWebhook applies a partial update to a webhook. Only fields with a non-nil pointer in WebhookUpdate are touched. Returns the updated row.
Validation (charset, count caps, agent ownership) is the handler's job; the storage layer enforces only the re-enable cooldown and the per-row CHECK constraints (events non-empty, url non-empty).
func (*Store) VerifyDomain ¶
VerifyDomain marks a domain as verified, only if owned by the given user.
func (*Store) WithTx ¶
WithTx opens a transaction, runs fn inside it, and commits if fn returns nil (or rolls back if fn returns an error). Used by the slice-3 relay refactor so the messages INSERT and the webhook_events outbox INSERT commit together, closing the at-least-once publish-loss window.
The relay handler is the primary v1 caller; future trigger sites (slice 4 outbound + HITL) reuse the same helper. Keeps callers from having to import pgxpool directly.
type Suppression ¶
type Suppression 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"`
}
Suppression is one (user, address) entry on the per-tenant suppression list.
type SuppressionExportEntry ¶
type SuppressionExportEntry struct {
AgentEmail string `json:"agent_email,omitempty"`
Address string `json:"address"`
Reason string `json:"reason,omitempty"`
Source string `json:"source"`
CreatedAt time.Time `json:"created_at"`
} // @name SuppressionExportEntry
SuppressionExportEntry is one suppressed recipient address the account owns. source_message_id is an internal correlation id, deliberately omitted.
type Template ¶
type Template struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Name string `json:"name"`
Alias string `json:"alias,omitempty"`
Subject string `json:"subject"`
Body string `json:"body"`
HTMLBody string `json:"html_body,omitempty"`
// FromStarterAlias/FromStarterVersion record which starter master (and
// at what catalog version) the template was copied from at create time;
// "" (SQL NULL) for literal creates. Read-only provenance — edits don't
// clear it.
FromStarterAlias string `json:"from_starter_alias,omitempty"`
FromStarterVersion string `json:"from_starter_version,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
Template is one row in the templates table. Alias and HTMLBody use "" for SQL NULL (no alias / no HTML part) — the write path stores NULL via nullIfEmpty so the partial unique index on (user_id, alias) never sees empty-string collisions.
type TemplateCreate ¶
type TemplateCreate struct {
Name string
Alias string
Subject string
Body string
HTMLBody string
// FromStarterAlias/FromStarterVersion are set only when the template is
// copied from a starter master (from_starter on the create endpoint).
FromStarterAlias string
FromStarterVersion string
}
TemplateCreate carries the fields of a new template. Alias, HTMLBody and the FromStarter* provenance pair may be "" (stored as SQL NULL).
type TemplateSummary ¶
type TemplateSummary struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Name string `json:"name"`
Alias string `json:"alias,omitempty"`
Subject string `json:"subject"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
TemplateSummary is the list-shape row: metadata only, no body sources. The list SELECT skips body/html_body on purpose — a full library of maximal templates would otherwise ship megabytes per list call, and every list consumer only needs metadata (starter-templates list/detail precedent). Fetch by id for the sources.
type TemplateUpdate ¶
type TemplateUpdate struct {
Name *string
Alias *string
Subject *string
Body *string
HTMLBody *string
}
TemplateUpdate carries the fields a PATCH can change. All fields are pointers so handlers can distinguish "set to X" (including clearing the alias or HTML part with an empty string, stored as NULL) from "leave unchanged".
type UnsubscribeScope ¶
UnsubscribeScope is the exact account, sending agent, and recipient bound to an opaque managed-unsubscribe token.
type UsageEventEntry ¶
type UsageEventEntry struct {
ID string `json:"id"`
AgentID string `json:"agent_email"`
Domain string `json:"domain"`
Direction string `json:"direction"`
EventType string `json:"type"`
CreatedAt time.Time `json:"created_at"`
} // @name UsageEventEntry
UsageEventEntry is one row of the usage_events table for the user.
type User ¶
type User struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
GoogleSubject string `json:"-"`
CreatedAt time.Time `json:"created_at"`
// AccountClass (usage.AccountClass) is loaded at auth for metering and
// rate-limit decisions. Hidden from API JSON. Empty for principals not
// resolved via API key (e.g. session auth), which fail-closes to standard
// (metered + limited) in the consuming policies.
AccountClass string `json:"-"`
}
type UserExport ¶
type UserExport struct {
GeneratedAt time.Time `json:"generated_at"`
SchemaVersion string `` /* 489-byte string literal not displayed */
User UserExportUser `json:"user"`
Domains []Domain `json:"domains" nullable:"false"`
Agents []AgentIdentity `json:"agents" nullable:"false"`
APIKeys []APIKeyExportEntry `json:"api_keys" nullable:"false"`
Messages []Message `json:"messages" nullable:"false"`
Suppressions []SuppressionExportEntry `json:"suppressions" nullable:"false"`
ProtectionEvents []ProtectionEventExportEntry `json:"protection_events" nullable:"false"`
UsageEvents []UsageEventEntry `json:"usage_events,omitempty" nullable:"false"`
OAuthConnections []OAuthConnectionEntry `json:"oauth_connections,omitempty" nullable:"false"`
} // @name UserExport
UserExport is the structured dump returned by ExportUserData. It's designed to be a complete, machine-readable record of everything a single user owns in the system — the right-of-access counterpart to DeleteUserData below. Sensitive secrets are deliberately excluded:
- API key plaintexts are not stored anywhere (only hashes), so they can't be exported even in principle.
- google_subject is an internal OAuth identifier with no value to the user and is omitted on purpose.
- User session tokens are transient and excluded.
type UserExportUser ¶
type UserExportUser struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
} // @name UserExportUser
UserExportUser mirrors User but omits the google_subject internal identifier from the export payload.
type Webhook ¶
type Webhook struct {
ID string `json:"id"`
UserID string `json:"user_id"`
URL string `json:"url"`
Description string `json:"description"`
Events []string `json:"events"`
Filters WebhookFilters `json:"filters"`
SigningSecret string `json:"signing_secret,omitempty"`
SigningSecretPrev string `json:"-"`
SigningSecretPrevExpiresAt *time.Time `json:"-"`
Enabled bool `json:"enabled"`
AutoDisabledAt *time.Time `json:"auto_disabled_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
LastDeliveredAt *time.Time `json:"last_delivered_at,omitempty"`
}
Webhook is one row in the webhooks table.
SigningSecret carries the plaintext secret. It's populated on CreateWebhook responses (the caller's one chance to see the secret) and read by the delivery worker when signing the X-E2A-Signature header. Public API GET endpoints in slice 2 will scrub this field before responding so a stolen API key cannot exfiltrate webhook secrets via list/get.
SigningSecretPrev + SigningSecretPrevExpiresAt hold the previous secret during the 24h rotation grace window; slice 4 dual-signs using both during that window so receivers can roll forward.
type WebhookFilters ¶
type WebhookFilters struct {
AgentIDs []string `json:"agent_ids,omitempty"`
ConversationIDs []string `json:"conversation_ids,omitempty"`
Labels []string `json:"labels,omitempty"`
}
WebhookFilters is the structured form of webhooks.filters JSONB. Empty / nil slices mean "no constraint of that type" — a webhook with all-empty filters is a cross-cutting subscriber that matches every event of the right type for the owning user.
type WebhookIdemCompleter ¶
WebhookIdemCompleter completes a keyed create's idempotency row inside the webhook-insert transaction, so the new subscription and its cached replay response commit atomically — a crash after the commit replays the first response (same webhook id + one-time secret) instead of re-creating. Same shape as agent.AcceptIdemCompleter on the async accept path. nil = no idempotency (unkeyed create).
type WebhookUpdate ¶
type WebhookUpdate struct {
URL *string
Description *string
Events *[]string
Filters *WebhookFilters
Enabled *bool
}
WebhookUpdate carries the fields a PATCH can change. All fields are pointers (or "set-or-leave" flags) so handlers can distinguish "field present, set to X" from "field not present, leave unchanged".
Per the design, url / events / filters are full-replace fields (the sent value is canonical when present). Enabled is a toggle. Re-enable has a 5-minute cooldown — UpdateWebhook returns ErrWebhookCooldown when the caller tries to flip Enabled true within 5 minutes of auto_disabled_at.