diff --git a/AGENTS.md b/AGENTS.md
index 3d2d21143..e95245b1f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -791,9 +791,16 @@ Do not wait for an inbox to reach an extreme failure state before acting.
Important:
-- `80%` spam placement is not a sensible block threshold
-- if a mailbox is landing in spam `80%` of the time, it has already become dangerous to the shared pool
-- action should happen much earlier
+- complaints, bounces and tampering are things a mailbox does to other people, and they act early
+- spam placement is a reading of reputation, not misconduct, and warming is how it recovers, so it only ever slows a mailbox down: watch at `10%`, throttled (half volume, warmup keeps running) at `20%`, and nothing past that. It never quarantines, blocks or needs an appeal. Do not add a placement band above throttled
+- a band a mailbox is already in lifts only below `0.75` of the line that set it (`spamPlacementExitFactor`), so a mailbox near a line is not flipped and announced on every delivery
+
+How placement is read (`WarmupPlacementEvidence` in `internal/models/warmup_deliverability.go`, `placementEvidenceSQL` in `internal/repository/warmup_placement_sql.go`, the one definition behind the health bands and the advisor):
+
+- over verified deliveries (`warmup_received`), not over sends
+- only Google, Microsoft and Yahoo recipients judge a sender. They filter on sender reputation, which is what cold mail is judged on; a small host runs its own filter, so its spam folder is not evidence of spam and is never held against a sender, in the bands, the ramps (`majorRecipientSQL`) or the advisor. A host that junks half of everything once froze the ramp permanently and quarantined healthy mailboxes
+- the headline inbox rate (`WarmupPlacementWindow.Rate`) is taken at the same three providers, and over every host only when none of them received the mailbox's mail in the window (`scope: "all"`)
+- partner selection draws a small-host recipient whose own filter junks what it receives less often (`FilterJunkRate`, `recipientFilterPenaltyK`), never excludes it, and never reads this at the big three, where a junk verdict is the senders' reputation
Use separate metrics for separate failure modes:
@@ -805,35 +812,33 @@ Use separate metrics for separate failure modes:
Recommended internal policy for shared paid pools:
- start evaluating after a minimum sample size
-- suggested sample floor for spam placement: at least `20` warmup deliveries in the last `7 days`
+- sample floor for spam placement: at least `20` verified warmup deliveries in the last `7 days`
- suggested sample floor for complaints: at least `100` delivered emails in the last `30 days`
Suggested automatic actions:
- warning band:
- spam-folder placement `>= 10%` over the last `20+` warmup deliveries
+ spam placement at the big three `>= 10%` over the last `20+` verified warmup deliveries there
or complaint rate `>= 0.03%`
Action: lower warmup volume, increase spacing, increase monitoring
+- throttle band:
+ spam placement at the big three `>= 20%`
+ Action: warmup keeps running at half volume and double spacing; cold volume halved; lifts on its own
+
- quarantine band:
- spam-folder placement `>= 20%`
- or complaint rate `>= 0.10%`
+ complaint rate `>= 0.10%`
or bounce rate `>= 5%`
or repeated tampering with received warmup mail
- Action: immediately remove mailbox from the shared paid warmup pool for `7 days`
+ Action: immediately remove mailbox from the shared paid warmup pool for `7 days`; cold sending paused
- hard block band:
- spam-folder placement `>= 40%`
- or complaint rate `>= 0.30%`
+ complaint rate `>= 0.30%`
or bounce rate `>= 10%`
or clear abuse indicators such as repeated spam flags on received warmup mail
- Action: block mailbox from shared paid pool for `30 days` and require review before re-entry
+ Action: block mailbox from shared paid pool for `30 days`
-- catastrophic band:
- spam-folder placement `>= 80%`
- Action: immediate long-duration block and full reputation reset workflow; do not allow the mailbox back into the shared paid pool automatically
-
-These thresholds are intentionally stricter than the point where large providers start penalizing senders, because shared warmup pools should act before provider-level enforcement hits the IP reputation.
+The complaint and bounce thresholds are intentionally stricter than the point where large providers start penalizing senders, because shared warmup pools should act before provider-level enforcement hits the IP reputation.
### What should happen when a paid-pool mailbox is quarantined
@@ -858,7 +863,7 @@ Do not automatically restore a blocked mailbox just because time elapsed.
Two mechanisms make the sentence real, and both are easy to undo by accident:
-- a quarantine or block holds until `blocked_until` whatever fresh metrics say. The floor is inside `UpdateParticipantHealth`'s SQL (`internal/repository/pg_warmup.go`), decided against the row at write time, so it is compare-and-swap and an admin unblock landing mid-sweep is not overwritten by the block the sweep read earlier. Equal severity keeps the later end (a 90-day catastrophic block is not cut to 30 by a milder reading); throttled is not floored because the docs promise it lifts on recovery. The bands read windows shorter than the terms they hand out (seven days of placement against a 30-day block), so without this every block cleared within a week, and a re-added mailbox with no history on the next sweep
+- a quarantine or block holds until `blocked_until` whatever fresh metrics say. The floor is inside `UpdateParticipantHealth`'s SQL (`internal/repository/pg_warmup.go`), decided against the row at write time, so it is compare-and-swap and an admin unblock landing mid-sweep is not overwritten by the block the sweep read earlier. Equal severity keeps the later end (a 30-day block is not cut to 7 by a milder reading); throttled is not floored because the docs promise it lifts on recovery. The bands read windows shorter than the terms they hand out (seven days of placement against a 30-day block), so without this every block cleared within a week, and a re-added mailbox with no history on the next sweep
- the standing follows the address within the workspace: `warmup_reputation_ledger` is a mirror of the address's worst live standing, written only by the `warmup_reputation_mirror` trigger on `warmup_pool_participants` (migration 000152, scoped to the standing columns by 000156 so a pool move does not restart the retention window), so every path that writes a standing keeps it current and no caller can bypass it. The pool row dies on paths that never touch the mailbox (`LeaveAllPools` on an auth error, a lapsed plan, warmup toggled off) and on `HardDeleteUser`'s cascade, which is why a snapshot at mailbox deletion was not enough. `MoveToPool` seeds a new row from it and never consumes it; `Delete` and `LeaveAllPools` only restart its retention window (`config.WarmupReputationLedgerDays`, applied by the purge in `EvaluateAllParticipants`, never while a live row backs it). A review-required block (`blocked_until NULL`) never lapses. A mailbox in good standing has no row, and recovery clears it (#476)
Require the mailbox to pass re-entry checks such as:
diff --git a/docs/content/docs/api/reference/analytics.mdx b/docs/content/docs/api/reference/analytics.mdx
index f4dffc231..9a482eedf 100644
--- a/docs/content/docs/api/reference/analytics.mdx
+++ b/docs/content/docs/api/reference/analytics.mdx
@@ -244,7 +244,7 @@ Returns where warmup mail landed in partners' mailboxes over a date range: the p
Every figure is measured, not estimated: a delivery is counted when the recipient's own sync finds the warmup email and records the folder it arrived in. `rescued` counts spam placements the recipient's mailbox was told to move back to the inbox; the move itself is not confirmed back, so it is the rescues requested, not a verified count. `unconfirmed` counts completed sends more than 24 hours old that no recipient has reported seeing, bucketed on the day they were sent; a provider filter cannot split it, so it is only reported unfiltered.
-`rate` is the headline deliverability figure the dashboard shows next to each mailbox: `inbox_rate` is inbox plus tabs over everything delivered in the trailing 7 UTC days, and stays `null` until `min_sample` (`20`) deliveries are in. `band` is `good` at `90` or above, `fair` from `80`, `poor` below `80`, `collecting` below the sample floor and `none` with no deliveries. Each day's `rolling_inbox_rate` is the same trailing figure ending on that day. Rates are percentages with two decimals.
+`rate` is the headline deliverability figure the dashboard shows next to each mailbox: `inbox_rate` is inbox plus tabs over what was delivered in the trailing 7 UTC days, and stays `null` until `min_sample` (`20`) deliveries are in. `scope` says which deliveries: `major` is Google, Microsoft and Yahoo recipients only, the providers that filter on sender reputation, and `all` is every host, used only when none of those three received the mailbox's warmup mail in the window. Other hosts are always in `summary`, `daily` and `providers`, and beside a `major` rate `other_delivered` and `other_inbox_rate` give their share of the same window (`0` and `null` with none). `band` is `good` at `90` or above, `fair` from `80`, `poor` below `80`, `collecting` below the sample floor and `none` with no deliveries. Each day's `rolling_inbox_rate` is the same trailing figure ending on that day. Rates are percentages with two decimals.
Recipient providers are grouped as `google` (Gmail and Google Workspace), `microsoft` (Outlook.com and Microsoft 365), `yahoo` (Yahoo and AOL) and `other`; each group lists its `hosts`, where an empty `host` means the recipient's host was not detected yet.
@@ -267,7 +267,7 @@ A range longer than 366 days, `from` after `to`, a malformed date or an invalid
"rescued": 44, "unconfirmed": 12, "inbox_rate": 93.93, "spam_rate": 6.07
},
"rate": {
- "window_days": 7, "min_sample": 20, "delivered": 196, "inbox": 180, "tabs": 9, "spam": 7,
+ "window_days": 7, "min_sample": 20, "scope": "major", "delivered": 196, "inbox": 180, "tabs": 9, "spam": 7,
"inbox_rate": 96.43, "band": "good"
},
"daily": [
@@ -562,6 +562,7 @@ Returns the detailed status for one email account: a combined health score (fold
"warmup_placement": {
"window_days": 7,
"min_sample": 20,
+ "scope": "major",
"delivered": 142,
"inbox": 126,
"tabs": 5,
diff --git a/docs/content/docs/api/reference/mailboxes.mdx b/docs/content/docs/api/reference/mailboxes.mdx
index a184bb915..c0acd3685 100644
--- a/docs/content/docs/api/reference/mailboxes.mdx
+++ b/docs/content/docs/api/reference/mailboxes.mdx
@@ -601,7 +601,7 @@ Auth: **Scope** `READ_EMAILS` · **Org permission** `view_campaigns`
"email_account_id": "0c0f1a2b-3c4d-5e6f-7a8b-9c0d1e2f3a4b",
"blocked": true,
"health_state": "quarantined",
- "reason": "spam-folder placement above threshold",
+ "reason": "complaint rate 0.15% exceeded quarantine threshold",
"blocked_at": "2026-06-09T14:00:00Z",
"blocked_until": "2026-06-16T14:00:00Z",
"can_appeal": true,
diff --git a/docs/content/docs/guides/advisor.mdx b/docs/content/docs/guides/advisor.mdx
index 1d28b4c6b..8af47c7bc 100644
--- a/docs/content/docs/guides/advisor.mdx
+++ b/docs/content/docs/guides/advisor.mdx
@@ -49,7 +49,7 @@ Only the first two badge a nav tab.
## What it checks
-**Deliverability**: complaint rate per mailbox (warns `0.03%`, critical `0.10%`, matching Google's bulk-sender ceiling); bounce rate (warns `3%`, critical `5%`, where SES opens a review); spam placement on warmup mail (warns `10%`, escalates `20%`, critical `40%`); SPF and DMARC per sending domain (critical while sending cold, since Google requires them; an unchecked domain is never called failing, and DKIM is never reported missing because its selector is not discoverable from DNS); and whether a mailbox has its own verified tracking domain.
+**Deliverability**: complaint rate per mailbox (warns `0.03%`, critical `0.10%`, matching Google's bulk-sender ceiling); bounce rate (warns `3%`, critical `5%`, where SES opens a review); spam placement on warmup mail at Google, Microsoft and Yahoo (warns `10%`, escalates `20%`, critical `50%`; other mail hosts are not counted, see [how spam placement is judged](/guides/warmup/#how-spam-placement-is-judged)); SPF and DMARC per sending domain (critical while sending cold, since Google requires them; an unchecked domain is never called failing, and DKIM is never reported missing because its selector is not discoverable from DNS); and whether a mailbox has its own verified tracking domain.
**Mailbox configuration**: daily cap above the `50`/day safe band (gently with clean numbers, firmly without); a mailbox under `30` days old sending above `20`/day; a send gap under `5` minutes; unresolved errors on a mailbox campaigns still use; too few mailboxes carrying total volume; an inactive mailbox attached to a running campaign.
diff --git a/docs/content/docs/guides/billing.mdx b/docs/content/docs/guides/billing.mdx
index 11e190f13..85f2cd095 100644
--- a/docs/content/docs/guides/billing.mdx
+++ b/docs/content/docs/guides/billing.mdx
@@ -23,7 +23,7 @@ Unlimited mailboxes means exactly that, under a fair-use allowance of one mailbo
The Warmup plan is for a workspace that only warms mailboxes and does not send from Warmbly Cloud: $15 a month, or $144 a year. It moves the workspace's mailboxes into the premium warmup pool, which is built for inbox placement:
- **Better deliverability.** Warmup mail is exchanged with paying senders first. A free mailbox is let in only after it has been a healthy pool member for at least three days, in a workspace in good standing.
-- **Reputation protected.** A partner is pulled from the pool at 20% spam placement, well before the point where mailbox providers start penalizing, so a struggling mailbox cannot drag yours down. See [pool safety](/guides/warmup/#pool-safety).
+- **Reputation protected.** A partner that complains, bounces or tampers with warmup mail is pulled from the pool well before mailbox providers would act, and one landing in spam is slowed down, so a struggling mailbox cannot drag yours down. See [pool safety](/guides/warmup/#pool-safety).
- **Priority matching.** Premium mailboxes are given partners before the free pool is.
- **No mailbox cap.** Every mailbox the workspace holds warms, whether it is connected on the cloud or through a linked self-hosted instance.
diff --git a/docs/content/docs/guides/campaigns.mdx b/docs/content/docs/guides/campaigns.mdx
index 2ecb8e1e7..9a0e76c0e 100644
--- a/docs/content/docs/guides/campaigns.mdx
+++ b/docs/content/docs/guides/campaigns.mdx
@@ -349,7 +349,7 @@ The mailbox starts at a volume set by how long it warmed, then adds `5` a day un
| 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 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.
+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.
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.
diff --git a/docs/content/docs/guides/deliverability.mdx b/docs/content/docs/guides/deliverability.mdx
index e52ee5469..a76130892 100644
--- a/docs/content/docs/guides/deliverability.mdx
+++ b/docs/content/docs/guides/deliverability.mdx
@@ -40,19 +40,23 @@ Each mailbox sits in a band, shown as a colored chip. The band controls how Warm
| Band | Spam placement | Complaint rate | Bounce rate | Other triggers |
| --- | --- | --- | --- | --- |
| Watch | `>= 10%` | `>= 0.03%` | n/a | n/a |
-| Quarantine | `>= 20%` | `>= 0.10%` | `>= 5%` | Repeated tampering with received warmup mail |
-| Blocked | `>= 40%` | `>= 0.30%` | `>= 10%` | Clear abuse signals (repeated spam flags on received warmup mail) |
-| Catastrophic | `>= 80%` | n/a | n/a | n/a |
+| Throttled | `>= 20%` | n/a | n/a | n/a |
+| Quarantine | n/a | `>= 0.10%` | `>= 5%` | Repeated tampering with received warmup mail |
+| Blocked | n/a | `>= 0.30%` | `>= 10%` | Clear abuse signals (repeated spam flags on received warmup mail) |
A mailbox enters a band by crossing any single threshold. What each band does:
- **Healthy**: sending normally, nothing to do.
-- **Watch**: warmup volume lowered and spacing increased. Cold volume is dampened by the same factor, so it slows across both before reaching quarantine.
-- **Quarantine**: out of the shared paid warmup pool for `7 days`, as neither sender nor recipient. Cold sending is throttled or paused.
-- **Blocked**: out of the shared pool for `30 days`, review required before re-entry.
-- **Catastrophic**: immediate long block and a full reputation reset. No automatic return.
+- **Watch**: warmup volume lowered and spacing increased. Cold volume is dampened by the same factor, so it slows across both before anything stricter. Lifts once spam placement is back below `7.5%`.
+- **Throttled**: warmup keeps running at half volume with double spacing, because warming is how placement recovers, and cold volume is halved. Lifts once spam placement is back below `15%`.
+- **Quarantine**: out of the shared paid warmup pool for `7 days`, as neither sender nor recipient. Cold sending is paused.
+- **Blocked**: out of the shared pool for `30 days`.
-**Sample floors** stop one bad event flipping a new mailbox: spam placement needs `20+` warmup deliveries in `7 days`, complaints need `100+` delivered emails in `30 days`.
+Spam placement never goes past throttled. It is a reading of reputation, and warming is how a mailbox recovers, so it slows the mailbox down and lifts on its own; there is nothing to appeal.
+
+**Sample floors** stop one bad event flipping a new mailbox: spam placement needs `20+` verified warmup deliveries in `7 days`, complaints need `100+` delivered emails in `30 days`.
+
+Spam placement is judged at Google, Microsoft and Yahoo only. Other mail hosts run their own filters, so their spam folders are shown and never held against a mailbox; see [how spam placement is judged](/guides/warmup/#how-spam-placement-is-judged).
Warmbly never acts on the composite score alone. Complaint rate, spam placement, and bounce rate are tracked separately because they fail for different reasons, so any one can move a mailbox even while the score still looks fine.
diff --git a/docs/content/docs/guides/warmbly-cloud.mdx b/docs/content/docs/guides/warmbly-cloud.mdx
index 0a2e4120d..03dd2b62a 100644
--- a/docs/content/docs/guides/warmbly-cloud.mdx
+++ b/docs/content/docs/guides/warmbly-cloud.mdx
@@ -86,7 +86,7 @@ The Warmup plan is the same plan a hosted workspace buys when it only warms and
## Safety and enforcement
-Enrolled mailboxes are ordinary members of the hosted pool and are held to the same rules: verification tokens on every warmup message, spam placement and complaint tracking, and automatic quarantine or blocking when a mailbox starts hurting partners. A mailbox that gets quarantined on the cloud shows that state on your instance, stops being offered to partners, and re-enters on the same probation terms as any hosted mailbox.
+Enrolled mailboxes are ordinary members of the hosted pool and are held to the same rules: verification tokens on every warmup message, complaint and bounce tracking with automatic quarantine or blocking when a mailbox starts hurting partners, and spam placement that slows a mailbox down. A mailbox that gets quarantined on the cloud shows that state on your instance, stops being offered to partners, and re-enters on the same probation terms as any hosted mailbox.
For mailboxes signed in through Warmbly Cloud the enforcement reaches sending too. Every access token the instance draws is checked on the cloud: a mailbox the cloud has **blocked**, one that is no longer active there, one that was removed from the workspace, or an instance whose link was revoked gets no token, and the instance stops sending and syncing from that mailbox as soon as its cached token expires.
diff --git a/docs/content/docs/guides/warmup.mdx b/docs/content/docs/guides/warmup.mdx
index a580d54bf..93f567417 100644
--- a/docs/content/docs/guides/warmup.mdx
+++ b/docs/content/docs/guides/warmup.mdx
@@ -94,9 +94,9 @@ The **inbox rate** is inbox plus category tabs over everything delivered in the
| `80%` to `90%` | Watch |
| Below `80%` | Landing in spam |
-A rate only appears once `20` deliveries 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`.
+The rate is taken at Google, Microsoft and Yahoo, the providers that judge a mailbox, and over every host only when none of them received its warmup mail in the window. 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 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 the same reading feeds [pool safety](#pool-safety). 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 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.
## The ramp
@@ -116,7 +116,7 @@ Warmup hours are read in the mailbox's timezone: its own when one is set on the
### Holding the ramp on an early signal
-If any warmup email lands in a recipient's spam folder, that mailbox stops climbing immediately:
+If any warmup email lands in the spam folder of a Google, Microsoft or Yahoo recipient, that mailbox stops climbing immediately:
- today's target is cut by about a quarter, for 48 hours
- the daily increase pauses for three days
@@ -124,7 +124,9 @@ If any warmup email lands in a recipient's spam folder, that mailbox stops climb
Another placement during a pause extends it and does not lift the ramp: repeated spam placement can only ever slow a mailbox down.
-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 warmup sends 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.
+A landing at any other mail host never holds the ramp. Those hosts each run their own filter, so their spam folder says more about the server than about your mailbox; see [how spam placement is judged](#how-spam-placement-is-judged).
+
+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.
@@ -213,6 +215,22 @@ Shared pools only work if participants behave, so every mailbox is judged on rat
| `quarantined` | Clear problem | Out of the shared pool for a cooldown |
| `blocked` | Serious or repeated abuse | Out for longer, review required |
+The thresholds for each signal are in the [health bands](/guides/deliverability/#health-bands) table.
+
+### 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:
+
+- `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.
+- A watch lifts below `7.5%` and a throttle below `15%`, on their own. They lift below the line that set them rather than at it, so a mailbox sitting near a line is not flipped in and out of a band (and announced to your integrations) with every delivery.
+
+Only Google, Microsoft and Yahoo recipients judge a mailbox. They filter on sender reputation, which is what your cold email is judged on at those same providers. Every other mail host runs its own filter, often a stock rule set tuned for that one server, so a message landing in its spam folder is not evidence that the mail is spam. Those landings are shown in the placement view and never held against the mailbox, in the bands, the ramp or the [Advisor](/guides/advisor/). The rate is taken over verified deliveries at the three providers in the last seven days, and needs `20` of them before it acts.
+
+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.
+
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 marking one as spam counts as two, 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 deleting one fresh warmup message is a warning, not a ban, while flagging pool mail as spam twice is a block.
@@ -220,7 +238,7 @@ What a mailbox does to warmup mail it received is held against it, on a ladder r
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.
- Warmbly intervenes well before providers would penalize a mailbox. One landing in spam a large share of the time is already dangerous to the pool, so it is throttled or removed rather than left collecting negative signals.
+ 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.
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.
diff --git a/docs/content/docs/learn/email-warmup.mdx b/docs/content/docs/learn/email-warmup.mdx
index f63ecd244..2a2d69932 100644
--- a/docs/content/docs/learn/email-warmup.mdx
+++ b/docs/content/docs/learn/email-warmup.mdx
@@ -60,12 +60,14 @@ In Warmbly, free-tier mailboxes never silently appear in the premium pool. Even
## When to quarantine a mailbox
-Most platforms wait until a mailbox is landing in spam 80% of the time before they pull it. By then the reputation damage is already done. Acting earlier protects everyone in the shared pool. Our policy:
+Complaints, bounces and tampering are things a mailbox does to other people, so they act early. Spam placement is different: it is a reading of reputation, and warming is how reputation recovers, so pulling a struggling mailbox out of warmup takes away the one thing that would fix it. Placement only ever slows a mailbox down. Our policy:
-- **Watch**: spam placement ≥ 10% over a 20-delivery sample. Lower volume, increase spacing.
-- **Quarantine**: spam placement ≥ 20%, or complaint rate ≥ 0.10%, or bounce rate ≥ 5%. 7-day removal from the paid pool.
-- **Block**: spam placement ≥ 40%, or complaint rate ≥ 0.30%. 30-day block, manual review to re-enter.
-- **Catastrophic**: spam placement ≥ 80%. Long block plus reputation reset, with no automatic re-entry.
+- **Watch**: spam placement ≥ 10% over a 20-delivery sample, or complaint rate ≥ 0.03%. Lower volume, increase spacing.
+- **Throttled**: spam placement ≥ 20%. Warmup keeps running at half volume; cold volume is halved too. Lifts by itself once placement recovers.
+- **Quarantine**: complaint rate ≥ 0.10%, or bounce rate ≥ 5%, or repeated tampering with received warmup mail. 7-day removal from the paid pool.
+- **Block**: complaint rate ≥ 0.30%, or bounce rate ≥ 10%. 30-day block.
+
+Which mailbox provider filed a message matters too. Google, Microsoft and Yahoo filter on sender reputation, the same reputation your cold email is judged on, so only their verdicts count. A small mail host runs its own filter with its own rules, so a message in its spam folder is not evidence that the mail is spam, and it is never held against the sender.
## Practical checklist
diff --git a/docs/content/docs/learn/reputation-recovery.mdx b/docs/content/docs/learn/reputation-recovery.mdx
index a35d381e9..7cd232caa 100644
--- a/docs/content/docs/learn/reputation-recovery.mdx
+++ b/docs/content/docs/learn/reputation-recovery.mdx
@@ -15,7 +15,7 @@ Before you start a recovery plan, decide whether the mailbox is worth recovering
- Pause every [campaign](/guides/campaigns/) sending from the affected mailbox immediately.
- Remove the mailbox from active sender pools in your campaigns.
-- If the mailbox is in the [warmup pool](/learn/warmup-pools/), let the platform's auto-quarantine kick in (it likely already has).
+- If the mailbox is in the [warmup pool](/learn/warmup-pools/), keep warmup running. Warmbly slows a mailbox landing in spam down on its own and keeps it warming, because warming is how placement recovers.
- Do not "send a few test emails to gauge". Every additional send while reputation is poor is another data point in the wrong column.
## Audit the basics
diff --git a/docs/public/openapi.json b/docs/public/openapi.json
index a2f67acef..0cfd1f713 100644
--- a/docs/public/openapi.json
+++ b/docs/public/openapi.json
@@ -39661,6 +39661,14 @@
"min_sample": {
"type": "integer"
},
+ "scope": {
+ "type": "string",
+ "enum": [
+ "major",
+ "all"
+ ],
+ "description": "major: taken over Google, Microsoft and Yahoo recipients only. all: every host, when none of them received the mailbox's warmup mail in the window."
+ },
"delivered": {
"type": "integer"
},
@@ -39688,6 +39696,17 @@
"collecting",
"none"
]
+ },
+ "other_delivered": {
+ "type": "integer",
+ "description": "Deliveries at other mail hosts, left out of a major-scope rate; 0 on an all-scope rate."
+ },
+ "other_inbox_rate": {
+ "type": [
+ "number",
+ "null"
+ ],
+ "description": "Inbox rate at those other hosts, shown beside the rate and never judged; null with none."
}
}
},
diff --git a/internal/app/advisor/detect_deliverability.go b/internal/app/advisor/detect_deliverability.go
index ad21f4f31..7f6924cb0 100644
--- a/internal/app/advisor/detect_deliverability.go
+++ b/internal/app/advisor/detect_deliverability.go
@@ -160,14 +160,11 @@ func detectBounceRate(s *repository.AdvisorSnapshot) []Finding {
func detectSpamPlacement(s *repository.AdvisorSnapshot) []Finding {
out := []Finding{}
for _, m := range s.Mailboxes {
- // Warmup deliveries are the denominator: the mail we know landed
- // somewhere, and could observe the folder for.
- delivered := m.WarmupSent7d
- if delivered < minWarmupDeliveriesForPlacement || m.WarmupSpam7d == 0 {
- continue
- }
- r := rate(m.WarmupSpam7d, delivered)
- if r < spamPlacementWarn {
+ // The pool's own reading, over verified deliveries at Google,
+ // Microsoft and Yahoo, so the advice and the band cannot disagree.
+ p := m.WarmupPlacement
+ r, delivered := p.Judged()
+ if delivered < minWarmupDeliveriesForPlacement || r < spamPlacementWarn {
continue
}
@@ -194,8 +191,8 @@ func detectSpamPlacement(s *repository.AdvisorSnapshot) []Finding {
Impact: clampImpact(45 + int(r)),
Title: fmt.Sprintf("%s is landing in spam %s of the time", m.Email, pct(r)),
Detail: fmt.Sprintf(
- "%d of %d warmup messages from %s were filed into spam by the receiving inbox in the last 7 days. Warmup placement is the earliest honest read on where cold mail is landing, because it is measured on mail the platform controls end to end.",
- m.WarmupSpam7d, delivered, m.Email),
+ "Over the last 7 days %d of %d warmup messages from %s delivered at Google, Microsoft and Yahoo went to spam. Warmup placement is the earliest honest read on where cold mail is landing, because it is measured on mail the platform controls end to end. Other mail hosts run their own filters and are not counted.",
+ p.MajorSpam, p.MajorDelivered, m.Email),
Remedy: remedy,
Steps: []string{
"Check SPF, DKIM and DMARC on this domain first. Authentication is the single biggest cause of spam placement, and no amount of volume tuning compensates for it.",
@@ -205,13 +202,13 @@ func detectSpamPlacement(s *repository.AdvisorSnapshot) []Finding {
"Give it a week and check this number again before putting the mailbox back into full rotation.",
},
Evidence: map[string]any{
- "mailbox": m.Email,
- "warmup_spam_7d": m.WarmupSpam7d,
- "warmup_delivered_7d": delivered,
- "spam_placement_percent": band(r),
- "quarantine_band_percent": spamPlacementQuarantine,
- "currently_sending_cold": m.InActiveCampaign,
- "current_daily_cap": m.CampaignLimit,
+ "mailbox": m.Email,
+ "major_provider_spam_7d": p.MajorSpam,
+ "major_provider_delivered_7d": p.MajorDelivered,
+ "spam_placement_percent": band(r),
+ "quarantine_band_percent": spamPlacementQuarantine,
+ "currently_sending_cold": m.InActiveCampaign,
+ "current_daily_cap": m.CampaignLimit,
},
}
diff --git a/internal/app/advisor/detect_test.go b/internal/app/advisor/detect_test.go
index 41c7d88ad..34423dd66 100644
--- a/internal/app/advisor/detect_test.go
+++ b/internal/app/advisor/detect_test.go
@@ -242,18 +242,16 @@ func TestWarmupOffWhileSendingIsCriticalOnANewMailbox(t *testing.T) {
func TestSpamPlacementRespectsItsSampleFloorAndBands(t *testing.T) {
m := healthyMailbox()
- m.WarmupSent7d = minWarmupDeliveriesForPlacement - 1
- m.WarmupSpam7d = 5
+ m.WarmupPlacement = models.WarmupPlacementEvidence{MajorDelivered: minWarmupDeliveriesForPlacement - 1, MajorSpam: 5}
if _, fired := findingsByKey(Detect(snapshotOf(m), defaults()))["mailbox_spam_placement"]; fired {
t.Fatal("placement detector fired below its delivery floor")
}
- m.WarmupSent7d = 100
- m.WarmupSpam7d = 45 // 45%: past the hard-block band.
+ m.WarmupPlacement = models.WarmupPlacementEvidence{MajorDelivered: 100, MajorSpam: 55} // past the quarantine line
f := findingsByKey(Detect(snapshotOf(m), defaults()))["mailbox_spam_placement"]
if f.Severity != models.AdvisorCritical {
- t.Errorf("45%% spam placement should be critical, got %q", f.Severity)
+ t.Errorf("55%% spam placement should be critical, got %q", f.Severity)
}
if f.Action == nil {
t.Error("a mailbox this deep in spam while sending cold should offer to stop")
@@ -464,8 +462,7 @@ func TestEveryFindingIsSelfContained(t *testing.T) {
m.AuthDMARC = false
m.AuthState = "failing"
m.Complaints30d = 5
- m.WarmupSent7d = 60
- m.WarmupSpam7d = 20
+ m.WarmupPlacement = models.WarmupPlacementEvidence{MajorDelivered: 60, MajorSpam: 20}
for _, f := range Detect(snapshotOf(m), defaults()) {
if f.Title == "" || f.Detail == "" || f.Remedy == "" {
@@ -589,8 +586,7 @@ func TestAutopilotOnlyGetsReversibleSafeFixes(t *testing.T) {
m.MinWaitTime = 30
m.Complaints30d = 5
m.ColdSent30d = 4000
- m.WarmupSent7d = 60
- m.WarmupSpam7d = 30
+ m.WarmupPlacement = models.WarmupPlacementEvidence{MajorDelivered: 60, MajorSpam: 30}
m.InActiveCampaign = true
// Detectors whose one-click fix is deliberately hand-only: each either
@@ -639,8 +635,7 @@ func TestEveryFindingOffersAWayForward(t *testing.T) {
m.Complaints30d = 5
m.ColdSent30d = 4000
m.Bounces30d = 300
- m.WarmupSent7d = 60
- m.WarmupSpam7d = 30
+ m.WarmupPlacement = models.WarmupPlacementEvidence{MajorDelivered: 60, MajorSpam: 30}
m.UnresolvedErrs = 5
m.TrackingDomain = ""
m.InActiveCampaign = true
@@ -1009,3 +1004,14 @@ func TestNoSendersNamesHowTheCampaignPicksMailboxes(t *testing.T) {
})
}
}
+
+// Everything junked at small hosts while Google and Microsoft inbox all of it
+// is not a mailbox landing in spam.
+func TestSpamPlacementAtSmallHostsAloneDoesNotAlarm(t *testing.T) {
+ m := healthyMailbox()
+ m.InActiveCampaign = true
+ m.WarmupPlacement = models.WarmupPlacementEvidence{MajorDelivered: 40, OtherDelivered: 40, OtherSpam: 40}
+ if f, fired := findingsByKey(Detect(snapshotOf(m), defaults()))["mailbox_spam_placement"]; fired {
+ t.Fatalf("small-host spam alone raised %q: %s", f.Severity, f.Title)
+ }
+}
diff --git a/internal/app/advisor/thresholds.go b/internal/app/advisor/thresholds.go
index 72be5f900..3facb244e 100644
--- a/internal/app/advisor/thresholds.go
+++ b/internal/app/advisor/thresholds.go
@@ -35,10 +35,11 @@ const (
bounceRateWarn = 3.0
bounceRateCritical = 5.0
- // Spam-folder placement, from the paid-pool policy bands.
+ // Spam-folder placement at Google, Microsoft and Yahoo. The pool only
+ // slows a mailbox at these; the advice is what the member may choose.
spamPlacementWarn = 10.0
spamPlacementQuarantine = 20.0
- spamPlacementBlock = 40.0
+ spamPlacementBlock = 50.0
// --- mailbox volume ----------------------------------------------------
// The repo defaults: 50/day cold cap, 600s minimum gap.
diff --git a/internal/app/analytics/warmup_placement.go b/internal/app/analytics/warmup_placement.go
index 8b2b2326f..9c1b825cf 100644
--- a/internal/app/analytics/warmup_placement.go
+++ b/internal/app/analytics/warmup_placement.go
@@ -31,11 +31,21 @@ func (s *analyticsService) placementRates(ctx context.Context, orgID uuid.UUID,
if s.placementRepo == nil {
return nil
}
- rates, err := s.placementRepo.Rates(ctx, orgID, emailID, placementWindowStart(time.Now().UTC()))
+ windows, err := s.placementRepo.Rates(ctx, orgID, emailID, placementWindowStart(time.Now().UTC()))
if err != nil {
return nil
}
- return rates
+ return headlineRates(windows)
+}
+
+// headlineRates is each mailbox's headline, over the major providers when
+// they received its warmup mail.
+func headlineRates(windows map[uuid.UUID]models.WarmupPlacementWindow) map[uuid.UUID]models.WarmupPlacementRate {
+ out := make(map[uuid.UUID]models.WarmupPlacementRate, len(windows))
+ for id, w := range windows {
+ out[id] = w.Rate()
+ }
+ return out
}
// placementWindowStart is the first UTC day of the trailing window ending on now's day.
@@ -44,8 +54,8 @@ func placementWindowStart(now time.Time) time.Time {
return day.AddDate(0, 0, -(models.WarmupPlacementWindowDays - 1))
}
-// applyWarmupPlacement caps the health score at the measured inbox rate, so a
-// mailbox landing in spam reads as degraded before the pool acts on it.
+// applyWarmupPlacement caps the health score at the headline inbox rate, so a
+// mailbox landing in spam at the major providers reads as degraded.
func applyWarmupPlacement(health *models.AccountHealth, r *models.WarmupPlacementRate) {
if r == nil || r.InboxRate == nil {
return
@@ -109,10 +119,11 @@ func (s *analyticsService) GetWarmupPlacement(ctx context.Context, orgID uuid.UU
if err != nil {
return nil, errx.InternalError()
}
- rates, err := s.placementRepo.Rates(ctx, orgID, emailID, placementWindowStart(time.Now().UTC()))
+ windows, err := s.placementRepo.Rates(ctx, orgID, emailID, placementWindowStart(time.Now().UTC()))
if err != nil {
return nil, errx.InternalError()
}
+ rates := headlineRates(windows)
b := newPlacementBuilder(from, to)
for _, r := range dayRows {
@@ -132,13 +143,11 @@ func (s *analyticsService) GetWarmupPlacement(ctx context.Context, orgID uuid.UU
report.Summary.Finish()
report.Providers = placementProviders(hostRows)
- var total [3]int
- for _, r := range rates {
- total[0] += r.Inbox
- total[1] += r.Tabs
- total[2] += r.Spam
+ var total models.WarmupPlacementWindow
+ for _, w := range windows {
+ total.Add(w)
}
- report.Rate = models.NewWarmupPlacementRate(total[0], total[1], total[2])
+ report.Rate = total.Rate()
if emailID == nil {
// Names come from the org-scoped rows themselves, so no mailbox with
diff --git a/internal/app/analytics/warmup_ramp_live_test.go b/internal/app/analytics/warmup_ramp_live_test.go
index d3d83cff1..112fccefb 100644
--- a/internal/app/analytics/warmup_ramp_live_test.go
+++ b/internal/app/analytics/warmup_ramp_live_test.go
@@ -28,7 +28,10 @@ type rampFixture struct {
user uuid.UUID
org uuid.UUID
mailbox uuid.UUID
- svc AnalyticsService
+ // gmail is the recipient that files the placements: only a Google,
+ // Microsoft or Yahoo placement moves the ramp on its own.
+ gmail uuid.UUID
+ svc AnalyticsService
}
func newRampFixture(t *testing.T, daysWarming, base, increase, max int) *rampFixture {
@@ -44,7 +47,7 @@ func newRampFixture(t *testing.T, daysWarming, base, increase, max int) *rampFix
}
t.Cleanup(func() { handle.Pool.Close() })
- f := &rampFixture{pool: handle.Pool, user: uuid.New(), org: uuid.New(), mailbox: uuid.New()}
+ f := &rampFixture{pool: handle.Pool, user: uuid.New(), org: uuid.New(), mailbox: uuid.New(), gmail: uuid.New()}
exec := func(sql string, args ...any) {
t.Helper()
if _, err := f.pool.Exec(ctx, sql, args...); err != nil {
@@ -62,6 +65,10 @@ func newRampFixture(t *testing.T, daysWarming, base, increase, max int) *rampFix
$5, $6, $7, $8)`,
f.mailbox, f.user, f.org, "ramp-"+f.mailbox.String()[:8]+"@test.local",
time.Now().Add(-time.Duration(daysWarming)*24*time.Hour), base, increase, max)
+ 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, $4, 'Partner', '', '', 'gmail', 'active', 50, 600, 'UTC')`,
+ f.gmail, f.user, f.org, "ramp-"+f.gmail.String()[:8]+"@gmail.test")
t.Cleanup(func() {
c := context.Background()
@@ -73,6 +80,7 @@ func newRampFixture(t *testing.T, daysWarming, base, increase, max int) *rampFix
{`DELETE FROM warmup_statistics WHERE email_account_id = $1`, f.mailbox},
{`DELETE FROM campaigns WHERE organization_id = $1`, f.org},
{`DELETE FROM email_accounts WHERE id = $1`, f.mailbox},
+ {`DELETE FROM email_accounts WHERE id = $1`, f.gmail},
{`DELETE FROM organizations WHERE id = $1`, f.org},
{`DELETE FROM users WHERE id = $1`, f.user},
} {
@@ -100,8 +108,8 @@ func (f *rampFixture) placement(t *testing.T, hoursAgo int) {
t.Helper()
if _, err := f.pool.Exec(context.Background(),
`INSERT INTO warmup_spam_reports (id, reporter_account_id, reported_account_id, message_id, report_type, created_at)
- VALUES (gen_random_uuid(), $1, $1, $2, 'spam_placement', $3)`,
- f.mailbox, "msg-"+uuid.New().String(),
+ VALUES (gen_random_uuid(), $1, $2, $3, 'spam_placement', $4)`,
+ f.gmail, f.mailbox, "msg-"+uuid.New().String(),
time.Now().Add(-time.Duration(hoursAgo)*time.Hour)); err != nil {
t.Fatalf("record placement: %v", err)
}
@@ -165,6 +173,23 @@ func TestLiveRecentPlacementCutsTheTargetAndExplainsItself(t *testing.T) {
}
}
+// A junk-folder landing at a small host's own filter reaches the health band at
+// a fifth of its weight but never holds or cuts the ramp by itself.
+func TestLiveSmallHostPlacementDoesNotHoldTheRamp(t *testing.T) {
+ f := newRampFixture(t, 10, 10, 1, 40)
+ if _, err := f.pool.Exec(context.Background(),
+ `INSERT INTO warmup_spam_reports (id, reporter_account_id, reported_account_id, message_id, report_type, created_at)
+ VALUES (gen_random_uuid(), $1, $1, $2, 'spam_placement', NOW() - INTERVAL '2 hours')`,
+ f.mailbox, "msg-"+uuid.New().String()); err != nil {
+ t.Fatalf("record placement: %v", err)
+ }
+
+ target, held := f.status(t)
+ if held || target != 20 {
+ t.Errorf("target = %d, held = %v; want the unheld day-10 target of 20", target, held)
+ }
+}
+
func TestLiveOldPlacementNoLongerCutsButStillShiftsTheRamp(t *testing.T) {
// A placement 8 days ago is outside both windows: no cut, no hold reported.
// The three frozen days are still subtracted, so the ramp sits below where
diff --git a/internal/app/warmup/health_evaluation_live_test.go b/internal/app/warmup/health_evaluation_live_test.go
index 1c4dc09a7..72320ad93 100644
--- a/internal/app/warmup/health_evaluation_live_test.go
+++ b/internal/app/warmup/health_evaluation_live_test.go
@@ -154,16 +154,13 @@ func TestLiveSweepEvaluatesAFreePoolAccount(t *testing.T) {
func TestLiveHealthSignalsBeforeTheFloorAreNotCounted(t *testing.T) {
repo, handle := liveWarmupRepo(t)
f := newFreePoolAccount(t, handle)
+ partner := newFreePoolAccount(t, handle)
+ execSQL(t, handle.Pool, `UPDATE email_accounts SET provider = 'gmail' WHERE id = $1`, partner.account)
svc := NewService(repo)
ctx := context.Background()
- // One placement per send, a full sample. Sends are counted by day, so they
- // stay in view; placements carry the timestamp the floor is applied to.
- placements := func(offset string) {
- insertSpamReports(t, handle, f.account, "spam_placement", offset, minSpamPlacementSample)
- }
- execSQL(t, handle.Pool, `INSERT INTO warmup_statistics (email_account_id, date, emails_sent, emails_replied, target_volume)
- VALUES ($1, CURRENT_DATE, $2, 0, $2)`, f.account, minSpamPlacementSample)
- placements("2 hours")
+ // A full sample of deliveries, every one junked; the floor is applied to
+ // the receipts' timestamps.
+ deliverWarmup(t, handle, f.account, partner.account, "2 hours", minSpamPlacementSample, true)
execSQL(t, handle.Pool, `UPDATE warmup_pool_participants SET health_signals_from = NOW() - INTERVAL '1 hour'
WHERE email_account_id = $1`, f.account)
@@ -176,14 +173,36 @@ func TestLiveHealthSignalsBeforeTheFloorAreNotCounted(t *testing.T) {
health.HealthState)
}
- // The same placements after the floor are the catastrophic band.
- placements("0 seconds")
+ // The same placements after the floor slow the mailbox down, and no more.
+ deliverWarmup(t, handle, f.account, partner.account, "0 seconds", minSpamPlacementSample, true)
health, err = svc.(*service).evaluateAndPersistAnyPool(ctx, f.account)
if err != nil {
t.Fatalf("evaluate: %v", err)
}
- if health.HealthState != models.WarmupHealthBlocked {
+ if health.HealthState != models.WarmupHealthThrottled {
t.Fatalf("health_state is %q after %d placements past the floor, want %q",
- health.HealthState, minSpamPlacementSample, models.WarmupHealthBlocked)
+ health.HealthState, minSpamPlacementSample, models.WarmupHealthThrottled)
}
}
+
+// deliverWarmup files n verified receipts of sender's warmup mail at recipient,
+// stamped offset ago, each also a spam placement when junked. Rows cascade
+// away with the mailboxes.
+func deliverWarmup(t *testing.T, handle *db.DB, sender, recipient uuid.UUID, offset string, n int, junked bool) []string {
+ t.Helper()
+ ids := make([]string, n)
+ for i := range ids {
+ ids[i] = "<" + uuid.NewString() + "@test.local>"
+ }
+ execSQL(t, handle.Pool, `
+ INSERT INTO warmup_received (email_account_id, internal_id, message_id, sender_account_id, created_at)
+ SELECT $1, gen_random_uuid(), m, $2, NOW() - $3::interval FROM unnest($4::text[]) AS m`,
+ recipient, sender, offset, ids)
+ if junked {
+ execSQL(t, handle.Pool, `
+ INSERT INTO warmup_spam_reports (reporter_account_id, reported_account_id, message_id, report_type, created_at)
+ SELECT $1, $2, m, 'spam_placement', NOW() - $3::interval FROM unnest($4::text[]) AS m`,
+ recipient, sender, offset, ids)
+ }
+ return ids
+}
diff --git a/internal/app/warmup/metrics_counts_live_test.go b/internal/app/warmup/metrics_counts_live_test.go
index 0f478cb0d..c8bd3f0a9 100644
--- a/internal/app/warmup/metrics_counts_live_test.go
+++ b/internal/app/warmup/metrics_counts_live_test.go
@@ -113,3 +113,34 @@ func TestLiveSweepListsTheStalestFirst(t *testing.T) {
t.Fatalf("order never=%d stale=%d fresh=%d; want never, then stale, then fresh", pos[never.account], pos[stale.account], pos[fresh.account])
}
}
+
+// Placement is read over verified deliveries, and only a Google, Microsoft or
+// Yahoo recipient's junk folder is judged; another host's is carried apart.
+func TestLivePlacementIsJudgedAtTheMajorProviders(t *testing.T) {
+ repo, handle := liveWarmupRepo(t)
+ ctx := context.Background()
+ sender := newFreePoolAccount(t, handle)
+ gmail := newFreePoolAccount(t, handle)
+ small := newFreePoolAccount(t, handle)
+ execSQL(t, handle.Pool, `UPDATE email_accounts SET provider = 'gmail' WHERE id = $1`, gmail.account)
+ execSQL(t, handle.Pool, `UPDATE warmup_pool_participants SET health_signals_from = NOW() - INTERVAL '1 day' WHERE email_account_id = $1`, sender.account)
+
+ deliverWarmup(t, handle, sender.account, gmail.account, "30 minutes", 18, false)
+ deliverWarmup(t, handle, sender.account, gmail.account, "30 minutes", 2, true)
+ deliverWarmup(t, handle, sender.account, small.account, "30 minutes", 10, true)
+ // Spam reported with no receipt behind it was never verifiably delivered.
+ insertSpamReports(t, handle, sender.account, "spam_placement", "10 minutes", 3)
+
+ row, err := repo.GetParticipantHealthForAccount(ctx, sender.account)
+ if err != nil || row == nil {
+ t.Fatalf("participant row: %v", err)
+ }
+ m, err := NewService(repo).(*service).loadMetrics(ctx, sender.account, row)
+ if err != nil {
+ t.Fatalf("loadMetrics: %v", err)
+ }
+ if m.PlacementSample != 20 || m.SpamPlacementRate != 10 || m.OtherDelivered != 10 || m.OtherSpamRate != 100 {
+ t.Fatalf("sample %d, rate %v, other %d at %v%%; want 20 at 10%%, other 10 at 100%%",
+ m.PlacementSample, m.SpamPlacementRate, m.OtherDelivered, m.OtherSpamRate)
+ }
+}
diff --git a/internal/app/warmup/service.go b/internal/app/warmup/service.go
index 5c3ac005c..bdaa8eb6b 100644
--- a/internal/app/warmup/service.go
+++ b/internal/app/warmup/service.go
@@ -3,6 +3,8 @@ package warmup
import (
"context"
"fmt"
+ "math"
+ "strconv"
"strings"
"time"
@@ -31,11 +33,14 @@ type HealthRealtimePublisher interface {
const (
minSpamPlacementSample = 20
- spamPlacementWatchPct = 10.0
- spamPlacementThrottlePct = 15.0
- spamPlacementQuarantinePct = 20.0
- spamPlacementBlockPct = 40.0
- spamPlacementCatastrophicPct = 80.0
+ // Placement is a reading of reputation, not misconduct, and warming is how
+ // it recovers, so it only ever slows a mailbox down: it never quarantines,
+ // blocks or needs an appeal.
+ spamPlacementWatchPct = 10.0
+ spamPlacementThrottlePct = 20.0
+ // A watch or throttle lifts only once the rate falls this far below the
+ // line that set it, so a mailbox near a line does not flap across it.
+ spamPlacementExitFactor = 0.75
complaintRateWatchPct = 0.03
complaintRateQuarantinePct = 0.10
@@ -65,7 +70,6 @@ const (
warmupThrottleDuration = 3 * 24 * time.Hour
warmupQuarantineDuration = 7 * 24 * time.Hour
warmupBlockDuration = 30 * 24 * time.Hour
- warmupCatastrophicBlock = 90 * 24 * time.Hour
)
type Service interface {
@@ -177,20 +181,6 @@ func (s *service) dispatchHealthEvent(ctx context.Context, accountID uuid.UUID,
return
}
- var event models.WebhookEventType
- switch newState {
- case models.WarmupHealthBlocked:
- event = models.WebhookEventWarmupBlocked
- case models.WarmupHealthQuarantined:
- event = models.WebhookEventWarmupQuarantined
- case models.WarmupHealthThrottled, models.WarmupHealthWatch, models.WarmupHealthHealthy:
- // Fire the generic health_changed event for these — quarantine /
- // blocked also re-fire it so subscribers can carry a single handler.
- event = models.WebhookEventWarmupHealthChanged
- default:
- return
- }
-
payload := map[string]any{
"email_account_id": accountID,
"email": account.Email,
@@ -198,10 +188,9 @@ func (s *service) dispatchHealthEvent(ctx context.Context, accountID uuid.UUID,
"new_state": string(newState),
"reason": reason,
}
- _, _ = s.webhooks.Dispatch(ctx, *account.OrganizationID, event, payload)
-
- // For block/quarantine, also fire the specific event in addition to
- // the generic transition so callers can subscribe selectively.
+ // Every transition fires health_changed once; a quarantine or block also
+ // fires its own event once, so a subscriber to either hears it once.
+ _, _ = s.webhooks.Dispatch(ctx, *account.OrganizationID, models.WebhookEventWarmupHealthChanged, payload)
switch newState {
case models.WarmupHealthBlocked:
_, _ = s.webhooks.Dispatch(ctx, *account.OrganizationID, models.WebhookEventWarmupBlocked, payload)
@@ -564,7 +553,7 @@ func (s *service) evaluateAndPersist(ctx context.Context, participant *models.Wa
// The floor that keeps a block from being overturned by a fresh reading is
// applied by UpdateParticipantHealth against the row as it is at write time.
- decision := evaluateMetrics(metrics, s.now().UTC())
+ decision := evaluateMetrics(metrics, priorState, s.now().UTC())
health, err := s.repo.UpdateParticipantHealth(ctx, accountID, decision.State, decision.BlockedUntil, decision.Reason, decision.Score)
if err != nil {
return nil, fail("persist", err)
@@ -602,12 +591,11 @@ func (s *service) loadMetrics(ctx context.Context, accountID uuid.UUID, particip
// Placement (the provider's classifier) and complaint (the recipient) have
// different remediation paths, so they earn separate rates.
- placementRate := 0.0
warmupComplaintRate := 0.0
if sentLast7d > 0 {
- placementRate = float64(spamPlacementsLast7d) / float64(sentLast7d) * 100
warmupComplaintRate = float64(userComplaintsLast7d) / float64(sentLast7d) * 100
}
+ placementRate, placementSample := counts.Placement.Judged()
complaintRate := 0.0
if deliveredLast30d > 0 {
@@ -622,6 +610,9 @@ func (s *service) loadMetrics(ctx context.Context, accountID uuid.UUID, particip
SentLast7d: sentLast7d,
SpamPlacementsLast7d: spamPlacementsLast7d,
SpamPlacementRate: placementRate,
+ PlacementSample: placementSample,
+ OtherSpamRate: pct(counts.Placement.OtherSpam, counts.Placement.OtherDelivered),
+ OtherDelivered: counts.Placement.OtherDelivered,
UserComplaintsLast7d: userComplaintsLast7d,
WarmupComplaintRate: warmupComplaintRate,
ComplaintsLast30d: complaintsLast30d,
@@ -634,6 +625,13 @@ func (s *service) loadMetrics(ctx context.Context, accountID uuid.UUID, particip
}, nil
}
+func pct(part, whole int) float64 {
+ if whole <= 0 {
+ return 0
+ }
+ return float64(part) / float64(whole) * 100
+}
+
type evaluationDecision struct {
State models.WarmupHealthState
BlockedUntil *time.Time
@@ -644,8 +642,11 @@ type evaluationDecision struct {
// evaluateMetrics is the rate bands and the tampering band judged apart, with
// the more severe finding kept, so a seven-day rate quarantine can never hide
// a thirty-day tampering block or the other way round.
-func evaluateMetrics(metrics *models.WarmupHealthMetrics, now time.Time) evaluationDecision {
- return moreSevere(evaluateRateBands(metrics, now), evaluateTampering(metrics, now))
+// prior is the standing the row carries, which the placement band needs to
+// hold a watch or throttle until the rate has clearly recovered.
+func evaluateMetrics(metrics *models.WarmupHealthMetrics, prior models.WarmupHealthState, now time.Time) evaluationDecision {
+ rates := moreSevere(evaluateRateBands(metrics, now), evaluatePlacement(metrics, prior, now))
+ return moreSevere(rates, evaluateTampering(metrics, now))
}
// healthSeverity orders the bands; ties go to the later term.
@@ -800,57 +801,53 @@ func evaluateRateBands(metrics *models.WarmupHealthMetrics, now time.Time) evalu
}
}
- // Evaluate spam placement rate (requires minimum 20 warmup sends in 7d)
- if metrics.SentLast7d < minSpamPlacementSample {
- return decision
- }
+ return decision
+}
+// evaluatePlacement is the spam-placement ladder. It only slows a mailbox
+// down, and lifts on its own once the rate is clearly back down.
+func evaluatePlacement(m *models.WarmupHealthMetrics, prior models.WarmupHealthState, now time.Time) evaluationDecision {
+ healthy := evaluationDecision{State: models.WarmupHealthHealthy, Score: m.SpamPlacementRate}
+ if m.PlacementSample < minSpamPlacementSample {
+ return healthy
+ }
+ rate := m.SpamPlacementRate
switch {
- case metrics.SpamPlacementRate >= spamPlacementCatastrophicPct:
- until := now.Add(warmupCatastrophicBlock)
- return evaluationDecision{
- State: models.WarmupHealthBlocked,
- BlockedUntil: &until,
- Reason: fmt.Sprintf("catastrophic warmup spam placement %.1f%% over %d sends", metrics.SpamPlacementRate, metrics.SentLast7d),
- Score: metrics.SpamPlacementRate,
- }
- case metrics.SpamPlacementRate >= spamPlacementBlockPct:
- until := now.Add(warmupBlockDuration)
- return evaluationDecision{
- State: models.WarmupHealthBlocked,
- BlockedUntil: &until,
- Reason: fmt.Sprintf("warmup spam placement %.1f%% exceeded block threshold", metrics.SpamPlacementRate),
- Score: metrics.SpamPlacementRate,
- }
- case metrics.SpamPlacementRate >= spamPlacementQuarantinePct:
- until := now.Add(warmupQuarantineDuration)
- return evaluationDecision{
- State: models.WarmupHealthQuarantined,
- BlockedUntil: &until,
- Reason: fmt.Sprintf("warmup spam placement %.1f%% exceeded quarantine threshold", metrics.SpamPlacementRate),
- Score: metrics.SpamPlacementRate,
- }
- case metrics.SpamPlacementRate >= spamPlacementThrottlePct:
+ case rate >= spamPlacementThrottlePct,
+ prior == models.WarmupHealthThrottled && rate >= spamPlacementThrottlePct*spamPlacementExitFactor:
until := now.Add(warmupThrottleDuration)
return evaluationDecision{
State: models.WarmupHealthThrottled,
BlockedUntil: &until,
- Reason: fmt.Sprintf("warmup spam placement %.1f%% in throttle band", metrics.SpamPlacementRate),
- Score: metrics.SpamPlacementRate,
+ Reason: fmt.Sprintf("%s Warmup and cold sending run at half volume with wider spacing until it is below %s, then return to normal on their own.",
+ placementSummary(m), fmtPct(spamPlacementThrottlePct*spamPlacementExitFactor)),
+ Score: rate,
}
- case metrics.SpamPlacementRate >= spamPlacementWatchPct:
- // Only upgrade to watch if not already at a worse state from complaint checks
- if decision.State == models.WarmupHealthHealthy {
- return evaluationDecision{
- State: models.WarmupHealthWatch,
- Reason: fmt.Sprintf("warmup spam placement %.1f%% in watch band", metrics.SpamPlacementRate),
- Score: metrics.SpamPlacementRate,
- }
+ case rate >= spamPlacementWatchPct,
+ (prior == models.WarmupHealthWatch || prior == models.WarmupHealthThrottled) && rate >= spamPlacementWatchPct*spamPlacementExitFactor:
+ return evaluationDecision{
+ State: models.WarmupHealthWatch,
+ Reason: fmt.Sprintf("%s Sending is slowed slightly until it is below %s.",
+ placementSummary(m), fmtPct(spamPlacementWatchPct*spamPlacementExitFactor)),
+ Score: rate,
}
- return decision
- default:
- return decision
}
+ return healthy
+}
+
+// placementSummary says what the placement band read.
+func placementSummary(m *models.WarmupHealthMetrics) string {
+ out := fmt.Sprintf("%s of warmup mail delivered at Google, Microsoft and Yahoo landed in spam over 7 days (%d delivered).",
+ fmtPct(m.SpamPlacementRate), m.PlacementSample)
+ if m.OtherDelivered > 0 && m.OtherSpamRate > 0 {
+ out += fmt.Sprintf(" Other mail hosts (%s of %d) run their own filters and are not counted.", fmtPct(m.OtherSpamRate), m.OtherDelivered)
+ }
+ return out
+}
+
+// fmtPct is a percentage to one decimal, without a trailing ".0".
+func fmtPct(v float64) string {
+ return strconv.FormatFloat(math.Round(v*10)/10, 'f', -1, 64) + "%"
}
func maxFloat(a, b float64) float64 {
diff --git a/internal/app/warmup/service_test.go b/internal/app/warmup/service_test.go
index 1b37fee44..e1c7a4841 100644
--- a/internal/app/warmup/service_test.go
+++ b/internal/app/warmup/service_test.go
@@ -1,6 +1,7 @@
package warmup
import (
+ "math"
"testing"
"time"
@@ -17,17 +18,18 @@ func TestEvaluateMetricsSpamThresholds(t *testing.T) {
wantBlocked time.Duration
}{
{name: "watch", rate: 10, wantState: models.WarmupHealthWatch},
- {name: "quarantine", rate: 20, wantState: models.WarmupHealthQuarantined, wantBlocked: warmupQuarantineDuration},
- {name: "block", rate: 40, wantState: models.WarmupHealthBlocked, wantBlocked: warmupBlockDuration},
- {name: "catastrophic", rate: 80, wantState: models.WarmupHealthBlocked, wantBlocked: warmupCatastrophicBlock},
+ {name: "throttle", rate: 20, wantState: models.WarmupHealthThrottled, wantBlocked: warmupThrottleDuration},
+ // Placement only ever slows a mailbox down; it never takes it out of the pool.
+ {name: "half in spam still warms", rate: 50, wantState: models.WarmupHealthThrottled, wantBlocked: warmupThrottleDuration},
+ {name: "all in spam still warms", rate: 100, wantState: models.WarmupHealthThrottled, wantBlocked: warmupThrottleDuration},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
decision := evaluateMetrics(&models.WarmupHealthMetrics{
- SentLast7d: 20,
+ PlacementSample: 20,
SpamPlacementRate: tc.rate,
- }, now)
+ }, "", now)
if decision.State != tc.wantState {
t.Fatalf("expected %s, got %s", tc.wantState, decision.State)
@@ -49,9 +51,9 @@ func TestEvaluateMetricsThrottleBand(t *testing.T) {
now := time.Date(2026, 4, 3, 12, 0, 0, 0, time.UTC)
decision := evaluateMetrics(&models.WarmupHealthMetrics{
- SentLast7d: 25,
- SpamPlacementRate: 16.0, // between 15% (throttle) and 20% (quarantine)
- }, now)
+ PlacementSample: 25,
+ SpamPlacementRate: 26.0,
+ }, "", now)
if decision.State != models.WarmupHealthThrottled {
t.Fatalf("expected throttled, got %s", decision.State)
@@ -72,7 +74,7 @@ func TestEvaluateMetricsComplaintRateWatch(t *testing.T) {
DeliveredLast30d: 200,
ComplaintsLast30d: 1,
ComplaintRate: 0.05, // between 0.03% (watch) and 0.10% (quarantine)
- }, now)
+ }, "", now)
if decision.State != models.WarmupHealthWatch {
t.Fatalf("expected watch from complaint rate, got %s", decision.State)
@@ -87,7 +89,7 @@ func TestEvaluateMetricsComplaintRateQuarantine(t *testing.T) {
DeliveredLast30d: 200,
ComplaintsLast30d: 2,
ComplaintRate: 0.15, // > 0.10% quarantine
- }, now)
+ }, "", now)
if decision.State != models.WarmupHealthQuarantined {
t.Fatalf("expected quarantined from complaint rate, got %s", decision.State)
@@ -102,7 +104,7 @@ func TestEvaluateMetricsComplaintRateBlock(t *testing.T) {
DeliveredLast30d: 200,
ComplaintsLast30d: 10,
ComplaintRate: 0.5, // > 0.30% block
- }, now)
+ }, "", now)
if decision.State != models.WarmupHealthBlocked {
t.Fatalf("expected blocked from complaint rate, got %s", decision.State)
@@ -117,7 +119,7 @@ func TestEvaluateMetricsBounceRateQuarantine(t *testing.T) {
DeliveredLast30d: 200,
BouncesLast30d: 12,
BounceRate: 6.0, // > 5% quarantine
- }, now)
+ }, "", now)
if decision.State != models.WarmupHealthQuarantined {
t.Fatalf("expected quarantined from bounce rate, got %s", decision.State)
@@ -132,7 +134,7 @@ func TestEvaluateMetricsBounceRateBlock(t *testing.T) {
DeliveredLast30d: 200,
BouncesLast30d: 25,
BounceRate: 12.5, // > 10% block
- }, now)
+ }, "", now)
if decision.State != models.WarmupHealthBlocked {
t.Fatalf("expected blocked from bounce rate, got %s", decision.State)
@@ -147,7 +149,7 @@ func TestEvaluateMetricsComplaintBelowSampleIgnored(t *testing.T) {
DeliveredLast30d: 50, // below 100 minimum
ComplaintsLast30d: 5,
ComplaintRate: 10.0,
- }, now)
+ }, "", now)
if decision.State == models.WarmupHealthQuarantined || decision.State == models.WarmupHealthBlocked {
t.Fatalf("should not quarantine/block with insufficient sample, got %s", decision.State)
@@ -158,9 +160,10 @@ func TestEvaluateMetricsIgnoresSmallSamples(t *testing.T) {
now := time.Date(2026, 4, 3, 12, 0, 0, 0, time.UTC)
decision := evaluateMetrics(&models.WarmupHealthMetrics{
- SentLast7d: 19,
+ SentLast7d: 40,
+ PlacementSample: 19,
SpamPlacementRate: 100,
- }, now)
+ }, "", now)
if decision.State != models.WarmupHealthHealthy {
t.Fatalf("expected healthy for undersampled account, got %s", decision.State)
@@ -191,7 +194,7 @@ func TestEvaluateMetricsTamperingLadder(t *testing.T) {
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
- decision := evaluateMetrics(&models.WarmupHealthMetrics{DeletionsLast7d: tc.deletions, SpamFlagsLast7d: tc.spamFlags}, now)
+ decision := evaluateMetrics(&models.WarmupHealthMetrics{DeletionsLast7d: tc.deletions, SpamFlagsLast7d: tc.spamFlags}, "", now)
if decision.State != tc.wantState {
t.Fatalf("state = %s, want %s (reason %q)", decision.State, tc.wantState, decision.Reason)
}
@@ -214,7 +217,7 @@ func TestEvaluateMetricsTamperingLadder(t *testing.T) {
// A tampering block never requires review: it lapses like every other band,
// so an accidental run of deletions is not permanent.
func TestEvaluateMetricsTamperingBlockLapses(t *testing.T) {
- decision := evaluateMetrics(&models.WarmupHealthMetrics{DeletionsLast7d: 6}, time.Now())
+ decision := evaluateMetrics(&models.WarmupHealthMetrics{DeletionsLast7d: 6}, "", time.Now())
if decision.State != models.WarmupHealthBlocked || decision.BlockedUntil == nil {
t.Fatalf("decision = %+v, want a block with a term", decision)
}
@@ -232,18 +235,18 @@ func TestEvaluateMetricsTamperingCombinesWithRates(t *testing.T) {
want models.WarmupHealthState
until *time.Time
}{
- {"one deletion does not mask a placement block", models.WarmupHealthMetrics{DeletionsLast7d: 1, SentLast7d: 20, SpamPlacementRate: 40}, models.WarmupHealthBlocked, &block},
- {"a placement watch does not mask a tampering quarantine", models.WarmupHealthMetrics{DeletionsLast7d: 2, SentLast7d: 20, SpamPlacementRate: 10}, models.WarmupHealthQuarantined, &quarantine},
+ {"one deletion does not mask a placement throttle", models.WarmupHealthMetrics{DeletionsLast7d: 1, PlacementSample: 20, SpamPlacementRate: 50}, models.WarmupHealthThrottled, func() *time.Time { u := now.Add(warmupThrottleDuration); return &u }()},
+ {"a placement watch does not mask a tampering quarantine", models.WarmupHealthMetrics{DeletionsLast7d: 2, PlacementSample: 20, SpamPlacementRate: 10}, models.WarmupHealthQuarantined, &quarantine},
{"a complaint-rate quarantine does not mask a tampering block", models.WarmupHealthMetrics{DeletionsLast7d: 4, DeliveredLast30d: 100, ComplaintRate: complaintRateQuarantinePct}, models.WarmupHealthBlocked, &block},
{"a bounce-rate quarantine does not mask a tampering block", models.WarmupHealthMetrics{SpamFlagsLast7d: 2, DeliveredLast30d: 100, BounceRate: bounceRateQuarantinePct}, models.WarmupHealthBlocked, &block},
{"a warmup-complaint quarantine does not mask a tampering block", models.WarmupHealthMetrics{DeletionsLast7d: 4, SentLast7d: 20, WarmupComplaintRate: warmupComplaintQuarantinePct}, models.WarmupHealthBlocked, &block},
- {"a placement throttle does not mask a tampering quarantine", models.WarmupHealthMetrics{DeletionsLast7d: 2, SentLast7d: 20, SpamPlacementRate: spamPlacementThrottlePct}, models.WarmupHealthQuarantined, &quarantine},
- {"a longer rate block outlasts a tampering block", models.WarmupHealthMetrics{DeletionsLast7d: 4, SentLast7d: 20, SpamPlacementRate: 80}, models.WarmupHealthBlocked, func() *time.Time { u := now.Add(warmupCatastrophicBlock); return &u }()},
+ {"a placement throttle does not mask a tampering quarantine", models.WarmupHealthMetrics{DeletionsLast7d: 2, PlacementSample: 20, SpamPlacementRate: spamPlacementThrottlePct}, models.WarmupHealthQuarantined, &quarantine},
+ {"heavy placement does not soften a tampering block", models.WarmupHealthMetrics{DeletionsLast7d: 4, PlacementSample: 20, SpamPlacementRate: 90}, models.WarmupHealthBlocked, &block},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
m := tc.metrics
- decision := evaluateMetrics(&m, now)
+ decision := evaluateMetrics(&m, "", now)
if decision.State != tc.want {
t.Fatalf("state = %s, want %s (reason %q)", decision.State, tc.want, decision.Reason)
}
@@ -256,3 +259,89 @@ func TestEvaluateMetricsTamperingCombinesWithRates(t *testing.T) {
})
}
}
+
+// Only Google, Microsoft and Yahoo judge a sender; another host's spam folder
+// is never held against it.
+func TestJudgedPlacementRate(t *testing.T) {
+ cases := []struct {
+ name string
+ evidence models.WarmupPlacementEvidence
+ wantRate float64
+ wantSample int
+ }{
+ {"majors only", models.WarmupPlacementEvidence{MajorDelivered: 40, MajorSpam: 4}, 10, 40},
+ {"small-host spam is not counted", models.WarmupPlacementEvidence{MajorDelivered: 30, OtherDelivered: 20, OtherSpam: 20}, 0, 30},
+ {"only small hosts is not judged", models.WarmupPlacementEvidence{OtherDelivered: 40, OtherSpam: 40}, 0, 0},
+ {"nothing delivered", models.WarmupPlacementEvidence{}, 0, 0},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ rate, sample := tc.evidence.Judged()
+ if math.Abs(rate-tc.wantRate) > 1e-9 || sample != tc.wantSample {
+ t.Fatalf("rate, sample = %v, %d; want %v, %d", rate, sample, tc.wantRate, tc.wantSample)
+ }
+ })
+ }
+}
+
+// Every small host junking everything leaves a mailbox that inboxes at the
+// majors healthy; junk at the majors slows it down and never pauses it.
+func TestPlacementAtSmallHostsDoesNotSlowAHealthyMailbox(t *testing.T) {
+ now := time.Date(2026, 9, 26, 12, 0, 0, 0, time.UTC)
+ metricsFor := func(e models.WarmupPlacementEvidence) *models.WarmupHealthMetrics {
+ rate, sample := e.Judged()
+ return &models.WarmupHealthMetrics{SpamPlacementRate: rate, PlacementSample: sample,
+ OtherDelivered: e.OtherDelivered, OtherSpamRate: pct(e.OtherSpam, e.OtherDelivered)}
+ }
+ small := evaluateMetrics(metricsFor(models.WarmupPlacementEvidence{MajorDelivered: 40, OtherDelivered: 40, OtherSpam: 40}), "", now)
+ if small.State != models.WarmupHealthHealthy {
+ t.Fatalf("small-host spam alone set %s (%q)", small.State, small.Reason)
+ }
+ major := evaluateMetrics(metricsFor(models.WarmupPlacementEvidence{MajorDelivered: 40, MajorSpam: 36}), "", now)
+ if major.State != models.WarmupHealthThrottled {
+ t.Fatalf("90%% junk at the majors set %s, want throttled", major.State)
+ }
+}
+
+// A watch or throttle holds until the rate is clearly below the line that set
+// it, so a mailbox hovering at a line is not flipped (and announced) each pass.
+func TestPlacementBandsHoldUntilClearlyRecovered(t *testing.T) {
+ now := time.Date(2026, 9, 26, 12, 0, 0, 0, time.UTC)
+ cases := []struct {
+ name string
+ rate float64
+ prior models.WarmupHealthState
+ want models.WarmupHealthState
+ }{
+ {"a fresh 9% is healthy", 9, models.WarmupHealthHealthy, models.WarmupHealthHealthy},
+ {"9% keeps a watch", 9, models.WarmupHealthWatch, models.WarmupHealthWatch},
+ {"7% lifts a watch", 7, models.WarmupHealthWatch, models.WarmupHealthHealthy},
+ {"a fresh 18% is only a watch", 18, models.WarmupHealthHealthy, models.WarmupHealthWatch},
+ {"18% keeps a throttle", 18, models.WarmupHealthThrottled, models.WarmupHealthThrottled},
+ {"14% steps a throttle down to watch", 14, models.WarmupHealthThrottled, models.WarmupHealthWatch},
+ {"7% lifts a throttle", 7, models.WarmupHealthThrottled, models.WarmupHealthHealthy},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ d := evaluateMetrics(&models.WarmupHealthMetrics{PlacementSample: 30, SpamPlacementRate: tc.rate}, tc.prior, now)
+ if d.State != tc.want {
+ t.Fatalf("state = %s, want %s (reason %q)", d.State, tc.want, d.Reason)
+ }
+ })
+ }
+}
+
+// The reason a mailbox owner reads names what was judged, what was not, and
+// that it recovers by itself.
+func TestPlacementReasonExplainsTheReading(t *testing.T) {
+ d := evaluateMetrics(&models.WarmupHealthMetrics{
+ PlacementSample: 40, SpamPlacementRate: 25,
+ OtherDelivered: 20, OtherSpamRate: 50,
+ }, "", time.Now())
+ want := "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. " +
+ "Warmup and cold sending run at half volume with wider spacing until it is below 15%, then return to normal on their own."
+ if d.Reason != want {
+ t.Fatalf("reason =\n%q\nwant\n%q", d.Reason, want)
+ }
+}
diff --git a/internal/infrastructure/db/migrations/000220_warmup_placement_slows_only.down.sql b/internal/infrastructure/db/migrations/000220_warmup_placement_slows_only.down.sql
new file mode 100644
index 000000000..78d9cf6ee
--- /dev/null
+++ b/internal/infrastructure/db/migrations/000220_warmup_placement_slows_only.down.sql
@@ -0,0 +1 @@
+-- Released standings are not restored; nothing to undo.
diff --git a/internal/infrastructure/db/migrations/000220_warmup_placement_slows_only.up.sql b/internal/infrastructure/db/migrations/000220_warmup_placement_slows_only.up.sql
new file mode 100644
index 000000000..6f8557bf4
--- /dev/null
+++ b/internal/infrastructure/db/migrations/000220_warmup_placement_slows_only.up.sql
@@ -0,0 +1,20 @@
+-- Spam placement only slows a mailbox down now, so a quarantine or block it
+-- decided alone is released: throttled with a spent term, re-judged on the
+-- next evaluation, and not treated as probation.
+UPDATE public.warmup_pool_participants
+ SET health_state = 'throttled',
+ blocked_until = NOW()
+ WHERE health_state IN ('quarantined', 'blocked')
+ AND blocked_until IS NOT NULL
+ AND blocked_until > NOW()
+ AND COALESCE(last_health_reason, blocked_reason, '') ~ '^(catastrophic )?warmup spam placement ';
+
+-- The same for a standing held against a removed address; a live row's mirror
+-- was rewritten by its trigger above.
+UPDATE public.warmup_reputation_ledger
+ SET health_state = 'throttled',
+ blocked_until = NOW()
+ WHERE health_state IN ('quarantined', 'blocked')
+ AND blocked_until IS NOT NULL
+ AND blocked_until > NOW()
+ AND COALESCE(last_health_reason, blocked_reason, '') ~ '^(catastrophic )?warmup spam placement ';
diff --git a/internal/infrastructure/db/migrations/000221_warmup_received_sender_index.down.sql b/internal/infrastructure/db/migrations/000221_warmup_received_sender_index.down.sql
new file mode 100644
index 000000000..c88d57673
--- /dev/null
+++ b/internal/infrastructure/db/migrations/000221_warmup_received_sender_index.down.sql
@@ -0,0 +1 @@
+DROP INDEX CONCURRENTLY IF EXISTS idx_warmup_received_sender;
diff --git a/internal/infrastructure/db/migrations/000221_warmup_received_sender_index.up.sql b/internal/infrastructure/db/migrations/000221_warmup_received_sender_index.up.sql
new file mode 100644
index 000000000..5140a2a11
--- /dev/null
+++ b/internal/infrastructure/db/migrations/000221_warmup_received_sender_index.up.sql
@@ -0,0 +1,4 @@
+-- Alone in its file for CONCURRENTLY. The health read walks one sender's
+-- receipts over seven days; the primary key starts at the recipient.
+CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_warmup_received_sender
+ ON warmup_received (sender_account_id, created_at);
diff --git a/internal/models/warmup.go b/internal/models/warmup.go
index d647acd87..dc4f4cfdb 100644
--- a/internal/models/warmup.go
+++ b/internal/models/warmup.go
@@ -189,6 +189,8 @@ type WarmupPartnerCandidate struct {
// sample is taken.
Sent7d int
Received7d int
+ // Junked7d is how much of what it received its own filter put in spam.
+ Junked7d int
}
// Borrowed reports whether the candidate was drawn from the tier the sender's
@@ -206,6 +208,20 @@ func (c WarmupPartnerCandidate) Starvation() float64 {
return float64(c.Sent7d-c.Received7d) / float64(c.Sent7d)
}
+// warmupFilterMinReceived is the sample a recipient's filter is judged on.
+const warmupFilterMinReceived = 10
+
+// FilterJunkRate is the share of verified warmup mail this candidate's own
+// filter put in spam over seven days (0..1). Only a small host's filter is
+// read: at Google, Microsoft and Yahoo the verdict is the senders' reputation,
+// not a quirk of the recipient. 0 below the sample.
+func (c WarmupPartnerCandidate) FilterJunkRate() float64 {
+ if WarmupRecipientGroup(c.MailHost, c.Provider) != WarmupRecipientOther || c.Received7d < warmupFilterMinReceived {
+ return 0
+ }
+ return min(1, float64(c.Junked7d)/float64(c.Received7d))
+}
+
type WarmupHealthState string
const (
@@ -273,16 +289,23 @@ type WarmupHealthCounts struct {
// because a deletion is usually housekeeping and a spam flag never is.
DeletionsLast7d int
SpamFlagsLast7d int
+ // Placement is the last seven days of verified deliveries.
+ Placement WarmupPlacementEvidence
}
type WarmupHealthMetrics struct {
SentLast7d int `json:"sent_last_7d"`
- // SpamPlacementsLast7d counts warmup messages that landed in the
- // recipient's Junk/Spam folder on delivery. SpamPlacementRate is the
- // ratio against SentLast7d.
- SpamPlacementsLast7d int `json:"spam_placements_last_7d"`
- SpamPlacementRate float64 `json:"spam_placement_rate"`
+ // SpamPlacementsLast7d counts every warmup message that landed in a
+ // recipient's Junk/Spam folder on delivery, whoever filed it.
+ SpamPlacementsLast7d int `json:"spam_placements_last_7d"`
+ // SpamPlacementRate is the rate the placement band acts on: spam over
+ // verified deliveries at Google, Microsoft and Yahoo, the PlacementSample.
+ // Other hosts are carried for the reason text and never judged.
+ SpamPlacementRate float64 `json:"spam_placement_rate"`
+ PlacementSample int `json:"placement_sample"`
+ OtherSpamRate float64 `json:"other_spam_rate"`
+ OtherDelivered int `json:"other_delivered"`
// UserComplaintsLast7d counts warmup messages the recipient explicitly
// flagged as spam. WarmupComplaintRate is the ratio against SentLast7d.
diff --git a/internal/models/warmup_deliverability.go b/internal/models/warmup_deliverability.go
index d788f5171..d4da1db42 100644
--- a/internal/models/warmup_deliverability.go
+++ b/internal/models/warmup_deliverability.go
@@ -38,6 +38,27 @@ const (
WarmupUnconfirmedAfterHours = 24
)
+// WarmupPlacementEvidence is one sender's verified warmup deliveries, split
+// into the providers that judge it and every other host. Only Google,
+// Microsoft and Yahoo verdicts count: they filter on the sender reputation
+// cold mail is judged on, while a small host's own filter says little about
+// the sender, so its spam folder is shown and never held against anyone.
+type WarmupPlacementEvidence struct {
+ MajorDelivered int
+ MajorSpam int
+ OtherDelivered int
+ OtherSpam int
+}
+
+// Judged is the spam rate at the providers that judge a sender, and the
+// deliveries it is taken over.
+func (e WarmupPlacementEvidence) Judged() (rate float64, sample int) {
+ if e.MajorDelivered <= 0 {
+ return 0, 0
+ }
+ return float64(e.MajorSpam) / float64(e.MajorDelivered) * 100, e.MajorDelivered
+}
+
// Bands a mailbox's rolling inbox rate falls into.
const (
WarmupPlacementBandGood = "good"
@@ -48,8 +69,8 @@ const (
)
// WarmupRecipientGroup buckets a recipient mailbox by who hosts it, falling
-// back to how it connects when the host is not known yet. Mirrors the CASE in
-// migration 000209.
+// back to how it connects when the host is not known yet. Mirrored in SQL by
+// recipientGroupSQL in the repository.
func WarmupRecipientGroup(mailHost, provider string) string {
switch mailHost {
case "google_workspace", "gmail":
@@ -157,18 +178,33 @@ func (c *WarmupPlacementCounts) Add(o WarmupPlacementCounts) {
c.Unconfirmed += o.Unconfirmed
}
+// Scopes a headline placement rate is taken over.
+const (
+ // WarmupPlacementScopeMajor is Google, Microsoft and Yahoo recipients only.
+ WarmupPlacementScopeMajor = "major"
+ // WarmupPlacementScopeAll is every host, for a mailbox none of the major
+ // providers has received warmup mail from in the window.
+ WarmupPlacementScopeAll = "all"
+)
+
// WarmupPlacementRate is a mailbox's headline deliverability: the inbox rate
// over the trailing window, withheld below the sample floor.
type WarmupPlacementRate struct {
WindowDays int `json:"window_days"`
MinSample int `json:"min_sample"`
- Delivered int `json:"delivered"`
- Inbox int `json:"inbox"`
- Tabs int `json:"tabs"`
- Spam int `json:"spam"`
+ // Scope is which recipients the rate is taken over.
+ Scope string `json:"scope"`
+ Delivered int `json:"delivered"`
+ Inbox int `json:"inbox"`
+ Tabs int `json:"tabs"`
+ Spam int `json:"spam"`
// InboxRate is nil until Delivered reaches MinSample.
InboxRate *float64 `json:"inbox_rate"`
Band string `json:"band"`
+ // OtherDelivered and OtherInboxRate are the other mail hosts left out of a
+ // major-scope rate, shown beside it and never judged; nil with none.
+ OtherDelivered int `json:"other_delivered"`
+ OtherInboxRate *float64 `json:"other_inbox_rate"`
}
// NewWarmupPlacementRate builds the rolling rate from window counters.
@@ -176,6 +212,7 @@ func NewWarmupPlacementRate(inbox, tabs, spam int) WarmupPlacementRate {
r := WarmupPlacementRate{
WindowDays: WarmupPlacementWindowDays,
MinSample: WarmupPlacementMinSample,
+ Scope: WarmupPlacementScopeAll,
Inbox: inbox,
Tabs: tabs,
Spam: spam,
@@ -189,6 +226,44 @@ func NewWarmupPlacementRate(inbox, tabs, spam int) WarmupPlacementRate {
return r
}
+// WarmupPlacementTally is where a window's deliveries landed.
+type WarmupPlacementTally struct {
+ Inbox, Tabs, Spam int
+}
+
+// WarmupPlacementWindow is a trailing window's deliveries, at the major
+// providers and at every host.
+type WarmupPlacementWindow struct {
+ Major WarmupPlacementTally
+ All WarmupPlacementTally
+}
+
+// Add accumulates o into w.
+func (w *WarmupPlacementWindow) Add(o WarmupPlacementWindow) {
+ w.Major.Inbox += o.Major.Inbox
+ w.Major.Tabs += o.Major.Tabs
+ w.Major.Spam += o.Major.Spam
+ w.All.Inbox += o.All.Inbox
+ w.All.Tabs += o.All.Tabs
+ w.All.Spam += o.All.Spam
+}
+
+// Rate is the headline over the major providers, or over every host when none
+// of them received anything in the window.
+func (w WarmupPlacementWindow) Rate() WarmupPlacementRate {
+ if m := w.Major; m.Inbox+m.Tabs+m.Spam > 0 {
+ r := NewWarmupPlacementRate(m.Inbox, m.Tabs, m.Spam)
+ r.Scope = WarmupPlacementScopeMajor
+ ok := w.All.Inbox + w.All.Tabs - m.Inbox - m.Tabs
+ if r.OtherDelivered = w.All.Inbox + w.All.Tabs + w.All.Spam - r.Delivered; r.OtherDelivered > 0 {
+ v := pct2(ok, r.OtherDelivered)
+ r.OtherInboxRate = &v
+ }
+ return r
+ }
+ return NewWarmupPlacementRate(w.All.Inbox, w.All.Tabs, w.All.Spam)
+}
+
// WarmupPlacementGroupCounts is one recipient group's share of a day.
type WarmupPlacementGroupCounts struct {
Group string `json:"group"`
diff --git a/internal/models/warmup_deliverability_test.go b/internal/models/warmup_deliverability_test.go
index 04572ba6d..f7c991d67 100644
--- a/internal/models/warmup_deliverability_test.go
+++ b/internal/models/warmup_deliverability_test.go
@@ -61,3 +61,24 @@ func TestNewWarmupPlacementRate(t *testing.T) {
t.Fatalf("75%% is poor: %+v", r)
}
}
+
+// The headline is taken at the major providers while any of them received
+// mail, and the other hosts ride beside it; with none it covers every host.
+func TestWarmupPlacementWindowRate(t *testing.T) {
+ w := WarmupPlacementWindow{
+ Major: WarmupPlacementTally{Inbox: 18, Tabs: 2},
+ All: WarmupPlacementTally{Inbox: 28, Tabs: 2, Spam: 10},
+ }
+ r := w.Rate()
+ if r.Scope != WarmupPlacementScopeMajor || r.Delivered != 20 || r.InboxRate == nil || *r.InboxRate != 100 {
+ t.Fatalf("major rate = %+v, want 20 delivered at 100%%", r)
+ }
+ if r.OtherDelivered != 20 || r.OtherInboxRate == nil || *r.OtherInboxRate != 50 {
+ t.Fatalf("other hosts = %d at %v, want 20 at 50%%", r.OtherDelivered, r.OtherInboxRate)
+ }
+
+ only := WarmupPlacementWindow{All: WarmupPlacementTally{Inbox: 15, Spam: 5}}.Rate()
+ if only.Scope != WarmupPlacementScopeAll || only.Delivered != 20 || only.OtherDelivered != 0 || only.OtherInboxRate != nil {
+ t.Fatalf("all-host rate = %+v, want every host and nothing beside it", only)
+ }
+}
diff --git a/internal/models/warmup_partner_test.go b/internal/models/warmup_partner_test.go
index ee7ca52e8..0114e19dd 100644
--- a/internal/models/warmup_partner_test.go
+++ b/internal/models/warmup_partner_test.go
@@ -43,3 +43,25 @@ func TestWarmupPoolReturnsToMirrorsTheBorrow(t *testing.T) {
}
}
}
+
+// Only a small host's own filter is read, and only on a sample: at the major
+// providers a junk verdict is the senders' reputation, not the recipient's.
+func TestWarmupPartnerCandidateFilterJunkRate(t *testing.T) {
+ cases := []struct {
+ name string
+ c WarmupPartnerCandidate
+ want float64
+ }{
+ {"small host junking half", WarmupPartnerCandidate{Provider: "smtp_imap", Received7d: 20, Junked7d: 10}, 0.5},
+ {"below the sample", WarmupPartnerCandidate{Provider: "smtp_imap", Received7d: 9, Junked7d: 9}, 0},
+ {"gmail is never read", WarmupPartnerCandidate{Provider: "gmail", Received7d: 20, Junked7d: 20}, 0},
+ {"workspace on imap is never read", WarmupPartnerCandidate{Provider: "smtp_imap", MailHost: "google_workspace", Received7d: 20, Junked7d: 20}, 0},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := tc.c.FilterJunkRate(); got != tc.want {
+ t.Fatalf("FilterJunkRate() = %v, want %v", got, tc.want)
+ }
+ })
+ }
+}
diff --git a/internal/repository/pg_advisor.go b/internal/repository/pg_advisor.go
index b9ae9aa21..9bf40a059 100644
--- a/internal/repository/pg_advisor.go
+++ b/internal/repository/pg_advisor.go
@@ -56,14 +56,16 @@ type AdvisorMailbox struct {
// ColdSent7d / ColdSent1d count completed campaign sends. Bounces and
// complaints are 30-day windows, matching the documented complaint sample
// floor.
- ColdSent7d int
- ColdSent1d int
- ColdSent30d int
- Bounces30d int
- Complaints30d int
- WarmupSent7d int
- WarmupSpam7d int
- WarmupRecv7d int
+ ColdSent7d int
+ ColdSent1d int
+ ColdSent30d int
+ Bounces30d int
+ Complaints30d int
+ WarmupSent7d int
+ WarmupRecv7d int
+ // WarmupPlacement is the week's verified warmup deliveries, split the way
+ // the pool judges them.
+ WarmupPlacement models.WarmupPlacementEvidence
PoolHealth string
PoolHealthScore float64
PoolHealthReason string
diff --git a/internal/repository/pg_advisor_snapshot.go b/internal/repository/pg_advisor_snapshot.go
index 6e0a18071..d1748de06 100644
--- a/internal/repository/pg_advisor_snapshot.go
+++ b/internal/repository/pg_advisor_snapshot.go
@@ -55,7 +55,8 @@ func (r *advisorRepository) loadMailboxes(ctx context.Context, orgID uuid.UUID)
ea.risk_band::text,
COALESCE(sent.d7, 0), COALESCE(sent.d1, 0), COALESCE(sent.d30, 0),
COALESCE(dl.bounces, 0), COALESCE(dl.complaints, 0),
- COALESCE(w.sent7, 0), COALESCE(w.recv7, 0), COALESCE(ws.spam7, 0),
+ COALESCE(w.sent7, 0), COALESCE(w.recv7, 0),
+ ws.major, ws.major_spam, ws.other, ws.other_spam,
COALESCE(p.health_state, ''), COALESCE(p.last_health_score, 0), COALESCE(p.last_health_reason, ''),
COALESCE(p.blocked_until > NOW(), false) AS pool_blocked,
COALESCE(err.n, 0),
@@ -88,12 +89,7 @@ func (r *advisorRepository) loadMailboxes(ctx context.Context, orgID uuid.UUID)
FROM warmup_statistics wst
WHERE wst.email_account_id = ea.id AND wst.date > CURRENT_DATE - 7
) w ON true
- LEFT JOIN LATERAL (
- SELECT COUNT(*) AS spam7
- FROM warmup_spam_reports sr
- WHERE sr.reported_account_id = ea.id
- AND sr.report_type = 'spam_placement'
- AND sr.created_at > NOW() - INTERVAL '7 days'
+ LEFT JOIN LATERAL (` + placementEvidenceSQL("ea.id", "NOW() - INTERVAL '7 days'") + `
) ws ON true
LEFT JOIN LATERAL (
SELECT wpp.health_state, wpp.last_health_score, wpp.last_health_reason, wpp.blocked_until
@@ -148,7 +144,9 @@ func (r *advisorRepository) loadMailboxes(ctx context.Context, orgID uuid.UUID)
&m.RiskBand,
&m.ColdSent7d, &m.ColdSent1d, &m.ColdSent30d,
&m.Bounces30d, &m.Complaints30d,
- &m.WarmupSent7d, &m.WarmupRecv7d, &m.WarmupSpam7d,
+ &m.WarmupSent7d, &m.WarmupRecv7d,
+ &m.WarmupPlacement.MajorDelivered, &m.WarmupPlacement.MajorSpam,
+ &m.WarmupPlacement.OtherDelivered, &m.WarmupPlacement.OtherSpam,
&m.PoolHealth, &m.PoolHealthScore, &m.PoolHealthReason, &m.PoolBlocked,
&m.UnresolvedErrs, &m.InActiveCampaign,
); err != nil {
diff --git a/internal/repository/pg_warmup.go b/internal/repository/pg_warmup.go
index f8c937c37..d619d7c17 100644
--- a/internal/repository/pg_warmup.go
+++ b/internal/repository/pg_warmup.go
@@ -161,9 +161,10 @@ type WarmupRepository interface {
// StampColdRampStart records a mailbox's first cold send. Idempotent: a
// mailbox that already has an anchor keeps it.
StampColdRampStart(ctx context.Context, accountID uuid.UUID) error
- // SpamPlacementsSince lists when this sender's warmup mail was found in a
- // recipient's junk folder. The ramp subtracts a freeze window per
- // placement, so it needs all of them, not just the newest.
+ // SpamPlacementsSince lists when this sender's warmup mail was found in the
+ // junk folder of a Google, Microsoft or Yahoo recipient; another host's
+ // junk folder is never held against a sender. The ramp subtracts a freeze
+ // window per placement, so it needs all of them, not just the newest.
SpamPlacementsSince(ctx context.Context, accountID uuid.UUID, since time.Time) ([]time.Time, error)
SumWarmupSentSince(ctx context.Context, accountID uuid.UUID, since time.Time) (int, error)
@@ -749,8 +750,9 @@ func (r *warmupRepository) ListParticipantHealth(ctx context.Context) ([]models.
return out, rows.Err()
}
-// HealthMetricCounts runs the four aggregates as one statement; each keeps
-// its own predicate so the (type, created_at) indexes still serve it.
+// HealthMetricCounts runs the aggregates as one statement; each keeps its own
+// predicate so the (type, created_at) indexes still serve it. Placement is read
+// through the sender's verified receipts, so its rate is over deliveries.
func (r *warmupRepository) HealthMetricCounts(ctx context.Context, accountID uuid.UUID, since7d, since30d time.Time) (models.WarmupHealthCounts, error) {
query := `
SELECT
@@ -771,13 +773,17 @@ func (r *warmupRepository) HealthMetricCounts(ctx context.Context, accountID uui
(SELECT COUNT(*) FILTER (WHERE kind = 'deletion') FROM warmup_tampering_events
WHERE email_account_id = $1 AND created_at >= $2),
(SELECT COUNT(*) FILTER (WHERE kind = 'spam_flag') FROM warmup_tampering_events
- WHERE email_account_id = $1 AND created_at >= $2)
+ WHERE email_account_id = $1 AND created_at >= $2),
+ placed.major, placed.major_spam, placed.other, placed.other_spam
+ FROM (` + placementEvidenceSQL("$1", "$2") + `) placed
`
var c models.WarmupHealthCounts
+ p := &c.Placement
err := r.db.QueryRow(ctx, query, accountID, since7d, since30d).Scan(
&c.SentLast7d, &c.SpamPlacementsLast7d, &c.UserComplaintsLast7d,
&c.ComplaintsLast30d, &c.BouncesLast30d, &c.DeliveredLast30d,
- &c.DeletionsLast7d, &c.SpamFlagsLast7d)
+ &c.DeletionsLast7d, &c.SpamFlagsLast7d,
+ &p.MajorDelivered, &p.MajorSpam, &p.OtherDelivered, &p.OtherSpam)
return c, err
}
@@ -832,10 +838,11 @@ func (r *warmupRepository) ColdRampStateForAccounts(ctx context.Context, account
placementRows, err := r.db.Query(ctx, `
SELECT reported_account_id, created_at
- FROM warmup_spam_reports
+ FROM warmup_spam_reports sr
WHERE reported_account_id = ANY($1::uuid[])
AND report_type = 'spam_placement'
AND created_at >= $2
+ AND `+majorRecipientSQL+`
ORDER BY created_at
`, accountIDs, since)
if err != nil {
@@ -867,10 +874,11 @@ func (r *warmupRepository) StampColdRampStart(ctx context.Context, accountID uui
func (r *warmupRepository) SpamPlacementsSince(ctx context.Context, accountID uuid.UUID, since time.Time) ([]time.Time, error) {
rows, err := r.db.Query(ctx, `
SELECT created_at
- FROM warmup_spam_reports
+ FROM warmup_spam_reports sr
WHERE reported_account_id = $1
AND report_type = 'spam_placement'
AND created_at >= $2
+ AND `+majorRecipientSQL+`
ORDER BY created_at
`, accountID, since)
if err != nil {
@@ -1111,8 +1119,8 @@ const partnerProvenSQL = `
AND o.risk_state NOT IN ('restricted', 'suspended')`
// partnerCandidateSelectPrefix and partnerCandidateSelectSuffix wrap a
-// candidate set in the reciprocity counts the draw reads (what each candidate
-// sent and received over the last seven days) and apply the inbound cap: a
+// candidate set in the counts the draw reads (what each candidate sent,
+// received and filed as spam over the last seven days) and apply the inbound cap: a
// candidate that has already received, or been dispatched, its day's share is
// not offered. The cap is decided here, before any count or sample is taken
// from the set, so a thin tier is sized on who can still receive. Aggregated
@@ -1136,6 +1144,14 @@ func partnerCandidateSelectSuffix(sharePercent int) string {
AND wr.email_account_id IN (SELECT id FROM cand)
GROUP BY wr.email_account_id
),
+ junk AS (
+ SELECT sr.reporter_account_id, COUNT(*) AS week
+ FROM warmup_spam_reports sr
+ WHERE sr.created_at >= NOW() - interval '7 days'
+ AND sr.report_type = 'spam_placement'
+ AND sr.reporter_account_id IN (SELECT id FROM cand)
+ GROUP BY sr.reporter_account_id
+ ),
sent AS (
SELECT wt.sender_account_id, COUNT(*) AS week
FROM warmup_tokens wt
@@ -1152,9 +1168,10 @@ func partnerCandidateSelectSuffix(sharePercent int) string {
GROUP BY wt.recipient_account_id
)
SELECT cand.id, cand.email, cand.organization_id, cand.provider, cand.mail_host,
- COALESCE(sent.week, 0), COALESCE(recv.week, 0)
+ COALESCE(sent.week, 0), COALESCE(recv.week, 0), COALESCE(junk.week, 0)
FROM cand
LEFT JOIN recv ON recv.email_account_id = cand.id
+ LEFT JOIN junk ON junk.reporter_account_id = cand.id
LEFT JOIN sent ON sent.sender_account_id = cand.id
LEFT JOIN inflight ON inflight.recipient_account_id = cand.id
WHERE GREATEST(COALESCE(recv.today, 0), COALESCE(inflight.today, 0))
@@ -1296,7 +1313,7 @@ func (r *warmupRepository) queryPartnerCandidates(ctx context.Context, candidate
var out []models.WarmupPartnerCandidate
for rows.Next() {
c := models.WarmupPartnerCandidate{PoolType: poolType, Origin: origin}
- if err := rows.Scan(&c.ID, &c.Email, &c.OrganizationID, &c.Provider, &c.MailHost, &c.Sent7d, &c.Received7d); err != nil {
+ if err := rows.Scan(&c.ID, &c.Email, &c.OrganizationID, &c.Provider, &c.MailHost, &c.Sent7d, &c.Received7d, &c.Junked7d); err != nil {
return nil, err
}
out = append(out, c)
diff --git a/internal/repository/pg_warmup_placement.go b/internal/repository/pg_warmup_placement.go
index ff6a22bec..aeca3fb5d 100644
--- a/internal/repository/pg_warmup_placement.go
+++ b/internal/repository/pg_warmup_placement.go
@@ -66,7 +66,7 @@ type WarmupPlacementRepository interface {
// those sent before cutoff.
Unconfirmed(ctx context.Context, orgID uuid.UUID, senderID *uuid.UUID, from, to, cutoff time.Time) ([]WarmupSenderDayCount, error)
// Rates is each sender's trailing-window placement since the given day.
- Rates(ctx context.Context, orgID uuid.UUID, senderID *uuid.UUID, since time.Time) (map[uuid.UUID]models.WarmupPlacementRate, error)
+ Rates(ctx context.Context, orgID uuid.UUID, senderID *uuid.UUID, since time.Time) (map[uuid.UUID]models.WarmupPlacementWindow, error)
}
type warmupPlacementRepository struct {
@@ -123,10 +123,10 @@ func (r *warmupPlacementRepository) RecordPlacement(ctx context.Context, recipie
}
func (r *warmupPlacementRepository) SweepUnplaced(ctx context.Context, cutoff time.Time, limit int) (int, error) {
- // The group CASE mirrors models.WarmupRecipientGroup. Category tabs and
+ // Category tabs and
// rescues are only known on the live path, so a swept receipt reads as
// inbox or spam.
- const query = `
+ query := `
WITH batch AS (
SELECT email_account_id, internal_id FROM warmup_received
WHERE NOT placed AND created_at < $1
@@ -141,14 +141,7 @@ func (r *warmupPlacementRepository) SweepUnplaced(ctx context.Context, cutoff ti
), graded AS (
SELECT c.sender_account_id,
(c.created_at AT TIME ZONE 'UTC')::date AS day,
- CASE
- WHEN rec.mail_host IN ('google_workspace', 'gmail') THEN 'google'
- WHEN rec.mail_host IN ('microsoft365', 'outlook') THEN 'microsoft'
- WHEN rec.mail_host IN ('yahoo', 'aol') THEN 'yahoo'
- WHEN rec.mail_host = '' AND rec.provider = 'gmail' THEN 'google'
- WHEN rec.mail_host = '' AND rec.provider = 'outlook' THEN 'microsoft'
- ELSE 'other'
- END AS grp,
+ ` + recipientGroupSQL("rec") + ` AS grp,
rec.mail_host AS host,
EXISTS (
SELECT 1 FROM warmup_spam_reports sr
@@ -286,9 +279,13 @@ func (r *warmupPlacementRepository) senderDayCounts(ctx context.Context, query s
return out, rows.Err()
}
-func (r *warmupPlacementRepository) Rates(ctx context.Context, orgID uuid.UUID, senderID *uuid.UUID, since time.Time) (map[uuid.UUID]models.WarmupPlacementRate, error) {
+func (r *warmupPlacementRepository) Rates(ctx context.Context, orgID uuid.UUID, senderID *uuid.UUID, since time.Time) (map[uuid.UUID]models.WarmupPlacementWindow, error) {
const query = `
- SELECT p.sender_account_id, SUM(p.inbox)::int, SUM(p.tabs)::int, SUM(p.spam)::int
+ SELECT p.sender_account_id,
+ SUM(p.inbox) FILTER (WHERE p.recipient_group <> 'other')::int,
+ SUM(p.tabs) FILTER (WHERE p.recipient_group <> 'other')::int,
+ SUM(p.spam) FILTER (WHERE p.recipient_group <> 'other')::int,
+ SUM(p.inbox)::int, SUM(p.tabs)::int, SUM(p.spam)::int
FROM warmup_placement_daily p
JOIN email_accounts ea ON ea.id = p.sender_account_id
WHERE ea.organization_id = $1
@@ -303,14 +300,21 @@ func (r *warmupPlacementRepository) Rates(ctx context.Context, orgID uuid.UUID,
return nil, err
}
defer rows.Close()
- out := make(map[uuid.UUID]models.WarmupPlacementRate)
+ out := make(map[uuid.UUID]models.WarmupPlacementWindow)
for rows.Next() {
var id uuid.UUID
- var inbox, tabs, spam int
- if err := rows.Scan(&id, &inbox, &tabs, &spam); err != nil {
+ var major [3]*int
+ var w models.WarmupPlacementWindow
+ if err := rows.Scan(&id, &major[0], &major[1], &major[2], &w.All.Inbox, &w.All.Tabs, &w.All.Spam); err != nil {
return nil, err
}
- out[id] = models.NewWarmupPlacementRate(inbox, tabs, spam)
+ // A sum over no major rows is NULL.
+ for i, dst := range []*int{&w.Major.Inbox, &w.Major.Tabs, &w.Major.Spam} {
+ if major[i] != nil {
+ *dst = *major[i]
+ }
+ }
+ out[id] = w
}
return out, rows.Err()
}
diff --git a/internal/repository/warmup_placement_live_test.go b/internal/repository/warmup_placement_live_test.go
index bcfe0a390..4dec370b4 100644
--- a/internal/repository/warmup_placement_live_test.go
+++ b/internal/repository/warmup_placement_live_test.go
@@ -118,8 +118,8 @@ func TestLiveWarmupPlacementRollup(t *testing.T) {
if err != nil {
t.Fatalf("Rates: %v", err)
}
- if r := rates[sender]; r.Delivered != 4 || r.Spam != 1 || r.Band != models.WarmupPlacementBandCollecting {
- t.Fatalf("rate = %+v, want 4 delivered, below the floor", r)
+ if r := rates[sender].Rate(); r.Delivered != 4 || r.Spam != 1 || r.Band != models.WarmupPlacementBandCollecting || r.Scope != models.WarmupPlacementScopeMajor {
+ t.Fatalf("rate = %+v, want 4 delivered at the major providers, below the floor", r)
}
// Every receipt the live path counted is marked, so the sweep never counts it again.
diff --git a/internal/repository/warmup_placement_sql.go b/internal/repository/warmup_placement_sql.go
new file mode 100644
index 000000000..f3fa4e8ae
--- /dev/null
+++ b/internal/repository/warmup_placement_sql.go
@@ -0,0 +1,44 @@
+package repository
+
+import (
+ "fmt"
+)
+
+// recipientGroupSQL is models.WarmupRecipientGroup over an email_accounts
+// alias; the two must agree.
+func recipientGroupSQL(alias string) string {
+ return fmt.Sprintf(`CASE
+ WHEN %[1]s.mail_host IN ('google_workspace', 'gmail') THEN 'google'
+ WHEN %[1]s.mail_host IN ('microsoft365', 'outlook') THEN 'microsoft'
+ WHEN %[1]s.mail_host IN ('yahoo', 'aol') THEN 'yahoo'
+ WHEN %[1]s.mail_host = '' AND %[1]s.provider = 'gmail' THEN 'google'
+ WHEN %[1]s.mail_host = '' AND %[1]s.provider = 'outlook' THEN 'microsoft'
+ ELSE 'other'
+ END`, alias)
+}
+
+// majorRecipientSQL holds for a spam report filed at Google, Microsoft or
+// Yahoo, the only placements held against a sender.
+var majorRecipientSQL = fmt.Sprintf(`EXISTS (
+ SELECT 1 FROM email_accounts rec
+ WHERE rec.id = sr.reporter_account_id AND %s <> 'other')`, recipientGroupSQL("rec"))
+
+// placementEvidenceSQL selects models.WarmupPlacementEvidence for one sender
+// since a time, read through its verified receipts; both are SQL expressions.
+func placementEvidenceSQL(sender, since string) string {
+ return fmt.Sprintf(`
+ SELECT
+ COUNT(*) FILTER (WHERE NOT other) AS major,
+ COUNT(*) FILTER (WHERE NOT other AND spam) AS major_spam,
+ COUNT(*) FILTER (WHERE other) AS other,
+ COUNT(*) FILTER (WHERE other AND spam) AS other_spam
+ FROM (
+ SELECT %[3]s = 'other' AS other, sr.id IS NOT NULL AS spam
+ FROM warmup_received wr
+ JOIN email_accounts rec ON rec.id = wr.email_account_id
+ LEFT JOIN warmup_spam_reports sr
+ ON sr.reporter_account_id = wr.email_account_id AND sr.message_id = wr.message_id
+ AND sr.report_type = 'spam_placement'
+ WHERE wr.sender_account_id = %[1]s AND wr.created_at >= %[2]s AND wr.message_id <> ''
+ ) d`, sender, since, recipientGroupSQL("rec"))
+}
diff --git a/internal/tasks/email_task.go b/internal/tasks/email_task.go
index 44826d03c..e44c27347 100644
--- a/internal/tasks/email_task.go
+++ b/internal/tasks/email_task.go
@@ -535,6 +535,7 @@ func (s *tasksService) selectWarmupPartner(ctx context.Context, account Email) (
hostsByID := make(map[uuid.UUID]string, len(candidates))
ruleWeight := make(map[uuid.UUID]float64, len(candidates))
starvation := make(map[uuid.UUID]float64, len(candidates))
+ filterJunk := make(map[uuid.UUID]float64)
poolOf := make(map[uuid.UUID]string, len(candidates))
excluded := 0
for _, c := range candidates {
@@ -551,6 +552,9 @@ func (s *tasksService) selectWarmupPartner(ctx context.Context, account Email) (
hostsByID[c.ID] = partnerHost(c)
ruleWeight[c.ID] = weight
starvation[c.ID] = c.Starvation()
+ if junk := c.FilterJunkRate(); junk > 0 {
+ filterJunk[c.ID] = junk
+ }
poolOf[c.ID] = c.PoolType
eligible = append(eligible, c)
}
@@ -643,6 +647,7 @@ func (s *tasksService) selectWarmupPartner(ctx context.Context, account Email) (
placementByHost: placementByHost,
ruleWeight: ruleWeight,
starvation: starvation,
+ filterJunk: filterJunk,
}
// A pick that fails the gate is dropped and the draw repeats; an emptied
@@ -720,6 +725,9 @@ const (
// and the boost fades as the pool pays it back, so traffic settles near
// parity instead of overshooting.
reciprocityBoostK = 3.0
+ // weight *= 1/(1 + k*junk). A small host whose own filter junks what the
+ // pool sends it earns no sender anything, so it is drawn less, never excluded.
+ recipientFilterPenaltyK = 3.0
)
// partnerSignals are the per-pick inputs to partner weighting.
@@ -735,6 +743,15 @@ type partnerSignals struct {
ruleWeight map[uuid.UUID]float64
// starvation is how far behind each candidate is on what it sent (0..1).
starvation map[uuid.UUID]float64
+ // filterJunk is how much warmup mail a small-host candidate's own filter
+ // junks (0..1); absent when nothing is known or the host is a major one.
+ filterJunk map[uuid.UUID]float64
+}
+
+// filterPenalty draws a recipient whose own filter junks warmup mail less
+// often. 1.0 when nothing is known.
+func (sig partnerSignals) filterPenalty(partnerID uuid.UUID) float64 {
+ return 1.0 / (1.0 + recipientFilterPenaltyK*sig.filterJunk[partnerID])
}
// reciprocityBoost favours the inbox that is owed the most. 1.0 for one in
@@ -768,6 +785,7 @@ func (sig partnerSignals) hostPenalty(partnerID uuid.UUID) float64 {
// - inverse-frequency on the partner's recipient domain (diversity)
// - this sender's recent junk rate at the partner's mail host (feedback)
// - how far behind the partner is on what it sent (reciprocity)
+// - how much a small-host partner's own filter junks (recipient quality)
// - customer-defined routing rule multipliers (preference)
//
// Every candidate here is one the customer allows; an excluded pair was
@@ -776,7 +794,7 @@ func pickWeightedPartner(candidates []uuid.UUID, sig partnerSignals) uuid.UUID {
if len(candidates) == 1 {
return candidates[0]
}
- if len(sig.domainsByID) == 0 && len(sig.ruleWeight) == 0 && len(sig.placementByHost) == 0 && len(sig.starvation) == 0 {
+ if len(sig.domainsByID) == 0 && len(sig.ruleWeight) == 0 && len(sig.placementByHost) == 0 && len(sig.starvation) == 0 && len(sig.filterJunk) == 0 {
return candidates[rand.Intn(len(candidates))]
}
@@ -793,6 +811,9 @@ func pickWeightedPartner(candidates []uuid.UUID, sig partnerSignals) uuid.UUID {
// The pool's debt to this inbox.
w *= sig.reciprocityBoost(id)
+ // A recipient whose own filter junks what it is sent.
+ w *= sig.filterPenalty(id)
+
// Routing rule multiplier (premium pool only, when configured).
if rw, ok := sig.ruleWeight[id]; ok {
w *= rw
diff --git a/internal/tasks/partner_selection_buckets_test.go b/internal/tasks/partner_selection_buckets_test.go
index 5f48be520..d750fc810 100644
--- a/internal/tasks/partner_selection_buckets_test.go
+++ b/internal/tasks/partner_selection_buckets_test.go
@@ -376,3 +376,23 @@ func TestReciprocityBoostFavoursTheStarvedInbox(t *testing.T) {
t.Fatalf("starved inbox drawn %d/2000, want about 1600", draws[starved])
}
}
+
+// A small host whose own filter junks everything it receives is drawn a
+// quarter as often, and never ruled out.
+func TestFilterPenaltyDrawsAJunkingRecipientLess(t *testing.T) {
+ strict, fine := uuid.New(), uuid.New()
+ sig := partnerSignals{filterJunk: map[uuid.UUID]float64{strict: 1}}
+ if got := sig.filterPenalty(strict); got != 1/(1+recipientFilterPenaltyK) {
+ t.Fatalf("strict penalty = %v, want %v", got, 1/(1+recipientFilterPenaltyK))
+ }
+ if got := sig.filterPenalty(fine); got != 1 {
+ t.Fatalf("unknown recipient penalty = %v, want 1", got)
+ }
+ picks := map[uuid.UUID]int{}
+ for range 4000 {
+ picks[pickWeightedPartner([]uuid.UUID{strict, fine}, sig)]++
+ }
+ if picks[strict] == 0 || picks[strict]*2 > picks[fine] {
+ t.Fatalf("picks = strict %d, fine %d; want the junking host drawn far less but still drawn", picks[strict], picks[fine])
+ }
+}
diff --git a/web/src/components/app/placement/MailboxPlacementTab.tsx b/web/src/components/app/placement/MailboxPlacementTab.tsx
index b16d905a8..af7198961 100644
--- a/web/src/components/app/placement/MailboxPlacementTab.tsx
+++ b/web/src/components/app/placement/MailboxPlacementTab.tsx
@@ -17,6 +17,7 @@ import {
bandForRate,
fmtNum,
fmtPct,
+ otherHostsNote,
rateSentence,
totals,
utcWindow,
@@ -111,6 +112,7 @@ export default function MailboxPlacementTab({ mailboxId, poolHealth }: { mailbox
{fmtNum(rate.spam)} in spam · {fmtNum(rate.tabs)} in other tabs · 90%+ is healthy
)}
+ {otherHostsNote(rate) && {otherHostsNote(rate)}
}
@@ -177,8 +179,13 @@ export default function MailboxPlacementTab({ mailboxId, poolHealth }: { mailbox
How this is measured
- Each warmup email is found in the partner's mailbox and recorded where it arrived: the inbox, a Gmail category tab, or spam. Nothing is estimated.
- - The inbox rate counts category tabs as inbox, over a trailing {rate.window_days} days, and appears once {rate.min_sample} deliveries are in. Below 90% is worth watching; below 80% means the mailbox needs attention.
+ - The inbox rate counts category tabs as inbox, over a trailing {rate.window_days} days, and appears once {rate.min_sample} deliveries are in. It covers every host only when none of the major providers received this mailbox's warmup mail. Below 90% is worth watching; below 80% means the mailbox needs attention.
- Rescued counts spam placements the partner's mailbox was told to move back to the inbox, which is the signal providers learn from; the move is requested, not confirmed back. Unconfirmed mail has not been seen in the partner's mailbox a day after it was sent.
+ -
+ The inbox rate above and the mailbox's standing are judged at Google, Microsoft and Yahoo, the providers that filter on sender
+ reputation. Other mail hosts run their own filters, so what lands in their spam folders is shown in the breakdown and never held
+ against this mailbox. Spam at the major providers only slows sending down; it never removes the mailbox from warmup.
+
- Days are UTC. This covers warmup mail only, not campaign sends.
diff --git a/web/src/components/app/placement/WarmupPlacementSection.tsx b/web/src/components/app/placement/WarmupPlacementSection.tsx
index a5970c9c5..4c2762486 100644
--- a/web/src/components/app/placement/WarmupPlacementSection.tsx
+++ b/web/src/components/app/placement/WarmupPlacementSection.tsx
@@ -7,7 +7,7 @@ import { EmptyBlock, SectionBar, Stat, StatStrip } from "@/components/layout/Pag
import useWarmupPlacement from "@/lib/api/hooks/app/analytics/useWarmupPlacement";
import type { PlacementGroup, PlacementMailbox } from "@/lib/api/models/app/analytics/WarmupPlacement";
import { cn } from "@/lib/utils";
-import { BAND, GROUP_LABEL, GROUP_ORDER, bandForRate, fmtNum, fmtPct, rateSentence, totals, utcWindow, viewDays, type GroupFilter } from "./placement";
+import { BAND, GROUP_LABEL, GROUP_ORDER, bandForRate, fmtNum, fmtPct, otherHostsNote, rateSentence, totals, utcWindow, viewDays, type GroupFilter } from "./placement";
import {
BandChip,
GroupFilterChips,
@@ -81,7 +81,7 @@ export default function WarmupPlacementSection({ days }: { days: number }) {
{rate.inbox_rate != null ? fmtPct(rate.inbox_rate) : "—"}}
- sub={rate.inbox_rate != null ? BAND[rate.band].label : rateSentence(rate)}
+ sub={rate.inbox_rate != null ? (otherHostsNote(rate, true) ?? BAND[rate.band].label) : rateSentence(rate)}
/>