diff --git a/docs/content/docs/api/endpoints.mdx b/docs/content/docs/api/endpoints.mdx index e7c61fc01..4e2a934ec 100644 --- a/docs/content/docs/api/endpoints.mdx +++ b/docs/content/docs/api/endpoints.mdx @@ -658,7 +658,7 @@ Alongside them, `api_url` is this API's own public base (for a copyable example ### Internal execution and managed consent -`PATCH /cloud-link/mailboxes/:id/participation` is self-hosted JWT-only with `MANAGE_EMAILS` and organization ownership. It accepts typed `mode` (`diagnostic` or `off`), `send`, `receive` and optional positive shared/rolling limits. Cloud mirrors it through instance-token-only `PATCH /pool-link/instance/mailboxes/:remoteId/participation`, scoped to the calling instance. These dedicated routes prevent an old Cloud silently ignoring additive consent fields; old state without participation is not explicit diagnostic consent. The existing native lifecycle routes select diagnostic send/receive on start/resume and complete off on stop while retaining history. See [participation and send safety](/development/send-safety/). +`PATCH /cloud-link/mailboxes/:id/participation` is self-hosted JWT-only with `MANAGE_EMAILS` and organization ownership. It accepts typed `mode` (`diagnostic` or `off`), `send`, `receive` and optional positive shared/rolling limits. `receive` also authorizes probabilistic spam rescue, reads, importance and stars or flags on verified warmup messages where supported; it does not authorize replies or guarantee reputation improvement. Cloud mirrors it through instance-token-only `PATCH /pool-link/instance/mailboxes/:remoteId/participation`, scoped to the calling instance. These dedicated routes prevent an old Cloud silently ignoring additive consent fields; old state without participation is not explicit diagnostic consent. The existing native lifecycle routes select diagnostic send/receive on start/resume and complete off on stop while retaining history. See [participation and send safety](/development/send-safety/). Cloud-managed OAuth additionally exposes `POST /pool-link/instance/oauth/start-correlated`, `POST /pool-link/instance/oauth/finish-correlated` and `POST /pool-link/instance/mailboxes/adopt-correlated`, all instance-token only and requiring `protocol: 1`. The authenticated instance response advertises `managed_consent_protocol: 1`. Legacy routes remain unchanged; absence of the capability cannot be bypassed with fields an old replica might ignore. See [the Cloud route and recovery contract](/guides/warmbly-cloud/). diff --git a/docs/content/docs/development/send-safety.mdx b/docs/content/docs/development/send-safety.mdx index 91095893c..0c7047afb 100644 --- a/docs/content/docs/development/send-safety.mdx +++ b/docs/content/docs/development/send-safety.mdx @@ -13,11 +13,13 @@ This control system restricts sending using current authority and observed evide |---|---| | `test_mode` | New actions accept `diagnostic` or `off`. Historical `legacy` and NULL remain readable without relabeling; newly connected mailboxes default to `off` | | `test_send_enabled` | Enables diagnostic sending only in `diagnostic` mode | -| `test_receive_enabled` | Separately enables diagnostic receiving only in `diagnostic` mode | +| `test_receive_enabled` | Separately enables receiving and provider-supported automated warmup mailbox actions in `diagnostic` mode | | `shared_daily_limit` | Positive mailbox-wide calendar-day ceiling across outbound lanes; PATCH zero clears the override, restoring the default shared ceiling | | `rolling_recipient_limit` | Positive rolling 24-hour ceiling counting each To/CC/BCC occurrence; PATCH zero restores the default ceiling | -Choose sender-only, recipient-only or both explicitly. Recipient-only does not authorize a reply. `off` excludes diagnostic starts, active-campaign health checks, reply-back and new recipient selection. It does not stop legitimate campaign/manual traffic by itself: pause the campaign or mailbox for that. Diagnostic mode disables synthetic read, star, importance and spam-rescue actions. Authorized filing and deletion remain housekeeping, not positive deliverability evidence. Cleanup can remove an already received diagnostic after participation stops. +Choose sender-only, recipient-only or both explicitly. Receiving participation authorizes probabilistic spam rescue, reads, importance and stars or flags on verified warmup messages where the provider supports them. This applies to existing receive-enabled diagnostic mailboxes too; no database backfill or new setting is required. Recipient-only does not authorize a reply. Sender-only and `off` deny recipient engagement. `off` also excludes diagnostic starts, active-campaign health checks, reply-back and new recipient selection. It does not stop legitimate campaign/manual traffic by itself: pause the campaign or mailbox for that. These are disclosed automated actions, not human engagement or proof of improved reputation. Authorized filing and deletion remain housekeeping, not positive deliverability evidence. Cleanup can remove an already received diagnostic after participation stops. + +Deploy the updated consumer, backend and workers together before expecting restored engagement. The consumer still persists delayed actions and reloads current receiving permission and worker assignment when they become due; the worker rechecks admission before execution. Revoking receiving or switching Off prevents queued recipient engagement. Previously filtered-out actions are not recreated, and old message recovery remains filing-only. Provider errors can still prevent individual actions; existing rescue-request counters are not confirmation of provider success. The control plane checks participation before publication; capable executors recheck it immediately before native execution. A queued command is not consent. Queue callbacks, received text and model output cannot authorize sending. Revoke receiving before a queued diagnostic starts and it is denied; revoke sending and a recipient-only mailbox cannot reply. No software can retroactively cancel a provider submission that already began. diff --git a/docs/content/docs/guides/mailboxes.mdx b/docs/content/docs/guides/mailboxes.mdx index 5bc91a0bb..6262ed009 100644 --- a/docs/content/docs/guides/mailboxes.mdx +++ b/docs/content/docs/guides/mailboxes.mdx @@ -54,7 +54,7 @@ The Accounts list checks mailbox health and, on linked self-hosted instances, Cl ### Diagnostic participation and stopping -New mailboxes default to diagnostic participation **off**. The organization-authorized mailbox PATCH exposes `test_mode` (`off`, `diagnostic`, `legacy`), separate `test_send_enabled` and `test_receive_enabled` flags, and optional positive `shared_daily_limit`/`rolling_recipient_limit`. These are API controls, not new dashboard switches. Diagnostic mode sends disclosed tests but does not manufacture reads, stars, importance or spam rescue. Existing upgraded mailboxes with NULL participation retain legacy behavior until explicitly changed. Set `test_mode=off` to stop future diagnostic send/receive admission; current protocol-2 workers also recheck queued sends/actions. Already accepted old-worker commands or a provider send underway cannot be recalled. A local Cloud alias unlink stops local authority, not an independently cloud-owned mailbox or provider grant: pause/remove that mailbox on Cloud separately. See [send safety, repair evidence and rollout](/development/send-safety/). +New mailboxes default to participation **off**. The organization-authorized mailbox PATCH exposes `test_mode` (`off`, `diagnostic`; historical `legacy` remains readable), separate `test_send_enabled` and `test_receive_enabled` flags, and optional positive `shared_daily_limit`/`rolling_recipient_limit`. The Warmup tab exposes the directional controls. Receiving participation also authorizes probabilistic spam rescue, reads, importance and stars or flags on verified warmup messages where supported, including existing receive-enabled diagnostic mailboxes. These actions are disclosed automation, not human engagement or a guaranteed reputation improvement. Existing upgraded mailboxes with NULL participation retain legacy behavior until explicitly changed. Set `test_mode=off` to stop future warmup send/receive admission; current protocol-2 workers also recheck queued sends/actions. Already accepted old-worker commands or a provider send underway cannot be recalled. A local Cloud alias unlink stops local authority, not an independently cloud-owned mailbox or provider grant: pause/remove that mailbox on Cloud separately. See [send safety, repair evidence and rollout](/development/send-safety/). diff --git a/docs/content/docs/guides/warmup.mdx b/docs/content/docs/guides/warmup.mdx index 1ab8bdbea..0bc2d2af9 100644 --- a/docs/content/docs/guides/warmup.mdx +++ b/docs/content/docs/guides/warmup.mdx @@ -77,7 +77,7 @@ Providers may strip headers or replace identifiers. Exact authorized lineage and When an SMTP relay rewrites a Message-ID, a valid recipient-bound verification token, the expected sender address and a confirmed send can authorize one received copy. Replies and message-specific DKIM verification use that copy's received identifier. Headerless arrivals still require an exact confirmed identifier for new diagnostic lineage; a matching subject or mailbox pair alone does not authorize a reply. Arrivals held before send confirmation or during a verification outage are retained and retried automatically while their token remains valid. -New mailboxes default off. Independent send/receive controls and shared daily/rolling limits are on the Warmup tab. Start/resume enables diagnostic sending, Pause preserves separately authorized receiving, and Stop disables both while retaining ramp history and holds. Recipient-only cannot reply. Existing NULL stays legacy until explicit action; unrelated edits do not relabel it. New controls cannot re-enable legacy synthetic behavior. Cloud controls require updated Cloud replicas. See [send safety](/development/send-safety/). +New mailboxes default off. Independent send/receive controls and shared daily/rolling limits are on the Warmup tab. Start/resume enables disclosed automated sending and, when starting from Off or legacy, receiving with provider-supported spam rescue, reads, importance and stars or flags. Pause preserves separately authorized receiving, and Stop disables both while retaining ramp history and holds. Recipient-only cannot reply. Sender-only does not authorize recipient engagement. Existing receive-enabled diagnostic mailboxes also use these actions after the consumer, backend and workers are upgraded; no new setting or data migration is needed. Existing NULL stays legacy until explicit action; unrelated edits do not relabel it. Cloud controls require updated Cloud replicas. See [send safety](/development/send-safety/). ### When a send fails @@ -186,7 +186,7 @@ The **inbox rate** is inbox plus category tabs over everything delivered in the The rate is taken at Google, Microsoft and Yahoo only, the providers that judge a mailbox. Smaller mail hosts run their own filters, so their spam folders never count toward it: a mailbox whose warmup mail only reached small hosts shows no rate rather than one built from them. Other mail hosts are still shown: their inbox rate sits right under the headline in the drawer and on **Deliverability**, and they appear in the daily charts, the counts and the per-provider breakdown. It only appears once `20` deliveries at those three are in the window, the same sample the pool needs before it acts on spam placement. Until then the mailbox shows how far along it is (`8/20`) rather than a percentage that would swing on every delivery. The mailbox's health score is capped at its inbox rate, so a mailbox with some mail in spam reads `97`, not a flat `100`. -Filter by provider to distinguish observations from gaps. Historical rescued counts are legacy requests, not confirmed moves or benefits. Diagnostic mode does not rescue spam. Unconfirmed means not observed after timeout, not verified failure. First-folder evidence is separate from housekeeping; absent historical folders stay unknown. Days are UTC. See [warmup placement](/api/reference/analytics/#get-warmup-placement). +Filter by provider to distinguish observations from gaps. Historical rescued counts are requests, not confirmed moves or benefits. Receiving-authorized warmup can request spam rescue, but placement remains the originally observed folder, not a rewritten success after a move. Unconfirmed means not observed after timeout, not verified failure. First-folder evidence is separate from housekeeping; absent historical folders stay unknown. Days are UTC. See [warmup placement](/api/reference/analytics/#get-warmup-placement). @@ -242,7 +242,7 @@ Replies thread properly with a real `Re:` subject and `In-Reply-To` header, and Receiving warmup mail can also prompt an answer directly. When a verified warmup email arrives, the recipient sometimes points its next scheduled send back at whoever wrote, 25 minutes to 5 hours later and inside its own warmup hours. That is a re-pointing, not extra work: each mailbox has one warmup send queued at a time, so a reply-back moves that send earlier and aims it, and can never push a send the mailbox had already planned sooner. The chance is the recipient's own reply rate, drawn once when the reply is scheduled rather than again when it sends, and it stops before a thread reaches its message cap so replies cannot answer replies indefinitely. -Scheduling respects windows, provider feedback, shared limits and spacing. Jitter distributes load, not human identities. Diagnostic mode schedules no synthetic read, star, importance or spam-rescue actions. +Scheduling respects windows, provider feedback, shared limits and spacing. Jitter distributes load, not human identities. Receiving participation allows probabilistic spam rescue, reads, importance and stars or flags on verified warmup messages. Filing and selected spam rescues are requested immediately; reads, importance and stars are durably scheduled after a randomized dwell. Current receiving permission and worker assignment are checked again at execution time. These actions are automation, not human feedback or a guaranteed reputation improvement. @@ -261,12 +261,12 @@ The filing is per mailbox, on the **Warmup** tab of its drawer: | Setting | What your mail client shows | |---------|------------------------------| | **Its own folder** (default) | One folder, `Warmbly` unless you rename it, holding every warmup message in and out. Nothing warmup-related in Inbox or Sent. | -| **Leave it in the inbox** | Diagnostics stay where the provider placed them, including spam. | +| **Leave it in the inbox** | Warmup stays where the provider placed it unless selected for spam rescue into Inbox. | | **Archive it** | Out of the inbox with no folder of its own: findable by search, listed in All Mail on Gmail. | The folder is created the first time it is needed, and it is also the answer to "what is this message?": nothing but warmup is filed there, so the folder itself is what identifies warmup traffic in a mail client. If another tool already made a folder you use for this, name that one instead and warmup joins it rather than adding a second. -Diagnostic mode leaves spam as spam and emits no not-junk signals. Filing is housekeeping, not engagement evidence. Historical NULL/legacy retains existing behavior until explicit action. Upgrade all workers before relying on new restrictions. +Spam rescue is provider-specific: Gmail removes Spam and supports Important and Starred labels; Microsoft moves verified Junk messages to Inbox and supports high importance and a follow-up flag; IMAP moves messages out of recognized Spam/Junk folders and uses flags plus best-effort not-junk keywords. IMAP does not have a universal Gmail-style Important label, and SMTP-only mailboxes cannot perform recipient-side actions without mailbox access. A move is not proof that a provider learned a positive reputation signal. Filing remains housekeeping, not engagement evidence. Historical NULL/legacy retains existing behavior until explicit action. Upgrade the consumer, backend and workers together; previously skipped engagement is not replayed onto old mail. Mail that arrives somewhere unexpected is still handled. A server-side rule, or another warmup tool's filter, can put warmup mail in a folder of its own choosing before Warmbly sees it; the message is found there and filed from there, and one already sitting in the destination is left alone rather than moved onto itself. diff --git a/internal/app/consumer/warmup_engagement_test.go b/internal/app/consumer/warmup_engagement_test.go index 2993e1f4d..be992af14 100644 --- a/internal/app/consumer/warmup_engagement_test.go +++ b/internal/app/consumer/warmup_engagement_test.go @@ -1,13 +1,80 @@ package jobs import ( + "context" + "encoding/json" + "slices" "testing" "time" "github.com/google/uuid" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/repository" ) +type pendingEngagementStub struct { + repository.WarmupEngagementRepository + due []repository.PendingEngagement +} + +func (r *pendingEngagementStub) ClaimDuePendingEngagements(context.Context, int) ([]repository.PendingEngagement, error) { + due := r.due + r.due = nil + return due, nil +} + +func TestDelayedWarmupEngagementRechecksReceivingAndAssignment(t *testing.T) { + mode := models.TestParticipationDiagnostic + actions := []string{models.WarmupActionMarkRead, models.WarmupActionMarkImportant, models.WarmupActionStar} + for _, tc := range []struct { + name string + mode models.TestParticipationMode + receive bool + status string + want bool + }{ + {"receiving enabled", mode, true, "active", true}, + {"worker reassigned during dwell", mode, true, "active", true}, + {"unassigned during dwell", mode, true, "active", false}, + {"receiving revoked during dwell", mode, false, "active", false}, + {"stopped during dwell", models.TestParticipationOff, true, "active", false}, + {"inactive during dwell", mode, true, "inactive", false}, + } { + t.Run(tc.name, func(t *testing.T) { + worker := uuid.New() + account := &models.Email{ID: uuid.New(), WorkerID: &worker, TestMode: &tc.mode, TestReceiveEnabled: tc.receive, Status: tc.status} + payload, err := json.Marshal(models.WarmupEmailAction{EmailID: account.ID, Actions: actions, DelaySeconds: 120}) + if err != nil { + t.Fatal(err) + } + pending := &pendingEngagementStub{due: []repository.PendingEngagement{{ID: uuid.New(), EmailAccountID: account.ID, Payload: payload}}} + if tc.name == "worker reassigned during dwell" { + worker = uuid.New() + account.WorkerID = &worker + } else if tc.name == "unassigned during dwell" { + account.WorkerID = nil + } + pub := &backfillPublisher{} + s := JobsService{WarmupEngagementRepo: pending, Publisher: pub, EmailRepository: backfillEmailRepo{account: account}} + if err := s.drainDueEngagements(t.Context()); err != nil { + t.Fatal(err) + } + if !tc.want { + if len(pub.actions) != 0 { + t.Fatal("revoked engagement was published", pub.actions) + } + return + } + if len(pub.actions) != 1 || !slices.Equal(pub.actions[0].Actions, actions) || pub.actions[0].DelaySeconds != 0 || pub.workers[0] != worker { + t.Fatal("engagement lost actions, elapsed dwell or current worker", pub.actions, pub.workers) + } + if err := s.drainDueEngagements(t.Context()); err != nil || len(pub.actions) != 1 { + t.Fatal("engagement replayed after being claimed", err) + } + }) + } +} + func TestHumanizeFireAt_DefersNightToMorning(t *testing.T) { // 3am local — a read at this hour is a bot signature. night := time.Date(2026, 1, 1, 3, 0, 0, 0, time.UTC) diff --git a/internal/models/test_participation.go b/internal/models/test_participation.go index 36d83bd20..387d050fb 100644 --- a/internal/models/test_participation.go +++ b/internal/models/test_participation.go @@ -21,7 +21,7 @@ func (e *Email) TestReceivingAllowed() bool { } func (e *Email) SyntheticActionsAllowed() bool { - return e.LegacyTestParticipation() + return e.TestReceivingAllowed() } func (e *Email) PermittedWarmupActions(actions []string) []string { diff --git a/internal/models/test_participation_test.go b/internal/models/test_participation_test.go new file mode 100644 index 000000000..ba68c3b98 --- /dev/null +++ b/internal/models/test_participation_test.go @@ -0,0 +1,41 @@ +package models + +import ( + "slices" + "testing" +) + +func TestWarmupEngagementRequiresReceivingParticipation(t *testing.T) { + actions := []string{WarmupActionFile, WarmupActionRescueFromSpam, WarmupActionMarkRead, WarmupActionMarkImportant, WarmupActionStar, WarmupActionDelete, WarmupActionVerifyRemoval, "unknown"} + cleanup := []string{WarmupActionDelete, WarmupActionVerifyRemoval} + for _, tc := range []struct { + name string + mode TestParticipationMode + send bool + receive bool + allowed bool + }{ + {"legacy NULL", "", false, false, true}, + {"explicit legacy", TestParticipationLegacy, false, false, true}, + {"send and receive", TestParticipationDiagnostic, true, true, true}, + {"receive only", TestParticipationDiagnostic, false, true, true}, + {"send only", TestParticipationDiagnostic, true, false, false}, + {"neither direction", TestParticipationDiagnostic, false, false, false}, + {"off with old flags", TestParticipationOff, true, true, false}, + {"unknown mode", "unknown", true, true, false}, + } { + t.Run(tc.name, func(t *testing.T) { + e := Email{TestSendEnabled: tc.send, TestReceiveEnabled: tc.receive} + if tc.mode != "" { + e.TestMode = &tc.mode + } + want := cleanup + if tc.allowed { + want = actions[:len(actions)-1] + } + if got := e.PermittedWarmupActions(actions); !slices.Equal(got, want) || e.SyntheticActionsAllowed() != tc.allowed { + t.Fatalf("actions=%v want=%v engagement=%v", got, want, e.SyntheticActionsAllowed()) + } + }) + } +} diff --git a/internal/repository/review_corrections_live_test.go b/internal/repository/review_corrections_live_test.go index c4f2cb5c6..28b6a6c8d 100644 --- a/internal/repository/review_corrections_live_test.go +++ b/internal/repository/review_corrections_live_test.go @@ -25,16 +25,16 @@ func TestLiveParticipationPreservesNullUntilExplicitActionAndStopsBothDirections t.Fatal("legacy NULL changed", row, xerr) } row, xerr = r.SetWarmupLifecycle(ctx, f.org.String(), f.sender.String(), "start") - if xerr != nil || row.TestMode == nil || *row.TestMode != models.TestParticipationDiagnostic || !row.TestSendingAllowed() || !row.TestReceivingAllowed() || row.SyntheticActionsAllowed() { + if xerr != nil || row.TestMode == nil || *row.TestMode != models.TestParticipationDiagnostic || !row.TestSendingAllowed() || !row.TestReceivingAllowed() || !row.SyntheticActionsAllowed() { t.Fatal("explicit start lacked diagnostic consent", row, xerr) } started := *row.Warmup row, xerr = r.SetWarmupLifecycle(ctx, f.org.String(), f.sender.String(), "pause") - if xerr != nil || row.TestSendingAllowed() || !row.TestReceivingAllowed() || !row.Warmup.Equal(started) { + if xerr != nil || row.TestSendingAllowed() || !row.TestReceivingAllowed() || !row.SyntheticActionsAllowed() || !row.Warmup.Equal(started) { t.Fatal("pause lost receiving/history", row, xerr) } row, xerr = r.SetWarmupLifecycle(ctx, f.org.String(), f.sender.String(), "stop") - if xerr != nil || row.TestSendingAllowed() || row.TestReceivingAllowed() || !row.Warmup.Equal(started) { + if xerr != nil || row.TestSendingAllowed() || row.TestReceivingAllowed() || row.SyntheticActionsAllowed() || !row.Warmup.Equal(started) { t.Fatal("stop did not fence both directions", row, xerr) } enable := true diff --git a/internal/repository/test_participation_live_test.go b/internal/repository/test_participation_live_test.go index 7d5d71fae..80b7ce159 100644 --- a/internal/repository/test_participation_live_test.go +++ b/internal/repository/test_participation_live_test.go @@ -2,13 +2,43 @@ package repository import ( "context" + "encoding/json" "errors" + "slices" "testing" + "time" "github.com/google/uuid" "github.com/warmbly/warmbly/internal/models" ) +func TestLiveWarmupEngagementSurvivesRepositoryRestart(t *testing.T) { + f, _ := lineageFixture(t) + action := models.WarmupEmailAction{EmailID: f.recipient, RFCMessageID: "warmup@example.test", Actions: []string{models.WarmupActionMarkRead, models.WarmupActionMarkImportant, models.WarmupActionStar}} + payload, err := json.Marshal(action) + if err != nil { + t.Fatal(err) + } + original := NewWarmupEngagementRepository(f.pool) + for _, due := range []time.Time{time.Now().Add(-time.Minute), time.Now().Add(time.Hour)} { + if err := original.EnqueuePendingEngagement(t.Context(), f.recipient, payload, due); err != nil { + t.Fatal(err) + } + } + restarted := NewWarmupEngagementRepository(f.pool) + pending, err := restarted.ClaimDuePendingEngagements(t.Context(), 100) + if err != nil || len(pending) != 1 || pending[0].EmailAccountID != f.recipient { + t.Fatal("restart lost due engagement or claimed a future row", pending, err) + } + var got models.WarmupEmailAction + if err := json.Unmarshal(pending[0].Payload, &got); err != nil || got.EmailID != action.EmailID || got.RFCMessageID != action.RFCMessageID || !slices.Equal(got.Actions, action.Actions) { + t.Fatal("restart changed queued actions or message identity", got, err) + } + if again, err := restarted.ClaimDuePendingEngagements(t.Context(), 100); err != nil || len(again) != 0 { + t.Fatal("claimed engagement replayed or future dwell was bypassed", again, err) + } +} + func TestLiveWarmupActionsDenyUnavailableMailbox(t *testing.T) { for _, state := range []string{"missing", "deleted", "unassigned"} { t.Run(state, func(t *testing.T) { @@ -173,10 +203,15 @@ func TestLiveDiagnosticOffStopsQueuedSendAndActions(t *testing.T) { if state, err := r.BeginOutbound(ctx, id, f.sender, worker, nonce); err != nil || state.State != "execute" { t.Fatal("valid sender/recipient consent unavailable", err) } - actions := []string{models.WarmupActionMarkRead, models.WarmupActionRescueFromSpam, models.WarmupActionStar, models.WarmupActionFile, models.WarmupActionDelete} + actions := []string{models.WarmupActionMarkRead, models.WarmupActionRescueFromSpam, models.WarmupActionMarkImportant, models.WarmupActionStar, models.WarmupActionFile, models.WarmupActionDelete} got, err := r.PermittedWarmupActions(ctx, f.recipient, worker, actions) - if err != nil || len(got) != 2 || got[0] != models.WarmupActionFile || got[1] != models.WarmupActionDelete { - t.Fatal("diagnostic mode permitted synthetic engagement", got, err) + if err != nil || len(got) != len(actions) { + t.Fatal("receiving-authorized warmup lost engagement", got, err) + } + exec(`UPDATE email_accounts SET test_receive_enabled=false WHERE id=$1`, f.recipient) + got, err = r.PermittedWarmupActions(ctx, f.recipient, worker, actions) + if err != nil || len(got) != 1 || got[0] != models.WarmupActionDelete { + t.Fatal("queued engagement ignored receiving revocation", got, err) } exec(`UPDATE email_accounts SET test_mode='legacy' WHERE id=$1`, f.recipient) got, err = r.PermittedWarmupActions(ctx, f.recipient, worker, actions) diff --git a/web/src/components/app/emails/CloudWarmupCard.tsx b/web/src/components/app/emails/CloudWarmupCard.tsx index 968e2ab8c..e8402b95e 100644 --- a/web/src/components/app/emails/CloudWarmupCard.tsx +++ b/web/src/components/app/emails/CloudWarmupCard.tsx @@ -69,7 +69,7 @@ export default function CloudWarmupCard({ mailboxId, email, provider }: { mailbo -

