diff --git a/docs/content/docs/api/endpoints.mdx b/docs/content/docs/api/endpoints.mdx index f765c3ac3..74a6f9a0d 100644 --- a/docs/content/docs/api/endpoints.mdx +++ b/docs/content/docs/api/endpoints.mdx @@ -636,6 +636,8 @@ Cloud-managed OAuth additionally exposes `POST /pool-link/instance/oauth/start-c `POST /internal/worker/outbound-dispatch`, `/internal/worker/warmup-actions`, and `/internal/worker/diagnostic-auth` use the existing execution-plane internal token, never customer API keys. They bind the current mailbox/worker and require protocol 2. Outbound start additionally rechecks task/organization/provider/nonce/recipients and current campaign or diagnostic policy. Diagnostic authorization/result submission binds one exact already-owned warmup receipt and short-lived nonce; it neither ingests unrelated inbox messages nor trusts event/header claims. No public scope or permission was added. Details and limits: [send safety](/development/send-safety/). +Warmup actions for a deleted, unassigned or no-longer-authorized mailbox return no permitted actions. The worker treats that authoritative denial as a no-op without touching the provider. Database or authority failures still return `503` and retry; denial does not authorize a send, clear a send hold, or acknowledge an arrival. + ### Referrals and discounts The referral program and billing discount history are part of `/subscription/*`, so they are JWT only and never accept an API key. There is no API permission scope for them; browser callers are gated by the org permission below. The discount validation and checkout endpoints are listed under [Subscription and billing](/api/reference/account-org/) in the reference. diff --git a/internal/app/worker/event_warmup_action_test.go b/internal/app/worker/event_warmup_action_test.go index de6ad507b..ae61870c8 100644 --- a/internal/app/worker/event_warmup_action_test.go +++ b/internal/app/worker/event_warmup_action_test.go @@ -9,8 +9,36 @@ import ( "github.com/google/uuid" "github.com/warmbly/warmbly/internal/config" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/repository" ) +type warmupActionAuthorityStub struct { + repository.SyncContextRepository + err error +} + +func (s *warmupActionAuthorityStub) PermittedWarmupActions(context.Context, uuid.UUID, uuid.UUID, []string) ([]string, error) { + return nil, s.err +} + +func TestWarmupActionAuthorityDenialDoesNotTouchMailbox(t *testing.T) { + w := &WorkerService{ID: uuid.NewString(), SyncContextRepository: &warmupActionAuthorityStub{}} + for _, action := range []string{models.WarmupActionFile, models.WarmupActionDelete, models.WarmupActionVerifyRemoval} { + if err := w.HandleWarmupAction(t.Context(), models.WarmupEmailAction{EmailID: uuid.New(), Actions: []string{action}, FilingID: uuid.NewString()}); err != nil { + t.Fatalf("denied %s action must finish without loading a mailbox: %v", action, err) + } + } +} + +func TestWarmupActionAuthorityFailureMustRetry(t *testing.T) { + failure := errors.New("authority unavailable") + w := &WorkerService{ID: uuid.NewString(), SyncContextRepository: &warmupActionAuthorityStub{err: failure}} + err := w.HandleWarmupAction(t.Context(), models.WarmupEmailAction{EmailID: uuid.New(), Actions: []string{models.WarmupActionDelete}}) + if !errors.Is(err, failure) { + t.Fatalf("authority failure must remain retryable: %v", err) + } +} + func TestWarmupFilingFallsBackForOlderEvents(t *testing.T) { // An event published before the placement fields existed has to behave the // way every mailbox did then: the default folder. diff --git a/internal/repository/test_participation_live_test.go b/internal/repository/test_participation_live_test.go index 1a55a57c9..90c683824 100644 --- a/internal/repository/test_participation_live_test.go +++ b/internal/repository/test_participation_live_test.go @@ -2,12 +2,49 @@ package repository import ( "context" + "errors" "testing" "github.com/google/uuid" "github.com/warmbly/warmbly/internal/models" ) +func TestLiveWarmupActionsDenyUnavailableMailbox(t *testing.T) { + for _, state := range []string{"missing", "deleted", "unassigned"} { + t.Run(state, func(t *testing.T) { + f, r := lineageFixture(t) + worker := uuid.New() + mailbox := f.recipient + switch state { + case "missing": + mailbox = uuid.New() + case "deleted": + if _, err := f.pool.Exec(t.Context(), `DELETE FROM email_accounts WHERE id=$1`, mailbox); err != nil { + t.Fatal(err) + } + case "unassigned": + if _, err := f.pool.Exec(t.Context(), `UPDATE email_accounts SET worker_id=NULL WHERE id=$1`, mailbox); err != nil { + t.Fatal(err) + } + } + actions, err := r.PermittedWarmupActions(t.Context(), mailbox, worker, []string{models.WarmupActionFile, models.WarmupActionDelete}) + if err != nil || len(actions) != 0 { + t.Fatalf("unavailable mailbox must deny provider actions without retry: actions=%v err=%v", actions, err) + } + }) + } +} + +func TestLiveWarmupActionsPreserveAuthorityFailure(t *testing.T) { + f, r := lineageFixture(t) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + actions, err := r.PermittedWarmupActions(ctx, f.recipient, uuid.New(), []string{models.WarmupActionDelete}) + if !errors.Is(err, context.Canceled) || len(actions) != 0 { + t.Fatalf("authority failure must remain retryable: actions=%v err=%v", actions, err) + } +} + func TestLiveDiagnosticOffStopsQueuedSendAndActions(t *testing.T) { f, r := lineageFixture(t) ctx := t.Context() @@ -64,6 +101,10 @@ func TestLiveDiagnosticOffStopsQueuedSendAndActions(t *testing.T) { if err != nil || len(got) != len(actions) { t.Fatal("explicit legacy behavior changed", got, err) } + got, err = r.PermittedWarmupActions(ctx, f.recipient, uuid.New(), actions) + if err != nil || len(got) != 0 { + t.Fatal("a different worker gained provider action authority", got, err) + } exec(`UPDATE email_accounts SET test_mode='off' WHERE id=$1`, f.recipient) got, err = r.PermittedWarmupActions(ctx, f.recipient, worker, actions) if err != nil || len(got) != 1 || got[0] != models.WarmupActionDelete { diff --git a/internal/repository/warmup_action_admission.go b/internal/repository/warmup_action_admission.go index 0493bb119..769c43eb6 100644 --- a/internal/repository/warmup_action_admission.go +++ b/internal/repository/warmup_action_admission.go @@ -8,6 +8,7 @@ import ( "net/http" "github.com/google/uuid" + "github.com/jackc/pgx/v5" "github.com/warmbly/warmbly/internal/models" ) @@ -24,6 +25,9 @@ func (r *taskRepository) PermittedWarmupActions(ctx context.Context, mailbox, wo err := r.db.QueryRow(ctx, `SELECT ea.status='active' AND ea.worker_id=$2 AND n.role='worker' AND n.active AND n.last_seen_at>NOW()-INTERVAL '10 minutes' AND n.warmup_send_protocol>=2 AND o.risk_state IN ('trusted','watch') AND NOT EXISTS(SELECT 1 FROM cloud_link_mailboxes c WHERE c.email_account_id=ea.id),ea.test_mode,ea.test_send_enabled,ea.test_receive_enabled FROM email_accounts ea JOIN organizations o ON o.id=ea.organization_id JOIN fleet_nodes n ON n.id=ea.worker_id WHERE ea.id=$1`, mailbox, worker).Scan(&active, &a.TestMode, &a.TestSendEnabled, &a.TestReceiveEnabled) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } if err != nil { return nil, err }