mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-09 08:02:14 +00:00
feat: complete native and Cloud diagnostic consent immutable shared provider attempt accounting authorized bounded IMAP timing retries conservative feedback pacing and released-schema correction fixtures
This commit is contained in:
@@ -616,6 +616,8 @@ 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/).
|
||||
|
||||
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/).
|
||||
|
||||
`POST /internal/worker/warmup-dispatch` is an execution-plane internal-token route, not a public JWT/API-key scope. It inspects, begins and durably finishes a task/mailbox/worker/nonce-bound command. Start repeats authoritative policy checks; unknown or denied authority cannot permit native execution. Receipt/header/model text supplies no authorization.
|
||||
|
||||
@@ -11,11 +11,11 @@ This control system restricts sending using current authority and observed evide
|
||||
|
||||
| Field | Behavior |
|
||||
|---|---|
|
||||
| `test_mode` | `diagnostic`, `off` or explicit `legacy`. Existing NULL values retain legacy behavior; newly connected mailboxes default to `off` |
|
||||
| `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 |
|
||||
| `shared_daily_limit` | Positive mailbox-wide calendar-day ceiling across campaign, manual, warmup and placement reservations |
|
||||
| `rolling_recipient_limit` | Positive rolling 24-hour ceiling counting each To/CC/BCC recipient occurrence, not just messages |
|
||||
| `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.
|
||||
|
||||
@@ -33,6 +33,8 @@ Every new outbound lane goes through a mailbox/task advisory lock and one durabl
|
||||
|
||||
Reservation requires an active organization-owned mailbox, its current configured provider and worker, current suppression and recovery state, and the exact recipient envelope. Campaign sends additionally require active campaign/lead/progress state and current calendar/pacing policy. Diagnostic partners retain their real pool, role, tier, trust, standing and inbound capacity constraints. Seed mailboxes stay measurements, not conversation partners. Future, missing or stale positive Cloud standing is unavailable evidence; restrictive holds are not erased.
|
||||
|
||||
Provider starts also write immutable per-nonce `outbound_attempts`. A definitive failure may refund successful-message capacity but never refunds attempt-rate evidence. Daily and rolling limits count those attempts plus outstanding reservations without double counting the same nonce. Campaign test sends create a real task before shared admission; rejection/publication failure records a task failure, with tracking still disabled.
|
||||
|
||||
The executor rechecks organization, mailbox, worker, provider, durable task/nonce and exact recipients before starting. A command cannot broaden its CC/BCC or switch tenants after reservation. Worker silence, publication uncertainty, provider timeout and executor restart are unknown outcomes, not unsent proof. Their reservations and mailbox hold remain until a correlated definite result resolves them. Terminal replay returns the saved result without resending. A definite native throttle records only the evidence and scope actually observed; legacy codes preserve conservative mailbox/provider cooldowns without invented HTTP or SMTP codes.
|
||||
|
||||
Result reconciliation commits task, campaign progress, variant, suppression and warmup-health changes together. Bounce webhooks and notifications enter an idempotent durable outbox in that transaction, and result consumers drain it after commit. Failed delivery retains a leased retry row; a consumer restart does not lose the effect. Stable downstream webhook delivery and notification-feed IDs prevent duplicate stored rows. External integration, Slack/push and realtime delivery remain at least once/best effort, not exactly-once delivery. Core accounting and unknown holds remain durable.
|
||||
@@ -46,20 +48,23 @@ Authentication, permanent and conflicting-result holds do not clear merely becau
|
||||
"send_recovery_resolution": {
|
||||
"held_task_id": "00000000-0000-0000-0000-000000000001",
|
||||
"held_reason": "authentication",
|
||||
"evidence_type": "authentication_repaired"
|
||||
"evidence_type": "authentication_repaired",
|
||||
"evidence_task_id": "00000000-0000-0000-0000-000000000002"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`authentication_repaired` requires a newer successful mailbox sync after the held task. `operator_provider_confirmation` instead requires `evidence_task_id` naming a definitive, applied result on that same mailbox and a non-empty `confirmation_reference`. Include the exact `held_task_id` and `held_reason` (`authentication`, `permanent`, `conflict`) inspected by the operator. A changed hold, inactive mailbox, mismatched configured provider, cross-organization request or any remaining `send_result_state=unknown` refuses resolution. Investigate unknown tasks first; never label them failed merely to free capacity. The history and previous reason are inserted atomically before clearing only the repair hold. Cooldowns, suppression and other restrictions remain. References must not contain credentials, message bodies or raw provider payloads.
|
||||
`authentication_repaired` requires `evidence_task_id` naming a newer definitive, applied successful outbound send on the same mailbox; fresh inbox sync alone is not repair proof. `operator_provider_confirmation` requires a same-mailbox definitive, applied evidence task and non-empty `confirmation_reference`. Include the exact held task and reason (`authentication`, `permanent`, `conflict`). Changed holds, inactive mailboxes, cross-organization requests and remaining unknown sends refuse resolution. Investigate unknown tasks; never label them failed just to free capacity. Repair history is inserted atomically before clearing only the repair hold. Cooldowns, suppression and other restrictions remain. References must not contain credentials, message bodies or provider payloads.
|
||||
|
||||
## Actual-message authentication
|
||||
|
||||
An updated worker can retrieve bounded raw MIME for exactly one already authorized application-owned warmup diagnostic received through Gmail or Microsoft Graph. The control plane first binds the immutable diagnostic token/task, exact sender and recipient, provider message ID, current mailbox/worker and participation. A short-lived nonce binds the result submission. The worker retrieves only that named message, not an unrelated inbox corpus. Gmail uses raw format; Graph uses the named message's MIME `$value`.
|
||||
An updated worker retrieves bounded raw MIME for one authorized application-owned diagnostic through Gmail, Graph or IMAP. Authority binds the immutable token/task, exact identities, stamped message ID, current mailbox/worker and participation. A short-lived nonce binds completion. Gmail uses raw format, Graph the exact message's MIME `$value`, and IMAP the exact folder/UIDVALIDITY/UID with bounded `BODY.PEEK[]`. IMAP uses the existing authenticated connection after sync releases its folder mutex, never widening inbox ingestion or reconnecting outside the deadline.
|
||||
|
||||
Receipt-before-send-result and transient DNS unknowns have metadata-only retries, at most three attempts within fifteen minutes. Each retries current authorization; absent, expired or revoked context never fetches MIME. The retry queue is bounded to 64 per worker mailbox and is in memory; restart or exhaustion can leave unknown rather than manufacture proof. Definitive stored pass/fail results do not authorize another raw fetch. Raw bytes are cleared after verification or errors and never stored or logged.
|
||||
|
||||
Cryptographic verification uses `go-msgauth/dkim-v0.7.0`, at most 1 MiB of MIME and four signatures, with context-bounded key lookup. A valid DKIM signature is pass; exact From-domain/signer equality establishes only a conservative alignment-pass subset. Body mutation fails verification. Forged or copied Authentication-Results cannot become proof. The stored result includes verifier/version and observation time, not raw MIME or DNS responses. The exact receipt receives the minimal proof; duplicate results cannot replace it with unrelated evidence.
|
||||
|
||||
SPF, DMARC and receiving-hop TLS remain unknown absent independent evidence. IMAP/SMTP diagnostics, seed/placement messages outside this authorized warmup context, legacy workers, unavailable MIME, transient key/DNS failures, size/signature bounds and absent authorization remain unknown. Parsed provider Authentication-Results are kept separately as unverified claims. DNS configuration discovery is useful readiness input, not proof of a received message authenticating. Generated prose cannot claim a provider/client check actually passed.
|
||||
SPF, DMARC and receiving-hop TLS remain unknown absent independent evidence. Unauthorized diagnostics, seed/placement messages outside this context, legacy workers, unavailable MIME, transient DNS failures and byte/signature bounds remain unknown. Parsed Authentication-Results stay unverified claims. DNS configuration is readiness input, not actual-message authentication. Generated prose cannot claim a provider/client check passed.
|
||||
|
||||
## Unsubscribe and negative feedback
|
||||
|
||||
@@ -69,12 +74,14 @@ Hard bounce/complaint/suppression and authentication failures remain negative ev
|
||||
|
||||
## Upgrade, rollback and portability
|
||||
|
||||
1. Back up and preserve released migrations 1 through 265 byte-for-byte. Apply the contiguous additive 266 through 272 sequence; no new environment variable is required.
|
||||
1. Back up and preserve released migrations 1 through 265 byte-for-byte. Apply the contiguous additive 266 through 273 sequence; no new environment variable is required. Migration 273 preserves historical executor starts as attempt evidence and refuses rollback while attempts from the last 24 hours remain.
|
||||
2. Pause dispatch during a mixed control-plane rollout. Upgrade every backend/result consumer, including Cloud replicas, before relying on generation stop, exact-parent resolution, current participation or correlated managed consent.
|
||||
3. Drain/reconcile previously accepted work, then upgrade execution workers. Protocol `warmup_send_protocol=2` is required for shared reservations, execution-time action authority and cryptographic diagnostics. Protocol 1 supports only its previous lineage capability. Old JSON and Avro payloads remain readable; old capability is not stronger cancellation/admission support. Use matching Kafka-tagged binaries where applicable.
|
||||
4. Recheck holds, actual configured provider, worker liveness, schedules, directional participation, shared/rolling limits and Cloud-side settings before resuming. New mailboxes default off; existing NULL participation stays legacy rather than silently relabeling historical consent. No migration rewrites legacy AI provenance as reviewed. Legacy NULL-provenance sources do not count toward new versioned selection/readiness; existing exact-parent ambiguity stays closed.
|
||||
|
||||
Organization export retains participation, ceilings, restrictive task/account evidence and audited repair history. Executor worker/nonce/start/result handles are reset on import. Exact provider-message diagnostic authorization and nonces are instance-local and excluded; minimal first-observation proof is portable historical evidence, never a reusable execution grant. See [workspace moves](/guides/workspace-export-import/).
|
||||
Organization export retains participation, ceilings, restrictive task/account evidence and repair history. Executor worker/nonce/start/result handles reset on import. Exact diagnostic authorization and nonces, including raw warmup receipt provenance, are instance-local and excluded; registered placement aggregates travel as history, never as execution grants. See [workspace moves](/guides/workspace-export-import/).
|
||||
|
||||
Immutable attempt timestamps and recipient counts travel with the sending group and retain restrictive rate evidence; their historical nonces do not authorize execution. Raw warmup receipt provenance remains source-instance-local and excluded, while registered placement aggregates travel.
|
||||
|
||||
The source-instance reconciliation outbox is excluded from organization archives so an import cannot replay customer notifications. Drain pending effects before moving; whole-instance backups retain the queue. Delivered outbox rows expire after seven days; pending rows remain until delivery or organization/task deletion. They contain event/notification metadata, not raw MIME, provider responses or credentials. Repair history lasts with the organization and must not contain sensitive message content.
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ A draft's Overview tab shows what it still needs (leads, an email, senders), eac
|
||||
The estimate simulates the campaign day by day against the mailboxes that would send it. It uses the same rules as the scheduler:
|
||||
|
||||
- each mailbox's own cap and the campaign's daily limit
|
||||
- the warmup graduation ceiling, which starts a freshly warmed mailbox low and adds 5 a day
|
||||
- conservative cold pacing, requiring recent real-recipient replies for bounded increases rather than synthetic age
|
||||
- the workspace's risk band and each mailbox's health band
|
||||
- mailboxes on hold or failing domain authentication, which send nothing
|
||||
- sending behaviour profiles
|
||||
@@ -394,17 +394,11 @@ A **placement monitor** repeats that on a schedule while the campaign runs, ever
|
||||
|
||||
## Easing out of warmup
|
||||
|
||||
A mailbox that has been warming does not jump straight to its full cold cap. Going from 40 warmup emails a day to 50 cold emails the next morning is the volume jump mailbox providers penalise, so Warmbly ramps into it.
|
||||
A diagnostic-history mailbox starts cold sending at a conservative ceiling of five/day. Synthetic traffic and elapsed age do not show that real recipients welcome outreach.
|
||||
|
||||
The mailbox starts at a volume set by how long it warmed, then adds `5` a day until it reaches the cap you configured:
|
||||
Confirmed human replies to actual campaign recipients in the last seven days permit bounded increases: at most five per clean day, and never more than the confirmed reply count. Complaints clear positive progression. Without reply evidence, the ceiling stays five/day.
|
||||
|
||||
| Warmed for | Starts cold at |
|
||||
|------------|----------------|
|
||||
| under a week | `5`/day |
|
||||
| one to two weeks | `10`/day |
|
||||
| two weeks or more | `20`/day |
|
||||
|
||||
The ramp only ever **lowers** a cap, never raises one, so your configured limit and the campaign's daily limit still bind. A spam placement at Google, Microsoft or Yahoo pauses the climb for the same three days it pauses the warmup ramp, and the paused days are subtracted rather than made up. The mailbox drawer shows today's allowance and roughly when it reaches your cap.
|
||||
Configured campaign/mailbox caps, shared daily/rolling recipient limits and negative-feedback holds still bind. This is product pacing, not a universal provider-approved safe volume or proven reputation benefit. Missing evidence leaves full-cap dates unknown rather than projecting synthetic age into readiness.
|
||||
|
||||
<Callout type="info" title="Only for mailboxes that warmed">
|
||||
A mailbox that never used warmup is not gated. This exists to smooth the warmup-to-cold transition, not to cap senders who never opted into warmup.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
title: Warmup
|
||||
description: "How email warmup builds sender reputation, pools, and ramp."
|
||||
description: "Disclosed diagnostics, placement evidence, participation and conservative pacing."
|
||||
---
|
||||
|
||||
Warmup sends small amounts of low-risk, natural-looking mail between participating mailboxes to build sender reputation, so your cold outreach lands in the inbox. You turn it on per mailbox and pick how fast it ramps; Warmbly handles partners, spacing, replies, and safety.
|
||||
Warmup sends disclosed automated diagnostics between participating mailboxes. It measures observed placement and bounded actual-message DKIM evidence. It does not establish organic engagement or guarantee reputation improvement or inbox placement.
|
||||
|
||||
<Callout type="info" title="Warmup is a paid feature">
|
||||
Free organizations can connect mailboxes, but no warmup traffic is scheduled for them; pending warmup activity is skipped rather than sent.
|
||||
@@ -11,23 +11,23 @@ Warmup sends small amounts of low-risk, natural-looking mail between participati
|
||||
|
||||
## Why it works
|
||||
|
||||
Providers watch how an address behaves over time. A new address that suddenly bursts out cold email looks suspicious. One that sends a steady, modest, conversational stream and gets replies looks like a person. Warmup manufactures that history: low volume instead of spikes, natural spacing across the day, replies so threads look real, and slow growth. Reputation builds slowly and is lost quickly, so warmup favors patience over throughput.
|
||||
Diagnostics reveal configuration and delivery problems, not whether real recipients welcome outreach. Provider acceptance is not inbox placement; synthetic replies are not human feedback. Missing observations remain unknown.
|
||||
|
||||
## How it works
|
||||
|
||||
With warmup enabled, Warmbly repeatedly picks a partner mailbox (avoiding recent ones), decides whether to start a thread or reply to one, sends a short plaintext message through that mailbox's worker, then records stats and schedules the next send.
|
||||
|
||||
Every warmup send carries a hidden verification token so the receiver can confirm it is genuine warmup traffic. Warmup mail is plaintext and never carries open or click pixels, because it needs to look like ordinary personal email.
|
||||
Diagnostics carry task/sender/recipient-bound application tokens and disclose automation and hypothetical scenarios. They use configured mailbox identities and no open/click pixels.
|
||||
|
||||
The token travels in a message header, and some providers do not pass custom headers on to the recipient. Microsoft in particular strips them in transit and replaces the message identifier, so a warmup email sent from an Outlook or Microsoft 365 mailbox arrives carrying no header at all. Warmbly therefore also records what each send was addressed to, and the message identifier the provider assigned to it, and matches an incoming warmup email on those when the header is gone. Verification does not depend on any one provider behaving well, so warmup from every mailbox type counts, stays out of your unibox, and gets the same engagement.
|
||||
Providers may strip headers or replace identifiers. Exact authorized lineage and send-result identifiers correlate diagnostics; missing or ambiguous lineage stays closed. Tokens identify application-owned traffic, not cryptographic authentication.
|
||||
|
||||
Enabling warmup controls **outbound** scheduling only. An active mailbox with warmup off stays available as a recipient-only participant, and never starts sending just because it received something.
|
||||
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/).
|
||||
|
||||
### Where the content comes from
|
||||
|
||||
Message generation stays off the live sending path. When an AI provider is configured, a background batch builds complete conversation plans (a subject, an opening, and five alternating reply turns), each validated against a strict schema, checked for robotic language, and passed through a safety lint before entering the active bank. When the operator has also configured TypeSafe (`TYPESAFE_API_KEY`), each thread is judged for pitches and generated-sounding filler before entering the bank, and the thread text is sent to TypeSafe for that.
|
||||
|
||||
At send time a message draws from the least-used suitable thread and accounts for usage atomically, spreading content evenly across thousands of mailboxes. The opening is personalized with the mailbox's stable persona, greeting, sign-off, and signature; reply turns stay unused until the recipient actually replies. If generation is unavailable or the bank is empty, a built-in reviewed library takes over, so **warmup never waits on an AI provider**.
|
||||
Canonical turns use actual alternating configured identities and pinned subject/source/version. Exact-parent continuation preserves facts and closes exhausted, retired or missing threads. A varied vetted bank of disclosed hypothetical exercises supplies fallback. Generated prose never claims a provider check passed.
|
||||
|
||||
The controller runs every six hours against the last seven days of demand: at least 200 active threads, up to 5,000, refreshing the most-used content, at most 250 threads per batch and 1,000 per day. Threads are retired automatically when at least 3 of 20 or more sampled deliveries land in spam and the rate is `15%` or higher. Admins monitor it but never refill it by hand.
|
||||
|
||||
@@ -48,7 +48,7 @@ The borrow is returned: a free-tier mailbox that meets the same bar (healthy now
|
||||
|
||||
Every draw also favours the inbox that is owed the most. A mailbox that has sent to many partners and heard back from few is drawn up to four times as often as one in balance, and the preference fades as the pool pays it back, so traffic settles near parity instead of overshooting. The other way is bounded too: an inbox receives at most about twice what it sends in a day (never fewer than 10, never more than 60), and once it reaches that it is left out of every draw for the rest of the day. Free-tier senders stop at three quarters of that, so the rest of every inbox's day is kept for premium mailboxes. A mailbox with nobody left to write to sends nothing and tries again later rather than mailing the same partner twice.
|
||||
|
||||
Warmbly spreads sends across many partners and recipient domains rather than looping the same two mailboxes, since tight reciprocal pairs are easy for providers to spot. **Pool quality matters more than pool size.**
|
||||
Partner/domain diversity broadens diagnostic coverage. It is not identity rotation to evade quotas or evidence of reputation efficacy.
|
||||
|
||||
A [placement seed inbox](/guides/placement-tests/#your-own-seed-inboxes) never joins a pool: marking a mailbox as a seed turns its warmup off, and starting warmup on a seed is refused, because a seed that exchanged mail with the pool would stop being a stranger to every sender in it. It never sends campaign mail either.
|
||||
|
||||
@@ -56,7 +56,7 @@ Self-hosted instances warm only among their own mailboxes unless they are linked
|
||||
|
||||
### Choosing partners
|
||||
|
||||
Partners outside your own workspace come first. Mail your own mailboxes exchange between your own domains teaches the providers that will receive your cold outreach nothing, costs the mailbox its daily ramp, and is the closed loop a spam filter is most likely to read as one, so a mailbox works through every available outside partner before any of its siblings. It is a preference rather than a rule, because a self-hosted instance's pool is your workspace and nothing else: there, siblings are the only partners there are, and warmup would otherwise stop. Bringing more mailboxes into a shared pool therefore widens your diversity instead of narrowing it.
|
||||
Outside-workspace partners broaden observations; unlinked self-hosted instances may have only their own mailboxes. Diversity does not turn automated traffic into organic engagement.
|
||||
|
||||
Within that order, partners are picked by weight rather than at random. Three things move the weight:
|
||||
|
||||
@@ -70,11 +70,11 @@ A struggling host is downweighted, never excluded: a sender that stops mailing a
|
||||
|
||||
### Seeing the diversity you are getting
|
||||
|
||||
A mailbox's drawer shows how many distinct partners its confirmed sends reached over the last seven days, on how many domains, in how many workspaces, and the other direction: how much verified warmup mail arrived and from how many partners. Failed and still-pending attempts do not count on the sending side, and only mail the mailbox's own sync verified counts on the receiving side. The numbers appear next to the local warmup reputation or inside the Warmbly Cloud card when the cloud warms the mailbox. A single workspace over a whole week is the number to watch, whether it is yours or somebody else's: warmup builds the most reputation across many workspaces and domains. It happens when a pool has no other partner available for that mailbox, and on a self-hosted instance that is not linked to Warmbly Cloud it is the only thing that can happen. A mailbox writing to far more partners than write back is flagged in the same place; the pool favours it on every draw until the two sides even out.
|
||||
The drawer shows confirmed partners/domains/workspaces and verified arrivals over seven days, locally or through Cloud. Pending and failed attempts do not count as confirmed sending. Narrow coverage is a diagnostic limitation, not a reputation verdict.
|
||||
|
||||
The **Analytics** tab of the same drawer charts the exchange over time: sent, received and replies per day, with the total received against the total sent for the range. The same numbers are in the [warmup analytics](/api/reference/analytics/#get-warmup-analytics) endpoint.
|
||||
|
||||
Turning off outbound warmup does not remove a healthy mailbox from the recipient pool. Disconnecting it, losing warmup-plan access, or entering a quarantined or blocked state does. Losing access removes the mailbox from its pool within minutes, whether or not it was still warming.
|
||||
Pause preserves separate receiving permission; Stop disables both directions. Lost authority, disconnection, quarantine or block also restrict participation. Health alone is not consent.
|
||||
|
||||
## Seeing where warmup lands
|
||||
|
||||
@@ -96,7 +96,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 the drawer or the page by recipient provider to see whether a problem is general or confined to one: **Google** covers Gmail and Google Workspace, **Microsoft** covers Outlook.com and Microsoft 365, and expanding a provider shows each host inside it. **Rescued** counts spam placements Warmbly told the partner's mailbox to move back to the inbox, the "not spam" signal providers learn from; it is the rescues requested, since the partner's mailbox does not confirm the move. **Unconfirmed** counts mail sent more than a day ago that the partner has not seen yet, usually a partner whose sync is behind, and occasionally mail that never arrived. Spam is read from where the partner's provider filed the message: Gmail's Spam label, Outlook's Junk Email folder, or an IMAP server's Junk folder whether or not the server also flags it, and [pool safety](#how-spam-placement-is-judged) reads the same landings at Google, Microsoft and Yahoo. History from before this view existed is filled in from the receipts still on file shortly after an upgrade, without the tab and rescue split. The figures are warmup mail only, not campaign sends, and update live as partners report in. Days are UTC. The same numbers are in the [warmup placement](/api/reference/analytics/#get-warmup-placement) endpoint.
|
||||
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).
|
||||
|
||||
## The ramp
|
||||
|
||||
@@ -106,7 +106,7 @@ Filter the drawer or the page by recipient provider to see whether a problem is
|
||||
| Daily increase | `+1`/day | How much the target grows daily |
|
||||
| Ceiling | `40`/day | Where the ramp stops climbing |
|
||||
|
||||
At defaults a mailbox sends 10 on day one, 11 the next, and levels off at 40 after roughly a month. You can change these per mailbox, but conservative defaults build the most durable reputation.
|
||||
Configured diagnostic targets start at 10/day and rise by one toward 40/day before other constraints. These are product pacing defaults, not universal safe volumes or readiness evidence.
|
||||
|
||||
Four things shape the real daily number: sends are spread across your warmup hours with jitter, the target is capped by how many eligible partners exist, a mailbox whose health drops gets reduced volume and wider spacing until it recovers, and a recent spam placement holds the ramp where it is.
|
||||
|
||||
@@ -128,7 +128,7 @@ A landing at any other mail host never holds the ramp. Those hosts each run thei
|
||||
|
||||
Nothing needs to be switched on and no health band has to trip first. That matters because the bands need a sample before they can judge a mailbox at all (twenty verified warmup deliveries in seven days, a hundred delivered in thirty), which a mailbox in its first fortnight has not reached. Without this, the mailbox landing in spam on day three would keep adding an email a day until it had sent enough to be judged.
|
||||
|
||||
The two windows end at different times, so between 48 and 72 hours a mailbox is back at full volume while the ramp is still paused. The mailbox drawer says which of the two is happening, how many emails were affected, and when the ramp resumes. Restarting warmup resets the ramp and clears any hold with it.
|
||||
Placement slowdown and ramp pause have different windows. The drawer shows observed targets/holds. Lifecycle actions retain ramp history and restrictive holds rather than erasing negative evidence.
|
||||
|
||||
<Callout type="info" title="Warmup and campaigns coexist">
|
||||
Keep warmup on after campaigns start. A mailbox backing a live campaign drops to at most five warmup messages per day, leaving the campaign as primary traffic. Warmup does not make repetitive or unwanted copy safe: you still need real targeting, personalization, suppression, and unsubscribe support.
|
||||
@@ -136,13 +136,13 @@ The two windows end at different times, so between 48 and 72 hours a mailbox is
|
||||
|
||||
## Replies
|
||||
|
||||
Real inboxes reply, so a configurable share of the time a mailbox answers an existing warmup thread instead of starting one. For AI content Warmbly reloads the original conversation plan and sends its next unused turn, with the conversation ID and turn number traveling on every verified send, so a reply continues the same exchange. A parent message is answered only once, and an exhausted or retired thread ends rather than repeating.
|
||||
Configured diagnostic turns continue disclosed scenarios, not organic relationships. Exact pinned plans and subjects are reloaded, each parent is answered once, and exhausted, retired or missing threads close.
|
||||
|
||||
Replies thread properly with a real `Re:` subject and `In-Reply-To` header, and candidates must be between 45 minutes and seven days old, so nothing is answered instantly or revived indefinitely.
|
||||
|
||||
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.
|
||||
|
||||
Timing imitates people throughout: sends come in bursts and lulls rather than a fixed rhythm, never land on round clock marks, and opens happen on a natural delay during the recipient's waking hours. No mailbox in the pool reads mail at 3am or reacts within seconds.
|
||||
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.
|
||||
|
||||
## Keeping warmup out of your inbox
|
||||
|
||||
@@ -157,26 +157,26 @@ 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** | Warmup mail stays where the provider put it. Mail that landed in spam is still rescued. |
|
||||
| **Leave it in the inbox** | Diagnostics stay where the provider placed them, including spam. |
|
||||
| **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.
|
||||
|
||||
Anything that landed in spam is rescued before it is filed, so the provider records the positive placement signal warmup exists for. On Gmail that means the `SPAM` label comes off, exactly as pressing **Not spam** does, and the message then goes where your placement says rather than straight back into the inbox. On an IMAP server the message also gets the standard not-junk keywords (`$NotJunk`, `NonJunk`) and loses the junk ones before it leaves the Junk folder, for servers and mail clients that learn from keywords rather than from the move.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
Filing happens as each message arrives, so changing the setting applies to warmup from that point on.
|
||||
|
||||
One case reaches back. If a warmup message is ever missed on arrival and shows up in the unibox, a daily sweep recognises it later, removes it from the unibox and files it in your mail client too. Only the filing runs on that path: read state, importance and stars are how fresh warmup earns its placement signal, and replaying them on old mail would be a burst of activity no real reader produces. A mailbox set to leave warmup in the inbox is left alone, because that is a choice rather than a leak.
|
||||
A sweep identifies missed application-owned diagnostics and applies authorized housekeeping. It does not establish retroactive authentication or engagement. Leaving mail in the inbox remains an owner choice.
|
||||
|
||||
The sweep repairs what it can still see, so it does not clear a backlog that built up before the setting existed. To do that on Gmail, search `label:Warmbly` and archive the results: warmup mail has always carried the label, so that selects exactly it. On Outlook and IMAP the received mail is already in the folder, and only the Sent copies from before this change stay in Sent.
|
||||
|
||||
### Retention
|
||||
|
||||
Warmup mail is real mail taking up real space, and once its engagement has been recorded there is nothing left to keep it for. So Warmbly clears the folder itself: every warmup message, received or sent, is deleted from the mailbox once it is older than the mailbox's retention window, 30 days unless you or your instance operator chose otherwise. On Gmail that means it is moved to Trash, which Gmail empties after another 30 days; on Outlook, Microsoft 365 and IMAP it is deleted outright. The copy of the message Warmbly itself stored goes with it.
|
||||
Authorized retention deletes old diagnostic mail after its window, 30 days by default. Gmail moves it to Trash; Outlook, Microsoft 365 and IMAP delete it. Stored copies follow retention policy. Recipient deletion is not tampering or proof of failure.
|
||||
|
||||
The window is per mailbox, next to the filing setting on the **Warmup** tab of its drawer, and on the API as `warmup_retention_days`. Leave it empty to follow the instance setting; the shortest is 3 days, so a thread always has time to finish and the delayed engagement (read, starred, marked important) has always run before its message goes. A self-hosted instance sets its default under **Instance > Configuration > Data retention**, see [data control](/development/data-control/#warmup-mail-in-mailboxes).
|
||||
Set warmup_retention_days on the Warmup tab or API. Empty follows the instance default; the minimum is three days. Self-host operators configure Instance > Configuration > Data retention. See [data control](/development/data-control/#warmup-mail-in-mailboxes).
|
||||
|
||||
At the volumes warmup runs, a few plaintext messages of a couple of kilobytes each, a mailbox in balance takes years to fill even on a small hosting quota, so the retention is there so that you never have to think about it rather than because the folder is about to overflow. You do not need to clear the folder by hand, and if you do, see [pool safety](#pool-safety) for what counts.
|
||||
|
||||
@@ -188,7 +188,7 @@ At the volumes warmup runs, a few plaintext messages of a couple of kilobytes ea
|
||||
|
||||
Warmup never appears in the unified inbox, in either direction. Warmbly recognizes it by its token, including for recipient-only mailboxes. A consumed or expired token still identifies warmup for filtering, but cannot trigger engagement again. When the header is missing, a recorded message identifier can identify older warmup during history sync; the sender-and-subject fallback stays limited to recent inbound sends so an ordinary conversation is not hidden just because it shares a subject. Existing verified warmup entries are removed from the unibox automatically in background batches, without deleting mail at the provider or changing warmup standing.
|
||||
|
||||
A reply typed by hand in a warmup thread is warmup too. Answering a warmup message from Gmail or Outlook produces mail with no token, a fresh identifier and a "Re:" subject, so none of the checks above can see it; what it does carry is the identifier of the message it answers, and that is what Warmbly matches. The reply stays out of the unibox and is filed in your mail client like the message it answers, and so is every later turn in that thread, from either mailbox. Nothing is engaged with on that path: reads, stars and importance are earned by verified warmup deliveries only. On a self-hosted instance the partners are usually your own mailboxes, which is where this comes up: replying to a warmup message from one of them by hand no longer puts the thread in your inbox.
|
||||
Manual replies to verified diagnostic parents can stay hidden from the unibox and follow housekeeping. They do not authorize another automated turn or count as real campaign replies.
|
||||
|
||||
If a provider syncs a headerless Sent copy before reporting its final message identifier, Warmbly holds a possible match for verification. The send confirmation then identifies which message was warmup, and ordinary mail sharing its subject can enter the unibox. A matching subject alone never permanently hides a sent message.
|
||||
|
||||
@@ -205,7 +205,7 @@ Warmup writes to none of the tables the customer-facing views read, so nothing n
|
||||
|
||||
## Pool safety
|
||||
|
||||
Shared pools only work if participants behave, so every mailbox is judged on rates rather than raw counts: complaints, spam-folder placement, bounces, and tampering with warmup mail it received, such as deleting it or flagging it as spam. Each band needs a minimum sample before it acts, so a slow week cannot convict a mailbox and a busy one is not punished for its volume.
|
||||
Pools enforce permission and restrictive complaint/bounce/authentication evidence, current authority and durable holds. Deleting diagnostics or choosing spam is recipient withdrawal, not automatic tampering misconduct.
|
||||
|
||||
| State | Meaning | Effect |
|
||||
|-------|---------|--------|
|
||||
@@ -219,7 +219,7 @@ The thresholds for each signal are in the [health bands](/guides/deliverability/
|
||||
|
||||
### How spam placement is judged
|
||||
|
||||
Spam placement is a reading of reputation, not misconduct, and warming is how a mailbox recovers from it. So spam placement only ever slows a mailbox down. It never quarantines or blocks one, and there is nothing to appeal:
|
||||
Spam placement is a folder outcome, not misconduct or evidence of diagnostic reputation benefit. It slows sending but does not alone quarantine or block a recipient:
|
||||
|
||||
- `10%` puts the mailbox on watch: warmup and cold sending are spaced out a little more.
|
||||
- `20%` throttles it: warmup keeps running at half volume with double spacing, and cold volume is halved.
|
||||
@@ -229,28 +229,14 @@ Only Google, Microsoft and Yahoo recipients judge a mailbox. They filter on send
|
||||
|
||||
So a mailbox that inboxes everything at Google and Microsoft stays healthy however many small hosts junk its mail. The reason in the mailbox drawer says what was judged: for example `25% of warmup mail delivered at Google, Microsoft and Yahoo landed in spam over 7 days (40 delivered). Other mail hosts (50% of 20) run their own filters and are not counted.`
|
||||
|
||||
Partner selection favours the recipients where warmup earns something. A small-host partner whose own filter junks most of the warmup mail it receives, from everyone, is drawn less often (never excluded), since a message filed into spam there builds no reputation for the sender. This is never read at Google, Microsoft or Yahoo, where a junk verdict reflects the senders rather than the recipient.
|
||||
Selection uses observed coverage to reduce uninformative pairings. Measurement allocation does not prove traffic to a host builds reputation.
|
||||
|
||||
Quarantined and blocked mailboxes are selected as neither sender nor recipient. Mail arriving in a mailbox is never held against it: every warmup message carries a single-use token bound to its recipient, so a token cannot be replayed or redirected, and one that lands where it does not belong is simply filed as ordinary mail.
|
||||
|
||||
What a mailbox does to warmup mail it received is held against it, on a ladder rather than at once. Deleting a warmup email within a day of its arrival counts as one strike, and so does moving one to spam, over the last seven days. One strike puts the mailbox on watch with the reason shown in its drawer, two pause it from the pool for seven days, and four block it for thirty. So one deleted or junked warmup message is a warning, not a ban.
|
||||
|
||||
Only a fresh deletion counts, because that is the one that costs the pool something: the engagement a warmup message earns happens in its first hours, and removing it before then takes that signal away. A warmup message deleted later is housekeeping, whether by you, by Gmail emptying its Trash, by a retention rule on your mail server or by Warmbly's own [retention](#retention), and it is never held against the mailbox. On Gmail, pressing Delete is what is judged, not the purge from Trash weeks later. A mailbox's own filing is never counted either: Warmbly moving a warmup email into its folder, marking it read, rescuing it from spam or deleting it once its window has passed is the platform acting, not the owner.
|
||||
|
||||
Moving a warmup email is not deleting it. Outlook and Microsoft 365 report a message moved to another folder exactly as they report one deleted, and a mailbox's own rules, its provider's filter or a second Warmbly instance syncing the same mailbox can all move mail. So before a removal counts, Warmbly searches the whole mailbox for the message: found in any folder other than Trash (Deleted Items on Outlook), it was filed, and nothing is held against the mailbox. Only a message in Trash or gone for good is a strike. A search that cannot run, or cannot tell, counts as nothing. Deletion strikes recorded before this search existed are searched for in the same way. A strike whose message is still in the mailbox is withdrawn, and the pause or block it caused is decided again on the strikes that remain, so it is lifted or shortened only when those no longer earn it.
|
||||
|
||||
No provider says who moved a message into spam. Gmail, Microsoft 365 and IMAP report it the same way whether you pressed "Report spam", the provider re-filed it after delivery (Microsoft's zero-hour auto purge, Google Workspace's post-delivery scanning), a desktop mail client's junk filter moved it, or a security tool did. So Warmbly charges a move to spam only when the evidence points at a person, and waits 30 minutes after the move to see it:
|
||||
|
||||
- **Arrived in spam.** A warmup email that was in spam when it arrived is never a strike. The label on it is the provider's filter, and it counts as spam placement against the sender.
|
||||
- **The provider at work.** When the same sender's warmup mail was moved to spam in another workspace within a day, the provider is re-judging that sender, and nobody is charged. If a mailbox was already charged for one of those, the strike is withdrawn and any pause it caused is decided again. A move within 15 minutes of arrival while nobody is using the mailbox is the filter catching up, and is not charged either.
|
||||
- **You at the mailbox.** A move while someone was reading, marking unread or starring mail in the mailbox, within 30 minutes either side, is taken as yours. Only changes made at the provider count: reading a message in Warmbly's inbox is never mistaken for it.
|
||||
- **A pattern.** In a mailbox someone has used in the last 14 days, unexplained moves of warmup mail from three or more different senders in a week, which no other workspace sees, are the mailbox's own filtering and are charged.
|
||||
- **Anything else charges nobody**, including every move in a mailbox nobody has used for 14 days. The sender still has it counted as spam placement, which only slows sending down.
|
||||
|
||||
Only a move charged to you files a complaint against the sender. A move attributed to the provider, or to nobody, is spam placement for the sender instead. On Outlook, Microsoft 365 and IMAP mailboxes, a move to Junk is found by the search above and is not charged at all.
|
||||
Withdrawal creates no unconditional deletion/spam tampering strike. Owner-attributed spam can still create sender complaint evidence and suppression; provider-attributed moves remain placement evidence. Ambiguity charges nobody. Historical evidence is rechecked without resetting unrelated restrictive holds; failed searches stay unknown.
|
||||
|
||||
<Callout type="warn" title="Acting early protects everyone">
|
||||
Warmbly intervenes well before providers would penalize a mailbox. Complaints, bounces and tampering take a mailbox out of the pool early. A mailbox landing in spam at the major providers is slowed down instead, so it keeps warming, which is how it recovers.
|
||||
Complaint, bounce and authentication evidence may hold sending. Placement slows pacing. Diagnostic age and automated engagement do not establish recovery.
|
||||
</Callout>
|
||||
|
||||
Getting back in requires requalifying, not just waiting: healthy authentication, no recent complaints or hard-bounce spikes, and spam placement back to a low level. Return is gradual, not a jump back to the old ceiling.
|
||||
@@ -263,7 +249,7 @@ The standing follows the address, not the connection. Removing a mailbox from th
|
||||
|
||||
## Graduating to cold sending
|
||||
|
||||
Warmup ending does not mean full cold volume the next day. A mailbox joining a campaign starts at a cold volume set by how long it warmed and adds `5` a day from there. See [Easing out of warmup](/guides/campaigns/) for the bands.
|
||||
Diagnostic-history mailboxes start cold sending at a conservative ceiling of five/day. Synthetic age never increases it. Recent confirmed human replies to actual campaign recipients permit bounded increases, subject to calendar days and negative feedback. Missing evidence leaves readiness/full-cap dates unknown. This product policy is not a provider-approved safe volume. See [campaign pacing](/guides/campaigns/).
|
||||
|
||||
## Recommended posture
|
||||
|
||||
|
||||
@@ -105,6 +105,7 @@ Some things belong to an instance rather than to a workspace, so they are not ap
|
||||
| Pending warmup verification | Mail waiting for this instance's local or cloud warmup check is not exported. The destination re-syncs it from the provider and applies its own verification |
|
||||
| Raw warmup receipt provenance | `warmup_received` observations, first-folder/auth header provenance and provider thread handles are source-instance evidence and are explicitly excluded. Aggregated warmup placement history travels, but raw receipt provenance does not |
|
||||
| Warmup dispatch and continuation authority | Parent task/receipt/message handles, lineage authority, queue revisions, worker IDs, dispatch nonces and native result state reset. Imported historical diagnostic metadata and tokens do not authorize automatic continuation on the destination |
|
||||
| Provider-attempt history | Immutable attempted timestamps and recipient counts travel in the sending group, retaining restrictive daily/rolling evidence after a capacity refund. Historical attempt nonces never authorize a new executor command |
|
||||
| Warmup pool membership | Pools are shared across every workspace on an instance, so membership is re-earned rather than asserted by a file |
|
||||
| Domain authentication timings | The verdict travels (public DNS reads the same anywhere), but the destination re-checks before it can stop any sending, so a mailbox is never blocked on an observation the new instance never made |
|
||||
| Risk and review status | A workspace's abuse posture is one platform's verdict about a tenant on its own infrastructure, reached from evidence the destination never saw. An archive can neither carry a restriction nor clear one |
|
||||
|
||||
@@ -156,6 +156,28 @@ func (h *Handler) CloudLinkResume(c *gin.Context) {
|
||||
h.cloudLinkLifecycle(c, "resume", models.AuditActionResume)
|
||||
}
|
||||
|
||||
func (h *Handler) CloudLinkParticipation(c *gin.Context) {
|
||||
if !h.cloudLinkReady(c) {
|
||||
return
|
||||
}
|
||||
id, orgID, ok := cloudLinkAccountID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var p models.DiagnosticParticipation
|
||||
if err := c.ShouldBindJSON(&p); err != nil {
|
||||
errx.JSON(c, errx.InvalidBody(err))
|
||||
return
|
||||
}
|
||||
row, xerr := h.CloudLinkService.SetParticipation(c.Request.Context(), *orgID, id, p)
|
||||
if xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
return
|
||||
}
|
||||
h.auditOrg(c, models.AuditActionUpdate, models.AuditEntityCloudLink, &id, nil, nil)
|
||||
c.JSON(http.StatusOK, row)
|
||||
}
|
||||
|
||||
func (h *Handler) cloudLinkLifecycle(c *gin.Context, action string, audit models.AuditAction) {
|
||||
if !h.cloudLinkReady(c) {
|
||||
return
|
||||
|
||||
@@ -1660,6 +1660,7 @@ func Run(
|
||||
poolLinkInstance.POST("/mailboxes", h.PoolLinkEnroll)
|
||||
poolLinkInstance.GET("/mailboxes/:remoteId", h.PoolLinkGetMailbox)
|
||||
poolLinkInstance.PATCH("/mailboxes/:remoteId", h.PoolLinkPatchMailbox)
|
||||
poolLinkInstance.PATCH("/mailboxes/:remoteId/participation", h.PoolLinkPatchMailbox)
|
||||
poolLinkInstance.DELETE("/mailboxes/:remoteId", h.PoolLinkUnenroll)
|
||||
// Cloud-managed mailboxes: consent on this deployment's OAuth app, brokered tokens.
|
||||
poolLinkInstance.POST("/oauth/start", h.PoolLinkOAuthStart)
|
||||
@@ -1700,6 +1701,7 @@ func Run(
|
||||
members.DELETE("/mailboxes/:id/enroll", h.CloudLinkUnenroll)
|
||||
members.POST("/mailboxes/:id/pause", h.CloudLinkPause)
|
||||
members.POST("/mailboxes/:id/resume", h.CloudLinkResume)
|
||||
members.PATCH("/mailboxes/:id/participation", h.CloudLinkParticipation)
|
||||
members.POST("/oauth/start", h.CloudLinkOAuthStart)
|
||||
members.POST("/oauth/finish", h.CloudLinkOAuthFinish)
|
||||
operator := cloudLink.Group("", m.AdminMiddleware(), middleware.RequireAdminPermission(models.AdminPermManageSettings))
|
||||
|
||||
@@ -809,7 +809,7 @@ func (s *analyticsService) coldRampInfo(ctx context.Context, email *models.Email
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
info := warmupramp.Notice(state.WarmupStartedAt, state.ColdRampStartedAt, state.Placements, email.CampaignLimit, time.Now())
|
||||
info := warmupramp.Notice(state.WarmupStartedAt, state.ColdRampStartedAt, state.Placements, email.CampaignLimit, time.Now(), state.ConfirmedReplies)
|
||||
if info == nil || info.Ceiling >= email.CampaignLimit {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package cloudlink
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
)
|
||||
|
||||
func TestParticipationUsesCapabilitySpecificRouteAndRefusesOldCloud(t *testing.T) {
|
||||
org, account, remote := uuid.New(), uuid.New(), uuid.New()
|
||||
calls := 0
|
||||
daily, rolling := 8, 11
|
||||
participation := models.DiagnosticParticipation{Mode: models.TestParticipationDiagnostic, Send: false, Receive: true, SharedDailyLimit: &daily, RollingRecipientLimit: &rolling}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
if r.Method != http.MethodPatch || r.URL.Path != "/v1/pool-link/instance/mailboxes/"+remote.String()+"/participation" {
|
||||
t.Error("participation sent to legacy silently ignored route", r.Method, r.URL.Path)
|
||||
}
|
||||
var patch models.PoolLinkMailboxPatch
|
||||
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil || patch.Participation == nil || patch.Participation.Send || !patch.Participation.Receive || patch.Participation.SharedDailyLimit == nil || *patch.Participation.SharedDailyLimit != daily || patch.Participation.RollingRecipientLimit == nil || *patch.Participation.RollingRecipientLimit != rolling {
|
||||
t.Error("typed directions/limits lost", patch, err)
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"code":"not_found","message":"old cloud route"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
s := &service{repo: &stubLinkRepo{link: &models.CloudLink{CloudURL: srv.URL, Token: "local-fixture"}, mailbox: &models.CloudLinkMailbox{EmailAccountID: account, RemoteID: remote}}, emails: stubEmails{account: &models.Email{ID: account, OrganizationID: &org}}, tokens: map[uuid.UUID]cachedToken{}}
|
||||
if row, xerr := s.SetParticipation(context.Background(), uuid.New(), account, participation); xerr == nil || row != nil || calls != 0 {
|
||||
t.Fatal("cross-tenant participation reached Cloud", row, xerr, calls)
|
||||
}
|
||||
if row, xerr := s.SetParticipation(context.Background(), org, account, participation); xerr == nil || row != nil || calls != 1 {
|
||||
t.Fatal("old Cloud silently accepted unsupported controls", row, xerr, calls)
|
||||
}
|
||||
participation.Mode = models.TestParticipationOff
|
||||
if row, xerr := s.SetParticipation(context.Background(), org, account, participation); xerr == nil || row != nil || calls != 1 {
|
||||
t.Fatal("off with receiving enabled accepted", row, xerr, calls)
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,7 @@ type Service interface {
|
||||
// alike, without ever calling back into the email service.
|
||||
RevokeForDelete(ctx context.Context, orgID, accountID uuid.UUID) *errx.Error
|
||||
SetLifecycle(ctx context.Context, orgID, accountID uuid.UUID, action string) (*models.CloudLinkMailboxRow, *errx.Error)
|
||||
SetParticipation(ctx context.Context, orgID, accountID uuid.UUID, participation models.DiagnosticParticipation) (*models.CloudLinkMailboxRow, *errx.Error)
|
||||
|
||||
// Root redirects Warmbly Cloud serves for this instance (redirects.go).
|
||||
RedirectOffer(ctx context.Context) (*models.PoolLinkRedirectOffer, bool)
|
||||
@@ -705,3 +706,29 @@ func (s *service) SetLifecycle(ctx context.Context, orgID, accountID uuid.UUID,
|
||||
s.recordStanding(ctx, accountID, state.Health, true)
|
||||
return s.row(ctx, orgID, accountID)
|
||||
}
|
||||
|
||||
func (s *service) SetParticipation(ctx context.Context, orgID, accountID uuid.UUID, participation models.DiagnosticParticipation) (*models.CloudLinkMailboxRow, *errx.Error) {
|
||||
if !participation.Valid() {
|
||||
return nil, errx.ErrInvalid
|
||||
}
|
||||
l, xerr := s.link(ctx)
|
||||
if xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
if _, xerr = s.ownedAccount(ctx, orgID, accountID); xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
m, err := s.repo.GetByAccount(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
if m == nil || m.EnrollmentState == "pending_remove" {
|
||||
return nil, errx.ErrNotFound
|
||||
}
|
||||
var state models.PoolLinkMailboxState
|
||||
if xerr = s.clientFor(l).do(ctx, http.MethodPatch, "/instance/mailboxes/"+m.RemoteID.String()+"/participation", models.PoolLinkMailboxPatch{Participation: &participation}, &state); xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
s.recordStanding(ctx, accountID, state.Health, true)
|
||||
return s.row(ctx, orgID, accountID)
|
||||
}
|
||||
|
||||
@@ -33,14 +33,6 @@ func (s *JobsService) HandleFlagsAdd(ctx context.Context, e *models.JobEventFlag
|
||||
}); err != nil {
|
||||
return fmt.Errorf("hold warmup spam move: %w", err)
|
||||
}
|
||||
case containsTrashFlag(e.Flags) && warmupDeletionCounts(rec, time.Now()):
|
||||
// Gmail reports Delete as gaining the TRASH label and only
|
||||
// reports the message gone when Trash is emptied, weeks later.
|
||||
// The label is the owner's act, so it is judged here, on the
|
||||
// same freshness rule as a removal; the later purge is then
|
||||
// outside the window and reads as housekeeping.
|
||||
hHarmer, _ := s.WarmupService.RecordTampering(ctx, e.EmailID, rec.MessageID, "deletion")
|
||||
s.markRiskBandFromWarmupHealth(ctx, e.EmailID, hHarmer)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -61,11 +61,6 @@ func (s *JobsService) HandleWarmupRemovalChecked(ctx context.Context, e *models.
|
||||
withdraw = true
|
||||
case models.WarmupRemovalTrashed, models.WarmupRemovalGone:
|
||||
if !e.Recheck {
|
||||
health, xerr := s.WarmupService.RecordTampering(ctx, e.EmailID, e.RFCMessageID, "deletion")
|
||||
if xerr != nil {
|
||||
return fmt.Errorf("record warmup strike: %w", xerr)
|
||||
}
|
||||
s.markRiskBandFromWarmupHealth(ctx, e.EmailID, health)
|
||||
return nil
|
||||
}
|
||||
if s.WarmupRepo == nil {
|
||||
|
||||
@@ -172,8 +172,8 @@ func TestRemovalCheckedJudgesOnWhereTheMessageIs(t *testing.T) {
|
||||
verified int
|
||||
}{
|
||||
{"moved to another folder", models.WarmupRemovalPresent, false, false, []string{"withdraw:deletion"}, 0},
|
||||
{"in the trash", models.WarmupRemovalTrashed, false, false, []string{"deletion"}, 0},
|
||||
{"gone for good", models.WarmupRemovalGone, false, false, []string{"deletion"}, 0},
|
||||
{"in the trash", models.WarmupRemovalTrashed, false, false, nil, 0},
|
||||
{"gone for good", models.WarmupRemovalGone, false, false, nil, 0},
|
||||
{"a fresh search that cannot tell charges nothing", models.WarmupRemovalUnknown, false, false, nil, 0},
|
||||
{"recheck: still in the mailbox", models.WarmupRemovalPresent, true, false, []string{"withdraw:deletion"}, 0},
|
||||
{"recheck: in the trash confirms without a new strike", models.WarmupRemovalTrashed, true, false, nil, 1},
|
||||
@@ -206,14 +206,13 @@ func TestRemovalCheckedJudgesOnWhereTheMessageIs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A strike the service could not record is redelivered, not acked.
|
||||
func TestRemovalCheckedRedeliversAFailedStrike(t *testing.T) {
|
||||
func TestRemovalCheckedDoesNotChargeRecipientWithdrawal(t *testing.T) {
|
||||
s, svc := retentionService(nil)
|
||||
svc.fail = true
|
||||
if err := s.HandleWarmupRemovalChecked(context.Background(), &models.JobEventWarmupRemovalChecked{
|
||||
UserID: uuid.New(), EmailID: uuid.New(), RFCMessageID: "<m@example.test>", Outcome: models.WarmupRemovalTrashed,
|
||||
}); err == nil {
|
||||
t.Fatal("a failed strike was acked")
|
||||
}); err != nil || len(svc.strikes) != 0 {
|
||||
t.Fatal("recipient withdrawal was penalized", err, svc.strikes)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,12 +30,7 @@ const (
|
||||
spamMoveNoOwnerTrace = "no_owner_activity"
|
||||
)
|
||||
|
||||
// attributeSpamMove decides who moved a received warmup email into spam. No
|
||||
// provider says, so the verdict rests on what the pool and the mailbox show:
|
||||
// the provider when other workspaces saw the same sender junked or the move
|
||||
// came straight after arrival with nobody there, the owner when they were
|
||||
// active in the mailbox around it or keep junking pool mail nobody else does,
|
||||
// and nobody otherwise. Only the owner is charged.
|
||||
// Attribution is inferred, not provider proof; recipient withdrawal is not tampering.
|
||||
func attributeSpamMove(m repository.WarmupSpamMove, ev repository.WarmupSpamMoveEvidence) (string, []string) {
|
||||
quick := m.ObservedAt.Sub(m.ReceivedAt) < time.Duration(config.WarmupSpamMoveQuickMinutes)*time.Minute
|
||||
switch {
|
||||
@@ -113,11 +108,6 @@ func (s *JobsService) attributeOneSpamMove(ctx context.Context, m repository.War
|
||||
return fmt.Errorf("record warmup spam complaint: %w", xerr)
|
||||
}
|
||||
s.markRiskBandFromWarmupHealth(ctx, m.SenderAccountID, hSender)
|
||||
hOwner, xerr := s.WarmupService.RecordTampering(ctx, m.EmailAccountID, m.MessageID, "spam_flag")
|
||||
if xerr != nil {
|
||||
return fmt.Errorf("record warmup spam strike: %w", xerr)
|
||||
}
|
||||
s.markRiskBandFromWarmupHealth(ctx, m.EmailAccountID, hOwner)
|
||||
} else {
|
||||
// The provider junked the sender's mail, or may have: a placement
|
||||
// reading against the sender, which only ever slows it down.
|
||||
|
||||
@@ -147,7 +147,7 @@ func TestAttributeSpamMovesAppliesTheVerdict(t *testing.T) {
|
||||
want []string
|
||||
}{
|
||||
{name: "owner", move: spamMoveAfter(6 * time.Hour), ev: owner,
|
||||
want: []string{"fix:owner", "sender:user_complaint", "strike:spam_flag", "complete"}},
|
||||
want: []string{"fix:owner", "sender:user_complaint", "complete"}},
|
||||
{name: "provider on arrival", move: spamMoveAfter(time.Minute),
|
||||
want: []string{"fix:provider", "sender:spam_placement", "complete"}},
|
||||
{name: "unattributed", move: spamMoveAfter(6 * time.Hour), ev: repository.WarmupSpamMoveEvidence{OwnerActiveRecently: true, PatternSenders: 1},
|
||||
|
||||
@@ -771,6 +771,10 @@ var Tables = []Table{
|
||||
// The handle belongs to the source instance's queue.
|
||||
ResetOnImport: []string{"cloud_task_name", "send_executor_nonce", "send_executor_worker", "send_executor_started_at", "send_executor_result"},
|
||||
},
|
||||
{
|
||||
Name: "outbound_attempts", Group: models.OrgDataGroupSending,
|
||||
Scope: `email_account_id IN (SELECT id FROM email_accounts WHERE organization_id = $1)`,
|
||||
},
|
||||
{
|
||||
Name: "send_recovery_resolutions", Group: models.OrgDataGroupSending,
|
||||
Scope: scopeOrg,
|
||||
|
||||
@@ -587,6 +587,7 @@ func (s *service) state(ctx context.Context, inst *models.PoolLinkInstance, m *m
|
||||
return nil, xerr
|
||||
}
|
||||
st := &models.PoolLinkMailboxState{
|
||||
Participation: &models.DiagnosticParticipation{Mode: models.TestParticipationLegacy, Send: acc.TestSendingAllowed(), Receive: acc.TestReceivingAllowed(), SharedDailyLimit: acc.SharedDailyLimit, RollingRecipientLimit: acc.RollingRecipientLimit},
|
||||
RemoteID: m.RemoteID,
|
||||
EmailAccountID: acc.ID,
|
||||
Email: acc.Email,
|
||||
@@ -601,6 +602,9 @@ func (s *service) state(ctx context.Context, inst *models.PoolLinkInstance, m *m
|
||||
StartTime: acc.WarmupStartTime, EndTime: acc.WarmupEndTime, Days: acc.WarmupDays, Timezone: acc.ClockTimezone(),
|
||||
},
|
||||
}
|
||||
if acc.TestMode != nil {
|
||||
st.Participation.Mode = *acc.TestMode
|
||||
}
|
||||
if s.analytics != nil {
|
||||
// Detail carries the partner cap, so a target no partner can meet is never shown as one.
|
||||
status, xerr := s.analytics.GetAccountStatusDetail(ctx, inst.OrganizationID, acc.ID)
|
||||
@@ -631,6 +635,9 @@ func (s *service) state(ctx context.Context, inst *models.PoolLinkInstance, m *m
|
||||
}
|
||||
|
||||
func (s *service) PatchMailbox(ctx context.Context, inst *models.PoolLinkInstance, remoteID uuid.UUID, patch models.PoolLinkMailboxPatch) (*models.PoolLinkMailboxState, *errx.Error) {
|
||||
if patch.Participation != nil && !patch.Participation.Valid() {
|
||||
return nil, errx.ErrInvalid
|
||||
}
|
||||
if r, ok := s.repo.(repository.PoolLinkManagedRepository); ok {
|
||||
if !managedOperationLocked(ctx) {
|
||||
var out *models.PoolLinkMailboxState
|
||||
@@ -681,6 +688,18 @@ func (s *service) PatchMailbox(ctx context.Context, inst *models.PoolLinkInstanc
|
||||
if patch.Warmup != nil {
|
||||
s.applyWarmupSettings(ctx, inst.OrganizationID, userID, m.EmailAccountID, *patch.Warmup)
|
||||
}
|
||||
if p := patch.Participation; p != nil {
|
||||
upd := &models.UpdateEmail{TestMode: &p.Mode, TestSendEnabled: &p.Send, TestReceiveEnabled: &p.Receive, SharedDailyLimit: p.SharedDailyLimit, RollingRecipientLimit: p.RollingRecipientLimit}
|
||||
if p.Send {
|
||||
upd.Warmup = &p.Send
|
||||
}
|
||||
if _, xerr := s.emailSvc.Update(ctx, inst.OrganizationID.String(), userID, m.EmailAccountID.String(), upd); xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
if p.Send && s.scheduler != nil {
|
||||
_ = s.scheduler.EnsureWarmupScheduled(ctx, m.EmailAccountID)
|
||||
}
|
||||
}
|
||||
switch patch.Lifecycle {
|
||||
case "pause", "resume":
|
||||
if _, xerr := s.emailSvc.SetWarmupLifecycle(ctx, inst.OrganizationID.String(), m.EmailAccountID.String(), patch.Lifecycle); xerr != nil {
|
||||
|
||||
@@ -10,26 +10,12 @@ const (
|
||||
// ColdRampIncrement is how much a graduating mailbox may add per clean day.
|
||||
ColdRampIncrement = 5
|
||||
|
||||
// Starting volumes by warmup maturity, following the documented cold
|
||||
// posture: a recently connected mailbox belongs near 10-20/day.
|
||||
coldStartUnproven = 5
|
||||
coldStartWarmed = 10
|
||||
coldStartMature = 20
|
||||
|
||||
coldWarmedDays = 7
|
||||
coldMatureDays = 14
|
||||
coldStart = 5
|
||||
)
|
||||
|
||||
// ColdStart is the cold volume a mailbox graduates at, from how long it warmed.
|
||||
func ColdStart(warmupDays int) int {
|
||||
switch {
|
||||
case warmupDays >= coldMatureDays:
|
||||
return coldStartMature
|
||||
case warmupDays >= coldWarmedDays:
|
||||
return coldStartWarmed
|
||||
default:
|
||||
return coldStartUnproven
|
||||
}
|
||||
// ColdStart is conservative pacing, not a synthetic-age readiness verdict.
|
||||
func ColdStart(_ int) int {
|
||||
return coldStart
|
||||
}
|
||||
|
||||
// ColdCeiling is a graduating mailbox's cold cap for today: its starting volume
|
||||
@@ -37,10 +23,14 @@ func ColdStart(warmupDays int) int {
|
||||
// the mailbox's own cap. Placements freeze the climb through the same Days()
|
||||
// union the warmup ramp uses. A zero rampStart means it has not sent cold mail
|
||||
// yet, so it gets its starting volume.
|
||||
func ColdCeiling(warmupDays int, rampStart time.Time, placements []time.Time, now time.Time, mailboxCap int) int {
|
||||
func ColdCeiling(warmupDays int, rampStart time.Time, placements []time.Time, now time.Time, mailboxCap int, confirmedReplies ...int) int {
|
||||
ceiling := ColdStart(warmupDays)
|
||||
replies := 0
|
||||
if len(confirmedReplies) > 0 {
|
||||
replies = max(0, confirmedReplies[0])
|
||||
}
|
||||
if !rampStart.IsZero() {
|
||||
ceiling += Days(rampStart, placements, now, FreezeWindow) * ColdRampIncrement
|
||||
ceiling += min(replies, Days(rampStart, placements, now, FreezeWindow)*ColdRampIncrement)
|
||||
}
|
||||
if ceiling > mailboxCap {
|
||||
return mailboxCap
|
||||
@@ -53,7 +43,7 @@ func ColdCeiling(warmupDays int, rampStart time.Time, placements []time.Time, no
|
||||
// before the two meet, and whether a placement is pausing the climb. One
|
||||
// builder, so the two surfaces cannot round differently. Nil when the
|
||||
// mailbox never warmed.
|
||||
func Notice(warmupStartedAt, coldRampStartedAt *time.Time, placements []time.Time, mailboxCap int, now time.Time) *models.ColdRampInfo {
|
||||
func Notice(warmupStartedAt, coldRampStartedAt *time.Time, placements []time.Time, mailboxCap int, now time.Time, confirmedReplies ...int) *models.ColdRampInfo {
|
||||
if warmupStartedAt == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -65,11 +55,10 @@ func Notice(warmupStartedAt, coldRampStartedAt *time.Time, placements []time.Tim
|
||||
if coldRampStartedAt != nil {
|
||||
rampStart = *coldRampStartedAt
|
||||
}
|
||||
ceiling := ColdCeiling(warmupDays, rampStart, placements, now, mailboxCap)
|
||||
left := max(0, mailboxCap-ceiling)
|
||||
days := left / ColdRampIncrement
|
||||
if left%ColdRampIncrement != 0 {
|
||||
days++
|
||||
ceiling := ColdCeiling(warmupDays, rampStart, placements, now, mailboxCap, confirmedReplies...)
|
||||
days := 0
|
||||
if ceiling < mailboxCap {
|
||||
days = -1
|
||||
}
|
||||
return &models.ColdRampInfo{
|
||||
Ceiling: ceiling,
|
||||
|
||||
@@ -5,11 +5,11 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestColdStartBandsOnWarmupMaturity(t *testing.T) {
|
||||
func TestColdStartDoesNotRewardSyntheticAge(t *testing.T) {
|
||||
for _, tt := range []struct{ days, want int }{
|
||||
{0, coldStartUnproven}, {6, coldStartUnproven},
|
||||
{7, coldStartWarmed}, {13, coldStartWarmed},
|
||||
{14, coldStartMature}, {90, coldStartMature},
|
||||
{0, 5}, {6, 5},
|
||||
{7, 5}, {13, 5},
|
||||
{14, 5}, {90, 5},
|
||||
} {
|
||||
if got := ColdStart(tt.days); got != tt.want {
|
||||
t.Errorf("ColdStart(%d) = %d, want %d", tt.days, got, tt.want)
|
||||
@@ -23,20 +23,20 @@ func TestColdCeilingClimbsAndClamps(t *testing.T) {
|
||||
|
||||
// A mature mailbox on its first cold day: its starting volume, not the cap.
|
||||
// This is the overnight 40 -> 50 jump the gate exists to stop.
|
||||
if got := ColdCeiling(30, time.Time{}, nil, at(0), 50); got != 20 {
|
||||
t.Errorf("first cold day = %d, want the 20 start", got)
|
||||
if got := ColdCeiling(30, time.Time{}, nil, at(0), 50); got != 5 {
|
||||
t.Errorf("first cold day = %d, want the conservative start", got)
|
||||
}
|
||||
if got := ColdCeiling(30, start, nil, at(0), 50); got != 20 {
|
||||
t.Errorf("day 0 = %d, want 20", got)
|
||||
if got := ColdCeiling(30, start, nil, at(0), 50); got != 5 {
|
||||
t.Errorf("day 0 = %d, want 5", got)
|
||||
}
|
||||
if got := ColdCeiling(30, start, nil, at(2), 50); got != 30 {
|
||||
t.Errorf("day 2 = %d, want 20 + 2*5", got)
|
||||
if got := ColdCeiling(30, start, nil, at(2), 50, 100); got != 15 {
|
||||
t.Errorf("day 2 with actual replies = %d, want 5 + 2*5", got)
|
||||
}
|
||||
// Clamped to the mailbox's own cap, never above it.
|
||||
if got := ColdCeiling(30, start, nil, at(90), 50); got != 50 {
|
||||
if got := ColdCeiling(30, start, nil, at(90), 50, 100); got != 50 {
|
||||
t.Errorf("day 90 = %d, want the cap of 50", got)
|
||||
}
|
||||
if got := ColdCeiling(30, start, nil, at(90), 12); got != 12 {
|
||||
if got := ColdCeiling(30, start, nil, at(90), 12, 100); got != 12 {
|
||||
t.Errorf("a low mailbox cap must still bind: got %d, want 12", got)
|
||||
}
|
||||
// An unproven mailbox starts lower and takes longer to arrive.
|
||||
@@ -50,8 +50,8 @@ func TestColdCeilingFreezesOnPlacement(t *testing.T) {
|
||||
at := func(d float64) time.Time {
|
||||
return start.Add(time.Duration(d * float64(24*time.Hour)))
|
||||
}
|
||||
clean := ColdCeiling(30, start, nil, at(6), 50)
|
||||
held := ColdCeiling(30, start, []time.Time{at(4)}, at(6), 50)
|
||||
clean := ColdCeiling(30, start, nil, at(6), 50, 100)
|
||||
held := ColdCeiling(30, start, []time.Time{at(4)}, at(6), 50, 100)
|
||||
if held >= clean {
|
||||
t.Errorf("a placement did not hold the cold ramp: held %d vs clean %d", held, clean)
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func TestColdHeldUntilMatchesWhatTheCeilingCounts(t *testing.T) {
|
||||
if got := ColdHeldUntil(rampStart, after, now, freeze); got == nil {
|
||||
t.Error("a placement during the ramp is not reported as holding it")
|
||||
}
|
||||
if ColdCeiling(30, rampStart, after, now, 50) >= ColdCeiling(30, rampStart, nil, now, 50) {
|
||||
if ColdCeiling(30, rampStart, after, now, 50, 100) >= ColdCeiling(30, rampStart, nil, now, 50, 100) {
|
||||
t.Error("a placement during the ramp did not lower the ceiling")
|
||||
}
|
||||
|
||||
@@ -96,3 +96,17 @@ func TestColdHeldUntilMatchesWhatTheCeilingCounts(t *testing.T) {
|
||||
t.Errorf("an unanchored mailbox reported a hold: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestColdReadinessRemainsUnknownWithoutFreshHumanReplies(t *testing.T) {
|
||||
now := time.Now()
|
||||
start := now.AddDate(0, 0, -90)
|
||||
if got := ColdCeiling(90, start, nil, now, 50); got != 5 {
|
||||
t.Fatal("age fabricated readiness", got)
|
||||
}
|
||||
if got := ColdCeiling(90, start, nil, now, 50, 2); got != 7 {
|
||||
t.Fatal("ceiling exceeded actual reply evidence", got)
|
||||
}
|
||||
if info := Notice(&start, &start, nil, 50, now, 2); info == nil || info.DaysToFullCap != -1 {
|
||||
t.Fatal("invented full-speed date", info)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ func (w *WMail) endTick(stats *tickStats) {
|
||||
// events. mapKey is the id the provider reports on later remove/flag events
|
||||
// (RFC Message-ID for IMAP, provider message id for Gmail and Graph).
|
||||
func (w *WMail) storeNew(ctx context.Context, msg *models.EmailMessageData, data *models.EmailMessageStoreData, mapKey string) error {
|
||||
w.verifyDiagnostic(ctx, msg)
|
||||
w.verifyDiagnostic(ctx, msg, data)
|
||||
evidence := models.EvidenceFromFlags(data.Flags)
|
||||
if evidence.Source != "unavailable" {
|
||||
evidence.ObservedAt = time.Now().UTC()
|
||||
|
||||
@@ -12,11 +12,19 @@ import (
|
||||
"github.com/warmbly/warmbly/internal/repository"
|
||||
)
|
||||
|
||||
func (w *WMail) verifyDiagnostic(ctx context.Context, msg *models.EmailMessageData) {
|
||||
if w.ExecutorID == uuid.Nil || w.EmailType == models.InboxProviderSMTPIMAP {
|
||||
type diagnosticRetry struct {
|
||||
token uuid.UUID
|
||||
messageID, providerID, folder string
|
||||
validity, uid uint32
|
||||
next, expires time.Time
|
||||
attempts int
|
||||
}
|
||||
|
||||
func (w *WMail) verifyDiagnostic(ctx context.Context, msg *models.EmailMessageData, data *models.EmailMessageStoreData) {
|
||||
if w.ExecutorID == uuid.Nil {
|
||||
return
|
||||
}
|
||||
authority, ok := w.SyncContext.(repository.DiagnosticAuthAuthority)
|
||||
_, ok := w.SyncContext.(repository.DiagnosticAuthAuthority)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -30,30 +38,83 @@ func (w *WMail) verifyDiagnostic(ctx context.Context, msg *models.EmailMessageDa
|
||||
if token == uuid.Nil {
|
||||
return
|
||||
}
|
||||
retry := diagnosticRetry{token: token, messageID: msg.MessageID, providerID: msg.GmailID, folder: data.FolderPath, validity: data.Mailbox, uid: msg.UID, attempts: 1, next: time.Now().Add(time.Minute), expires: time.Now().Add(15 * time.Minute)}
|
||||
// IMAP sync owns the selected-folder mutex until the pass finishes.
|
||||
if w.EmailType == models.InboxProviderSMTPIMAP {
|
||||
retry.attempts = 0
|
||||
retry.next = time.Now()
|
||||
}
|
||||
if w.EmailType == models.InboxProviderSMTPIMAP || w.runDiagnostic(ctx, retry) {
|
||||
if w.diagnosticRetries == nil {
|
||||
w.diagnosticRetries = make(map[uuid.UUID]diagnosticRetry)
|
||||
}
|
||||
if len(w.diagnosticRetries) < 64 {
|
||||
w.diagnosticRetries[token] = retry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WMail) retryDiagnostics(ctx context.Context) {
|
||||
for token, retry := range w.diagnosticRetries {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if time.Now().After(retry.expires) || retry.attempts >= 3 {
|
||||
delete(w.diagnosticRetries, token)
|
||||
continue
|
||||
}
|
||||
if time.Now().Before(retry.next) {
|
||||
continue
|
||||
}
|
||||
retry.attempts++
|
||||
retry.next = time.Now().Add(2 * time.Minute)
|
||||
if !w.runDiagnostic(ctx, retry) {
|
||||
delete(w.diagnosticRetries, token)
|
||||
} else {
|
||||
w.diagnosticRetries[token] = retry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WMail) runDiagnostic(ctx context.Context, retry diagnosticRetry) bool {
|
||||
authority, ok := w.SyncContext.(repository.DiagnosticAuthAuthority)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
request := models.DiagnosticAuthRequest{Token: token, MailboxID: w.ID, WorkerID: w.ExecutorID, MessageID: msg.MessageID}
|
||||
request := models.DiagnosticAuthRequest{Token: retry.token, MailboxID: w.ID, WorkerID: w.ExecutorID, MessageID: retry.messageID}
|
||||
grant, err := authority.DiagnosticAuth(ctx, request)
|
||||
if err != nil || grant == nil {
|
||||
return
|
||||
return true
|
||||
}
|
||||
var raw []byte
|
||||
switch w.EmailType {
|
||||
case models.InboxProviderGoogle:
|
||||
if w.GoogleData != nil && w.GoogleData.Client != nil {
|
||||
raw, err = w.GoogleData.Client.DiagnosticRawMessage(ctx, msg.GmailID, diagnosticauth.MaxMIMEBytes)
|
||||
raw, err = w.GoogleData.Client.DiagnosticRawMessage(ctx, retry.providerID, diagnosticauth.MaxMIMEBytes)
|
||||
}
|
||||
case models.InboxProviderOutlook:
|
||||
if w.GraphData != nil && w.GraphData.Client != nil {
|
||||
raw, err = w.GraphData.Client.DiagnosticRawMessage(ctx, msg.GmailID, diagnosticauth.MaxMIMEBytes)
|
||||
raw, err = w.GraphData.Client.DiagnosticRawMessage(ctx, retry.providerID, diagnosticauth.MaxMIMEBytes)
|
||||
}
|
||||
case models.InboxProviderSMTPIMAP:
|
||||
if w.SmtpImapData != nil {
|
||||
if client, ok := w.SmtpImapData.ImapClient.(interface {
|
||||
DiagnosticRawMessage(context.Context, string, uint32, uint32, int) ([]byte, error)
|
||||
}); ok {
|
||||
raw, err = client.DiagnosticRawMessage(ctx, retry.folder, retry.validity, retry.uid, diagnosticauth.MaxMIMEBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil || len(raw) == 0 {
|
||||
return
|
||||
clear(raw)
|
||||
return true
|
||||
}
|
||||
result := diagnosticauth.Verify(ctx, raw, *grant, nil)
|
||||
clear(raw)
|
||||
request.Nonce = grant.Nonce
|
||||
request.Result = &result
|
||||
_, _ = authority.DiagnosticAuth(ctx, request)
|
||||
_, err = authority.DiagnosticAuth(ctx, request)
|
||||
return err != nil || result.DKIM == "unknown"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package wmail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/warmbly/warmbly/internal/config"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/pkg/diagnosticauth"
|
||||
"github.com/warmbly/warmbly/internal/repository"
|
||||
)
|
||||
|
||||
type diagnosticAuthorityStub struct {
|
||||
repository.SyncContextRepository
|
||||
allow bool
|
||||
requests []models.DiagnosticAuthRequest
|
||||
}
|
||||
|
||||
func (a *diagnosticAuthorityStub) DiagnosticAuth(_ context.Context, req models.DiagnosticAuthRequest) (*models.DiagnosticAuthGrant, error) {
|
||||
a.requests = append(a.requests, req)
|
||||
if !a.allow {
|
||||
return nil, errors.New("send result not stamped")
|
||||
}
|
||||
if req.Result != nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &models.DiagnosticAuthGrant{Nonce: uuid.New(), MessageID: "probe@example.test", From: "sender@example.test", To: "recipient@example.test"}, nil
|
||||
}
|
||||
|
||||
type diagnosticIMAPStub struct {
|
||||
ImapConn
|
||||
calls int
|
||||
raw []byte
|
||||
}
|
||||
|
||||
func (c *diagnosticIMAPStub) DiagnosticRawMessage(ctx context.Context, folder string, validity, uid uint32, limit int) ([]byte, error) {
|
||||
if folder != "Diagnostic" || validity != 17 || uid != 4 || limit != diagnosticauth.MaxMIMEBytes || ctx.Err() != nil {
|
||||
return nil, errors.New("incorrect raw locator/bound")
|
||||
}
|
||||
c.calls++
|
||||
c.raw = []byte("From: sender@example.test\r\nTo: recipient@example.test\r\nMessage-ID: <probe@example.test>\r\n\r\nDiagnostic fixture\r\n")
|
||||
return c.raw, nil
|
||||
}
|
||||
|
||||
func TestDiagnosticRetryWaitsForSendResultAndDefersIMAPUntilSyncReleasesMutex(t *testing.T) {
|
||||
a, c := &diagnosticAuthorityStub{}, &diagnosticIMAPStub{}
|
||||
w := &WMail{ID: uuid.New(), ExecutorID: uuid.New(), EmailType: models.InboxProviderSMTPIMAP, SyncContext: a, SmtpImapData: &SmtpImapData{ImapClient: c}}
|
||||
token := uuid.New()
|
||||
w.verifyDiagnostic(t.Context(), &models.EmailMessageData{MessageID: "probe@example.test", UID: 4, Flags: []string{config.WarmupVerifyHeader + ":" + token.String()}}, &models.EmailMessageStoreData{FolderPath: "Diagnostic", Mailbox: 17})
|
||||
if c.calls != 0 || len(a.requests) != 0 || len(w.diagnosticRetries) != 1 {
|
||||
t.Fatal("retrieved raw while sync still owns mutex")
|
||||
}
|
||||
w.retryDiagnostics(t.Context())
|
||||
if c.calls != 0 || len(a.requests) != 1 {
|
||||
t.Fatal("denied authority retrieved MIME")
|
||||
}
|
||||
a.allow = true
|
||||
for attempt := 1; attempt < 3; attempt++ {
|
||||
retry := w.diagnosticRetries[token]
|
||||
retry.next = time.Time{}
|
||||
w.diagnosticRetries[token] = retry
|
||||
w.retryDiagnostics(t.Context())
|
||||
}
|
||||
if c.calls != 2 {
|
||||
t.Fatal("later exact result did not enable bounded retry", c.calls)
|
||||
}
|
||||
for _, b := range c.raw {
|
||||
if b != 0 {
|
||||
t.Fatal("worker retained raw MIME")
|
||||
}
|
||||
}
|
||||
w.retryDiagnostics(t.Context())
|
||||
if len(w.diagnosticRetries) != 0 || c.calls != 2 {
|
||||
t.Fatal("unknown retry was unbounded")
|
||||
}
|
||||
for _, req := range a.requests {
|
||||
if req.Token != token || req.MailboxID != w.ID || req.WorkerID != w.ExecutorID || req.MessageID != "probe@example.test" {
|
||||
t.Fatal("retry widened authorization context", req)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnosticRetryExpiresAndIgnoresOrdinaryMessages(t *testing.T) {
|
||||
a := &diagnosticAuthorityStub{allow: true}
|
||||
w := &WMail{ExecutorID: uuid.New(), SyncContext: a}
|
||||
w.verifyDiagnostic(t.Context(), &models.EmailMessageData{MessageID: "ordinary@example.test"}, &models.EmailMessageStoreData{})
|
||||
if len(a.requests) != 0 || len(w.diagnosticRetries) != 0 {
|
||||
t.Fatal("ordinary inbox message retrieved")
|
||||
}
|
||||
token := uuid.New()
|
||||
w.diagnosticRetries = map[uuid.UUID]diagnosticRetry{token: {expires: time.Now().Add(-time.Minute)}}
|
||||
w.retryDiagnostics(t.Context())
|
||||
if len(a.requests) != 0 || len(w.diagnosticRetries) != 0 {
|
||||
t.Fatal("expired retry executed")
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
)
|
||||
|
||||
func (w *WMail) SyncMail(ctx context.Context) *errx.MailError {
|
||||
w.retryDiagnostics(ctx)
|
||||
defer w.retryDiagnostics(ctx)
|
||||
switch w.EmailType {
|
||||
case models.InboxProviderGoogle:
|
||||
return w.SyncGoogle(ctx)
|
||||
|
||||
@@ -58,9 +58,10 @@ type SmtpImapData struct {
|
||||
}
|
||||
|
||||
type WMail struct {
|
||||
ExecutorID uuid.UUID
|
||||
UserID uuid.UUID
|
||||
ID uuid.UUID
|
||||
diagnosticRetries map[uuid.UUID]diagnosticRetry
|
||||
ExecutorID uuid.UUID
|
||||
UserID uuid.UUID
|
||||
ID uuid.UUID
|
||||
// OrgID scopes the organization-wide sync budget; nil for a legacy
|
||||
// personal mailbox.
|
||||
OrgID *uuid.UUID
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package imap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
goimap "github.com/emersion/go-imap/v2"
|
||||
"github.com/emersion/go-imap/v2/imapclient"
|
||||
)
|
||||
|
||||
func (c *Client) DiagnosticRawMessage(ctx context.Context, folder string, validity, uid uint32, limit int) ([]byte, error) {
|
||||
if folder == "" || validity == 0 || uid == 0 || limit <= 0 || limit > 1<<20 {
|
||||
return nil, errors.New("invalid diagnostic locator")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
for !c.mu.TryLock() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(10 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
defer c.mu.Unlock()
|
||||
for !c.lifecycle.TryRLock() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(10 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
defer c.lifecycle.RUnlock()
|
||||
client := c.client
|
||||
conn := c.conn
|
||||
if client == nil || conn == nil || (client.State() != goimap.ConnStateAuthenticated && client.State() != goimap.ConnStateSelected) {
|
||||
return nil, errors.New("diagnostic IMAP connection unavailable")
|
||||
}
|
||||
done := make(chan struct{})
|
||||
stop := context.AfterFunc(ctx, func() { _ = conn.Close(); close(done) })
|
||||
defer func() {
|
||||
if !stop() {
|
||||
<-done
|
||||
}
|
||||
}()
|
||||
defer c.begin()()
|
||||
selected, err := c.selectMailbox(c.qualifyMailboxLocked(folder), nil)
|
||||
if err != nil || selected.UIDValidity != validity {
|
||||
return nil, errors.New("diagnostic folder generation changed")
|
||||
}
|
||||
section := &goimap.FetchItemBodySection{Peek: true, Partial: &goimap.SectionPartial{Size: int64(limit + 1)}}
|
||||
cmd := client.Fetch(goimap.UIDSetNum(goimap.UID(uid)), &goimap.FetchOptions{UID: true, BodySection: []*goimap.FetchItemBodySection{section}})
|
||||
defer cmd.Close()
|
||||
var raw []byte
|
||||
for msg := cmd.Next(); msg != nil; msg = cmd.Next() {
|
||||
for item := msg.Next(); item != nil; item = msg.Next() {
|
||||
if part, ok := item.(imapclient.FetchItemDataBodySection); ok && part.Literal != nil {
|
||||
raw, err = io.ReadAll(io.LimitReader(part.Literal, int64(limit+1)))
|
||||
if err != nil || len(raw) > limit {
|
||||
clear(raw)
|
||||
_ = conn.Close()
|
||||
return nil, errors.New("diagnostic raw message exceeds limit or is unavailable")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err = cmd.Close(); err != nil || ctx.Err() != nil || len(raw) == 0 {
|
||||
clear(raw)
|
||||
return nil, errors.New("diagnostic raw message unavailable")
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package imap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDiagnosticRawIsExactBoundedPeekAndChecksFolderGeneration(t *testing.T) {
|
||||
c, wire := recordingServer(t, nil)
|
||||
if err := c.Connect(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appendMessage(t, c, "INBOX", "<exact@test>")
|
||||
c.mu.Lock()
|
||||
selected, err := c.selectMailbox("INBOX", nil)
|
||||
c.mu.Unlock()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if raw, err := c.DiagnosticRawMessage(t.Context(), "INBOX", selected.UIDValidity+1, 1, 1024); err == nil || raw != nil {
|
||||
t.Fatal("stale UID generation retrieved", err)
|
||||
}
|
||||
if len(wire.commands("FETCH")) != 0 {
|
||||
t.Fatal("stale locator fetched MIME")
|
||||
}
|
||||
raw, err := c.DiagnosticRawMessage(t.Context(), "INBOX", selected.UIDValidity, 1, 1024)
|
||||
if err != nil || !strings.Contains(string(raw), "Message-ID: <exact@test>") {
|
||||
t.Fatal("exact retrieval failed", err)
|
||||
}
|
||||
for _, cmd := range wire.commands("FETCH") {
|
||||
if !strings.Contains(cmd, "UID FETCH 1") || !strings.Contains(cmd, "BODY.PEEK[]<0.1025>") {
|
||||
t.Fatal("unbounded or nonpeek fetch", cmd)
|
||||
}
|
||||
}
|
||||
if raw, err = c.DiagnosticRawMessage(t.Context(), "INBOX", selected.UIDValidity, 1, 16); err == nil || raw != nil {
|
||||
t.Fatal("oversize MIME returned", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnosticRawMutexWaitHonorsContext(t *testing.T) {
|
||||
c := testServer(t, nil)
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond)
|
||||
defer cancel()
|
||||
start := time.Now()
|
||||
if _, err := c.DiagnosticRawMessage(ctx, "INBOX", 1, 1, 1024); err == nil || time.Since(start) > time.Second {
|
||||
t.Fatal("diagnostic wait exceeded context", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnosticRawLifecycleWaitHonorsContextWithoutReconnect(t *testing.T) {
|
||||
c, wire := recordingServer(t, nil)
|
||||
c.lifecycle.Lock()
|
||||
defer c.lifecycle.Unlock()
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond)
|
||||
defer cancel()
|
||||
start := time.Now()
|
||||
if raw, err := c.DiagnosticRawMessage(ctx, "INBOX", 1, 1, 1024); raw != nil || !errors.Is(err, context.DeadlineExceeded) || time.Since(start) > time.Second {
|
||||
t.Fatal("lifecycle wait escaped deadline", err)
|
||||
}
|
||||
if len(wire.commands("LOGIN")) != 0 || len(wire.commands("FETCH")) != 0 {
|
||||
t.Fatal("waiting diagnostic reconnected or retrieved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnosticRawDisconnectedDoesNotReconnect(t *testing.T) {
|
||||
c, wire := recordingServer(t, nil)
|
||||
start := time.Now()
|
||||
if raw, err := c.DiagnosticRawMessage(t.Context(), "INBOX", 1, 1, 1024); raw != nil || err == nil || time.Since(start) > time.Second {
|
||||
t.Fatal("disconnected diagnostic reconnected", err)
|
||||
}
|
||||
if len(wire.commands("LOGIN")) != 0 || len(wire.commands("FETCH")) != 0 {
|
||||
t.Fatal("disconnected diagnostic issued commands")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnosticRawMissingUIDRemainsUnknown(t *testing.T) {
|
||||
c := testServer(t, nil)
|
||||
if err := c.Connect(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.mu.Lock()
|
||||
selected, err := c.selectMailbox("INBOX", nil)
|
||||
c.mu.Unlock()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if raw, err := c.DiagnosticRawMessage(t.Context(), "INBOX", selected.UIDValidity, 99, 1024); raw != nil || err == nil {
|
||||
t.Fatal("missing message returned diagnostic bytes", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (SELECT 1 FROM outbound_attempts WHERE attempted_at>NOW()-INTERVAL '24 hours') THEN
|
||||
RAISE EXCEPTION 'recent provider attempts must be retained until their rolling rate window expires';
|
||||
END IF;
|
||||
END $$;
|
||||
ALTER TABLE diagnostic_auth_verifications DROP COLUMN attempts;
|
||||
DROP TABLE outbound_attempts;
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE outbound_attempts (
|
||||
nonce uuid PRIMARY KEY,
|
||||
task_id uuid REFERENCES tasks(id) ON DELETE SET NULL,
|
||||
email_account_id uuid NOT NULL REFERENCES email_accounts(id) ON DELETE CASCADE,
|
||||
provider text NOT NULL,
|
||||
attempted_at timestamptz NOT NULL,
|
||||
recipient_count integer NOT NULL CHECK (recipient_count > 0)
|
||||
);
|
||||
CREATE INDEX outbound_attempts_mailbox_time ON outbound_attempts(email_account_id,attempted_at);
|
||||
INSERT INTO outbound_attempts(nonce,task_id,email_account_id,provider,attempted_at,recipient_count)
|
||||
SELECT t.send_executor_nonce,t.id,t.email_account_id,ea.provider::text,t.send_executor_started_at,GREATEST(1,cardinality(t.send_recipients))
|
||||
FROM tasks t JOIN email_accounts ea ON ea.id=t.email_account_id
|
||||
WHERE t.send_executor_started_at IS NOT NULL AND t.send_executor_nonce IS NOT NULL;
|
||||
ALTER TABLE diagnostic_auth_verifications ADD COLUMN attempts integer NOT NULL DEFAULT 1 CHECK (attempts BETWEEN 1 AND 3);
|
||||
+33
-18
@@ -217,24 +217,25 @@ type PoolLinkWarmupDeliveryQuery struct {
|
||||
|
||||
// PoolLinkMailboxState is the per-mailbox view shown in both dashboards.
|
||||
type PoolLinkMailboxState struct {
|
||||
ConsentCompletedAt *time.Time `json:"consent_completed_at,omitempty"`
|
||||
RemoteID uuid.UUID `json:"remote_id"`
|
||||
EmailAccountID uuid.UUID `json:"email_account_id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
Status string `json:"status"`
|
||||
EnrolledAt time.Time `json:"enrolled_at"`
|
||||
Managed bool `json:"managed"`
|
||||
Warmup *WarmupStatusInfo `json:"warmup,omitempty"`
|
||||
Health *WarmupHealthInfo `json:"health,omitempty"`
|
||||
SentToday int `json:"sent_today"`
|
||||
Sent7d int `json:"sent_7d"`
|
||||
Replied7d int `json:"replied_7d"`
|
||||
SpamPlaced7d int `json:"spam_placed_7d"`
|
||||
Errors []AccountError `json:"errors,omitempty"`
|
||||
AuthState string `json:"auth_state"`
|
||||
Settings PoolLinkWarmupSettings `json:"settings"`
|
||||
Participation *DiagnosticParticipation `json:"participation,omitempty"`
|
||||
ConsentCompletedAt *time.Time `json:"consent_completed_at,omitempty"`
|
||||
RemoteID uuid.UUID `json:"remote_id"`
|
||||
EmailAccountID uuid.UUID `json:"email_account_id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
Status string `json:"status"`
|
||||
EnrolledAt time.Time `json:"enrolled_at"`
|
||||
Managed bool `json:"managed"`
|
||||
Warmup *WarmupStatusInfo `json:"warmup,omitempty"`
|
||||
Health *WarmupHealthInfo `json:"health,omitempty"`
|
||||
SentToday int `json:"sent_today"`
|
||||
Sent7d int `json:"sent_7d"`
|
||||
Replied7d int `json:"replied_7d"`
|
||||
SpamPlaced7d int `json:"spam_placed_7d"`
|
||||
Errors []AccountError `json:"errors,omitempty"`
|
||||
AuthState string `json:"auth_state"`
|
||||
Settings PoolLinkWarmupSettings `json:"settings"`
|
||||
}
|
||||
|
||||
// PoolLinkMailboxStanding is one enrolled mailbox's warmup standing, the
|
||||
@@ -246,6 +247,7 @@ type PoolLinkMailboxStanding struct {
|
||||
|
||||
// PoolLinkMailboxPatch updates a mailbox's ramp or lifecycle on the cloud.
|
||||
type PoolLinkMailboxPatch struct {
|
||||
Participation *DiagnosticParticipation `json:"participation,omitempty"`
|
||||
// Lifecycle is "pause", "resume" or empty.
|
||||
Lifecycle string `json:"lifecycle,omitempty"`
|
||||
Warmup *PoolLinkWarmupSettings `json:"warmup,omitempty"`
|
||||
@@ -253,6 +255,19 @@ type PoolLinkMailboxPatch struct {
|
||||
SMTPIMAP *SmtpImap `json:"smtp_imap,omitempty"`
|
||||
}
|
||||
|
||||
type DiagnosticParticipation struct {
|
||||
Mode TestParticipationMode `json:"mode"`
|
||||
Send bool `json:"send"`
|
||||
Receive bool `json:"receive"`
|
||||
SharedDailyLimit *int `json:"shared_daily_limit,omitempty"`
|
||||
RollingRecipientLimit *int `json:"rolling_recipient_limit,omitempty"`
|
||||
}
|
||||
|
||||
func (p DiagnosticParticipation) Valid() bool {
|
||||
return (p.Mode == TestParticipationDiagnostic || p.Mode == TestParticipationOff && !p.Send && !p.Receive) &&
|
||||
(p.SharedDailyLimit == nil || *p.SharedDailyLimit >= 0) && (p.RollingRecipientLimit == nil || *p.RollingRecipientLimit >= 0)
|
||||
}
|
||||
|
||||
// CloudLink is the self-hosted instance's single link row; Token is never serialized.
|
||||
type CloudLink struct {
|
||||
CloudURL string `json:"cloud_url"`
|
||||
|
||||
@@ -50,8 +50,9 @@ func (r *taskRepository) DiagnosticAuth(ctx context.Context, req models.Diagnost
|
||||
if req.Result == nil {
|
||||
grant.Nonce = uuid.New()
|
||||
err = tx.QueryRow(ctx, `INSERT INTO diagnostic_auth_verifications(task_id,email_account_id,worker_id,nonce,message_id)VALUES($1,$2,$3,$4,$5)
|
||||
ON CONFLICT(task_id,email_account_id) DO UPDATE SET worker_id=EXCLUDED.worker_id,nonce=EXCLUDED.nonce,message_id=EXCLUDED.message_id,authorized_at=NOW()
|
||||
WHERE diagnostic_auth_verifications.result IS NULL RETURNING nonce`, task, req.MailboxID, req.WorkerID, grant.Nonce, grant.MessageID).Scan(&grant.Nonce)
|
||||
ON CONFLICT(task_id,email_account_id) DO UPDATE SET worker_id=EXCLUDED.worker_id,nonce=EXCLUDED.nonce,message_id=EXCLUDED.message_id,authorized_at=NOW(),result=NULL,verified_at=NULL,attempts=diagnostic_auth_verifications.attempts+1
|
||||
WHERE diagnostic_auth_verifications.attempts<3 AND diagnostic_auth_verifications.authorized_at<NOW()-INTERVAL '30 seconds'
|
||||
AND (diagnostic_auth_verifications.result IS NULL OR diagnostic_auth_verifications.result->>'dkim'='unknown') RETURNING nonce`, task, req.MailboxID, req.WorkerID, grant.Nonce, grant.MessageID).Scan(&grant.Nonce)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -29,8 +29,12 @@ func TestLiveDiagnosticAuthBindsWorkerContextAndRetainsOnlyMinimalProof(t *testi
|
||||
exec(`UPDATE email_accounts SET send_as_email='unused-alias@example.test' WHERE id=$1`, f.sender)
|
||||
exec(`INSERT INTO warmup_pool_participants(pool_id,email_account_id)SELECT id,$1 FROM warmup_pools WHERE pool_type='free'`, f.recipient)
|
||||
exec(`INSERT INTO warmup_tasks(task_id,lineage_version,subject,scenario_version,rendering_version,max_turns)VALUES($1,1,'Diagnostic','diagnostic-v1','canonical-v1',1)`, f.task)
|
||||
exec(`INSERT INTO warmup_tokens(token,task_id,sender_account_id,recipient_account_id,sent_message_id)VALUES($1,$2,$3,$4,'<probe@example.test>')`, token, f.task, f.sender, f.recipient)
|
||||
exec(`INSERT INTO warmup_tokens(token,task_id,sender_account_id,recipient_account_id,sent_message_id)VALUES($1,$2,$3,$4,'')`, token, f.task, f.sender, f.recipient)
|
||||
request := models.DiagnosticAuthRequest{Token: token, MailboxID: f.recipient, WorkerID: worker, MessageID: "probe@example.test"}
|
||||
if grant, err := r.DiagnosticAuth(ctx, request); err == nil || grant != nil {
|
||||
t.Fatal("receipt-before-result authorized raw retrieval", grant, err)
|
||||
}
|
||||
exec(`UPDATE warmup_tokens SET sent_message_id='<probe@example.test>' WHERE token=$1`, token)
|
||||
for _, bad := range []models.DiagnosticAuthRequest{
|
||||
{Token: token, MailboxID: f.recipient, WorkerID: uuid.New(), MessageID: request.MessageID},
|
||||
{Token: token, MailboxID: f.sender, WorkerID: worker, MessageID: request.MessageID},
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestLiveReleased265AttemptUpgradePreservesLegacyConsentAndFailedAttempts(t *testing.T) {
|
||||
dsn := os.Getenv("WARMBLY_ATTEMPT_TEST_DB")
|
||||
if dsn == "" {
|
||||
t.Skip("WARMBLY_ATTEMPT_TEST_DB must name an empty dedicated scratch database")
|
||||
}
|
||||
source, err := iofs.New(os.DirFS("../infrastructure/db"), "migrations")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := migrate.NewWithSourceInstance("iofs", source, dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer m.Close()
|
||||
if _, _, err := m.Version(); !errors.Is(err, migrate.ErrNilVersion) {
|
||||
t.Fatal("requires empty scratch database")
|
||||
}
|
||||
if err = m.Migrate(265); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
exec := func(sql string, args ...any) {
|
||||
t.Helper()
|
||||
if _, err := pool.Exec(ctx, sql, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
user, org, mailbox, newMailbox, task, nonce := uuid.New(), uuid.New(), uuid.New(), uuid.New(), uuid.New(), uuid.New()
|
||||
exec(`INSERT INTO users(id,email,first_name,last_name)VALUES($1,'upgrade@example.test','Upgrade','Fixture')`, user)
|
||||
exec(`INSERT INTO organizations(id,name,slug,owner_user_id)VALUES($1,'Upgrade','upgrade-fixture',$2)`, org, user)
|
||||
exec(`INSERT INTO email_accounts(id,user_id,organization_id,email,name,signature_plain,signature_html,provider,status,campaign_limit,min_wait_time,timezone,warmup)VALUES($1,$2,$3,'legacy@example.test','Legacy','','','smtp_imap','active',50,600,'UTC',NOW()-INTERVAL '40 days')`, mailbox, user, org)
|
||||
if err = m.Migrate(272); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var legacy bool
|
||||
if err = pool.QueryRow(ctx, `SELECT test_mode IS NULL AND NOT test_send_enabled AND NOT test_receive_enabled AND warmup IS NOT NULL FROM email_accounts WHERE id=$1`, mailbox).Scan(&legacy); err != nil || !legacy {
|
||||
t.Fatal("old consent or history changed", err)
|
||||
}
|
||||
exec(`INSERT INTO email_accounts(id,user_id,organization_id,email,name,signature_plain,signature_html,provider,status,campaign_limit,min_wait_time,timezone)VALUES($1,$2,$3,'new@example.test','New','','','smtp_imap','active',50,600,'UTC')`, newMailbox, user, org)
|
||||
exec(`INSERT INTO tasks(id,task_type,email_account_id,status,message_id,send_executor_nonce,send_executor_started_at,send_recipients,send_released_at,send_result_state,send_result_applied_at)VALUES($1,'email',$2,'failed','failed@example.test',$3,NOW(),ARRAY['r@example.test','r@example.test','c@example.test'],NOW(),'failed',NOW())`, task, mailbox, nonce)
|
||||
if err = m.Up(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertLedger := func() {
|
||||
t.Helper()
|
||||
var count, recipients int
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*),COALESCE(SUM(recipient_count),0) FROM outbound_attempts WHERE nonce=$1 AND task_id=$2 AND provider='smtp_imap'`, nonce, task).Scan(&count, &recipients); err != nil || count != 1 || recipients != 3 {
|
||||
t.Fatal("failed/refunded attempt not preserved exactly once", count, recipients, err)
|
||||
}
|
||||
}
|
||||
assertLedger()
|
||||
var off bool
|
||||
if err = pool.QueryRow(ctx, `SELECT test_mode='off' AND NOT test_send_enabled AND NOT test_receive_enabled FROM email_accounts WHERE id=$1`, newMailbox).Scan(&off); err != nil || !off {
|
||||
t.Fatal("new mailbox inferred consent", err)
|
||||
}
|
||||
if err = m.Up(); !errors.Is(err, migrate.ErrNoChange) {
|
||||
t.Fatal("repeat startup", err)
|
||||
}
|
||||
assertLedger()
|
||||
down, err := os.ReadFile("../infrastructure/db/migrations/000273_outbound_attempt_evidence.down.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, guardErr := tx.Exec(ctx, string(down))
|
||||
_ = tx.Rollback(ctx)
|
||||
if guardErr == nil || !strings.Contains(guardErr.Error(), "recent provider attempts") {
|
||||
t.Fatal("rollback discarded recent negative attempt evidence", guardErr)
|
||||
}
|
||||
assertLedger()
|
||||
exec(`UPDATE outbound_attempts SET attempted_at=NOW()-INTERVAL '25 hours' WHERE nonce=$1`, nonce)
|
||||
exec(`UPDATE tasks SET send_executor_started_at=NOW()-INTERVAL '25 hours' WHERE id=$1`, task)
|
||||
if err = m.Steps(-1); err != nil {
|
||||
t.Fatal("expired-window rollback", err)
|
||||
}
|
||||
if err = m.Up(); err != nil {
|
||||
t.Fatal("re-upgrade", err)
|
||||
}
|
||||
assertLedger()
|
||||
version, dirty, err := m.Version()
|
||||
if err != nil || dirty || version != 273 {
|
||||
t.Fatal("final migration state", version, dirty, err)
|
||||
}
|
||||
}
|
||||
@@ -1462,18 +1462,24 @@ func (r *emailRepository) Update(ctx context.Context, orgID, emailAccountID stri
|
||||
return nil, errx.ErrEmailWarmupBase
|
||||
}
|
||||
if udata.Warmup != nil {
|
||||
var warmupTime *time.Time
|
||||
if udata.TestMode == nil {
|
||||
mode := models.TestParticipationOff
|
||||
if *udata.Warmup {
|
||||
mode = models.TestParticipationDiagnostic
|
||||
}
|
||||
udata.TestMode = &mode
|
||||
if udata.TestSendEnabled == nil {
|
||||
udata.TestSendEnabled = udata.Warmup
|
||||
}
|
||||
if udata.TestReceiveEnabled == nil {
|
||||
udata.TestReceiveEnabled = udata.Warmup
|
||||
}
|
||||
}
|
||||
if *udata.Warmup {
|
||||
t := time.Now()
|
||||
warmupTime = &t
|
||||
setClauses = append(setClauses, `warmup = CASE WHEN warmup IS NULL THEN now() WHEN warmup_paused_at IS NOT NULL THEN warmup+(now()-warmup_paused_at) ELSE warmup END`, "warmup_paused_at = NULL")
|
||||
} else {
|
||||
setClauses = append(setClauses, `warmup_paused_at = CASE WHEN warmup IS NOT NULL THEN COALESCE(warmup_paused_at,now()) ELSE NULL END`)
|
||||
}
|
||||
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "warmup", argPos))
|
||||
args = append(args, warmupTime)
|
||||
argPos++
|
||||
// A direct warmup on/off via PATCH always clears the pause marker so
|
||||
// state stays coherent (enable = fresh ramp, disable = off). Pause and
|
||||
// resume that preserve ramp progress go through the lifecycle endpoints.
|
||||
setClauses = append(setClauses, "warmup_paused_at = NULL")
|
||||
}
|
||||
if udata.WarmupBase != nil {
|
||||
if *udata.WarmupBase < 0 || *udata.WarmupBase > 100 {
|
||||
@@ -1580,8 +1586,12 @@ func (r *emailRepository) Update(ctx context.Context, orgID, emailAccountID stri
|
||||
argPos++
|
||||
}
|
||||
|
||||
if udata.TestMode == nil && (udata.TestSendEnabled != nil || udata.TestReceiveEnabled != nil) {
|
||||
mode := models.TestParticipationDiagnostic
|
||||
udata.TestMode = &mode
|
||||
}
|
||||
if udata.TestMode != nil {
|
||||
if *udata.TestMode != "legacy" && *udata.TestMode != "diagnostic" && *udata.TestMode != "off" {
|
||||
if *udata.TestMode != "diagnostic" && *udata.TestMode != "off" {
|
||||
return nil, errx.ErrNotEnough
|
||||
}
|
||||
setClauses = append(setClauses, fmt.Sprintf("test_mode = $%d", argPos))
|
||||
@@ -1647,8 +1657,9 @@ func (r *emailRepository) Update(ctx context.Context, orgID, emailAccountID stri
|
||||
WHERE ea.organization_id=$1 AND ea.id=$2 AND ea.status='active' AND ea.send_recovery_hold AND ea.send_recovery_task_id=$6 AND ea.send_recovery_reason=$7 AND ea.send_recovery_reason IN('authentication','permanent','conflict')
|
||||
AND (ea.send_cooldown_provider IS NULL OR ea.send_cooldown_provider=ea.provider::text) FOR UPDATE OF ea),
|
||||
valid AS(SELECT * FROM held WHERE NOT EXISTS(SELECT 1 FROM tasks u WHERE u.email_account_id=held.id AND u.send_result_state='unknown' AND u.send_result_applied_at IS NULL)
|
||||
AND (($3='authentication_repaired' AND held.send_recovery_reason='authentication' AND held.last_synced_at>held.completed_at)
|
||||
OR ($3='operator_provider_confirmation' AND held.send_recovery_reason IN('permanent','conflict') AND length($5)>0 AND $4::uuid IS NOT NULL
|
||||
AND (($3='authentication_repaired' AND held.send_recovery_reason='authentication' AND $4::uuid IS NOT NULL
|
||||
AND EXISTS(SELECT 1 FROM tasks e WHERE e.id=$4 AND e.email_account_id=held.id AND e.send_result_state='sent' AND e.send_result_applied_at>held.completed_at AND e.send_executor_started_at>held.completed_at))
|
||||
OR ($3='operator_provider_confirmation' AND held.send_recovery_reason IN('authentication','permanent','conflict') AND length($5)>0 AND $4::uuid IS NOT NULL
|
||||
AND EXISTS(SELECT 1 FROM tasks e WHERE e.id=$4 AND e.email_account_id=held.id AND e.send_result_state IN('sent','failed') AND e.send_result_applied_at IS NOT NULL)))),
|
||||
history AS(INSERT INTO send_recovery_resolutions(organization_id,email_account_id,recovery_task_id,evidence_task_id,previous_reason,evidence_type,confirmation_reference)
|
||||
SELECT organization_id,id,send_recovery_task_id,$4,send_recovery_reason,$3,$5 FROM valid RETURNING email_account_id)
|
||||
@@ -2132,14 +2143,19 @@ func (r *emailRepository) SetWarmupLifecycle(ctx context.Context, orgID, emailAc
|
||||
WHEN warmup_paused_at IS NOT NULL THEN warmup + (now() - warmup_paused_at)
|
||||
ELSE warmup
|
||||
END,
|
||||
warmup_paused_at = NULL`
|
||||
warmup_paused_at = NULL,
|
||||
test_receive_enabled = CASE WHEN test_mode IS NULL OR test_mode IN ('off','legacy') THEN true ELSE test_receive_enabled END,
|
||||
test_mode = 'diagnostic', test_send_enabled = true`
|
||||
case "pause":
|
||||
setClause = `warmup_paused_at = CASE
|
||||
WHEN warmup IS NOT NULL AND warmup_paused_at IS NULL THEN now()
|
||||
ELSE warmup_paused_at
|
||||
END`
|
||||
END,
|
||||
test_receive_enabled = CASE WHEN test_mode IS NULL OR test_mode='legacy' THEN true ELSE test_receive_enabled END,
|
||||
test_mode = 'diagnostic', test_send_enabled = false`
|
||||
case "disable", "stop":
|
||||
setClause = `warmup = NULL, warmup_paused_at = NULL`
|
||||
setClause = `warmup_paused_at = CASE WHEN warmup IS NOT NULL THEN COALESCE(warmup_paused_at,now()) ELSE NULL END,
|
||||
test_mode = 'off', test_send_enabled = false, test_receive_enabled = false`
|
||||
default:
|
||||
return nil, errx.ErrInvalid
|
||||
}
|
||||
|
||||
@@ -911,6 +911,7 @@ func (r *warmupRepository) CountWarmupSpamReportsSince(ctx context.Context, acco
|
||||
|
||||
// ColdRampState is one mailbox's warmup-to-cold graduation inputs.
|
||||
type ColdRampState struct {
|
||||
ConfirmedReplies int
|
||||
WarmupStartedAt *time.Time
|
||||
ColdRampStartedAt *time.Time
|
||||
Placements []time.Time
|
||||
@@ -966,7 +967,38 @@ func (r *warmupRepository) ColdRampStateForAccounts(ctx context.Context, account
|
||||
state.Placements = append(state.Placements, at)
|
||||
out[id] = state
|
||||
}
|
||||
return out, placementRows.Err()
|
||||
if err := placementRows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
placementRows.Close()
|
||||
feedback, err := resultDB(ctx, r.db).Query(ctx, `SELECT t.email_account_id,COUNT(DISTINCT p.contact_id) FILTER(WHERE p.replied_at>NOW()-INTERVAL '7 days' AND p.bounced_at IS NULL AND p.complained_at IS NULL),
|
||||
MAX(p.bounced_at),MAX(p.complained_at)
|
||||
FROM campaign_contact_progress p JOIN tasks t ON t.id=p.dispatch_task_id
|
||||
WHERE t.email_account_id=ANY($1::uuid[]) AND t.task_type='campaign' AND p.sent_at IS NOT NULL
|
||||
AND p.sent_at>NOW()-INTERVAL '7 days' GROUP BY t.email_account_id`, accountIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer feedback.Close()
|
||||
for feedback.Next() {
|
||||
var id uuid.UUID
|
||||
var replies int
|
||||
var bounced, complained *time.Time
|
||||
if err := feedback.Scan(&id, &replies, &bounced, &complained); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state := out[id]
|
||||
state.ConfirmedReplies = replies
|
||||
if bounced != nil {
|
||||
state.Placements = append(state.Placements, *bounced)
|
||||
}
|
||||
if complained != nil {
|
||||
state.Placements = append(state.Placements, *complained)
|
||||
state.ConfirmedReplies = 0
|
||||
}
|
||||
out[id] = state
|
||||
}
|
||||
return out, feedback.Err()
|
||||
}
|
||||
|
||||
func (r *warmupRepository) StampColdRampStart(ctx context.Context, accountID uuid.UUID) error {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/warmbly/warmbly/internal/errx"
|
||||
"github.com/warmbly/warmbly/internal/infrastructure/db"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
)
|
||||
|
||||
func TestLiveParticipationPreservesNullUntilExplicitActionAndStopsBothDirections(t *testing.T) {
|
||||
f, _ := lineageFixture(t)
|
||||
ctx := t.Context()
|
||||
r := NewEmailRepostory(&db.DB{Pool: f.pool}, nil)
|
||||
if _, err := f.pool.Exec(ctx, `UPDATE email_accounts SET test_mode=NULL, test_send_enabled=false, test_receive_enabled=false WHERE id=$1`, f.sender); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
name := "Unrelated edit"
|
||||
row, xerr := r.Update(ctx, f.org.String(), f.sender.String(), &models.UpdateEmail{Name: &name})
|
||||
if xerr != nil || row.TestMode != nil || !row.TestSendingAllowed() || !row.TestReceivingAllowed() {
|
||||
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() {
|
||||
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) {
|
||||
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) {
|
||||
t.Fatal("stop did not fence both directions", row, xerr)
|
||||
}
|
||||
enable := true
|
||||
row, xerr = r.Update(ctx, f.org.String(), f.sender.String(), &models.UpdateEmail{Warmup: &enable})
|
||||
if xerr != nil || row.TestMode == nil || *row.TestMode != models.TestParticipationDiagnostic || !row.TestSendingAllowed() || !row.TestReceivingAllowed() {
|
||||
t.Fatal("explicit legacy API/bulk start did not activate off mailbox", row, xerr)
|
||||
}
|
||||
diagnostic, receiveOnly := models.TestParticipationDiagnostic, false
|
||||
row, xerr = r.Update(ctx, f.org.String(), f.sender.String(), &models.UpdateEmail{Warmup: &enable, TestMode: &diagnostic, TestSendEnabled: &enable, TestReceiveEnabled: &receiveOnly})
|
||||
if xerr != nil || !row.TestSendingAllowed() || row.TestReceivingAllowed() {
|
||||
t.Fatal("Cloud-shaped sender-only update lost direction or duplicated SQL assignments", row, xerr)
|
||||
}
|
||||
if _, xerr = r.SetWarmupLifecycle(ctx, uuid.NewString(), f.sender.String(), "start"); xerr == nil {
|
||||
t.Fatal("cross-tenant consent accepted")
|
||||
}
|
||||
legacy := models.TestParticipationLegacy
|
||||
if _, xerr = r.Update(ctx, f.org.String(), f.sender.String(), &models.UpdateEmail{TestMode: &legacy}); xerr == nil {
|
||||
t.Fatal("explicit legacy reactivation accepted")
|
||||
}
|
||||
receive, send, daily, rolling := true, false, 8, 11
|
||||
row, xerr = r.Update(ctx, f.org.String(), f.sender.String(), &models.UpdateEmail{TestSendEnabled: &send, TestReceiveEnabled: &receive, SharedDailyLimit: &daily, RollingRecipientLimit: &rolling})
|
||||
if xerr != nil || row.TestMode == nil || *row.TestMode != models.TestParticipationDiagnostic || row.TestSendingAllowed() || !row.TestReceivingAllowed() || row.SharedDailyLimit == nil || *row.SharedDailyLimit != daily || row.RollingRecipientLimit == nil || *row.RollingRecipientLimit != rolling {
|
||||
t.Fatal("receive-only/limits not persisted", row, xerr)
|
||||
}
|
||||
zero := 0
|
||||
row, xerr = r.Update(ctx, f.org.String(), f.sender.String(), &models.UpdateEmail{SharedDailyLimit: &zero, RollingRecipientLimit: &zero})
|
||||
if xerr != nil || row.SharedDailyLimit != nil || row.RollingRecipientLimit != nil || row.TestSendingAllowed() || !row.TestReceivingAllowed() {
|
||||
t.Fatal("reset ceiling changed consent or failed to restore default", row, xerr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveFailedProviderAttemptSurvivesRefundAndCapsOtherLanes(t *testing.T) {
|
||||
f, r := lineageFixture(t)
|
||||
ctx := t.Context()
|
||||
worker := uuid.New()
|
||||
exec := func(q string, a ...any) {
|
||||
t.Helper()
|
||||
if _, err := f.pool.Exec(ctx, q, a...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
exec(`INSERT INTO fleet_nodes(id,role,active,last_seen_at,warmup_send_protocol)VALUES($1,'worker',true,NOW(),2)`, worker)
|
||||
exec(`INSERT INTO workers(id)VALUES($1)`, worker)
|
||||
t.Cleanup(func() { _, _ = f.pool.Exec(context.Background(), `DELETE FROM fleet_nodes WHERE id=$1`, worker) })
|
||||
exec(`UPDATE email_accounts SET worker_id=$1, shared_daily_limit=1, rolling_recipient_limit=1 WHERE id=$2`, worker, f.sender)
|
||||
reservation := func(lane string) OutboundReservation {
|
||||
id := uuid.New()
|
||||
exec(`INSERT INTO tasks(id,task_type,email_account_id,status,message_id)VALUES($1,$2,$3,'active','')`, id, lane, f.sender)
|
||||
return OutboundReservation{TaskID: id, MailboxID: f.sender, OrganizationID: f.org, WorkerID: worker, Provider: models.InboxProviderSMTPIMAP, Recipients: []string{"recipient@example.test"}}
|
||||
}
|
||||
in := reservation("email")
|
||||
nonce, err := r.ReserveOutbound(ctx, in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for range 2 {
|
||||
state, err := r.BeginOutbound(ctx, in.TaskID, in.MailboxID, in.WorkerID, nonce)
|
||||
if err != nil || state == nil {
|
||||
t.Fatal(state, err)
|
||||
}
|
||||
}
|
||||
result := models.SendEmailResult{TaskID: in.TaskID, Error: &models.EmailSendError{Code: "RECIPIENT_REJECTED", Failure: &errx.SendFailure{Provider: string(models.InboxProviderSMTPIMAP), Protocol: "smtp", Status: 550, Disposition: errx.SendPermanent, Scope: "recipient", ObservedAt: time.Now()}}}
|
||||
if err = r.FinishOutbound(ctx, in.TaskID, in.MailboxID, in.WorkerID, result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for range 2 {
|
||||
if err = r.ApplySendResult(ctx, result, func(context.Context) error { return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var released bool
|
||||
var attempts int
|
||||
if err = f.pool.QueryRow(ctx, `SELECT send_released_at IS NOT NULL FROM tasks WHERE id=$1`, in.TaskID).Scan(&released); err != nil || !released {
|
||||
t.Fatal("failed capacity not refunded", released, err)
|
||||
}
|
||||
if err = f.pool.QueryRow(ctx, `SELECT COUNT(*) FROM outbound_attempts WHERE task_id=$1`, in.TaskID).Scan(&attempts); err != nil || attempts != 1 {
|
||||
t.Fatal("duplicate execution/result changed attempts", attempts, err)
|
||||
}
|
||||
for _, lane := range []string{"email", "placement"} {
|
||||
if _, err = r.ReserveOutbound(ctx, reservation(lane)); !errors.Is(err, ErrSendAdmissionDenied) {
|
||||
t.Fatal("refunded attempt escaped shared rate limit", lane, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveDiagnosticUnknownRetryIsBoundedAndDefinitiveProofStopsFetches(t *testing.T) {
|
||||
f, r := lineageFixture(t)
|
||||
ctx := t.Context()
|
||||
worker, token := uuid.New(), uuid.New()
|
||||
exec := func(q string, a ...any) {
|
||||
t.Helper()
|
||||
if _, err := f.pool.Exec(ctx, q, a...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
exec(`INSERT INTO fleet_nodes(id,role,active,last_seen_at,warmup_send_protocol)VALUES($1,'worker',true,NOW(),2)`, worker)
|
||||
exec(`INSERT INTO workers(id)VALUES($1)`, worker)
|
||||
t.Cleanup(func() { _, _ = f.pool.Exec(context.Background(), `DELETE FROM fleet_nodes WHERE id=$1`, worker) })
|
||||
exec(`UPDATE email_accounts SET worker_id=$1 WHERE id=$2`, worker, f.recipient)
|
||||
exec(`INSERT INTO warmup_pool_participants(pool_id,email_account_id)SELECT id,$1 FROM warmup_pools WHERE pool_type='free'`, f.recipient)
|
||||
exec(`INSERT INTO warmup_tasks(task_id,lineage_version,subject,scenario_version,rendering_version,max_turns)VALUES($1,1,'Diagnostic','diagnostic-v1','canonical-v1',1)`, f.task)
|
||||
exec(`INSERT INTO warmup_tokens(token,task_id,sender_account_id,recipient_account_id,sent_message_id)VALUES($1,$2,$3,$4,'<retry@example.test>')`, token, f.task, f.sender, f.recipient)
|
||||
req := models.DiagnosticAuthRequest{Token: token, MailboxID: f.recipient, WorkerID: worker, MessageID: "retry@example.test"}
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
grant, err := r.DiagnosticAuth(ctx, req)
|
||||
if err != nil || grant == nil {
|
||||
t.Fatal("unknown permanently lost retry", attempt, grant, err)
|
||||
}
|
||||
completion := req
|
||||
completion.Nonce = grant.Nonce
|
||||
completion.Result = &models.DiagnosticDKIMResult{DKIM: "unknown", Alignment: "unknown", Verifier: models.DiagnosticDKIMVerifier, ObservedAt: time.Now()}
|
||||
if _, err = r.DiagnosticAuth(ctx, completion); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if next, err := r.DiagnosticAuth(ctx, req); err != nil || next != nil {
|
||||
t.Fatal("retry bypassed spacing", next, err)
|
||||
}
|
||||
exec(`UPDATE diagnostic_auth_verifications SET authorized_at=NOW()-INTERVAL '1 minute' WHERE task_id=$1`, f.task)
|
||||
}
|
||||
if next, err := r.DiagnosticAuth(ctx, req); err != nil || next != nil {
|
||||
t.Fatal("retry budget exceeded", next, err)
|
||||
}
|
||||
}
|
||||
@@ -151,6 +151,24 @@ func (r *taskRepository) ReserveOutbound(ctx context.Context, in OutboundReserva
|
||||
return uuid.Nil, err
|
||||
}
|
||||
// Calendar counters and rolling recipient occurrences are independent constraints.
|
||||
var attemptedRecipients, attemptedToday int
|
||||
err = tx.QueryRow(ctx, `SELECT COALESCE(SUM(recipient_count) FILTER(WHERE attempted_at>NOW()-INTERVAL '24 hours'),0),
|
||||
COUNT(*) FILTER(WHERE (attempted_at AT TIME ZONE $2)::date=(NOW() AT TIME ZONE $2)::date)
|
||||
FROM outbound_attempts WHERE email_account_id=$1`, in.MailboxID, timezone).Scan(&attemptedRecipients, &attemptedToday)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
var reservedAttemptRecipients, reservedAttemptsToday int
|
||||
err = tx.QueryRow(ctx, `SELECT COALESCE(SUM(GREATEST(1,cardinality(send_recipients))) FILTER(WHERE COALESCE(send_reserved_at,completed_at)>NOW()-INTERVAL '24 hours'),0),
|
||||
COUNT(*) FILTER(WHERE COALESCE(send_business_day,(completed_at AT TIME ZONE $3)::date)=(NOW() AT TIME ZONE $3)::date)
|
||||
FROM tasks t WHERE email_account_id=$1 AND id<>$2 AND task_type IN('email','campaign','warmup','placement')
|
||||
AND ((send_reserved_at IS NOT NULL AND send_released_at IS NULL) OR (send_reserved_at IS NULL AND status='completed' AND completed_at IS NOT NULL
|
||||
AND (task_type<>'campaign' OR EXISTS(SELECT 1 FROM campaign_tasks ct WHERE ct.task_id=t.id AND ct.sequence_id IS NOT NULL))))
|
||||
AND NOT EXISTS(SELECT 1 FROM outbound_attempts a WHERE a.nonce=t.send_executor_nonce)`, in.MailboxID, in.TaskID, timezone).Scan(&reservedAttemptRecipients, &reservedAttemptsToday)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
occurrences = attemptedRecipients + reservedAttemptRecipients
|
||||
var dayTotal, dayCold, dayDiagnostic int
|
||||
err = tx.QueryRow(ctx, `SELECT COUNT(*),COUNT(*) FILTER(WHERE task_type IN ('campaign','email')),COUNT(*) FILTER(WHERE task_type IN ('warmup','placement')) FROM tasks t
|
||||
WHERE email_account_id=$1 AND id<>$2 AND task_type IN ('campaign','email','warmup','placement')
|
||||
@@ -160,7 +178,14 @@ func (r *taskRepository) ReserveOutbound(ctx context.Context, in OutboundReserva
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
if shared != nil && dayTotal >= *shared || rolling != nil && occurrences+len(recipients) > *rolling || (lane == "campaign" || lane == "email") && dayCold >= campaignCap || (lane == "warmup" || lane == "placement") && dayDiagnostic >= warmupCap {
|
||||
sharedCap, rollingCap := max(1, campaignCap+warmupCap), max(1, campaignCap+warmupCap)
|
||||
if shared != nil {
|
||||
sharedCap = *shared
|
||||
}
|
||||
if rolling != nil {
|
||||
rollingCap = *rolling
|
||||
}
|
||||
if max(dayTotal, attemptedToday+reservedAttemptsToday) >= sharedCap || occurrences+len(recipients) > rollingCap || (lane == "campaign" || lane == "email") && dayCold >= campaignCap || (lane == "warmup" || lane == "placement") && dayDiagnostic >= warmupCap {
|
||||
return uuid.Nil, ErrSendAdmissionDenied
|
||||
}
|
||||
if lane == "campaign" {
|
||||
@@ -338,6 +363,9 @@ func (r *taskRepository) BeginOutbound(ctx context.Context, task, mailbox, worke
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO outbound_attempts(nonce,task_id,email_account_id,provider,attempted_at,recipient_count) VALUES($1,$2,$3,$4,NOW(),$5)`, nonce, task, mailbox, provider, len(recipients)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -37,6 +37,14 @@ func TestLiveSendRecoveryResolutionRequiresEvidenceAndRetainsUnknown(t *testing.
|
||||
if _, err := f.pool.Exec(ctx, `DELETE FROM tasks WHERE id=$1`, unknown); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, xerr := repo.Update(ctx, f.org.String(), f.sender.String(), resolve); xerr == nil {
|
||||
t.Fatal("fresh inbox sync alone cleared outbound authentication hold")
|
||||
}
|
||||
evidence := uuid.New()
|
||||
if _, err := f.pool.Exec(ctx, `INSERT INTO tasks(id,task_type,email_account_id,status,message_id,send_result_state,send_result_applied_at,send_executor_started_at)VALUES($1,'email',$2,'completed','evidence@example.test','sent',NOW(),NOW())`, evidence, f.sender); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolve.SendRecoveryResolution.EvidenceTaskID = &evidence
|
||||
if _, xerr := repo.Update(ctx, f.org.String(), f.sender.String(), resolve); xerr != nil {
|
||||
t.Fatal(xerr)
|
||||
}
|
||||
|
||||
@@ -208,7 +208,7 @@ func projectSenderDay(p *projectedSender, spans []span, midnight, rampStart time
|
||||
start = *p.cold.ColdRampStartedAt
|
||||
}
|
||||
warmupDays := max(0, int(noon.Sub(*p.cold.WarmupStartedAt).Hours()/24))
|
||||
if g := warmupramp.ColdCeiling(warmupDays, start, p.cold.Placements, noon, c); g < c {
|
||||
if g := warmupramp.ColdCeiling(warmupDays, start, p.cold.Placements, noon, c, p.cold.ConfirmedReplies); g < c {
|
||||
d.graduation = c - g
|
||||
c = g
|
||||
}
|
||||
@@ -447,7 +447,7 @@ func (s *schedulerService) ProjectCampaign(ctx context.Context, in CampaignProje
|
||||
for i := projectionExactDays - 7; i < projectionExactDays; i++ {
|
||||
out.SteadyCapacity = max(out.SteadyCapacity, poolExact[i])
|
||||
}
|
||||
if out.SteadyCapacity > 0 && firstFull >= 0 && poolExact[firstFull] < out.SteadyCapacity {
|
||||
if fullCapacityEvidenceKnown(senders) && out.SteadyCapacity > 0 && firstFull >= 0 && poolExact[firstFull] < out.SteadyCapacity {
|
||||
for i := firstFull; i < projectionExactDays; i++ {
|
||||
if sendingExact[i] && poolExact[i] >= out.SteadyCapacity {
|
||||
at := time.Date(startDay.Year(), startDay.Month(), startDay.Day()+i, 0, 0, 0, 0, loc)
|
||||
@@ -565,6 +565,15 @@ func (s *schedulerService) ProjectCampaign(ctx context.Context, in CampaignProje
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func fullCapacityEvidenceKnown(senders []*projectedSender) bool {
|
||||
for _, p := range senders {
|
||||
if p.hasCold && p.cold.WarmupStartedAt != nil && coldCeilingFor(p.cold, p.cap) < p.cap {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type simDay struct{ first, followUps int }
|
||||
|
||||
type leadSim struct {
|
||||
|
||||
@@ -21,6 +21,19 @@ func TestSimulateLeadsSingleStep(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullCapacityDateDoesNotProjectMissingFeedbackIntoReadiness(t *testing.T) {
|
||||
warmed := time.Now().Add(-90 * 24 * time.Hour)
|
||||
ramp := time.Now().Add(-30 * 24 * time.Hour)
|
||||
p := &projectedSender{cap: 50, hasCold: true, cold: repository.ColdRampState{WarmupStartedAt: &warmed, ColdRampStartedAt: &ramp}}
|
||||
if fullCapacityEvidenceKnown([]*projectedSender{p}) {
|
||||
t.Fatal("synthetic age projected full-cap readiness")
|
||||
}
|
||||
p.cold.ConfirmedReplies = 100
|
||||
if !fullCapacityEvidenceKnown([]*projectedSender{p}) {
|
||||
t.Fatal("current already-full evidence lost")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateLeadsFollowUpsFirst(t *testing.T) {
|
||||
// Two steps three days apart: day 3's capacity goes to day 0's follow-ups
|
||||
// before any new contact.
|
||||
|
||||
@@ -47,5 +47,5 @@ func coldCeilingFor(state repository.ColdRampState, mailboxCap int) int {
|
||||
if state.ColdRampStartedAt != nil {
|
||||
rampStart = *state.ColdRampStartedAt
|
||||
}
|
||||
return warmupramp.ColdCeiling(warmupDays, rampStart, state.Placements, now, mailboxCap)
|
||||
return warmupramp.ColdCeiling(warmupDays, rampStart, state.Placements, now, mailboxCap, state.ConfirmedReplies)
|
||||
}
|
||||
|
||||
@@ -1009,8 +1009,8 @@ func TestLiveGraduationStopsTheOvernightJumpToFullCap(t *testing.T) {
|
||||
f := newLiveFixture(t, pool, "UTC")
|
||||
f.graduateMailbox(t, 30, nil) // a month of warmup, first cold day
|
||||
|
||||
if got := f.dailyCapacity(t, handle); got != 20 {
|
||||
t.Errorf("first cold day allows %d, want the graduation start of 20 rather than the 50 cap", got)
|
||||
if got := f.dailyCapacity(t, handle); got != 5 {
|
||||
t.Errorf("first cold day allows %d, want conservative start without reply evidence", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1020,14 +1020,14 @@ func TestLiveGraduationClimbsToTheCap(t *testing.T) {
|
||||
|
||||
three := 3
|
||||
f.graduateMailbox(t, 30, &three)
|
||||
if got := f.dailyCapacity(t, handle); got != 35 {
|
||||
t.Errorf("cold day 3 allows %d, want 20 + 3*5", got)
|
||||
if got := f.dailyCapacity(t, handle); got != 5 {
|
||||
t.Errorf("cold day 3 allows %d without actual recipient feedback", got)
|
||||
}
|
||||
|
||||
long := 60
|
||||
f.graduateMailbox(t, 30, &long)
|
||||
if got := f.dailyCapacity(t, handle); got != 50 {
|
||||
t.Errorf("a long-graduated mailbox allows %d, want the full 50 cap", got)
|
||||
if got := f.dailyCapacity(t, handle); got != 5 {
|
||||
t.Errorf("a long-graduated mailbox fabricated readiness: %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ func (s *schedulerService) planMailbox(ctx context.Context, pass *campaignPass,
|
||||
d.byGraduation = step(stages[3])
|
||||
d.byRisk = step(stages[4])
|
||||
if st, ok := pass.coldRamp[acct.ID]; ok && stages[3] < stages[2] {
|
||||
d.graduation = warmupramp.Notice(st.WarmupStartedAt, st.ColdRampStartedAt, st.Placements, stages[2], now)
|
||||
d.graduation = warmupramp.Notice(st.WarmupStartedAt, st.ColdRampStartedAt, st.Placements, stages[2], now, st.ConfirmedReplies)
|
||||
}
|
||||
|
||||
// Standing gates: authentication, cold rotation, warmup health. Asked with
|
||||
|
||||
@@ -2,7 +2,6 @@ package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -137,15 +136,19 @@ func TestLiveRepairedVerificationRestoresRouting(t *testing.T) {
|
||||
t.Fatalf("%d sends went out while every lead was undeliverable", f.sender.count())
|
||||
}
|
||||
|
||||
// The campaign reports what it skipped rather than claiming it sent everything.
|
||||
// Verification refusal parks the campaign; it is not successful completion.
|
||||
var reason string
|
||||
if err := f.pool.QueryRow(context.Background(), `SELECT message FROM campaign_logs
|
||||
WHERE campaign_id = $1 AND event_type = 'completed' ORDER BY created_at DESC LIMIT 1`,
|
||||
WHERE campaign_id = $1 AND event_type = 'auto_paused' ORDER BY created_at DESC LIMIT 1`,
|
||||
f.campaign).Scan(&reason); err != nil {
|
||||
t.Fatalf("read completion log: %v", err)
|
||||
t.Fatalf("read pause log: %v", err)
|
||||
}
|
||||
if !strings.Contains(reason, "2 lead(s) skipped") {
|
||||
t.Fatalf("completion logged %q; it must say the leads were skipped, not that everything sent", reason)
|
||||
if reason != UndeliverablePauseReason(2) {
|
||||
t.Fatalf("pause logged %q; it must report the exact refused lead count", reason)
|
||||
}
|
||||
var status string
|
||||
if err := f.pool.QueryRow(context.Background(), `SELECT status FROM campaigns WHERE id=$1`, f.campaign).Scan(&status); err != nil || status != "paused_undeliverable" {
|
||||
t.Fatalf("refused leads claimed completion: %s, %v", status, err)
|
||||
}
|
||||
|
||||
// The verifier is corrected and the contacts are re-checked.
|
||||
|
||||
@@ -3,10 +3,12 @@ package tasks
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/warmbly/warmbly/internal/errx"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/repository"
|
||||
)
|
||||
|
||||
// GetCampaignSequences returns the sequences for a campaign ordered by position
|
||||
@@ -76,7 +78,14 @@ func (s *tasksService) SendTestEmail(ctx context.Context, orgID uuid.UUID, accou
|
||||
}
|
||||
|
||||
taskID := uuid.New()
|
||||
if s.taskRepo == nil {
|
||||
return errx.InternalError()
|
||||
}
|
||||
if err := s.taskRepo.CreateTask(ctx, &repository.Task{ID: taskID, TaskType: "email", EmailAccountID: account.ID, Status: "active", MessageID: emailMsg.MessageID, CreatedAt: time.Now(), UpdatedAt: time.Now()}); err != nil {
|
||||
return errx.InternalError()
|
||||
}
|
||||
if err := s.emailSender.Send(ctx, taskID, emailMsg, *account); err != nil {
|
||||
_ = s.taskRepo.RecordTaskFailure(ctx, taskID, "Test send not dispatched", "Outbound admission or publication failed")
|
||||
return errx.New(errx.Internal, fmt.Sprintf("failed to send test email: %v", err))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/warmbly/warmbly/internal/events"
|
||||
"github.com/warmbly/warmbly/internal/repository"
|
||||
)
|
||||
|
||||
type testTaskPublisher struct {
|
||||
events.Publisher
|
||||
t *testing.T
|
||||
f *sendFixture
|
||||
params *events.SendEmailParams
|
||||
}
|
||||
|
||||
func (p *testTaskPublisher) PublishSendEmail(ctx context.Context, _ uuid.UUID, params *events.SendEmailParams) error {
|
||||
p.t.Helper()
|
||||
var task, nonce, message string
|
||||
if err := p.f.pool.QueryRow(ctx, `SELECT task_type,send_executor_nonce::text,message_id FROM tasks WHERE id=$1`, params.TaskID).Scan(&task, &nonce, &message); err != nil || task != "email" || nonce != params.DispatchNonce || message != params.MessageID || message == "" {
|
||||
p.t.Fatal("published without real admitted task", task, nonce, message, err)
|
||||
}
|
||||
p.params = params
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestLiveCampaignTestSendCreatesTaskBeforeSharedAdmissionAndKeepsTrackingDisabled(t *testing.T) {
|
||||
f := newSendFixture(t)
|
||||
ctx := t.Context()
|
||||
worker := uuid.New()
|
||||
for _, statement := range []struct {
|
||||
sql string
|
||||
args []any
|
||||
}{
|
||||
{`INSERT INTO fleet_nodes(id,role,active,last_seen_at,warmup_send_protocol)VALUES($1,'worker',true,NOW(),2)`, []any{worker}},
|
||||
{`INSERT INTO workers(id)VALUES($1)`, []any{worker}},
|
||||
{`UPDATE email_accounts SET worker_id=$1,shared_daily_limit=1 WHERE id=$2`, []any{worker, f.mailbox}},
|
||||
} {
|
||||
if _, err := f.pool.Exec(ctx, statement.sql, statement.args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() { _, _ = f.pool.Exec(context.Background(), `DELETE FROM fleet_nodes WHERE id=$1`, worker) })
|
||||
pub := &testTaskPublisher{t: t, f: f}
|
||||
sender := NewEmailSender(f.svc.emailRepo, pub).(*emailSender)
|
||||
sender.WireSendAdmission(f.svc.taskRepo.(repository.OutboundAdmissionRepository))
|
||||
f.svc.emailSender = sender
|
||||
campaign, err := f.svc.campaignRepo.GetByID(ctx, f.campaign)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sequence, err := f.svc.campaignRepo.GetSequenceByID(ctx, f.step)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if xerr := f.svc.SendTestEmail(ctx, f.org, f.mailbox, f.emailAAddr, campaign, sequence, nil); xerr != nil {
|
||||
t.Fatal(xerr)
|
||||
}
|
||||
if pub.params == nil || pub.params.Subject != "[TEST] Hi" || pub.params.TrackingInfo != nil || pub.params.UnsubscribeURL != "" {
|
||||
t.Fatal("test labeling/tracking changed", pub.params)
|
||||
}
|
||||
pub.params = nil
|
||||
if xerr := f.svc.SendTestEmail(ctx, f.org, f.mailbox, f.emailAAddr, campaign, sequence, nil); xerr == nil || pub.params != nil {
|
||||
t.Fatal("test bypassed mandatory admission", xerr)
|
||||
}
|
||||
var failures int
|
||||
if err = f.pool.QueryRow(ctx, `SELECT COUNT(*) FROM task_failures f JOIN tasks t ON t.id=f.task_id WHERE t.email_account_id=$1 AND t.status='failed'`, f.mailbox).Scan(&failures); err != nil || failures != 1 {
|
||||
t.Fatal("rejected test disappeared without failure", failures, err)
|
||||
}
|
||||
}
|
||||
@@ -94,8 +94,8 @@ func (f *crossTierFixture) memberOf(t *testing.T, org, poolID uuid.UUID, provenD
|
||||
t.Helper()
|
||||
id := uuid.New()
|
||||
f.exec(t, `INSERT INTO email_accounts (id, user_id, organization_id, email, name, signature_plain, signature_html,
|
||||
provider, status, campaign_limit, min_wait_time, timezone)
|
||||
VALUES ($1, $2, $3, $4, 'Borrow', '', '', 'smtp_imap', 'active', 50, 600, 'UTC')`,
|
||||
provider, status, campaign_limit, min_wait_time, timezone, test_mode, test_send_enabled, test_receive_enabled)
|
||||
VALUES ($1, $2, $3, $4, 'Borrow', '', '', 'smtp_imap', 'active', 50, 600, 'UTC', 'diagnostic', true, true)`,
|
||||
id, f.user, org, "borrow-"+id.String()[:8]+"@test.local")
|
||||
f.exec(t, `INSERT INTO warmup_pool_participants (pool_id, email_account_id, participant_role, health_state, joined_at)
|
||||
VALUES ($1, $2, 'sender_receiver', 'healthy', NOW() - make_interval(days => $3))`, poolID, id, provenDays)
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestLiveWarmupDispatchRechecksOpeningSourceAndExactRecipient(t *testing.T)
|
||||
}
|
||||
}
|
||||
exec(`UPDATE organizations SET risk_state='trusted' WHERE id=$1`, f.org)
|
||||
exec(`UPDATE email_accounts SET warmup=NOW()-INTERVAL '30 days',warmup_pool_type='free',warmup_days=127,warmup_start_time='00:00',warmup_end_time='23:59',warmup_base=20,warmup_max=20,name='Ádám Support <EMEA>' WHERE organization_id=$1`, f.org)
|
||||
exec(`UPDATE email_accounts SET test_mode='diagnostic',test_send_enabled=true,test_receive_enabled=true,warmup=NOW()-INTERVAL '30 days',warmup_pool_type='free',warmup_days=127,warmup_start_time='00:00',warmup_end_time='23:59',warmup_base=20,warmup_max=20,name='Ádám Support <EMEA>' WHERE organization_id=$1`, f.org)
|
||||
task := uuid.New()
|
||||
source := VettedDiagnosticConversations()[0]
|
||||
exec(`INSERT INTO tasks(id,task_type,email_account_id,status,message_id,scheduled_at)VALUES($1,'warmup',$2,'active','',NOW())`, task, f.sender.ID)
|
||||
|
||||
@@ -78,7 +78,7 @@ func newPartnerRoutingFixture(t *testing.T) *partnerRoutingFixture {
|
||||
}
|
||||
exec(`INSERT INTO users (id, email, first_name, last_name) VALUES ($1, $2, 'Pick', 'Test')`,
|
||||
f.user, "pick-"+f.user.String()[:8]+"@test.local")
|
||||
exec(`INSERT INTO organizations (id, name, slug, owner_user_id) VALUES ($1, 'Pick Test', $2, $3)`,
|
||||
exec(`INSERT INTO organizations (id, name, slug, owner_user_id, risk_state) VALUES ($1, 'Pick Test', $2, $3, 'trusted')`,
|
||||
f.org, "pick-"+f.org.String()[:8], f.user)
|
||||
// Both partners are on custom domains, so only the detected host can tell
|
||||
// them apart.
|
||||
@@ -92,8 +92,8 @@ func newPartnerRoutingFixture(t *testing.T) *partnerRoutingFixture {
|
||||
{f.atSmallHost, "shop.test", "hostinger"},
|
||||
} {
|
||||
exec(`INSERT INTO email_accounts (id, user_id, organization_id, email, name, signature_plain,
|
||||
signature_html, provider, status, campaign_limit, min_wait_time, timezone, mail_host)
|
||||
VALUES ($1, $2, $3, $4, 'Pick', '', '', 'smtp_imap', 'active', 50, 600, 'UTC', $5)`,
|
||||
signature_html, provider, status, campaign_limit, min_wait_time, timezone, mail_host, test_mode, test_send_enabled, test_receive_enabled)
|
||||
VALUES ($1, $2, $3, $4, 'Pick', '', '', 'smtp_imap', 'active', 50, 600, 'UTC', $5, 'diagnostic', true, true)`,
|
||||
m.id, f.user, f.org, "pick-"+m.id.String()[:8]+"@"+m.domain, m.host)
|
||||
}
|
||||
for _, id := range []uuid.UUID{senderID, f.atWorkspace, f.atSmallHost} {
|
||||
|
||||
@@ -7,6 +7,7 @@ import useEmails from "@/lib/api/hooks/app/emails/useEmails";
|
||||
import { NoAccess } from "@/components/layout/NoAccess";
|
||||
import { usePermission } from "@/hooks/usePermission";
|
||||
import useWarmupLifecycle from "@/lib/api/hooks/app/emails/useWarmupLifecycle";
|
||||
import { diagnosticSendingAllowed, diagnosticWarmupActive } from "@/lib/diagnosticParticipation";
|
||||
import useAccountStatuses from "@/lib/api/hooks/app/analytics/useAccountStatuses";
|
||||
import useFeatureStatus from "@/lib/api/hooks/app/subscription/useFeatureStatus";
|
||||
import warmupLifecycle from "@/lib/api/client/app/emails/warmupLifecycle";
|
||||
@@ -344,7 +345,7 @@ export default function AddressesPage() {
|
||||
// Mailboxes actively warming (enabled and not paused). Warmup pairs mailboxes
|
||||
// with each other, so too few starves it; the notice below warns on that.
|
||||
const warmupActive = useMemo(
|
||||
() => (emailsData.emails ?? []).filter((e) => !!e.warmup && !e.warmup_paused_at).length,
|
||||
() => (emailsData.emails ?? []).filter(diagnosticWarmupActive).length,
|
||||
[emailsData.emails],
|
||||
);
|
||||
|
||||
@@ -771,9 +772,9 @@ function MailboxRow({
|
||||
const source = mailboxSource(box);
|
||||
const brand = mailboxBrand(box);
|
||||
|
||||
const off = !box.warmup;
|
||||
const paused = !!box.warmup && !!box.warmup_paused_at;
|
||||
const active = !!box.warmup && !box.warmup_paused_at;
|
||||
const off = !box.warmup || box.test_mode === "off";
|
||||
const paused = !off && !!box.warmup && (!!box.warmup_paused_at || !diagnosticSendingAllowed(box));
|
||||
const active = diagnosticWarmupActive(box);
|
||||
// Warmup only runs on a mailbox that is on, whatever its warmup setting says.
|
||||
const warming = active && box.status === "active";
|
||||
|
||||
@@ -808,7 +809,7 @@ function MailboxRow({
|
||||
? "Stopped"
|
||||
: paused
|
||||
? "Paused"
|
||||
: inCampaign
|
||||
: inCampaign && diagnosticSendingAllowed(box)
|
||||
? "Health-check"
|
||||
: "Off";
|
||||
const warmupTone = sendFailure
|
||||
@@ -825,20 +826,23 @@ function MailboxRow({
|
||||
? "text-slate-400"
|
||||
: paused
|
||||
? "text-amber-600"
|
||||
: inCampaign
|
||||
: inCampaign && diagnosticSendingAllowed(box)
|
||||
? "text-sky-600"
|
||||
: "text-slate-400";
|
||||
|
||||
const run = (action: "start" | "pause" | "resume", verb: string) => {
|
||||
life.mutate(action, {
|
||||
const execute = () => life.mutate(action, {
|
||||
onSuccess: () => toast.success(`Warmup ${verb} for ${box.email}`),
|
||||
onError: (e) => toast.error(warmupErrorMessage(e as unknown as AppError)),
|
||||
});
|
||||
if (action === "start" || action === "resume") {
|
||||
confirm.show("Authorize disclosed automated diagnostic sending? Starting from Off also enables receiving tests. This is not organic engagement or a proven reputation benefit; provider policies apply.", execute);
|
||||
} else execute();
|
||||
};
|
||||
|
||||
const stopReset = () => {
|
||||
confirm.show(
|
||||
`Stop warmup for ${box.email}? This resets ramp progress — restarting begins from the base volume. Use Pause to keep progress.`,
|
||||
`Stop all diagnostic sending and receiving for ${box.email}? Ramp history and safety holds are retained. Updated workers recheck pending work, but accepted provider sends cannot be recalled.`,
|
||||
async () => {
|
||||
try {
|
||||
await life.mutateAsync("stop");
|
||||
@@ -1213,7 +1217,7 @@ const MAILBOX_COLUMNS: MailboxColumn[] = [
|
||||
sortValue: (b, s) =>
|
||||
b.status !== "active" || s?.errors?.length
|
||||
? -1
|
||||
: (s?.in_campaign ? 2 : 0) + (b.warmup && !b.warmup_paused_at ? 1 : 0),
|
||||
: (s?.in_campaign ? 2 : 0) + (diagnosticWarmupActive(b) ? 1 : 0),
|
||||
},
|
||||
{
|
||||
id: "sent",
|
||||
@@ -1227,7 +1231,7 @@ const MAILBOX_COLUMNS: MailboxColumn[] = [
|
||||
label: "Warmup",
|
||||
header: <InfoHeader label="Warmup" title="Warmup emails sent today, against today's ramp target." aria="How warmup is counted" />,
|
||||
className: "w-28",
|
||||
sortValue: (b, s) => (b.warmup && !b.warmup_paused_at ? (s?.warmup_status?.current_volume ?? 0) : -1),
|
||||
sortValue: (b, s) => (diagnosticWarmupActive(b) ? (s?.warmup_status?.current_volume ?? 0) : -1),
|
||||
},
|
||||
{
|
||||
id: "inbox",
|
||||
@@ -1312,8 +1316,8 @@ function MailboxStatusPill({ box, status, warming }: { box: Inbox; status?: Acco
|
||||
sending ? "Sending campaign emails" : resting ? `Held out of campaign sending${lifecycle?.reason ? `: ${lifecycle.reason}` : ""}` : "Not in a live campaign",
|
||||
warming
|
||||
? "warming up"
|
||||
: inCampaign
|
||||
? "a low-volume health-check warmup keeps running"
|
||||
: inCampaign && diagnosticSendingAllowed(box)
|
||||
? "low-volume diagnostic health checks are enabled"
|
||||
: box.warmup && box.warmup_paused_at
|
||||
? "warmup paused"
|
||||
: "warmup off",
|
||||
|
||||
@@ -56,16 +56,14 @@ export function estimateHeadline(
|
||||
}
|
||||
|
||||
// Why it takes as long as it does, in the pool's own terms.
|
||||
export function bottleneckText(e: CampaignEstimateResult, tz?: string): string | null {
|
||||
export function bottleneckText(e: CampaignEstimateResult, _tz?: string): string | null {
|
||||
switch (e.bottleneck) {
|
||||
case "warmup_graduation":
|
||||
return `${plural(e.ramping, "mailbox", "mailboxes")} ${e.ramping === 1 ? "is" : "are"} graduating from warmup: cold volume starts low and climbs 5 a day${
|
||||
e.full_capacity_at ? `, reaching full speed around ${fmtDay(e.full_capacity_at, tz)}` : ""
|
||||
}. This protects their reputation, so it cannot be skipped.`;
|
||||
return `${plural(e.ramping, "mailbox", "mailboxes")} use conservative cold pacing. Synthetic test age does not unlock volume; recent real-recipient replies and negative feedback constrain increases. No full-speed date or reputation benefit is guaranteed.`;
|
||||
case "spacing":
|
||||
return "The sending window is the limit: with the gap between sends, and warmup sharing the same clock, mailboxes run out of hours before they reach their cap. A wider window goes faster.";
|
||||
case "campaign_limit":
|
||||
return "The daily limit per mailbox is the limit. Raise it to go faster, but stay near 50 until the mailboxes have proven their reputation.";
|
||||
return "The configured daily limit per mailbox is the constraint. Raising it is not proof that a higher volume is safe; consider provider policies, recipient permission and observed feedback.";
|
||||
case "other_campaigns":
|
||||
return `These mailboxes already send about ${e.other_campaigns_per_day.toLocaleString()} a day for other campaigns, and that shares their daily caps.`;
|
||||
case "health":
|
||||
|
||||
@@ -112,7 +112,7 @@ export default function BulkWarmupDialog({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[13px] font-medium text-slate-900">Start warmup</div>
|
||||
<div className="text-[11px] text-slate-400">
|
||||
{n} mailbox{n > 1 ? "es" : ""} selected
|
||||
{n} mailbox{n > 1 ? "es" : ""} selected. Starting authorizes disclosed automated sending; Off mailboxes also enable receiving tests. This is not organic engagement or a proven reputation benefit.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DiagnosticParticipation } from "@/lib/api/models/app/cloudlink/CloudLink";
|
||||
import CloudWarmupCard from "./CloudWarmupCard";
|
||||
|
||||
const fixtures = vi.hoisted(() => ({ participation: undefined as DiagnosticParticipation | undefined, patch: vi.fn() }));
|
||||
vi.mock("@/hooks/useCloudPool", () => ({ default: () => ({ manageable: true, connected: true, rowFor: () => ({ enrolled: true, managed: false, cloud: { participation: fixtures.participation, settings: { base: 10 }, sent_today: 0, sent_7d: 0 } }) }) }));
|
||||
vi.mock("@/lib/api/hooks/app/cloudlink/useCloudLink", () => ({ useEnrollCloudLinkMailbox: () => ({}), useUnenrollCloudLinkMailbox: () => ({}), useCloudLinkMailboxLifecycle: () => ({}) }));
|
||||
vi.mock("@/hooks/context/confirm", () => ({ useConfirm: () => ({ show: (_: string, confirm: () => void) => confirm() }) }));
|
||||
vi.mock("@/lib/api/client/app/cloudlink/cloudLink", () => ({ setCloudLinkParticipation: fixtures.patch }));
|
||||
vi.mock("./WarmupPartnerDiversity", () => ({ default: () => null }));
|
||||
vi.mock("./WarmupSendFailureNote", () => ({ default: () => null }));
|
||||
|
||||
describe("Cloud diagnostic participation", () => {
|
||||
beforeEach(() => { fixtures.participation = undefined; fixtures.patch.mockReset().mockResolvedValue({}); });
|
||||
afterEach(cleanup);
|
||||
const draw = () => render(<QueryClientProvider client={new QueryClient()}><CloudWarmupCard mailboxId="mailbox" email="fixture@example.test" provider="smtp_imap" /></QueryClientProvider>);
|
||||
|
||||
it("does not infer consent or offer new controls from an old Cloud payload", () => {
|
||||
draw();
|
||||
expect(screen.getByText(/Legacy state is not proof of diagnostic consent/)).toBeInTheDocument();
|
||||
expect(screen.queryByText("Stop all diagnostic participation")).not.toBeInTheDocument();
|
||||
expect(fixtures.patch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows off rather than actively warming and stops both directions with shared limits intact", async () => {
|
||||
fixtures.participation = { mode: "off", send: false, receive: false, shared_daily_limit: 8, rolling_recipient_limit: 11 };
|
||||
draw();
|
||||
expect(screen.getByText("Diagnostics off in Warmbly Cloud")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText("Stop all diagnostic participation"));
|
||||
await waitFor(() => expect(fixtures.patch).toHaveBeenCalledWith("mailbox", fixtures.participation));
|
||||
});
|
||||
|
||||
it("labels receive-only as sending paused, without claiming replies are authorized", () => {
|
||||
fixtures.participation = { mode: "diagnostic", send: false, receive: true };
|
||||
draw();
|
||||
expect(screen.getByText("Diagnostic sending paused in Cloud")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Receive-only does not authorize replies/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,10 @@ import { providerSupported } from "@/app/app/settings/warmbly-cloud/providers";
|
||||
import WarmupPartnerDiversity from "./WarmupPartnerDiversity";
|
||||
import WarmupSendFailureNote from "./WarmupSendFailureNote";
|
||||
import { cloudSendFailure, cloudWarmupPaused } from "@/lib/cloudWarmup";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { setCloudLinkParticipation } from "@/lib/api/client/app/cloudlink/cloudLink";
|
||||
import type { DiagnosticParticipation } from "@/lib/api/models/app/cloudlink/CloudLink";
|
||||
import { Toggle } from "@/components/app/campaigns/preferences/components/CampaignPreferenceBoolBox";
|
||||
|
||||
export default function CloudWarmupCard({ mailboxId, email, provider }: { mailboxId: string; email: string; provider: string }) {
|
||||
const pool = useCloudPool();
|
||||
@@ -22,7 +26,9 @@ export default function CloudWarmupCard({ mailboxId, email, provider }: { mailbo
|
||||
const unenroll = useUnenrollCloudLinkMailbox();
|
||||
const lifecycle = useCloudLinkMailboxLifecycle();
|
||||
const confirm = useConfirm();
|
||||
const busy = enroll.isPending || unenroll.isPending || lifecycle.isPending;
|
||||
const qc = useQueryClient();
|
||||
const participation = useMutation({ mutationFn: (p: DiagnosticParticipation) => setCloudLinkParticipation(mailboxId, p), onSuccess: () => qc.invalidateQueries({ queryKey: ["cloud-link"] }) });
|
||||
const busy = enroll.isPending || unenroll.isPending || lifecycle.isPending || participation.isPending;
|
||||
|
||||
if (!pool.manageable) return null;
|
||||
|
||||
@@ -63,7 +69,7 @@ export default function CloudWarmupCard({ mailboxId, email, provider }: { mailbo
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void run(() => enroll.mutateAsync(mailboxId), `${email} is now warming in the pool`)}
|
||||
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`))}
|
||||
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" />}
|
||||
@@ -74,7 +80,8 @@ export default function CloudWarmupCard({ mailboxId, email, provider }: { mailbo
|
||||
);
|
||||
}
|
||||
|
||||
const paused = cloudWarmupPaused(cloud);
|
||||
const p = cloud?.participation;
|
||||
const paused = cloudWarmupPaused(cloud) || !!p && !p.send;
|
||||
const health = cloud?.health?.state;
|
||||
return (
|
||||
<div className="px-5 py-4">
|
||||
@@ -85,7 +92,7 @@ export default function CloudWarmupCard({ mailboxId, email, provider }: { mailbo
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[12.5px] font-medium text-slate-900">
|
||||
{paused ? "Paused in Warmbly Cloud" : row.managed ? "Signed in through Warmbly Cloud" : "Warmed by Warmbly Cloud"}
|
||||
{p?.mode === "off" ? "Diagnostics off in Warmbly Cloud" : paused ? "Diagnostic sending paused in Cloud" : row.managed ? "Signed in through Warmbly Cloud" : "Automated diagnostics in Warmbly Cloud"}
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-500 truncate">
|
||||
{cloud
|
||||
@@ -125,6 +132,16 @@ export default function CloudWarmupCard({ mailboxId, email, provider }: { mailbo
|
||||
</div>
|
||||
</div>
|
||||
{sendFailure && <WarmupSendFailureNote failure={sendFailure} cloud className="pl-10" />}
|
||||
{p && <div className="mt-3 pl-10 space-y-2 text-xs">
|
||||
<label className="flex justify-between">Allow disclosed diagnostic sending<Toggle disabled={busy} value={p.send} onChange={(send) => {
|
||||
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>
|
||||
<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>
|
||||
</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 && (
|
||||
<p className="mt-2 pl-10 text-[11.5px] text-slate-500 leading-relaxed">
|
||||
{cloud.warmup.partner_limit.reachable === 0
|
||||
|
||||
@@ -13,6 +13,7 @@ import { DashboardImage } from "@/components/ui/dashboard-image";
|
||||
// validation. Read data: /analytics/accounts/:id and /analytics/warmup?email_id=.
|
||||
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { diagnosticSendingAllowed, diagnosticWarmupActive } from "@/lib/diagnosticParticipation";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import AdvisorStrip from "@/components/app/advisor/AdvisorStrip";
|
||||
import {
|
||||
@@ -257,18 +258,9 @@ function ColdRampNotice({ ramp }: { ramp: import("@/lib/api/models/app/analytics
|
||||
Easing into cold sending: {ramp.ceiling} of {ramp.mailbox_cap} a day
|
||||
</p>
|
||||
<p className="text-[11.5px] text-sky-800/90 leading-relaxed mt-0.5">
|
||||
{ramp.held ? (
|
||||
<>
|
||||
The climb is paused after a recent spam placement. It resumes on its own, then adds 5 a
|
||||
day until it reaches {ramp.mailbox_cap}.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Going straight from warmup to a full cold cap is the volume jump mailbox providers
|
||||
penalise, so this adds 5 a day instead. At this rate it reaches {ramp.mailbox_cap} in
|
||||
about {ramp.days_to_full_cap} {ramp.days_to_full_cap === 1 ? "day" : "days"}.
|
||||
</>
|
||||
)}
|
||||
{ramp.held ? "Recent negative feedback is holding the pacing policy. " : ""}
|
||||
Automated test history does not prove real-recipient readiness. This conservative policy
|
||||
requires recent human campaign replies before increasing volume. No full-speed date or safe volume is guaranteed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -405,6 +397,7 @@ const EDITABLE: (keyof Inbox)[] = [
|
||||
"warmup_base", "warmup_max", "warmup_increase", "warmup_reply_rate",
|
||||
"warmup_tag", "warmup_start_time", "warmup_end_time", "warmup_days",
|
||||
"warmup_placement", "warmup_folder", "warmup_retention_days",
|
||||
"test_mode", "test_send_enabled", "test_receive_enabled", "shared_daily_limit", "rolling_recipient_limit",
|
||||
];
|
||||
|
||||
function Detail({ mailbox, onClose, initialTab = "overview", canWarmup = true }: { mailbox: Inbox; onClose: () => void; initialTab?: string; canWarmup?: boolean }) {
|
||||
@@ -1249,22 +1242,26 @@ function WarmupTab({ form, update, status, mailbox, canWarmup = true }: { form:
|
||||
// back into cache on success.
|
||||
const life = useWarmupLifecycle(mailbox.id);
|
||||
const confirm = useConfirm();
|
||||
const off = !mailbox.warmup;
|
||||
const paused = !!mailbox.warmup && !!mailbox.warmup_paused_at;
|
||||
const active = !!mailbox.warmup && !mailbox.warmup_paused_at;
|
||||
const off = !mailbox.warmup || mailbox.test_mode === "off";
|
||||
const paused = !off && !!mailbox.warmup && (!!mailbox.warmup_paused_at || !diagnosticSendingAllowed(mailbox));
|
||||
const active = diagnosticWarmupActive(mailbox);
|
||||
// When Warmbly Cloud warms this mailbox the local controls step aside.
|
||||
const pool = useCloudPool();
|
||||
const inCloud = pool.connected && pool.isEnrolled(mailbox.id);
|
||||
|
||||
const run = (action: "start" | "pause" | "resume" | "stop", verb: string) =>
|
||||
life.mutate(action, {
|
||||
const run = (action: "start" | "pause" | "resume" | "stop", verb: string) => {
|
||||
const execute = () => life.mutate(action, {
|
||||
onSuccess: () => toast.success(`Warmup ${verb}`),
|
||||
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);
|
||||
} else execute();
|
||||
};
|
||||
|
||||
const stopReset = () => {
|
||||
confirm.show(
|
||||
"Stop warmup and reset ramp progress? Restarting begins from the base volume. Use Pause to keep progress.",
|
||||
"Stop all pool participation, including receiving tests and campaign health checks? Pending work is rechecked by updated workers; accepted provider sends cannot be recalled. Ramp history and safety holds are kept.",
|
||||
async () => {
|
||||
try {
|
||||
await life.mutateAsync("stop");
|
||||
@@ -1294,7 +1291,7 @@ function WarmupTab({ form, update, status, mailbox, canWarmup = true }: { form:
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[12.5px] font-medium text-slate-900">{active ? "Warming up" : paused ? "Paused" : "Warmup off"}</div>
|
||||
<div className="text-[11px] text-slate-400 truncate">{active ? "Building sender reputation" : paused ? "Ramp progress kept — resume anytime" : "Not building reputation"}</div>
|
||||
<div className="text-[11px] text-slate-400 truncate">{active ? "Automated diagnostic traffic, not organic engagement" : paused && !off ? "Sending paused; receiving permission is separate" : "Sending and receiving diagnostic tests disabled"}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
@@ -1321,7 +1318,7 @@ function WarmupTab({ form, update, status, mailbox, canWarmup = true }: { form:
|
||||
<button
|
||||
onClick={stopReset}
|
||||
disabled={life.isPending}
|
||||
title="Stop warmup and reset ramp progress"
|
||||
title="Stop sending and receiving diagnostic tests"
|
||||
className="h-8 px-3 rounded-md border border-slate-200 hover:border-rose-200 text-[12px] font-medium text-slate-600 hover:text-rose-600 inline-flex items-center gap-1.5 transition-colors disabled:opacity-60"
|
||||
>
|
||||
Stop
|
||||
@@ -1332,7 +1329,7 @@ function WarmupTab({ form, update, status, mailbox, canWarmup = true }: { form:
|
||||
<button
|
||||
onClick={stopReset}
|
||||
disabled={life.isPending}
|
||||
title="Stop warmup and reset ramp progress"
|
||||
title="Stop sending and receiving diagnostic tests"
|
||||
className="h-8 px-3 rounded-md border border-slate-200 hover:border-rose-200 text-[12px] font-medium text-slate-600 hover:text-rose-600 inline-flex items-center gap-1.5 transition-colors disabled:opacity-60"
|
||||
>
|
||||
Stop
|
||||
@@ -1346,12 +1343,32 @@ function WarmupTab({ form, update, status, mailbox, canWarmup = true }: { form:
|
||||
{!inCloud && off && !canWarmup && (
|
||||
<div className="px-5 py-4">
|
||||
<div className="rounded-md border border-sky-100 bg-sky-50/70 px-3 py-2.5 text-[11.5px] text-sky-900/90 leading-relaxed">
|
||||
Warmup is available on paid plans. Upgrade to build and protect sender reputation automatically.
|
||||
Paid plans include disclosed diagnostic traffic and placement monitoring, not a guaranteed reputation benefit.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live volume */}
|
||||
{!inCloud && (
|
||||
<div className="px-5 py-4 space-y-3 border-t border-slate-100">
|
||||
<Eyebrow>Diagnostic participation</Eyebrow>
|
||||
<select aria-label="Diagnostic participation mode" value={form.test_mode ?? "legacy"}
|
||||
onChange={(e) => update({ test_mode: e.target.value as Inbox["test_mode"], test_send_enabled: false, test_receive_enabled: false })}
|
||||
className="h-8 rounded border border-slate-200 text-sm">
|
||||
{(form.test_mode == null || form.test_mode === "legacy") && <option value="legacy" disabled>Legacy participation (unchanged until explicit action)</option>}
|
||||
<option value="diagnostic">Disclosed automated diagnostics</option>
|
||||
<option value="off">Off: no diagnostic sends or receipts</option>
|
||||
</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>
|
||||
</>}
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{ws && active && (
|
||||
<div className="px-5 py-4">
|
||||
<Eyebrow>Today</Eyebrow>
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
CloudLinkPollResult,
|
||||
CloudLinkStatus,
|
||||
PoolLinkWorkspaceMailbox,
|
||||
DiagnosticParticipation,
|
||||
} from "@/lib/api/models/app/cloudlink/CloudLink";
|
||||
import type Inbox from "@/lib/api/models/app/emails/Inbox";
|
||||
|
||||
@@ -49,6 +50,10 @@ export async function setCloudLinkMailboxLifecycle(id: string, action: "pause" |
|
||||
return await Request<CloudLinkMailboxRow>({ method: "POST", url: `/cloud-link/mailboxes/${id}/${action}`, authorization: true });
|
||||
}
|
||||
|
||||
export async function setCloudLinkParticipation(id: string, participation: DiagnosticParticipation): Promise<CloudLinkMailboxRow> {
|
||||
return await Request<CloudLinkMailboxRow>({ method: "PATCH", url: `/cloud-link/mailboxes/${id}/participation`, data: participation, authorization: true });
|
||||
}
|
||||
|
||||
// Cloud-managed mailboxes: Google/Microsoft sign-in through Warmbly Cloud's own
|
||||
// OAuth app, and adoption of mailboxes connected directly on the workspace.
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyWarmup } from "./useWarmupLifecycle";
|
||||
import type Inbox from "@/lib/api/models/app/emails/Inbox";
|
||||
|
||||
describe("explicit diagnostic lifecycle", () => {
|
||||
const started = new Date("2026-01-02T12:00:00Z");
|
||||
const mailbox = { id: "mailbox", warmup: started, test_mode: null, shared_daily_limit: 8, rolling_recipient_limit: 11 } as Inbox;
|
||||
|
||||
it("keeps untouched legacy input unchanged and transitions only on explicit action", () => {
|
||||
const row = applyWarmup(mailbox, "start");
|
||||
expect(mailbox.test_mode).toBeNull();
|
||||
expect(row).toMatchObject({ test_mode: "diagnostic", test_send_enabled: true, test_receive_enabled: true, warmup: started, shared_daily_limit: 8, rolling_recipient_limit: 11 });
|
||||
});
|
||||
|
||||
it("pauses sending while preserving legacy receiving and stops both without resetting history", () => {
|
||||
expect(applyWarmup(mailbox, "pause")).toMatchObject({ test_mode: "diagnostic", test_send_enabled: false, test_receive_enabled: true, warmup: started });
|
||||
expect(applyWarmup(mailbox, "stop")).toMatchObject({ test_mode: "off", test_send_enabled: false, test_receive_enabled: false, warmup: started });
|
||||
});
|
||||
|
||||
it("does not opt an explicit sender-only diagnostic into receiving when resumed", () => {
|
||||
const row = { ...mailbox, test_mode: "diagnostic", test_receive_enabled: false } as Inbox;
|
||||
expect(applyWarmup(row, "resume")).toMatchObject({ test_send_enabled: true, test_receive_enabled: false });
|
||||
});
|
||||
|
||||
it("starts a new default-off mailbox only on an explicit start", () => {
|
||||
const row = { ...mailbox, test_mode: "off", test_send_enabled: false, test_receive_enabled: false, warmup: null } as Inbox;
|
||||
expect(applyWarmup(row, "pause")).toMatchObject({ test_mode: "diagnostic", test_send_enabled: false, test_receive_enabled: false, warmup: null });
|
||||
expect(applyWarmup(row, "start")).toMatchObject({ test_mode: "diagnostic", test_send_enabled: true, test_receive_enabled: true });
|
||||
});
|
||||
});
|
||||
@@ -6,17 +6,17 @@ import patchEmailLists from "./patchEmailLists";
|
||||
import { mailboxMutationKey } from "./useUpdateEmail";
|
||||
|
||||
// The warmup fields each action leaves behind; ramp progress is the server's to keep.
|
||||
function applyWarmup(row: Inbox, action: WarmupAction): Inbox {
|
||||
export function applyWarmup(row: Inbox, action: WarmupAction): Inbox {
|
||||
const now = new Date();
|
||||
switch (action) {
|
||||
case "start":
|
||||
return { ...row, warmup: row.warmup ?? now, warmup_paused_at: null };
|
||||
return { ...row, warmup: row.warmup ?? now, warmup_paused_at: null, test_mode: "diagnostic", test_send_enabled: true, test_receive_enabled: row.test_mode == null || row.test_mode === "legacy" || row.test_mode === "off" ? true : row.test_receive_enabled };
|
||||
case "resume":
|
||||
return { ...row, warmup_paused_at: null };
|
||||
return { ...row, warmup: row.warmup ?? now, warmup_paused_at: null, test_mode: "diagnostic", test_send_enabled: true, test_receive_enabled: row.test_mode == null || row.test_mode === "legacy" || row.test_mode === "off" ? true : row.test_receive_enabled };
|
||||
case "pause":
|
||||
return { ...row, warmup_paused_at: now };
|
||||
return { ...row, warmup_paused_at: row.warmup ? row.warmup_paused_at ?? now : null, test_mode: "diagnostic", test_send_enabled: false, test_receive_enabled: row.test_mode == null || row.test_mode === "legacy" ? true : row.test_receive_enabled };
|
||||
case "stop":
|
||||
return { ...row, warmup: null, warmup_paused_at: null };
|
||||
return { ...row, warmup_paused_at: row.warmup ? row.warmup_paused_at ?? now : null, test_mode: "off", test_send_enabled: false, test_receive_enabled: false };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -131,6 +131,7 @@ export interface PoolLinkMailboxError {
|
||||
}
|
||||
|
||||
export interface PoolLinkMailboxState {
|
||||
participation?: DiagnosticParticipation;
|
||||
remote_id: string;
|
||||
email_account_id: string;
|
||||
email: string;
|
||||
@@ -151,6 +152,14 @@ export interface PoolLinkMailboxState {
|
||||
settings: PoolLinkWarmupSettings;
|
||||
}
|
||||
|
||||
export interface DiagnosticParticipation {
|
||||
mode: "legacy" | "diagnostic" | "off";
|
||||
send: boolean;
|
||||
receive: boolean;
|
||||
shared_daily_limit?: number | null;
|
||||
rolling_recipient_limit?: number | null;
|
||||
}
|
||||
|
||||
export interface CloudLink {
|
||||
cloud_url: string;
|
||||
instance_id: string;
|
||||
|
||||
@@ -28,6 +28,11 @@ export default interface Inbox {
|
||||
last_synced_at: Date;
|
||||
last_id?: number | null;
|
||||
campaign_limit: number;
|
||||
test_mode?: "legacy" | "diagnostic" | "off" | null;
|
||||
test_send_enabled?: boolean;
|
||||
test_receive_enabled?: boolean;
|
||||
shared_daily_limit?: number | null;
|
||||
rolling_recipient_limit?: number | null;
|
||||
min_wait_time: number;
|
||||
reply_to: string;
|
||||
/** SMTP/IMAP only: file a copy of each sent message in the mailbox Sent folder. */
|
||||
|
||||
@@ -11,5 +11,5 @@ export function cloudSendFailure(state?: PoolLinkMailboxState | null): WarmupSen
|
||||
|
||||
// Not warming on the cloud: paused, or reported with warmup never started there. Resume starts either.
|
||||
export function cloudWarmupPaused(state?: PoolLinkMailboxState | null): boolean {
|
||||
return !!state && (!state.warmup || state.warmup.paused);
|
||||
return !!state && (!state.warmup || state.warmup.paused || !!state.participation && (state.participation.mode === "off" || !state.participation.send));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type Inbox from "@/lib/api/models/app/emails/Inbox";
|
||||
import { diagnosticSendingAllowed, diagnosticWarmupActive } from "./diagnosticParticipation";
|
||||
import mailboxDisplayStatus from "./mailboxStatus";
|
||||
import { cloudWarmupPaused } from "./cloudWarmup";
|
||||
import type { PoolLinkMailboxState } from "@/lib/api/models/app/cloudlink/CloudLink";
|
||||
|
||||
describe("consent-aware mailbox activity", () => {
|
||||
const legacy = { status: "active", warmup: new Date("2020-01-01"), warmup_paused_at: null, test_mode: null } as Inbox;
|
||||
|
||||
it("preserves old NULL/legacy activity without presenting age as readiness", () => {
|
||||
expect(diagnosticWarmupActive(legacy)).toBe(true);
|
||||
expect(diagnosticSendingAllowed({ ...legacy, test_mode: "legacy" })).toBe(true);
|
||||
expect(mailboxDisplayStatus(legacy)).toBe("warming");
|
||||
});
|
||||
|
||||
it("never displays off or receive-only accounts as sending diagnostics despite old warmup timestamps", () => {
|
||||
const off = { ...legacy, test_mode: "off" } as Inbox;
|
||||
const receiver = { ...legacy, test_mode: "diagnostic", test_send_enabled: false, test_receive_enabled: true } as Inbox;
|
||||
expect(diagnosticWarmupActive(off)).toBe(false);
|
||||
expect(diagnosticWarmupActive(receiver)).toBe(false);
|
||||
expect(mailboxDisplayStatus(receiver)).toBe("paused");
|
||||
expect(mailboxDisplayStatus(off)).not.toBe("warming");
|
||||
expect(diagnosticWarmupActive({ ...receiver, test_send_enabled: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("respects Cloud off/receive-only while preserving unsupported legacy shape", () => {
|
||||
const old = { warmup: { paused: false } } as PoolLinkMailboxState;
|
||||
expect(cloudWarmupPaused(old)).toBe(false);
|
||||
expect(cloudWarmupPaused({ ...old, participation: { mode: "off", send: false, receive: false } })).toBe(true);
|
||||
expect(cloudWarmupPaused({ ...old, participation: { mode: "diagnostic", send: false, receive: true } })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import type Inbox from "@/lib/api/models/app/emails/Inbox";
|
||||
|
||||
type Participation = Pick<Inbox, "test_mode" | "test_send_enabled" | "test_receive_enabled">;
|
||||
|
||||
export function diagnosticSendingAllowed(row: Participation): boolean {
|
||||
return row.test_mode == null || row.test_mode === "legacy" || row.test_mode === "diagnostic" && row.test_send_enabled === true;
|
||||
}
|
||||
|
||||
export function diagnosticWarmupActive(row: Participation & Pick<Inbox, "warmup" | "warmup_paused_at">): boolean {
|
||||
return diagnosticSendingAllowed(row) && !!row.warmup && !row.warmup_paused_at;
|
||||
}
|
||||
@@ -1,20 +1,12 @@
|
||||
import type Inbox from "@/lib/api/models/app/emails/Inbox";
|
||||
import { diagnosticSendingAllowed, diagnosticWarmupActive } from "./diagnosticParticipation";
|
||||
|
||||
export type MailboxDisplayStatus = "healthy" | "warming" | "paused" | "inactive";
|
||||
|
||||
// The API returns the raw account status (active | inactive | revoked); the
|
||||
// lifecycle the UI talks about is derived from the warmup fields. An active
|
||||
// account mid-ramp is "warming"; a finished ramp, or an active account that
|
||||
// isn't warming, is "healthy".
|
||||
// Account activity is not proof of deliverability or reputation readiness.
|
||||
export default function mailboxDisplayStatus(box: Inbox): MailboxDisplayStatus {
|
||||
if (box.status !== "active") return "inactive";
|
||||
if (box.warmup && box.warmup_paused_at) return "paused";
|
||||
if (box.warmup) {
|
||||
const days = Math.floor(
|
||||
(Date.now() - new Date(box.warmup).getTime()) / 86_400_000,
|
||||
);
|
||||
const target = box.warmup_base + days * box.warmup_increase;
|
||||
if (target < box.warmup_max) return "warming";
|
||||
}
|
||||
if (box.test_mode !== "off" && box.warmup && (box.warmup_paused_at || !diagnosticSendingAllowed(box))) return "paused";
|
||||
if (diagnosticWarmupActive(box)) return "warming";
|
||||
return "healthy";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user