feat: take the warmup inbox rate and its daily rolling line at Google, Microsoft and Yahoo only (small hosts ride beside it and never make the headline), and rebuild the Accounts mailbox list in the Leads style with host logos, sender and connection line, a status naming whether the mailbox is sending, warming or both, sortable Sent today, animated Warmup, Inbox and pulsing Health columns, with docs and OpenAPI updated

This commit is contained in:
Matthew Meszaros
2026-09-27 20:40:45 -07:00
parent f45c4552eb
commit 25075fcdd8
14 changed files with 376 additions and 167 deletions
+1 -1
View File
@@ -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:
@@ -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.
+2 -2
View File
@@ -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.
+1 -1
View File
@@ -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.
+4 -5
View File
@@ -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."
}
}
},
+11 -11
View File
@@ -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 {
@@ -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()
+12 -23
View File
@@ -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.
+7 -13
View File
@@ -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)
}
}
+309 -84
View File
@@ -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<string, number> = { 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<MailboxSort | null>(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 <NoAccess feature="email accounts" permissionLabel="Manage mailboxes" />;
}
@@ -415,10 +447,12 @@ export default function AddressesPage() {
/>
) : null
) : (
<table className="w-full text-left">
// table-fixed like the Leads list: every column but Mailbox
// carries a width, so one long address never widens the table.
<table className="w-full table-fixed text-left">
<thead className="sticky top-0 bg-white z-[1]">
<tr className="border-b border-slate-200">
<th className="pl-5 pr-2 py-2 w-9">
<th className="pl-5 pr-2 py-2 w-11">
<Checkbox
checked={isSelectedAll()}
onChange={() => {
@@ -437,20 +471,14 @@ export default function AddressesPage() {
}}
/>
</th>
<th className="px-3 py-2 text-[10px] font-medium text-slate-400 uppercase tracking-[0.14em]">Account</th>
<th className="px-3 py-2 text-[10px] font-medium text-slate-400 uppercase tracking-[0.14em] w-24 text-right">Warmup</th>
<th
className="px-3 py-2 text-[10px] font-medium text-slate-400 uppercase tracking-[0.14em] w-16 md:w-20 text-right"
title="Share of warmup mail that reached the inbox over the last 7 days, measured in partners' mailboxes"
>
Inbox
</th>
<th className="px-3 py-2 text-[10px] font-medium text-slate-400 uppercase tracking-[0.14em] w-10 md:w-32"><span className="hidden md:inline">Health</span></th>
<th className="px-3 py-2 w-16"></th>
{MAILBOX_COLUMNS.map((col) => (
<MailboxTh key={col.id} col={col} sort={sort} onSort={sortBy} />
))}
<th className="px-3 py-2 w-[76px]"></th>
</tr>
</thead>
<tbody>
{emailsData.emails.map((box) => (
{sortedEmails.map((box) => (
<MailboxRow
key={box.id}
box={box}
@@ -663,7 +691,7 @@ function MailboxRow({
.filter((t): t is Tag => !!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 (
<tr
onClick={() => 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"}`}
>
<td className="pl-5 pr-2">
<Checkbox
checked={checked}
onChange={onToggleSelect}
onClick={(e) => e.stopPropagation()}
/>
<td className="pl-5 pr-2" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={checked} onChange={onToggleSelect} />
</td>
<td className="px-3 max-w-0 md:max-w-none">
<td className="px-3 overflow-hidden">
{/* The flag is a sibling of the open-row button, not a child:
it has its own trigger and nesting buttons is invalid. */}
<div className="flex w-full min-w-0 items-center gap-2">
<button type="button" onClick={(e) => { e.stopPropagation(); onOpen(box.id); }} className="flex min-w-0 flex-1 items-center gap-2.5 text-left">
<div className="w-6 h-6 rounded-full bg-sky-100 flex items-center justify-center shrink-0">
<span className="text-[9.5px] font-semibold text-sky-700">
{box.email.slice(0, 2).toUpperCase()}
</span>
</div>
<span className="text-[12.5px] font-medium text-slate-900 truncate">{box.email}</span>
<MailboxSourceChip box={box} labelClassName={mailboxSource(box).kind === "host" ? "hidden lg:inline" : "hidden md:inline"} />
{inCloud && (
<span
title={cloud?.managed ? "Signed in through Warmbly Cloud, which warms it" : cloudPaused ? "Paused in Warmbly Cloud" : "Warmed by Warmbly Cloud"}
className={`inline-flex items-center gap-1 h-4 px-1.5 rounded-full text-[9.5px] font-medium uppercase tracking-[0.08em] shrink-0 ${cloudPaused ? "bg-amber-50 text-amber-600" : "bg-sky-600 text-white"}`}
>
<CloudIcon className="w-2.5 h-2.5" /> Cloud
</span>
)}
{inCampaign && (
<span className="hidden sm:inline-flex items-center gap-1 h-4 px-1.5 rounded-full bg-sky-50 text-sky-600 text-[9.5px] font-medium uppercase tracking-[0.08em]">
<ActivityIcon className="w-2.5 h-2.5" /> In campaign
</span>
)}
{shownTags.map((t) => (
<span
key={t.id}
className="hidden md:inline-flex items-center gap-1 h-4 px-1.5 rounded-full text-[9.5px] font-medium shrink-0"
style={{ backgroundColor: `${t.color}1a`, color: t.color }}
>
<span className="size-1.5 rounded-full" style={{ backgroundColor: t.color }} />
{t.title}
</span>
))}
{rowTags.length > shownTags.length && (
<span className="hidden md:inline-flex items-center h-4 px-1 rounded-full bg-slate-100 text-slate-500 text-[9.5px] font-medium shrink-0">
+{rowTags.length - shownTags.length}
</span>
)}
</button>
{retiring && <SigninRetiringChip onClick={onRetiring} />}
<AdvisorRowFlag findings={findings} subject={box.email} />
<button type="button" onClick={(e) => { e.stopPropagation(); onOpen(box.id); }} className="flex min-w-0 flex-1 items-center gap-2.5 text-left">
<ProviderLogo id={source.logo} size="md" title={source.title} className="shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-[12.5px] font-medium text-slate-900 leading-tight flex items-center gap-1.5 min-w-0">
<span className="truncate" {...clippedTitle}>{box.email}</span>
{inCloud && (
<span
title={cloud?.managed ? "Signed in through Warmbly Cloud, which warms it" : cloudPaused ? "Paused in Warmbly Cloud" : "Warmed by Warmbly Cloud"}
className={`inline-flex items-center gap-1 h-4 px-1.5 rounded text-[10px] font-medium shrink-0 ${cloudPaused ? "bg-amber-50 text-amber-600" : "bg-sky-50 text-sky-700"}`}
>
<CloudIcon className="w-2.5 h-2.5" /> Cloud
</span>
)}
{rowTags.length > 0 && (
<span className="hidden md:inline-flex items-center gap-0.5 min-w-0 max-w-[40%]">
<span
className="inline-flex items-center gap-1 h-4 px-1.5 rounded text-[10px] font-medium min-w-0"
style={{ backgroundColor: `${rowTags[0].color}1a`, color: rowTags[0].color }}
>
<span className="size-1.5 rounded-full shrink-0" style={{ backgroundColor: rowTags[0].color }} />
<span className="truncate">{rowTags[0].title}</span>
</span>
{rowTags.length > 1 && (
<span
className="inline-flex items-center h-4 px-1 shrink-0 rounded text-[10px] font-medium bg-slate-100 text-slate-500"
title={rowTags.slice(1).map((t) => t.title).join(", ")}
>
+{rowTags.length - 1}
</span>
)}
</span>
)}
</div>
<div className="text-[10.5px] text-slate-400 leading-tight flex items-center gap-1 min-w-0 mt-0.5" title={source.title}>
{box.name && (
<>
<UserIcon className="w-2.5 h-2.5 shrink-0" />
<span className="truncate max-w-[50%]">{box.name}</span>
{source.label && <span className="text-slate-300">·</span>}
</>
)}
{source.label && <span className="truncate">{source.label}</span>}
{inCampaign && (
<span className="hidden sm:inline-flex items-center gap-1 shrink-0 text-sky-600">
<span className="text-slate-300">·</span>
<ActivityIcon className="w-2.5 h-2.5" /> In campaign
</span>
)}
</div>
</div>
</button>
{retiring && <SigninRetiringChip onClick={onRetiring} />}
<AdvisorRowFlag findings={findings} subject={box.email} />
</div>
</td>
<td className={`px-3 text-[12px] tabular-nums text-right font-mono ${warmupTone}`}>
<td className="px-3 overflow-hidden">
<MailboxStatusPill box={box} status={status} warming={inCloud ? !cloudPaused : active} />
</td>
<td className="px-3 overflow-hidden hidden md:table-cell">
{status?.daily_usage ? (
<span
className={`inline-flex items-center gap-1.5 font-mono text-[12px] tabular-nums ${status.daily_usage.campaign_sent > 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`}
>
<SendIcon className="w-3 h-3 shrink-0 text-sky-500" />
<span>
<AnimatedNumber value={status.daily_usage.campaign_sent} />
<span className="text-slate-400">/{status.daily_usage.campaign_limit || box.campaign_limit}</span>
</span>
</span>
) : (
<Dash />
)}
</td>
<td className={`px-3 overflow-hidden font-mono text-[12px] tabular-nums ${warmupTone}`}>
{inCloud ? (
<span className="inline-flex items-center justify-end gap-1.5">
<span className="inline-flex items-center gap-1.5" title="Warmed by Warmbly Cloud">
<CloudIcon className="w-3 h-3 shrink-0" />
<span>{warmupLabel}</span>
</span>
) : active ? (
<span className="inline-flex items-center justify-end gap-1.5">
<span className="inline-flex items-center gap-1.5" title={`${ws?.current_volume ?? 0} of ${ws?.target_volume ?? box.warmup_base} warmup emails sent today`}>
<span className="campaign-grid shrink-0" aria-hidden />
<span>
<AnimatedNumber value={ws?.current_volume ?? 0} />/
{ws?.target_volume ?? box.warmup_base}
<AnimatedNumber value={ws?.current_volume ?? 0} />
<span className="opacity-60">/{ws?.target_volume ?? box.warmup_base}</span>
</span>
</span>
) : (
warmupLabel
<span className="inline-flex items-center gap-1.5 font-sans text-[11.5px] font-medium">
{paused ? (
<PauseIcon className="w-3 h-3 shrink-0" />
) : inCampaign ? (
<ActivityIcon className="w-3 h-3 shrink-0" />
) : (
<RiFireLine className="w-3 h-3 shrink-0" />
)}
{warmupLabel}
</span>
)}
</td>
<td className="px-3 text-right">
<button
type="button"
onClick={(e) => { e.stopPropagation(); onOpen(box.id, "deliverability"); }}
aria-label="View warmup deliverability"
>
<PlacementRateBadge rate={status?.warmup_placement} />
</button>
</td>
<td className="px-3">
<button
type="button"
onClick={(e) => { e.stopPropagation(); onOpen(box.id, "overview"); }}
className={`inline-flex items-center gap-1.5 text-[11px] font-medium ${tone.text}`}
title="View mailbox health"
onClick={(e) => { e.stopPropagation(); onOpen(box.id, "deliverability"); }}
aria-label="View warmup deliverability"
className="inline-flex items-center gap-1.5"
>
<span className="relative flex w-1.5 h-1.5">
<MailCheckIcon className="w-3 h-3 shrink-0 text-slate-400 hidden sm:block" />
<PlacementRateBadge rate={status?.warmup_placement} />
</button>
</td>
<td className="px-3 overflow-hidden">
<button
type="button"
onClick={(e) => { e.stopPropagation(); onOpen(box.id, "overview"); }}
className={`inline-flex items-center gap-1.5 text-[11px] font-medium max-w-full ${tone.text}`}
title={status?.health?.issues?.join("\n") || "View mailbox health"}
>
<span className="relative flex w-1.5 h-1.5 shrink-0">
{tone.pulse && (
<span className={`absolute inline-flex h-full w-full rounded-full opacity-60 animate-ping ${tone.dot}`} />
)}
<span className={`relative inline-flex w-1.5 h-1.5 rounded-full ${tone.dot}`} />
</span>
<span className="uppercase tracking-[0.08em] hidden md:inline">{tone.label}</span>
<span className="uppercase tracking-[0.08em] hidden md:inline truncate">{tone.label}</span>
</button>
</td>
<td className="px-3">
<td className="px-3" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-0.5 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity">
<PopoverMenu align="end">
<PopoverMenuTrigger asChild>
@@ -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: (
<>
<span className="sr-only">Status</span>
<span aria-hidden className="hidden sm:inline">Status</span>
</>
),
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: <InfoHeader label="Sent today" title="Campaign emails sent from this mailbox today, against its daily cap." aria="How sends are counted" />,
className: "w-28 hidden md:table-cell",
sortValue: (_, s) => s?.daily_usage?.campaign_sent ?? -1,
},
{
id: "warmup",
label: "Warmup",
header: <InfoHeader label="Warmup" title="Warmup emails sent today, against today's ramp target." aria="How warmup is counted" />,
className: "w-28",
sortValue: (b, s) => (b.warmup && !b.warmup_paused_at ? (s?.warmup_status?.current_volume ?? 0) : -1),
},
{
id: "inbox",
label: "Inbox",
header: (
<InfoHeader
label="Inbox"
title="Share of warmup mail that reached the inbox at Google, Microsoft and Yahoo over the last 7 days. Smaller mail hosts run their own filters and are not counted."
aria="How the inbox rate is measured"
/>
),
className: "w-20 sm:w-24",
sortValue: (_, s) => s?.warmup_placement?.inbox_rate ?? -1,
},
{
id: "health",
label: "Health",
header: (
<>
<span className="sr-only">Health</span>
<span aria-hidden className="hidden md:inline">
<InfoHeader label="Health" title="The mailbox's overall health score out of 100: connection, errors, bounces and warmup standing. Click it for the details." aria="How health is scored" />
</span>
</>
),
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 <th className={base}>{content}</th>;
const active = sort?.by === col.id;
const Dir = sort?.reverse ? ArrowUpIcon : ArrowDownIcon;
return (
<th className={base} aria-sort={active ? (sort?.reverse ? "ascending" : "descending") : "none"}>
<button
type="button"
onClick={() => onSort(col)}
title={`Sort by ${col.label}`}
className={`group/th inline-flex items-center gap-1 max-w-full uppercase tracking-[0.14em] hover:text-slate-700 transition-colors ${
active ? "text-slate-700" : ""
} ${col.align === "right" ? "flex-row-reverse" : ""}`}
>
<span className="truncate">{content}</span>
<Dir className={`w-3 h-3 shrink-0 ${active ? "" : "opacity-0 group-hover/th:opacity-60"}`} aria-hidden />
</button>
</th>
);
}
// 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 (
<span className={`inline-flex items-center gap-1.5 max-w-full text-[10.5px] font-medium uppercase tracking-[0.08em] ${problem.text}`} title={problem.title}>
<Icon className="w-3 h-3 shrink-0" />
<span className="sr-only">{problem.label}</span>
<span aria-hidden className="hidden sm:inline truncate">{problem.label}</span>
</span>
);
}
// 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 (
<span className={`inline-flex items-center gap-1.5 max-w-full text-[10.5px] font-medium uppercase tracking-[0.08em] ${text}`} title={title}>
<span className="inline-flex items-center gap-0.5 shrink-0">
{sending && <SendIcon className="w-3 h-3 text-sky-600" />}
{resting && <MoonIcon className="w-3 h-3 text-violet-500" />}
{warming && <FlameIcon className="w-3 h-3 text-orange-500" />}
{!sending && !resting && !warming && <CircleSlashIcon className="w-3 h-3" />}
</span>
<span className="sr-only">{label}</span>
<span aria-hidden className="hidden sm:inline truncate">{label}</span>
</span>
);
}
@@ -180,12 +180,11 @@ export default function MailboxPlacementTab({ mailboxId, poolHealth }: { mailbox
<Eyebrow>How this is measured</Eyebrow>
<ul className="mt-2 space-y-1.5 text-[11.5px] text-slate-500 leading-relaxed">
<li>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.</li>
<li>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.</li>
<li>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.</li>
<li>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.</li>
<li>
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.
</li>
<li>Days are UTC. This covers warmup mail only, not campaign sends.</li>
@@ -33,7 +33,7 @@ import {
export function PlacementRateBadge({ rate, className }: { rate?: PlacementRate | null; className?: string }) {
if (!rate || rate.band === "none") {
return (
<span className={cn("font-mono text-[11px] text-slate-300", className)} title="No warmup deliveries in the last 7 days">
<span className={cn("font-mono text-[11px] text-slate-300", className)} title={rate ? rateSentence(rate) : "No warmup deliveries in the last 7 days"}>
—
</span>
);
+12 -9
View File
@@ -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.`;
}
@@ -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;
}