diff --git a/api/openapi.yaml b/api/openapi.yaml index 3d49b67b7..f4e240e0c 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1984,7 +1984,7 @@ components: additionalProperties: true properties: code: - description: "Machine-branchable error code — the stable discriminator clients switch on. Open set: treat it as a string and tolerate unknown values, since new codes may be added over time (branch on the ones you handle, fall back to the HTTP status otherwise). Exact current vocabulary (machine-checked): unauthorized, forbidden, blocked_by_policy, invalid_request, invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope, reserved_domain, too_many_recipients, template_render_failed, template_rendered_empty, recipient_suppressed, not_found, attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found, gone, conflict, precondition_failed, agent_taken, domain_taken, alias_taken, address_in_trash, message_held, message_not_pending, message_not_yet_delivered, not_in_trash, purge_in_progress, send_in_progress, webhook_disabled, webhook_cooldown, domain_not_registered, domain_has_agents, domain_not_verified, inbound_mx_missing, limit_exceeded, rate_limited, contact_limit_reached, template_limit_reached, webhook_limit_reached, idempotency_in_flight, idempotency_key_reuse, payload_too_large, attachment_too_large, not_implemented, events_log_disabled, limits_unavailable, inbound_mx_check_failed, auth_unavailable, internal_error, method_not_allowed, unsupported_media_type, error. Grouped semantics: auth: unauthorized (401), forbidden (403), blocked_by_policy (403, outbound policy gate; experimental). Validation: invalid_request is the single canonical code for input-validation failures whether they arrive as 400 (malformed) or 422 (semantically invalid); field/resource-specific invalid_* refinements (invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope), reserved_domain, too_many_recipients, template_render_failed, template_rendered_empty (all 400); recipient_suppressed (422). Not found: not_found (404) plus the *_not_found family (attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found); gone (410, past retention). Conflict/state: conflict (409, generic), precondition_failed (412, optimistic-concurrency validator is stale), the *_taken family — the requested identifier is already claimed — (agent_taken, domain_taken, alias_taken, all 409), address_in_trash (409), message_held (409), message_not_pending (409), message_not_yet_delivered (409, retry after the source outbound message is sent), not_in_trash (409), purge_in_progress (409, permanent delete already claimed), send_in_progress (409), webhook_disabled (409), webhook_cooldown (409), domain_not_registered (400), domain_has_agents (400), domain_not_verified (400 on create-agent, 403 on send), inbound_mx_missing (400). Capacity: limit_exceeded (402, plan quota — see LimitExceededDetails), rate_limited (429, request rate — see RateLimitedDetails), contact_limit_reached, template_limit_reached and webhook_limit_reached (400, fixed per-account caps). Idempotency: idempotency_in_flight (409, wait then retry the byte-identical request), idempotency_key_reuse (422, caller bug — do not retry as-is). Size: payload_too_large (413, request body), attachment_too_large (413, inline fetch over the cap — use download_url). Availability: not_implemented (501, feature not available on this deployment), events_log_disabled (501), limits_unavailable (503), inbound_mx_check_failed (503), auth_unavailable (503, an auth backend — e.g. a delegated-token verifier or the identity store — could not judge the credential; retry). Server/fallback: internal_error (5xx), method_not_allowed (405), unsupported_media_type (415), and the generic code error for any otherwise-unmapped status." + description: "Machine-branchable error code — the stable discriminator clients switch on. Open set: treat it as a string and tolerate unknown values, since new codes may be added over time (branch on the ones you handle, fall back to the HTTP status otherwise). Exact current vocabulary (machine-checked): unauthorized, forbidden, blocked_by_policy, sending_paused, invalid_request, invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope, reserved_domain, too_many_recipients, template_render_failed, template_rendered_empty, recipient_suppressed, not_found, attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found, gone, conflict, precondition_failed, agent_taken, domain_taken, alias_taken, address_in_trash, message_held, message_not_pending, message_not_yet_delivered, not_in_trash, purge_in_progress, send_in_progress, webhook_disabled, webhook_cooldown, domain_not_registered, domain_has_agents, domain_not_verified, inbound_mx_missing, limit_exceeded, rate_limited, contact_limit_reached, template_limit_reached, webhook_limit_reached, idempotency_in_flight, idempotency_key_reuse, payload_too_large, attachment_too_large, not_implemented, events_log_disabled, limits_unavailable, inbound_mx_check_failed, auth_unavailable, internal_error, method_not_allowed, unsupported_media_type, error. Grouped semantics: auth: unauthorized (401), forbidden (403), blocked_by_policy (403, outbound policy gate; experimental), sending_paused (403, outbound sending is paused for the account by the platform abuse controls; queued mail is held, new sends are refused until an operator resumes; experimental). Validation: invalid_request is the single canonical code for input-validation failures whether they arrive as 400 (malformed) or 422 (semantically invalid); field/resource-specific invalid_* refinements (invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope), reserved_domain, too_many_recipients, template_render_failed, template_rendered_empty (all 400); recipient_suppressed (422). Not found: not_found (404) plus the *_not_found family (attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found); gone (410, past retention). Conflict/state: conflict (409, generic), precondition_failed (412, optimistic-concurrency validator is stale), the *_taken family — the requested identifier is already claimed — (agent_taken, domain_taken, alias_taken, all 409), address_in_trash (409), message_held (409), message_not_pending (409), message_not_yet_delivered (409, retry after the source outbound message is sent), not_in_trash (409), purge_in_progress (409, permanent delete already claimed), send_in_progress (409), webhook_disabled (409), webhook_cooldown (409), domain_not_registered (400), domain_has_agents (400), domain_not_verified (400 on create-agent, 403 on send), inbound_mx_missing (400). Capacity: limit_exceeded (402, plan quota — see LimitExceededDetails), rate_limited (429, request rate — see RateLimitedDetails), contact_limit_reached, template_limit_reached and webhook_limit_reached (400, fixed per-account caps). Idempotency: idempotency_in_flight (409, wait then retry the byte-identical request), idempotency_key_reuse (422, caller bug — do not retry as-is). Size: payload_too_large (413, request body), attachment_too_large (413, inline fetch over the cap — use download_url). Availability: not_implemented (501, feature not available on this deployment), events_log_disabled (501), limits_unavailable (503), inbound_mx_check_failed (503), auth_unavailable (503, an auth backend — e.g. a delegated-token verifier or the identity store — could not judge the credential; retry). Server/fallback: internal_error (5xx), method_not_allowed (405), unsupported_media_type (415), and the generic code error for any otherwise-unmapped status." type: string x-e2a-error-contracts: address_in_trash: @@ -2259,6 +2259,11 @@ components: retryable: false statuses: - 409 + sending_paused: + family: auth + retryable: false + statuses: + - 403 starter_template_not_found: family: not_found retryable: false @@ -2317,6 +2322,7 @@ components: - 400 x-experimental-values: - blocked_by_policy + - sending_paused details: additionalProperties: true description: Optional structured context, polymorphic by code. Treat it as an open object keyed off code; unknown codes and fields must be preserved. @@ -2891,6 +2897,8 @@ components: - submission.provider_rejected - submission.local_retries_exhausted - submission.cancelled + - submission.policy_budget_expired + - submission.sending_setup_expired - delivery.recipient_server_accepted - delivery.temporary_delay - delivery.permanent_bounce diff --git a/cmd/e2a/main.go b/cmd/e2a/main.go index aa8ec21a7..7e13c1726 100644 --- a/cmd/e2a/main.go +++ b/cmd/e2a/main.go @@ -38,7 +38,6 @@ import ( "github.com/tokencanopy/e2a/internal/limits" "github.com/tokencanopy/e2a/internal/oauth" "github.com/tokencanopy/e2a/internal/outbound" - "github.com/tokencanopy/e2a/internal/outboundsend" "github.com/tokencanopy/e2a/internal/relay" "github.com/tokencanopy/e2a/internal/senderidentity" "github.com/tokencanopy/e2a/internal/sendingpolicy" @@ -343,29 +342,28 @@ func main() { // Outbound delivery is queue-first and at-least-once for GA. The accept-tx // enqueues an outbound_send job in the same transaction as the message row; - // there is no submit-inline fallback. + // there is no submit-inline fallback. Every provider call passes through + // the sending-protection gate and the authorized submitter — see + // newOutboundSending, whose wiring test pins that composition. rampStore := sendramp.NewStore(pool) - outboundRamp := agent.NewOutboundRampGate( - rampStore, - sendramp.NewSchedule(cfg.SendingRamp.StartDaily, cfg.SendingRamp.TargetDaily, cfg.SendingRamp.RampDays), - cfg.SendingRamp.Enabled, - ) - if cfg.SendingRamp.Enabled { - log.Printf("Outbound sending ramp enabled: %d→%d recipients over %d qualified days", cfg.SendingRamp.StartDaily, cfg.SendingRamp.TargetDaily, cfg.SendingRamp.RampDays) - } outboundSendStore := agent.NewOutboundSendStore(store, webhookOutbox, usageTracker) store.SetScheduledSendFinalizer(outboundSendStore) - outboundJobs := outboundsend.NewJobs( - outboundSendStore, - agent.NewOutboundDeliverer(sender), - pool, - outboundRamp, - ).WithMetrics(metrics). + outboundSending := newOutboundSending(outboundSendingDeps{ + pool: pool, + store: outboundSendStore, + relay: smtpRelay, + secrets: spSecrets, + source: spSource, + policy: spPolicy, + sesConfigSet: cfg.DeliveryFeedback.SESConfigurationSet, + metrics: metrics, // Fire-time per-agent rate limit (60 submissions/min/agent sliding // window, durable in Postgres): the cross-replica counterpart of the // acceptance-time in-memory limiter, enforced immediately before // provider submission so scheduled-send bursts can't exceed it. - WithRateGate(sendrate.NewStore(pool, time.Minute, 60)) + rate: sendrate.NewStore(pool, time.Minute, 60), + }) + outboundJobs := outboundSending.jobs registrars = append(registrars, outboundJobs) registrars = append(registrars, sendramp.NewMaintenanceJobs(rampStore)) // Queue depth/age gauges: a 30s maintenance periodic sampling river_job diff --git a/cmd/e2a/outbound_wiring.go b/cmd/e2a/outbound_wiring.go new file mode 100644 index 000000000..462cdea66 --- /dev/null +++ b/cmd/e2a/outbound_wiring.go @@ -0,0 +1,51 @@ +package main + +import ( + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/tokencanopy/e2a/internal/agent" + "github.com/tokencanopy/e2a/internal/outbound" + "github.com/tokencanopy/e2a/internal/outboundsend" + "github.com/tokencanopy/e2a/internal/sendingpolicy" +) + +// outboundSendingDeps is everything the outbound composition root needs. It +// is a struct rather than positional arguments so the wiring test can build +// the production composition from synthetic inputs and inspect the result. +type outboundSendingDeps struct { + pool *pgxpool.Pool + store outboundsend.Store + relay *outbound.SMTPRelay + secrets sendingpolicy.Secrets + source sendingpolicy.PolicySource + policy sendingpolicy.RuntimePolicy + sesConfigSet string + metrics outboundsend.Metrics + rate outboundsend.RateGate +} + +// outboundSending is the composed outbound send path. +type outboundSending struct { + gate sendingpolicy.Gate + submitter *outbound.ProviderSubmitter + jobs *outboundsend.Jobs +} + +// newOutboundSending is the ONE composition root for provider-bound customer +// mail. The gate is the deployment's policy authority; the submitter is the +// only object that opens a socket to the provider and it refuses to do so +// without a token from that gate; the jobs bundle prepares an operation at +// enqueue and authorizes every worker execution through the same gate. No +// raw sender and no direct ramp store reach the worker from here. +func newOutboundSending(d outboundSendingDeps) outboundSending { + gate := sendingpolicy.NewGate(d.pool, d.secrets, d.source, d.policy) + submitter := outbound.NewProviderSubmitter(d.relay, gate) + // Delivery feedback: tag outbound with the SES configuration set so SES + // publishes delivery/bounce/complaint events. Empty = off. + submitter.SetSESConfigurationSet(d.sesConfigSet) + jobs := outboundsend.NewJobs(d.store, agent.NewOutboundDeliverer(submitter), d.pool). + WithGate(gate). + WithMetrics(d.metrics). + WithRateGate(d.rate) + return outboundSending{gate: gate, submitter: submitter, jobs: jobs} +} diff --git a/cmd/e2a/sending_policy_wiring_test.go b/cmd/e2a/sending_policy_wiring_test.go new file mode 100644 index 000000000..a7eebc1a4 --- /dev/null +++ b/cmd/e2a/sending_policy_wiring_test.go @@ -0,0 +1,78 @@ +package main + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/riverqueue/river" + + "github.com/tokencanopy/e2a/internal/config" + "github.com/tokencanopy/e2a/internal/outbound" + "github.com/tokencanopy/e2a/internal/sendingpolicy" + "github.com/tokencanopy/e2a/internal/testutil/testdb" +) + +// TestSendingPolicyWiring builds the production outbound composition from +// synthetic inputs and proves the registered send path holds the concrete +// Gate and the authorized submitter. It exists so that a refactor that +// reintroduced a raw sender or a direct ramp gate in the worker's path could +// not pass CI: the only deliverer the composition root may produce is the one +// over outbound.ProviderSubmitter, and the only admission authority is the +// sendingpolicy module. +func TestSendingPolicyWiring(t *testing.T) { + pool := testdb.TestDB(t) + relay := outbound.NewSMTPRelay(&config.OutboundSMTPConfig{Host: "relay.invalid", Port: 587, FromDomain: "test.e2a.dev"}) + + composed := newOutboundSending(outboundSendingDeps{ + pool: pool, + store: nil, // the store is not exercised by construction + relay: relay, + secrets: sendingpolicy.Secrets{}, + source: sendingpolicy.PolicySourceConfig, + policy: sendingpolicy.DisabledPolicy(), + sesConfigSet: "e2a-delivery-test", + }) + + if _, ok := composed.gate.(*sendingpolicy.Module); !ok { + t.Fatalf("gate is %T, want the concrete *sendingpolicy.Module", composed.gate) + } + if composed.submitter == nil { + t.Fatal("no authorized submitter composed") + } + if got := composed.submitter.SESConfigurationSet(); got != "e2a-delivery-test" { + t.Fatalf("submitter configuration set = %q, want the deployment's — delivery feedback must stay on", got) + } + if composed.jobs.Gate() != composed.gate { + t.Fatal("the jobs bundle does not hold the composed gate") + } + // The worker RegisterJobs registers is what runs in production; it, not + // the bundle, must carry the gate and the legacy resolver. Without the + // resolver every job in flight at cutover would fail closed. + // Register exactly as main does and inspect what River received — the + // constructor alone would not catch a RegisterJobs that bypassed it. + composed.jobs.RegisterJobs(river.NewWorkers()) + worker := composed.jobs.RegisteredSendWorker() + if worker == nil { + t.Fatal("RegisterJobs registered no send worker") + } + if worker.Gate() != composed.gate { + t.Fatal("the registered send worker does not hold the composed gate") + } + if !worker.HasOperationResolver() { + t.Fatal("the registered send worker has no legacy operation resolver") + } + if composed.jobs.TerminalReconcileWorker() == nil { + t.Fatal("no terminal reconciler composed") + } + if got := fmt.Sprintf("%T", composed.jobs.Deliverer()); !strings.HasSuffix(got, "agent.outboundDeliverer") { + t.Fatalf("worker deliverer is %s, want the ProviderSubmitter-backed agent.outboundDeliverer", got) + } + + // The composed gate is live: a config-source module answers policy reads + // against the real database, which is what the worker will do. + if _, err := composed.gate.LookupOperation(context.Background(), "op_wiring_probe"); err == nil { + t.Fatal("a never-prepared operation resolved") + } +} diff --git a/docs/api.md b/docs/api.md index c4aad7739..db8b9b960 100644 --- a/docs/api.md +++ b/docs/api.md @@ -85,7 +85,8 @@ stable field are beta, `x-experimental-values` on that field): the screening + review-hold event types (`email.flagged`, `email.blocked`, `email.review_requested`, `email.review_approved`, `email.review_rejected` — marked via `x-experimental-values` on the stable `type` field). The stable -`error.code` vocabulary likewise marks only `blocked_by_policy` experimental. +`error.code` vocabulary likewise marks only `blocked_by_policy` and +`sending_paused` experimental. See [events.md](events.md). The exact operation-level list is repeated with methods and paths in @@ -313,6 +314,7 @@ retryable ones (the per-row retry notes in the table below are authoritative). | `unauthorized` | 401 | Missing or invalid credentials (REST and the WebSocket handshake). | | `forbidden` | 403 | Authenticated but not allowed (key scope, cross-tenant access). | | `blocked_by_policy` | 403 | **Experimental.** The outbound message was blocked by the agent's outbound policy gate. | +| `sending_paused` | 403 | **Experimental.** Outbound sending is paused for the account by the platform abuse controls. Nothing was queued; queued mail is held until an operator resumes. | | **Validation** | | | | `invalid_request` | 400 / 422 | The canonical input-validation code — malformed (400) or semantically invalid (422). `error.details` carries the per-field list. | | `invalid_cursor` | 400 | Bad pagination cursor — drop it and re-fetch from the start. | @@ -465,7 +467,8 @@ every `/v1` operation not listed here is covered by the GA freeze. `x-experimental-values` listing exactly those values — the field itself stays stable, the listed values (and their payloads) may still change, and every unlisted value is stable. The stable `ErrorBody.code` discriminator - similarly marks only `blocked_by_policy` experimental. Anything not marked + similarly marks only `blocked_by_policy` and `sending_paused` experimental. + Anything not marked beta or experimental is stable surface. One deliberate schema-level use of the beta marker under a **stable** operation: the account export's interior record schemas (`GET /v1/account/export`) are beta-marked because they are @@ -859,6 +862,8 @@ retryability; clients must not reinterpret those fields independently: | `submission.provider_rejected` | `submission` | `failed` | false | | `submission.local_retries_exhausted` | `submission` | `failed` | true | | `submission.cancelled` | `submission` | `failed` | false | +| `submission.policy_budget_expired` | `submission` | `failed` | true | +| `submission.sending_setup_expired` | `submission` | `failed` | true | | `delivery.recipient_server_accepted` | `delivery` | `delivered` | false | | `delivery.temporary_delay` | `delivery` | `deferred` | true | | `delivery.permanent_bounce` | `delivery` | `bounced` | false | diff --git a/docs/design/async-message-pipeline.md b/docs/design/async-message-pipeline.md index 1a27614d0..7aff7c3de 100644 --- a/docs/design/async-message-pipeline.md +++ b/docs/design/async-message-pipeline.md @@ -256,3 +256,39 @@ Still open: 6. **Residual-window reconciler** (header-tagged SNS feedback vs a `sending` row): ~~alert-only v1, auto-heal later~~ **shipped as auto-heal (2026-07-16)**: header-tagged evidence is recorded on the row, the re-driven worker/terminal reconciler settles evidence-bearing `accepted`/`sending` rows as sent, and the §3.1 correction rule heals an already-written local `failed` when correlated delivery feedback arrives. 7. **Inbound (I2): raw-blob retention** — `river_job.args` holds full raw messages for pending inbound jobs; cap size / age-out policy for a backlog. 8. **`email.accepted` event — emit or not?** Currently **not** emitted: the caller learns `accepted` synchronously (the 200 body + `delivery_status='accepted'` on the row), and contract §4's *push* vocabulary is deliberately terminal-only (`sent`/`failed`/`deferred`). Optional addition: a one-line `PublishTx` of `email.accepted` in the accept-tx would populate the `webhook_events` log (visible in `GET /v1/events`) and deliver only to anyone who *explicitly* subscribes — harmless, but it widens the event vocabulary. Decide: accept-time event-log entry for observability vs. keep the push vocabulary terminal-only. (Leaning: skip at GA — the sync 200 already carries `accepted`; revisit if subscribers ask for an accept-time signal.) + +## Addendum (2026-09-05): the sending-protection gate owns admission + +Slice B6 of the sending abuse prevention plan (`e2a-ops` docs/superpowers) moved +every provider-bound decision behind `internal/sendingpolicy`'s `Gate`. The +worker-owned `RampGate` and `agent.NewOutboundRampGate` are gone; the +custom-domain ramp is composed inside the gate (B4) and the SMTP seam is the +token-requiring `outbound.ProviderSubmitter` (B5). The worker order is now +fixed: + +1. `Reserve` the durable attempt (idempotent per ordinal; a confirmed ordinal + is followed by a fresh one, allocated by the gate, never by the worker); +2. an early hold snoozes without provider I/O; +3. the per-agent rate gate `DeferAttempt`s and snoozes; a final suppression + match `CancelAttempt`s and fails; +4. `ConsumeAttempt` is the last serialized decision; a hold here is handled + like an early one; +5. the authorized submitter redeems the token immediately before the socket + opens and settles the provider's answer (`SettleProvider`); a lost 250 is + `ErrProviderAcceptanceUnknown` — retried as a new ordinal, never settled. + +The accept transaction prepares the operation (`PrepareExternalTx`) between the +message insert and the River insert; a paused account is refused at the door +(`ErrSendingPaused` → HTTP 403 `sending_paused`). Jobs enqueued by a pre-floor +slot carry no reference and are resolved at fire time through the same path +(`Jobs.ResolveLegacyOperation`). + +Finite holds persist `messages.local_hold_class` / `local_hold_anchor` +(migration 116); the deadline is always derived — 72 hours for +`rate_ramp_or_provider` and `tenant_setup`, seven days for `policy_budget` — +and expiry emits `submission.local_retries_exhausted`, +`submission.sending_setup_expired`, or `submission.policy_budget_expired` +respectively. An account pause has no clock and starts no hold, but a deadline +already running keeps running. Terminal reconciliation is settlement-only: an +evidence-settled row also settles the attempt that dialed +(`Gate.SettleOperation`). diff --git a/docs/events.md b/docs/events.md index 4ec7e0105..40ba564d4 100644 --- a/docs/events.md +++ b/docs/events.md @@ -106,7 +106,7 @@ The event-to-reason mapping is: |---|---| | `email.received` | `acceptance.inbound_smtp` (or `acceptance.local_loopback`); DMARC `pass` → `authentication.dmarc_pass`, DMARC `fail` → `authentication.dmarc_fail`, DMARC `none` → `authentication.dmarc_none`, DMARC `temperror` → `authentication.dmarc_temporary_error`, and DMARC `permerror` → `authentication.dmarc_permanent_error`; plus `queue.inbound_processing` when async intake was durably queued. | | `email.sent` | `submission.upstream_accepted` or `submission.local_loopback_accepted`. | -| `email.failed` | `submission.provider_rejected`, `submission.local_retries_exhausted`, or `submission.cancelled`, matching the terminal cause. Temporary attempts use `submission.temporary_failure` in the ledger but do not emit a terminal `email.failed` event. | +| `email.failed` | `submission.provider_rejected`, `submission.local_retries_exhausted`, `submission.cancelled`, `submission.policy_budget_expired`, or `submission.sending_setup_expired`, matching the terminal cause. Temporary attempts use `submission.temporary_failure` in the ledger but do not emit a terminal `email.failed` event. | | `email.delivered` | `delivery.recipient_server_accepted` for `delivered_to`. | | `email.bounced` | `delivery.permanent_bounce`, `delivery.transient_bounce`, or `delivery.undetermined_bounce` for `delivered_to`. | | `email.complained` | `complaint.recipient_reported` for `delivered_to`. | diff --git a/internal/agent/api.go b/internal/agent/api.go index 42bf91119..fe16be741 100644 --- a/internal/agent/api.go +++ b/internal/agent/api.go @@ -34,6 +34,7 @@ import ( "github.com/tokencanopy/e2a/internal/logredact" "github.com/tokencanopy/e2a/internal/oauth" "github.com/tokencanopy/e2a/internal/outbound" + "github.com/tokencanopy/e2a/internal/outboundsend" "github.com/tokencanopy/e2a/internal/piguard" "github.com/tokencanopy/e2a/internal/ratelimit" "github.com/tokencanopy/e2a/internal/telemetry" @@ -1607,6 +1608,12 @@ func (a *API) DeliverOutbound(ctx context.Context, user *identity.User, agent *i accepted = msg return nil }); txErr != nil { + if errors.Is(txErr, outboundsend.ErrSendingPaused) { + // The account is paused for sending abuse: refuse at the door + // rather than queue mail that can never leave. Nothing was + // committed — the message row rolled back with the job. + return nil, &OutboundError{Status: http.StatusForbidden, Code: "sending_paused", Msg: "sending is paused for this account"} + } log.Printf("[api] async accept tx failed: agent=%s to_count=%d to_domains=%v error=%v", agent.Domain, len(req.To), logredact.AddressDomains(req.To), txErr) return nil, &OutboundError{Status: http.StatusInternalServerError, Code: "internal_error", Msg: "failed to accept message for send"} } @@ -1728,6 +1735,9 @@ func (a *API) acceptPlatformSend(ctx context.Context, agent *identity.AgentIdent accepted = msg return nil }); txErr != nil { + if errors.Is(txErr, outboundsend.ErrSendingPaused) { + return nil, &OutboundError{Status: http.StatusForbidden, Code: "sending_paused", Msg: "sending is paused for this account"} + } log.Printf("[api] platform accept tx failed: agent=%s to_count=%d to_domains=%v error=%v", agent.Domain, len(req.To), logredact.AddressDomains(req.To), txErr) return nil, &OutboundError{Status: http.StatusInternalServerError, Code: "internal_error", Msg: "failed to accept message for send"} } diff --git a/internal/agent/hitl_api.go b/internal/agent/hitl_api.go index fe36bf636..38a7c7cd8 100644 --- a/internal/agent/hitl_api.go +++ b/internal/agent/hitl_api.go @@ -15,6 +15,7 @@ import ( "github.com/tokencanopy/e2a/internal/limits" "github.com/tokencanopy/e2a/internal/logredact" "github.com/tokencanopy/e2a/internal/outbound" + "github.com/tokencanopy/e2a/internal/outboundsend" ) // approveRequest is the JSON body accepted by the approve endpoint. Every @@ -280,6 +281,10 @@ func approveAsyncError(agentID, messageID string, err error) *OutboundError { return &OutboundError{Status: http.StatusConflict, Code: "message_not_pending", Msg: "message is not pending approval"} case errors.Is(err, identity.ErrMessageNotFound): return &OutboundError{Status: http.StatusNotFound, Code: "not_found", Msg: "message not found"} + case errors.Is(err, outboundsend.ErrSendingPaused): + // The draft stays pending_review (the approval transaction rolled + // back); the reviewer learns why rather than seeing a 500. + return &OutboundError{Status: http.StatusForbidden, Code: "sending_paused", Msg: "sending is paused for this account; the draft remains pending"} default: var ve *outbound.ValidationError if errors.As(err, &ve) { diff --git a/internal/agent/outbound_async.go b/internal/agent/outbound_async.go index 4408be993..55b46411d 100644 --- a/internal/agent/outbound_async.go +++ b/internal/agent/outbound_async.go @@ -2,6 +2,7 @@ package agent import ( "context" + "errors" "fmt" "hash/fnv" "log" @@ -17,7 +18,7 @@ import ( "github.com/tokencanopy/e2a/internal/messagelifecycle" "github.com/tokencanopy/e2a/internal/outbound" "github.com/tokencanopy/e2a/internal/outboundsend" - "github.com/tokencanopy/e2a/internal/sendramp" + "github.com/tokencanopy/e2a/internal/sendingpolicy" "github.com/tokencanopy/e2a/internal/usage" "github.com/tokencanopy/e2a/internal/webhookpub" ) @@ -106,71 +107,6 @@ func NewOutboundSendStore(store *identity.Store, outbox webhookpub.Outbox, usage return &outboundSendStore{store: store, outbox: outbox, usage: usageTracker} } -type outboundRampGate struct { - store *sendramp.Store - schedule sendramp.Schedule - enabled bool - now func() time.Time -} - -// NewOutboundRampGate adapts the durable sendramp store to the worker-owned -// gate contract. The schedule is snapshotted by Store on the first eligible -// send; config changes therefore affect only domains that have not armed yet. -func NewOutboundRampGate(store *sendramp.Store, schedule sendramp.Schedule, enabled bool, clocks ...func() time.Time) outboundsend.RampGate { - now := time.Now - if len(clocks) > 0 && clocks[0] != nil { - now = clocks[0] - } - return &outboundRampGate{store: store, schedule: schedule, enabled: enabled, now: now} -} - -func (g *outboundRampGate) Reserve(ctx context.Context, req outboundsend.RampRequest) (outboundsend.RampDecision, error) { - if !g.enabled { - // Disabled is pass-through: reserve nothing, count nothing, stamp - // nothing. The domain stays 'inactive'. - // - // An earlier revision stamped the domain 'exempt' here, reasoning that - // a sender allowed to send unthrottled must not be re-throttled if the - // ramp is later enabled. That turned every eligible send into a silent, - // unmarked grandfathering decision, and 'exempt' has since grown - // meaning beyond "skip the ramp": an exempt domain reads as an - // established sender, so it also stops consuming the shared probation - // pool that bounds Sybil abuse. Widening that set from the send path, - // once per send, is not a decision this gate gets to make. - // - // Grandfathering belongs to the audited one-shot that already exists - // for it: sendingpolicy's ActivationRequest.GrandfatherCurrentSendingDomains, - // which writes a replay marker, locks the domains table against - // concurrent sender transitions, and can never widen its set twice. - return outboundsend.RampDecision{Allowed: true}, nil - } - d, err := g.store.Reserve(ctx, sendramp.ReserveRequest{ - MessageID: req.MessageID, - UserID: req.UserID, - Domain: req.Domain, - Units: req.Units, - Day: g.now().UTC(), - Schedule: g.schedule, - }) - return outboundsend.RampDecision{Allowed: d.Allowed, RetryAt: d.RetryAt}, err -} - -// Confirm, Release and Resolve delegate unconditionally, including while the -// ramp is disabled: a reservation taken before an operator turned the ramp off -// still has to settle. With the ramp disabled no reservation is ever created, -// so on that path the store methods find no row and write nothing. -func (g *outboundRampGate) Confirm(ctx context.Context, messageID string) error { - return g.store.Confirm(ctx, messageID) -} - -func (g *outboundRampGate) Release(ctx context.Context, messageID string) error { - return g.store.Release(ctx, messageID) -} - -func (g *outboundRampGate) Resolve(ctx context.Context, messageID string) error { - return g.store.Resolve(ctx, messageID) -} - func (a *outboundSendStore) ClaimSend(ctx context.Context, messageID string, jobID int64) (*outboundsend.SendJob, error) { if a.usage == nil { return nil, fmt.Errorf("outbound usage tracker is required") @@ -210,7 +146,7 @@ func (a *outboundSendStore) ClaimSend(ctx context.Context, messageID string, job anchor = *p.ScheduledAt } if !anchor.IsZero() && time.Since(anchor) > outboundsend.SendRetryHorizon { - if _, _, failErr := a.MarkFailed(ctx, p.ID, jobID, 0, time.Now().UTC(), + if _, _, _, failErr := a.MarkFailed(ctx, p.ID, jobID, 0, time.Now().UTC(), "daily_send_cap_timeout: daily send limit still exceeded past the retry horizon", delivery.FailureSourceLocal, messagelifecycle.ReasonSubmissionLocalRetriesExhausted, nil); failErr != nil { return nil, failErr @@ -228,7 +164,7 @@ func (a *outboundSendStore) ClaimSend(ctx context.Context, messageID string, job log.Printf("[outbound-send:%s] daily send cap exhausted at fire time, deferring to %s", p.ID, retryAt.Format(time.RFC3339)) return nil, &outboundsend.DailyQuotaDeferredError{RetryAt: retryAt} } - if _, _, failErr := a.MarkFailed(ctx, p.ID, jobID, 0, time.Now().UTC(), + if _, _, _, failErr := a.MarkFailed(ctx, p.ID, jobID, 0, time.Now().UTC(), "send canceled: monthly send limit exceeded at send time", delivery.FailureSourceLocal, messagelifecycle.ReasonSubmissionCancelled, nil); failErr != nil { return nil, failErr @@ -258,9 +194,24 @@ func (a *outboundSendStore) ClaimSend(ctx context.Context, messageID string, job if p.ReviewedAt != nil { sj.ReviewedAt = *p.ReviewedAt } + sj.LocalHoldClass = outboundsend.HoldClass(p.LocalHoldClass) + if p.LocalHoldAnchor != nil { + sj.LocalHoldAnchor = *p.LocalHoldAnchor + } + if p.LastResumedAt != nil { + sj.LastResumedAt = *p.LastResumedAt + } + if p.TenantReadyAt != nil { + sj.TenantReadyAt = *p.TenantReadyAt + } return sj, nil } +// RecordHold persists the worker's finite-hold class and anchor on the row. +func (a *outboundSendStore) RecordHold(ctx context.Context, messageID string, class outboundsend.HoldClass, anchor time.Time) error { + return a.store.RecordOutboundHold(ctx, messageID, string(class), anchor) +} + // SuppressedRecipients backs the SendWorker's pre-provider suppression guard: // the effective account-wide + exact-agent subset (the store normalizes both // sides). @@ -416,7 +367,7 @@ func (a *outboundSendStore) FinalizeScheduledCancellationTx( // time is the occurred_at the write actually used: the provider-accept // evidence time on an evidence settle, the caller's occurredAt on a failure, // zero on a no-op. -func (a *outboundSendStore) MarkFailed(ctx context.Context, messageID string, jobID int64, attempt int, occurredAt time.Time, detail string, source delivery.FailureSource, reason messagelifecycle.ReasonCode, blockedRecipients []string) (delivery.Status, time.Time, error) { +func (a *outboundSendStore) MarkFailed(ctx context.Context, messageID string, jobID int64, attempt int, occurredAt time.Time, detail string, source delivery.FailureSource, reason messagelifecycle.ReasonCode, blockedRecipients []string) (delivery.Status, time.Time, string, error) { detail = messagelifecycle.SafeDiagnostic(detail) blockedRecipients = normalizeBlockedRecipients(blockedRecipients) var settled delivery.Status @@ -464,12 +415,12 @@ func (a *outboundSendStore) MarkFailed(ctx context.Context, messageID string, jo e.ID = webhookpub.DeterministicEventID(messageID, webhookpub.EventEmailFailed) return a.outbox.PublishTx(ctx, tx, e) }); err != nil { - return "", time.Time{}, err + return "", time.Time{}, "", err } if resolved != nil { log.Printf("[outbound-send] %s: terminal-failure guard settled as sent on provider evidence (provider id %q)", messageID, resolvedProviderID) } - return settled, settledAt, nil + return settled, settledAt, resolvedProviderID, nil } func (a *outboundSendStore) PreserveTerminalFailure(ctx context.Context, messageID string, jobID int64, attempt int, occurredAt time.Time, detail string, source delivery.FailureSource, reason messagelifecycle.ReasonCode, blockedRecipients []string) error { @@ -615,30 +566,39 @@ func buildEmailFailedEventFromRow(info *identity.OutboundSentInfo, detail string } } -// outboundDeliverer implements outboundsend.Deliverer over Sender.SubmitOnce — a -// single SMTP submit of the persisted Sent-folder bytes (River owns retries). +// outboundDeliverer implements outboundsend.Deliverer over the authorized +// provider seam (outbound.ProviderSubmitter): one token-redeeming SMTP submit +// of the persisted Sent-folder bytes (River owns retries). There is no +// tokenless path through here. type outboundDeliverer struct { - sender *outbound.Sender + submitter *outbound.ProviderSubmitter } // NewOutboundDeliverer builds the outboundsend.Deliverer adapter for main.go. -func NewOutboundDeliverer(sender *outbound.Sender) outboundsend.Deliverer { - return &outboundDeliverer{sender: sender} +func NewOutboundDeliverer(submitter *outbound.ProviderSubmitter) outboundsend.Deliverer { + return &outboundDeliverer{submitter: submitter} } -func (d *outboundDeliverer) Deliver(ctx context.Context, j *outboundsend.SendJob) outboundsend.DeliverOutcome { - providerID, err := d.sender.SubmitOnceContext(ctx, j.MessageID, j.EnvelopeFrom, j.Recipients, j.RawMessage) +func (d *outboundDeliverer) Deliver(ctx context.Context, j *outboundsend.SendJob, auth sendingpolicy.ProviderAuthorization) outboundsend.DeliverOutcome { + res, err := d.submitter.SubmitOnce(ctx, auth, outbound.Envelope{ + From: j.EnvelopeFrom, + Recipients: j.Recipients, + Message: j.RawMessage, + }) if err != nil { // Classify (design §8): a definitely-permanent 5xx is terminal (JobCancel); // a provider-connection failure (relay unreachable/misconfigured) is an - // outage → snooze without burning an attempt; everything else (4xx/unknown) - // takes the bounded retry. Terminal-failing a send that could still succeed - // would violate at-least-once. + // outage → snooze without burning an attempt; a failure after the body + // was handed over is acceptance-unknown; everything else (4xx/unknown) + // takes the bounded retry. Terminal-failing a send that could still + // succeed would violate at-least-once. + unknown := errors.Is(err, outbound.ErrProviderAcceptanceUnknown) return outboundsend.DeliverOutcome{ - Err: err, - Permanent: outbound.IsPermanentSMTPError(err), - Outage: outbound.IsConnectionError(err), + Err: err, + Permanent: outbound.IsPermanentSMTPError(err), + Outage: !unknown && outbound.IsConnectionError(err), + AcceptanceUnknown: unknown, } } - return outboundsend.DeliverOutcome{ProviderMessageID: providerID, SentAs: j.SentAs} + return outboundsend.DeliverOutcome{ProviderMessageID: res.ProviderMessageID, SentAs: j.SentAs, SettlementErr: res.SettlementErr} } diff --git a/internal/agent/outbound_async_test.go b/internal/agent/outbound_async_test.go index 6b645dd24..574fd3409 100644 --- a/internal/agent/outbound_async_test.go +++ b/internal/agent/outbound_async_test.go @@ -22,6 +22,7 @@ import ( "github.com/tokencanopy/e2a/internal/messagelifecycle" "github.com/tokencanopy/e2a/internal/outbound" "github.com/tokencanopy/e2a/internal/outboundsend" + "github.com/tokencanopy/e2a/internal/sendingpolicy" "github.com/tokencanopy/e2a/internal/testutil" "github.com/tokencanopy/e2a/internal/usage" "github.com/tokencanopy/e2a/internal/webhookpub" @@ -122,13 +123,13 @@ func (f *fakeNotifyEnqueuer) EnqueueNotifyTx(_ context.Context, _ pgx.Tx, _ stri // fakeAsyncDeliverer is the SMTP submit the SendWorker calls — no network. type fakeAsyncDeliverer struct{ out outboundsend.DeliverOutcome } -func (f fakeAsyncDeliverer) Deliver(_ context.Context, _ *outboundsend.SendJob) outboundsend.DeliverOutcome { +func (f fakeAsyncDeliverer) Deliver(_ context.Context, _ *outboundsend.SendJob, _ sendingpolicy.ProviderAuthorization) outboundsend.DeliverOutcome { return f.out } type countingAsyncDeliverer struct{ calls int } -func (d *countingAsyncDeliverer) Deliver(context.Context, *outboundsend.SendJob) outboundsend.DeliverOutcome { +func (d *countingAsyncDeliverer) Deliver(context.Context, *outboundsend.SendJob, sendingpolicy.ProviderAuthorization) outboundsend.DeliverOutcome { d.calls++ return outboundsend.DeliverOutcome{ProviderMessageID: "unexpected"} } @@ -138,7 +139,7 @@ type timedAsyncDeliverer struct { returnedAt time.Time } -func (d *timedAsyncDeliverer) Deliver(context.Context, *outboundsend.SendJob) outboundsend.DeliverOutcome { +func (d *timedAsyncDeliverer) Deliver(context.Context, *outboundsend.SendJob, sendingpolicy.ProviderAuthorization) outboundsend.DeliverOutcome { d.returnedAt = time.Now().UTC() return d.out } @@ -155,7 +156,7 @@ type blockingAsyncDeliverer struct { out outboundsend.DeliverOutcome } -func (d *blockingAsyncDeliverer) Deliver(_ context.Context, _ *outboundsend.SendJob) outboundsend.DeliverOutcome { +func (d *blockingAsyncDeliverer) Deliver(_ context.Context, _ *outboundsend.SendJob, _ sendingpolicy.ProviderAuthorization) outboundsend.DeliverOutcome { close(d.entered) <-d.release return d.out @@ -1270,7 +1271,7 @@ func TestOutboundSendStore_MarkFailed(t *testing.T) { adapter := agent.NewOutboundSendStore(store, outbox, usage.NewNoopUsageTracker()) occurredAt := time.Now().UTC() - settled, settledAt, err := adapter.MarkFailed(ctx, res.MessageID, 999, 6, occurredAt, "550 mailbox unavailable", delivery.FailureSourceProvider, messagelifecycle.ReasonSubmissionProviderRejected, nil) + settled, settledAt, _, err := adapter.MarkFailed(ctx, res.MessageID, 999, 6, occurredAt, "550 mailbox unavailable", delivery.FailureSourceProvider, messagelifecycle.ReasonSubmissionProviderRejected, nil) if err != nil { t.Fatalf("MarkFailed: %v", err) } diff --git a/internal/agent/outbound_ramp_test.go b/internal/agent/outbound_ramp_test.go deleted file mode 100644 index c909a5dd6..000000000 --- a/internal/agent/outbound_ramp_test.go +++ /dev/null @@ -1,152 +0,0 @@ -package agent_test - -import ( - "context" - "testing" - "time" - - "github.com/jackc/pgx/v5/pgxpool" - "github.com/tokencanopy/e2a/internal/agent" - "github.com/tokencanopy/e2a/internal/identity" - "github.com/tokencanopy/e2a/internal/outbound" - "github.com/tokencanopy/e2a/internal/outboundsend" - "github.com/tokencanopy/e2a/internal/sendramp" - "github.com/tokencanopy/e2a/internal/testutil" - "github.com/tokencanopy/e2a/internal/usage" -) - -func seedOutboundRampAdapter(t *testing.T, suffix string) (*pgxpool.Pool, *sendramp.Store, string, string, string) { - t.Helper() - pool := testutil.TestDB(t) - ctx := context.Background() - ids := identity.NewStore(pool) - user, err := ids.CreateOrGetUser(ctx, "adapter-"+suffix+"@example.com", "Adapter", "adapter-"+suffix) - if err != nil { - t.Fatal(err) - } - domain := "adapter-" + suffix + ".example.com" - if _, err := ids.ClaimOrCreateDomain(ctx, domain, user.ID); err != nil { - t.Fatal(err) - } - if _, err := pool.Exec(ctx, `UPDATE domains SET sending_status='verified' WHERE domain=$1`, domain); err != nil { - t.Fatal(err) - } - ag, err := ids.CreateAgent(ctx, "agent@"+domain, domain, "", "", "local", user.ID) - if err != nil { - t.Fatal(err) - } - msg, err := ids.CreateOutboundMessage(ctx, ag.ID, []string{"one@example.net"}, nil, nil, "subject", "send", "smtp", "", "", []byte("raw")) - if err != nil { - t.Fatal(err) - } - return pool, sendramp.NewStore(pool), user.ID, domain, msg.ID -} - -// assertNoRampState asserts the full "the ramp wrote nothing" contract for one -// account: the domain never left 'inactive' and no ledger row was created. -func assertNoRampState(t *testing.T, pool *pgxpool.Pool, userID, domain, messageID string) { - t.Helper() - ctx := context.Background() - var status string - if err := pool.QueryRow(ctx, `SELECT sending_ramp_status FROM domains WHERE domain=$1 AND user_id=$2`, domain, userID).Scan(&status); err != nil { - t.Fatalf("read sending_ramp_status: %v", err) - } - if status != sendramp.StatusInactive { - t.Fatalf("sending_ramp_status = %q, want %q: a disabled ramp must not grandfather a domain from the send path", status, sendramp.StatusInactive) - } - for _, q := range []struct{ table, sql string }{ - {"sending_ramp_scopes", `SELECT count(*) FROM sending_ramp_scopes WHERE user_id=$1`}, - {"domain_send_counters", `SELECT count(*) FROM domain_send_counters WHERE user_id=$1`}, - } { - var n int - if err := pool.QueryRow(ctx, q.sql, userID).Scan(&n); err != nil { - t.Fatalf("count %s: %v", q.table, err) - } - if n != 0 { - t.Fatalf("%s has %d rows, want 0", q.table, n) - } - } - var reservations int - if err := pool.QueryRow(ctx, `SELECT count(*) FROM sending_ramp_reservations WHERE message_id=$1`, messageID).Scan(&reservations); err != nil { - t.Fatalf("count sending_ramp_reservations: %v", err) - } - if reservations != 0 { - t.Fatalf("sending_ramp_reservations has %d rows, want 0", reservations) - } -} - -// TestOutboundRampGateDisabledIsPassThrough pins the disabled contract: allow -// the send and write NOTHING. The gate used to stamp the domain 'exempt' on -// every eligible send, which permanently grandfathered any domain that sent -// while the ramp was off — pre-empting the audited one-shot in sendingpolicy -// and, because 'exempt' also reads as "established" to the shared probation -// pool, handing away an abuse bound. Delete-and-re-register made it a repeatable -// reset primitive on top. -func TestOutboundRampGateDisabledIsPassThrough(t *testing.T) { - pool, store, userID, domain, messageID := seedOutboundRampAdapter(t, "disabled") - gate := agent.NewOutboundRampGate(store, sendramp.DefaultSchedule, false) - d, err := gate.Reserve(context.Background(), outboundsend.RampRequest{MessageID: messageID, UserID: userID, Domain: domain, Units: 1}) - if err != nil || !d.Allowed { - t.Fatalf("Reserve = %+v, %v", d, err) - } - assertNoRampState(t, pool, userID, domain, messageID) - - // The read surface (GET /v1/domains/{domain}.sending_ramp.status) therefore - // reports 'inactive', not 'exempt', for a domain sending under a disabled ramp. - snap, err := store.Snapshot(context.Background(), userID, domain, time.Now()) - if err != nil || snap.Status != sendramp.StatusInactive { - t.Fatalf("Snapshot = %+v, %v, want status %q", snap, err, sendramp.StatusInactive) - } -} - -// TestSendWorkerDisabledRampWritesNoRampState is the same contract one level -// out: a real ramp-eligible send (own_address, message_type send) driven -// through the send worker with the ramp disabled must leave the domain -// 'inactive' and the ramp ledger empty. -func TestSendWorkerDisabledRampWritesNoRampState(t *testing.T) { - api, store, outbox, _, pool := setupAsyncAPIWithPool(t) - ctx := context.Background() - user, ag := selfAgent(t, store, "rampdisabled") - if err := store.SetSendingStatus(ctx, ag.RegisteredDomain, "verified", "verified", "verified", "", nil); err != nil { - t.Fatalf("SetSendingStatus: %v", err) - } - res, oerr := api.DeliverOutbound(ctx, user, ag, outbound.SendRequest{ - To: []string{"recipient@external.test"}, Subject: "disabled ramp send", Body: "x", - }, "send", "", nil, nil) - if oerr != nil { - t.Fatalf("DeliverOutbound: %+v", oerr) - } - - ramp := agent.NewOutboundRampGate(sendramp.NewStore(pool), sendramp.DefaultSchedule, false) - deliverer := &countingDeliverer{out: outboundsend.DeliverOutcome{ProviderMessageID: "provider-disabled-ramp", SentAs: "own_address"}} - worker := outboundsend.NewSendWorker( - agent.NewOutboundSendStore(store, outbox, usage.NewNoopUsageTracker()), deliverer, ramp) - - if err := worker.Work(ctx, workerJobWithID(res.MessageID, 999, 1)); err != nil { - t.Fatalf("worker.Work: %v", err) - } - if deliverer.calls != 1 { - t.Fatalf("deliverer calls = %d, want 1: the disabled ramp must still allow the send", deliverer.calls) - } - assertNoRampState(t, pool, user.ID, ag.RegisteredDomain, res.MessageID) -} - -func TestOutboundRampGateInjectsDayAndDelegatesLifecycle(t *testing.T) { - _, store, userID, domain, messageID := seedOutboundRampAdapter(t, "enabled") - day := time.Date(2026, 7, 2, 23, 30, 0, 0, time.FixedZone("west", -7*60*60)) - gate := agent.NewOutboundRampGate(store, sendramp.NewSchedule(50, 100, 2), true, func() time.Time { return day }) - d, err := gate.Reserve(context.Background(), outboundsend.RampRequest{MessageID: messageID, UserID: userID, Domain: domain, Units: 25}) - if err != nil || !d.Allowed { - t.Fatalf("Reserve = %+v, %v", d, err) - } - if err := gate.Confirm(context.Background(), messageID); err != nil { - t.Fatal(err) - } - snap, err := store.Snapshot(context.Background(), userID, domain, day) - if err != nil { - t.Fatal(err) - } - if snap.ActiveDays != 1 || snap.UsedToday != 25 { - t.Fatalf("Snapshot = %+v", snap) - } -} diff --git a/internal/agent/outbound_suppression_guard_test.go b/internal/agent/outbound_suppression_guard_test.go index 07d4a85cb..03536e5f5 100644 --- a/internal/agent/outbound_suppression_guard_test.go +++ b/internal/agent/outbound_suppression_guard_test.go @@ -9,7 +9,6 @@ import ( "context" "errors" "strings" - "sync" "testing" "time" @@ -22,38 +21,11 @@ import ( "github.com/tokencanopy/e2a/internal/messagelifecycle" "github.com/tokencanopy/e2a/internal/outbound" "github.com/tokencanopy/e2a/internal/outboundsend" - "github.com/tokencanopy/e2a/internal/sendramp" + "github.com/tokencanopy/e2a/internal/sendingpolicy" "github.com/tokencanopy/e2a/internal/usage" "github.com/tokencanopy/e2a/internal/webhookpub" ) -type blockingRampGate struct { - entered chan struct{} - resume chan struct{} - mu sync.Mutex - released []string -} - -func (g *blockingRampGate) Reserve(context.Context, outboundsend.RampRequest) (outboundsend.RampDecision, error) { - close(g.entered) - <-g.resume - return outboundsend.RampDecision{Allowed: true}, nil -} -func (*blockingRampGate) Confirm(context.Context, string) error { return nil } -func (g *blockingRampGate) Release(_ context.Context, messageID string) error { - g.mu.Lock() - defer g.mu.Unlock() - g.released = append(g.released, messageID) - return nil -} -func (*blockingRampGate) Resolve(context.Context, string) error { return nil } - -func (g *blockingRampGate) releasedIDs() []string { - g.mu.Lock() - defer g.mu.Unlock() - return append([]string(nil), g.released...) -} - // countingDeliverer records provider submits so the guard can assert zero I/O. type countingDeliverer struct { calls int @@ -73,7 +45,7 @@ func (s *failOnceSuppressionStore) SuppressedRecipients(ctx context.Context, use return s.Store.SuppressedRecipients(ctx, userID, agentID, recipients) } -func (d *countingDeliverer) Deliver(_ context.Context, _ *outboundsend.SendJob) outboundsend.DeliverOutcome { +func (d *countingDeliverer) Deliver(_ context.Context, _ *outboundsend.SendJob, _ sendingpolicy.ProviderAuthorization) outboundsend.DeliverOutcome { d.calls++ return d.out } @@ -378,100 +350,6 @@ func TestSendWorker_ProviderEvidenceCorrectionRetainsFallbackSuppression(t *test } } -func TestSendWorker_SuppressionAddedDuringRampReservePreventsProviderIO(t *testing.T) { - api, store, outbox, _ := setupAsyncAPI(t) - ctx := context.Background() - user, ag := selfAgent(t, store, "suppduringramp") - if err := store.SetSendingStatus(ctx, ag.RegisteredDomain, "verified", "verified", "verified", "", nil); err != nil { - t.Fatalf("SetSendingStatus: %v", err) - } - res, oerr := api.DeliverOutbound(ctx, user, ag, outbound.SendRequest{ - To: []string{"late@external.test"}, Subject: "ramp race", Body: "x", - }, "send", "", nil, nil) - if oerr != nil { - t.Fatalf("DeliverOutbound: %+v", oerr) - } - - gate := &blockingRampGate{entered: make(chan struct{}), resume: make(chan struct{})} - deliverer := &countingDeliverer{out: outboundsend.DeliverOutcome{ProviderMessageID: "must-not-happen"}} - worker := outboundsend.NewSendWorker(agent.NewOutboundSendStore(store, outbox, usage.NewNoopUsageTracker()), deliverer, gate) - done := make(chan error, 1) - go func() { done <- worker.Work(ctx, workerJob(res.MessageID, 1)) }() - <-gate.entered - if _, _, err := store.AddAgentSuppression(ctx, user.ID, ag.ID, "late@external.test", "opted out", "unsubscribe", nil); err != nil { - t.Fatal(err) - } - close(gate.resume) - if err := <-done; err == nil { - t.Fatal("suppression created during ramp reservation must cancel the send") - } - if deliverer.calls != 0 { - t.Fatalf("provider calls = %d, want zero", deliverer.calls) - } - if got := gate.releasedIDs(); len(got) != 1 || got[0] != res.MessageID { - t.Fatalf("released reservations = %v, want [%s]", got, res.MessageID) - } - var status, detail string - if err := store.WithTx(ctx, func(tx pgx.Tx) error { - return tx.QueryRow(ctx, `SELECT delivery_status, COALESCE(delivery_detail,'') FROM messages WHERE id=$1`, res.MessageID).Scan(&status, &detail) - }); err != nil { - t.Fatal(err) - } - if status != "failed" || !strings.Contains(detail, "recipient_suppressed") { - t.Fatalf("status/detail = %q/%q, want failed recipient_suppressed", status, detail) - } -} - -func TestSendWorker_TransientSuppressionFailureReusesRealRampReservation(t *testing.T) { - api, store, outbox, _, pool := setupAsyncAPIWithPool(t) - ctx := context.Background() - user, ag := selfAgent(t, store, "rampretryreal") - if err := store.SetSendingStatus(ctx, ag.RegisteredDomain, "verified", "verified", "verified", "", nil); err != nil { - t.Fatalf("SetSendingStatus: %v", err) - } - res, oerr := api.DeliverOutbound(ctx, user, ag, outbound.SendRequest{ - To: []string{"recipient@external.test"}, Subject: "retry after suppression lookup", Body: "x", - }, "send", "", nil, nil) - if oerr != nil { - t.Fatalf("DeliverOutbound: %+v", oerr) - } - - baseStore := agent.NewOutboundSendStore(store, outbox, usage.NewNoopUsageTracker()) - failingStore := &failOnceSuppressionStore{Store: baseStore} - day := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC) - ramp := agent.NewOutboundRampGate(sendramp.NewStore(pool), sendramp.NewSchedule(50, 100, 2), true, func() time.Time { return day }) - deliverer := &countingDeliverer{out: outboundsend.DeliverOutcome{ProviderMessageID: "ses-after-retry", SentAs: "own_address"}} - worker := outboundsend.NewSendWorker(failingStore, deliverer, ramp) - - if err := worker.Work(ctx, workerJobWithID(res.MessageID, 999, 1)); err == nil { - t.Fatal("first worker attempt must return the injected transient error") - } - var firstState string - if err := pool.QueryRow(ctx, `SELECT state FROM sending_ramp_reservations WHERE message_id=$1`, res.MessageID).Scan(&firstState); err != nil { - t.Fatalf("read first reservation: %v", err) - } - if firstState != "reserved" { - t.Fatalf("reservation after transient error = %q, want reserved", firstState) - } - if deliverer.calls != 0 { - t.Fatalf("provider calls after transient error = %d, want zero", deliverer.calls) - } - - if err := worker.Work(ctx, workerJobWithID(res.MessageID, 999, 2)); err != nil { - t.Fatalf("retry worker attempt: %v", err) - } - var finalState, status string - if err := pool.QueryRow(ctx, `SELECT state FROM sending_ramp_reservations WHERE message_id=$1`, res.MessageID).Scan(&finalState); err != nil { - t.Fatalf("read final reservation: %v", err) - } - if err := pool.QueryRow(ctx, `SELECT delivery_status FROM messages WHERE id=$1`, res.MessageID).Scan(&status); err != nil { - t.Fatalf("read final message: %v", err) - } - if finalState != "confirmed" || status != "sent" || deliverer.calls != 1 { - t.Fatalf("final reservation/status/provider calls = %q/%q/%d, want confirmed/sent/1", finalState, status, deliverer.calls) - } -} - func TestAccountSuppressionFromBounceBlocksEveryAgentSend(t *testing.T) { api, store, _, _ := setupAsyncAPI(t) ctx := context.Background() diff --git a/internal/agent/test_send_async_test.go b/internal/agent/test_send_async_test.go index 64ba85368..83117d0dd 100644 --- a/internal/agent/test_send_async_test.go +++ b/internal/agent/test_send_async_test.go @@ -13,6 +13,7 @@ import ( "github.com/tokencanopy/e2a/internal/identity" "github.com/tokencanopy/e2a/internal/outbound" "github.com/tokencanopy/e2a/internal/outboundsend" + "github.com/tokencanopy/e2a/internal/sendingpolicy" "github.com/tokencanopy/e2a/internal/testutil" "github.com/tokencanopy/e2a/internal/usage" "github.com/tokencanopy/e2a/internal/webhookpub" @@ -26,7 +27,7 @@ type captureDeliverer struct { out outboundsend.DeliverOutcome } -func (c *captureDeliverer) Deliver(_ context.Context, j *outboundsend.SendJob) outboundsend.DeliverOutcome { +func (c *captureDeliverer) Deliver(_ context.Context, j *outboundsend.SendJob, _ sendingpolicy.ProviderAuthorization) outboundsend.DeliverOutcome { c.jobs = append(c.jobs, j) return c.out } diff --git a/internal/e2e/email_eval_runner_e2e_test.go b/internal/e2e/email_eval_runner_e2e_test.go index eaf48b538..559fc6527 100644 --- a/internal/e2e/email_eval_runner_e2e_test.go +++ b/internal/e2e/email_eval_runner_e2e_test.go @@ -1490,9 +1490,18 @@ func waitForOutboundJobsTerminal( func outboundJobMessageID(job outboundJobRecord) (string, error) { var args map[string]json.RawMessage - if json.Unmarshal([]byte(job.Args), &args) != nil || len(args) != 1 { + if json.Unmarshal([]byte(job.Args), &args) != nil { return "", errors.New("invalid outbound job args") } + // The accept transaction stamps the durable sending operation reference + // beside the message id (sending abuse prevention, slice B6). Nothing + // else may appear: the eval's safety claim is that the queue holds only + // the jobs it knows the shape of. + for key := range args { + if key != "message_id" && key != "operation_ref" { + return "", errors.New("invalid outbound job args") + } + } var messageID string if json.Unmarshal(args["message_id"], &messageID) != nil || messageID == "" { return "", errors.New("invalid outbound job message identity") diff --git a/internal/hitlworker/async_approve_test.go b/internal/hitlworker/async_approve_test.go index 65ddf1d0f..962308d72 100644 --- a/internal/hitlworker/async_approve_test.go +++ b/internal/hitlworker/async_approve_test.go @@ -8,6 +8,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/tokencanopy/e2a/internal/identity" + "github.com/tokencanopy/e2a/internal/outboundsend" ) // fakeEnq records EnqueueSendTx / EnqueueScheduledSendTx calls (the outbound_send @@ -16,10 +17,14 @@ import ( type fakeEnq struct { calls []string scheduledCalls map[string]time.Time + err error } func (f *fakeEnq) EnqueueSendTx(_ context.Context, _ pgx.Tx, messageID string) (int64, error) { f.calls = append(f.calls, messageID) + if f.err != nil { + return 0, f.err + } return 7777, nil } @@ -155,3 +160,46 @@ func TestWorkerAutoApproveAsync_SelfSendStaysLoopback(t *testing.T) { t.Errorf("self-send status = %q, want %q (resolved via loopback)", status, identity.MessageStatusReviewExpiredApproved) } } + +// TestWorkerAutoApprovePausedAccountDefersWithoutBlocking: a TTL-expired hold on +// an account paused for sending stays pending — held, as the pause promises — +// but its TTL is pushed forward so it does not sit at the head of the sweep and +// starve every other expired review, and it is not retried every cycle. +func TestWorkerAutoApprovePausedAccountDefersWithoutBlocking(t *testing.T) { + w, store, pool, smtpDone := setupWorker(t) + ctx := context.Background() + agent := prepareAgent(t, store, "approve-paused", identity.HITLExpirationApprove) + enq := &fakeEnq{err: outboundsend.ErrSendingPaused} + w.SetOutboundEnqueuer(enq) + msg, err := store.CreatePendingOutboundMessage(ctx, agent.ID, + []string{"alice@external.test"}, nil, nil, + "Held", "body", "

html

", nil, "send", "", "", "", 60) + if err != nil { + t.Fatal(err) + } + backdateExpiry(t, pool, msg.ID) + + w.RunOnce(ctx) + if msgs := smtpDone(); len(msgs) != 0 { + t.Fatalf("paused account must not send inline, got %d SMTP messages", len(msgs)) + } + if len(enq.calls) != 1 { + t.Fatalf("enqueue attempts = %v, want exactly one", enq.calls) + } + var status string + var expiresAt time.Time + if err := pool.QueryRow(ctx, `SELECT status, approval_expires_at FROM messages WHERE id=$1`, msg.ID).Scan(&status, &expiresAt); err != nil { + t.Fatal(err) + } + if status != identity.MessageStatusPendingReview { + t.Fatalf("status = %q, want pending_review (held, not rejected)", status) + } + if expiresAt.Before(time.Now().Add(50 * time.Minute)) { + t.Fatalf("approval_expires_at = %v, want deferred about an hour ahead", expiresAt) + } + // Deferred out of the window: the next sweep leaves it alone. + w.RunOnce(ctx) + if len(enq.calls) != 1 { + t.Fatalf("enqueue attempts after deferral = %v, want still one", enq.calls) + } +} diff --git a/internal/hitlworker/worker.go b/internal/hitlworker/worker.go index ca4fbe5ec..00a1467e2 100644 --- a/internal/hitlworker/worker.go +++ b/internal/hitlworker/worker.go @@ -27,6 +27,7 @@ import ( "github.com/tokencanopy/e2a/internal/loopback" "github.com/tokencanopy/e2a/internal/messagelifecycle" "github.com/tokencanopy/e2a/internal/outbound" + "github.com/tokencanopy/e2a/internal/outboundsend" "github.com/tokencanopy/e2a/internal/piguard" "github.com/tokencanopy/e2a/internal/usage" "github.com/tokencanopy/e2a/internal/webhookpub" @@ -61,6 +62,11 @@ const DefaultBatchSize = 100 // Worker runs the TTL sweep. Construct with New; its RunOnce is driven on a // schedule by the River maintenance periodic (see maintenance.go). +// pausedReviewRetry is how far a TTL-expired review on a paused account is +// deferred before the sweep looks at it again. Long enough not to churn, short +// enough that a resume is picked up within the hour. +const pausedReviewRetry = time.Hour + type Worker struct { store *identity.Store sender *outbound.Sender @@ -406,6 +412,17 @@ func (w *Worker) autoApproveAsync(ctx context.Context, agent *identity.AgentIden if errors.Is(err, identity.ErrNotPendingApproval) { return true // resolved between load and transition } + if errors.Is(err, outboundsend.ErrSendingPaused) { + // The account is paused for sending. The draft stays pending_review + // — that is the held queue the pause promises — but it must not + // stay the sweep's oldest candidate, or it is re-picked first every + // cycle and starves every other expired review. Defer its TTL; the + // sweep after resume resolves it. + if derr := w.store.DeferReviewExpiry(ctx, c.MessageID, time.Now().Add(pausedReviewRetry)); derr != nil { + log.Printf("[hitl-worker] auto-approve %s: defer while account is paused: %v", c.MessageID, derr) + } + return true + } // Transient tx/enqueue failure: leave the row pending_review for the next // cycle. Do NOT autoReject — no send happened, so this is not a "stuck" send. log.Printf("[hitl-worker] auto-approve %s: accept+enqueue: %v", c.MessageID, err) diff --git a/internal/httpapi/error_catalog.go b/internal/httpapi/error_catalog.go index 558bab232..e71b00587 100644 --- a/internal/httpapi/error_catalog.go +++ b/internal/httpapi/error_catalog.go @@ -21,6 +21,7 @@ var errorCodeCatalog = []errorCodeContract{ {Code: "unauthorized", Status: "401", Family: "auth"}, {Code: "forbidden", Status: "403", Family: "auth"}, {Code: "blocked_by_policy", Status: "403", Family: "auth"}, + {Code: "sending_paused", Status: "403", Family: "auth"}, {Code: "invalid_request", Status: "400 / 422", Family: "validation", DetailsSchema: "ValidationErrorDetails"}, {Code: "invalid_cursor", Status: "400", Family: "validation"}, {Code: "invalid_filter", Status: "400", Family: "validation"}, diff --git a/internal/httpapi/errors.go b/internal/httpapi/errors.go index 50b3c6470..049c48b54 100644 --- a/internal/httpapi/errors.go +++ b/internal/httpapi/errors.go @@ -54,7 +54,7 @@ type ErrorEnvelope struct { // ErrorBody is the inner object of the envelope. type ErrorBody struct { - Code string `json:"code" doc:"Machine-branchable error code — the stable discriminator clients switch on. Open set: treat it as a string and tolerate unknown values, since new codes may be added over time (branch on the ones you handle, fall back to the HTTP status otherwise). Exact current vocabulary (machine-checked): unauthorized, forbidden, blocked_by_policy, invalid_request, invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope, reserved_domain, too_many_recipients, template_render_failed, template_rendered_empty, recipient_suppressed, not_found, attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found, gone, conflict, precondition_failed, agent_taken, domain_taken, alias_taken, address_in_trash, message_held, message_not_pending, message_not_yet_delivered, not_in_trash, purge_in_progress, send_in_progress, webhook_disabled, webhook_cooldown, domain_not_registered, domain_has_agents, domain_not_verified, inbound_mx_missing, limit_exceeded, rate_limited, contact_limit_reached, template_limit_reached, webhook_limit_reached, idempotency_in_flight, idempotency_key_reuse, payload_too_large, attachment_too_large, not_implemented, events_log_disabled, limits_unavailable, inbound_mx_check_failed, auth_unavailable, internal_error, method_not_allowed, unsupported_media_type, error. Grouped semantics: auth: unauthorized (401), forbidden (403), blocked_by_policy (403, outbound policy gate; experimental). Validation: invalid_request is the single canonical code for input-validation failures whether they arrive as 400 (malformed) or 422 (semantically invalid); field/resource-specific invalid_* refinements (invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope), reserved_domain, too_many_recipients, template_render_failed, template_rendered_empty (all 400); recipient_suppressed (422). Not found: not_found (404) plus the *_not_found family (attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found); gone (410, past retention). Conflict/state: conflict (409, generic), precondition_failed (412, optimistic-concurrency validator is stale), the *_taken family — the requested identifier is already claimed — (agent_taken, domain_taken, alias_taken, all 409), address_in_trash (409), message_held (409), message_not_pending (409), message_not_yet_delivered (409, retry after the source outbound message is sent), not_in_trash (409), purge_in_progress (409, permanent delete already claimed), send_in_progress (409), webhook_disabled (409), webhook_cooldown (409), domain_not_registered (400), domain_has_agents (400), domain_not_verified (400 on create-agent, 403 on send), inbound_mx_missing (400). Capacity: limit_exceeded (402, plan quota — see LimitExceededDetails), rate_limited (429, request rate — see RateLimitedDetails), contact_limit_reached, template_limit_reached and webhook_limit_reached (400, fixed per-account caps). Idempotency: idempotency_in_flight (409, wait then retry the byte-identical request), idempotency_key_reuse (422, caller bug — do not retry as-is). Size: payload_too_large (413, request body), attachment_too_large (413, inline fetch over the cap — use download_url). Availability: not_implemented (501, feature not available on this deployment), events_log_disabled (501), limits_unavailable (503), inbound_mx_check_failed (503), auth_unavailable (503, an auth backend — e.g. a delegated-token verifier or the identity store — could not judge the credential; retry). Server/fallback: internal_error (5xx), method_not_allowed (405), unsupported_media_type (415), and the generic code error for any otherwise-unmapped status."` + Code string `json:"code" doc:"Machine-branchable error code — the stable discriminator clients switch on. Open set: treat it as a string and tolerate unknown values, since new codes may be added over time (branch on the ones you handle, fall back to the HTTP status otherwise). Exact current vocabulary (machine-checked): unauthorized, forbidden, blocked_by_policy, sending_paused, invalid_request, invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope, reserved_domain, too_many_recipients, template_render_failed, template_rendered_empty, recipient_suppressed, not_found, attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found, gone, conflict, precondition_failed, agent_taken, domain_taken, alias_taken, address_in_trash, message_held, message_not_pending, message_not_yet_delivered, not_in_trash, purge_in_progress, send_in_progress, webhook_disabled, webhook_cooldown, domain_not_registered, domain_has_agents, domain_not_verified, inbound_mx_missing, limit_exceeded, rate_limited, contact_limit_reached, template_limit_reached, webhook_limit_reached, idempotency_in_flight, idempotency_key_reuse, payload_too_large, attachment_too_large, not_implemented, events_log_disabled, limits_unavailable, inbound_mx_check_failed, auth_unavailable, internal_error, method_not_allowed, unsupported_media_type, error. Grouped semantics: auth: unauthorized (401), forbidden (403), blocked_by_policy (403, outbound policy gate; experimental), sending_paused (403, outbound sending is paused for the account by the platform abuse controls; queued mail is held, new sends are refused until an operator resumes; experimental). Validation: invalid_request is the single canonical code for input-validation failures whether they arrive as 400 (malformed) or 422 (semantically invalid); field/resource-specific invalid_* refinements (invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope), reserved_domain, too_many_recipients, template_render_failed, template_rendered_empty (all 400); recipient_suppressed (422). Not found: not_found (404) plus the *_not_found family (attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found); gone (410, past retention). Conflict/state: conflict (409, generic), precondition_failed (412, optimistic-concurrency validator is stale), the *_taken family — the requested identifier is already claimed — (agent_taken, domain_taken, alias_taken, all 409), address_in_trash (409), message_held (409), message_not_pending (409), message_not_yet_delivered (409, retry after the source outbound message is sent), not_in_trash (409), purge_in_progress (409, permanent delete already claimed), send_in_progress (409), webhook_disabled (409), webhook_cooldown (409), domain_not_registered (400), domain_has_agents (400), domain_not_verified (400 on create-agent, 403 on send), inbound_mx_missing (400). Capacity: limit_exceeded (402, plan quota — see LimitExceededDetails), rate_limited (429, request rate — see RateLimitedDetails), contact_limit_reached, template_limit_reached and webhook_limit_reached (400, fixed per-account caps). Idempotency: idempotency_in_flight (409, wait then retry the byte-identical request), idempotency_key_reuse (422, caller bug — do not retry as-is). Size: payload_too_large (413, request body), attachment_too_large (413, inline fetch over the cap — use download_url). Availability: not_implemented (501, feature not available on this deployment), events_log_disabled (501), limits_unavailable (503), inbound_mx_check_failed (503), auth_unavailable (503, an auth backend — e.g. a delegated-token verifier or the identity store — could not judge the credential; retry). Server/fallback: internal_error (5xx), method_not_allowed (405), unsupported_media_type (415), and the generic code error for any otherwise-unmapped status."` Message string `json:"message" doc:"Human-readable explanation. Not for branching — use code."` Details any `json:"details,omitempty" doc:"Optional structured context, polymorphic by code. Treat it as an open object keyed off code; unknown codes and fields must be preserved."` RequestID string `json:"request_id" doc:"Echoes the X-Request-Id response header so a failing call is greppable in logs."` diff --git a/internal/httpapi/spec_review_test.go b/internal/httpapi/spec_review_test.go index abf340c3f..13a9c62f7 100644 --- a/internal/httpapi/spec_review_test.go +++ b/internal/httpapi/spec_review_test.go @@ -263,6 +263,8 @@ func assertMessageLifecycleContractSchema(t *testing.T, doc map[string]any) { "suppression.recipient_blocked", "suppression.hard_bounce_applied", "suppression.complaint_applied", "queue.inbound_processing", "queue.outbound_submission", "submission.upstream_accepted", "submission.local_loopback_accepted", "submission.temporary_failure", "submission.provider_rejected", "submission.local_retries_exhausted", "submission.cancelled", + "submission.policy_budget_expired", + "submission.sending_setup_expired", "delivery.recipient_server_accepted", "delivery.temporary_delay", "delivery.permanent_bounce", "delivery.transient_bounce", "delivery.undetermined_bounce", "complaint.recipient_reported", }, diff --git a/internal/httpapi/stability.go b/internal/httpapi/stability.go index 7eea7ad91..7d30d3b26 100644 --- a/internal/httpapi/stability.go +++ b/internal/httpapi/stability.go @@ -244,9 +244,10 @@ func (s *Server) applyEvolutionStance() { for _, schema := range []string{"HoldReasonView", "ProtectionFindingView", "ThreatCategoryView"} { markSchema(schemas, schema, extStabilityLevel, stabilityBeta) } - // ErrorBody.code is a stable open discriminator; only the outbound - // gate-policy value remains experimental. - markProperty(schemas, "ErrorBody", "code", extExperimentalValues, []string{"blocked_by_policy"}) + // ErrorBody.code is a stable open discriminator; the outbound gate-policy + // value and the sending-abuse pause value remain experimental — both are + // produced by controls that ship disabled. + markProperty(schemas, "ErrorBody", "code", extExperimentalValues, []string{"blocked_by_policy", "sending_paused"}) // // The template hooks on send are beta (templates are beta) even though // sendMessage itself is stable. diff --git a/internal/httpapi/stability_test.go b/internal/httpapi/stability_test.go index dfdb3dd0f..1f37e285d 100644 --- a/internal/httpapi/stability_test.go +++ b/internal/httpapi/stability_test.go @@ -436,12 +436,13 @@ func TestSpecBetaMarkers(t *testing.T) { } } - // The error discriminator remains stable; only the gate-policy value is - // experimental. + // The error discriminator remains stable; only the two values produced by + // controls that ship disabled — the outbound gate policy and the sending + // abuse pause — are experimental. errorCode, _ := schemaProps(t, doc, "ErrorBody")["code"].(map[string]any) rawErrorValues, _ := errorCode["x-experimental-values"].([]any) - if len(rawErrorValues) != 1 || rawErrorValues[0] != "blocked_by_policy" { - t.Errorf("ErrorBody.code x-experimental-values = %v, want [blocked_by_policy]", rawErrorValues) + if len(rawErrorValues) != 2 || rawErrorValues[0] != "blocked_by_policy" || rawErrorValues[1] != "sending_paused" { + t.Errorf("ErrorBody.code x-experimental-values = %v, want [blocked_by_policy sending_paused]", rawErrorValues) } // Managed unsubscribe is a beta opt-in nested inside otherwise-stable diff --git a/internal/identity/delivery_store.go b/internal/identity/delivery_store.go index 3be22a369..4d53d0c8d 100644 --- a/internal/identity/delivery_store.go +++ b/internal/identity/delivery_store.go @@ -319,6 +319,7 @@ func (s *Store) RecordDeliveryOutcomeTx(ctx context.Context, tx pgx.Tx, messageI if _, err := tx.Exec(ctx, `UPDATE messages SET delivery_status = 'failed', + local_hold_class = NULL, local_hold_anchor = NULL, delivery_failure_source = COALESCE(delivery_failure_source, 'provider') WHERE id = $1`, messageID, ); err != nil { @@ -373,7 +374,7 @@ func (s *Store) MarkMessageSent(ctx context.Context, messageID, sentAs string, t defer tx.Rollback(ctx) if _, err := tx.Exec(ctx, - `UPDATE messages SET delivery_status = 'sent', sent_as = $2 WHERE id = $1`, + `UPDATE messages SET delivery_status = 'sent', sent_as = $2, local_hold_class = NULL, local_hold_anchor = NULL WHERE id = $1`, messageID, nullIfEmpty(sentAs), ); err != nil { return err @@ -444,6 +445,18 @@ type OutboundSendPayload struct { ReviewedAt *time.Time // ProviderMessageID is the evidence-repaired provider id ('' when none). ProviderMessageID string + // LocalHoldClass / LocalHoldAnchor are the durable finite-hold state the + // worker persisted on an earlier execution ('' / nil when the message has + // never entered a finite hold). The absolute deadline is always derived + // from this pair, never stored. + LocalHoldClass string + LocalHoldAnchor *time.Time + // LastResumedAt is account_sending_controls.last_resumed_at for the owning + // account; TenantReadyAt is its ses_tenant_ready_at (nil until the SES + // tenant is ready). Both feed the worker's hold-anchor and setup→rate + // transition rules. nil when the account has no control row yet. + LastResumedAt *time.Time + TenantReadyAt *time.Time } // OutboundSentInfo carries the fields the async worker's MarkSent/MarkFailed @@ -590,6 +603,10 @@ func (s *Store) ClaimOutboundForSend(ctx context.Context, messageID string, jobI failureAttempt *int scheduledAt *time.Time reviewedAt *time.Time + holdClass string + holdAnchor *time.Time + lastResumedAt *time.Time + tenantReadyAt *time.Time ) var userID, registeredDomain string // Lock agent first to match permanent agent deletion's lock order, then @@ -613,14 +630,18 @@ func (s *Store) ClaimOutboundForSend(ctx context.Context, messageID string, jobI m.to_recipients, m.cc, m.bcc, m.raw_message, m.created_at, m.deleted_at, m.send_job_id, m.provider_accepted_at, COALESCE(m.provider_message_id,''), COALESCE(m.delivery_failure_source,''),COALESCE(m.delivery_failure_reason_code,''), - m.delivery_failure_occurred_at,m.delivery_failure_attempt,m.scheduled_at,m.reviewed_at + m.delivery_failure_occurred_at,m.delivery_failure_attempt,m.scheduled_at,m.reviewed_at, + COALESCE(m.local_hold_class,''), m.local_hold_anchor, + c.last_resumed_at, c.ses_tenant_ready_at FROM messages m + LEFT JOIN account_sending_controls c ON c.user_id = $3 WHERE m.id = $1 AND m.agent_id = $2 AND m.direction = 'outbound' FOR UPDATE OF m`, - messageID, agentID, + messageID, agentID, userID, ).Scan(&deliveryStatus, &envelopeFrom, &sentAs, &messageType, &to, &cc, &bcc, &raw, &createdAt, &deletedAt, &stampedJobID, &providerAcceptedAt, &providerMessageID, - &failureSource, &failureReason, &failureOccurredAt, &failureAttempt, &scheduledAt, &reviewedAt) + &failureSource, &failureReason, &failureOccurredAt, &failureAttempt, &scheduledAt, &reviewedAt, + &holdClass, &holdAnchor, &lastResumedAt, &tenantReadyAt) if errors.Is(err, pgx.ErrNoRows) { if err := tx.Commit(ctx); err != nil { return nil, err @@ -665,6 +686,7 @@ func (s *Store) ClaimOutboundForSend(ctx context.Context, messageID string, jobI if _, err := tx.Exec(ctx, `UPDATE messages SET delivery_status = 'failed', + local_hold_class = NULL, local_hold_anchor = NULL, delivery_detail = 'send canceled because the message or agent is in trash', delivery_failure_source = 'local', delivery_failure_reason_code = 'submission.cancelled', @@ -710,6 +732,10 @@ func (s *Store) ClaimOutboundForSend(ctx context.Context, messageID string, jobI ProviderMessageID: providerMessageID, ScheduledAt: scheduledAt, ReviewedAt: reviewedAt, + LocalHoldClass: holdClass, + LocalHoldAnchor: holdAnchor, + LastResumedAt: lastResumedAt, + TenantReadyAt: tenantReadyAt, } if err := tx.Commit(ctx); err != nil { return nil, err @@ -717,6 +743,28 @@ func (s *Store) ClaimOutboundForSend(ctx context.Context, messageID string, jobI return p, nil } +// RecordOutboundHold persists a message's finite-hold class and anchor. +// +// The worker owns the transition rules (first finite hold, setup→rate, +// monotonic promotion to policy_budget); this writes exactly the pair it was +// given and only while the message is still pre-terminal. Terminal writes +// clear the pair, so a stale hold can never outlive its message's outcome. +func (s *Store) RecordOutboundHold(ctx context.Context, messageID, class string, anchor time.Time) error { + if class == "" || anchor.IsZero() { + return fmt.Errorf("record outbound hold: class and anchor are required") + } + _, err := s.pool.Exec(ctx, ` + UPDATE messages + SET local_hold_class = $2, local_hold_anchor = $3 + WHERE id = $1 AND direction = 'outbound' + AND delivery_status IN ('accepted', 'sending')`, + messageID, class, anchor.UTC()) + if err != nil { + return fmt.Errorf("record outbound hold: %w", err) + } + return nil +} + func isCompleteTerminalFallback(source, reason string, occurredAt *time.Time, attempt *int) bool { if occurredAt == nil || occurredAt.IsZero() || attempt == nil || *attempt < 0 { return false @@ -724,7 +772,8 @@ func isCompleteTerminalFallback(source, reason string, occurredAt *time.Time, at switch messagelifecycle.ReasonCode(reason) { case messagelifecycle.ReasonSubmissionProviderRejected: return delivery.FailureSource(source) == delivery.FailureSourceProvider - case messagelifecycle.ReasonSubmissionLocalRetriesExhausted, messagelifecycle.ReasonSubmissionCancelled: + case messagelifecycle.ReasonSubmissionLocalRetriesExhausted, messagelifecycle.ReasonSubmissionCancelled, + messagelifecycle.ReasonSubmissionPolicyBudgetExpired, messagelifecycle.ReasonSubmissionSendingSetupExpired: return delivery.FailureSource(source) == delivery.FailureSourceLocal default: return false @@ -808,6 +857,7 @@ func (s *Store) MarkOutboundSentTx(ctx context.Context, tx pgx.Tx, messageID, pr err := tx.QueryRow(ctx, `UPDATE messages m SET delivery_status = 'sent', provider_message_id = $2, send_claimed_at = NULL, + local_hold_class = NULL, local_hold_anchor = NULL, rfc_message_id_key = CASE WHEN rfc_message_id_key IS NULL AND $3 <> '' THEN $3 ELSE rfc_message_id_key @@ -891,7 +941,7 @@ func (s *Store) ResolveOutboundProviderAcceptedTx(ctx context.Context, tx pgx.Tx m := &Message{ID: messageID, Direction: "outbound", DeliveryStatus: "sent"} err = tx.QueryRow(ctx, `UPDATE messages m - SET delivery_status = 'sent', send_claimed_at = NULL, delivery_failure_source = NULL, delivery_failure_reason_code = NULL, delivery_detail = NULL, + SET delivery_status = 'sent', send_claimed_at = NULL, local_hold_class = NULL, local_hold_anchor = NULL, delivery_failure_source = NULL, delivery_failure_reason_code = NULL, delivery_detail = NULL, delivery_failure_occurred_at=NULL, delivery_failure_attempt=NULL, delivery_failure_blocked_recipients=NULL FROM agent_identities a WHERE m.id = $1 AND m.direction = 'outbound' @@ -981,6 +1031,7 @@ func (s *Store) MarkOutboundFailedTx(ctx context.Context, tx pgx.Tx, messageID, err := tx.QueryRow(ctx, `UPDATE messages m SET delivery_status = 'failed', + local_hold_class = NULL, local_hold_anchor = NULL, delivery_detail = COALESCE(NULLIF(m.delivery_detail, ''), $2), delivery_failure_source = $3, send_claimed_at = NULL diff --git a/internal/identity/outbound_hold_test.go b/internal/identity/outbound_hold_test.go new file mode 100644 index 000000000..88f8df06d --- /dev/null +++ b/internal/identity/outbound_hold_test.go @@ -0,0 +1,110 @@ +package identity_test + +import ( + "context" + "testing" + "time" + + "github.com/jackc/pgx/v5" + + "github.com/tokencanopy/e2a/internal/identity" + "github.com/tokencanopy/e2a/internal/testutil" +) + +// The finite-hold pair rides the claim payload so every worker execution +// re-derives the same deadline, and it is cleared by the terminal write so a +// stale hold can never outlive its message's outcome. +func TestOutboundHoldRidesTheClaimAndClearsOnTerminal(t *testing.T) { + pool := testutil.TestDB(t) + store := identity.NewStore(pool) + ctx := context.Background() + agentID := convoTestSetup(t, store, "hold-claim") + + var userID string + if err := pool.QueryRow(ctx, `SELECT user_id FROM agent_identities WHERE id = $1`, agentID).Scan(&userID); err != nil { + t.Fatal(err) + } + resumed := time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC) + ready := time.Date(2026, 9, 2, 9, 30, 0, 0, time.UTC) + if _, err := pool.Exec(ctx, ` + INSERT INTO account_sending_controls (user_id, last_resumed_at, ses_tenant_name, ses_tenant_ready, ses_tenant_ready_at) + VALUES ($1, $2, 'tenant_hold_test', true, $3) + ON CONFLICT (user_id) DO UPDATE SET last_resumed_at = $2, ses_tenant_ready = true, ses_tenant_ready_at = $3`, + userID, resumed, ready, + ); err != nil { + t.Fatal(err) + } + + var msgID string + if err := store.WithTx(ctx, func(tx pgx.Tx) error { + m, err := store.CreateOutboundMessageTx(ctx, tx, agentID, + []string{"one@example.test"}, nil, nil, "Hold", "send", "smtp", "", "conv-hold", + []byte("From: bot\r\n\r\nbody"), "accepted", "agent@test.e2a.dev", "relay") + if err != nil { + return err + } + msgID = m.ID + return store.StampSendJobIDTx(ctx, tx, m.ID, 4242) + }); err != nil { + t.Fatalf("seed: %v", err) + } + + p, err := store.ClaimOutboundForSend(ctx, msgID, 4242) + if err != nil || p == nil { + t.Fatalf("claim: payload=%v err=%v", p, err) + } + if p.LocalHoldClass != "" || p.LocalHoldAnchor != nil { + t.Fatalf("fresh claim carries a hold: %q %v", p.LocalHoldClass, p.LocalHoldAnchor) + } + if p.LastResumedAt == nil || !p.LastResumedAt.Equal(resumed) || p.TenantReadyAt == nil || !p.TenantReadyAt.Equal(ready) { + t.Fatalf("control timestamps = %v / %v, want %v / %v", p.LastResumedAt, p.TenantReadyAt, resumed, ready) + } + if err := store.ReleaseOutboundSendClaim(ctx, msgID, 4242); err != nil { + t.Fatal(err) + } + + anchor := time.Date(2026, 9, 3, 10, 0, 0, 0, time.UTC) + if err := store.RecordOutboundHold(ctx, msgID, "policy_budget", anchor); err != nil { + t.Fatalf("record hold: %v", err) + } + p, err = store.ClaimOutboundForSend(ctx, msgID, 4242) + if err != nil || p == nil { + t.Fatalf("re-claim: payload=%v err=%v", p, err) + } + if p.LocalHoldClass != "policy_budget" || p.LocalHoldAnchor == nil || !p.LocalHoldAnchor.Equal(anchor) { + t.Fatalf("hold on re-claim = %q %v, want policy_budget @ %v", p.LocalHoldClass, p.LocalHoldAnchor, anchor) + } + + if err := store.WithTx(ctx, func(tx pgx.Tx) error { + _, err := store.MarkOutboundSentTx(ctx, tx, msgID, "") + return err + }); err != nil { + t.Fatalf("mark sent: %v", err) + } + var class *string + var holdAnchor *time.Time + if err := pool.QueryRow(ctx, `SELECT local_hold_class, local_hold_anchor FROM messages WHERE id = $1`, msgID).Scan(&class, &holdAnchor); err != nil { + t.Fatal(err) + } + if class != nil || holdAnchor != nil { + t.Fatalf("hold survived the terminal write: %v %v", class, holdAnchor) + } + // A terminal row refuses a late hold write. + if err := store.RecordOutboundHold(ctx, msgID, "policy_budget", anchor); err != nil { + t.Fatalf("late hold write errored: %v", err) + } + if err := pool.QueryRow(ctx, `SELECT local_hold_class FROM messages WHERE id = $1`, msgID).Scan(&class); err != nil { + t.Fatal(err) + } + if class != nil { + t.Fatalf("hold written on a sent row: %q", *class) + } +} + +func TestOutboundHoldRejectsAnEmptyPair(t *testing.T) { + pool := testutil.TestDB(t) + store := identity.NewStore(pool) + if err := store.RecordOutboundHold(context.Background(), "msg_none", "", time.Time{}); err == nil { + t.Fatal("empty class and anchor accepted") + } +} diff --git a/internal/identity/review.go b/internal/identity/review.go index dc627786f..9fd35c958 100644 --- a/internal/identity/review.go +++ b/internal/identity/review.go @@ -349,6 +349,23 @@ func (s *Store) ExpireApproveReviewWithTransition(ctx context.Context, messageID return s.transitionReview(ctx, messageID, "", MessageStatusReviewExpiredApproved, nil, "") } +// DeferReviewExpiry pushes a pending review's TTL forward without resolving +// it. The expiration sweep orders candidates by approval_expires_at, so a +// hold that cannot resolve yet — its account is paused for sending — would +// otherwise stay the oldest candidate and be re-picked first every cycle, +// starving every other expired review once enough of them accumulate. +// Deferring it yields the slot; when the account resumes, the next sweep +// after the deferred instant resolves it normally. +func (s *Store) DeferReviewExpiry(ctx context.Context, messageID string, until time.Time) error { + _, err := s.pool.Exec(ctx, + `UPDATE messages SET approval_expires_at = $2 WHERE id = $1 AND status = 'pending_review'`, + messageID, until.UTC()) + if err != nil { + return fmt.Errorf("defer review expiry: %w", err) + } + return nil +} + // ExpireRejectReview is the worker-side TTL auto-reject: drops the message // (status review_expired_rejected) with no human reviewer. System-scoped. func (s *Store) ExpireRejectReview(ctx context.Context, messageID, reason string) error { diff --git a/internal/messagelifecycle/catalog.go b/internal/messagelifecycle/catalog.go index b7f71272d..17ccd5b68 100644 --- a/internal/messagelifecycle/catalog.go +++ b/internal/messagelifecycle/catalog.go @@ -66,12 +66,20 @@ const ( ReasonSubmissionProviderRejected ReasonCode = "submission.provider_rejected" ReasonSubmissionLocalRetriesExhausted ReasonCode = "submission.local_retries_exhausted" ReasonSubmissionCancelled ReasonCode = "submission.cancelled" - ReasonDeliveryRecipientServerAccepted ReasonCode = "delivery.recipient_server_accepted" - ReasonDeliveryTemporaryDelay ReasonCode = "delivery.temporary_delay" - ReasonDeliveryPermanentBounce ReasonCode = "delivery.permanent_bounce" - ReasonDeliveryTransientBounce ReasonCode = "delivery.transient_bounce" - ReasonDeliveryUndeterminedBounce ReasonCode = "delivery.undetermined_bounce" - ReasonComplaintRecipientReported ReasonCode = "complaint.recipient_reported" + // ReasonSubmissionPolicyBudgetExpired means a sending-budget hold reached + // its seven-day deadline without capacity freeing. It is a local policy + // outcome, never a recipient rejection or a provider outage. + ReasonSubmissionPolicyBudgetExpired ReasonCode = "submission.policy_budget_expired" + // ReasonSubmissionSendingSetupExpired means the account's provider-side + // sending setup (SES tenant readiness) did not complete within the + // 72-hour setup deadline. + ReasonSubmissionSendingSetupExpired ReasonCode = "submission.sending_setup_expired" + ReasonDeliveryRecipientServerAccepted ReasonCode = "delivery.recipient_server_accepted" + ReasonDeliveryTemporaryDelay ReasonCode = "delivery.temporary_delay" + ReasonDeliveryPermanentBounce ReasonCode = "delivery.permanent_bounce" + ReasonDeliveryTransientBounce ReasonCode = "delivery.transient_bounce" + ReasonDeliveryUndeterminedBounce ReasonCode = "delivery.undetermined_bounce" + ReasonComplaintRecipientReported ReasonCode = "complaint.recipient_reported" ) // Definition is the fixed meaning of a reason code. @@ -106,6 +114,8 @@ var canonicalCatalog = map[ReasonCode]Definition{ ReasonSubmissionProviderRejected: {StageSubmission, OutcomeFailed, false}, ReasonSubmissionLocalRetriesExhausted: {StageSubmission, OutcomeFailed, true}, ReasonSubmissionCancelled: {StageSubmission, OutcomeFailed, false}, + ReasonSubmissionPolicyBudgetExpired: {StageSubmission, OutcomeFailed, true}, + ReasonSubmissionSendingSetupExpired: {StageSubmission, OutcomeFailed, true}, ReasonDeliveryRecipientServerAccepted: {StageDelivery, OutcomeDelivered, false}, ReasonDeliveryTemporaryDelay: {StageDelivery, OutcomeDeferred, true}, ReasonDeliveryPermanentBounce: {StageDelivery, OutcomeBounced, false}, diff --git a/internal/messagelifecycle/model.go b/internal/messagelifecycle/model.go index 783187f82..9c2c2b0ab 100644 --- a/internal/messagelifecycle/model.go +++ b/internal/messagelifecycle/model.go @@ -69,7 +69,7 @@ type MessageLifecycleTransition struct { Recipient string `json:"recipient,omitempty" nullable:"true"` Stage Stage `json:"stage" enum:"accepted,authentication,review,suppression,queued,submission,delivery,complaint"` Outcome Outcome `json:"outcome" enum:"accepted,passed,failed,indeterminate,pending,approved,rejected,blocked,applied,enqueued,deferred,delivered,bounced,reported"` - ReasonCode ReasonCode `json:"reason_code" enum:"acceptance.inbound_smtp,acceptance.outbound_api,acceptance.local_loopback,authentication.dmarc_pass,authentication.dmarc_fail,authentication.dmarc_none,authentication.dmarc_temporary_error,authentication.dmarc_permanent_error,review.hold_created,review.approved,review.rejected,review.expired_approved,review.expired_rejected,suppression.recipient_blocked,suppression.hard_bounce_applied,suppression.complaint_applied,queue.inbound_processing,queue.outbound_submission,submission.upstream_accepted,submission.local_loopback_accepted,submission.temporary_failure,submission.provider_rejected,submission.local_retries_exhausted,submission.cancelled,delivery.recipient_server_accepted,delivery.temporary_delay,delivery.permanent_bounce,delivery.transient_bounce,delivery.undetermined_bounce,complaint.recipient_reported"` + ReasonCode ReasonCode `json:"reason_code" enum:"acceptance.inbound_smtp,acceptance.outbound_api,acceptance.local_loopback,authentication.dmarc_pass,authentication.dmarc_fail,authentication.dmarc_none,authentication.dmarc_temporary_error,authentication.dmarc_permanent_error,review.hold_created,review.approved,review.rejected,review.expired_approved,review.expired_rejected,suppression.recipient_blocked,suppression.hard_bounce_applied,suppression.complaint_applied,queue.inbound_processing,queue.outbound_submission,submission.upstream_accepted,submission.local_loopback_accepted,submission.temporary_failure,submission.provider_rejected,submission.local_retries_exhausted,submission.cancelled,submission.policy_budget_expired,submission.sending_setup_expired,delivery.recipient_server_accepted,delivery.temporary_delay,delivery.permanent_bounce,delivery.transient_bounce,delivery.undetermined_bounce,complaint.recipient_reported"` Retryable bool `json:"retryable"` Evidence map[string]any `json:"evidence"` CorrelationIDs map[string]string `json:"correlation_ids"` diff --git a/internal/messagelifecycle/model_test.go b/internal/messagelifecycle/model_test.go index 47ef4fdcb..21295bc6e 100644 --- a/internal/messagelifecycle/model_test.go +++ b/internal/messagelifecycle/model_test.go @@ -42,6 +42,8 @@ func TestCatalogIsExhaustive(t *testing.T) { {ReasonSubmissionProviderRejected, StageSubmission, OutcomeFailed, false}, {ReasonSubmissionLocalRetriesExhausted, StageSubmission, OutcomeFailed, true}, {ReasonSubmissionCancelled, StageSubmission, OutcomeFailed, false}, + {ReasonSubmissionPolicyBudgetExpired, StageSubmission, OutcomeFailed, true}, + {ReasonSubmissionSendingSetupExpired, StageSubmission, OutcomeFailed, true}, {ReasonDeliveryRecipientServerAccepted, StageDelivery, OutcomeDelivered, false}, {ReasonDeliveryTemporaryDelay, StageDelivery, OutcomeDeferred, true}, {ReasonDeliveryPermanentBounce, StageDelivery, OutcomeBounced, false}, @@ -51,7 +53,7 @@ func TestCatalogIsExhaustive(t *testing.T) { } catalog := Catalog() - if got, want := len(catalog), 30; got != want { + if got, want := len(catalog), 32; got != want { t.Fatalf("Catalog() length = %d, want %d", got, want) } seen := make(map[ReasonCode]bool, len(tests)) @@ -93,7 +95,7 @@ func TestCatalogRejectsUnknownAndCannotBeMutated(t *testing.T) { if !ok || got != (Definition{Stage: StageAccepted, Outcome: OutcomeAccepted}) { t.Fatalf("caller mutation changed canonical lookup: %+v, %v", got, ok) } - if got := len(Catalog()); got != 30 { + if got := len(Catalog()); got != 32 { t.Fatalf("caller mutation changed canonical catalog length to %d", got) } } @@ -500,7 +502,7 @@ func TestNewTransitionSchemaEnumTags(t *testing.T) { assertTag("Direction", "enum", "inbound,outbound") assertTag("Stage", "enum", "accepted,authentication,review,suppression,queued,submission,delivery,complaint") assertTag("Outcome", "enum", "accepted,passed,failed,indeterminate,pending,approved,rejected,blocked,applied,enqueued,deferred,delivered,bounced,reported") - assertTag("ReasonCode", "enum", "acceptance.inbound_smtp,acceptance.outbound_api,acceptance.local_loopback,authentication.dmarc_pass,authentication.dmarc_fail,authentication.dmarc_none,authentication.dmarc_temporary_error,authentication.dmarc_permanent_error,review.hold_created,review.approved,review.rejected,review.expired_approved,review.expired_rejected,suppression.recipient_blocked,suppression.hard_bounce_applied,suppression.complaint_applied,queue.inbound_processing,queue.outbound_submission,submission.upstream_accepted,submission.local_loopback_accepted,submission.temporary_failure,submission.provider_rejected,submission.local_retries_exhausted,submission.cancelled,delivery.recipient_server_accepted,delivery.temporary_delay,delivery.permanent_bounce,delivery.transient_bounce,delivery.undetermined_bounce,complaint.recipient_reported") + assertTag("ReasonCode", "enum", "acceptance.inbound_smtp,acceptance.outbound_api,acceptance.local_loopback,authentication.dmarc_pass,authentication.dmarc_fail,authentication.dmarc_none,authentication.dmarc_temporary_error,authentication.dmarc_permanent_error,review.hold_created,review.approved,review.rejected,review.expired_approved,review.expired_rejected,suppression.recipient_blocked,suppression.hard_bounce_applied,suppression.complaint_applied,queue.inbound_processing,queue.outbound_submission,submission.upstream_accepted,submission.local_loopback_accepted,submission.temporary_failure,submission.provider_rejected,submission.local_retries_exhausted,submission.cancelled,submission.policy_budget_expired,submission.sending_setup_expired,delivery.recipient_server_accepted,delivery.temporary_delay,delivery.permanent_bounce,delivery.transient_bounce,delivery.undetermined_bounce,complaint.recipient_reported") } func validAppendInput() AppendInput { diff --git a/internal/outbound/provider_submit.go b/internal/outbound/provider_submit.go index 79668a82b..fa01de163 100644 --- a/internal/outbound/provider_submit.go +++ b/internal/outbound/provider_submit.go @@ -139,6 +139,10 @@ func NewProviderSubmitter(relay *SMTPRelay, gate sendingpolicy.Gate) *ProviderSu // tagged with. Empty means no header (dev/self-host without SES). func (s *ProviderSubmitter) SetSESConfigurationSet(name string) { s.sesConfigSet = name } +// SESConfigurationSet reports the configured configuration set, for wiring +// tests that must prove delivery feedback stayed switched on. +func (s *ProviderSubmitter) SESConfigurationSet() string { return s.sesConfigSet } + // SubmitOnce makes exactly one provider call for one authorized attempt. // // The sequence is fixed and every early exit is I/O-free: prove the envelope is diff --git a/internal/outboundsend/gate_worker_test.go b/internal/outboundsend/gate_worker_test.go new file mode 100644 index 000000000..78d9ec641 --- /dev/null +++ b/internal/outboundsend/gate_worker_test.go @@ -0,0 +1,526 @@ +package outboundsend_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/riverqueue/river" + + "github.com/tokencanopy/e2a/internal/delivery" + "github.com/tokencanopy/e2a/internal/messagelifecycle" + "github.com/tokencanopy/e2a/internal/outboundsend" + "github.com/tokencanopy/e2a/internal/sendingpolicy" +) + +// These tests pin the fixed worker order over the sending-protection gate: +// Reserve → rate → suppression → ConsumeAttempt → authorized submit, with +// every hold snoozing without provider I/O, every deferral/cancellation +// returning the right ledger, and every finite hold persisting a class whose +// derived deadline decides expiry and its lifecycle reason. + +func isSnooze(err error) bool { + var snooze *river.JobSnoozeError + return errors.As(err, &snooze) +} + +func isCancel(err error) bool { + var cancel *river.JobCancelError + return errors.As(err, &cancel) +} + +func TestGatedWorker_AllowedPathAuthorizesThenSubmits(t *testing.T) { + st := &fakeStore{job: acceptedJob("msg_1")} + dl := &fakeDeliverer{out: outboundsend.DeliverOutcome{ProviderMessageID: "ses-1", SentAs: "relay"}} + g := allowAll() + if err := outboundsend.NewSendWorker(st, dl).WithGate(g).Work(context.Background(), gatedJob("msg_1", 1)); err != nil { + t.Fatalf("Work: %v", err) + } + if g.reserves != 1 || g.consumes != 1 || dl.calls != 1 || len(st.sent) != 1 { + t.Fatalf("reserves=%d consumes=%d delivers=%d sent=%d, want 1/1/1/1", g.reserves, g.consumes, dl.calls, len(st.sent)) + } + if len(g.deferred)+len(g.cancelled) != 0 { + t.Fatalf("deferred=%v cancelled=%v on an allowed path", g.deferred, g.cancelled) + } +} + +func TestGatedWorker_EarlyHoldSnoozesWithoutProviderIOAndPersistsClass(t *testing.T) { + for reason, want := range map[string]outboundsend.HoldClass{ + sendingpolicy.ReasonAccountDailyBudget: outboundsend.HoldPolicyBudget, + sendingpolicy.ReasonGlobalProbation: outboundsend.HoldPolicyBudget, + sendingpolicy.ReasonTenantNotReady: outboundsend.HoldTenantSetup, + sendingpolicy.ReasonTenantUnnamed: outboundsend.HoldTenantSetup, + sendingpolicy.ReasonRampCapacity: outboundsend.HoldRateRampOrProvider, + sendingpolicy.ReasonSendingIdentityUnverified: outboundsend.HoldRateRampOrProvider, + } { + j := acceptedJob("msg_hold") + j.AcceptedAt = time.Now().Add(-time.Hour) + st := &fakeStore{job: j} + dl := &fakeDeliverer{} + g := &fakeGate{reserve: sendingpolicy.Decision{Allow: false, Reason: reason, RetryAt: time.Now().Add(2 * time.Hour)}} + err := outboundsend.NewSendWorker(st, dl).WithGate(g).Work(context.Background(), gatedJob("msg_hold", 1)) + if !isSnooze(err) { + t.Fatalf("%s: err = %v, want snooze", reason, err) + } + if dl.calls != 0 || len(st.failed) != 0 || g.consumes != 0 { + t.Fatalf("%s: delivers=%d failed=%d consumes=%d, want no I/O and no terminal", reason, dl.calls, len(st.failed), g.consumes) + } + if len(st.holds) != 1 || st.holds[0].class != want { + t.Fatalf("%s: holds = %+v, want one %s hold", reason, st.holds, want) + } + // A first-observed tenant-setup hold starts its clock at the + // observation; every other class starts at the latest of the + // message's own timestamps. + if want == outboundsend.HoldTenantSetup { + if st.holds[0].anchor.Before(j.AcceptedAt.Add(time.Hour - time.Minute)) { + t.Fatalf("%s: anchor = %v, want the observation time, not accept", reason, st.holds[0].anchor) + } + } else if !st.holds[0].anchor.Equal(j.AcceptedAt) { + t.Fatalf("%s: anchor = %v, want accept %v", reason, st.holds[0].anchor, j.AcceptedAt) + } + if len(st.released) != 1 { + t.Fatalf("%s: claim releases = %v, want one", reason, st.released) + } + } +} + +func TestGatedWorker_PauseHoldIsIndefiniteAndPersistsNothing(t *testing.T) { + j := acceptedJob("msg_paused") + j.AcceptedAt = time.Now().Add(-30 * 24 * time.Hour) // far past every finite horizon + st := &fakeStore{job: j} + g := &fakeGate{reserve: sendingpolicy.Decision{Allow: false, Reason: sendingpolicy.ReasonAccountPaused}} + err := outboundsend.NewSendWorker(st, &fakeDeliverer{}).WithGate(g).Work(context.Background(), gatedJob("msg_paused", 1)) + if !isSnooze(err) { + t.Fatalf("err = %v, want snooze — a pause waits for an operator", err) + } + if len(st.holds) != 0 || len(st.failed) != 0 { + t.Fatalf("holds=%+v failed=%+v, want neither for a pause", st.holds, st.failed) + } +} + +func TestGatedWorker_PauseNeverEvaluatesADeadlineButNeverExtendsIt(t *testing.T) { + // Paused with a budget deadline already eight days gone: the paused job + // only waits. Nothing is failed, nothing rewritten. + j := acceptedJob("msg_paused_budget") + j.LocalHoldClass, j.LocalHoldAnchor = outboundsend.HoldPolicyBudget, time.Now().Add(-8*24*time.Hour) + st := &fakeStore{job: j} + g := &fakeGate{reserve: sendingpolicy.Decision{Allow: false, Reason: sendingpolicy.ReasonAccountPaused}} + if err := outboundsend.NewSendWorker(st, &fakeDeliverer{}).WithGate(g).Work(context.Background(), gatedJob("msg_paused_budget", 1)); !isSnooze(err) { + t.Fatalf("paused err = %v, want snooze — a paused job evaluates no deadline", err) + } + if len(st.failed) != 0 || len(st.holds) != 0 { + t.Fatalf("failed=%+v holds=%+v, want nothing touched while paused", st.failed, st.holds) + } + // After resume the first hold it meets applies the unextended deadline. + g = &fakeGate{reserve: sendingpolicy.Decision{Allow: false, Reason: sendingpolicy.ReasonGlobalAllBudget, RetryAt: time.Now().Add(time.Hour)}} + err := outboundsend.NewSendWorker(st, &fakeDeliverer{}).WithGate(g).Work(context.Background(), gatedJob("msg_paused_budget", 2)) + if !isCancel(err) || len(st.failed) != 1 || st.failed[0].reason != messagelifecycle.ReasonSubmissionPolicyBudgetExpired { + t.Fatalf("after resume err=%v failed=%+v, want the budget deadline to fire with its own reason", err, st.failed) + } +} + +func TestGatedWorker_BudgetHoldPromotesAnyClassAndKeepsTheAnchor(t *testing.T) { + anchor := time.Now().Add(-2 * time.Hour) + for _, existing := range []outboundsend.HoldClass{outboundsend.HoldRateRampOrProvider, outboundsend.HoldTenantSetup} { + j := acceptedJob("msg_promote") + j.LocalHoldClass, j.LocalHoldAnchor = existing, anchor + st := &fakeStore{job: j} + g := &fakeGate{reserve: sendingpolicy.Decision{Allow: false, Reason: sendingpolicy.ReasonGlobalAllBudget, RetryAt: time.Now().Add(time.Hour)}} + if err := outboundsend.NewSendWorker(st, &fakeDeliverer{}).WithGate(g).Work(context.Background(), gatedJob("msg_promote", 1)); !isSnooze(err) { + t.Fatalf("%s: err = %v, want snooze", existing, err) + } + if len(st.holds) != 1 || st.holds[0].class != outboundsend.HoldPolicyBudget || !st.holds[0].anchor.Equal(anchor) { + t.Fatalf("%s: holds = %+v, want promotion to policy_budget with the anchor kept", existing, st.holds) + } + } + // And policy_budget never changes again, even under a later setup hold. + j := acceptedJob("msg_sticky") + j.LocalHoldClass, j.LocalHoldAnchor = outboundsend.HoldPolicyBudget, anchor + st := &fakeStore{job: j} + g := &fakeGate{reserve: sendingpolicy.Decision{Allow: false, Reason: sendingpolicy.ReasonTenantNotReady}} + if err := outboundsend.NewSendWorker(st, &fakeDeliverer{}).WithGate(g).Work(context.Background(), gatedJob("msg_sticky", 1)); !isSnooze(err) { + t.Fatalf("err = %v, want snooze", err) + } + if len(st.holds) != 0 { + t.Fatalf("holds = %+v, want no rewrite of a policy_budget hold", st.holds) + } +} + +func TestGatedWorker_ExpiryReasonFollowsTheClass(t *testing.T) { + for _, tc := range []struct { + class outboundsend.HoldClass + age time.Duration + reason messagelifecycle.ReasonCode + hold string + }{ + {outboundsend.HoldPolicyBudget, 7*24*time.Hour + time.Minute, messagelifecycle.ReasonSubmissionPolicyBudgetExpired, sendingpolicy.ReasonGlobalAllBudget}, + {outboundsend.HoldTenantSetup, 72*time.Hour + time.Minute, messagelifecycle.ReasonSubmissionSendingSetupExpired, sendingpolicy.ReasonTenantNotReady}, + {outboundsend.HoldRateRampOrProvider, 72*time.Hour + time.Minute, messagelifecycle.ReasonSubmissionLocalRetriesExhausted, sendingpolicy.ReasonRampCapacity}, + } { + j := acceptedJob("msg_expire") + j.LocalHoldClass, j.LocalHoldAnchor = tc.class, time.Now().Add(-tc.age) + st := &fakeStore{job: j} + g := &fakeGate{reserve: sendingpolicy.Decision{Allow: false, Reason: tc.hold, RetryAt: time.Now().Add(time.Hour)}} + err := outboundsend.NewSendWorker(st, &fakeDeliverer{}).WithGate(g).Work(context.Background(), gatedJob("msg_expire", 1)) + if !isCancel(err) { + t.Fatalf("%s: err = %v, want cancel", tc.class, err) + } + if len(st.failed) != 1 || st.failed[0].reason != tc.reason || st.failed[0].source != delivery.FailureSourceLocal { + t.Fatalf("%s: failed = %+v, want one local failure with reason %s", tc.class, st.failed, tc.reason) + } + if len(g.cancelled) != 1 { + t.Fatalf("%s: cancelled = %v, want the attempt given back", tc.class, g.cancelled) + } + } + // One minute short of the deadline still snoozes. + j := acceptedJob("msg_almost") + j.LocalHoldClass, j.LocalHoldAnchor = outboundsend.HoldPolicyBudget, time.Now().Add(-7*24*time.Hour+time.Minute) + st := &fakeStore{job: j} + g := &fakeGate{reserve: sendingpolicy.Decision{Allow: false, Reason: sendingpolicy.ReasonGlobalAllBudget, RetryAt: time.Now().Add(time.Hour)}} + if err := outboundsend.NewSendWorker(st, &fakeDeliverer{}).WithGate(g).Work(context.Background(), gatedJob("msg_almost", 1)); !isSnooze(err) { + t.Fatalf("err = %v, want snooze one minute before the deadline", err) + } +} + +func TestGatedWorker_TerminalHoldCancelsNow(t *testing.T) { + for _, reason := range []string{sendingpolicy.ReasonAccountDeleted, sendingpolicy.ReasonClassChanged, sendingpolicy.ReasonRampUnavailable} { + st := &fakeStore{job: acceptedJob("msg_terminal")} + g := &fakeGate{reserve: sendingpolicy.Decision{Allow: false, Reason: reason, Terminal: true}} + err := outboundsend.NewSendWorker(st, &fakeDeliverer{}).WithGate(g).Work(context.Background(), gatedJob("msg_terminal", 1)) + if !isCancel(err) { + t.Fatalf("%s: err = %v, want cancel", reason, err) + } + if len(st.failed) != 1 || st.failed[0].reason != messagelifecycle.ReasonSubmissionCancelled { + t.Fatalf("%s: failed = %+v, want one local cancellation", reason, st.failed) + } + } +} + +func TestGatedWorker_RateDeferralDefersTheAttempt(t *testing.T) { + st := &fakeStore{job: acceptedJob("msg_rate")} + g := allowAll() + gate := &fakeRateGate{decision: outboundsend.RateDecision{Allowed: false, RetryAt: time.Now().Add(30 * time.Second)}, window: time.Minute} + err := outboundsend.NewSendWorker(st, &fakeDeliverer{}).WithGate(g).WithRateGate(gate).Work(context.Background(), gatedJob("msg_rate", 1)) + if !isSnooze(err) { + t.Fatalf("err = %v, want snooze", err) + } + if len(g.deferred) != 1 || g.consumes != 0 { + t.Fatalf("deferred=%v consumes=%d, want the attempt deferred before final authorization", g.deferred, g.consumes) + } + if len(st.holds) != 1 || st.holds[0].class != outboundsend.HoldRateRampOrProvider { + t.Fatalf("holds = %+v, want a rate/ramp/provider hold", st.holds) + } +} + +func TestGatedWorker_SuppressionCancelsTheAttempt(t *testing.T) { + st := &fakeStore{job: acceptedJob("msg_sup"), suppressed: []string{"b@y.com"}} + g := allowAll() + dl := &fakeDeliverer{} + err := outboundsend.NewSendWorker(st, dl).WithGate(g).Work(context.Background(), gatedJob("msg_sup", 1)) + if !isCancel(err) || dl.calls != 0 { + t.Fatalf("err=%v delivers=%d, want cancel with no I/O", err, dl.calls) + } + if len(g.cancelled) != 1 || g.consumes != 0 { + t.Fatalf("cancelled=%v consumes=%d, want the attempt cancelled before final authorization", g.cancelled, g.consumes) + } +} + +func TestGatedWorker_FinalAuthorizationHoldSnoozesWithoutProviderIO(t *testing.T) { + j := acceptedJob("msg_late_hold") + j.AcceptedAt = time.Now().Add(-time.Hour) + st := &fakeStore{job: j} + dl := &fakeDeliverer{} + g := &fakeGate{reserve: sendingpolicy.Decision{Allow: true}, consume: sendingpolicy.Decision{Allow: false, Reason: sendingpolicy.ReasonAccountSharedBudget, RetryAt: time.Now().Add(time.Hour)}} + err := outboundsend.NewSendWorker(st, dl).WithGate(g).Work(context.Background(), gatedJob("msg_late_hold", 1)) + if !isSnooze(err) || dl.calls != 0 { + t.Fatalf("err=%v delivers=%d, want snooze with no I/O", err, dl.calls) + } + if len(st.holds) != 1 || st.holds[0].class != outboundsend.HoldPolicyBudget { + t.Fatalf("holds = %+v, want a policy_budget hold from the late gate", st.holds) + } +} + +func TestGatedWorker_GateOutageSnoozesWithoutBurningAnAttempt(t *testing.T) { + for name, g := range map[string]*fakeGate{ + "reserve": {reserveErr: errors.New("policy db down")}, + "authorize": {reserve: sendingpolicy.Decision{Allow: true}, consumeErr: errors.New("policy db down")}, + } { + st := &fakeStore{job: acceptedJob("msg_gate_down")} + dl := &fakeDeliverer{} + err := outboundsend.NewSendWorker(st, dl).WithGate(g).Work(context.Background(), gatedJob("msg_gate_down", 1)) + if !isSnooze(err) || dl.calls != 0 || len(st.failed) != 0 { + t.Fatalf("%s: err=%v delivers=%d failed=%d, want snooze, no I/O, no terminal", name, err, dl.calls, len(st.failed)) + } + if len(st.released) != 1 { + t.Fatalf("%s: claim releases = %v, want one", name, st.released) + } + } +} + +func TestGatedWorker_ProviderEvidenceSettlesTheOperation(t *testing.T) { + j := acceptedJob("msg_evidence") + j.ProviderAccepted, j.ProviderMessageID = true, "ses-evidence" + st := &fakeStore{job: j} + dl := &fakeDeliverer{} + g := allowAll() + if err := outboundsend.NewSendWorker(st, dl).WithGate(g).Work(context.Background(), gatedJob("msg_evidence", 2)); err != nil { + t.Fatalf("Work: %v", err) + } + if dl.calls != 0 || len(st.sent) != 1 || g.reserves != 0 { + t.Fatalf("delivers=%d sent=%d reserves=%d, want settle without resubmit or a new reservation", dl.calls, len(st.sent), g.reserves) + } + if g.lookupCalls != 1 || len(g.settled) != 1 || g.settled[0] != sendingpolicy.SettlementProviderAccepted { + t.Fatalf("lookups=%d settled=%v, want the operation settled as accepted", g.lookupCalls, g.settled) + } + if len(g.settledIDs) != 1 || g.settledIDs[0] != "ses-evidence" { + t.Fatalf("settled ids = %v, want the evidence's provider id carried into the settlement", g.settledIDs) + } +} + +func TestGatedWorker_LegacyJobResolvesThroughTheAcceptPath(t *testing.T) { + st := &fakeStore{job: acceptedJob("msg_legacy")} + dl := &fakeDeliverer{out: outboundsend.DeliverOutcome{ProviderMessageID: "ses-legacy"}} + g := allowAll() + resolved := 0 + w := outboundsend.NewSendWorker(st, dl).WithGate(g).WithOperationResolver(func(_ context.Context, id string) (sendingpolicy.AcceptanceDecision, sendingpolicy.OperationRef, error) { + resolved++ + return sendingpolicy.AcceptanceAccept, refFor(id), nil + }) + if err := w.Work(context.Background(), job("msg_legacy", 1)); err != nil { + t.Fatalf("Work: %v", err) + } + if resolved != 1 || g.reserves != 1 || dl.calls != 1 { + t.Fatalf("resolved=%d reserves=%d delivers=%d, want the legacy job authorized like a new one", resolved, g.reserves, dl.calls) + } + + // A paused account at resolution holds; an orphan source cancels; no + // resolver at all fails closed. + st = &fakeStore{job: acceptedJob("msg_legacy_paused")} + w = outboundsend.NewSendWorker(st, &fakeDeliverer{}).WithGate(allowAll()).WithOperationResolver(func(context.Context, string) (sendingpolicy.AcceptanceDecision, sendingpolicy.OperationRef, error) { + return sendingpolicy.AcceptanceSendingPaused, sendingpolicy.OperationRef{}, nil + }) + if err := w.Work(context.Background(), job("msg_legacy_paused", 1)); !isSnooze(err) { + t.Fatalf("paused legacy: err = %v, want snooze", err) + } + st = &fakeStore{job: acceptedJob("msg_legacy_orphan")} + w = outboundsend.NewSendWorker(st, &fakeDeliverer{}).WithGate(allowAll()).WithOperationResolver(func(context.Context, string) (sendingpolicy.AcceptanceDecision, sendingpolicy.OperationRef, error) { + return "", sendingpolicy.OperationRef{}, sendingpolicy.ErrSourceUnavailable + }) + if err := w.Work(context.Background(), job("msg_legacy_orphan", 1)); !isCancel(err) || len(st.failed) != 1 { + t.Fatalf("orphan legacy: err=%v failed=%d, want cancel with one local failure", err, len(st.failed)) + } + st = &fakeStore{job: acceptedJob("msg_legacy_unwired")} + dl = &fakeDeliverer{} + if err := outboundsend.NewSendWorker(st, dl).WithGate(allowAll()).Work(context.Background(), job("msg_legacy_unwired", 1)); !isCancel(err) || dl.calls != 0 { + t.Fatalf("unwired resolver: err=%v delivers=%d, want cancel with no I/O", err, dl.calls) + } +} + +func TestGatedWorker_TenantReadinessMovesSetupHoldToRateClassOnce(t *testing.T) { + anchor := time.Now().Add(-70 * time.Hour) + ready := anchor.Add(60 * time.Hour) // inside the 72h setup deadline + j := acceptedJob("msg_ready") + j.LocalHoldClass, j.LocalHoldAnchor, j.TenantReadyAt = outboundsend.HoldTenantSetup, anchor, ready + st := &fakeStore{job: j} + dl := &fakeDeliverer{out: outboundsend.DeliverOutcome{ProviderMessageID: "ses-ready"}} + if err := outboundsend.NewSendWorker(st, dl).WithGate(allowAll()).Work(context.Background(), gatedJob("msg_ready", 1)); err != nil { + t.Fatalf("Work: %v", err) + } + if len(st.holds) != 1 || st.holds[0].class != outboundsend.HoldRateRampOrProvider || !st.holds[0].anchor.Equal(ready) { + t.Fatalf("holds = %+v, want the one-way move to rate_ramp_or_provider anchored at readiness", st.holds) + } + + // Readiness that landed AFTER the setup deadline does not rescue the + // message: it expires as setup on its next hold. + late := acceptedJob("msg_late_ready") + late.LocalHoldClass, late.LocalHoldAnchor, late.TenantReadyAt = outboundsend.HoldTenantSetup, time.Now().Add(-80*time.Hour), time.Now().Add(-time.Hour) + st = &fakeStore{job: late} + g := &fakeGate{reserve: sendingpolicy.Decision{Allow: false, Reason: sendingpolicy.ReasonRampCapacity, RetryAt: time.Now().Add(time.Hour)}} + err := outboundsend.NewSendWorker(st, &fakeDeliverer{}).WithGate(g).Work(context.Background(), gatedJob("msg_late_ready", 1)) + if !isCancel(err) || len(st.failed) != 1 || st.failed[0].reason != messagelifecycle.ReasonSubmissionSendingSetupExpired { + t.Fatalf("late readiness: err=%v failed=%+v, want setup expiry", err, st.failed) + } +} + +func TestGatedWorker_FirstHoldAnchorsAtTheLatestOfAcceptScheduleReviewResume(t *testing.T) { + base := time.Now().Add(-10 * 24 * time.Hour) + j := acceptedJob("msg_anchor") + j.AcceptedAt = base + j.ScheduledAt = base.Add(24 * time.Hour) + j.ReviewedAt = base.Add(48 * time.Hour) + j.LastResumedAt = base.Add(9*24*time.Hour + 23*time.Hour) // an hour ago: the latest + st := &fakeStore{job: j} + g := &fakeGate{reserve: sendingpolicy.Decision{Allow: false, Reason: sendingpolicy.ReasonRampCapacity, RetryAt: time.Now().Add(time.Hour)}} + if err := outboundsend.NewSendWorker(st, &fakeDeliverer{}).WithGate(g).Work(context.Background(), gatedJob("msg_anchor", 1)); !isSnooze(err) { + t.Fatalf("err = %v, want snooze — a ten-day-old accept is not the clock, the resume an hour ago is", err) + } + if len(st.holds) != 1 || !st.holds[0].anchor.Equal(j.LastResumedAt) { + t.Fatalf("holds = %+v, want anchored at the last resume", st.holds) + } +} + +func TestGatedWorker_AcceptanceUnknownIsRetriedAsANewOrdinalNotSettled(t *testing.T) { + st := &fakeStore{job: acceptedJob("msg_unknown")} + dl := &fakeDeliverer{out: outboundsend.DeliverOutcome{Err: errors.New("data final: acceptance unknown"), AcceptanceUnknown: true}} + g := allowAll() + err := outboundsend.NewSendWorker(st, dl).WithGate(g).Work(context.Background(), gatedJob("msg_unknown", 1)) + if err == nil || isSnooze(err) || isCancel(err) { + t.Fatalf("err = %v, want a plain retryable error (River's next attempt returns to Reserve)", err) + } + if len(st.temporary) != 1 || len(st.failed) != 0 || len(g.settled) != 0 { + t.Fatalf("temporary=%d failed=%d settled=%v, want a temporary record and nothing settled", len(st.temporary), len(st.failed), g.settled) + } +} + +func TestGatedWorker_HoldConstantsMatchThePolicyDefault(t *testing.T) { + if got := time.Duration(sendingpolicy.DisabledPolicy().BudgetHoldMaxDays) * 24 * time.Hour; got != outboundsend.PolicyBudgetHoldHorizon { + t.Fatalf("PolicyBudgetHoldHorizon = %s, policy budget_hold_max_days default = %s", outboundsend.PolicyBudgetHoldHorizon, got) + } +} + +func TestGatedWorker_GateOutageIsBoundedByTheHoldDeadline(t *testing.T) { + j := acceptedJob("msg_gate_down_long") + j.AcceptedAt = time.Now().Add(-73 * time.Hour) + st := &fakeStore{job: j} + dl := &fakeDeliverer{} + g := &fakeGate{reserveErr: errors.New("policy db down")} + err := outboundsend.NewSendWorker(st, dl).WithGate(g).Work(context.Background(), gatedJob("msg_gate_down_long", 1)) + if !isCancel(err) || dl.calls != 0 { + t.Fatalf("err=%v delivers=%d, want the 72-hour expiry with no I/O", err, dl.calls) + } + if len(st.failed) != 1 || st.failed[0].reason != messagelifecycle.ReasonSubmissionLocalRetriesExhausted { + t.Fatalf("failed = %+v, want local_retries_exhausted", st.failed) + } + // Inside the horizon it holds as rate/ramp/provider and snoozes. + j = acceptedJob("msg_gate_down_short") + j.AcceptedAt = time.Now().Add(-time.Hour) + st = &fakeStore{job: j} + if err := outboundsend.NewSendWorker(st, dl).WithGate(g).Work(context.Background(), gatedJob("msg_gate_down_short", 1)); !isSnooze(err) { + t.Fatalf("err = %v, want snooze", err) + } + if len(st.holds) != 1 || st.holds[0].class != outboundsend.HoldRateRampOrProvider { + t.Fatalf("holds = %+v, want a rate/ramp/provider hold", st.holds) + } +} + +func TestGatedWorker_ReadinessLossDoesNotReplaceARateClass(t *testing.T) { + anchor := time.Now().Add(-time.Hour) + j := acceptedJob("msg_keep_rate") + j.LocalHoldClass, j.LocalHoldAnchor = outboundsend.HoldRateRampOrProvider, anchor + st := &fakeStore{job: j} + g := &fakeGate{reserve: sendingpolicy.Decision{Allow: false, Reason: sendingpolicy.ReasonTenantNotReady}} + if err := outboundsend.NewSendWorker(st, &fakeDeliverer{}).WithGate(g).Work(context.Background(), gatedJob("msg_keep_rate", 1)); !isSnooze(err) { + t.Fatalf("err = %v, want snooze", err) + } + if len(st.holds) != 0 { + t.Fatalf("holds = %+v, want the persisted rate class left alone", st.holds) + } +} + +func TestGatedWorker_ProviderOutagePersistsTheHoldAndHonorsTheBudgetClock(t *testing.T) { + // First outage: enters the rate/ramp/provider class anchored at accept. + j := acceptedJob("msg_outage") + j.AcceptedAt = time.Now().Add(-time.Hour) + st := &fakeStore{job: j} + dl := &fakeDeliverer{out: outboundsend.DeliverOutcome{Err: errors.New("connection refused"), Outage: true}} + if err := outboundsend.NewSendWorker(st, dl).WithGate(allowAll()).Work(context.Background(), gatedJob("msg_outage", 1)); !isSnooze(err) { + t.Fatalf("err = %v, want snooze", err) + } + if len(st.holds) != 1 || st.holds[0].class != outboundsend.HoldRateRampOrProvider || !st.holds[0].anchor.Equal(j.AcceptedAt) { + t.Fatalf("holds = %+v, want rate/ramp/provider anchored at accept", st.holds) + } + // Under a policy_budget hold four days old, an outage keeps waiting on + // the seven-day clock instead of the 72-hour one. + j = acceptedJob("msg_outage_budget") + j.AcceptedAt = time.Now().Add(-5 * 24 * time.Hour) + j.LocalHoldClass, j.LocalHoldAnchor = outboundsend.HoldPolicyBudget, time.Now().Add(-4*24*time.Hour) + st = &fakeStore{job: j} + if err := outboundsend.NewSendWorker(st, dl).WithGate(allowAll()).Work(context.Background(), gatedJob("msg_outage_budget", 1)); !isSnooze(err) { + t.Fatalf("budget-held outage err = %v, want snooze on the seven-day clock", err) + } + if len(st.holds) != 0 || len(st.failed) != 0 { + t.Fatalf("holds=%+v failed=%+v, want the budget class untouched", st.holds, st.failed) + } + // An outage that expires a tenant_setup class never emits the setup + // reason: setup was not what blocked the send at the end. + j = acceptedJob("msg_outage_setup") + j.LocalHoldClass, j.LocalHoldAnchor = outboundsend.HoldTenantSetup, time.Now().Add(-73*time.Hour) + st = &fakeStore{job: j} + err := outboundsend.NewSendWorker(st, dl).WithGate(allowAll()).Work(context.Background(), gatedJob("msg_outage_setup", 1)) + if err == nil || len(st.failed) != 1 || st.failed[0].reason != messagelifecycle.ReasonSubmissionLocalRetriesExhausted { + t.Fatalf("err=%v failed=%+v, want local_retries_exhausted, never sending_setup_expired", err, st.failed) + } +} + +func TestGatedWorker_EvidenceSettleUnderATerminalWriteSettlesTheOperation(t *testing.T) { + // A suppression arrives for a message whose earlier attempt dialed and + // whose provider evidence has since landed: the guarded terminal write + // settles the row as SENT, and the dialed attempt must be settled too. + st := &fakeStore{job: acceptedJob("msg_late_evidence"), suppressed: []string{"b@y.com"}, settleStatus: delivery.StatusSent, settleProviderID: "ses-under-terminal"} + g := allowAll() + if err := outboundsend.NewSendWorker(st, &fakeDeliverer{}).WithGate(g).Work(context.Background(), gatedJob("msg_late_evidence", 2)); !isCancel(err) { + t.Fatalf("err = %v, want cancel", err) + } + if g.lookupCalls != 1 || len(g.settled) != 1 || g.settled[0] != sendingpolicy.SettlementProviderAccepted { + t.Fatalf("lookups=%d settled=%v, want the operation settled as accepted from the evidence", g.lookupCalls, g.settled) + } + if len(g.settledIDs) != 1 || g.settledIDs[0] != "ses-under-terminal" { + t.Fatalf("settled ids = %v, want the store's resolved provider id carried into the settlement", g.settledIDs) + } +} + +func TestHoldClassForNamesEveryReasonExplicitly(t *testing.T) { + // Every hold reason the gate can emit decides a horizon; the mapping is + // by name, and an unknown name takes the shorter clock. + cases := map[string]outboundsend.HoldClass{ + sendingpolicy.ReasonAccountPaused: "", + sendingpolicy.ReasonAccountDailyBudget: outboundsend.HoldPolicyBudget, + sendingpolicy.ReasonAccountSharedBudget: outboundsend.HoldPolicyBudget, + sendingpolicy.ReasonGlobalAllBudget: outboundsend.HoldPolicyBudget, + sendingpolicy.ReasonGlobalProbation: outboundsend.HoldPolicyBudget, + sendingpolicy.ReasonGlobalCritical: outboundsend.HoldPolicyBudget, + sendingpolicy.ReasonGlobalViolation: outboundsend.HoldPolicyBudget, + sendingpolicy.ReasonTenantNotReady: outboundsend.HoldTenantSetup, + sendingpolicy.ReasonTenantUnnamed: outboundsend.HoldTenantSetup, + sendingpolicy.ReasonRampCapacity: outboundsend.HoldRateRampOrProvider, + sendingpolicy.ReasonSendingIdentityUnverified: outboundsend.HoldRateRampOrProvider, + "some_future_budget_exhausted": outboundsend.HoldRateRampOrProvider, + } + for reason, want := range cases { + if got := outboundsend.HoldClassFor(reason); got != want { + t.Errorf("HoldClassFor(%q) = %q, want %q", reason, got, want) + } + } +} + +func TestGatedWorker_OperationReferenceMustNameThisMessage(t *testing.T) { + st := &fakeStore{job: acceptedJob("msg_a")} + dl := &fakeDeliverer{} + g := allowAll() + rj := job("msg_a", 1) + other := refFor("msg_b") + rj.Args.OperationRef = &other + err := outboundsend.NewSendWorker(st, dl).WithGate(g).Work(context.Background(), rj) + if !isCancel(err) || dl.calls != 0 || g.reserves != 0 { + t.Fatalf("err=%v delivers=%d reserves=%d, want cancel before any ledger call", err, dl.calls, g.reserves) + } + if len(st.failed) != 1 || st.failed[0].reason != messagelifecycle.ReasonSubmissionCancelled { + t.Fatalf("failed = %+v, want one local cancellation", st.failed) + } +} + +func TestGatedWorker_FailedSettlementAfterAcceptanceIsRetriedNotResent(t *testing.T) { + st := &fakeStore{job: acceptedJob("msg_resettle")} + dl := &fakeDeliverer{out: outboundsend.DeliverOutcome{ProviderMessageID: "ses-resettle", SettlementErr: errors.New("settle: db blip")}} + g := allowAll() + if err := outboundsend.NewSendWorker(st, dl).WithGate(g).Work(context.Background(), gatedJob("msg_resettle", 1)); err != nil { + t.Fatalf("Work: %v — an accepted send must never surface a settlement failure as a send error", err) + } + if dl.calls != 1 || len(st.sent) != 1 { + t.Fatalf("delivers=%d sent=%d, want exactly one of each", dl.calls, len(st.sent)) + } + if len(g.settled) != 1 || g.settled[0] != sendingpolicy.SettlementProviderAccepted || g.settledIDs[0] != "ses-resettle" { + t.Fatalf("settlements = %v / %v, want one retried acceptance carrying the provider id", g.settled, g.settledIDs) + } +} diff --git a/internal/outboundsend/jobs.go b/internal/outboundsend/jobs.go index 8636ba6d9..2819eb3f1 100644 --- a/internal/outboundsend/jobs.go +++ b/internal/outboundsend/jobs.go @@ -2,6 +2,7 @@ package outboundsend import ( "context" + "fmt" "time" "github.com/jackc/pgx/v5" @@ -9,6 +10,7 @@ import ( "github.com/riverqueue/river" "github.com/tokencanopy/e2a/internal/jobs" + "github.com/tokencanopy/e2a/internal/sendingpolicy" ) // Jobs is the outbound-send integration on the shared River client: a @@ -19,23 +21,57 @@ import ( type Jobs struct { store Store deliverer Deliverer - ramp RampGate + gate sendingpolicy.Gate rate RateGate pool *pgxpool.Pool enq jobs.Enqueuer metrics Metrics + + // registered is the send worker the last RegisterJobs call handed to River. + registered *SendWorker } // NewJobs builds the integration with its dependencies (no client yet). pool -// backs the periodic terminal-state reconciler's scan. -func NewJobs(store Store, deliverer Deliverer, pool *pgxpool.Pool, ramp ...RampGate) *Jobs { - j := &Jobs{store: store, deliverer: deliverer, pool: pool, metrics: noopMetrics{}} - if len(ramp) > 0 { - j.ramp = ramp[0] +// backs the periodic terminal-state reconciler's scan and the legacy-argument +// resolver's transaction. +func NewJobs(store Store, deliverer Deliverer, pool *pgxpool.Pool) *Jobs { + return &Jobs{store: store, deliverer: deliverer, pool: pool, metrics: noopMetrics{}} +} + +// WithGate injects the sending-protection gate. Every enqueue then prepares a +// durable operation in the accept transaction, and every worker execution +// authorizes through it. Chainable; nil keeps the gateless default (unit +// tests only — see NewSendWorker). +func (j *Jobs) WithGate(g sendingpolicy.Gate) *Jobs { + if g != nil { + j.gate = g } return j } +// SendWorker builds the fully armed send worker RegisterJobs registers: the +// gate, the legacy resolver, the rate gate, and metrics. It is the one place +// those are wired, and the composition root's test inspects its result. +func (j *Jobs) SendWorker() *SendWorker { + return NewSendWorker(j.store, j.deliverer).WithMetrics(j.metrics).WithRateGate(j.rate).WithGate(j.gate).WithOperationResolver(j.ResolveLegacyOperation) +} + +// TerminalReconcileWorker builds the reconciler RegisterJobs registers. +func (j *Jobs) TerminalReconcileWorker() *TerminalReconcileWorker { + return NewTerminalReconcileWorker(j.pool, j.store).WithMetrics(j.metrics).WithGate(j.gate) +} + +// RegisteredSendWorker returns the send worker the last RegisterJobs call +// registered with River, or nil before any registration. +func (j *Jobs) RegisteredSendWorker() *SendWorker { return j.registered } + +// Gate exposes the wired sending-protection gate, for the composition root's +// wiring test. nil when none is wired. +func (j *Jobs) Gate() sendingpolicy.Gate { return j.gate } + +// Deliverer exposes the wired provider deliverer, for the same test. +func (j *Jobs) Deliverer() Deliverer { return j.deliverer } + // SetEnqueuer injects the shared client so EnqueueSendTx can insert jobs. func (j *Jobs) SetEnqueuer(e jobs.Enqueuer) { j.enq = e } @@ -62,8 +98,12 @@ func (j *Jobs) WithRateGate(g RateGate) *Jobs { // RegisterJobs adds the SendWorker and terminal-state safety net to the shared // client's bundle. Implements jobs.Registrar. func (j *Jobs) RegisterJobs(w *river.Workers) []*river.PeriodicJob { - river.AddWorker(w, NewSendWorker(j.store, j.deliverer, j.ramp).WithMetrics(j.metrics).WithRateGate(j.rate)) - river.AddWorker(w, NewTerminalReconcileWorker(j.pool, j.store, j.ramp).WithMetrics(j.metrics)) + // The worker registered here is recorded so the composition root's + // wiring test can inspect the exact object River will run, not merely + // what a constructor would produce. + j.registered = j.SendWorker() + river.AddWorker(w, j.registered) + river.AddWorker(w, j.TerminalReconcileWorker()) return []*river.PeriodicJob{ river.NewPeriodicJob( river.PeriodicInterval(terminalReconcileInterval), @@ -119,7 +159,31 @@ func (j *Jobs) EnqueueScheduledSendTx(ctx context.Context, tx pgx.Tx, messageID // enqueueSendTx is the shared outbox insert behind the immediate and scheduled // entry points. A non-zero `at` sets InsertOpts.ScheduledAt; a zero value omits // it (River defaults ScheduledAt to now, i.e. immediately available). +// +// With a gate wired, the durable provider operation is prepared HERE, after +// the message insert and before the River insert, in the caller's transaction: +// a paused account is refused at the door (ErrSendingPaused) rather than +// queueing mail that can never leave, and the job carries the operation +// reference so the worker never derives purpose or attribution on its own. func (j *Jobs) enqueueSendTx(ctx context.Context, tx pgx.Tx, messageID string, at time.Time) (int64, error) { + args := OutboundSendArgs{MessageID: messageID} + if j.gate != nil { + decision, ref, err := j.gate.PrepareExternalTx(ctx, tx, messageID) + if err != nil { + return 0, fmt.Errorf("prepare sending operation: %w", err) + } + if decision == sendingpolicy.AcceptanceSendingPaused { + return 0, ErrSendingPaused + } + if ref.IsZero() { + // The only accepted shape without an operation is an exact + // self-send, and those never enqueue. Refusing here keeps a + // prepared-but-operationless job from masquerading as a legacy + // one that the worker would then kill. + return 0, fmt.Errorf("prepare sending operation: message %s has no provider operation", messageID) + } + args.OperationRef = &ref + } opts := &river.InsertOpts{ Queue: jobs.QueueOutbound, MaxAttempts: MaxSendAttempts, @@ -127,9 +191,34 @@ func (j *Jobs) enqueueSendTx(ctx context.Context, tx pgx.Tx, messageID string, a if !at.IsZero() { opts.ScheduledAt = at } - res, err := j.enq.InsertTx(ctx, tx, OutboundSendArgs{MessageID: messageID}, opts) + res, err := j.enq.InsertTx(ctx, tx, args, opts) if err != nil { return 0, err } return res.Job.ID, nil } + +// ResolveLegacyOperation is the compatibility resolver for a job enqueued by +// a pre-floor slot with no operation reference. It runs the same +// PrepareExternalTx an accept transaction runs — idempotent on the durable +// operation row — in its own committed transaction, so an old job and a new +// one authorize identically. There is deliberately no other way to obtain an +// operation from a bare message id. +func (j *Jobs) ResolveLegacyOperation(ctx context.Context, messageID string) (sendingpolicy.AcceptanceDecision, sendingpolicy.OperationRef, error) { + if j.gate == nil || j.pool == nil { + return "", sendingpolicy.OperationRef{}, fmt.Errorf("legacy operation resolver is not wired") + } + tx, err := j.pool.Begin(ctx) + if err != nil { + return "", sendingpolicy.OperationRef{}, fmt.Errorf("begin legacy resolve: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + decision, ref, err := j.gate.PrepareExternalTx(ctx, tx, messageID) + if err != nil { + return "", sendingpolicy.OperationRef{}, err + } + if err := tx.Commit(ctx); err != nil { + return "", sendingpolicy.OperationRef{}, fmt.Errorf("commit legacy resolve: %w", err) + } + return decision, ref, nil +} diff --git a/internal/outboundsend/jobs_gate_test.go b/internal/outboundsend/jobs_gate_test.go new file mode 100644 index 000000000..0c73f4add --- /dev/null +++ b/internal/outboundsend/jobs_gate_test.go @@ -0,0 +1,338 @@ +package outboundsend_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/riverqueue/river" + "github.com/riverqueue/river/rivertype" + + "github.com/tokencanopy/e2a/internal/agent" + "github.com/tokencanopy/e2a/internal/identity" + "github.com/tokencanopy/e2a/internal/jobs" + "github.com/tokencanopy/e2a/internal/outboundsend" + "github.com/tokencanopy/e2a/internal/sendingpolicy" + "github.com/tokencanopy/e2a/internal/testutil" + "github.com/tokencanopy/e2a/internal/usage" + "github.com/tokencanopy/e2a/internal/webhookpub" +) + +// These tests drive the real gate against real Postgres through the jobs +// bundle: the accept transaction prepares the operation, a paused account is +// refused at the door, and a legacy job with no reference authorizes through +// the same path as a new one. + +type gateFixture struct { + t *testing.T + ctx context.Context + pool *pgxpool.Pool + store *identity.Store + adapter outboundsend.Store + gate sendingpolicy.Gate + userID string + agentID string + client jobs.Enqueuer + gated *outboundsend.Jobs + legacy *outboundsend.Jobs +} + +func newGateFixture(t *testing.T) *gateFixture { + t.Helper() + ctx := context.Background() + pool := testutil.TestDB(t) + store := identity.NewStore(pool) + if err := jobs.Migrate(ctx, pool); err != nil { + t.Fatalf("jobs.Migrate: %v", err) + } + user, err := store.CreateOrGetUser(ctx, "owner-gate@example.test", "Owner", "google-gate") + if err != nil { + t.Fatalf("CreateOrGetUser: %v", err) + } + domain := "gate.example.test" + if _, err := store.ClaimOrCreateDomain(ctx, domain, user.ID); err != nil { + t.Fatalf("ClaimOrCreateDomain: %v", err) + } + if err := store.VerifyDomain(ctx, domain, user.ID); err != nil { + t.Fatalf("VerifyDomain: %v", err) + } + ag, err := store.CreateAgent(ctx, "bot@"+domain, domain, "", "", "local", user.ID) + if err != nil { + t.Fatalf("CreateAgent: %v", err) + } + adapter := agent.NewOutboundSendStore(store, webhookpub.NewOutbox(pool, webhookpub.StaticFlag(true)), usage.NewNoopUsageTracker()) + gate := sendingpolicy.NewGate(pool, sendingpolicy.Secrets{}, sendingpolicy.PolicySourceConfig, sendingpolicy.DisabledPolicy()) + gated := outboundsend.NewJobs(adapter, &fakeDeliverer{}, pool).WithGate(gate) + legacy := outboundsend.NewJobs(adapter, &fakeDeliverer{}, pool) + client, err := jobs.New(pool, jobs.Config{}, gated) + if err != nil { + t.Fatalf("jobs.New: %v", err) + } + gated.SetEnqueuer(client) + legacy.SetEnqueuer(client) + return &gateFixture{t: t, ctx: ctx, pool: pool, store: store, adapter: adapter, gate: gate, userID: user.ID, agentID: ag.ID, client: client, gated: gated, legacy: legacy} +} + +// accept runs the accept transaction the API runs, through the given bundle. +func (f *gateFixture) accept(bundle *outboundsend.Jobs, label string) (messageID string, jobID int64, err error) { + f.t.Helper() + err = f.store.WithTx(f.ctx, func(tx pgx.Tx) error { + m, err := f.store.CreateOutboundMessageTx(f.ctx, tx, f.agentID, + []string{label + "@example.test"}, nil, nil, label, "send", "smtp", "", "conv-"+label, + []byte("From: bot\r\n\r\nbody"), "accepted", "bot@gate.example.test", "relay") + if err != nil { + return err + } + messageID = m.ID + jobID, err = bundle.EnqueueSendTx(f.ctx, tx, messageID) + if err != nil { + return err + } + return f.store.StampSendJobIDTx(f.ctx, tx, messageID, jobID) + }) + return messageID, jobID, err +} + +func (f *gateFixture) operationExists(messageID string) bool { + f.t.Helper() + var n int + if err := f.pool.QueryRow(f.ctx, `SELECT count(*) FROM sending_provider_operations WHERE operation_id = $1 AND purpose = 'customer_message'`, messageID).Scan(&n); err != nil { + f.t.Fatal(err) + } + return n == 1 +} + +func TestJobs_EnqueuePreparesTheOperationInTheAcceptTransaction(t *testing.T) { + f := newGateFixture(t) + messageID, jobID, err := f.accept(f.gated, "prepared") + if err != nil { + t.Fatalf("accept: %v", err) + } + var refID string + if err := f.pool.QueryRow(f.ctx, `SELECT args->'operation_ref'->>'id' FROM river_job WHERE id = $1`, jobID).Scan(&refID); err != nil { + t.Fatal(err) + } + if refID != messageID { + t.Fatalf("job carries operation_ref id %q, want the message id %q", refID, messageID) + } + if !f.operationExists(messageID) { + t.Fatal("no customer_message operation was prepared in the accept transaction") + } +} + +func TestJobs_EnqueueRefusesAPausedAccountAndRollsBack(t *testing.T) { + f := newGateFixture(t) + if _, err := f.pool.Exec(f.ctx, ` + INSERT INTO account_sending_controls (user_id, state, reason, actor) VALUES ($1, 'paused', 'test', 'test') + ON CONFLICT (user_id) DO UPDATE SET state = 'paused'`, f.userID); err != nil { + t.Fatal(err) + } + messageID, _, err := f.accept(f.gated, "paused") + if !errors.Is(err, outboundsend.ErrSendingPaused) { + t.Fatalf("accept on a paused account err = %v, want ErrSendingPaused", err) + } + var rows int + if err := f.pool.QueryRow(f.ctx, `SELECT count(*) FROM messages WHERE id = $1`, messageID).Scan(&rows); err != nil { + t.Fatal(err) + } + if rows != 0 { + t.Fatalf("message row survived the refused accept; the transaction must roll back") + } +} + +func TestJobs_LegacyJobResolvesAndAuthorizesThroughTheGate(t *testing.T) { + f := newGateFixture(t) + // A pre-floor slot enqueued this job: no operation reference in its args. + messageID, jobID, err := f.accept(f.legacy, "legacy") + if err != nil { + t.Fatalf("legacy accept: %v", err) + } + var hasRef bool + if err := f.pool.QueryRow(f.ctx, `SELECT args ? 'operation_ref' FROM river_job WHERE id = $1`, jobID).Scan(&hasRef); err != nil { + t.Fatal(err) + } + if hasRef || f.operationExists(messageID) { + t.Fatal("the legacy enqueue must carry no reference and prepare nothing") + } + + dl := &fakeDeliverer{out: outboundsend.DeliverOutcome{ProviderMessageID: ""}} + w := outboundsend.NewSendWorker(f.adapter, dl).WithGate(f.gate).WithOperationResolver(f.gated.ResolveLegacyOperation) + rj := &river.Job[outboundsend.OutboundSendArgs]{ + JobRow: &rivertype.JobRow{ID: jobID, Attempt: 1, MaxAttempts: outboundsend.MaxSendAttempts, Kind: outboundsend.OutboundSendArgs{}.Kind()}, + Args: outboundsend.OutboundSendArgs{MessageID: messageID}, + } + if err := w.Work(f.ctx, rj); err != nil { + t.Fatalf("Work: %v", err) + } + if dl.calls != 1 { + t.Fatalf("provider calls = %d, want exactly one", dl.calls) + } + if !f.operationExists(messageID) { + t.Fatal("the resolver did not prepare the operation") + } + var state, callState string + if err := f.pool.QueryRow(f.ctx, ` + SELECT state, call_state FROM sending_budget_reservations + WHERE operation_id = $1 AND submission_attempt = 1`, messageID).Scan(&state, &callState); err != nil { + t.Fatalf("read reservation: %v", err) + } + if state != "confirmed" { + t.Fatalf("attempt state = %s, want confirmed (final authorization ran)", state) + } + var status string + if err := f.pool.QueryRow(f.ctx, `SELECT delivery_status FROM messages WHERE id = $1`, messageID).Scan(&status); err != nil { + t.Fatal(err) + } + if status != "sent" { + t.Fatalf("delivery_status = %s, want sent", status) + } +} + +func TestJobs_GatedWorkerAuthorizesANewJob(t *testing.T) { + f := newGateFixture(t) + messageID, jobID, err := f.accept(f.gated, "gated") + if err != nil { + t.Fatalf("accept: %v", err) + } + dl := &fakeDeliverer{out: outboundsend.DeliverOutcome{ProviderMessageID: ""}} + w := outboundsend.NewSendWorker(f.adapter, dl).WithGate(f.gate) + ref := refFor(messageID) + rj := &river.Job[outboundsend.OutboundSendArgs]{ + JobRow: &rivertype.JobRow{ID: jobID, Attempt: 1, MaxAttempts: outboundsend.MaxSendAttempts, Kind: outboundsend.OutboundSendArgs{}.Kind()}, + Args: outboundsend.OutboundSendArgs{MessageID: messageID, OperationRef: &ref}, + } + if err := w.Work(f.ctx, rj); err != nil { + t.Fatalf("Work: %v", err) + } + if dl.calls != 1 || len(dl.auths) != 1 || dl.auths[0].IsZero() { + t.Fatalf("calls=%d auths=%d, want one provider call carrying a real authorization", dl.calls, len(dl.auths)) + } + // A re-drive of the sent row is a no-op: no new ordinal, no new call. + if err := w.Work(f.ctx, rj); err != nil { + t.Fatalf("re-drive: %v", err) + } + var attempts int + if err := f.pool.QueryRow(f.ctx, `SELECT current_attempt FROM sending_provider_operations WHERE operation_id = $1`, messageID).Scan(&attempts); err != nil { + t.Fatal(err) + } + if dl.calls != 1 || attempts != 1 { + t.Fatalf("after re-drive calls=%d current_attempt=%d, want 1/1", dl.calls, attempts) + } +} + +// TestJobs_ReconcilerSettlesTheDialedAttemptFromEvidence: the worker dialed +// (the token was redeemed) but lost the 250; SES's feedback later proved +// acceptance; the job is terminal. The reconciler settles the row as sent and, +// through the gate, settles the attempt that dialed — binding the provider id +// to its correlation — without resubmitting or reserving anything. +func TestJobs_ReconcilerSettlesTheDialedAttemptFromEvidence(t *testing.T) { + f := newGateFixture(t) + messageID, jobID, err := f.accept(f.gated, "evidence") + if err != nil { + t.Fatalf("accept: %v", err) + } + dl := &fakeDeliverer{out: outboundsend.DeliverOutcome{Err: errors.New("data final: lost"), AcceptanceUnknown: true}} + w := outboundsend.NewSendWorker(f.adapter, dl).WithGate(f.gate) + ref := refFor(messageID) + rj := &river.Job[outboundsend.OutboundSendArgs]{ + JobRow: &rivertype.JobRow{ID: jobID, Attempt: 1, MaxAttempts: outboundsend.MaxSendAttempts, Kind: outboundsend.OutboundSendArgs{}.Kind()}, + Args: outboundsend.OutboundSendArgs{MessageID: messageID, OperationRef: &ref}, + } + if err := w.Work(f.ctx, rj); err == nil { + t.Fatal("an acceptance-unknown failure must return a retryable error") + } + // The production submitter redeems before it dials; the fake did not, so + // redeem the token it was handed to reproduce "dialed, answer lost". + if len(dl.auths) != 1 { + t.Fatalf("auths = %d, want the one the worker handed over", len(dl.auths)) + } + if err := f.gate.RedeemProviderCall(f.ctx, dl.auths[0]); err != nil { + t.Fatalf("redeem: %v", err) + } + // SES feedback proved acceptance; River gave up on the job. + if _, err := f.pool.Exec(f.ctx, ` + UPDATE messages SET provider_accepted_at = now(), provider_message_id = '' + WHERE id = $1`, messageID); err != nil { + t.Fatal(err) + } + if _, err := f.pool.Exec(f.ctx, `UPDATE river_job SET state = 'discarded', finalized_at = now() - interval '16 minutes' WHERE id = $1`, jobID); err != nil { + t.Fatal(err) + } + + if err := outboundsend.NewTerminalReconcileWorker(f.pool, f.adapter).WithGate(f.gate).Work(f.ctx, &river.Job[outboundsend.TerminalReconcileArgs]{}); err != nil { + t.Fatalf("reconcile: %v", err) + } + var status string + if err := f.pool.QueryRow(f.ctx, `SELECT delivery_status FROM messages WHERE id = $1`, messageID).Scan(&status); err != nil { + t.Fatal(err) + } + if status != "sent" { + t.Fatalf("delivery_status = %s, want sent from evidence", status) + } + var bound *string + if err := f.pool.QueryRow(f.ctx, ` + SELECT provider_message_id FROM sending_feedback_correlations + WHERE operation_id = $1 AND submission_attempt = 1`, messageID).Scan(&bound); err != nil { + t.Fatalf("read correlation: %v", err) + } + if bound == nil || *bound != "ses-evidence-000000" { + t.Fatalf("correlation provider id = %v, want the bare evidence id bound to the dialed attempt", bound) + } + if dl.calls != 1 { + t.Fatalf("provider calls = %d, want the original one only", dl.calls) + } +} + +func TestJobs_RateDeferralReleasesTheRealReservation(t *testing.T) { + f := newGateFixture(t) + messageID, jobID, err := f.accept(f.gated, "rate") + if err != nil { + t.Fatalf("accept: %v", err) + } + gate := &fakeRateGate{decision: outboundsend.RateDecision{Allowed: false, RetryAt: time.Now().Add(30 * time.Second)}, window: time.Minute} + dl := &fakeDeliverer{} + w := outboundsend.NewSendWorker(f.adapter, dl).WithGate(f.gate).WithRateGate(gate) + ref := refFor(messageID) + rj := &river.Job[outboundsend.OutboundSendArgs]{ + JobRow: &rivertype.JobRow{ID: jobID, Attempt: 1, MaxAttempts: outboundsend.MaxSendAttempts, Kind: outboundsend.OutboundSendArgs{}.Kind()}, + Args: outboundsend.OutboundSendArgs{MessageID: messageID, OperationRef: &ref}, + } + if err := w.Work(f.ctx, rj); !isSnooze(err) || dl.calls != 0 { + t.Fatalf("err=%v delivers=%d, want snooze with no I/O", err, dl.calls) + } + var state string + if err := f.pool.QueryRow(f.ctx, `SELECT state FROM sending_budget_reservations WHERE operation_id = $1 AND submission_attempt = 1`, messageID).Scan(&state); err != nil { + t.Fatalf("read reservation: %v", err) + } + if state != "released" { + t.Fatalf("reservation state = %s, want released — the deferral must give the budget back", state) + } +} + +// zeroRefGate accepts but prepares nothing — the shape only an exact +// self-send produces, which never enqueues. +type zeroRefGate struct{ *fakeGate } + +func (zeroRefGate) PrepareExternalTx(context.Context, pgx.Tx, string) (sendingpolicy.AcceptanceDecision, sendingpolicy.OperationRef, error) { + return sendingpolicy.AcceptanceAccept, sendingpolicy.OperationRef{}, nil +} + +func TestJobs_EnqueueRefusesAnAcceptWithoutAnOperation(t *testing.T) { + f := newGateFixture(t) + bundle := outboundsend.NewJobs(f.adapter, &fakeDeliverer{}, f.pool).WithGate(zeroRefGate{allowAll()}) + bundle.SetEnqueuer(f.client) + messageID, _, err := f.accept(bundle, "zero-ref") + if err == nil { + t.Fatal("an accept that prepared no operation was enqueued as a legacy-looking job") + } + var rows int + if err := f.pool.QueryRow(f.ctx, `SELECT count(*) FROM messages WHERE id = $1`, messageID).Scan(&rows); err != nil { + t.Fatal(err) + } + if rows != 0 { + t.Fatal("the refused accept left a message row behind") + } +} diff --git a/internal/outboundsend/rate_test.go b/internal/outboundsend/rate_test.go index cb71b5294..f05f0c602 100644 --- a/internal/outboundsend/rate_test.go +++ b/internal/outboundsend/rate_test.go @@ -222,13 +222,12 @@ func TestSendWorker_RateLimitedPastRetryHorizonFailsTerminally(t *testing.T) { j.Domain, j.MessageType, j.SentAs = "new.example.com", "send", "own_address" st := &fakeStore{job: j} dl := &fakeDeliverer{} - ramp := &fakeRampGate{decision: outboundsend.RampDecision{Allowed: true}} gate := &fakeRateGate{decision: outboundsend.RateDecision{ Allowed: false, RetryAt: time.Now().Add(30 * time.Second), }} rec := &recordingMetrics{} - w := outboundsend.NewSendWorker(st, dl, ramp).WithRateGate(gate).WithMetrics(rec) + w := outboundsend.NewSendWorker(st, dl).WithRateGate(gate).WithMetrics(rec) err := w.Work(context.Background(), job("msg_1", 4)) if err == nil { @@ -248,9 +247,6 @@ func TestSendWorker_RateLimitedPastRetryHorizonFailsTerminally(t *testing.T) { t.Errorf("terminal = {detail %q, source %v}, want {send_rate_timeout, local}", got.detail, got.source) } - if len(ramp.released) != 1 || ramp.released[0] != "msg_1" { - t.Errorf("ramp releases = %v, want [msg_1] (timeout releases the reservation)", ramp.released) - } if !stringsEqual(rec.terminals, []string{"failed_local_retries"}) { t.Errorf("terminals = %v, want [failed_local_retries]", rec.terminals) } @@ -266,10 +262,9 @@ func TestSendWorker_RateGateErrorPastRetryHorizonFailsTerminally(t *testing.T) { j.AcceptedAt = time.Now().Add(-73 * time.Hour) j.Domain, j.MessageType, j.SentAs = "new.example.com", "send", "own_address" st := &fakeStore{job: j} - ramp := &fakeRampGate{decision: outboundsend.RampDecision{Allowed: true}} gate := &fakeRateGate{err: errors.New("rate store down")} rec := &recordingMetrics{} - w := outboundsend.NewSendWorker(st, &fakeDeliverer{}, ramp).WithRateGate(gate).WithMetrics(rec) + w := outboundsend.NewSendWorker(st, &fakeDeliverer{}).WithRateGate(gate).WithMetrics(rec) err := w.Work(context.Background(), job("msg_1", 4)) if err == nil { @@ -286,36 +281,6 @@ func TestSendWorker_RateGateErrorPastRetryHorizonFailsTerminally(t *testing.T) { t.Errorf("terminal = {detail %q, source %v}, want {send_rate_timeout: rate store down, local}", got.detail, got.source) } - if len(ramp.released) != 1 || ramp.released[0] != "msg_1" { - t.Errorf("ramp releases = %v, want [msg_1] (timeout releases the reservation)", ramp.released) - } -} - -// TestSendWorker_RateLimitedDeferralKeepsRampReservation pins the complement -// of the horizon path: an ordinary deferral releases the SEND CLAIM but keeps -// the ramp reservation — same-message Reserve is idempotent, while a released -// reservation is terminal and cannot be re-reserved. -func TestSendWorker_RateLimitedDeferralKeepsRampReservation(t *testing.T) { - j := acceptedJob("msg_1") - j.Domain, j.MessageType, j.SentAs = "new.example.com", "send", "own_address" - st := &fakeStore{job: j} - ramp := &fakeRampGate{decision: outboundsend.RampDecision{Allowed: true}} - gate := &fakeRateGate{decision: outboundsend.RateDecision{ - Allowed: false, - RetryAt: time.Now().Add(30 * time.Second), - }} - w := outboundsend.NewSendWorker(st, &fakeDeliverer{}, ramp).WithRateGate(gate) - - requireSnooze(t, w.Work(context.Background(), job("msg_1", 1))) - if len(ramp.calls) != 1 { - t.Errorf("ramp reserves = %d, want 1 (taken before the rate gate)", len(ramp.calls)) - } - if len(ramp.released) != 0 { - t.Errorf("ramp releases = %v, want none — a deferral keeps the reservation", ramp.released) - } - if len(st.released) != 1 || st.released[0] != "msg_1" { - t.Errorf("send-claim releases = %v, want [msg_1]", st.released) - } } // TestSendWorker_RateGateAllowsSubmission: an allowed reservation falls diff --git a/internal/outboundsend/reconcile_test.go b/internal/outboundsend/reconcile_test.go index c81325f89..c048843bc 100644 --- a/internal/outboundsend/reconcile_test.go +++ b/internal/outboundsend/reconcile_test.go @@ -20,6 +20,7 @@ import ( "github.com/tokencanopy/e2a/internal/jobs" "github.com/tokencanopy/e2a/internal/messagelifecycle" "github.com/tokencanopy/e2a/internal/outboundsend" + "github.com/tokencanopy/e2a/internal/sendingpolicy" "github.com/tokencanopy/e2a/internal/testutil" "github.com/tokencanopy/e2a/internal/usage" "github.com/tokencanopy/e2a/internal/webhookpub" @@ -481,9 +482,8 @@ func TestTerminalReconcileWorker_ReconcilesOnlyTerminalJobs(t *testing.T) { sentID := f.seed(t, "sent", "sent", "completed", false) missingID := f.seed(t, "missing", "accepted", "", true) - gate := &fakeRampGate{} rec := &recordingMetrics{} - worker := outboundsend.NewTerminalReconcileWorker(pool, adapter, gate).WithMetrics(rec) + worker := outboundsend.NewTerminalReconcileWorker(pool, adapter).WithMetrics(rec) if err := worker.Work(context.Background(), &river.Job[outboundsend.TerminalReconcileArgs]{}); err != nil { t.Fatalf("Work: %v", err) } @@ -541,9 +541,6 @@ func TestTerminalReconcileWorker_ReconcilesOnlyTerminalJobs(t *testing.T) { } f.assertEventCarriesOnly(t, tc.id, webhookpub.EventEmailFailed, tr) } - if len(gate.resolved) != 4 { - t.Errorf("ramp resolutions = %v, want four terminal outcomes", gate.resolved) - } // One terminal metric per settled row; all four sweeps here wrote a // locally inferred failure (no provider provenance, no suppression list). // One terminal per settled row, labeled by provenance: the cancelled-state @@ -572,82 +569,6 @@ func TestTerminalReconcileWorker_ReconcilesOnlyTerminalJobs(t *testing.T) { } } -func TestTerminalReconcileWorker_ResolvesReservedRampForTerminalMessage(t *testing.T) { - pool := testutil.TestDB(t) - store := identity.NewStore(pool) - adapter := agent.NewOutboundSendStore(store, - webhookpub.NewOutbox(pool, webhookpub.StaticFlag(true)), usage.NewNoopUsageTracker()) - f := newTerminalFixture(t, pool, store, adapter) - messageID := f.seed(t, "terminal-ramp-cleanup", "accepted", "cancelled", false) - - ctx := context.Background() - var userID string - if err := pool.QueryRow(ctx, `SELECT user_id FROM agent_identities WHERE id=$1`, f.agentID).Scan(&userID); err != nil { - t.Fatalf("read agent owner: %v", err) - } - if _, err := pool.Exec(ctx, - `UPDATE messages SET delivery_status='failed' WHERE id=$1`, messageID); err != nil { - t.Fatalf("make message terminal: %v", err) - } - if _, err := pool.Exec(ctx, - `INSERT INTO domain_send_counters (user_id, domain, day, reserved_count, confirmed_count, daily_limit) - VALUES ($1, 'example.com', current_date, 1, 0, 50)`, userID); err != nil { - t.Fatalf("seed ramp counter: %v", err) - } - if _, err := pool.Exec(ctx, - `INSERT INTO sending_ramp_reservations (message_id, day, user_id, domain, units) - VALUES ($1, current_date, $2, 'example.com', 1)`, messageID, userID); err != nil { - t.Fatalf("seed reserved ramp: %v", err) - } - - gate := &fakeRampGate{} - worker := outboundsend.NewTerminalReconcileWorker(pool, adapter, gate) - if err := worker.Work(ctx, &river.Job[outboundsend.TerminalReconcileArgs]{}); err != nil { - t.Fatalf("Work: %v", err) - } - if len(gate.resolved) != 1 || gate.resolved[0] != messageID { - t.Fatalf("ramp resolutions = %v, want [%s]", gate.resolved, messageID) - } -} - -func TestTerminalReconcileWorker_ResolvesReleasedRampAfterProviderCorrection(t *testing.T) { - pool := testutil.TestDB(t) - store := identity.NewStore(pool) - adapter := agent.NewOutboundSendStore(store, - webhookpub.NewOutbox(pool, webhookpub.StaticFlag(true)), usage.NewNoopUsageTracker()) - f := newTerminalFixture(t, pool, store, adapter) - messageID := f.seed(t, "released-ramp-provider-correction", "accepted", "cancelled", false) - - ctx := context.Background() - var userID string - if err := pool.QueryRow(ctx, `SELECT user_id FROM agent_identities WHERE id=$1`, f.agentID).Scan(&userID); err != nil { - t.Fatalf("read agent owner: %v", err) - } - if _, err := pool.Exec(ctx, - `UPDATE messages SET delivery_status='delivered' WHERE id=$1`, messageID); err != nil { - t.Fatalf("apply provider correction: %v", err) - } - if _, err := pool.Exec(ctx, - `INSERT INTO domain_send_counters (user_id, domain, day, reserved_count, confirmed_count, daily_limit) - VALUES ($1, 'example.com', current_date, 0, 0, 50)`, userID); err != nil { - t.Fatalf("seed ramp counter: %v", err) - } - if _, err := pool.Exec(ctx, - `INSERT INTO sending_ramp_reservations (message_id, day, user_id, domain, units, state) - VALUES ($1, current_date, $2, 'example.com', 1, 'released')`, messageID, userID); err != nil { - t.Fatalf("seed released ramp: %v", err) - } - - gate := &fakeRampGate{} - worker := outboundsend.NewTerminalReconcileWorker(pool, adapter, gate) - if err := worker.Work(ctx, &river.Job[outboundsend.TerminalReconcileArgs]{}); err != nil { - t.Fatalf("Work: %v", err) - } - if len(gate.resolved) != 1 || gate.resolved[0] != messageID { - t.Fatalf("ramp resolutions = %v, want [%s]", gate.resolved, messageID) - } -} - // TestTerminalReconcileWorker_GraceWindowHoldsFreshTerminalJobs pins the §3.1 // grace behavior: a row whose job just reached a terminal state is NOT failed // while provider evidence may still be arriving; it is failed once the job has @@ -662,8 +583,7 @@ func TestTerminalReconcileWorker_GraceWindowHoldsFreshTerminalJobs(t *testing.T) freshID := f.seed(t, "fresh-discard", "accepted", "discarded", false) f.freshenJob(t, freshID) // terminal seconds ago — inside the grace window - gate := &fakeRampGate{} - worker := outboundsend.NewTerminalReconcileWorker(pool, adapter, gate) + worker := outboundsend.NewTerminalReconcileWorker(pool, adapter) if err := worker.Work(context.Background(), &river.Job[outboundsend.TerminalReconcileArgs]{}); err != nil { t.Fatalf("Work: %v", err) } @@ -721,8 +641,7 @@ func TestTerminalReconcileWorker_ProviderEvidenceSettlesAsSent(t *testing.T) { t.Fatal(err) } - gate := &fakeRampGate{} - worker := outboundsend.NewTerminalReconcileWorker(pool, adapter, gate) + worker := outboundsend.NewTerminalReconcileWorker(pool, adapter) if err := worker.Work(context.Background(), &river.Job[outboundsend.TerminalReconcileArgs]{}); err != nil { t.Fatalf("Work: %v", err) } @@ -784,9 +703,6 @@ func TestTerminalReconcileWorker_ProviderEvidenceSettlesAsSent(t *testing.T) { if got := f.failedEventCount(t, evidenceID); got != 0 { t.Errorf("email.failed count = %d, want 0 — evidence must suppress the false failure", got) } - if len(gate.resolved) != 1 || gate.resolved[0] != evidenceID { - t.Errorf("ramp resolutions = %v, want evidence message", gate.resolved) - } // Idempotent: a second pass no-ops (the row left accepted/sending). if err := worker.Work(context.Background(), &river.Job[outboundsend.TerminalReconcileArgs]{}); err != nil { @@ -938,14 +854,19 @@ func testLocalFallbackReason(t *testing.T, label string, want messagelifecycle.R if _, err := pool.Exec(context.Background(), `UPDATE messages SET sent_as='own_address' WHERE id=$1`, messageID); err != nil { t.Fatal(err) } - worker = outboundsend.NewSendWorker(adapter, &fakeDeliverer{}, &fakeRampGate{err: permanentRampError{msg: "invalid ramp"}}) + // A terminal gate hold (the account is gone) is the local cancellation + // this reason describes. + worker = outboundsend.NewSendWorker(adapter, &fakeDeliverer{}).WithGate(&fakeGate{ + reserve: sendingpolicy.Decision{Allow: false, Reason: sendingpolicy.ReasonAccountDeleted, Terminal: true}, + }) } else { if _, err := pool.Exec(context.Background(), `UPDATE messages SET created_at=now()-interval '73 hours' WHERE id=$1`, messageID); err != nil { t.Fatal(err) } worker = outboundsend.NewSendWorker(adapter, &fakeDeliverer{out: outboundsend.DeliverOutcome{Err: errors.New("provider unavailable"), Outage: true}}) } - rj := &river.Job[outboundsend.OutboundSendArgs]{JobRow: &rivertype.JobRow{ID: jobID, Attempt: 3, CreatedAt: time.Now().UTC()}, Args: outboundsend.OutboundSendArgs{MessageID: messageID}} + ref := refFor(messageID) + rj := &river.Job[outboundsend.OutboundSendArgs]{JobRow: &rivertype.JobRow{ID: jobID, Attempt: 3, CreatedAt: time.Now().UTC()}, Args: outboundsend.OutboundSendArgs{MessageID: messageID, OperationRef: &ref}} if err := worker.Work(context.Background(), rj); err == nil { t.Fatal("terminal branch must return cancellation/error") } @@ -988,8 +909,7 @@ func testProviderRejectionAtomicFailure(t *testing.T, label, install, uninstall } t.Cleanup(func() { _, _ = pool.Exec(context.Background(), uninstall) }) deliverer := &fakeDeliverer{out: outboundsend.DeliverOutcome{Err: errors.New("550 explicit rejection"), Permanent: true}} - ramp := &fakeRampGate{decision: outboundsend.RampDecision{Allowed: true}} - w := outboundsend.NewSendWorker(adapter, deliverer, ramp) + w := outboundsend.NewSendWorker(adapter, deliverer) rj := &river.Job[outboundsend.OutboundSendArgs]{JobRow: &rivertype.JobRow{ID: jobID, Attempt: 2, CreatedAt: time.Now().UTC()}, Args: outboundsend.OutboundSendArgs{MessageID: messageID}} if err := w.Work(context.Background(), rj); err == nil { t.Fatal("provider rejection must cancel") @@ -1018,9 +938,6 @@ func testProviderRejectionAtomicFailure(t *testing.T, label, install, uninstall if deliverer.calls != 1 { t.Fatalf("fallback re-drive provider calls=%d, want exactly the original call", deliverer.calls) } - if len(ramp.calls) != 1 || len(ramp.released) != 1 || len(ramp.resolved) != 1 { - t.Fatalf("fallback ramp reserve=%d release=%v resolve=%v, want one of each without re-reserve", len(ramp.calls), ramp.released, ramp.resolved) - } if _, err := pool.Exec(context.Background(), uninstall); err != nil { t.Fatal(err) } @@ -1132,14 +1049,17 @@ func (s failingTerminalStore) ClaimSend(context.Context, string, int64) (*outbou return nil, nil } func (s failingTerminalStore) ReleaseSend(context.Context, string, int64) error { return nil } +func (s failingTerminalStore) RecordHold(context.Context, string, outboundsend.HoldClass, time.Time) error { + return nil +} func (s failingTerminalStore) MarkSent(context.Context, string, int64, int, time.Time, string, string) error { return nil } -func (s failingTerminalStore) MarkFailed(_ context.Context, _ string, _ int64, _ int, occurredAt time.Time, _ string, _ delivery.FailureSource, _ messagelifecycle.ReasonCode, _ []string) (delivery.Status, time.Time, error) { +func (s failingTerminalStore) MarkFailed(_ context.Context, _ string, _ int64, _ int, occurredAt time.Time, _ string, _ delivery.FailureSource, _ messagelifecycle.ReasonCode, _ []string) (delivery.Status, time.Time, string, error) { if s.err != nil { - return "", time.Time{}, s.err + return "", time.Time{}, "", s.err } - return delivery.StatusFailed, occurredAt, nil + return delivery.StatusFailed, occurredAt, "", nil } func (s failingTerminalStore) PreserveTerminalFailure(context.Context, string, int64, int, time.Time, string, delivery.FailureSource, messagelifecycle.ReasonCode, []string) error { return nil diff --git a/internal/outboundsend/suppression_test.go b/internal/outboundsend/suppression_test.go index cb535827e..80bd3803e 100644 --- a/internal/outboundsend/suppression_test.go +++ b/internal/outboundsend/suppression_test.go @@ -17,12 +17,13 @@ import ( "testing" "github.com/tokencanopy/e2a/internal/outboundsend" + "github.com/tokencanopy/e2a/internal/sendingpolicy" ) // trippingDeliverer fails the test if any provider I/O is attempted. type trippingDeliverer struct{ t *testing.T } -func (d trippingDeliverer) Deliver(_ context.Context, j *outboundsend.SendJob) outboundsend.DeliverOutcome { +func (d trippingDeliverer) Deliver(_ context.Context, j *outboundsend.SendJob, _ sendingpolicy.ProviderAuthorization) outboundsend.DeliverOutcome { d.t.Errorf("provider Deliver called for %s despite suppression guard", j.MessageID) return outboundsend.DeliverOutcome{} } @@ -31,8 +32,7 @@ func TestSendWorker_SuppressedRecipientFailsTerminallyWithoutProviderIO(t *testi j := acceptedJob("msg_1") j.Domain, j.MessageType, j.SentAs = "new.example.com", "send", "own_address" st := &fakeStore{job: j, suppressed: []string{"b@y.com"}} - gate := &fakeRampGate{decision: outboundsend.RampDecision{Allowed: true}} - w := outboundsend.NewSendWorker(st, trippingDeliverer{t}, gate) + w := outboundsend.NewSendWorker(st, trippingDeliverer{t}) err := w.Work(context.Background(), job("msg_1", 1)) if err == nil { @@ -57,9 +57,6 @@ func TestSendWorker_SuppressedRecipientFailsTerminallyWithoutProviderIO(t *testi if st.suppressionAgentID != st.job.AgentID { t.Errorf("suppression check agent = %q, want %q", st.suppressionAgentID, st.job.AgentID) } - if len(gate.released) != 1 || gate.released[0] != "msg_1" { - t.Errorf("ramp releases = %v, want [msg_1]", gate.released) - } } // A store error on the guard is conservative: no provider I/O, no terminal @@ -83,45 +80,6 @@ func TestSendWorker_SuppressionCheckErrorFailsClosed(t *testing.T) { } } -func TestSendWorker_SuppressionCheckErrorAfterRampPreservesReservation(t *testing.T) { - j := acceptedJob("msg_1") - j.Domain, j.MessageType, j.SentAs = "new.example.com", "send", "own_address" - st := &fakeStore{job: j, suppressedErr: errors.New("suppression store down")} - gate := &fakeRampGate{decision: outboundsend.RampDecision{Allowed: true}} - w := outboundsend.NewSendWorker(st, trippingDeliverer{t}, gate) - - if err := w.Work(context.Background(), job("msg_1", 1)); err == nil { - t.Fatal("suppression-store error must retry") - } - if len(gate.released) != 0 { - t.Fatalf("ramp releases = %v, want none so same-day retry stays idempotent", gate.released) - } - if len(st.released) != 1 || st.released[0] != "msg_1" { - t.Fatalf("claim releases = %v, want [msg_1]", st.released) - } -} - -func TestSendWorker_SuppressionCheckErrorKeepsRampReservationWhenClaimReleaseFails(t *testing.T) { - lookupErr := errors.New("suppression store down") - claimErr := errors.New("claim release down") - j := acceptedJob("msg_1") - j.Domain, j.MessageType, j.SentAs = "new.example.com", "send", "own_address" - st := &fakeStore{job: j, suppressedErr: lookupErr, releaseErr: claimErr} - gate := &fakeRampGate{decision: outboundsend.RampDecision{Allowed: true}} - w := outboundsend.NewSendWorker(st, trippingDeliverer{t}, gate) - - err := w.Work(context.Background(), job("msg_1", 1)) - if !errors.Is(err, lookupErr) || !errors.Is(err, claimErr) { - t.Fatalf("error = %v, want joined lookup and claim-release causes", err) - } - if len(st.released) != 1 { - t.Fatalf("claim release calls = %v, want one attempt", st.released) - } - if len(gate.released) != 0 { - t.Fatalf("ramp releases = %v, want none while claim remains held", gate.released) - } -} - func TestSendWorker_UnsuppressedRecipientStillSends(t *testing.T) { st := &fakeStore{job: acceptedJob("msg_1")} // no suppressions dl := &fakeDeliverer{out: outboundsend.DeliverOutcome{ProviderMessageID: "ses-ok", SentAs: "relay"}} diff --git a/internal/outboundsend/terminal_reconcile.go b/internal/outboundsend/terminal_reconcile.go index 584361204..e212c7477 100644 --- a/internal/outboundsend/terminal_reconcile.go +++ b/internal/outboundsend/terminal_reconcile.go @@ -2,6 +2,7 @@ package outboundsend import ( "context" + "errors" "fmt" "log" "time" @@ -12,6 +13,7 @@ import ( "github.com/tokencanopy/e2a/internal/delivery" "github.com/tokencanopy/e2a/internal/jobs" "github.com/tokencanopy/e2a/internal/messagelifecycle" + "github.com/tokencanopy/e2a/internal/sendingpolicy" ) const terminalReconcileInterval = time.Minute @@ -49,15 +51,21 @@ type TerminalReconcileWorker struct { river.WorkerDefaults[TerminalReconcileArgs] pool *pgxpool.Pool store Store - ramp RampGate + gate sendingpolicy.Gate metrics Metrics } // NewTerminalReconcileWorker builds the periodic safety-net worker. -func NewTerminalReconcileWorker(pool *pgxpool.Pool, store Store, ramps ...RampGate) *TerminalReconcileWorker { - w := &TerminalReconcileWorker{pool: pool, store: store, metrics: noopMetrics{}} - if len(ramps) > 0 { - w.ramp = ramps[0] +func NewTerminalReconcileWorker(pool *pgxpool.Pool, store Store) *TerminalReconcileWorker { + return &TerminalReconcileWorker{pool: pool, store: store, metrics: noopMetrics{}} +} + +// WithGate injects the sending-protection gate so an evidence-settled row can +// also settle its provider attempt (ramp progress, provider-id binding). +// Reconciliation is settlement-only: it never resubmits and never reserves. +func (w *TerminalReconcileWorker) WithGate(g sendingpolicy.Gate) *TerminalReconcileWorker { + if g != nil { + w.gate = g } return w } @@ -86,6 +94,7 @@ type terminalCandidate struct { failureOccurredAt *time.Time failureAttempt *int failureBlockedRecipients []string + providerMessageID string } // submissionAnchor is this candidate's acceptance→terminal SLI baseline — the @@ -116,7 +125,8 @@ func (w *TerminalReconcileWorker) Work(ctx context.Context, _ *river.Job[Termina r.finalized_at, m.created_at, m.scheduled_at, m.reviewed_at, COALESCE(m.delivery_failure_source,''),COALESCE(m.delivery_detail,''),COALESCE(m.delivery_failure_reason_code,''), - m.delivery_failure_occurred_at,m.delivery_failure_attempt,m.delivery_failure_blocked_recipients + m.delivery_failure_occurred_at,m.delivery_failure_attempt,m.delivery_failure_blocked_recipients, + COALESCE(m.provider_message_id,'') FROM messages m LEFT JOIN river_job r ON r.id = m.send_job_id WHERE m.direction = 'outbound' @@ -138,7 +148,7 @@ func (w *TerminalReconcileWorker) Work(ctx context.Context, _ *river.Job[Termina candidates := make([]terminalCandidate, 0) for rows.Next() { var candidate terminalCandidate - if err := rows.Scan(&candidate.messageID, &candidate.jobID, &candidate.attempt, &candidate.state, &candidate.finalizedAt, &candidate.acceptedAt, &candidate.scheduledAt, &candidate.reviewedAt, &candidate.failureSource, &candidate.detail, &candidate.failureReason, &candidate.failureOccurredAt, &candidate.failureAttempt, &candidate.failureBlockedRecipients); err != nil { + if err := rows.Scan(&candidate.messageID, &candidate.jobID, &candidate.attempt, &candidate.state, &candidate.finalizedAt, &candidate.acceptedAt, &candidate.scheduledAt, &candidate.reviewedAt, &candidate.failureSource, &candidate.detail, &candidate.failureReason, &candidate.failureOccurredAt, &candidate.failureAttempt, &candidate.failureBlockedRecipients, &candidate.providerMessageID); err != nil { return err } candidates = append(candidates, candidate) @@ -184,7 +194,7 @@ func (w *TerminalReconcileWorker) Work(ctx context.Context, _ *river.Job[Termina // fails it with provenance 'local' so later authoritative evidence can // still correct it. The stored detail of a deferred final attempt is // preferred over this generic sweep detail. - settled, settledAt, err := w.store.MarkFailed(ctx, candidate.messageID, candidate.jobID, attempt, occurredAt, detail, source, reason, candidate.failureBlockedRecipients) + settled, settledAt, providerID, err := w.store.MarkFailed(ctx, candidate.messageID, candidate.jobID, attempt, occurredAt, detail, source, reason, candidate.failureBlockedRecipients) if err != nil { if processed > 0 { log.Printf("[outbound-terminal-reconcile] processed %d candidates", processed) @@ -204,69 +214,41 @@ func (w *TerminalReconcileWorker) Work(ctx context.Context, _ *river.Job[Termina emitTerminal(w.metrics, terminalOutcome(source, reason, candidate.failureBlockedRecipients), candidate.submissionAnchor(), settledAt) case delivery.StatusSent: emitTerminal(w.metrics, terminalSent, candidate.submissionAnchor(), settledAt) - } - if w.ramp != nil { - if err := w.ramp.Resolve(ctx, candidate.messageID); err != nil { - return fmt.Errorf("resolve sending ramp for %s: %w", candidate.messageID, err) + // Provider evidence settled the row; settle the attempt that + // dialed, so ramp progress and the provider-id binding catch up. + // Best effort and idempotent — an attempt that predates the gate + // has nothing to settle. + if providerID == "" { + providerID = candidate.providerMessageID } + w.settleFromEvidence(ctx, candidate.messageID, providerID) } processed++ } if processed > 0 { log.Printf("[outbound-terminal-reconcile] processed %d candidates", processed) } - return w.resolveTerminalRampReservations(ctx) + return nil } -// resolveTerminalRampReservations is the durable safety net for the narrow -// window where a worker commits a terminal message outcome, then cannot settle -// its sending-ramp reservation. That worker returns an error and normally fixes -// the reservation on its next (unclaimable-message) retry, but its last River -// attempt can be discarded before another retry. The sweep also revisits a -// released reservation when authoritative provider feedback later corrects a -// locally inferred failure. The reservation table's state/updated_at index -// makes this bounded sweep cheap; Resolve derives confirm versus release from -// the message's durable delivery status. -func (w *TerminalReconcileWorker) resolveTerminalRampReservations(ctx context.Context) error { - if w.ramp == nil { - return nil +func (w *TerminalReconcileWorker) settleFromEvidence(ctx context.Context, messageID, providerMessageID string) { + if w.gate == nil { + return } - rows, err := w.pool.Query(ctx, - `SELECT r.message_id - FROM sending_ramp_reservations r - JOIN messages m ON m.id = r.message_id - WHERE (r.state = 'reserved' - AND m.delivery_status IN ('sent', 'failed', 'deferred', 'delivered', 'bounced', 'complained')) - OR (r.state = 'released' - AND m.delivery_status IN ('sent', 'deferred', 'delivered', 'bounced', 'complained')) - ORDER BY r.updated_at ASC, r.message_id ASC - LIMIT $1`, - jobs.DefaultReconcileBatch, - ) + ref, err := w.gate.LookupOperation(ctx, messageID) if err != nil { - return err - } - messageIDs := make([]string, 0) - for rows.Next() { - var messageID string - if err := rows.Scan(&messageID); err != nil { - rows.Close() - return err + if !errors.Is(err, sendingpolicy.ErrSourceUnavailable) { + log.Printf("[outbound-terminal-reconcile] lookup operation for %s: %v", messageID, err) } - messageIDs = append(messageIDs, messageID) - } - if err := rows.Err(); err != nil { - rows.Close() - return err + return } - rows.Close() - - for _, messageID := range messageIDs { - if err := w.ramp.Resolve(ctx, messageID); err != nil { - return fmt.Errorf("resolve terminal sending ramp for %s: %w", messageID, err) + if err := w.gate.SettleOperation(ctx, ref, sendingpolicy.SettlementProviderAccepted, providerMessageID); err != nil && !errors.Is(err, sendingpolicy.ErrAttemptStale) { + if errors.Is(err, sendingpolicy.ErrProviderMessageIDConflict) { + log.Printf("[outbound-terminal-reconcile] CRITICAL: provider id conflict settling %s from evidence: %v", messageID, err) + return } + log.Printf("[outbound-terminal-reconcile] settle %s from provider evidence: %v", messageID, err) } - return nil } func terminalReconcilePeriodicConstructor() (river.JobArgs, *river.InsertOpts) { diff --git a/internal/outboundsend/worker.go b/internal/outboundsend/worker.go index a1bd86181..c43a612ee 100644 --- a/internal/outboundsend/worker.go +++ b/internal/outboundsend/worker.go @@ -15,6 +15,15 @@ // ambiguously defers its terminal write to the reconciler's provider-evidence // grace window rather than firing an immediate — possibly false — email.failed. // +// Every provider call passes through the sending-protection Gate +// (internal/sendingpolicy). The worker order is fixed: Reserve the durable +// attempt; on a hold, snooze without provider I/O; on a rate deferral, +// DeferAttempt; on a final suppression match, CancelAttempt; ConsumeAttempt is +// the last serialized decision; the authorized submitter redeems the token +// immediately before the socket opens and settles the provider's answer. A +// later execution after a confirmed attempt returns to Reserve, which +// allocates the next ordinal — the worker never chooses one. +// // One SMTP attempt per job attempt — River owns the multi-attempt envelope via // NextRetry, so Work() stays short (the deliverer does a single submit, not an // internal retry loop). See the design's "claim + rescue, not a lease" note. @@ -35,6 +44,7 @@ import ( "github.com/tokencanopy/e2a/internal/delivery" "github.com/tokencanopy/e2a/internal/messagelifecycle" "github.com/tokencanopy/e2a/internal/outbound" + "github.com/tokencanopy/e2a/internal/sendingpolicy" "github.com/tokencanopy/e2a/internal/sendrate" ) @@ -57,34 +67,92 @@ const MaxSendAttempts = 6 // MaxSendAttempts (design §8 circuit breaker). const outageSnoozeInterval = 5 * time.Minute -// rampErrorSnoozeInterval keeps a durable message queued when the ramp store is -// temporarily unavailable. JobSnooze does not consume a River attempt. -const rampErrorSnoozeInterval = time.Minute +// gateErrorSnoozeInterval keeps a durable message queued when the sending +// protection gate is temporarily unavailable. JobSnooze does not consume a +// River attempt: fail toward retry, never toward an unauthorized submit. +const gateErrorSnoozeInterval = time.Minute // rateErrorSnoozeInterval keeps a durable message queued when the fire-time -// rate store is temporarily unavailable — mirroring rampErrorSnoozeInterval: -// fail toward retry, never toward an unthrottled submit. +// rate store is temporarily unavailable — fail toward retry, never toward an +// unthrottled submit. const rateErrorSnoozeInterval = time.Minute // rateMinSnooze floors a rate deferral so a RetryAt at (or just past) now — // the window-boundary race — cannot hot-loop the queue. const rateMinSnooze = 250 * time.Millisecond -// SendRetryHorizon bounds the outage-tolerant tail: past this age (from accept) an -// outage-snoozing job stops deferring and is declared terminally failed. 72h matches -// the industry MTA retry horizon (and the webhook deliverer's envelope) — long enough -// to ride out a multi-hour regional SES incident, not forever. +// indefiniteHoldSnooze paces a hold that has no clock of its own — an account +// pause waits for an operator, not for midnight. +const indefiniteHoldSnooze = time.Hour + +// SendRetryHorizon bounds the outage-tolerant tail: past this age a message in a +// rate/ramp/provider or tenant-setup hold is declared terminally failed. 72h +// matches the industry MTA retry horizon (and the webhook deliverer's envelope) +// — long enough to ride out a multi-hour regional SES incident, not forever. const SendRetryHorizon = 72 * time.Hour -// OutboundSendArgs drives one outbound send. Args carry only the message id; the -// worker re-reads the messages row (the source of truth) each attempt. +// PolicyBudgetHoldHorizon bounds a sending-budget hold: a message may wait +// through several UTC days for capacity, but not forever. Seven days is the +// policy's budget_hold_max_days default; the worker holds it as a constant +// because the deadline is derived, never stored, and every execution must +// derive the same one. +const PolicyBudgetHoldHorizon = 7 * 24 * time.Hour + +// HoldClass is the durable finite-hold classification persisted on the message +// the first time it waits for something with a clock. +type HoldClass string + +const ( + // HoldRateRampOrProvider: per-agent rate, custom-domain ramp, or provider + // outage. 72-hour deadline; expiry reason submission.local_retries_exhausted. + HoldRateRampOrProvider HoldClass = "rate_ramp_or_provider" + // HoldTenantSetup: the account's SES tenant is not ready. 72-hour deadline; + // expiry reason submission.sending_setup_expired. Transitions exactly once + // to HoldRateRampOrProvider when readiness lands before the setup deadline. + HoldTenantSetup HoldClass = "tenant_setup" + // HoldPolicyBudget: a sending-budget pool is exhausted. Seven-day deadline + // from the existing anchor; every finite class promotes to it and nothing + // moves it afterwards. Expiry reason submission.policy_budget_expired. + HoldPolicyBudget HoldClass = "policy_budget" +) + +// horizon is the class's absolute deadline measured from its anchor. +func (c HoldClass) horizon() time.Duration { + if c == HoldPolicyBudget { + return PolicyBudgetHoldHorizon + } + return SendRetryHorizon +} + +// expiryReason is the lifecycle reason a class emits when its deadline passes. +func (c HoldClass) expiryReason() messagelifecycle.ReasonCode { + switch c { + case HoldPolicyBudget: + return messagelifecycle.ReasonSubmissionPolicyBudgetExpired + case HoldTenantSetup: + return messagelifecycle.ReasonSubmissionSendingSetupExpired + } + return messagelifecycle.ReasonSubmissionLocalRetriesExhausted +} + +// ErrSendingPaused is returned by the enqueue entry points when the owning +// account is paused: the acceptance surface must reject the request rather +// than queue mail that can never leave. +var ErrSendingPaused = errors.New("outboundsend: account sending is paused") + +// OutboundSendArgs drives one outbound send. Args carry the message id and the +// durable operation reference the accept transaction prepared; the worker +// re-reads the messages row (the source of truth) each attempt. A job enqueued +// before the reference existed (a pre-floor slot) carries none and is resolved +// at fire time through the same Prepare path. type OutboundSendArgs struct { - MessageID string `json:"message_id"` + MessageID string `json:"message_id"` + OperationRef *sendingpolicy.OperationRef `json:"operation_ref,omitempty"` } func (OutboundSendArgs) Kind() string { return "outbound_send" } -// SendJob is the send payload the worker loads from the messages row (Store.LoadForSend). +// SendJob is the send payload the worker loads from the messages row (Store.ClaimSend). type SendJob struct { MessageID string // UserID is the owning account — the tenant scope for the pre-provider @@ -98,46 +166,34 @@ type SendJob struct { Recipients []string RawMessage []byte // composed MIME SentAs string // From identity decided at accept ("own_address"|"relay") - // AcceptedAt is messages.created_at — the outage tail's clock, so a job that has - // been snoozing through an outage past SendRetryHorizon can be terminated. + // AcceptedAt is messages.created_at. AcceptedAt time.Time // ScheduledAt is messages.scheduled_at for a scheduled send (zero for an - // immediate one). The retry horizon is measured from max(AcceptedAt, - // ScheduledAt): a send scheduled far past accept still gets the full - // outage-tolerant tail from its fire time, instead of a horizon already blown - // the moment it first runs. + // immediate one). ScheduledAt time.Time // ReviewedAt is messages.reviewed_at — when a HITL hold was resolved into the - // send pipeline (human approve or TTL auto-approve), zero for a message that - // was never held. Consumed ONLY by submissionAnchor for the latency SLI; the - // retry horizon deliberately still measures from AcceptedAt, so the F2 - // limitation in docs/design/hitl-ttl-async-send.md is unchanged by this field. + // send pipeline, zero for a message that was never held. ReviewedAt time.Time // ProviderAccepted is set when authoritatively correlated provider-accept - // evidence (an SNS-verified, header- or provider-id-matched SES - // notification) has been recorded for this message: the provider already - // has it — an earlier attempt's submit landed in the SMTP-accept↔mark-sent - // crash window — so the worker settles the row as sent instead of - // re-submitting a duplicate. + // evidence has been recorded for this message: the provider already has it, + // so the worker settles the row as sent instead of re-submitting a duplicate. ProviderAccepted bool ProviderAcceptedAt *time.Time // ProviderMessageID is the evidence-repaired provider id accompanying // ProviderAccepted ('' when no evidence). ProviderMessageID string -} - -// pastRetryHorizon reports whether the accept is older than the outage-tolerant -// retry horizon. Zero AcceptedAt (unknown) is treated as not-past so an outage keeps -// deferring rather than being falsely terminated on a missing timestamp. -func (j *SendJob) pastRetryHorizon() bool { - // Measure from max(accept, scheduled): a scheduled send's outage tail starts - // when it fires, not when it was accepted, so a >72h-out schedule isn't - // terminally failed on its very first attempt. - start := j.AcceptedAt - if j.ScheduledAt.After(start) { - start = j.ScheduledAt - } - return !start.IsZero() && time.Since(start) > SendRetryHorizon + // LocalHoldClass / LocalHoldAnchor are the durable finite-hold pair a + // previous execution persisted (empty/zero when never held). The deadline + // is derived from them on every execution and never stored. + LocalHoldClass HoldClass + LocalHoldAnchor time.Time + // LastResumedAt is the owning account's last pause→active transition; a + // first finite hold anchors no earlier than it, so a pause that preceded + // the hold does not consume its horizon. Zero when unknown. + LastResumedAt time.Time + // TenantReadyAt is when the account's SES tenant became ready (zero until + // it is). Drives the one-way tenant_setup → rate_ramp_or_provider move. + TenantReadyAt time.Time } // submissionAnchor is this job's acceptance→terminal SLI baseline — see the @@ -160,7 +216,20 @@ func (j *SendJob) alreadyDone() bool { return s != delivery.StatusAccepted && s != delivery.StatusSending } -// DeliverOutcome is the result of one SMTP submit attempt. +// initialHoldAnchor is where a message's first finite hold starts its clock: +// the latest of accept, schedule, review, and the account's last resume, so +// time spent in review or under an earlier pause is not charged to the hold. +func (j *SendJob) initialHoldAnchor() time.Time { + anchor := j.AcceptedAt + for _, t := range []time.Time{j.ScheduledAt, j.ReviewedAt, j.LastResumedAt} { + if t.After(anchor) { + anchor = t + } + } + return anchor +} + +// DeliverOutcome is the result of one authorized provider submission. type DeliverOutcome struct { ProviderMessageID string SentAs string @@ -172,33 +241,23 @@ type DeliverOutcome struct { // the worker snoozes without burning an attempt (design §8), up to the retry // horizon. Mutually exclusive with Permanent in practice. Outage bool + // AcceptanceUnknown marks a failure AFTER the whole body was handed to the + // provider (the 250 never came): the provider may hold the message. Never + // permanent; the next attempt is a new ordinal, and provider feedback + // carrying the attempt header is the only authoritative answer. + AcceptanceUnknown bool + // SettlementErr reports that the provider ACCEPTED the message but the + // local settlement did not commit. The send happened; the caller must not + // resubmit. + SettlementErr error } -// Deliverer performs a SINGLE SMTP submit — River owns re-attempts. Implemented in -// the binary over internal/outbound's single-attempt path. +// Deliverer performs a SINGLE authorized SMTP submit — River owns re-attempts. +// The token is the authorization for exactly this call; the production +// implementation (the outbound.ProviderSubmitter) redeems it immediately before +// the socket opens and refuses to dial without it. type Deliverer interface { - Deliver(ctx context.Context, j *SendJob) DeliverOutcome -} - -type RampRequest struct { - MessageID string - UserID string - Domain string - Units int -} - -type RampDecision struct { - Allowed bool - RetryAt time.Time -} - -// RampGate reserves recipient capacity for an eligible custom-domain send. -// Implementations must make a same-message/day call idempotent. -type RampGate interface { - Reserve(ctx context.Context, req RampRequest) (RampDecision, error) - Confirm(ctx context.Context, messageID string) error - Release(ctx context.Context, messageID string) error - Resolve(ctx context.Context, messageID string) error + Deliver(ctx context.Context, j *SendJob, auth sendingpolicy.ProviderAuthorization) DeliverOutcome } // RateDecision is the fire-time rate gate's answer for one submission slot: @@ -210,21 +269,20 @@ type RateDecision = sendrate.Decision // RateGate reserves one slot in the per-agent fire-time submission budget // (internal/sendrate) — the durable counterpart to the acceptance-time // in-memory send limit, enforced immediately before provider submission so -// scheduled-send bursts and multi-replica deployments cannot exceed it. -// Unlike RampGate there is no Confirm/Release: the slot is consumed at -// Reserve and ages out of the sliding window on its own (see the sendrate -// package doc for the crash semantics). A nil gate allows everything. -// Window exposes the gate's sliding window so the deferral snooze clamp -// cannot diverge from the limiter's real window. +// scheduled-send bursts and multi-replica deployments cannot exceed it. It +// stays separate from the sending-protection gate because it controls provider +// throughput, not reputation admission. A nil gate allows everything. type RateGate interface { Reserve(ctx context.Context, agentID string) (RateDecision, error) Window() time.Duration } -// Store is the messages-store surface the worker needs. Implemented over -// internal/identity in the binary. ClaimSend atomically checks that the message -// and agent are live and persists delivery_status='sending' for the stamped River -// job before provider I/O begins. +// OperationResolver recovers the durable operation for a job that carries no +// reference — a legacy argument shape from a pre-floor slot. It runs the same +// Prepare path an accept transaction runs, idempotently, so an old job and a +// new one authorize identically. +type OperationResolver func(ctx context.Context, messageID string) (sendingpolicy.AcceptanceDecision, sendingpolicy.OperationRef, error) + // DailyQuotaDeferredError is returned by Store.ClaimSend when the owning // account's per-day send cap is exhausted at fire time. The store has already // released the send claim; the worker snoozes the job until RetryAt (the next @@ -237,6 +295,10 @@ func (e *DailyQuotaDeferredError) Error() string { return fmt.Sprintf("daily send cap exhausted; deferred until %s", e.RetryAt.Format(time.RFC3339)) } +// Store is the messages-store surface the worker needs. Implemented over +// internal/identity in the binary. ClaimSend atomically checks that the message +// and agent are live and persists delivery_status='sending' for the stamped River +// job before provider I/O begins. type Store interface { // ClaimSend returns nil when the message is gone, trashed, terminal, or owned // by a different River job. It returns *DailyQuotaDeferredError (claim @@ -245,6 +307,9 @@ type Store interface { ClaimSend(ctx context.Context, messageID string, jobID int64) (*SendJob, error) // ReleaseSend clears a side-effect-free attempt before River backoff. ReleaseSend(ctx context.Context, messageID string, jobID int64) error + // RecordHold persists the message's finite-hold class and anchor. Terminal + // writes clear the pair. + RecordHold(ctx context.Context, messageID string, class HoldClass, anchor time.Time) error // MarkSent records the provider outcome monotonically from a pre-terminal // state, including when trash won after ClaimSend. MarkSent(ctx context.Context, messageID string, jobID int64, attempt int, occurredAt time.Time, providerMessageID, sentAs string) error @@ -256,11 +321,11 @@ type Store interface { // state", not to unconditionally fail. // The returned status reports what the guarded write actually did: // StatusFailed, StatusSent (evidence settle), or "" (no-op). The returned - // time is the occurred_at the write actually used — the provider-accept - // evidence time on an evidence settle, the passed occurredAt on a - // failure, zero on a no-op — so observability reports what the write - // did, not what the caller asked for. - MarkFailed(ctx context.Context, messageID string, jobID int64, attempt int, occurredAt time.Time, detail string, source delivery.FailureSource, reason messagelifecycle.ReasonCode, blockedRecipients []string) (delivery.Status, time.Time, error) + // time is the occurred_at the write actually used, and the returned + // provider id is the evidence's provider message id on an evidence + // settle ('' otherwise), so the attempt that dialed can be settled with + // it. + MarkFailed(ctx context.Context, messageID string, jobID int64, attempt int, occurredAt time.Time, detail string, source delivery.FailureSource, reason messagelifecycle.ReasonCode, blockedRecipients []string) (delivery.Status, time.Time, string, error) PreserveTerminalFailure(ctx context.Context, messageID string, jobID int64, attempt int, occurredAt time.Time, detail string, source delivery.FailureSource, reason messagelifecycle.ReasonCode, blockedRecipients []string) error // DeferTerminalFailure records a final attempt's diagnostic + releases the // I/O claim WITHOUT declaring failed: the terminal reconciler declares the @@ -281,17 +346,19 @@ type SendWorker struct { river.WorkerDefaults[OutboundSendArgs] store Store deliverer Deliverer - ramp RampGate + gate sendingpolicy.Gate + resolve OperationResolver rate RateGate metrics Metrics + now func() time.Time } -func NewSendWorker(store Store, deliverer Deliverer, ramp ...RampGate) *SendWorker { - w := &SendWorker{store: store, deliverer: deliverer, metrics: noopMetrics{}} - if len(ramp) > 0 { - w.ramp = ramp[0] - } - return w +// NewSendWorker builds a worker with no sending-protection gate. Without a +// gate every provider call is made with an empty authorization, which the +// production submitter refuses before it dials; the composition root always +// installs one via WithGate, and its wiring test proves it. +func NewSendWorker(store Store, deliverer Deliverer) *SendWorker { + return &SendWorker{store: store, deliverer: deliverer, metrics: noopMetrics{}, now: time.Now} } // WithMetrics injects the SLI recorder. Chainable; nil keeps the no-op @@ -312,6 +379,41 @@ func (w *SendWorker) WithRateGate(g RateGate) *SendWorker { return w } +// WithGate injects the sending-protection gate every provider call must pass. +// Chainable; nil keeps the gateless default described on NewSendWorker. +func (w *SendWorker) WithGate(g sendingpolicy.Gate) *SendWorker { + if g != nil { + w.gate = g + } + return w +} + +// WithOperationResolver injects the legacy-argument resolver. Chainable; nil +// leaves a legacy job failing closed. +func (w *SendWorker) WithOperationResolver(r OperationResolver) *SendWorker { + if r != nil { + w.resolve = r + } + return w +} + +// WithClock overrides the worker's clock for deadline tests. Chainable. +func (w *SendWorker) WithClock(now func() time.Time) *SendWorker { + if now != nil { + w.now = now + } + return w +} + +// Gate exposes the wired sending-protection gate (nil when none), for the +// composition root's wiring test. +func (w *SendWorker) Gate() sendingpolicy.Gate { return w.gate } + +// HasOperationResolver reports whether a legacy-argument resolver is wired. +// Without one every job from a pre-floor slot fails closed, so the wiring +// test insists on it. +func (w *SendWorker) HasOperationResolver() bool { return w.resolve != nil } + // NextRetry overrides River's default backoff with the decided send envelope. func (w *SendWorker) NextRetry(job *river.Job[OutboundSendArgs]) time.Time { i := job.Attempt @@ -325,12 +427,10 @@ func (w *SendWorker) NextRetry(job *river.Job[OutboundSendArgs]) time.Time { // River's 60s default JobTimeout. (Contrast the maintenance/sweep workers, which // override it because they can run for minutes.) func (w *SendWorker) Work(ctx context.Context, job *river.Job[OutboundSendArgs]) error { - // Queue-wait SLI: due→pickup latency for THIS attempt (River stamps - // scheduled_at at enqueue, at each retry's backoff target, and on snooze; - // attempted_at at claim). scheduled_at — NOT created_at — is the baseline: - // a retried/snoozed/ramp-deferred message would otherwise record its entire - // cumulative age as "queue wait" on every pass, poisoning the p95. Guarded - // against zero/negative deltas (clock skew, hand-built rows). + // Queue-wait SLI: due→pickup latency for THIS attempt. scheduled_at — NOT + // created_at — is the baseline: a retried/snoozed/deferred message would + // otherwise record its entire cumulative age as "queue wait" on every + // pass, poisoning the p95. Guarded against zero/negative deltas. if job.AttemptedAt != nil && !job.ScheduledAt.IsZero() { if wait := job.AttemptedAt.Sub(job.ScheduledAt); wait > 0 { w.metrics.OutboundQueueWait(wait.Seconds()) @@ -353,23 +453,9 @@ func (w *SendWorker) Work(ctx context.Context, job *river.Job[OutboundSendArgs]) return err // DB error — retryable } if j == nil { - // A previous terminal attempt may have committed the durable message - // outcome before ramp cleanup failed. Terminal rows cannot be claimed on - // retry, so resolve any reservation from that durable outcome here. Resolve - // is also safe for deleted, non-ramped, and missing messages. - if w.ramp != nil { - if err := w.ramp.Resolve(ctx, job.Args.MessageID); err != nil { - return fmt.Errorf("resolve sending ramp for unclaimable message: %w", err) - } - } return nil // message gone or already terminal — nothing to provider-submit } if j.alreadyDone() { - if w.ramp != nil && j.rampEligible() { - if err := w.ramp.Resolve(ctx, j.MessageID); err != nil { - return fmt.Errorf("resolve sending ramp for completed message: %w", err) - } - } return nil // already submitted (sent+) — idempotent re-drive } if j.ProviderAccepted { @@ -384,138 +470,80 @@ func (w *SendWorker) Work(ctx context.Context, job *river.Job[OutboundSendArgs]) return err } // Terminal 'sent', but NOT an attempt — the submit happened on an - // earlier attempt; only the settle lands here. occurredAt is the - // provider-accept evidence time, so the latency measures - // acceptance→provider-accept, not acceptance→settle. + // earlier attempt; only the settle lands here. emitTerminal(w.metrics, terminalSent, j.submissionAnchor(), observedAt) - if w.ramp != nil && j.rampEligible() { - return w.ramp.Confirm(ctx, j.MessageID) - } + w.settleFromEvidence(ctx, j.MessageID, j.ProviderMessageID) return nil } - // Ramp only mail that uses a verified customer identity. Platform-originated - // test mail uses the relay identity and remains exempt; loopback never enters - // this worker. Reserve after the provider-evidence guard. The final suppression - // check deliberately follows an allowed reservation, closing the policy window - // while Reserve waits on shared capacity. Retryable work after Reserve keeps - // that reservation: same-message/day Reserve is idempotent, while a released - // reservation is terminal and cannot be re-reserved. - if w.ramp != nil && j.rampEligible() { - decision, rerr := w.ramp.Reserve(ctx, RampRequest{ - MessageID: j.MessageID, - UserID: j.UserID, - Domain: j.Domain, - Units: uniqueRecipientCount(j.Recipients), - }) - observedAt = time.Now().UTC() - if rerr != nil { - if isPermanentRampError(rerr) { - if err := w.markFailed(ctx, j.MessageID, job.ID, job.Attempt, j.submissionAnchor(), observedAt, "sending_ramp_invalid: "+rerr.Error(), delivery.FailureSourceLocal, messagelifecycle.ReasonSubmissionCancelled, nil); err != nil { - return err - } - return river.JobCancel(rerr) - } - if j.pastRetryHorizon() { - if err := w.markFailed(ctx, j.MessageID, job.ID, job.Attempt, j.submissionAnchor(), observedAt, "ramp_capacity_timeout: "+rerr.Error(), delivery.FailureSourceLocal, messagelifecycle.ReasonSubmissionLocalRetriesExhausted, nil); err != nil { - return err - } - _ = w.ramp.Release(ctx, j.MessageID) - return river.JobCancel(fmt.Errorf("sending ramp unavailable past %s horizon: %w", SendRetryHorizon, rerr)) - } - if err := w.store.ReleaseSend(ctx, j.MessageID, job.ID); err != nil { - return fmt.Errorf("release outbound send claim after ramp-check failure: %w", err) - } - log.Printf("[outbound-send] ramp reservation failed for %s (snoozing): %v", j.MessageID, rerr) - return river.JobSnooze(rampErrorSnoozeInterval) - } - if !decision.Allowed { - if j.pastRetryHorizon() { - if err := w.markFailed(ctx, j.MessageID, job.ID, job.Attempt, j.submissionAnchor(), observedAt, "ramp_capacity_timeout", delivery.FailureSourceLocal, messagelifecycle.ReasonSubmissionLocalRetriesExhausted, nil); err != nil { - return err - } - if err := w.ramp.Release(ctx, j.MessageID); err != nil { - return fmt.Errorf("release ramp reservation after timeout: %w", err) - } - return river.JobCancel(fmt.Errorf("sending ramp deferred past %s horizon", SendRetryHorizon)) - } - if err := w.store.ReleaseSend(ctx, j.MessageID, job.ID); err != nil { - return fmt.Errorf("release outbound send claim after ramp deferral: %w", err) - } - delay := time.Until(decision.RetryAt) - if delay < time.Minute { - delay = time.Minute + // A message whose SES tenant became ready in time leaves the setup class + // before any later gate is consulted, so the setup deadline it has already + // escaped cannot fail it and the new 72-hour horizon starts at readiness. + if err := w.applyTenantReadiness(ctx, j); err != nil { + return err + } + + // Without a gate (unit tests only) the gate steps are skipped and every + // other guard still runs; the production deliverer refuses the empty + // authorization that results, so a deployment that reaches the provider + // this way sends nothing. The composition root's wiring test proves + // production never builds this shape. + var attempt sendingpolicy.AttemptRef + if w.gate != nil { + ref, holdErr := w.operationFor(ctx, job, j) + if holdErr != nil { + return holdErr + } + // 1. Reserve the durable attempt. Reserve is idempotent per ordinal, + // so a re-driven execution that never reached ConsumeAttempt finds + // its own reservation, and a confirmed one is followed by a fresh + // ordinal. + early, reserved, err := w.gate.Reserve(ctx, ref) + observedAt = w.now().UTC() + if err != nil { + if errors.Is(err, sendingpolicy.ErrSourceUnavailable) { + return w.cancelTerminally(ctx, job, j, reserved, observedAt, "sending_policy: operation unavailable: "+err.Error()) } - return river.JobSnooze(delay) + return w.snoozeOnGateError(ctx, job, j, reserved, "reserve", err) + } + if !early.Allow { + return w.hold(ctx, job, j, reserved, early, observedAt) } + attempt = reserved } - // Fire-time per-agent rate gate (internal/sendrate): the durable, - // cross-replica counterpart of the acceptance-time in-memory send limit — - // scheduled sends accumulate as River jobs and would otherwise burst past - // the advertised 60/min/agent at the provider when they fire. Grouped with - // the other wait-gates: after the ramp reservation, before the final - // suppression check. A deferral RELEASES the send claim but KEEPS the ramp - // reservation (same invariant as the outage snooze above — same-message - // Reserve is idempotent, a released reservation is terminal), and snoozes - // WITHOUT burning an attempt, metering, or emitting lifecycle/terminal - // events: the message simply fires when the window frees capacity. + // 2. Fire-time per-agent rate gate: a deferral DeferAttempts (the budget + // is given back; the ramp reservation is kept) and snoozes WITHOUT + // burning an attempt, metering, or emitting lifecycle/terminal events. if w.rate != nil { decision, rerr := w.rate.Reserve(ctx, j.AgentID) - observedAt = time.Now().UTC() - if rerr != nil { - // Fail toward retry, never toward an unthrottled submit: the - // provider is never exposed because the limiter is down. - if j.pastRetryHorizon() { - if err := w.markFailed(ctx, j.MessageID, job.ID, job.Attempt, j.submissionAnchor(), observedAt, "send_rate_timeout: "+rerr.Error(), delivery.FailureSourceLocal, messagelifecycle.ReasonSubmissionLocalRetriesExhausted, nil); err != nil { - return err - } - if w.ramp != nil && j.rampEligible() { - _ = w.ramp.Release(ctx, j.MessageID) - } - return river.JobCancel(fmt.Errorf("send rate gate unavailable past %s horizon: %w", SendRetryHorizon, rerr)) - } - if err := w.store.ReleaseSend(ctx, j.MessageID, job.ID); err != nil { - return fmt.Errorf("release outbound send claim after rate-gate failure: %w", err) - } - log.Printf("[outbound-send] rate gate unavailable for %s (snoozing): %v", j.MessageID, rerr) - return river.JobSnooze(rateErrorSnoozeInterval) - } - if !decision.Allowed { - if j.pastRetryHorizon() { - if err := w.markFailed(ctx, j.MessageID, job.ID, job.Attempt, j.submissionAnchor(), observedAt, "send_rate_timeout", delivery.FailureSourceLocal, messagelifecycle.ReasonSubmissionLocalRetriesExhausted, nil); err != nil { - return err - } - if w.ramp != nil && j.rampEligible() { - if err := w.ramp.Release(ctx, j.MessageID); err != nil { - return fmt.Errorf("release ramp reservation after send-rate timeout: %w", err) - } - } - return river.JobCancel(fmt.Errorf("send rate deferred past %s horizon", SendRetryHorizon)) - } - if err := w.store.ReleaseSend(ctx, j.MessageID, job.ID); err != nil { - return fmt.Errorf("release outbound send claim after rate deferral: %w", err) + observedAt = w.now().UTC() + if rerr != nil || !decision.Allowed { + w.deferAttempt(ctx, attempt, "rate") + if rerr != nil { + log.Printf("[outbound-send] rate gate unavailable for %s (snoozing): %v", j.MessageID, rerr) + return w.holdFinite(ctx, job, j, attempt, HoldRateRampOrProvider, "send_rate_timeout: "+rerr.Error(), rateErrorSnoozeInterval, observedAt) } delay := clampRateSnooze(time.Until(decision.RetryAt), w.rate.Window()) + rateJitter(j.MessageID, w.rate.Window()) - w.metrics.OutboundRateDeferred() - // IDs only — never recipient data. - log.Printf("[outbound-send] rate_limited agent=%s msg=%s retry_in=%s", j.AgentID, j.MessageID, delay) - return river.JobSnooze(delay) + if !w.holdExpired(j, HoldRateRampOrProvider, observedAt) { + // A deferral is counted only when it defers; an expiry is a + // terminal outcome and is counted as one by markFailed. + w.metrics.OutboundRateDeferred() + // IDs only — never recipient data. + log.Printf("[outbound-send] rate_limited agent=%s msg=%s retry_in=%s", j.AgentID, j.MessageID, delay) + } + return w.holdFinite(ctx, job, j, attempt, HoldRateRampOrProvider, "send_rate_timeout", delay, observedAt) } } - // Final suppression guard immediately before provider I/O: a suppression - // added after acceptance or while an allowed ramp reservation was in flight - // must still prevent delivery. A match is terminal; a store error fails - // closed, releasing the side-effect-free claim while preserving an allowed - // ramp reservation for the idempotent River retry. + // 3. Final suppression guard immediately before authorization: a + // suppression added after acceptance must still prevent delivery. A + // match is terminal and cancels the attempt (both ledgers); a store + // error fails closed, releasing the side-effect-free claim. suppressed, serr := w.store.SuppressedRecipients(ctx, j.UserID, j.AgentID, j.Recipients) - observedAt = time.Now().UTC() + observedAt = w.now().UTC() if serr != nil { if err := w.store.ReleaseSend(ctx, j.MessageID, job.ID); err != nil { - // Keep the idempotent ramp reservation while the message claim remains - // held. Releasing capacity first would let another message consume it, - // then a retry could reserve the same message a second time. return fmt.Errorf("suppression check and claim cleanup before outbound send: %w", errors.Join(serr, fmt.Errorf("release outbound send claim: %w", err))) } @@ -526,17 +554,40 @@ func (w *SendWorker) Work(ctx context.Context, job *river.Job[OutboundSendArgs]) if err := w.markFailed(ctx, j.MessageID, job.ID, job.Attempt, j.submissionAnchor(), observedAt, supErr.Error(), delivery.FailureSourceLocal, messagelifecycle.ReasonSubmissionCancelled, suppressed); err != nil { return err } - if w.ramp != nil && j.rampEligible() { - if err := w.ramp.Release(ctx, j.MessageID); err != nil { - return fmt.Errorf("release ramp reservation after suppression: %w", err) - } - } + w.cancelAttempt(ctx, attempt, "suppression") return river.JobCancel(supErr) } + if w.gate == nil { + return w.submit(ctx, job, j, sendingpolicy.ProviderAuthorization{}, observedAt) + } + + // 4. Final authorization. ConsumeAttempt re-checks account state, tenant + // readiness, both ledgers, and the post-lock UTC day under lock; a hold + // here is handled exactly like an early one, and an error leaves the + // reservation standing for the idempotent retry. + decision, auth, err := w.gate.ConsumeAttempt(ctx, attempt) + observedAt = w.now().UTC() + if err != nil { + if errors.Is(err, sendingpolicy.ErrSourceUnavailable) { + return w.cancelTerminally(ctx, job, j, attempt, observedAt, "sending_policy: operation unavailable: "+err.Error()) + } + return w.snoozeOnGateError(ctx, job, j, attempt, "authorize", err) + } + if !decision.Allow || auth == nil { + return w.hold(ctx, job, j, attempt, decision, observedAt) + } + + // 5-6. The authorized submitter redeems the token immediately before the + // socket opens and settles the provider's answer. + return w.submit(ctx, job, j, *auth, observedAt) +} + +// submit makes the single authorized provider call and records its outcome. +func (w *SendWorker) submit(ctx context.Context, job *river.Job[OutboundSendArgs], j *SendJob, auth sendingpolicy.ProviderAuthorization, observedAt time.Time) error { deliverStart := time.Now() - out := w.deliverer.Deliver(ctx, j) - observedAt = time.Now().UTC() + out := w.deliverer.Deliver(ctx, j, auth) + observedAt = w.now().UTC() // Every Deliver call is exactly one submission attempt; classify it here // so no downstream branch (outage, horizon, deferral) can drop the sample. deliverSeconds := time.Since(deliverStart).Seconds() @@ -556,49 +607,44 @@ func (w *SendWorker) Work(ctx context.Context, job *river.Job[OutboundSendArgs]) // Emitted even when MarkSent was a no-op (the row was already // finalized sent by a racing SNS delivery notification): that path is // NOT instrumented, so this is still the message's ONLY sent count. - // If FinalizeProviderAcceptedTx is ever given its own emission, this - // site must become status-aware (like MarkFailed) or the race - // double-counts. The latency observation shares this exactly-once - // contract — emitTerminal emits count and latency together, here and - // everywhere else, and the SNS-feedback path stays uninstrumented - // for both. emitTerminal(w.metrics, terminalSent, j.submissionAnchor(), observedAt) - if w.ramp != nil && j.rampEligible() { - if err := w.ramp.Confirm(ctx, j.MessageID); err != nil { - return fmt.Errorf("confirm sending ramp: %w", err) - } + if out.SettlementErr != nil { + // The provider has the message; only the local settlement (ramp + // progress, provider-id binding) is behind. Never a resend: retry + // the settlement itself, idempotently, and leave the delayed + // feedback path to finish it if that fails too. + w.resettle(ctx, j.MessageID, out.ProviderMessageID, out.SettlementErr) } return nil } // Permanent failure (validation / permanent 5xx) — terminal now, no retries. // Provenance 'provider': SES itself refused this submission, so the §3.1 - // correction never revives it. + // correction never revives it. The submitter has already settled it. if out.Permanent { if err := w.markFailed(ctx, j.MessageID, job.ID, job.Attempt, j.submissionAnchor(), observedAt, out.Err.Error(), delivery.FailureSourceProvider, messagelifecycle.ReasonSubmissionProviderRejected, nil); err != nil { return err } - if w.ramp != nil && j.rampEligible() { - if err := w.ramp.Release(ctx, j.MessageID); err != nil { - return fmt.Errorf("release ramp reservation after provider rejection: %w", err) - } - } return river.JobCancel(out.Err) } // Provider outage (relay unreachable) — snooze WITHOUT burning an attempt so a // multi-hour SES incident defers instead of exhausting MaxSendAttempts and - // mass-firing false email.failed (§8 circuit breaker). Bounded by the retry - // horizon: once the accept is older than SendRetryHorizon, give up terminally - // (provenance 'local': the provider never confirmed a rejection). + // mass-firing false email.failed (§8 circuit breaker). Bounded by the hold + // deadline: a message under a policy_budget hold keeps its seven-day + // clock; any other message gets the 72-hour provider horizon. if out.Outage { - if j.pastRetryHorizon() { - if err := w.markFailed(ctx, j.MessageID, job.ID, job.Attempt, j.submissionAnchor(), observedAt, out.Err.Error(), delivery.FailureSourceLocal, messagelifecycle.ReasonSubmissionLocalRetriesExhausted, nil); err != nil { - return err + class, anchor, changed := w.nextHoldState(j, HoldRateRampOrProvider, observedAt) + if changed { + if err := w.store.RecordHold(ctx, j.MessageID, class, anchor); err != nil { + return fmt.Errorf("record outbound hold: %w", err) } - if w.ramp != nil && j.rampEligible() { - _ = w.ramp.Release(ctx, j.MessageID) + j.LocalHoldClass, j.LocalHoldAnchor = class, anchor + } + if !observedAt.Before(anchor.Add(class.horizon())) { + if err := w.markFailed(ctx, j.MessageID, job.ID, job.Attempt, j.submissionAnchor(), observedAt, out.Err.Error(), delivery.FailureSourceLocal, expiryReasonFor(class, true), nil); err != nil { + return err } - return fmt.Errorf("outbound send failed (provider outage past %s horizon): %w", SendRetryHorizon, out.Err) + return fmt.Errorf("outbound send failed (provider outage past %s horizon): %w", class.horizon(), out.Err) } if err := w.store.RecordTemporaryFailure(ctx, j.MessageID, job.ID, job.Attempt, observedAt, out.Err.Error()); err != nil { return fmt.Errorf("record outbound provider outage and release claim: %w", err) @@ -615,21 +661,301 @@ func (w *SendWorker) Work(ctx context.Context, job *river.Job[OutboundSendArgs]) if err := w.store.DeferTerminalFailure(ctx, j.MessageID, job.ID, job.Attempt, observedAt, out.Err.Error()); err != nil { log.Printf("[outbound-send] defer terminal failure for %s: %v", j.MessageID, err) } - // Not counted as terminal: the reconciler declares the real outcome - // (sent on evidence, failed otherwise) after the grace window and - // emits it then — counting the deferral too would double-count the - // message in e2a_outbound_terminal_total. return fmt.Errorf("outbound send failed (final attempt %d; outcome deferred to terminal reconciler): %w", job.Attempt, out.Err) } - // Retryable — River reschedules per NextRetry. + // Retryable — River reschedules per NextRetry. The next execution returns + // to Reserve, which allocates the next ordinal; an acceptance-unknown + // failure takes the same path because only provider feedback can say + // whether the body was kept. if err := w.store.RecordTemporaryFailure(ctx, j.MessageID, job.ID, job.Attempt, observedAt, out.Err.Error()); err != nil { return fmt.Errorf("record outbound temporary failure and release claim: %w", err) } return fmt.Errorf("outbound send attempt %d failed: %w", job.Attempt, out.Err) } -func (j *SendJob) rampEligible() bool { - return j.SentAs == "own_address" && j.MessageType != "test" +// operationFor returns the job's durable operation, resolving a legacy job +// through the accept path. It returns a River verdict (snooze/cancel) as its +// error when the message cannot proceed. +func (w *SendWorker) operationFor(ctx context.Context, job *river.Job[OutboundSendArgs], j *SendJob) (sendingpolicy.OperationRef, error) { + observedAt := w.now().UTC() + if job.Args.OperationRef != nil && !job.Args.OperationRef.IsZero() { + // A customer message's operation IS its message id. A job whose + // reference names another operation would charge that operation's + // account and route this message's feedback to that message; the + // gate cannot tell, because every reference reloads its row. This is + // the one place the two ids meet, so this is where they must agree. + if job.Args.OperationRef.ID() != j.MessageID { + return sendingpolicy.OperationRef{}, w.cancelTerminally(ctx, job, j, sendingpolicy.AttemptRef{}, observedAt, "sending_policy: job operation reference does not name this message") + } + return *job.Args.OperationRef, nil + } + if w.resolve == nil { + return sendingpolicy.OperationRef{}, w.cancelTerminally(ctx, job, j, sendingpolicy.AttemptRef{}, observedAt, "sending_policy: legacy job carries no operation and no resolver is wired") + } + decision, ref, err := w.resolve(ctx, j.MessageID) + if err != nil { + if errors.Is(err, sendingpolicy.ErrSourceUnavailable) { + return sendingpolicy.OperationRef{}, w.cancelTerminally(ctx, job, j, sendingpolicy.AttemptRef{}, observedAt, "sending_policy: legacy source unavailable: "+err.Error()) + } + return sendingpolicy.OperationRef{}, w.snoozeOnGateError(ctx, job, j, sendingpolicy.AttemptRef{}, "resolve", err) + } + if decision == sendingpolicy.AcceptanceSendingPaused { + return sendingpolicy.OperationRef{}, w.hold(ctx, job, j, sendingpolicy.AttemptRef{}, sendingpolicy.Decision{Reason: sendingpolicy.ReasonAccountPaused}, observedAt) + } + if ref.IsZero() { + // The only accepted shape with no operation is an exact self-send, + // which never enqueues. A queued message that resolves to nothing is + // not something this worker can authorize. + return sendingpolicy.OperationRef{}, w.cancelTerminally(ctx, job, j, sendingpolicy.AttemptRef{}, observedAt, "sending_policy: message has no provider operation") + } + return ref, nil +} + +// hold handles a gate hold: a terminal one fails the message now; a pause +// waits for an operator; every other one is a finite hold with a clock. +func (w *SendWorker) hold(ctx context.Context, job *river.Job[OutboundSendArgs], j *SendJob, attempt sendingpolicy.AttemptRef, d sendingpolicy.Decision, observedAt time.Time) error { + if d.Terminal { + return w.cancelTerminally(ctx, job, j, attempt, observedAt, "sending_policy: "+d.Reason) + } + class := HoldClassFor(d.Reason) + delay := indefiniteHoldSnooze + if !d.RetryAt.IsZero() { + delay = time.Until(d.RetryAt) + if delay < time.Minute { + delay = time.Minute + } + } + if class == "" { + // An account pause has no clock of its own. It starts no finite hold + // and evaluates none: a paused job only waits. A deadline persisted + // before the pause is not extended either — after resume the job + // either continues within its remaining time or expires with its + // class's own reason on the next hold it meets. + if err := w.store.ReleaseSend(ctx, j.MessageID, job.ID); err != nil { + return fmt.Errorf("release outbound send claim during account pause: %w", err) + } + return river.JobSnooze(delay) + } + return w.holdFinite(ctx, job, j, attempt, class, "sending_policy_hold: "+d.Reason, delay, observedAt) +} + +// holdFinite persists the hold state, expires the message when its derived +// deadline has passed, and otherwise releases the claim and snoozes. +func (w *SendWorker) holdFinite(ctx context.Context, job *river.Job[OutboundSendArgs], j *SendJob, attempt sendingpolicy.AttemptRef, requested HoldClass, detail string, delay time.Duration, observedAt time.Time) error { + class, anchor, changed := w.nextHoldState(j, requested, observedAt) + if changed { + if err := w.store.RecordHold(ctx, j.MessageID, class, anchor); err != nil { + return fmt.Errorf("record outbound hold: %w", err) + } + j.LocalHoldClass, j.LocalHoldAnchor = class, anchor + } + if !observedAt.Before(anchor.Add(class.horizon())) { + if err := w.markFailed(ctx, j.MessageID, job.ID, job.Attempt, j.submissionAnchor(), observedAt, detail, delivery.FailureSourceLocal, expiryReasonFor(class, false), nil); err != nil { + return err + } + w.cancelAttempt(ctx, attempt, "hold expiry") + return river.JobCancel(fmt.Errorf("%s: %s hold expired after %s", detail, class, class.horizon())) + } + if err := w.store.ReleaseSend(ctx, j.MessageID, job.ID); err != nil { + return fmt.Errorf("release outbound send claim during hold: %w", err) + } + return river.JobSnooze(delay) +} + +// holdExpired reports whether encountering `requested` now would find the +// message past its derived deadline, without persisting anything. +func (w *SendWorker) holdExpired(j *SendJob, requested HoldClass, observedAt time.Time) bool { + class, anchor, _ := w.nextHoldState(j, requested, observedAt) + return !observedAt.Before(anchor.Add(class.horizon())) +} + +// nextHoldState applies the durable hold rules to the message's persisted pair +// and the class it is now encountering, reporting whether anything changed. +// +// - First finite hold: the requested class, anchored at the latest of accept, +// schedule, review, and last resume — or at the observation time for a +// tenant-setup hold observed later than that. +// - A budget hold promotes any class to policy_budget, keeping the anchor. +// - policy_budget never changes again. +// - Otherwise the persisted class stands: a later readiness loss does not +// replace a rate class, and a rate hold does not replace a setup class. +func (w *SendWorker) nextHoldState(j *SendJob, requested HoldClass, observedAt time.Time) (HoldClass, time.Time, bool) { + if j.LocalHoldClass == "" { + anchor := j.initialHoldAnchor() + // A tenant-setup hold observed later than the anchor starts its + // clock at the observation; so does a message whose timestamps are + // unknown, which must never be treated as already expired. + if (requested == HoldTenantSetup && observedAt.After(anchor)) || anchor.IsZero() { + anchor = observedAt + } + return requested, anchor, true + } + if j.LocalHoldClass == HoldPolicyBudget { + return HoldPolicyBudget, j.LocalHoldAnchor, false + } + if requested == HoldPolicyBudget { + return HoldPolicyBudget, j.LocalHoldAnchor, true + } + return j.LocalHoldClass, j.LocalHoldAnchor, false +} + +// applyTenantReadiness performs the one-way setup→rate transition when the +// tenant became ready on or before the setup deadline. The comparison uses the +// stored readiness time, so a worker waking after the old deadline still +// honors readiness that committed in time. +func (w *SendWorker) applyTenantReadiness(ctx context.Context, j *SendJob) error { + if j.LocalHoldClass != HoldTenantSetup || j.TenantReadyAt.IsZero() { + return nil + } + if j.TenantReadyAt.After(j.LocalHoldAnchor.Add(HoldTenantSetup.horizon())) { + return nil + } + if err := w.store.RecordHold(ctx, j.MessageID, HoldRateRampOrProvider, j.TenantReadyAt); err != nil { + return fmt.Errorf("record tenant readiness transition: %w", err) + } + j.LocalHoldClass, j.LocalHoldAnchor = HoldRateRampOrProvider, j.TenantReadyAt + return nil +} + +// expiryReasonFor picks the lifecycle reason for a hold that expired. The +// persisted class decides, with the one exception the design names: a later +// provider outage cannot emit the setup reason, because the provider — not +// setup — is what blocked the send at the end. A rate or ramp wait met by a +// setup-class message still expires as setup: missing or late readiness is +// the story of that message. +func expiryReasonFor(class HoldClass, providerOutage bool) messagelifecycle.ReasonCode { + if class == HoldTenantSetup && providerOutage { + return HoldRateRampOrProvider.expiryReason() + } + return class.expiryReason() +} + +// HoldClassFor maps a gate hold reason to its finite-hold class; "" means the +// hold has no clock (an account pause). +func HoldClassFor(reason string) HoldClass { + switch reason { + case sendingpolicy.ReasonAccountPaused: + return "" + case sendingpolicy.ReasonAccountDailyBudget, sendingpolicy.ReasonAccountSharedBudget, + sendingpolicy.ReasonGlobalAllBudget, sendingpolicy.ReasonGlobalProbation, + sendingpolicy.ReasonGlobalCritical, sendingpolicy.ReasonGlobalViolation: + return HoldPolicyBudget + case sendingpolicy.ReasonTenantNotReady, sendingpolicy.ReasonTenantUnnamed: + return HoldTenantSetup + } + // Ramp capacity, an unverified sending identity, and any hold reason this + // worker does not know by name all wait on the 72-hour clock: unknown is + // the shorter horizon, never the longer one. + return HoldRateRampOrProvider +} + +// cancelTerminally fails the message for a reason no retry can change and +// gives its attempt back where the gate still allows it. +func (w *SendWorker) cancelTerminally(ctx context.Context, job *river.Job[OutboundSendArgs], j *SendJob, attempt sendingpolicy.AttemptRef, observedAt time.Time, detail string) error { + if err := w.markFailed(ctx, j.MessageID, job.ID, job.Attempt, j.submissionAnchor(), observedAt, detail, delivery.FailureSourceLocal, messagelifecycle.ReasonSubmissionCancelled, nil); err != nil { + return err + } + w.cancelAttempt(ctx, attempt, "terminal") + return river.JobCancel(errors.New(detail)) +} + +// snoozeOnGateError releases the claim and snoozes when the gate itself is +// unavailable: fail toward retry, never toward an unauthorized submit, and +// never burn a River attempt on infrastructure. +// +// It is a bounded wait like every other one: the message enters (or stays +// in) the rate/ramp/provider class and expires at that class's deadline, so a +// gate that is down for days does not park mail forever. +func (w *SendWorker) snoozeOnGateError(ctx context.Context, job *river.Job[OutboundSendArgs], j *SendJob, attempt sendingpolicy.AttemptRef, step string, gerr error) error { + log.Printf("[outbound-send] sending policy %s failed for %s (snoozing): %v", step, j.MessageID, gerr) + return w.holdFinite(ctx, job, j, attempt, HoldRateRampOrProvider, "sending_policy_unavailable: "+step+": "+gerr.Error(), gateErrorSnoozeInterval, w.now().UTC()) +} + +// deferAttempt gives the budget back for a rate deferral; a stale or already +// released attempt is not an error here — the next Reserve is idempotent. +func (w *SendWorker) deferAttempt(ctx context.Context, attempt sendingpolicy.AttemptRef, why string) { + if w.gate == nil { + return + } + if err := w.gate.DeferAttempt(ctx, attempt); err != nil && + !errors.Is(err, sendingpolicy.ErrAttemptStale) && !errors.Is(err, sendingpolicy.ErrSourceUnavailable) { + log.Printf("[outbound-send] defer attempt (%s) for %s: %v", why, attempt.OperationID(), err) + } +} + +// cancelAttempt gives both ledgers back for a terminal local outcome. A +// started attempt cannot be refunded and says so; that is expected on a +// terminal path reached after a socket opened. +func (w *SendWorker) cancelAttempt(ctx context.Context, attempt sendingpolicy.AttemptRef, why string) { + if w.gate == nil { + return + } + // A zero attempt (no reservation was ever made) has nothing to give back; + // the gate says so with ErrSourceUnavailable and that is not worth a log. + if err := w.gate.CancelAttempt(ctx, attempt); err != nil && + !errors.Is(err, sendingpolicy.ErrAttemptStale) && !errors.Is(err, sendingpolicy.ErrProviderCallStarted) && + !errors.Is(err, sendingpolicy.ErrSourceUnavailable) { + log.Printf("[outbound-send] cancel attempt (%s) for %s: %v", why, attempt.OperationID(), err) + } +} + +// resettle retries a settlement that failed after the provider accepted the +// message. It mirrors markFailed's bounded retry: a transient database error +// should not cost a domain its ramp progress for the day. +func (w *SendWorker) resettle(ctx context.Context, messageID, providerMessageID string, first error) { + if w.gate == nil { + return + } + err := first + for i := 0; i < terminalWriteRetries; i++ { + select { + case <-ctx.Done(): + // Shutdown or the job timeout: the one moment a lost settlement + // is likeliest, so it must not also be the one that goes unlogged. + log.Printf("[outbound-send] CRITICAL: %s accepted by provider but not settled (context ended before retry): %v", messageID, err) + return + case <-time.After(time.Duration(i+1) * terminalWriteBackoff): + } + ref, lerr := w.gate.LookupOperation(ctx, messageID) + if lerr != nil { + err = lerr + continue + } + err = w.gate.SettleOperation(ctx, ref, sendingpolicy.SettlementProviderAccepted, providerMessageID) + if err == nil || errors.Is(err, sendingpolicy.ErrAttemptStale) { + return + } + if errors.Is(err, sendingpolicy.ErrProviderMessageIDConflict) { + break + } + } + log.Printf("[outbound-send] CRITICAL: %s accepted by provider but not settled after retries: %v", messageID, err) +} + +// settleFromEvidence applies provider-accept evidence to the operation's +// latest dialed attempt. Best effort: the row is already settled as sent, and +// an attempt that predates the gate has nothing to settle. +func (w *SendWorker) settleFromEvidence(ctx context.Context, messageID, providerMessageID string) { + if w.gate == nil { + return + } + ref, err := w.gate.LookupOperation(ctx, messageID) + if err != nil { + if !errors.Is(err, sendingpolicy.ErrSourceUnavailable) { + log.Printf("[outbound-send] lookup operation for evidence settle of %s: %v", messageID, err) + } + return + } + if err := w.gate.SettleOperation(ctx, ref, sendingpolicy.SettlementProviderAccepted, providerMessageID); err != nil && !errors.Is(err, sendingpolicy.ErrAttemptStale) { + if errors.Is(err, sendingpolicy.ErrProviderMessageIDConflict) { + // Two physical sends for one charged attempt, or evidence + // attributed to the wrong attempt: an invariant violation, never + // a transient. Surface it as such. + log.Printf("[outbound-send] CRITICAL: provider id conflict settling %s from evidence: %v", messageID, err) + return + } + log.Printf("[outbound-send] settle %s from provider evidence: %v", messageID, err) + } } // clampRateSnooze bounds a rate deferral to [rateMinSnooze, window]: the floor @@ -672,11 +998,6 @@ func rateJitter(messageID string, window time.Duration) time.Duration { return time.Duration(h.Sum32()%uint32(ms)) * time.Millisecond } -func isPermanentRampError(err error) bool { - var permanent interface{ Permanent() bool } - return errors.As(err, &permanent) && permanent.Permanent() -} - func uniqueRecipientCount(recipients []string) int { seen := make(map[string]struct{}, len(recipients)) for _, recipient := range recipients { @@ -704,7 +1025,8 @@ func (w *SendWorker) markFailed(ctx context.Context, messageID string, jobID int for i := 0; i < terminalWriteRetries; i++ { var settled delivery.Status var settledAt time.Time - if settled, settledAt, err = w.store.MarkFailed(ctx, messageID, jobID, attempt, occurredAt, detail, source, reason, blockedRecipients); err == nil { + var providerID string + if settled, settledAt, providerID, err = w.store.MarkFailed(ctx, messageID, jobID, attempt, occurredAt, detail, source, reason, blockedRecipients); err == nil { // Emit what the guarded write actually did, exactly once, only // after the durable write: a failure with the caller's provenance, // or "sent" when provider evidence settled the row. A no-op write @@ -719,6 +1041,11 @@ func (w *SendWorker) markFailed(ctx context.Context, messageID string, jobID int emitTerminal(w.metrics, terminalOutcome(source, reason, blockedRecipients), anchorAt, settledAt) case delivery.StatusSent: emitTerminal(w.metrics, terminalSent, anchorAt, settledAt) + // Provider evidence settled the row under a terminal write that + // expected to fail it. The attempt that dialed still needs + // settling — ramp progress and the correlation binding — and + // only the operation, not this call's attempt, names it. + w.settleFromEvidence(ctx, messageID, providerID) } return nil } diff --git a/internal/outboundsend/worker_test.go b/internal/outboundsend/worker_test.go index 4ca28b6ac..1bcd55426 100644 --- a/internal/outboundsend/worker_test.go +++ b/internal/outboundsend/worker_test.go @@ -2,16 +2,19 @@ package outboundsend_test import ( "context" + "encoding/json" "errors" "testing" "time" + "github.com/jackc/pgx/v5" "github.com/riverqueue/river" "github.com/riverqueue/river/rivertype" "github.com/tokencanopy/e2a/internal/delivery" "github.com/tokencanopy/e2a/internal/messagelifecycle" "github.com/tokencanopy/e2a/internal/outboundsend" + "github.com/tokencanopy/e2a/internal/sendingpolicy" ) type fakeStore struct { @@ -24,6 +27,8 @@ type fakeStore struct { // occurred_at to the provider-accept evidence time for the durable write. settleStatus delivery.Status settleAt time.Time + // settleProviderID is the evidence's provider id an evidence settle reports. + settleProviderID string // terminalAfterFailure mirrors the production store: once MarkFailed commits, // a retry can no longer claim the terminal message and ClaimSend returns nil. terminalAfterFailure bool @@ -32,6 +37,7 @@ type fakeStore struct { suppressedErr error sent []sentCall + holds []holdCall failed []failedCall deferred []failedCall temporary []failedCall @@ -43,12 +49,18 @@ type fakeStore struct { } type sentCall struct{ id, provider, sentAs string } +type holdCall struct { + id string + class outboundsend.HoldClass + anchor time.Time +} type failedCall struct { id string attempt int occurredAt time.Time detail string source delivery.FailureSource + reason messagelifecycle.ReasonCode blockedRecipients []string } @@ -62,8 +74,8 @@ func (f *fakeStore) MarkSent(_ context.Context, id string, _ int64, _ int, _ tim f.sent = append(f.sent, sentCall{id, provider, sentAs}) return f.markSentErr } -func (f *fakeStore) MarkFailed(_ context.Context, id string, _ int64, attempt int, occurredAt time.Time, detail string, source delivery.FailureSource, _ messagelifecycle.ReasonCode, blockedRecipients []string) (delivery.Status, time.Time, error) { - f.failed = append(f.failed, failedCall{id: id, attempt: attempt, occurredAt: occurredAt, detail: detail, source: source, blockedRecipients: blockedRecipients}) +func (f *fakeStore) MarkFailed(_ context.Context, id string, _ int64, attempt int, occurredAt time.Time, detail string, source delivery.FailureSource, reason messagelifecycle.ReasonCode, blockedRecipients []string) (delivery.Status, time.Time, string, error) { + f.failed = append(f.failed, failedCall{id: id, attempt: attempt, occurredAt: occurredAt, detail: detail, source: source, reason: reason, blockedRecipients: blockedRecipients}) status := f.settleStatus if status == "" { status = delivery.StatusFailed @@ -72,7 +84,7 @@ func (f *fakeStore) MarkFailed(_ context.Context, id string, _ int64, attempt in if at.IsZero() { at = occurredAt } - return status, at, nil + return status, at, f.settleProviderID, nil } func (f *fakeStore) PreserveTerminalFailure(context.Context, string, int64, int, time.Time, string, delivery.FailureSource, messagelifecycle.ReasonCode, []string) error { return nil @@ -86,6 +98,13 @@ func (f *fakeStore) RecordTemporaryFailure(_ context.Context, id string, _ int64 f.temporary = append(f.temporary, failedCall{id: id}) return f.releaseErr } +func (f *fakeStore) RecordHold(_ context.Context, id string, class outboundsend.HoldClass, anchor time.Time) error { + f.holds = append(f.holds, holdCall{id: id, class: class, anchor: anchor}) + if f.job != nil && f.job.MessageID == id { + f.job.LocalHoldClass, f.job.LocalHoldAnchor = class, anchor + } + return nil +} func (f *fakeStore) ReleaseSend(_ context.Context, id string, _ int64) error { f.released = append(f.released, id) return f.releaseErr @@ -101,45 +120,11 @@ type fakeDeliverer struct { out outboundsend.DeliverOutcome calls int returnedAt time.Time + auths []sendingpolicy.ProviderAuthorization } -type fakeRampGate struct { - decision outboundsend.RampDecision - err error - calls []outboundsend.RampRequest - confirmed []string - released []string - resolved []string - confirmErr error - releaseErr error -} - -func (f *fakeRampGate) Reserve(_ context.Context, req outboundsend.RampRequest) (outboundsend.RampDecision, error) { - f.calls = append(f.calls, req) - return f.decision, f.err -} - -func (f *fakeRampGate) Confirm(_ context.Context, messageID string) error { - f.confirmed = append(f.confirmed, messageID) - return f.confirmErr -} - -func (f *fakeRampGate) Release(_ context.Context, messageID string) error { - f.released = append(f.released, messageID) - return f.releaseErr -} - -func (f *fakeRampGate) Resolve(_ context.Context, messageID string) error { - f.resolved = append(f.resolved, messageID) - return nil -} - -type permanentRampError struct{ msg string } - -func (e permanentRampError) Error() string { return e.msg } -func (e permanentRampError) Permanent() bool { return true } - -func (f *fakeDeliverer) Deliver(_ context.Context, _ *outboundsend.SendJob) outboundsend.DeliverOutcome { +func (f *fakeDeliverer) Deliver(_ context.Context, _ *outboundsend.SendJob, auth sendingpolicy.ProviderAuthorization) outboundsend.DeliverOutcome { + f.auths = append(f.auths, auth) f.calls++ f.returnedAt = time.Now().UTC() return f.out @@ -286,172 +271,6 @@ func TestSendWorker_SuppressionObservationTimeFollowsDecision(t *testing.T) { } } -func TestSendWorker_RampLimitedReleasesAndSnoozesWithoutProviderIO(t *testing.T) { - j := acceptedJob("msg_1") - j.Domain = "new.example.com" - j.MessageType = "send" - j.SentAs = "own_address" - j.Recipients = []string{"One@example.net", "one@example.net", "two@example.net"} - st := &fakeStore{job: j} - dl := &fakeDeliverer{} - gate := &fakeRampGate{decision: outboundsend.RampDecision{ - Allowed: false, - RetryAt: time.Now().Add(6 * time.Hour), - }} - - err := outboundsend.NewSendWorker(st, dl, gate).Work(context.Background(), job("msg_1", 5)) - if err == nil { - t.Fatal("limited send should snooze") - } - if dl.calls != 0 { - t.Fatalf("provider calls = %d, want 0", dl.calls) - } - if len(st.released) != 1 || st.released[0] != "msg_1" { - t.Fatalf("released = %v, want msg_1", st.released) - } - if len(gate.calls) != 1 || gate.calls[0].Units != 2 || gate.calls[0].Domain != "new.example.com" { - t.Fatalf("gate calls = %+v, want two deduplicated recipients", gate.calls) - } -} - -func TestSendWorker_RampErrorFailsClosedAndSnoozes(t *testing.T) { - j := acceptedJob("msg_1") - j.Domain, j.MessageType, j.SentAs = "new.example.com", "send", "own_address" - st := &fakeStore{job: j} - dl := &fakeDeliverer{} - gate := &fakeRampGate{err: errors.New("database unavailable")} - - if err := outboundsend.NewSendWorker(st, dl, gate).Work(context.Background(), job("msg_1", 1)); err == nil { - t.Fatal("ramp storage error should snooze") - } - if dl.calls != 0 || len(st.released) != 1 { - t.Fatalf("gate error must release without provider I/O: calls=%d released=%v", dl.calls, st.released) - } -} - -func TestSendWorker_RampExemptsPlatformTest(t *testing.T) { - j := acceptedJob("msg_test") - j.Domain, j.MessageType, j.SentAs = "new.example.com", "test", "relay" - st := &fakeStore{job: j} - dl := &fakeDeliverer{out: outboundsend.DeliverOutcome{ProviderMessageID: "ses-test"}} - gate := &fakeRampGate{decision: outboundsend.RampDecision{Allowed: false}} - - if err := outboundsend.NewSendWorker(st, dl, gate).Work(context.Background(), job("msg_test", 1)); err != nil { - t.Fatalf("Work: %v", err) - } - if len(gate.calls) != 0 || dl.calls != 1 { - t.Fatalf("platform test should bypass ramp: gate=%d provider=%d", len(gate.calls), dl.calls) - } -} - -func TestSendWorker_ProviderEvidencePrecedesRamp(t *testing.T) { - j := acceptedJob("msg_1") - j.Domain, j.MessageType, j.SentAs = "new.example.com", "send", "own_address" - j.ProviderAccepted, j.ProviderMessageID = true, "ses-evidence" - st := &fakeStore{job: j} - gate := &fakeRampGate{decision: outboundsend.RampDecision{Allowed: false}} - - if err := outboundsend.NewSendWorker(st, &fakeDeliverer{}, gate).Work(context.Background(), job("msg_1", 2)); err != nil { - t.Fatalf("Work: %v", err) - } - if len(gate.calls) != 0 { - t.Fatalf("provider evidence must settle before ramp reservation, got %+v", gate.calls) - } -} - -func TestSendWorker_ConfirmsRampAfterMarkSent(t *testing.T) { - j := acceptedJob("msg_confirm") - j.Domain, j.MessageType, j.SentAs = "new.example.com", "send", "own_address" - st := &fakeStore{job: j} - gate := &fakeRampGate{decision: outboundsend.RampDecision{Allowed: true}} - dl := &fakeDeliverer{out: outboundsend.DeliverOutcome{ProviderMessageID: "ses-confirm", SentAs: "own_address"}} - if err := outboundsend.NewSendWorker(st, dl, gate).Work(context.Background(), job(j.MessageID, 1)); err != nil { - t.Fatalf("Work: %v", err) - } - if len(st.sent) != 1 || len(gate.confirmed) != 1 || gate.confirmed[0] != j.MessageID { - t.Fatalf("sent=%v confirmed=%v", st.sent, gate.confirmed) - } -} - -func TestSendWorker_RepairsRampConfirmationForAlreadySentMessage(t *testing.T) { - j := acceptedJob("msg_repair") - j.Domain, j.MessageType, j.SentAs, j.Status = "new.example.com", "send", "own_address", "sent" - gate := &fakeRampGate{} - dl := &fakeDeliverer{} - if err := outboundsend.NewSendWorker(&fakeStore{job: j}, dl, gate).Work(context.Background(), job(j.MessageID, 2)); err != nil { - t.Fatalf("Work: %v", err) - } - if dl.calls != 0 || len(gate.resolved) != 1 { - t.Fatalf("deliver=%d resolved=%v", dl.calls, gate.resolved) - } -} - -func TestSendWorker_ReleasesRampOnPermanentProviderFailure(t *testing.T) { - j := acceptedJob("msg_release") - j.Domain, j.MessageType, j.SentAs = "new.example.com", "send", "own_address" - gate := &fakeRampGate{decision: outboundsend.RampDecision{Allowed: true}} - dl := &fakeDeliverer{out: outboundsend.DeliverOutcome{Err: errors.New("rejected"), Permanent: true}} - _ = outboundsend.NewSendWorker(&fakeStore{job: j}, dl, gate).Work(context.Background(), job(j.MessageID, 1)) - if len(gate.released) != 1 || gate.released[0] != j.MessageID { - t.Fatalf("released=%v", gate.released) - } -} - -func TestSendWorker_RetainsRampOnAmbiguousFailure(t *testing.T) { - j := acceptedJob("msg_ambiguous") - j.Domain, j.MessageType, j.SentAs = "new.example.com", "send", "own_address" - gate := &fakeRampGate{decision: outboundsend.RampDecision{Allowed: true}} - dl := &fakeDeliverer{out: outboundsend.DeliverOutcome{Err: errors.New("connection reset")}} - _ = outboundsend.NewSendWorker(&fakeStore{job: j}, dl, gate).Work(context.Background(), job(j.MessageID, 1)) - if len(gate.released) != 0 { - t.Fatalf("ambiguous failure released ramp: %v", gate.released) - } -} - -func TestSendWorker_FailsPermanentRampInvariant(t *testing.T) { - j := acceptedJob("msg_bad_ramp") - j.Domain, j.MessageType, j.SentAs = "new.example.com", "send", "own_address" - st := &fakeStore{job: j} - gate := &fakeRampGate{err: permanentRampError{"domain missing"}} - if err := outboundsend.NewSendWorker(st, &fakeDeliverer{}, gate).Work(context.Background(), job(j.MessageID, 1)); err == nil { - t.Fatal("permanent ramp invariant should terminate") - } - if len(st.failed) != 1 { - t.Fatalf("failed=%v", st.failed) - } -} - -func TestSendWorker_FailsRampDeferredMessagePastHorizon(t *testing.T) { - j := acceptedJob("msg_ramp_timeout") - j.Domain, j.MessageType, j.SentAs = "new.example.com", "send", "own_address" - j.AcceptedAt = time.Now().Add(-73 * time.Hour) - st := &fakeStore{job: j} - gate := &fakeRampGate{decision: outboundsend.RampDecision{Allowed: false, RetryAt: time.Now().Add(time.Hour)}} - if err := outboundsend.NewSendWorker(st, &fakeDeliverer{}, gate).Work(context.Background(), job(j.MessageID, 1)); err == nil { - t.Fatal("past-horizon ramp deferral should terminate") - } - if len(st.failed) != 1 || len(gate.released) != 1 { - t.Fatalf("failed=%v released=%v", st.failed, gate.released) - } -} - -// A scheduled send measures its retry horizon from scheduled_at, not accept: -// accepted 10 days ago but firing ~now, a ramp deferral must snooze/retry — NOT -// terminally fail as the immediate-send case above does at the same accept age. -// Guards the fix for the long-scheduled-send false-failure blocker. -func TestSendWorker_ScheduledSendHorizonMeasuredFromScheduledAt(t *testing.T) { - j := acceptedJob("msg_sched_horizon") - j.Domain, j.MessageType, j.SentAs = "new.example.com", "send", "own_address" - j.AcceptedAt = time.Now().Add(-10 * 24 * time.Hour) // long before fire - j.ScheduledAt = time.Now() // just fired — inside the horizon - st := &fakeStore{job: j} - gate := &fakeRampGate{decision: outboundsend.RampDecision{Allowed: false, RetryAt: time.Now().Add(time.Hour)}} - err := outboundsend.NewSendWorker(st, &fakeDeliverer{}, gate).Work(context.Background(), job(j.MessageID, 1)) - if len(st.failed) != 0 { - t.Fatalf("a just-fired long-scheduled send must not be terminated on a ramp deferral; failed=%v err=%v", st.failed, err) - } -} - func TestSendWorker_RetryableFailureDoesNotMarkFailed(t *testing.T) { st := &fakeStore{job: acceptedJob("msg_1")} dl := &fakeDeliverer{out: outboundsend.DeliverOutcome{Err: errors.New("transient 421")}} @@ -483,34 +302,6 @@ func TestSendWorker_RetryableFailureReleaseErrorRetries(t *testing.T) { } } -func TestSendWorker_TerminalRampReleaseFailureResolvesOnRetry(t *testing.T) { - j := acceptedJob("msg_1") - j.Domain, j.MessageType, j.SentAs = "new.example.com", "send", "own_address" - st := &fakeStore{job: j, terminalAfterFailure: true} - dl := &fakeDeliverer{out: outboundsend.DeliverOutcome{Err: errors.New("provider rejected message"), Permanent: true}} - gate := &fakeRampGate{ - decision: outboundsend.RampDecision{Allowed: true}, - releaseErr: errors.New("ramp database unavailable"), - } - w := outboundsend.NewSendWorker(st, dl, gate) - - if err := w.Work(context.Background(), job(j.MessageID, 1)); err == nil || !errors.Is(err, gate.releaseErr) { - t.Fatalf("first Work error = %v, want ramp release failure", err) - } - if len(st.failed) != 1 || len(gate.released) != 1 { - t.Fatalf("first Work failed/released = %v/%v, want one each", st.failed, gate.released) - } - - // MarkFailed made the message terminal, so the retry cannot claim it. The - // worker must still settle the orphaned reservation from the durable outcome. - if err := w.Work(context.Background(), job(j.MessageID, 2)); err != nil { - t.Fatalf("retry Work: %v", err) - } - if len(gate.resolved) != 1 || gate.resolved[0] != j.MessageID { - t.Fatalf("resolved reservations = %v, want [%s]", gate.resolved, j.MessageID) - } -} - func TestSendWorker_OutageSnoozesWithoutBurningAttempt(t *testing.T) { j := acceptedJob("msg_1") j.AcceptedAt = time.Now() // fresh accept — within the retry horizon @@ -561,3 +352,94 @@ func TestSendWorker_NextRetryMatchesEnvelope(t *testing.T) { } } } + +// fakeGate is a scriptable sendingpolicy.Gate. Its references and tokens are +// zero values — the worker never inspects them beyond nil/zero checks — and it +// records every ledger call so tests can assert the fixed worker order. +type fakeGate struct { + reserve sendingpolicy.Decision + reserveErr error + consume sendingpolicy.Decision + consumeErr error + deferred []string + cancelled []string + settled []sendingpolicy.SettlementOutcome + settledIDs []string + reserves int + consumes int + lookupErr error + lookupCalls int +} + +func allowAll() *fakeGate { + return &fakeGate{reserve: sendingpolicy.Decision{Allow: true}, consume: sendingpolicy.Decision{Allow: true}} +} + +func (g *fakeGate) PrepareExternalTx(context.Context, pgx.Tx, string) (sendingpolicy.AcceptanceDecision, sendingpolicy.OperationRef, error) { + return sendingpolicy.AcceptanceAccept, refFor("msg_prepared"), nil +} +func (g *fakeGate) PrepareNotificationTx(context.Context, pgx.Tx, sendingpolicy.NotificationRef) (sendingpolicy.OperationRef, error) { + return sendingpolicy.OperationRef{}, nil +} +func (g *fakeGate) PrepareProtectionNoticeTx(context.Context, pgx.Tx, sendingpolicy.ProtectionNoticeRef) (sendingpolicy.OperationRef, error) { + return sendingpolicy.OperationRef{}, nil +} +func (g *fakeGate) PreparePublicFeedback(context.Context, sendingpolicy.PublicFeedbackRef) (sendingpolicy.OperationRef, error) { + return sendingpolicy.OperationRef{}, nil +} +func (g *fakeGate) Reserve(context.Context, sendingpolicy.OperationRef) (sendingpolicy.Decision, sendingpolicy.AttemptRef, error) { + g.reserves++ + return g.reserve, sendingpolicy.AttemptRef{}, g.reserveErr +} +func (g *fakeGate) ConsumeAttempt(context.Context, sendingpolicy.AttemptRef) (sendingpolicy.Decision, *sendingpolicy.ProviderAuthorization, error) { + g.consumes++ + if g.consumeErr != nil || !g.consume.Allow { + return g.consume, nil, g.consumeErr + } + return g.consume, &sendingpolicy.ProviderAuthorization{}, nil +} +func (g *fakeGate) RedeemProviderCall(context.Context, sendingpolicy.ProviderAuthorization) error { + return nil +} +func (g *fakeGate) DeferAttempt(_ context.Context, a sendingpolicy.AttemptRef) error { + g.deferred = append(g.deferred, a.OperationID()) + return nil +} +func (g *fakeGate) CancelAttempt(_ context.Context, a sendingpolicy.AttemptRef) error { + g.cancelled = append(g.cancelled, a.OperationID()) + return nil +} +func (g *fakeGate) SettleProvider(_ context.Context, s sendingpolicy.ProviderSettlement) error { + g.settled = append(g.settled, s.Outcome) + return nil +} +func (g *fakeGate) SettleOperation(_ context.Context, _ sendingpolicy.OperationRef, o sendingpolicy.SettlementOutcome, id string) error { + g.settled = append(g.settled, o) + g.settledIDs = append(g.settledIDs, id) + return nil +} +func (g *fakeGate) LookupOperation(_ context.Context, id string) (sendingpolicy.OperationRef, error) { + g.lookupCalls++ + if g.lookupErr != nil { + return sendingpolicy.OperationRef{}, g.lookupErr + } + return refFor(id), nil +} + +// refFor builds an operation reference the way a River job carries one: the +// versioned wire form holding only the id. +func refFor(id string) sendingpolicy.OperationRef { + var ref sendingpolicy.OperationRef + if err := json.Unmarshal([]byte(`{"v":1,"id":"`+id+`"}`), &ref); err != nil { + panic(err) + } + return ref +} + +// gatedJob is job() with the operation reference the accept path would stamp. +func gatedJob(id string, attempt int) *river.Job[outboundsend.OutboundSendArgs] { + j := job(id, attempt) + ref := refFor(id) + j.Args.OperationRef = &ref + return j +} diff --git a/internal/sendingpolicy/gate.go b/internal/sendingpolicy/gate.go index 1bf71b0d8..15534515c 100644 --- a/internal/sendingpolicy/gate.go +++ b/internal/sendingpolicy/gate.go @@ -33,6 +33,8 @@ type Gate interface { DeferAttempt(context.Context, AttemptRef) error CancelAttempt(context.Context, AttemptRef) error SettleProvider(context.Context, ProviderSettlement) error + SettleOperation(context.Context, OperationRef, SettlementOutcome, string) error + LookupOperation(context.Context, string) (OperationRef, error) } var _ Gate = (*Module)(nil) @@ -1809,6 +1811,55 @@ func (m *Module) SettleProvider(ctx context.Context, settlement ProviderSettleme if settlement.Attempt.IsZero() { return ErrSourceUnavailable } + return m.settle(ctx, settlement.Attempt.operationID, settlement.Attempt.attempt, settlement) +} + +// LookupOperation recovers a reference to an operation that already exists. +// +// This is not a constructor: it returns a reference only for a durable +// operation row, and the reference carries an id and advisory fields exactly +// as a deserialized River argument does — every Gate method reloads the row +// under lock, so recovering a reference grants nothing. It exists for the +// reconciler, which learns of provider evidence by message id long after the +// worker and its token are gone. +func (m *Module) LookupOperation(ctx context.Context, operationID string) (OperationRef, error) { + if strings.TrimSpace(operationID) == "" { + return OperationRef{}, ErrSourceUnavailable + } + var row operationRow + err := m.pool.QueryRow(ctx, ` + SELECT operation_id, source_account_ref, policy_subject_ref, purpose, shared_reputation + FROM sending_provider_operations + WHERE operation_id = $1`, operationID, + ).Scan(&row.OperationID, &row.SourceAccountRef, &row.PolicySubjectRef, &row.Purpose, &row.Shared) + if errors.Is(err, pgx.ErrNoRows) { + return OperationRef{}, ErrSourceUnavailable + } + if err != nil { + return OperationRef{}, fmt.Errorf("sendingpolicy: lookup operation: %w", err) + } + return row.ref(), nil +} + +// SettleOperation applies a delayed authoritative provider outcome to the +// attempt of an operation that most recently opened a socket. +// +// It exists for the two callers that hold evidence but no token: the worker +// that finds provider-accept evidence already recorded on a row it is about to +// re-drive, and the terminal reconciler settling a stranded row from that same +// evidence. Neither can name an ordinal — the token that could is gone with the +// process that held it — but both know which OPERATION the evidence belongs to, +// and the only attempt evidence can describe is the latest one that dialed. +func (m *Module) SettleOperation(ctx context.Context, ref OperationRef, outcome SettlementOutcome, providerMessageID string) error { + if ref.IsZero() { + return ErrSourceUnavailable + } + return m.settle(ctx, ref.id, 0, ProviderSettlement{Outcome: outcome, ProviderMessageID: providerMessageID}) +} + +// settle is the shared settlement body. attempt 0 means "the latest attempt +// whose provider call started", resolved under the operation lock. +func (m *Module) settle(ctx context.Context, operationID string, attempt int, settlement ProviderSettlement) error { if !settlement.Outcome.valid() { return fmt.Errorf("sendingpolicy: unsupported settlement outcome %q", settlement.Outcome) } @@ -1819,11 +1870,46 @@ func (m *Module) SettleProvider(ctx context.Context, settlement ProviderSettleme } defer func() { _ = tx.Rollback(ctx) }() - op, err := lockOperation(ctx, tx, settlement.Attempt.operationID) + op, err := lockOperation(ctx, tx, operationID) if err != nil { return err } - stored, err := lockReservation(ctx, tx, settlement.Attempt.operationID, settlement.Attempt.attempt) + if attempt == 0 { + // Evidence without a token names an operation, not an ordinal. The + // attempt is chosen in this order: one already bound to this exact + // provider id (a replay, which must be idempotent and must not spill + // onto a later attempt); else the oldest dialed attempt with no + // provider id yet, because feedback arrives in send order far more + // often than not and each binding retires its attempt from this + // choice; else the latest dialed attempt, whose bind refuses a + // different id rather than absorb it. + if err := tx.QueryRow(ctx, ` + SELECT COALESCE( + (SELECT MIN(r.submission_attempt) + FROM sending_budget_reservations r + JOIN sending_feedback_correlations c + ON c.operation_id = r.operation_id AND c.submission_attempt = r.submission_attempt + WHERE r.operation_id = $1 AND r.call_state = 'started' + AND $2 <> '' AND c.provider_message_id = $2), + (SELECT MIN(r.submission_attempt) + FROM sending_budget_reservations r + LEFT JOIN sending_feedback_correlations c + ON c.operation_id = r.operation_id AND c.submission_attempt = r.submission_attempt + WHERE r.operation_id = $1 AND r.call_state = 'started' + AND c.provider_message_id IS NULL), + (SELECT MAX(submission_attempt) + FROM sending_budget_reservations + WHERE operation_id = $1 AND call_state = 'started'), + 0)`, operationID, NormalizeProviderMessageID(settlement.ProviderMessageID), + ).Scan(&attempt); err != nil { + return fmt.Errorf("sendingpolicy: find started attempt: %w", err) + } + if attempt == 0 { + return ErrAttemptStale + } + } + settlement.Attempt = AttemptRef{operationID: operationID, attempt: attempt} + stored, err := lockReservation(ctx, tx, operationID, attempt) if err != nil { return err } diff --git a/internal/sendingpolicy/provider_token_test.go b/internal/sendingpolicy/provider_token_test.go index 1e3d2d696..721eb4d5b 100644 --- a/internal/sendingpolicy/provider_token_test.go +++ b/internal/sendingpolicy/provider_token_test.go @@ -269,3 +269,139 @@ func TestProviderTokenSettlementComparesNormalizedProviderMessageID(t *testing.T t.Fatalf("different id err = %v, want ErrProviderMessageIDConflict", err) } } + +// TestProviderTokenSettleOperationTargetsTheLatestDialedAttempt: evidence that +// arrives without a token settles the most recent attempt that opened a +// socket — not a later ordinal that was only reserved, and nothing at all when +// no attempt ever dialed. +func TestProviderTokenSettleOperationTargetsTheLatestDialedAttempt(t *testing.T) { + f := newFixture(t) + g := f.gate(enforcingPolicy(nil)) + agent := f.agent(f.user("standard")) + ref, attempt := f.prepareAndReserve(g, agent, 1) + + err := g.SettleOperation(f.ctx, ref, sendingpolicy.SettlementProviderAccepted, "ses-early") + if !errors.Is(err, sendingpolicy.ErrAttemptStale) { + t.Fatalf("settle before any dial err = %v, want ErrAttemptStale", err) + } + + _, auth, err := g.ConsumeAttempt(f.ctx, attempt) + if err != nil || auth == nil { + t.Fatalf("authorize: auth=%v err=%v", auth, err) + } + if err := g.RedeemProviderCall(f.ctx, *auth); err != nil { + t.Fatalf("redeem: %v", err) + } + // The worker died after the socket opened; a later execution re-reserved + // ordinal two but never consumed it. Delayed evidence belongs to ordinal one. + if _, next, err := g.Reserve(f.ctx, ref); err != nil || next.Attempt() != 2 { + t.Fatalf("re-reserve: attempt=%v err=%v", next, err) + } + if err := g.SettleOperation(f.ctx, ref, sendingpolicy.SettlementProviderAccepted, ""); err != nil { + t.Fatalf("settle by operation: %v", err) + } + if got := f.providerMessageID(ref.ID(), 1); got == nil || *got != "ses-late" { + t.Fatalf("attempt one bound = %v, want ses-late", got) + } + var bound int + if err := f.pool.QueryRow(f.ctx, ` + SELECT count(*) FROM sending_feedback_correlations + WHERE operation_id = $1 AND provider_message_id IS NOT NULL`, ref.ID()).Scan(&bound); err != nil { + t.Fatal(err) + } + if bound != 1 { + t.Fatalf("%d attempts carry a provider id, want exactly the dialed one", bound) + } + if err := g.SettleProvider(f.ctx, sendingpolicy.ProviderSettlement{ + Attempt: auth.Attempt(), Outcome: sendingpolicy.SettlementProviderAccepted, ProviderMessageID: "ses-late", + }); err != nil { + t.Fatalf("replay by token: %v", err) + } +} + +// TestProviderTokenLookupOperationResolvesOnlyDurableOperations: a reference +// can be recovered for an operation that exists, and for nothing else. +func TestProviderTokenLookupOperationResolvesOnlyDurableOperations(t *testing.T) { + f := newFixture(t) + g := f.gate(enforcingPolicy(nil)) + agent := f.agent(f.user("standard")) + _, ref := f.prepareMessage(g, f.message(agent, "own_address", 1)) + + got, err := g.LookupOperation(f.ctx, ref.ID()) + if err != nil || got.ID() != ref.ID() || got.Purpose() != sendingpolicy.PurposeCustomerMessage { + t.Fatalf("lookup = %+v err=%v, want the prepared operation", got, err) + } + if _, err := g.LookupOperation(f.ctx, "msg_never_prepared"); !errors.Is(err, sendingpolicy.ErrSourceUnavailable) { + t.Fatalf("lookup of an unknown operation err = %v, want ErrSourceUnavailable", err) + } + if _, err := g.LookupOperation(f.ctx, ""); !errors.Is(err, sendingpolicy.ErrSourceUnavailable) { + t.Fatalf("lookup of an empty id err = %v, want ErrSourceUnavailable", err) + } +} + +// TestProviderTokenSettleOperationPrefersTheOldestUnboundDialedAttempt: two +// attempts dialed and both lost their 250. Evidence arriving in send order +// binds attempt one first, then attempt two — neither steals the other's id. +func TestProviderTokenSettleOperationPrefersTheOldestUnboundDialedAttempt(t *testing.T) { + f := newFixture(t) + g := f.gate(enforcingPolicy(nil)) + agent := f.agent(f.user("standard")) + ref, attempt := f.prepareAndReserve(g, agent, 1) + for i := 1; i <= 2; i++ { + if i == 2 { + var err error + if _, attempt, err = g.Reserve(f.ctx, ref); err != nil || attempt.Attempt() != 2 { + t.Fatalf("reserve ordinal two: attempt=%v err=%v", attempt, err) + } + } + _, auth, err := g.ConsumeAttempt(f.ctx, attempt) + if err != nil || auth == nil { + t.Fatalf("authorize %d: auth=%v err=%v", i, auth, err) + } + if err := g.RedeemProviderCall(f.ctx, *auth); err != nil { + t.Fatalf("redeem %d: %v", i, err) + } + } + if err := g.SettleOperation(f.ctx, ref, sendingpolicy.SettlementProviderAccepted, "ses-first"); err != nil { + t.Fatalf("settle first evidence: %v", err) + } + if err := g.SettleOperation(f.ctx, ref, sendingpolicy.SettlementProviderAccepted, "ses-second"); err != nil { + t.Fatalf("settle second evidence: %v", err) + } + if got := f.providerMessageID(ref.ID(), 1); got == nil || *got != "ses-first" { + t.Fatalf("attempt one bound = %v, want ses-first", got) + } + if got := f.providerMessageID(ref.ID(), 2); got == nil || *got != "ses-second" { + t.Fatalf("attempt two bound = %v, want ses-second", got) + } + // A replay for attempt one arriving while a LATER attempt is still + // unbound must return to attempt one, never spill onto the unbound one. + // Set that shape up on ordinal three. + if _, third, err := g.Reserve(f.ctx, ref); err != nil || third.Attempt() != 3 { + t.Fatalf("reserve ordinal three: attempt=%v err=%v", third, err) + } else { + _, auth, err := g.ConsumeAttempt(f.ctx, third) + if err != nil || auth == nil { + t.Fatalf("authorize 3: auth=%v err=%v", auth, err) + } + if err := g.RedeemProviderCall(f.ctx, *auth); err != nil { + t.Fatalf("redeem 3: %v", err) + } + } + if err := g.SettleOperation(f.ctx, ref, sendingpolicy.SettlementProviderAccepted, "ses-first"); err != nil { + t.Fatalf("replay of attempt one with attempt three unbound: %v", err) + } + if got := f.providerMessageID(ref.ID(), 3); got != nil { + t.Fatalf("attempt three bound = %q by a replay of attempt one's id", *got) + } + if err := g.SettleOperation(f.ctx, ref, sendingpolicy.SettlementProviderAccepted, "ses-third"); err != nil { + t.Fatalf("attempt three's own evidence: %v", err) + } + // Everything bound: a replay of any id is idempotent, a fourth id is a conflict. + if err := g.SettleOperation(f.ctx, ref, sendingpolicy.SettlementProviderAccepted, "ses-second"); err != nil { + t.Fatalf("replay: %v", err) + } + if err := g.SettleOperation(f.ctx, ref, sendingpolicy.SettlementProviderAccepted, "ses-fourth"); !errors.Is(err, sendingpolicy.ErrProviderMessageIDConflict) { + t.Fatalf("fourth id err = %v, want ErrProviderMessageIDConflict", err) + } +} diff --git a/internal/testutil/contract_server.go b/internal/testutil/contract_server.go index 86a3e3835..63ae44ad5 100644 --- a/internal/testutil/contract_server.go +++ b/internal/testutil/contract_server.go @@ -18,6 +18,7 @@ import ( "github.com/tokencanopy/e2a/internal/outbound" "github.com/tokencanopy/e2a/internal/outboundsend" "github.com/tokencanopy/e2a/internal/relay" + "github.com/tokencanopy/e2a/internal/sendingpolicy" "github.com/tokencanopy/e2a/internal/testutil/testdb" "github.com/tokencanopy/e2a/internal/unsubscribe" "github.com/tokencanopy/e2a/internal/usage" @@ -117,11 +118,15 @@ func StartContractServer(ctx context.Context, dbURL string) (*ContractServer, er // River enqueue semantics without submitting external email. outboundSendStore := agent.NewOutboundSendStore(store, outbox, noopUsage) store.SetScheduledSendFinalizer(outboundSendStore) + // The same composition production uses: a config-source gate running the + // disabled policy (pass-through admission, every attempt still durable) + // and the authorized submitter that refuses to dial without its token. + sendingGate := sendingpolicy.NewGate(pool, sendingpolicy.Secrets{}, sendingpolicy.PolicySourceConfig, sendingpolicy.DisabledPolicy()) outboundJobs := outboundsend.NewJobs( outboundSendStore, - agent.NewOutboundDeliverer(sender), + agent.NewOutboundDeliverer(outbound.NewProviderSubmitter(smtpRelay, sendingGate)), pool, - ) + ).WithGate(sendingGate) jobsClient, err := jobs.New(pool, jobs.Config{OutboundWorkers: 1}, outboundJobs) if err != nil { pool.Close() diff --git a/internal/testutil/server.go b/internal/testutil/server.go index c96a3d686..22d37e500 100644 --- a/internal/testutil/server.go +++ b/internal/testutil/server.go @@ -27,6 +27,7 @@ import ( "github.com/tokencanopy/e2a/internal/outbound" "github.com/tokencanopy/e2a/internal/outboundsend" "github.com/tokencanopy/e2a/internal/relay" + "github.com/tokencanopy/e2a/internal/sendingpolicy" "github.com/tokencanopy/e2a/internal/usage" "github.com/tokencanopy/e2a/internal/webhook" "github.com/tokencanopy/e2a/internal/webhookdelivery" @@ -217,11 +218,15 @@ func TestServer(t *testing.T, pool *pgxpool.Pool, opts ...TestServerOption) *E2A } outboundSendStore := agent.NewOutboundSendStore(store, outbox, noopUsage) store.SetScheduledSendFinalizer(outboundSendStore) + // The same composition production uses: a config-source gate running the + // disabled policy (pass-through admission, every attempt still durable) + // and the authorized submitter that refuses to dial without its token. + sendingGate := sendingpolicy.NewGate(pool, sendingpolicy.Secrets{}, sendingpolicy.PolicySourceConfig, sendingpolicy.DisabledPolicy()) outboundJobs := outboundsend.NewJobs( outboundSendStore, - agent.NewOutboundDeliverer(sender), + agent.NewOutboundDeliverer(outbound.NewProviderSubmitter(smtpRelay, sendingGate)), pool, - ) + ).WithGate(sendingGate) jobsClient, err := jobs.New(pool, jobs.Config{OutboundWorkers: 2}, outboundJobs) if err != nil { t.Fatalf("build River client: %v", err) diff --git a/sdks/python/src/e2a/v1/errors.py b/sdks/python/src/e2a/v1/errors.py index 700fa6658..f7abe5e1d 100644 --- a/sdks/python/src/e2a/v1/errors.py +++ b/sdks/python/src/e2a/v1/errors.py @@ -193,6 +193,7 @@ def is_retryable_status(status: int) -> bool: # 403 family "forbidden": (E2APermissionError, False), "blocked_by_policy": (E2APermissionError, False), + "sending_paused": (E2APermissionError, False), # 404/410 family — also covers *_not_found via the suffix check in _resolve. "not_found": (E2ANotFoundError, False), "gone": (E2ANotFoundError, False), diff --git a/sdks/python/src/e2a/v1/generated/models/error_body.py b/sdks/python/src/e2a/v1/generated/models/error_body.py index fc57b1a03..8e60232ef 100644 --- a/sdks/python/src/e2a/v1/generated/models/error_body.py +++ b/sdks/python/src/e2a/v1/generated/models/error_body.py @@ -26,7 +26,7 @@ class ErrorBody(BaseModel): """ ErrorBody """ # noqa: E501 - code: StrictStr = Field(description="Machine-branchable error code — the stable discriminator clients switch on. Open set: treat it as a string and tolerate unknown values, since new codes may be added over time (branch on the ones you handle, fall back to the HTTP status otherwise). Exact current vocabulary (machine-checked): unauthorized, forbidden, blocked_by_policy, invalid_request, invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope, reserved_domain, too_many_recipients, template_render_failed, template_rendered_empty, recipient_suppressed, not_found, attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found, gone, conflict, precondition_failed, agent_taken, domain_taken, alias_taken, address_in_trash, message_held, message_not_pending, message_not_yet_delivered, not_in_trash, purge_in_progress, send_in_progress, webhook_disabled, webhook_cooldown, domain_not_registered, domain_has_agents, domain_not_verified, inbound_mx_missing, limit_exceeded, rate_limited, contact_limit_reached, template_limit_reached, webhook_limit_reached, idempotency_in_flight, idempotency_key_reuse, payload_too_large, attachment_too_large, not_implemented, events_log_disabled, limits_unavailable, inbound_mx_check_failed, auth_unavailable, internal_error, method_not_allowed, unsupported_media_type, error. Grouped semantics: auth: unauthorized (401), forbidden (403), blocked_by_policy (403, outbound policy gate; experimental). Validation: invalid_request is the single canonical code for input-validation failures whether they arrive as 400 (malformed) or 422 (semantically invalid); field/resource-specific invalid_* refinements (invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope), reserved_domain, too_many_recipients, template_render_failed, template_rendered_empty (all 400); recipient_suppressed (422). Not found: not_found (404) plus the *_not_found family (attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found); gone (410, past retention). Conflict/state: conflict (409, generic), precondition_failed (412, optimistic-concurrency validator is stale), the *_taken family — the requested identifier is already claimed — (agent_taken, domain_taken, alias_taken, all 409), address_in_trash (409), message_held (409), message_not_pending (409), message_not_yet_delivered (409, retry after the source outbound message is sent), not_in_trash (409), purge_in_progress (409, permanent delete already claimed), send_in_progress (409), webhook_disabled (409), webhook_cooldown (409), domain_not_registered (400), domain_has_agents (400), domain_not_verified (400 on create-agent, 403 on send), inbound_mx_missing (400). Capacity: limit_exceeded (402, plan quota — see LimitExceededDetails), rate_limited (429, request rate — see RateLimitedDetails), contact_limit_reached, template_limit_reached and webhook_limit_reached (400, fixed per-account caps). Idempotency: idempotency_in_flight (409, wait then retry the byte-identical request), idempotency_key_reuse (422, caller bug — do not retry as-is). Size: payload_too_large (413, request body), attachment_too_large (413, inline fetch over the cap — use download_url). Availability: not_implemented (501, feature not available on this deployment), events_log_disabled (501), limits_unavailable (503), inbound_mx_check_failed (503), auth_unavailable (503, an auth backend — e.g. a delegated-token verifier or the identity store — could not judge the credential; retry). Server/fallback: internal_error (5xx), method_not_allowed (405), unsupported_media_type (415), and the generic code error for any otherwise-unmapped status.") + code: StrictStr = Field(description="Machine-branchable error code — the stable discriminator clients switch on. Open set: treat it as a string and tolerate unknown values, since new codes may be added over time (branch on the ones you handle, fall back to the HTTP status otherwise). Exact current vocabulary (machine-checked): unauthorized, forbidden, blocked_by_policy, sending_paused, invalid_request, invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope, reserved_domain, too_many_recipients, template_render_failed, template_rendered_empty, recipient_suppressed, not_found, attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found, gone, conflict, precondition_failed, agent_taken, domain_taken, alias_taken, address_in_trash, message_held, message_not_pending, message_not_yet_delivered, not_in_trash, purge_in_progress, send_in_progress, webhook_disabled, webhook_cooldown, domain_not_registered, domain_has_agents, domain_not_verified, inbound_mx_missing, limit_exceeded, rate_limited, contact_limit_reached, template_limit_reached, webhook_limit_reached, idempotency_in_flight, idempotency_key_reuse, payload_too_large, attachment_too_large, not_implemented, events_log_disabled, limits_unavailable, inbound_mx_check_failed, auth_unavailable, internal_error, method_not_allowed, unsupported_media_type, error. Grouped semantics: auth: unauthorized (401), forbidden (403), blocked_by_policy (403, outbound policy gate; experimental), sending_paused (403, outbound sending is paused for the account by the platform abuse controls; queued mail is held, new sends are refused until an operator resumes; experimental). Validation: invalid_request is the single canonical code for input-validation failures whether they arrive as 400 (malformed) or 422 (semantically invalid); field/resource-specific invalid_* refinements (invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope), reserved_domain, too_many_recipients, template_render_failed, template_rendered_empty (all 400); recipient_suppressed (422). Not found: not_found (404) plus the *_not_found family (attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found); gone (410, past retention). Conflict/state: conflict (409, generic), precondition_failed (412, optimistic-concurrency validator is stale), the *_taken family — the requested identifier is already claimed — (agent_taken, domain_taken, alias_taken, all 409), address_in_trash (409), message_held (409), message_not_pending (409), message_not_yet_delivered (409, retry after the source outbound message is sent), not_in_trash (409), purge_in_progress (409, permanent delete already claimed), send_in_progress (409), webhook_disabled (409), webhook_cooldown (409), domain_not_registered (400), domain_has_agents (400), domain_not_verified (400 on create-agent, 403 on send), inbound_mx_missing (400). Capacity: limit_exceeded (402, plan quota — see LimitExceededDetails), rate_limited (429, request rate — see RateLimitedDetails), contact_limit_reached, template_limit_reached and webhook_limit_reached (400, fixed per-account caps). Idempotency: idempotency_in_flight (409, wait then retry the byte-identical request), idempotency_key_reuse (422, caller bug — do not retry as-is). Size: payload_too_large (413, request body), attachment_too_large (413, inline fetch over the cap — use download_url). Availability: not_implemented (501, feature not available on this deployment), events_log_disabled (501), limits_unavailable (503), inbound_mx_check_failed (503), auth_unavailable (503, an auth backend — e.g. a delegated-token verifier or the identity store — could not judge the credential; retry). Server/fallback: internal_error (5xx), method_not_allowed (405), unsupported_media_type (415), and the generic code error for any otherwise-unmapped status.") details: Optional[Dict[str, Any]] = Field(default=None, description="Optional structured context, polymorphic by code. Treat it as an open object keyed off code; unknown codes and fields must be preserved.") message: StrictStr = Field(description="Human-readable explanation. Not for branching — use code.") request_id: StrictStr = Field(description="Echoes the X-Request-Id response header so a failing call is greppable in logs.") diff --git a/sdks/python/src/e2a/v1/generated/models/message_lifecycle_transition.py b/sdks/python/src/e2a/v1/generated/models/message_lifecycle_transition.py index edc2fac22..094aa1b2d 100644 --- a/sdks/python/src/e2a/v1/generated/models/message_lifecycle_transition.py +++ b/sdks/python/src/e2a/v1/generated/models/message_lifecycle_transition.py @@ -59,8 +59,8 @@ def outcome_validate_enum(cls, value): @field_validator('reason_code') def reason_code_validate_enum(cls, value): """Validates the enum""" - if value not in set(['acceptance.inbound_smtp', 'acceptance.outbound_api', 'acceptance.local_loopback', 'authentication.dmarc_pass', 'authentication.dmarc_fail', 'authentication.dmarc_none', 'authentication.dmarc_temporary_error', 'authentication.dmarc_permanent_error', 'review.hold_created', 'review.approved', 'review.rejected', 'review.expired_approved', 'review.expired_rejected', 'suppression.recipient_blocked', 'suppression.hard_bounce_applied', 'suppression.complaint_applied', 'queue.inbound_processing', 'queue.outbound_submission', 'submission.upstream_accepted', 'submission.local_loopback_accepted', 'submission.temporary_failure', 'submission.provider_rejected', 'submission.local_retries_exhausted', 'submission.cancelled', 'delivery.recipient_server_accepted', 'delivery.temporary_delay', 'delivery.permanent_bounce', 'delivery.transient_bounce', 'delivery.undetermined_bounce', 'complaint.recipient_reported']): - raise ValueError("must be one of enum values ('acceptance.inbound_smtp', 'acceptance.outbound_api', 'acceptance.local_loopback', 'authentication.dmarc_pass', 'authentication.dmarc_fail', 'authentication.dmarc_none', 'authentication.dmarc_temporary_error', 'authentication.dmarc_permanent_error', 'review.hold_created', 'review.approved', 'review.rejected', 'review.expired_approved', 'review.expired_rejected', 'suppression.recipient_blocked', 'suppression.hard_bounce_applied', 'suppression.complaint_applied', 'queue.inbound_processing', 'queue.outbound_submission', 'submission.upstream_accepted', 'submission.local_loopback_accepted', 'submission.temporary_failure', 'submission.provider_rejected', 'submission.local_retries_exhausted', 'submission.cancelled', 'delivery.recipient_server_accepted', 'delivery.temporary_delay', 'delivery.permanent_bounce', 'delivery.transient_bounce', 'delivery.undetermined_bounce', 'complaint.recipient_reported')") + if value not in set(['acceptance.inbound_smtp', 'acceptance.outbound_api', 'acceptance.local_loopback', 'authentication.dmarc_pass', 'authentication.dmarc_fail', 'authentication.dmarc_none', 'authentication.dmarc_temporary_error', 'authentication.dmarc_permanent_error', 'review.hold_created', 'review.approved', 'review.rejected', 'review.expired_approved', 'review.expired_rejected', 'suppression.recipient_blocked', 'suppression.hard_bounce_applied', 'suppression.complaint_applied', 'queue.inbound_processing', 'queue.outbound_submission', 'submission.upstream_accepted', 'submission.local_loopback_accepted', 'submission.temporary_failure', 'submission.provider_rejected', 'submission.local_retries_exhausted', 'submission.cancelled', 'submission.policy_budget_expired', 'submission.sending_setup_expired', 'delivery.recipient_server_accepted', 'delivery.temporary_delay', 'delivery.permanent_bounce', 'delivery.transient_bounce', 'delivery.undetermined_bounce', 'complaint.recipient_reported']): + raise ValueError("must be one of enum values ('acceptance.inbound_smtp', 'acceptance.outbound_api', 'acceptance.local_loopback', 'authentication.dmarc_pass', 'authentication.dmarc_fail', 'authentication.dmarc_none', 'authentication.dmarc_temporary_error', 'authentication.dmarc_permanent_error', 'review.hold_created', 'review.approved', 'review.rejected', 'review.expired_approved', 'review.expired_rejected', 'suppression.recipient_blocked', 'suppression.hard_bounce_applied', 'suppression.complaint_applied', 'queue.inbound_processing', 'queue.outbound_submission', 'submission.upstream_accepted', 'submission.local_loopback_accepted', 'submission.temporary_failure', 'submission.provider_rejected', 'submission.local_retries_exhausted', 'submission.cancelled', 'submission.policy_budget_expired', 'submission.sending_setup_expired', 'delivery.recipient_server_accepted', 'delivery.temporary_delay', 'delivery.permanent_bounce', 'delivery.transient_bounce', 'delivery.undetermined_bounce', 'complaint.recipient_reported')") return value @field_validator('stage') diff --git a/sdks/python/tests/test_enum_forward_compat.py b/sdks/python/tests/test_enum_forward_compat.py index 1275ef8f0..3c47279dc 100644 --- a/sdks/python/tests/test_enum_forward_compat.py +++ b/sdks/python/tests/test_enum_forward_compat.py @@ -76,6 +76,8 @@ "submission.provider_rejected", "submission.local_retries_exhausted", "submission.cancelled", + "submission.policy_budget_expired", + "submission.sending_setup_expired", "delivery.recipient_server_accepted", "delivery.temporary_delay", "delivery.permanent_bounce", diff --git a/sdks/python/tests/test_v1_errors.py b/sdks/python/tests/test_v1_errors.py index c7be267c9..a1c12658d 100644 --- a/sdks/python/tests/test_v1_errors.py +++ b/sdks/python/tests/test_v1_errors.py @@ -231,6 +231,11 @@ def test_catalog_family_overrides(): ), E2APermissionError, ) + paused = from_api_exception( + _exc(403, body='{"error":{"code":"sending_paused","message":"x"}}') + ) + assert isinstance(paused, E2APermissionError) + assert paused.retryable is False assert isinstance( from_api_exception( _exc(409, body='{"error":{"code":"message_not_pending","message":"x"}}') diff --git a/sdks/typescript/src/v1/errors.ts b/sdks/typescript/src/v1/errors.ts index bed04c315..f56e9c0c5 100644 --- a/sdks/typescript/src/v1/errors.ts +++ b/sdks/typescript/src/v1/errors.ts @@ -108,6 +108,7 @@ const CODE_TABLE: Record = { // 403 forbidden: { make: mkPermission, retryable: false }, blocked_by_policy: { make: mkPermission, retryable: false }, + sending_paused: { make: mkPermission, retryable: false }, // 404 / 410 — the *_not_found suffix family resolves in resolve() below. not_found: { make: mkNotFound, retryable: false }, gone: { make: mkNotFound, retryable: false }, diff --git a/sdks/typescript/src/v1/generated/models/ErrorBody.ts b/sdks/typescript/src/v1/generated/models/ErrorBody.ts index efb982e43..ba6f627b2 100644 --- a/sdks/typescript/src/v1/generated/models/ErrorBody.ts +++ b/sdks/typescript/src/v1/generated/models/ErrorBody.ts @@ -14,7 +14,7 @@ import { HttpFile } from '../http/http.js'; export class ErrorBody { /** - * Machine-branchable error code — the stable discriminator clients switch on. Open set: treat it as a string and tolerate unknown values, since new codes may be added over time (branch on the ones you handle, fall back to the HTTP status otherwise). Exact current vocabulary (machine-checked): unauthorized, forbidden, blocked_by_policy, invalid_request, invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope, reserved_domain, too_many_recipients, template_render_failed, template_rendered_empty, recipient_suppressed, not_found, attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found, gone, conflict, precondition_failed, agent_taken, domain_taken, alias_taken, address_in_trash, message_held, message_not_pending, message_not_yet_delivered, not_in_trash, purge_in_progress, send_in_progress, webhook_disabled, webhook_cooldown, domain_not_registered, domain_has_agents, domain_not_verified, inbound_mx_missing, limit_exceeded, rate_limited, contact_limit_reached, template_limit_reached, webhook_limit_reached, idempotency_in_flight, idempotency_key_reuse, payload_too_large, attachment_too_large, not_implemented, events_log_disabled, limits_unavailable, inbound_mx_check_failed, auth_unavailable, internal_error, method_not_allowed, unsupported_media_type, error. Grouped semantics: auth: unauthorized (401), forbidden (403), blocked_by_policy (403, outbound policy gate; experimental). Validation: invalid_request is the single canonical code for input-validation failures whether they arrive as 400 (malformed) or 422 (semantically invalid); field/resource-specific invalid_* refinements (invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope), reserved_domain, too_many_recipients, template_render_failed, template_rendered_empty (all 400); recipient_suppressed (422). Not found: not_found (404) plus the *_not_found family (attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found); gone (410, past retention). Conflict/state: conflict (409, generic), precondition_failed (412, optimistic-concurrency validator is stale), the *_taken family — the requested identifier is already claimed — (agent_taken, domain_taken, alias_taken, all 409), address_in_trash (409), message_held (409), message_not_pending (409), message_not_yet_delivered (409, retry after the source outbound message is sent), not_in_trash (409), purge_in_progress (409, permanent delete already claimed), send_in_progress (409), webhook_disabled (409), webhook_cooldown (409), domain_not_registered (400), domain_has_agents (400), domain_not_verified (400 on create-agent, 403 on send), inbound_mx_missing (400). Capacity: limit_exceeded (402, plan quota — see LimitExceededDetails), rate_limited (429, request rate — see RateLimitedDetails), contact_limit_reached, template_limit_reached and webhook_limit_reached (400, fixed per-account caps). Idempotency: idempotency_in_flight (409, wait then retry the byte-identical request), idempotency_key_reuse (422, caller bug — do not retry as-is). Size: payload_too_large (413, request body), attachment_too_large (413, inline fetch over the cap — use download_url). Availability: not_implemented (501, feature not available on this deployment), events_log_disabled (501), limits_unavailable (503), inbound_mx_check_failed (503), auth_unavailable (503, an auth backend — e.g. a delegated-token verifier or the identity store — could not judge the credential; retry). Server/fallback: internal_error (5xx), method_not_allowed (405), unsupported_media_type (415), and the generic code error for any otherwise-unmapped status. + * Machine-branchable error code — the stable discriminator clients switch on. Open set: treat it as a string and tolerate unknown values, since new codes may be added over time (branch on the ones you handle, fall back to the HTTP status otherwise). Exact current vocabulary (machine-checked): unauthorized, forbidden, blocked_by_policy, sending_paused, invalid_request, invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope, reserved_domain, too_many_recipients, template_render_failed, template_rendered_empty, recipient_suppressed, not_found, attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found, gone, conflict, precondition_failed, agent_taken, domain_taken, alias_taken, address_in_trash, message_held, message_not_pending, message_not_yet_delivered, not_in_trash, purge_in_progress, send_in_progress, webhook_disabled, webhook_cooldown, domain_not_registered, domain_has_agents, domain_not_verified, inbound_mx_missing, limit_exceeded, rate_limited, contact_limit_reached, template_limit_reached, webhook_limit_reached, idempotency_in_flight, idempotency_key_reuse, payload_too_large, attachment_too_large, not_implemented, events_log_disabled, limits_unavailable, inbound_mx_check_failed, auth_unavailable, internal_error, method_not_allowed, unsupported_media_type, error. Grouped semantics: auth: unauthorized (401), forbidden (403), blocked_by_policy (403, outbound policy gate; experimental), sending_paused (403, outbound sending is paused for the account by the platform abuse controls; queued mail is held, new sends are refused until an operator resumes; experimental). Validation: invalid_request is the single canonical code for input-validation failures whether they arrive as 400 (malformed) or 422 (semantically invalid); field/resource-specific invalid_* refinements (invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope), reserved_domain, too_many_recipients, template_render_failed, template_rendered_empty (all 400); recipient_suppressed (422). Not found: not_found (404) plus the *_not_found family (attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found); gone (410, past retention). Conflict/state: conflict (409, generic), precondition_failed (412, optimistic-concurrency validator is stale), the *_taken family — the requested identifier is already claimed — (agent_taken, domain_taken, alias_taken, all 409), address_in_trash (409), message_held (409), message_not_pending (409), message_not_yet_delivered (409, retry after the source outbound message is sent), not_in_trash (409), purge_in_progress (409, permanent delete already claimed), send_in_progress (409), webhook_disabled (409), webhook_cooldown (409), domain_not_registered (400), domain_has_agents (400), domain_not_verified (400 on create-agent, 403 on send), inbound_mx_missing (400). Capacity: limit_exceeded (402, plan quota — see LimitExceededDetails), rate_limited (429, request rate — see RateLimitedDetails), contact_limit_reached, template_limit_reached and webhook_limit_reached (400, fixed per-account caps). Idempotency: idempotency_in_flight (409, wait then retry the byte-identical request), idempotency_key_reuse (422, caller bug — do not retry as-is). Size: payload_too_large (413, request body), attachment_too_large (413, inline fetch over the cap — use download_url). Availability: not_implemented (501, feature not available on this deployment), events_log_disabled (501), limits_unavailable (503), inbound_mx_check_failed (503), auth_unavailable (503, an auth backend — e.g. a delegated-token verifier or the identity store — could not judge the credential; retry). Server/fallback: internal_error (5xx), method_not_allowed (405), unsupported_media_type (415), and the generic code error for any otherwise-unmapped status. */ 'code': string; /** diff --git a/sdks/typescript/src/v1/generated/models/MessageLifecycleTransition.ts b/sdks/typescript/src/v1/generated/models/MessageLifecycleTransition.ts index cfd726c5e..4c59b8575 100644 --- a/sdks/typescript/src/v1/generated/models/MessageLifecycleTransition.ts +++ b/sdks/typescript/src/v1/generated/models/MessageLifecycleTransition.ts @@ -157,6 +157,8 @@ export enum MessageLifecycleTransitionReasonCodeEnum { SubmissionProviderRejected = 'submission.provider_rejected', SubmissionLocalRetriesExhausted = 'submission.local_retries_exhausted', SubmissionCancelled = 'submission.cancelled', + SubmissionPolicyBudgetExpired = 'submission.policy_budget_expired', + SubmissionSendingSetupExpired = 'submission.sending_setup_expired', DeliveryRecipientServerAccepted = 'delivery.recipient_server_accepted', DeliveryTemporaryDelay = 'delivery.temporary_delay', DeliveryPermanentBounce = 'delivery.permanent_bounce', diff --git a/sdks/typescript/test/v1/errors.test.ts b/sdks/typescript/test/v1/errors.test.ts index cb1dfdf72..d1a249da8 100644 --- a/sdks/typescript/test/v1/errors.test.ts +++ b/sdks/typescript/test/v1/errors.test.ts @@ -168,6 +168,10 @@ describe("code-first class selection (F2)", () => { expect(toE2AError({ status: 403, code: "blocked_by_policy", message: "x" })).toBeInstanceOf( E2APermissionError, ); + expect(toE2AError({ status: 403, code: "sending_paused", message: "x" })).toBeInstanceOf( + E2APermissionError, + ); + expect(toE2AError({ status: 403, code: "sending_paused", message: "x" }).retryable).toBe(false); expect(toE2AError({ status: 409, code: "message_not_pending", message: "x" })).toBeInstanceOf( E2AConflictError, ); diff --git a/web/src/app/components/messages/MessageLifecycleTimeline.tsx b/web/src/app/components/messages/MessageLifecycleTimeline.tsx index 2b599ed6c..4aa74f51f 100644 --- a/web/src/app/components/messages/MessageLifecycleTimeline.tsx +++ b/web/src/app/components/messages/MessageLifecycleTimeline.tsx @@ -40,6 +40,8 @@ export const LIFECYCLE_PRESENTATION: Record = "submission.provider_rejected": { title: "Delivery provider rejected message", description: "The delivery provider refused the message, so it was not handed off." }, "submission.local_retries_exhausted": { title: "Delivery failed", description: "e2a could not hand off the message after repeated attempts." }, "submission.cancelled": { title: "Delivery cancelled", description: "Delivery was stopped before the message was handed off." }, + "submission.policy_budget_expired": { title: "Delivery failed", description: "The message waited for sending capacity for seven days and was not handed off." }, + "submission.sending_setup_expired": { title: "Delivery failed", description: "Sending setup for this account did not complete in time, so the message was not handed off." }, "delivery.recipient_server_accepted": { title: "Accepted by recipient server", description: "The recipient's mail server accepted the message. This does not confirm inbox placement." }, "delivery.temporary_delay": { title: "Delivery delayed", description: "The delivery provider reported a temporary delay." }, "delivery.permanent_bounce": { title: "Delivery failed permanently", description: "The recipient's mail server permanently rejected the message." }, @@ -79,6 +81,8 @@ function lifecycleSummary(last: MessageLifecycleTransitionWire): string { case "submission.provider_rejected": case "submission.local_retries_exhausted": case "submission.cancelled": + case "submission.policy_budget_expired": + case "submission.sending_setup_expired": case "suppression.recipient_blocked": return "Failed"; default: diff --git a/web/src/lib/messageLifecycle.ts b/web/src/lib/messageLifecycle.ts index 8b6fae737..96d361e43 100644 --- a/web/src/lib/messageLifecycle.ts +++ b/web/src/lib/messageLifecycle.ts @@ -19,6 +19,7 @@ export const MESSAGE_LIFECYCLE_REASON_CODES = [ "submission.upstream_accepted", "submission.local_loopback_accepted", "submission.temporary_failure", "submission.provider_rejected", "submission.local_retries_exhausted", "submission.cancelled", + "submission.policy_budget_expired", "submission.sending_setup_expired", "delivery.recipient_server_accepted", "delivery.temporary_delay", "delivery.permanent_bounce", "delivery.transient_bounce", "delivery.undetermined_bounce", "complaint.recipient_reported",