feat: restore provider-supported warmup engagement for receiving-authorized existing mailboxes with consent rechecks and rollout disclosures

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Matthew Meszaros
2026-10-12 06:41:29 +00:00
co-authored by Devin AI
parent 2aedb7ea0c
commit e0640539bb
11 changed files with 167 additions and 22 deletions
+1 -1
View File
@@ -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/).
@@ -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.
+1 -1
View File
@@ -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/).
</Fold>
+5 -5
View File
@@ -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).
</Fold>
@@ -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.
</Fold>
@@ -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.
@@ -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)
+1 -1
View File
@@ -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 {
@@ -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())
}
})
}
}
@@ -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
@@ -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)
@@ -69,7 +69,7 @@ export default function CloudWarmupCard({ mailboxId, email, provider }: { mailbo
<button
type="button"
disabled={busy}
onClick={() => confirm.show("Authorize disclosed automated diagnostic sending and receiving in Warmbly Cloud? This is not organic engagement or proof of improved reputation.", () => void run(() => enroll.mutateAsync(mailboxId), `${email} enrolled for diagnostics`))}
onClick={() => confirm.show("Authorize disclosed automated warmup sending and receiving in Warmbly Cloud, including spam rescue, reads, importance and stars or flags where supported? This is not organic engagement or proof of improved reputation.", () => void run(() => enroll.mutateAsync(mailboxId), `${email} enrolled for diagnostics`))}
className="shrink-0 h-7 px-2.5 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-60"
>
{busy ? <Loader2Icon className="w-3 h-3 animate-spin" /> : <CloudIcon className="w-3 h-3" />}
@@ -137,9 +137,9 @@ export default function CloudWarmupCard({ mailboxId, email, provider }: { mailbo
const apply = () => void run(() => participation.mutateAsync({ ...p, mode: "diagnostic", send }), "Diagnostic sending updated");
if (send) confirm.show("Authorize automated diagnostic sends and replies? Provider policies and safety holds still apply.", apply); else apply();
}} /></label>
<label className="flex justify-between">Allow receiving diagnostic tests<Toggle disabled={busy} value={p.receive} onChange={(receive) => void run(() => participation.mutateAsync({ ...p, mode: "diagnostic", receive }), "Diagnostic receiving updated")} /></label>
<label className="flex justify-between">Allow receiving tests and automated mailbox actions<Toggle disabled={busy} value={p.receive} onChange={(receive) => void run(() => participation.mutateAsync({ ...p, mode: "diagnostic", receive }), "Diagnostic receiving updated")} /></label>
<button disabled={busy} onClick={() => void run(() => participation.mutateAsync({ ...p, mode: "off", send: false, receive: false }), "Cloud diagnostics stopped")}>Stop all diagnostic participation</button>
<p className="text-slate-500">Receive-only does not authorize replies. Shared limits are managed on the cloud-owned mailbox. Accepted provider sends cannot be recalled.</p>
<p className="text-slate-500">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>
</div>}
{!p && <p className="mt-2 text-xs text-slate-500">Cloud participation controls require an updated Cloud. Legacy state is not proof of diagnostic consent.</p>}
{cloud?.warmup?.partner_limit && !sendFailure && (
@@ -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:
</select>
{form.test_mode === "diagnostic" && <>
<label className="flex items-center justify-between text-xs">Allow diagnostic sending and replies<Toggle value={!!form.test_send_enabled} onChange={(v) => update({ test_send_enabled: v })} /></label>
<label className="flex items-center justify-between text-xs">Allow receiving diagnostic tests<Toggle value={!!form.test_receive_enabled} onChange={(v) => update({ test_receive_enabled: v })} /></label>
<label className="flex items-center justify-between text-xs">Allow receiving tests and automated mailbox actions<Toggle value={!!form.test_receive_enabled} onChange={(v) => update({ test_receive_enabled: v })} /></label>
</>}
<p className="text-xs text-slate-500">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.</p>
<p className="text-xs text-slate-500">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.</p>
<label className="block text-xs">Shared daily send limit (all send types; 0 uses configured caps)<input type="number" min={0} value={form.shared_daily_limit ?? 0} onChange={(e) => update({ shared_daily_limit: Number(e.target.value) })} className="ml-2 w-20 rounded border border-slate-200" /></label>
<label className="block text-xs">Recipients in rolling 24 hours (To/CC/BCC; 0 uses configured caps)<input type="number" min={0} value={form.rolling_recipient_limit ?? 0} onChange={(e) => update({ rolling_recipient_limit: Number(e.target.value) })} className="ml-2 w-20 rounded border border-slate-200" /></label>
</div>