diff --git a/AGENTS.md b/AGENTS.md index e95245b1f..02e614886 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -799,7 +799,7 @@ How placement is read (`WarmupPlacementEvidence` in `internal/models/warmup_deli - 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"`) +- the headline inbox rate (`WarmupPlacementWindow.Rate`) and the daily rolling rate are taken at the same three providers only; a mailbox whose mail reached only small hosts has no rate, never one built from them - 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: diff --git a/docs/content/docs/api/reference/analytics.mdx b/docs/content/docs/api/reference/analytics.mdx index 75056dd4a..028de60bf 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 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 when those three have not reached `min_sample` in the window and every host has. 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 trailing 7-day figure over every host ending on that day, the same scope as the day's counts; filter by provider to read it for one. 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` is always `major`: the rate is taken over Google, Microsoft and Yahoo recipients only, the providers that filter on sender reputation. A mailbox whose warmup mail reached only other hosts has no rate (`delivered` `0`, `band` `none`). Other hosts are always in `summary`, `daily` and `providers`, and `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 trailing 7-day figure at the same three providers ending on that day; the day's counts cover every host, so filter by provider to read one. 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. diff --git a/docs/content/docs/guides/mailboxes.mdx b/docs/content/docs/guides/mailboxes.mdx index 037b67f33..acdea4920 100644 --- a/docs/content/docs/guides/mailboxes.mdx +++ b/docs/content/docs/guides/mailboxes.mdx @@ -406,9 +406,9 @@ This is not the same as the risk band that decides which sending worker and IP h ## Health -The Accounts list groups mailboxes as **Healthy** (sending normally), **Warming** (ramping through warmup), or **Needs attention** (paused, failing, or not sending), each row showing a live state and score. A drop between refreshes notifies you rather than waiting to be noticed. +The Accounts list groups mailboxes as **Healthy** (sending normally), **Warming** (ramping through warmup), or **Needs attention** (paused, failing, or not sending). Each row shows the mailbox's host, sender name and tags, a **Status** that says what it is doing (**Sending**, **Warming**, **Active** when it does both, **Resting**, or **Idle**, and **Error**, **Off** or **Reconnect** when something stops it), campaign emails sent today against its cap, warmup sent today against the ramp target, the inbox rate, and the health score. Hover a status for the reason, and click any column header to sort. A drop in health between refreshes notifies you rather than waiting to be noticed. -The **Inbox** column is the share of the mailbox's warmup mail that reached partners' inboxes over the last seven days, and the score is capped at it, so a mailbox landing some mail in spam no longer reads `100`. Click it, or open the drawer's **Deliverability** tab, for the day-by-day history and the split per provider. See [seeing where warmup lands](/guides/warmup/#seeing-where-warmup-lands). +The **Inbox** column is the share of the mailbox's warmup mail that reached partners' inboxes at Google, Microsoft and Yahoo over the last seven days (smaller mail hosts run their own filters and are never counted), and the score is capped at it, so a mailbox landing some mail in spam no longer reads `100`. Click it, or open the drawer's **Deliverability** tab, for the day-by-day history and the split per provider. See [seeing where warmup lands](/guides/warmup/#seeing-where-warmup-lands). The detail drawer runs a live `SPF`, `DKIM`, and `DMARC` check on your sending domain. Confirm it is green before sending; major providers require alignment for bulk and cold mail. A domain that stays Failing eventually stops cold sending and warmup from every mailbox on it, so fix the records and press **Re-check** to clear it straight away. DKIM reads **Not verified** rather than Missing when no key answers, because its selector is not something DNS can be asked for; that never gates sending. See [Deliverability](/guides/deliverability/) for the grace period and exactly what is stopped. diff --git a/docs/content/docs/guides/warmup.mdx b/docs/content/docs/guides/warmup.mdx index 955537c91..38ac94d24 100644 --- a/docs/content/docs/guides/warmup.mdx +++ b/docs/content/docs/guides/warmup.mdx @@ -94,7 +94,7 @@ The **inbox rate** is inbox plus category tabs over everything delivered in the | `80%` to `90%` | Watch | | Below `80%` | Landing in spam | -The rate is taken at Google, Microsoft and Yahoo, the providers that judge a mailbox. When they have not received `20` of its warmup emails in the window but every host together has, it is taken over every host instead, and that figure is shown without lowering the mailbox's health score. 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`. +The rate is taken at Google, Microsoft and Yahoo only, the providers that judge a mailbox. Smaller mail hosts run their own filters, so their spam folders never count toward it: a mailbox whose warmup mail only reached small hosts shows no rate rather than one built from them. Other mail hosts are still shown: their inbox rate sits right under the headline in the drawer and on **Deliverability**, and they appear in the daily charts, the counts and the per-provider breakdown. It only appears once `20` deliveries at those three are in the window, the same sample the pool needs before it acts on spam placement. Until then the mailbox shows how far along it is (`8/20`) rather than a percentage that would swing on every delivery. The mailbox's health score is capped at its inbox rate, so a mailbox with some mail in spam reads `97`, not a flat `100`. Filter the drawer or the page by recipient provider to see whether a problem is general or confined to one: **Google** covers Gmail and Google Workspace, **Microsoft** covers Outlook.com and Microsoft 365, and expanding a provider shows each host inside it. **Rescued** counts spam placements Warmbly told the partner's mailbox to move back to the inbox, the "not spam" signal providers learn from; it is the rescues requested, since the partner's mailbox does not confirm the move. **Unconfirmed** counts mail sent more than a day ago that the partner has not seen yet, usually a partner whose sync is behind, and occasionally mail that never arrived. Spam is read from where the partner's provider filed the message: Gmail's Spam label, Outlook's Junk Email folder, or an IMAP server's Junk folder whether or not the server also flags it, and [pool safety](#how-spam-placement-is-judged) reads the same landings at Google, Microsoft and Yahoo. History from before this view existed is filled in from the receipts still on file shortly after an upgrade, without the tab and rescue split. The figures are warmup mail only, not campaign sends, and update live as partners report in. Days are UTC. The same numbers are in the [warmup placement](/api/reference/analytics/#get-warmup-placement) endpoint. diff --git a/docs/public/openapi.json b/docs/public/openapi.json index 52d33f087..208fdb3f1 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -39685,10 +39685,9 @@ "scope": { "type": "string", "enum": [ - "major", - "all" + "major" ], - "description": "major: taken over Google, Microsoft and Yahoo recipients only. all: every host, when those three have not reached min_sample in the window and every host has." + "description": "Always major: the rate is taken over Google, Microsoft and Yahoo recipients only." }, "delivered": { "type": "integer" @@ -39720,14 +39719,14 @@ }, "other_delivered": { "type": "integer", - "description": "Deliveries at other mail hosts, left out of a major-scope rate; 0 on an all-scope rate." + "description": "Deliveries at other mail hosts, left out of the rate." }, "other_inbox_rate": { "type": [ "number", "null" ], - "description": "Inbox rate at those other hosts, shown beside the rate and never judged; null with none." + "description": "Inbox rate at those other hosts, shown beside the rate and never counted; null with none." } } }, diff --git a/internal/app/analytics/warmup_placement.go b/internal/app/analytics/warmup_placement.go index ffa341bbf..3f6b25c15 100644 --- a/internal/app/analytics/warmup_placement.go +++ b/internal/app/analytics/warmup_placement.go @@ -38,8 +38,7 @@ func (s *analyticsService) placementRates(ctx context.Context, orgID uuid.UUID, return headlineRates(windows) } -// headlineRates is each mailbox's headline, over the major providers when -// they received its warmup mail. +// headlineRates is each mailbox's headline, over the major providers. 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 { @@ -55,10 +54,9 @@ func placementWindowStart(now time.Time) time.Time { } // applyWarmupPlacement caps the health score at the headline inbox rate, so a -// mailbox landing in spam at the major providers reads as degraded. An -// all-host rate is shown, never held against the mailbox. +// 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 || r.Scope != models.WarmupPlacementScopeMajor { + if r == nil || r.InboxRate == nil { return } if capped := int(math.Floor(*r.InboxRate)); capped < health.Score { @@ -174,7 +172,7 @@ type placementBuilder struct { day []models.WarmupPlacementCounts groups []map[string]*models.WarmupPlacementGroupCounts senders map[uuid.UUID]*senderPlacement - // window holds every day's deliveries from the lookback on, for the rolling rate. + // window holds every day's major-provider deliveries from the lookback on, for the rolling rate. window map[string][3]int first time.Time } @@ -211,11 +209,13 @@ func (b *placementBuilder) sender(id uuid.UUID) *senderPlacement { } func (b *placementBuilder) addDelivery(r repository.WarmupPlacementDayRow) { - w := b.window[r.Date] - w[0] += r.Inbox - w[1] += r.Tabs - w[2] += r.Spam - b.window[r.Date] = w + if r.Group != models.WarmupRecipientOther { + w := b.window[r.Date] + w[0] += r.Inbox + w[1] += r.Tabs + w[2] += r.Spam + b.window[r.Date] = w + } i, ok := b.index[r.Date] if !ok { diff --git a/internal/app/analytics/warmup_placement_test.go b/internal/app/analytics/warmup_placement_test.go index 00b71218b..d85ba0436 100644 --- a/internal/app/analytics/warmup_placement_test.go +++ b/internal/app/analytics/warmup_placement_test.go @@ -17,19 +17,21 @@ func TestPlacementBuilderRollingReadsTheLookback(t *testing.T) { // Before the range: only the rolling rate may see it. b.addDelivery(repository.WarmupPlacementDayRow{SenderID: sender, Date: "2026-09-05", Group: "google", Inbox: 18, Spam: 2}) b.addDelivery(repository.WarmupPlacementDayRow{SenderID: sender, Date: "2026-09-10", Group: "microsoft", Inbox: 4, Tabs: 1, Spam: 5, Rescued: 4}) + // A small host counts in the day, never in the rolling rate. + b.addDelivery(repository.WarmupPlacementDayRow{SenderID: sender, Date: "2026-09-10", Group: "other", Spam: 10}) days := b.days() if len(days) != 2 { t.Fatalf("got %d days", len(days)) } d := days[0] - if d.Delivered != 10 || d.Spam != 5 || d.Rescued != 4 || *d.InboxRate != 50 { + if d.Delivered != 20 || d.Spam != 15 || d.Rescued != 4 || *d.InboxRate != 25 { t.Fatalf("day counts: %+v", d.WarmupPlacementCounts) } if d.RollingInboxRate == nil || *d.RollingInboxRate != 76.67 { t.Fatalf("rolling rate over 30 deliveries: %v", d.RollingInboxRate) } - if len(d.Groups) != 1 || d.Groups[0].Group != "microsoft" { + if len(d.Groups) != 2 || d.Groups[0].Group != "microsoft" { t.Fatalf("groups: %+v", d.Groups) } if days[1].Delivered != 0 || days[1].InboxRate != nil { @@ -37,7 +39,7 @@ func TestPlacementBuilderRollingReadsTheLookback(t *testing.T) { } boxes := b.mailboxes(map[uuid.UUID]string{sender: "a@example.com"}, nil) - if len(boxes) != 1 || boxes[0].Delivered != 10 || boxes[0].Rate.Band != models.WarmupPlacementBandNone { + if len(boxes) != 1 || boxes[0].Delivered != 20 || boxes[0].Rate.Band != models.WarmupPlacementBandNone { t.Fatalf("mailboxes: %+v", boxes) } } @@ -48,11 +50,11 @@ func TestApplyWarmupPlacementCapsHealth(t *testing.T) { if h.Score != 100 { t.Fatalf("no reading leaves health alone: %+v", h) } - // A rate over every host is shown, never held against the mailbox. - all := models.NewWarmupPlacementRate(50, 0, 50) - applyWarmupPlacement(&h, &all) + // Small hosts alone leave no headline, so nothing is held against the mailbox. + small := models.WarmupPlacementWindow{All: models.WarmupPlacementTally{Inbox: 50, Spam: 50}}.Rate() + applyWarmupPlacement(&h, &small) if h.Score != 100 || h.Status != "healthy" { - t.Fatalf("an all-host rate moved health: %+v", h) + t.Fatalf("a small-host reading moved health: %+v", h) } major := func(inbox, spam int) models.WarmupPlacementRate { return models.WarmupPlacementWindow{Major: models.WarmupPlacementTally{Inbox: inbox, Spam: spam}}.Rate() diff --git a/internal/models/warmup_deliverability.go b/internal/models/warmup_deliverability.go index 3ab36487d..6855ffcd4 100644 --- a/internal/models/warmup_deliverability.go +++ b/internal/models/warmup_deliverability.go @@ -178,14 +178,9 @@ 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 the major providers - // have not received enough warmup mail from in the window to rate. - WarmupPlacementScopeAll = "all" -) +// WarmupPlacementScopeMajor is the one scope a headline rate is taken over: +// Google, Microsoft and Yahoo recipients only. +const WarmupPlacementScopeMajor = "major" // WarmupPlacementRate is a mailbox's headline deliverability: the inbox rate // over the trailing window, withheld below the sample floor. @@ -212,7 +207,7 @@ func NewWarmupPlacementRate(inbox, tabs, spam int) WarmupPlacementRate { r := WarmupPlacementRate{ WindowDays: WarmupPlacementWindowDays, MinSample: WarmupPlacementMinSample, - Scope: WarmupPlacementScopeAll, + Scope: WarmupPlacementScopeMajor, Inbox: inbox, Tabs: tabs, Spam: spam, @@ -248,22 +243,16 @@ func (w *WarmupPlacementWindow) Add(o WarmupPlacementWindow) { w.All.Spam += o.All.Spam } -// Rate is the headline over the major providers, or over every host when they -// have not reached the sample and every host has, so a mailbox mostly warming -// with small hosts still shows a figure. +// Rate is the headline over the major providers only; a small host's own +// filter says nothing about the sender, so other hosts ride beside it. func (w WarmupPlacementWindow) Rate() WarmupPlacementRate { - major, all := w.Major.Inbox+w.Major.Tabs+w.Major.Spam, w.All.Inbox+w.All.Tabs+w.All.Spam - if m := w.Major; major > 0 && (major >= WarmupPlacementMinSample || all < WarmupPlacementMinSample) { - 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 + m := w.Major + r := NewWarmupPlacementRate(m.Inbox, m.Tabs, m.Spam) + if r.OtherDelivered = w.All.Inbox + w.All.Tabs + w.All.Spam - r.Delivered; r.OtherDelivered > 0 { + v := pct2(w.All.Inbox+w.All.Tabs-m.Inbox-m.Tabs, r.OtherDelivered) + r.OtherInboxRate = &v } - return NewWarmupPlacementRate(w.All.Inbox, w.All.Tabs, w.All.Spam) + return r } // WarmupPlacementGroupCounts is one recipient group's share of a day. diff --git a/internal/models/warmup_deliverability_test.go b/internal/models/warmup_deliverability_test.go index 56d5c4233..09bda7027 100644 --- a/internal/models/warmup_deliverability_test.go +++ b/internal/models/warmup_deliverability_test.go @@ -62,8 +62,8 @@ func TestNewWarmupPlacementRate(t *testing.T) { } } -// 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. +// The headline is taken at the major providers only, and the other hosts ride +// beside it however many deliveries they have. func TestWarmupPlacementWindowRate(t *testing.T) { w := WarmupPlacementWindow{ Major: WarmupPlacementTally{Inbox: 18, Tabs: 2}, @@ -77,19 +77,13 @@ func TestWarmupPlacementWindowRate(t *testing.T) { t.Fatalf("other hosts = %d at %v, want 20 at 50%%", r.OtherDelivered, r.OtherInboxRate) } + // Small hosts alone never produce a headline, however many there are. 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) + if only.InboxRate != nil || only.Band != WarmupPlacementBandNone || only.Delivered != 0 || only.OtherDelivered != 20 { + t.Fatalf("small-host-only rate = %+v, want no headline and 20 beside it", only) } - - // Three Gmail deliveries do not hide a rate over 150 at small hosts. thin := WarmupPlacementWindow{Major: WarmupPlacementTally{Inbox: 3}, All: WarmupPlacementTally{Inbox: 120, Spam: 33}}.Rate() - if thin.Scope != WarmupPlacementScopeAll || thin.InboxRate == nil || thin.Delivered != 153 { - t.Fatalf("thin major rate = %+v, want the all-host figure", thin) - } - // With neither at the sample, the major count is what is being collected. - early := WarmupPlacementWindow{Major: WarmupPlacementTally{Inbox: 3}, All: WarmupPlacementTally{Inbox: 8}}.Rate() - if early.Scope != WarmupPlacementScopeMajor || early.Band != WarmupPlacementBandCollecting || early.Delivered != 3 { - t.Fatalf("early rate = %+v, want 3 of the major sample collecting", early) + if thin.InboxRate != nil || thin.Band != WarmupPlacementBandCollecting || thin.Delivered != 3 || thin.OtherDelivered != 150 { + t.Fatalf("thin major rate = %+v, want 3 of the major sample collecting", thin) } } diff --git a/web/src/app/app/emails/page.tsx b/web/src/app/app/emails/page.tsx index a0f187672..be7b39664 100644 --- a/web/src/app/app/emails/page.tsx +++ b/web/src/app/app/emails/page.tsx @@ -32,7 +32,6 @@ import type { AppError } from "@/lib/api/client/normalizeError"; import BulkWarmupDialog from "@/components/app/emails/BulkWarmupDialog"; import BulkTagPopover from "@/components/app/emails/BulkTagPopover"; import MailboxImportsMenu from "@/components/app/emails/import/MailboxImportsMenu"; -import MailboxSourceChip from "@/components/app/emails/MailboxSourceChip"; import SigninMigrationBanner, { SigninRetiringChip } from "@/components/app/emails/migration/SigninMigrationBanner"; import SigninMigrationDialog from "@/components/app/emails/migration/SigninMigrationDialog"; import MailboxGrantDialog from "@/components/app/emails/import/grants/MailboxGrantDialog"; @@ -44,19 +43,32 @@ import mailboxDisplayStatus from "@/lib/mailboxStatus"; import type AccountStatus from "@/lib/api/models/app/analytics/AccountStatus"; import { ActivityIcon, + AlertTriangleIcon, + ArrowDownIcon, + ArrowUpIcon, CheckIcon, + CircleSlashIcon, FilterIcon, + FlameIcon, GaugeIcon, GlobeIcon, Loader2Icon, + MoonIcon, PauseIcon, PlayIcon, PlusIcon, RotateCcwIcon, + SendIcon, Settings2Icon, Trash2Icon, + UnplugIcon, + UserIcon, XIcon, + type LucideIcon, } from "lucide-react"; +import ProviderLogo from "@/components/app/emails/ProviderLogo"; +import { Dash, InfoHeader } from "@/components/app/contacts/cells"; +import clippedTitle from "@/lib/helper/clippedTitle"; import { SearchInput } from "@/components/ui/field"; import AnimatedNumber from "@/components/ui/AnimatedNumber"; import { @@ -93,7 +105,7 @@ const HEALTH_RANK: Record = { healthy: 0, warning: 1, error: 2 } function healthTone(status?: AccountStatus): { dot: string; text: string; label: string; pulse: boolean } { const h = status?.health; if (!h) return { dot: "bg-slate-300", text: "text-slate-500", label: "—", pulse: false }; - if (h.status === "healthy") return { dot: "bg-emerald-500", text: "text-emerald-600", label: `Healthy ${h.score}`, pulse: false }; + if (h.status === "healthy") return { dot: "bg-emerald-500", text: "text-emerald-700", label: `Healthy ${h.score}`, pulse: false }; if (h.status === "warning") return { dot: "bg-amber-500", text: "text-amber-600", label: `At risk ${h.score}`, pulse: true }; return { dot: "bg-rose-500", text: "text-rose-600", label: `Issue ${h.score}`, pulse: true }; } @@ -291,6 +303,26 @@ export default function AddressesPage() { : false; } + // Every mailbox page is loaded, so the headers sort in the browser. + const [sort, setSort] = React.useState(null); + const sortBy = (col: MailboxColumn) => + setSort((cur) => + cur?.by === col.id ? { by: col.id, reverse: !cur.reverse } : { by: col.id, reverse: !!col.sortAsc }, + ); + const sortedEmails = useMemo(() => { + const list = emailsData.emails ?? []; + const col = sort && MAILBOX_COLUMNS.find((c) => c.id === sort.by); + if (!sort || !col?.sortValue) return list; + const value = col.sortValue; + const dir = sort.reverse ? 1 : -1; + return [...list].sort((a, b) => { + const x = value(a, statusById.get(a.id)); + const y = value(b, statusById.get(b.id)); + if (x === y) return 0; + return (x > y ? 1 : -1) * dir; + }); + }, [emailsData.emails, sort, statusById]); + if (!canView) { return ; } @@ -415,10 +447,12 @@ export default function AddressesPage() { /> ) : null ) : ( - + // table-fixed like the Leads list: every column but Mailbox + // carries a width, so one long address never widens the table. +
- - - - - - + {MAILBOX_COLUMNS.map((col) => ( + + ))} + - {emailsData.emails.map((box) => ( + {sortedEmails.map((box) => ( !!t), [box.tags, tags], ); - const shownTags = rowTags.slice(0, 3); + const source = mailboxSource(box); const off = !box.warmup; const paused = !!box.warmup && !!box.warmup_paused_at; @@ -738,104 +766,144 @@ function MailboxRow({ return ( onOpen(box.id)} - className="border-b border-slate-200/60 hover:bg-slate-50/80 transition-colors group h-11 cursor-pointer" + className={`border-b border-slate-200/60 transition-colors group h-11 cursor-pointer ${checked ? "bg-sky-50/60" : "hover:bg-slate-50/80"}`} > - - - + + - + - ; + const active = sort?.by === col.id; + const Dir = sort?.reverse ? ArrowUpIcon : ArrowDownIcon; + return ( + + ); +} + +// What the mailbox is doing right now. Cold sending and warmup run side by +// side, so both show when both are on; a problem that stops it wins. +function MailboxStatusPill({ box, status, warming }: { box: Inbox; status?: AccountStatus; warming: boolean }) { + const error = status?.errors?.[0]; + const lifecycle = status?.send_lifecycle; + const inCampaign = !!status?.in_campaign; + const resting = inCampaign && !!lifecycle && lifecycle.state !== "active"; + const sending = inCampaign && !resting; + + let problem: { label: string; text: string; Icon: LucideIcon; title: string } | null = null; + if (box.status === "revoked") problem = { label: "Reconnect", text: "text-rose-600", Icon: UnplugIcon, title: "Access was revoked at the provider. Reconnect the mailbox to send and warm again." }; + else if (box.status !== "active") problem = { label: "Off", text: "text-slate-500", Icon: CircleSlashIcon, title: "Switched off: it neither sends, warms nor syncs." }; + else if (error) problem = { label: "Error", text: "text-rose-600", Icon: AlertTriangleIcon, title: error.action_required ? `${error.title}. ${error.action_required}` : error.title }; + if (problem) { + const { Icon } = problem; + return ( + + + {problem.label} + {problem.label} + + ); + } + + // One word; the icons beside it say which activities are on. + const label = sending && warming ? "Active" : sending ? "Sending" : resting ? (lifecycle?.state === "reserve" ? "Reserve" : "Resting") : warming ? "Warming" : "Idle"; + const title = [ + sending ? "Sending campaign emails" : resting ? `Held out of campaign sending${lifecycle?.reason ? `: ${lifecycle.reason}` : ""}` : "Not in a live campaign", + warming + ? "warming up" + : inCampaign + ? "a low-volume health-check warmup keeps running" + : box.warmup && box.warmup_paused_at + ? "warmup paused" + : "warmup off", + ].join(", "); + const text = sending ? "text-sky-700" : resting ? "text-violet-600" : warming ? "text-orange-600" : "text-slate-400"; + return ( + + + {sending && } + {resting && } + {warming && } + {!sending && !resting && !warming && } + + {label} + {label} + + ); +} diff --git a/web/src/components/app/placement/MailboxPlacementTab.tsx b/web/src/components/app/placement/MailboxPlacementTab.tsx index 5376e49b2..2171860ad 100644 --- a/web/src/components/app/placement/MailboxPlacementTab.tsx +++ b/web/src/components/app/placement/MailboxPlacementTab.tsx @@ -180,12 +180,11 @@ 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. It is taken at Google, Microsoft and Yahoo, and over every host instead when those three have fewer than {rate.min_sample} deliveries and every host together has that many. 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 is taken at Google, Microsoft and Yahoo only, the providers that filter on sender reputation. 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 mailbox's standing is always judged at Google, Microsoft and Yahoo, the providers that filter on sender reputation, even when - the inbox rate above is taken over every host. 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 + The mailbox's standing is judged at the same three providers. Other mail hosts run their own filters, so what lands in their + spam folders is shown in the breakdown and never counted in the rate or 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/PlacementCharts.tsx b/web/src/components/app/placement/PlacementCharts.tsx index 31ee75c1d..11f998687 100644 --- a/web/src/components/app/placement/PlacementCharts.tsx +++ b/web/src/components/app/placement/PlacementCharts.tsx @@ -33,7 +33,7 @@ import { export function PlacementRateBadge({ rate, className }: { rate?: PlacementRate | null; className?: string }) { if (!rate || rate.band === "none") { return ( - + — ); diff --git a/web/src/components/app/placement/placement.ts b/web/src/components/app/placement/placement.ts index 337f17d34..3084848de 100644 --- a/web/src/components/app/placement/placement.ts +++ b/web/src/components/app/placement/placement.ts @@ -158,21 +158,24 @@ export function totals(days: DayView[]) { }; } -/** The other mail hosts left out of a major-provider rate, or null with none. */ +/** The other mail hosts left out of the major-provider rate, or null with none. */ export function otherHostsNote(rate: PlacementRate, short = false): string | null { - if (rate.scope !== "major" || !rate.other_delivered || rate.other_inbox_rate == null) return null; + if (!rate.other_delivered || rate.other_inbox_rate == null) return null; if (short) return `Google, Microsoft, Yahoo · other hosts ${fmtPct(rate.other_inbox_rate)}`; - return `Other mail hosts: ${fmtPct(rate.other_inbox_rate)} inbox of ${fmtNum(rate.other_delivered)}, shown but not counted toward standing`; + return `Other mail hosts: ${fmtPct(rate.other_inbox_rate)} inbox of ${fmtNum(rate.other_delivered)}, shown but not counted`; } /** One line explaining a headline rate, for tooltips and captions. */ export function rateSentence(rate: PlacementRate): string { const ok = rate.inbox + rate.tabs; - if (rate.delivered === 0) return `No warmup deliveries in the last ${rate.window_days} days.`; - if (rate.inbox_rate == null) { - const where = rate.scope === "major" ? " at Google, Microsoft and Yahoo" : ""; - return `${rate.delivered} of the ${rate.min_sample} deliveries${where} needed before a rate is shown (last ${rate.window_days} days).`; + if (rate.delivered === 0) { + if (rate.other_delivered) { + return `No warmup mail reached Google, Microsoft or Yahoo in the last ${rate.window_days} days, so there is no rate yet. Other mail hosts run their own filters and are not counted.`; + } + return `No warmup deliveries in the last ${rate.window_days} days.`; } - const where = rate.scope === "major" ? " at Google, Microsoft and Yahoo" : ""; - return `${fmtNum(ok)} of ${fmtNum(rate.delivered)} warmup emails${where} reached the inbox over the last ${rate.window_days} days.`; + if (rate.inbox_rate == null) { + return `${rate.delivered} of the ${rate.min_sample} deliveries at Google, Microsoft and Yahoo needed before a rate is shown (last ${rate.window_days} days).`; + } + return `${fmtNum(ok)} of ${fmtNum(rate.delivered)} warmup emails at Google, Microsoft and Yahoo reached the inbox over the last ${rate.window_days} days.`; } diff --git a/web/src/lib/api/models/app/analytics/WarmupPlacement.ts b/web/src/lib/api/models/app/analytics/WarmupPlacement.ts index 31aa03b61..5eb5766fd 100644 --- a/web/src/lib/api/models/app/analytics/WarmupPlacement.ts +++ b/web/src/lib/api/models/app/analytics/WarmupPlacement.ts @@ -27,17 +27,15 @@ export interface PlacementCounts { export interface PlacementRate { window_days: number; min_sample: number; - // "major": Google, Microsoft and Yahoo recipients only. "all": every host, - // when those three have fewer than min_sample deliveries in the window - // (including none) and every host together has at least that many. - scope?: "major" | "all"; + // Always "major": Google, Microsoft and Yahoo recipients only. + scope?: "major"; delivered: number; inbox: number; tabs: number; spam: number; inbox_rate: number | null; band: PlacementBand; - // Other mail hosts beside a "major" rate: shown, never judged. + // Other mail hosts beside the rate: shown, never counted. other_delivered?: number; other_inbox_rate?: number | null; }
+ { @@ -437,20 +471,14 @@ export default function AddressesPage() { }} /> AccountWarmup - Inbox - Health
- e.stopPropagation()} - /> + e.stopPropagation()}> + + {/* The flag is a sibling of the open-row button, not a child: it has its own trigger and nesting buttons is invalid. */}
- - {retiring && } - + + {retiring && } +
+ + + + {status?.daily_usage ? ( + 0 ? "text-sky-700" : "text-slate-500"}`} + title={`${status.daily_usage.campaign_sent} of ${status.daily_usage.campaign_limit || box.campaign_limit} campaign emails sent today`} + > + + + + /{status.daily_usage.campaign_limit || box.campaign_limit} + + + ) : ( + + )} + {inCloud ? ( - + {warmupLabel} ) : active ? ( - + - / - {ws?.target_volume ?? box.warmup_base} + + /{ws?.target_volume ?? box.warmup_base} ) : ( - warmupLabel + + {paused ? ( + + ) : inCampaign ? ( + + ) : ( + + )} + {warmupLabel} + )} - - + + + e.stopPropagation()}>
@@ -958,3 +1026,160 @@ function warmupErrorMessage(e: AppError | null | undefined): string { if (e?.code && e.message) return e.message; return "Couldn't update warmup"; } + +/* ── columns ─────────────────────────────────────── */ + +type MailboxColumnId = "mailbox" | "status" | "sent" | "warmup" | "inbox" | "health"; + +interface MailboxColumn { + id: MailboxColumnId; + label: string; + header?: React.ReactNode; + // Width and breakpoint, shared by the header and the row's cell. + className: string; + align?: "right"; + // Whether the first click sorts ascending (text) rather than descending. + sortAsc?: boolean; + sortValue?: (box: Inbox, status?: AccountStatus) => string | number; +} + +interface MailboxSort { + by: MailboxColumnId; + reverse: boolean; +} + +const MAILBOX_COLUMNS: MailboxColumn[] = [ + { id: "mailbox", label: "Mailbox", className: "", sortAsc: true, sortValue: (b) => b.email.toLowerCase() }, + { + id: "status", + label: "Status", + header: ( + <> + Status + Status + + ), + className: "w-14 sm:w-32", + // Problems first, then idle, warming, sending, sending and warming. + sortValue: (b, s) => + b.status !== "active" || s?.errors?.length + ? -1 + : (s?.in_campaign ? 2 : 0) + (b.warmup && !b.warmup_paused_at ? 1 : 0), + }, + { + id: "sent", + label: "Sent today", + header: , + className: "w-28 hidden md:table-cell", + sortValue: (_, s) => s?.daily_usage?.campaign_sent ?? -1, + }, + { + id: "warmup", + label: "Warmup", + header: , + className: "w-28", + sortValue: (b, s) => (b.warmup && !b.warmup_paused_at ? (s?.warmup_status?.current_volume ?? 0) : -1), + }, + { + id: "inbox", + label: "Inbox", + header: ( + + ), + className: "w-20 sm:w-24", + sortValue: (_, s) => s?.warmup_placement?.inbox_rate ?? -1, + }, + { + id: "health", + label: "Health", + header: ( + <> + Health + + + + + ), + className: "w-10 md:w-32", + sortValue: (_, s) => s?.health?.score ?? -1, + }, +]; + +// A header cell; a sortable one is the sort control, like the Leads list. +function MailboxTh({ col, sort, onSort }: { col: MailboxColumn; sort: MailboxSort | null; onSort: (col: MailboxColumn) => void }) { + const base = `px-3 py-2 text-[10px] font-medium text-slate-400 uppercase tracking-[0.14em] truncate ${col.className} ${col.align === "right" ? "text-right" : ""}`; + const content = col.header ?? col.label; + if (!col.sortValue) return
{content} + +