Receive-only does not authorize replies. Shared limits are managed on the cloud-owned mailbox. Accepted provider sends cannot be recalled.

+

Receiving allows probabilistic spam rescue, reads, importance and stars or flags where supported, not replies. These are automated actions, not a guaranteed reputation improvement. Shared limits are managed on the cloud-owned mailbox. Accepted provider sends cannot be recalled.

} {!p &&

Cloud participation controls require an updated Cloud. Legacy state is not proof of diagnostic consent.

} {cloud?.warmup?.partner_limit && !sendFailure && ( diff --git a/web/src/components/app/emails/InboxDetails.tsx b/web/src/components/app/emails/InboxDetails.tsx index 84b580535..401e9f8ff 100644 --- a/web/src/components/app/emails/InboxDetails.tsx +++ b/web/src/components/app/emails/InboxDetails.tsx @@ -1268,7 +1268,7 @@ function WarmupTab({ form, update, status, mailbox, canWarmup = true }: { form: onError: (e) => toast.error(buildError(e as unknown as AppError)), }); if (action === "start" || action === "resume") { - confirm.show("Authorize automated diagnostic sends and replies? Starting from Off also enables receiving diagnostic tests. Tests are disclosed automation, not organic engagement or a proven reputation boost. Provider policies still apply.", execute); + confirm.show("Authorize automated warmup sends and replies? Starting from Off also enables receiving tests with spam rescue, reads, importance and stars or flags where supported. Tests are disclosed automation, not organic engagement or a proven reputation boost. Provider policies still apply.", execute); } else execute(); }; @@ -1374,9 +1374,9 @@ function WarmupTab({ form, update, status, mailbox, canWarmup = true }: { form: {form.test_mode === "diagnostic" && <> - + } -

Save to apply. Sending also requires an active schedule and sender role. Receive-only does not authorize replies. Diagnostic mode does not star, mark read or rescue spam. Off also stops placement tests, not legitimate campaign mail.

+

Save to apply. Sending also requires an active schedule and sender role. Receiving allows automated spam rescue, reads, importance and stars or flags where supported, not replies. Actions are probabilistic, not human engagement or a guaranteed reputation improvement. Off also stops placement tests, not legitimate campaign mail.