feat: persist workspace-scoped mailbox domain analytics deliverability placement and import browsing controls with strict schemas and resource navigation restoration regressions

This commit is contained in:
Matthew Meszaros
2026-10-08 07:37:36 +00:00
parent f3f4e8cafd
commit efb092fbfe
25 changed files with 609 additions and 59 deletions
@@ -0,0 +1,98 @@
import { act, renderHook } from "@testing-library/react";
import type { ComponentProps, ReactNode } from "react";
import { beforeEach, describe, expect, it } from "vitest";
import type { z } from "zod";
import useBrowseState from "@/hooks/useBrowseState";
import { UserContext } from "@/hooks/context/user";
import { useAppStore } from "@/stores/useAppStore";
import { BROWSE_STORAGE_PREFIX } from "@/lib/browseState";
import * as schemas from "@/lib/browse-accounts-analytics";
function Owner({ children }: { children: ReactNode }) {
const profile = { user: { id: "account-user" } } as ComponentProps<typeof UserContext.Provider>["value"];
return <UserContext.Provider value={profile}>{children}</UserContext.Provider>;
}
const cases: { name: string; schema: z.ZodType; initial: unknown; value: unknown }[] = [
{ name: "analytics.range", schema: schemas.analyticsRange, initial: "7d", value: "90d" },
{ name: "analytics.hiddenMetrics", schema: schemas.analyticsHiddenMetrics, initial: [], value: ["sent", "replies"] },
{ name: "deliverability.view", schema: schemas.deliverabilityView, initial: { range: "7d", hidden: [] }, value: { range: "30d", hidden: ["providers", "mailboxes"] } },
{ name: "deliverability.hiddenMetrics", schema: schemas.deliverabilityHiddenMetrics, initial: ["sent"], value: ["opens", "replies"] },
{ name: "emails.search", schema: schemas.browseString, initial: "", value: "alice" },
{ name: "emails.sort", schema: schemas.mailboxSort, initial: null, value: { by: "health", reverse: true } },
{ name: "emails.mailbox.a.tab", schema: schemas.mailboxTab, initial: "overview", value: "analytics" },
{ name: "emails.domains.filter", schema: schemas.domainFilter, initial: "all", value: "vendor" },
{ name: "emails.domain.a.test.tab", schema: schemas.domainTab, initial: "overview", value: "tracking" },
{ name: "emails.import.a.results.filter", schema: schemas.importFilter, initial: { status: "", cause: "" }, value: { status: "failed", cause: "password" } },
{ name: "emails.import.vendor.a.pick.filter", schema: schemas.importPickFilter, initial: "all", value: "moving" },
{ name: "emails.domains.bulkSetup.list.limit", schema: schemas.domainListLimit, initial: 20, value: 70 },
{ name: "emails.mailbox.a.placement.range", schema: schemas.placementRange, initial: "30d", value: "14d" },
{ name: "emails.mailbox.a.placement.group", schema: schemas.placementGroupFilter, initial: "all", value: "yahoo" },
{ name: "deliverability.placement.providerExpanded", schema: schemas.placementGroup.nullable(), initial: null, value: "google" },
{ name: "deliverability.placement.showAll", schema: schemas.browseBoolean, initial: false, value: true },
{ name: "placement.batch.a.tab", schema: schemas.batchTab, initial: "mailboxes", value: "recipients" },
{ name: "placement.batch.a.senders.sort", schema: schemas.batchSenderSort, initial: "worst", value: "status" },
{ name: "placement.batch.a.senders.status", schema: schemas.batchSenderStatus, initial: "", value: "cancelled" },
];
describe("accounts and analytics browse schemas", () => {
beforeEach(() => {
sessionStorage.clear();
useAppStore.setState({ currentOrganization: { id: "workspace", name: "Workspace", role: "owner" } });
});
it.each(cases)("restores $name synchronously after change and remount", ({ name, schema, initial, value }) => {
const page = renderHook(() => useBrowseState(name, initial, schema), { wrapper: Owner });
act(() => page.result.current[1](value));
page.unmount();
const refreshed = renderHook(() => useBrowseState(name, initial, schema), { wrapper: Owner });
expect(refreshed.result.current[0]).toEqual(value);
act(() => refreshed.result.current[1](initial));
refreshed.unmount();
const cleared = renderHook(() => useBrowseState(name, initial, schema), { wrapper: Owner });
expect(cleared.result.current[0]).toEqual(initial);
});
it("isolates import history filters by resource without erasing a remembered cause", () => {
const page = renderHook(({ id }) => useBrowseState(`emails.import.${id}.results.filter`, { status: "", cause: "" }, schemas.importFilter), {
wrapper: Owner, initialProps: { id: "a" },
});
act(() => page.result.current[1]({ status: "failed", cause: "password" }));
page.rerender({ id: "b" });
expect(page.result.current[0]).toEqual({ status: "", cause: "" });
act(() => page.result.current[1]({ status: "needs_signin", cause: "" }));
page.rerender({ id: "a" });
expect(page.result.current[0]).toEqual({ status: "failed", cause: "password" });
});
it("lets initial navigation override a remembered resource tab and remembers explicit clearing", () => {
const key = `${BROWSE_STORAGE_PREFIX}account-user:workspace:emails.mailbox.a.tab`;
sessionStorage.setItem(key, JSON.stringify({ value: "settings" }));
const page = renderHook(() => useBrowseState("emails.mailbox.a.tab", "overview", schemas.mailboxTab, { initialOverride: "analytics" }), { wrapper: Owner });
expect(page.result.current[0]).toBe("analytics");
act(() => page.result.current[1]("overview"));
page.unmount();
const refreshed = renderHook(() => useBrowseState("emails.mailbox.a.tab", "overview", schemas.mailboxTab), { wrapper: Owner });
expect(refreshed.result.current[0]).toBe("overview");
});
it("rejects stale enums, malformed structures, duplicate metrics and hidden-all metrics", () => {
expect(schemas.mailboxSort.safeParse({ by: "health", reverse: false, obsolete: true }).success).toBe(false);
expect(schemas.mailboxSort.safeParse({ by: "old", reverse: false }).success).toBe(false);
expect(schemas.mailboxSort.safeParse({ by: "health", reverse: "yes" }).success).toBe(false);
expect(schemas.mailboxTab.safeParse("old").success).toBe(false);
expect(schemas.domainTab.safeParse("old").success).toBe(false);
expect(schemas.batchSenderStatus.safeParse("needs_signin").success).toBe(false);
expect(schemas.analyticsHiddenMetrics.safeParse(["sent", "sent"]).success).toBe(false);
expect(schemas.analyticsHiddenMetrics.safeParse(["sent", "opens", "clicks", "replies"]).success).toBe(false);
expect(schemas.deliverabilityHiddenMetrics.safeParse(["sent", "opens", "replies", "bounces", "complaints"]).success).toBe(false);
expect(schemas.deliverabilityView.safeParse({ range: "30d", hidden: ["old"] }).success).toBe(false);
expect(schemas.deliverabilityView.safeParse({ range: "30d", hidden: ["chart", "chart"] }).success).toBe(false);
expect(schemas.importFilter.safeParse({ status: "connected", cause: "password" }).success).toBe(false);
expect(schemas.importFilter.safeParse({ status: "failed", cause: "password", cursor: "a" }).success).toBe(false);
});
it.each([0, -1, 1.5, Infinity, NaN, null, "20"])("rejects invalid display limits: %s", (value) => {
expect(schemas.domainListLimit.safeParse(value).success).toBe(false);
});
});
+5 -3
View File
@@ -1,6 +1,8 @@
import { NoAccess } from "@/components/layout/NoAccess";
import { usePermission } from "@/hooks/usePermission";
import { useMemo, useState } from "react";
import { useMemo } from "react";
import useBrowseState from "@/hooks/useBrowseState";
import { analyticsRange, analyticsHiddenMetrics } from "@/lib/browse-accounts-analytics";
import { Link } from "@tanstack/react-router";
import {
ActivityIcon,
@@ -60,9 +62,9 @@ function num(v: number | undefined): string {
export default function AnalyticsPage() {
const canView = usePermission("VIEW_ANALYTICS");
const [range, setRange] = useState<Range>("7d");
const [range, setRange] = useBrowseState<Range>("analytics.range", "7d", analyticsRange);
// Legend toggles: every metric charts together; hidden ones drop out.
const [hiddenMetrics, setHiddenMetrics] = useState<Metric[]>([]);
const [hiddenMetrics, setHiddenMetrics] = useBrowseState<Metric[]>("analytics.hiddenMetrics", [], analyticsHiddenMetrics);
const dash = useDashboard(range);
const d = dash.data;
const os = d?.overall_stats;
@@ -0,0 +1,45 @@
import { fireEvent, render, screen } from "@testing-library/react";
import type { ComponentProps, ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { UserContext } from "@/hooks/context/user";
import { useAppStore } from "@/stores/useAppStore";
import { BROWSE_STORAGE_PREFIX } from "@/lib/browseState";
import DeliverabilityPage from "./page";
vi.mock("@/lib/api/hooks/app/analytics/useDeliverability", () => ({ default: () => ({ data: undefined, isPending: true, refetch: vi.fn() }) }));
vi.mock("@/components/app/placement/WarmupPlacementSection", () => ({ default: () => null }));
vi.mock("@/components/app/advisor/AdvisorSummaryBar", () => ({ default: () => null }));
vi.mock("@tanstack/react-router", () => ({ Link: ({ children }: { children: ReactNode }) => <a>{children}</a> }));
function Page() {
const profile = { user: { id: "account-user" } } as ComponentProps<typeof UserContext.Provider>["value"];
return <UserContext.Provider value={profile}><DeliverabilityPage /></UserContext.Provider>;
}
const key = `${BROWSE_STORAGE_PREFIX}account-user:workspace:deliverability.view`;
describe("deliverability legacy browse preferences", () => {
beforeEach(() => {
vi.clearAllMocks();
sessionStorage.clear();
useAppStore.setState({ currentOrganization: { id: "workspace", name: "Workspace", role: "owner" } });
});
it("seeds compatible legacy preferences once, deduplicates sections, and never dual-writes them", () => {
vi.mocked(localStorage.getItem).mockReturnValue(JSON.stringify({ range: "30d", hidden: ["providers", "providers", "old"] }));
const page = render(<Page />);
expect(JSON.parse(sessionStorage.getItem(key)!).value).toEqual({ range: "30d", hidden: ["providers"] });
fireEvent.click(screen.getByRole("button", { name: "90d" }));
page.unmount();
render(<Page />);
expect(JSON.parse(sessionStorage.getItem(key)!).value).toEqual({ range: "90d", hidden: ["providers"] });
expect(localStorage.setItem).not.toHaveBeenCalledWith("warmbly:deliverability-view", expect.any(String));
});
it("retains a scoped preference in preference to a global legacy value", () => {
vi.mocked(localStorage.getItem).mockReturnValue(JSON.stringify({ range: "30d", hidden: ["chart"] }));
sessionStorage.setItem(key, JSON.stringify({ value: { range: "90d", hidden: ["totals"] } }));
render(<Page />);
expect(JSON.parse(sessionStorage.getItem(key)!).value).toEqual({ range: "90d", hidden: ["totals"] });
expect(localStorage.setItem).not.toHaveBeenCalledWith("warmbly:deliverability-view", expect.any(String));
});
});
+7 -10
View File
@@ -4,7 +4,9 @@
// is keyed under ["analytics", ...] which useRealtimeEvents already invalidates.
// The visible sections and the time window are user-customizable and persisted.
import { useMemo, useState } from "react";
import { useMemo } from "react";
import useBrowseState from "@/hooks/useBrowseState";
import { deliverabilityView, deliverabilityHiddenMetrics } from "@/lib/browse-accounts-analytics";
import { Link } from "@tanstack/react-router";
import { AlertTriangleIcon, ArrowUpRightIcon, CheckIcon, RefreshCcwIcon, SlidersHorizontalIcon } from "lucide-react";
import { EmptyBlock, Page, PageBody, PageTopbar, SectionBar, Stat, StatStrip } from "@/components/layout/Page";
@@ -88,7 +90,7 @@ function num(v: number | undefined): string {
return (v ?? 0).toLocaleString();
}
// View preferences (window + hidden sections), persisted per browser.
// Legacy browser preferences seed the scoped session preference once.
const VIEW_KEY = "warmbly:deliverability-view";
interface ViewPrefs {
@@ -103,7 +105,7 @@ function loadView(): ViewPrefs {
const v = JSON.parse(raw) as Partial<ViewPrefs>;
return {
range: v.range === "30d" || v.range === "90d" ? v.range : "7d",
hidden: Array.isArray(v.hidden) ? v.hidden.filter((k): k is SectionKey => SECTIONS.some((s) => s.key === k)) : [],
hidden: Array.isArray(v.hidden) ? [...new Set(v.hidden.filter((k): k is SectionKey => SECTIONS.some((s) => s.key === k)))] : [],
};
}
} catch {
@@ -121,10 +123,10 @@ function BandChip({ band }: { band: DeliverabilityBand }) {
}
export default function DeliverabilityPage() {
const [view, setView] = useState<ViewPrefs>(loadView);
const [view, setView] = useBrowseState<ViewPrefs>("deliverability.view", loadView, deliverabilityView);
// Legend toggles: every metric charts together; "sent" starts hidden so
// it doesn't dwarf the failure signals this page exists to surface.
const [hiddenMetrics, setHiddenMetrics] = useState<Metric[]>(["sent"]);
const [hiddenMetrics, setHiddenMetrics] = useBrowseState<Metric[]>("deliverability.hiddenMetrics", ["sent"], deliverabilityHiddenMetrics);
const toggleMetric = (k: Metric) =>
setHiddenMetrics((cur) => {
if (cur.includes(k)) return cur.filter((x) => x !== k);
@@ -137,11 +139,6 @@ export default function DeliverabilityPage() {
const updateView = (next: ViewPrefs) => {
setView(next);
try {
localStorage.setItem(VIEW_KEY, JSON.stringify(next));
} catch {
// Persisting is best-effort; the in-memory view still applies.
}
};
const show = (k: SectionKey) => !view.hidden.includes(k);
const toggleSection = (k: SectionKey) =>
@@ -0,0 +1,97 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { useCallback, useState, type ComponentProps, type ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { UserContext } from "@/hooks/context/user";
import { useAppStore } from "@/stores/useAppStore";
import { BROWSE_STORAGE_PREFIX } from "@/lib/browseState";
import type { SendingDomain } from "@/lib/api/models/app/emails/SendingDomain";
import type { DomainTab } from "@/components/app/emails/domains/SendingDomainDrawer";
import DomainsPage from "./page";
const navigation = vi.hoisted(() => ({ initial: "", consumed: "" }));
vi.mock("@/hooks/useSearchParams", () => ({
useSearchParams: () => {
const [params, setParams] = useState(() => new URLSearchParams(navigation.initial));
const set = useCallback((next: URLSearchParams | ((prev: URLSearchParams) => URLSearchParams)) => {
setParams((prev) => {
const result = typeof next === "function" ? next(prev) : next;
navigation.consumed = result.toString();
return result;
});
}, []);
return [params, set];
},
}));
const domains: SendingDomain[] = ["a.test", "b.test"].map((domain) => ({
domain, mailboxes: 1, mail_hosts: [], auth_state: "passing", auth_spf: true, auth_dkim: true, auth_dmarc: true, tracking_domains: [],
}));
vi.mock("@/lib/api/hooks/app/emails/useSendingDomains", () => ({
useSendingDomains: () => ({ data: { data: domains }, isLoading: false }),
}));
vi.mock("@tanstack/react-router", () => ({ Link: ({ children }: { children: ReactNode }) => <a>{children}</a> }));
vi.mock("@/components/app/emails/domains/RedirectRescue", () => ({ default: () => null }));
vi.mock("@/components/app/emails/domains/BulkSetupDialog", () => ({ default: () => null }));
vi.mock("@/components/app/emails/domains/DomainSelectionBar", () => ({ default: () => null }));
vi.mock("@/components/app/emails/domains/SendingDomainDrawer", () => ({
default: ({ open, tab, setTab, onClose }: { open: string; tab: DomainTab; setTab: (tab: DomainTab) => void; onClose: () => void }) => open ? (
<section aria-label="Domain drawer">
<output aria-label="Domain tab">{tab}</output>
<button onClick={() => setTab("tracking")}>Choose tracking</button>
<button onClick={() => setTab("overview")}>Choose overview</button>
<button onClick={onClose}>Close domain</button>
</section>
) : null,
}));
function Page() {
const profile = { user: { id: "account-user" } } as ComponentProps<typeof UserContext.Provider>["value"];
return <UserContext.Provider value={profile}><DomainsPage /></UserContext.Provider>;
}
describe("domain drawer browse persistence", () => {
beforeEach(() => {
sessionStorage.clear();
navigation.initial = "";
navigation.consumed = "";
useAppStore.setState({ currentOrganization: { id: "workspace", name: "Workspace", role: "owner" } });
});
it("remembers per-domain tabs without reopening drawers on page refresh", () => {
const page = render(<Page />);
fireEvent.click(screen.getByText("a.test"));
fireEvent.click(screen.getByRole("button", { name: "Choose tracking" }));
fireEvent.click(screen.getByRole("button", { name: "Close domain" }));
fireEvent.click(screen.getByText("b.test"));
expect(screen.getByLabelText("Domain tab")).toHaveTextContent("overview");
fireEvent.click(screen.getByRole("button", { name: "Close domain" }));
page.unmount();
render(<Page />);
expect(screen.queryByRole("region", { name: "Domain drawer" })).not.toBeInTheDocument();
fireEvent.click(screen.getByText("a.test"));
expect(screen.getByLabelText("Domain tab")).toHaveTextContent("tracking");
});
it("prioritizes and consumes a URL tab intent, then remembers the user's reset", () => {
const key = `${BROWSE_STORAGE_PREFIX}account-user:workspace:emails.domain.a.test.tab`;
sessionStorage.setItem(key, JSON.stringify({ value: "tracking" }));
navigation.initial = "domain=a.test&tab=redirect&keep=1";
const page = render(<Page />);
expect(screen.getByLabelText("Domain tab")).toHaveTextContent("redirect");
expect(navigation.consumed).toBe("keep=1");
fireEvent.click(screen.getByRole("button", { name: "Choose overview" }));
page.unmount();
navigation.initial = "";
render(<Page />);
fireEvent.click(screen.getByText("a.test"));
expect(screen.getByLabelText("Domain tab")).toHaveTextContent("overview");
});
it("does not replay a consumed URL tab intent on workspace switching", () => {
sessionStorage.setItem(`${BROWSE_STORAGE_PREFIX}account-user:other:emails.domain.a.test.tab`, JSON.stringify({ value: "tracking" }));
navigation.initial = "domain=a.test&tab=redirect";
render(<Page />);
expect(screen.getByLabelText("Domain tab")).toHaveTextContent("redirect");
act(() => useAppStore.setState({ currentOrganization: { id: "other", name: "Other", role: "owner" } }));
expect(screen.getByLabelText("Domain tab")).toHaveTextContent("tracking");
});
});
+20 -7
View File
@@ -2,6 +2,8 @@
// the tracking host its mailboxes use, whether its root redirects to the
// company website, and the inbox vendor that holds it. A row opens the drawer.
import React from "react";
import useBrowseState from "@/hooks/useBrowseState";
import { browseString, domainFilter, domainTab } from "@/lib/browse-accounts-analytics";
import { Link } from "@tanstack/react-router";
import { useSearchParams } from "@/hooks/useSearchParams";
import { AnimatePresence, motion } from "framer-motion";
@@ -69,10 +71,18 @@ export default function SendingDomainsPage() {
const canView = usePermission("MANAGE_EMAILS");
const profile = useUserProfile();
const list = useSendingDomains(canView);
const [query, setQuery] = React.useState("");
const [filter, setFilter] = React.useState<Filter>("all");
const [query, setQuery] = useBrowseState("emails.domains.search", "", browseString);
const [filter, setFilter] = useBrowseState<Filter>("emails.domains.filter", "all", domainFilter);
const [open, setOpen] = React.useState("");
const [tab, setTab] = React.useState<DomainTab>("overview");
const [tabIntent, setTabIntent] = React.useState<DomainTab | undefined>();
const [tab, setTab] = useBrowseState<DomainTab>(`emails.domain.${open}.tab`, "overview", domainTab,
tabIntent === undefined ? undefined : { initialOverride: tabIntent });
React.useEffect(() => {
if (tabIntent !== undefined) {
setTab(tabIntent);
setTabIntent(undefined);
}
}, [tabIntent, setTab]);
const [searchParams, setSearchParams] = useSearchParams();
const [selected, setSelected] = React.useState<Set<string>>(() => new Set());
const [bulk, setBulk] = React.useState<BulkSetupIntent | null>(null);
@@ -139,7 +149,9 @@ export default function SendingDomainsPage() {
const d = searchParams.get("domain");
if (!d) return;
const t = searchParams.get("tab");
setTab(t === "redirect" || t === "tracking" ? t : "overview");
const nextTab = t === "redirect" || t === "tracking" ? t : "overview";
setTabIntent(nextTab);
if (open === d.toLowerCase()) setTab(nextTab);
setOpen(d.toLowerCase());
setSearchParams(
(prev) => {
@@ -150,10 +162,11 @@ export default function SendingDomainsPage() {
},
{ replace: true },
);
}, [searchParams, setSearchParams]);
}, [searchParams, setSearchParams, open, setTab]);
const openDomain = (d: string, t: DomainTab = "overview") => {
setTab(t);
const openDomain = (d: string, t?: DomainTab) => {
setTabIntent(t);
if (open === d && t !== undefined) setTab(t);
setOpen(d);
};
const addMailboxes = () => profile.setAddEmail(true);
+12 -6
View File
@@ -1,5 +1,7 @@
import { RiFireLine, RiMoreLine } from "@remixicon/react";
import React, { useEffect, useMemo, useRef } from "react";
import useBrowseState from "@/hooks/useBrowseState";
import { browseString, mailboxSort, mailboxTab, type MailboxTab } from "@/lib/browse-accounts-analytics";
import { useSearchParams } from "@/hooks/useSearchParams";
import useMailboxTagFilter from "@/hooks/useMailboxTagFilter";
import groupMailboxes from "@/lib/groupMailboxes";
@@ -130,13 +132,15 @@ export default function AddressesPage() {
const confirm = useConfirm();
const canView = usePermission("MANAGE_EMAILS");
const [query, setQuery] = React.useState<string>("");
const [query, setQuery] = useBrowseState("emails.search", "", browseString);
const organizationID = useAppStore((state) => state.currentOrganization?.id ?? "personal");
const [tag, setTag] = useMailboxTagFilter(`${p.user.id}:${organizationID}`, p.user.tags);
const emailsData = useEmails({ query, tag });
const [selected, setSelected] = React.useState<string[]>([]);
const [view, setView] = React.useState<string>("");
const [viewTab, setViewTab] = React.useState<string>("overview");
const [viewTab, setViewTab] = React.useState<MailboxTab | undefined>();
const [tabIntentKey, setTabIntentKey] = React.useState(0);
const consumeTabIntent = React.useCallback(() => setViewTab(undefined), []);
const [removing, setRemoving] = React.useState(false);
const [bulkStart, setBulkStart] = React.useState(false);
const [searchParams, setSearchParams] = useSearchParams();
@@ -302,8 +306,10 @@ export default function AddressesPage() {
return out;
}, [selected, boxStatusById]);
const openDetail = (id: string, tab: string = "overview") => {
setViewTab(tab);
const openDetail = (id: string, tab?: string) => {
const parsed = mailboxTab.safeParse(tab);
setViewTab(tab === undefined ? undefined : parsed.success ? parsed.data : "overview");
setTabIntentKey((key) => key + 1);
setView(id);
};
@@ -360,7 +366,7 @@ export default function AddressesPage() {
}
// Every mailbox page is loaded, so the headers sort in the browser.
const [sort, setSort] = React.useState<MailboxSort | null>(null);
const [sort, setSort] = useBrowseState<MailboxSort | null>("emails.sort", null, mailboxSort);
const sortBy = (col: MailboxColumn) =>
setSort((cur) =>
cur?.by === col.id ? { by: col.id, reverse: !cur.reverse } : { by: col.id, reverse: !!col.sortAsc },
@@ -647,7 +653,7 @@ export default function AddressesPage() {
)}
</PageBody>
<InboxDetails emails={emailsData.emails} view={view} setView={setView} initialTab={viewTab} canWarmup={canWarmup} />
<InboxDetails emails={emailsData.emails} view={view} setView={setView} initialTab={viewTab} tabIntentKey={tabIntentKey} onTabIntentConsumed={consumeTabIntent} canWarmup={canWarmup} />
<SigninMigrationDialog
open={migrationOpen}
@@ -4,6 +4,8 @@
// provider losing reputation. Live through the child tests' events.
import React from "react";
import useBrowseState from "@/hooks/useBrowseState";
import { batchTab, batchSenderSort, batchSenderStatus, browseString, browseBoolean } from "@/lib/browse-accounts-analytics";
import { Link, useNavigate, useParams } from "@tanstack/react-router";
import { motion } from "framer-motion";
import { ArrowLeftIcon, ArrowUpRightIcon, AtSignIcon, Grid3x3Icon, Loader2Icon, MailIcon, ServerIcon, SquareIcon } from "lucide-react";
@@ -90,7 +92,7 @@ export default function PlacementBatchPage() {
body={err?.status === 404 ? "It may belong to another workspace." : err ? buildError(err) : undefined}
/>
) : (
<Detail batch={q.data} />
<Detail key={q.data.id} batch={q.data} />
)}
</div>
);
@@ -100,7 +102,7 @@ function Detail({ batch }: { batch: PlacementBatchDetail }) {
const confirm = useConfirm();
const cancel = useCancelPlacementBatch();
const campaign = useCampaign(batch.campaign_id ?? "");
const [tab, setTab] = React.useState<Tab>("mailboxes");
const [tab, setTab] = useBrowseState<Tab>(`placement.batch.${batch.id}.tab`, "mailboxes", batchTab);
const open = batchOpen(batch.status);
const p = batch.progress;
@@ -289,9 +291,9 @@ const STATUS_ORDER: PlacementBatchSenderStatus[] = ["running", "queued", "deferr
function SendersTab({ batch }: { batch: PlacementBatchDetail }) {
const navigate = useNavigate();
const [sort, setSort] = React.useState<PlacementBatchSenderSort>("worst");
const [status, setStatus] = React.useState<PlacementBatchSenderStatus | "">("");
const [q, setQ] = React.useState("");
const [sort, setSort] = useBrowseState<PlacementBatchSenderSort>(`placement.batch.${batch.id}.senders.sort`, "worst", batchSenderSort);
const [status, setStatus] = useBrowseState<PlacementBatchSenderStatus | "">(`placement.batch.${batch.id}.senders.status`, "", batchSenderStatus);
const [q, setQ] = useBrowseState(`placement.batch.${batch.id}.senders.search`, "", browseString);
const debounced = useDebouncedValue(q.trim(), 300);
const list = usePlacementBatchSenders(batch.id, { sort, status, q: debounced });
const p = batch.progress;
@@ -481,7 +483,7 @@ function GroupTable({ groups, label, empty }: { groups: PlacementBatchGroup[]; l
// Sending domain by recipient provider: the inbox rate in each cell.
function Matrix({ batch }: { batch: PlacementBatchDetail }) {
const [all, setAll] = React.useState(false);
const [all, setAll] = useBrowseState(`placement.batch.${batch.id}.matrix.showAll`, false, browseBoolean);
const cols = batch.recipients;
if (cols.length === 0 || batch.matrix.length === 0) {
return <p className="px-5 py-6 text-[12px] text-slate-400">No copy has a verdict yet.</p>;
+19 -6
View File
@@ -13,6 +13,8 @@ import { DashboardImage } from "@/components/ui/dashboard-image";
// validation. Read data: /analytics/accounts/:id and /analytics/warmup?email_id=.
import React, { useMemo, useState } from "react";
import useBrowseState from "@/hooks/useBrowseState";
import { mailboxTab, type MailboxTab } from "@/lib/browse-accounts-analytics";
import { diagnosticSendingAllowed, diagnosticWarmupActive } from "@/lib/diagnosticParticipation";
import { AnimatePresence, motion } from "framer-motion";
import AdvisorStrip from "@/components/app/advisor/AdvisorStrip";
@@ -333,7 +335,7 @@ function statusTone(status: string) {
/* ── tabs ─────────────────────── */
const TABS: { key: string; label: string; icon: LucideIcon }[] = [
const TABS: { key: MailboxTab; label: string; icon: LucideIcon }[] = [
{ key: "overview", label: "Overview", icon: GaugeIcon },
{ key: "deliverability", label: "Deliverability", icon: MailCheckIcon },
{ key: "analytics", label: "Analytics", icon: BarChart3Icon },
@@ -350,13 +352,17 @@ export default function InboxDetails({
emails,
view,
setView,
initialTab = "overview",
initialTab,
tabIntentKey,
onTabIntentConsumed,
canWarmup = true,
}: {
emails: Inbox[] | null;
view: string;
setView: React.Dispatch<React.SetStateAction<string>>;
initialTab?: string;
initialTab?: MailboxTab;
tabIntentKey?: number;
onTabIntentConsumed?: () => void;
canWarmup?: boolean;
}) {
const mailbox = emails?.find((e) => e.id === view) ?? null;
@@ -381,7 +387,7 @@ export default function InboxDetails({
transition={{ type: "spring", damping: 32, stiffness: 320 }}
className="fixed right-0 top-0 z-50 h-full w-full sm:w-[640px] xl:w-[720px] bg-white border-l border-slate-200 shadow-[0_0_60px_-12px_rgba(15,23,42,0.3)] flex flex-col"
>
<Detail key={mailbox.id} mailbox={mailbox} onClose={close} initialTab={initialTab} canWarmup={canWarmup} />
<Detail key={mailbox.id} mailbox={mailbox} onClose={close} initialTab={initialTab} tabIntentKey={tabIntentKey} onTabIntentConsumed={onTabIntentConsumed} canWarmup={canWarmup} />
</motion.aside>
</>
)}
@@ -400,8 +406,15 @@ const EDITABLE: (keyof Inbox)[] = [
"test_mode", "test_send_enabled", "test_receive_enabled", "shared_daily_limit", "rolling_recipient_limit",
];
function Detail({ mailbox, onClose, initialTab = "overview", canWarmup = true }: { mailbox: Inbox; onClose: () => void; initialTab?: string; canWarmup?: boolean }) {
const [tab, setTab] = useState(initialTab);
function Detail({ mailbox, onClose, initialTab, tabIntentKey, onTabIntentConsumed, canWarmup = true }: { mailbox: Inbox; onClose: () => void; initialTab?: MailboxTab; tabIntentKey?: number; onTabIntentConsumed?: () => void; canWarmup?: boolean }) {
const [tab, setTab] = useBrowseState<MailboxTab>(`emails.mailbox.${mailbox.id}.tab`, "overview", mailboxTab,
initialTab === undefined ? undefined : { initialOverride: initialTab });
React.useEffect(() => {
if (initialTab !== undefined) {
setTab(initialTab);
onTabIntentConsumed?.();
}
}, [initialTab, tabIntentKey, onTabIntentConsumed, setTab]);
const [form, setForm] = useState<Inbox>(mailbox);
const update = (patch: Partial<Inbox>) => setForm((f) => ({ ...f, ...patch }));
@@ -0,0 +1,89 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ComponentProps } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { UserContext } from "@/hooks/context/user";
import { ConfirmContext } from "@/hooks/context/confirm";
import { useAppStore } from "@/stores/useAppStore";
import type Inbox from "@/lib/api/models/app/emails/Inbox";
import type { MailboxTab } from "@/lib/browse-accounts-analytics";
import { BROWSE_STORAGE_PREFIX } from "@/lib/browseState";
import InboxDetails from "./InboxDetails";
vi.mock("@/lib/api/client/Request", () => ({ default: () => new Promise(() => {}) }));
vi.mock("@/hooks/PresenceProvider", () => ({ usePresenceResource: () => {} }));
vi.mock("@/components/app/presence/ResourceViewers", () => ({ default: () => null }));
vi.mock("@/components/app/advisor/AdvisorStrip", () => ({ default: () => null }));
vi.mock("@/hooks/useCloudPool", () => ({ default: () => ({ connected: false, selfHosted: false, isEnrolled: () => false }) }));
vi.mock("./SendingBehaviorTab", () => ({ default: () => <p>Sending behavior</p> }));
const mailbox: Inbox = {
id: "a", email: "alice@a.test", name: "Alice", signature_plain: "", signature_html: "", signature_sync: false, signature_code: false,
send_as_email: "", tags: [], provider: "smtp_imap", status: "active", last_synced_at: new Date(), campaign_limit: 50, min_wait_time: 60,
reply_to: "", save_to_sent: false, tracking_domain: "", tracking_domain_verified: false,
auth_state: "unknown", auth_spf: false, auth_dkim: false, auth_dmarc: false,
warmup_base: 5, warmup_max: 20, warmup_increase: 2, warmup_reply_rate: 30, created_at: new Date(), updated_at: new Date(),
};
let client: QueryClient;
function Page({ id = "a", initialTab, tabIntentKey = 0, onTabIntentConsumed }: { id?: string; initialTab?: MailboxTab; tabIntentKey?: number; onTabIntentConsumed?: () => void }) {
const profile = { user: { id: "account-user" } } as ComponentProps<typeof UserContext.Provider>["value"];
return (
<QueryClientProvider client={client}>
<UserContext.Provider value={profile}>
<ConfirmContext.Provider value={{ show: vi.fn(), setLoading: vi.fn(), setShow: vi.fn() }}>
<InboxDetails emails={[mailbox, { ...mailbox, id: "b", email: "bob@b.test" }]} view={id} setView={() => {}} initialTab={initialTab} tabIntentKey={tabIntentKey} onTabIntentConsumed={onTabIntentConsumed} />
</ConfirmContext.Provider>
</UserContext.Provider>
</QueryClientProvider>
);
}
const tab = (name: string) => screen.getByRole("button", { name });
describe("mailbox drawer browse persistence", () => {
beforeEach(() => {
HTMLElement.prototype.scrollBy = vi.fn();
sessionStorage.clear();
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
useAppStore.setState({ currentOrganization: { id: "workspace", name: "Workspace", role: "owner" } });
});
it("restores drawer tabs on remount and isolates mailbox resources", () => {
const page = render(<Page />);
fireEvent.click(tab("Analytics"));
page.unmount();
const refreshed = render(<Page />);
expect(tab("Analytics")).toHaveAttribute("data-active", "true");
refreshed.rerender(<Page id="b" />);
expect(tab("Overview")).toHaveAttribute("data-active", "true");
refreshed.rerender(<Page />);
expect(tab("Analytics")).toHaveAttribute("data-active", "true");
});
it("honors repeated explicit tab intents without discarding mailbox drafts", () => {
const first = render(<Page />);
fireEvent.click(tab("Sending"));
first.unmount();
const page = render(<Page initialTab="analytics" />);
expect(tab("Analytics")).toHaveAttribute("data-active", "true");
fireEvent.click(tab("Warmup"));
fireEvent.change(screen.getByLabelText(/Shared daily send limit/), { target: { value: "80" } });
expect(screen.getByText("Unsaved changes")).toBeInTheDocument();
page.rerender(<Page initialTab="analytics" tabIntentKey={1} />);
expect(tab("Analytics")).toHaveAttribute("data-active", "true");
expect(screen.getByText("Unsaved changes")).toBeInTheDocument();
fireEvent.click(tab("Warmup"));
expect(screen.getByLabelText(/Shared daily send limit/)).toHaveValue(80);
page.rerender(<Page initialTab="analytics" tabIntentKey={2} />);
expect(tab("Analytics")).toHaveAttribute("data-active", "true");
});
it("consumes explicit navigation so another workspace restores its own tab", () => {
sessionStorage.setItem(`${BROWSE_STORAGE_PREFIX}account-user:other:emails.mailbox.a.tab`, JSON.stringify({ value: "sending" }));
const consumed = vi.fn();
const page = render(<Page initialTab="analytics" onTabIntentConsumed={consumed} />);
expect(consumed).toHaveBeenCalledOnce();
page.rerender(<Page />);
act(() => useAppStore.setState({ currentOrganization: { id: "other", name: "Other", role: "owner" } }));
expect(tab("Sending")).toHaveAttribute("data-active", "true");
});
});
@@ -381,6 +381,7 @@ function Dialog({
{n > 1 && (
<PerDomainList
browseScope="emails.domains.bulkSetup.list"
rows={listRows}
onChange={(d, patch) => setEach((prev) => ({ ...prev, [d]: { ...prev[d], ...patch } }))}
onReset={(d) =>
@@ -2,6 +2,8 @@
// listing each domain with its own tracking host and redirect website, filled
// from the shared value until someone types over it.
import React from "react";
import useBrowseState from "@/hooks/useBrowseState";
import { browseString, browseBoolean, domainListLimit } from "@/lib/browse-accounts-analytics";
import { AnimatePresence, motion } from "framer-motion";
import { ChevronDownIcon, RotateCcwIcon } from "lucide-react";
import { SearchInput, TextInput } from "@/components/ui/field";
@@ -51,19 +53,21 @@ function Tick({ checked, onToggle, label, disabled }: { checked: boolean; onTogg
}
export function PerDomainList({
browseScope,
rows,
onChange,
onReset,
defaultOpen = false,
}: {
browseScope: string;
rows: PerDomainRow[];
onChange: (domain: string, patch: DomainOverride) => void;
onReset: (domain: string) => void;
defaultOpen?: boolean;
}) {
const [open, setOpen] = React.useState(defaultOpen);
const [query, setQuery] = React.useState("");
const [limit, setLimit] = React.useState(20);
const [open, setOpen] = useBrowseState(`${browseScope}.expanded`, defaultOpen, browseBoolean);
const [query, setQuery] = useBrowseState(`${browseScope}.search`, "", browseString);
const [limit, setLimit] = useBrowseState(`${browseScope}.limit`, 20, domainListLimit);
const changed = rows.filter((r) => r.custom).length;
const problems = rows.filter((r) => (r.track && r.hostProblem) || (r.redirect && r.urlProblem)).length;
const q = query.trim().toLowerCase();
@@ -0,0 +1,84 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { useState, type ComponentProps, type ReactNode } from "react";
import { beforeEach, describe, expect, it } from "vitest";
import { UserContext } from "@/hooks/context/user";
import { useAppStore } from "@/stores/useAppStore";
import PickTable, { type PickItem } from "../import/PickTable";
import { PerDomainList, type PerDomainRow } from "./PerDomainList";
function Owner({ children }: { children: ReactNode }) {
const profile = { user: { id: "account-user" } } as ComponentProps<typeof UserContext.Provider>["value"];
return <UserContext.Provider value={profile}>{children}</UserContext.Provider>;
}
const items: PickItem[] = [
{ id: "a", email: "alice@a.test", name: "Alice", connected: true, group: "A" },
{ id: "b", email: "bob@b.test", name: "Bob", connected: false, group: "B" },
];
function Picker({ scope = "emails.import.vendor.a.pick", loading = false }: { scope?: string; loading?: boolean }) {
const [selected, setSelected] = useState<Set<string>>(new Set());
return <Owner><PickTable browseScope={scope} items={loading ? undefined : items} loading={loading} selected={selected} setSelected={setSelected} noun={{ one: "mailbox", many: "mailboxes" }} /></Owner>;
}
const rows: PerDomainRow[] = Array.from({ length: 80 }, (_, i) => ({
domain: `domain-${i}.test`, canTrack: false, track: false, host: "", redirect: false, url: "", urlPlaceholder: "", custom: false,
}));
function DomainList({ scope = "emails.domains.bulkSetup.list" }: { scope?: string }) {
return <Owner><PerDomainList browseScope={scope} rows={rows} onChange={() => {}} onReset={() => {}} /></Owner>;
}
describe("nested account browsing persistence", () => {
beforeEach(() => {
sessionStorage.clear();
useAppStore.setState({ currentOrganization: { id: "workspace", name: "Workspace", role: "owner" } });
});
it("restores picker search, filter and group after loading/remount without restoring selected rows", () => {
const picker = render(<Picker />);
fireEvent.change(screen.getByPlaceholderText("Search mailboxes"), { target: { value: "alice" } });
fireEvent.click(screen.getByRole("button", { name: /^Connected/ }));
fireEvent.click(screen.getByRole("button", { name: /^A\s?1$/ }));
fireEvent.click(screen.getByRole("button", { name: /alice@a.test/ }));
picker.unmount();
const refreshed = render(<Picker loading />);
refreshed.rerender(<Picker />);
expect(screen.getByPlaceholderText("Search mailboxes")).toHaveValue("alice");
expect(screen.getByRole("button", { name: /^Connected/ })).toHaveClass("bg-sky-50");
expect(screen.getByRole("button", { name: /^A\s?1$/ })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: /alice@a.test/ })).toHaveAttribute("aria-pressed", "false");
expect(screen.queryByText("bob@b.test")).not.toBeInTheDocument();
});
it("isolates connection scopes and persists explicit picker clearing", () => {
const picker = render(<Picker />);
fireEvent.change(screen.getByPlaceholderText("Search mailboxes"), { target: { value: "alice" } });
picker.rerender(<Picker scope="emails.import.vendor.b.pick" />);
expect(screen.getByPlaceholderText("Search mailboxes")).toHaveValue("");
picker.rerender(<Picker />);
expect(screen.getByPlaceholderText("Search mailboxes")).toHaveValue("alice");
fireEvent.change(screen.getByPlaceholderText("Search mailboxes"), { target: { value: "" } });
picker.unmount();
render(<Picker />);
expect(screen.getByPlaceholderText("Search mailboxes")).toHaveValue("");
});
it("restores expanded domain lists, search and show-more without persisting editor values", () => {
const list = render(<DomainList />);
fireEvent.click(screen.getByRole("button", { name: /^Change per domain/ }));
fireEvent.click(screen.getByRole("button", { name: /Show more/ }));
fireEvent.change(screen.getByPlaceholderText("Find a domain…"), { target: { value: "domain" } });
list.unmount();
const refreshed = render(<DomainList />);
expect(screen.getByRole("button", { name: /^Change per domain/ })).toHaveAttribute("aria-expanded", "true");
expect(screen.getByPlaceholderText("Find a domain…")).toHaveValue("domain");
expect(screen.getByText("domain-69.test")).toBeInTheDocument();
expect(screen.queryByText("domain-70.test")).not.toBeInTheDocument();
refreshed.rerender(<DomainList scope="emails.import.file.domainChoices" />);
expect(screen.getByRole("button", { name: /^Change per domain/ })).toHaveAttribute("aria-expanded", "false");
refreshed.rerender(<DomainList />);
expect(screen.getByPlaceholderText("Find a domain…")).toHaveValue("domain");
act(() => useAppStore.setState({ currentOrganization: { id: "other", name: "Other", role: "owner" } }));
expect(screen.getByRole("button", { name: /^Change per domain/ })).toHaveAttribute("aria-expanded", "false");
});
});
@@ -171,10 +171,12 @@ export function AllDomainsChoice({
/** The shared choice with each domain's own values in a dropdown under it. */
export function DomainChoicesPanel({
browseScope = "emails.import.file.domainChoices",
infos,
picks,
setPicks,
}: {
browseScope?: string;
infos: DomainInfo[];
picks: DomainPicks;
setPicks: React.Dispatch<React.SetStateAction<DomainPicks>>;
@@ -226,6 +228,7 @@ export function DomainChoicesPanel({
<AllDomainsChoice infos={infos} picks={picks} setPicks={setPicks} />
{usable.length > 1 && (
<PerDomainList
browseScope={browseScope}
rows={rows}
onChange={(d, patch) => setPicks((prev) => ({ ...prev, each: { ...prev.each, [d]: { ...prev.each[d], ...patch } } }))}
onReset={(d) =>
@@ -243,11 +246,13 @@ export function DomainChoicesPanel({
/** The picked mailboxes' domains with their choices, for the vendor and grant wizards. */
export function DomainChoicesSection({
browseScope,
infos,
picks,
setPicks,
capped = 0,
}: {
browseScope: string;
infos: DomainInfo[];
picks: DomainPicks;
setPicks: React.Dispatch<React.SetStateAction<DomainPicks>>;
@@ -258,7 +263,7 @@ export function DomainChoicesSection({
return (
<div>
<SectionLabel className="mb-1.5">Domains</SectionLabel>
<DomainChoicesPanel infos={infos} picks={picks} setPicks={setPicks} />
<DomainChoicesPanel infos={infos} picks={picks} setPicks={setPicks} browseScope={browseScope} />
{capped > 0 && (
<p className="mt-1 text-[11px] text-slate-500">
{plural(capped, "more domain is", "more domains are")} not listed. Set them up on the Sending domains page after the
@@ -350,6 +350,7 @@ export default function MailboxImportWizard({
)}
{step === "run" && importId && (
<RunStep
key={importId}
importId={importId}
onDone={onDone}
onAllowance={onAllowance}
@@ -3,6 +3,8 @@
// span several, checkbox rows and paging. The caller owns the selection so the
// wizard's floating bar can act on it.
import React from "react";
import useBrowseState from "@/hooks/useBrowseState";
import { browseString, importPickFilter } from "@/lib/browse-accounts-analytics";
import { ChevronLeftIcon, ChevronRightIcon, LayersIcon, MailIcon, RefreshCwIcon } from "lucide-react";
import { SearchInput } from "@/components/ui/field";
import { CheckSquare } from "@/components/ui/check-square";
@@ -45,6 +47,7 @@ export function ProviderBadge({ provider }: { provider?: PickItem["provider"] })
}
export default function PickTable({
browseScope,
items,
loading,
fetching,
@@ -57,6 +60,7 @@ export default function PickTable({
loadingSteps,
groupLabel = "Workspace",
}: {
browseScope: string;
items: PickItem[] | undefined;
/** Whose mailboxes are being listed, and what is happening while they are. */
loadingLogo?: string;
@@ -71,9 +75,9 @@ export default function PickTable({
/** What PickItem.group names, for the switcher shown when rows span several. */
groupLabel?: string;
}) {
const [query, setQuery] = React.useState("");
const [filter, setFilter] = React.useState<Filter>("all");
const [group, setGroup] = React.useState<string | null>(null);
const [query, setQuery] = useBrowseState(`${browseScope}.search`, "", browseString);
const [filter, setFilter] = useBrowseState<Filter>(`${browseScope}.filter`, "all", importPickFilter);
const [group, setGroup] = useBrowseState<string | null>(`${browseScope}.group`, null, browseString.nullable());
const [page, setPage] = React.useState(0);
const all = React.useMemo(() => items ?? [], [items]);
@@ -199,7 +203,7 @@ export default function PickTable({
["connected", "Connected"],
] as const
)
.filter(([key]) => key !== "moving" || counts.moving > 0)
.filter(([key]) => key !== "moving" || counts.moving > 0 || filter === "moving")
.map(([key, label]) => (
<button
key={key}
@@ -4,6 +4,8 @@
// cause (optionally with a new password), a per-row fix, and Sign in for rows
// that connect with Google or Microsoft sign-in.
import React from "react";
import useBrowseState from "@/hooks/useBrowseState";
import { importFilter } from "@/lib/browse-accounts-analytics";
import { Link } from "@tanstack/react-router";
import { useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast/headless";
@@ -122,7 +124,7 @@ export default function RunStep({
const [granted, setGranted] = React.useState<DomainGrant | null>(null);
const consent = useMicrosoftAdminConsent(setGranted);
const [filter, setFilter] = React.useState<Filter>({ status: "", cause: "" });
const [filter, setFilter] = useBrowseState<Filter>(`emails.import.${importId}.results.filter`, { status: "", cause: "" }, importFilter);
const [cursors, setCursors] = React.useState<string[]>([""]);
const [fixing, setFixing] = React.useState<number | null>(null);
const applyFilter = (f: Filter) => {
@@ -314,6 +314,8 @@ export default function SourceImportWizard(props: SourceImportWizardProps) {
</button>
)}
<PickTable
key={`${source}:${sourceKey}`}
browseScope={`emails.import.${source}.${sourceKey}.pick`}
items={items}
loading={itemsLoading}
loadingLogo={itemsLoadingLogo}
@@ -373,6 +375,7 @@ export default function SourceImportWizard(props: SourceImportWizardProps) {
)}
<DomainChoicesSection
browseScope={`emails.import.${source}.${sourceKey}.domainChoices`}
infos={domainInfos}
picks={domainPicks}
setPicks={setDomainPicks}
@@ -384,6 +387,7 @@ export default function SourceImportWizard(props: SourceImportWizardProps) {
)}
{current === "run" && importId && (
<RunStep
key={importId}
importId={importId}
onDone={onDone}
onAllowance={onAllowance}
@@ -165,6 +165,7 @@ export default function SigninMigrationDialog({
>
{run ? (
<RunStep
key={run.importId}
importId={run.importId}
onDone={onClose}
onImportAnother={() => setRun(null)}
@@ -1,7 +1,9 @@
// A mailbox's warmup deliverability: where its warmup mail landed, day by day
// and per recipient provider, and the trailing inbox rate behind the list's
// percentage. Live: WARMUP_PLACEMENT events refresh the query.
import { useMemo, useState, type ReactNode } from "react";
import { useMemo, type ReactNode } from "react";
import useBrowseState from "@/hooks/useBrowseState";
import { placementRange, placementGroupFilter } from "@/lib/browse-accounts-analytics";
import { InboxIcon } from "lucide-react";
import { DitherRing } from "@/components/ui/dither";
import { Loading } from "@/components/loader";
@@ -35,8 +37,8 @@ const POOL_STATE_LABEL: Record<string, string> = {
};
export default function MailboxPlacementTab({ mailboxId, poolHealth }: { mailboxId: string; poolHealth?: WarmupHealthInfo }) {
const [range, setRange] = useState<RangeKey>("30d");
const [group, setGroup] = useState<GroupFilter>("all");
const [range, setRange] = useBrowseState<RangeKey>(`emails.mailbox.${mailboxId}.placement.range`, "30d", placementRange);
const [group, setGroup] = useBrowseState<GroupFilter>(`emails.mailbox.${mailboxId}.placement.group`, "all", placementGroupFilter);
const days = RANGES.find((r) => r.key === range)?.days ?? 30;
const { from, to } = useMemo(() => utcWindow(days), [days]);
const q = useWarmupPlacement(mailboxId, from, to);
@@ -171,7 +173,7 @@ export default function MailboxPlacementTab({ mailboxId, poolHealth }: { mailbox
<div className="px-5 pt-4 pb-2">
<Eyebrow>By recipient provider</Eyebrow>
</div>
<ProviderBreakdown providers={report.providers} />
<ProviderBreakdown providers={report.providers} browseScope={`emails.mailbox.${mailboxId}.placement`} />
</div>
</>
)}
@@ -0,0 +1,40 @@
import { fireEvent, render, screen } from "@testing-library/react";
import type { ComponentProps } from "react";
import { beforeEach, describe, expect, it } from "vitest";
import { UserContext } from "@/hooks/context/user";
import { useAppStore } from "@/stores/useAppStore";
import type { PlacementHost, PlacementProvider } from "@/lib/api/models/app/analytics/WarmupPlacement";
import { ProviderBreakdown } from "./PlacementCharts";
const counts = { sent: 10, delivered: 10, inbox: 8, tabs: 0, spam: 2, rescued: 0, unconfirmed: 0, inbox_rate: 80, spam_rate: 20 };
const providers: PlacementProvider[] = [{
...counts, group: "google", hosts: ["gmail", "google_workspace"].map((host): PlacementHost => ({ ...counts, host })),
}];
function Breakdown({ scope = "emails.mailbox.a.placement", loading = false }: { scope?: string; loading?: boolean }) {
const profile = { user: { id: "account-user" } } as ComponentProps<typeof UserContext.Provider>["value"];
return <UserContext.Provider value={profile}><ProviderBreakdown browseScope={scope} providers={loading ? [] : providers} /></UserContext.Provider>;
}
describe("provider expansion browse persistence", () => {
beforeEach(() => {
sessionStorage.clear();
useAppStore.setState({ currentOrganization: { id: "workspace", name: "Workspace", role: "owner" } });
});
it("restores expansion after remount and metadata loading, isolated from other placement panels", () => {
const panel = render(<Breakdown />);
fireEvent.click(screen.getByRole("button"));
panel.unmount();
const refreshed = render(<Breakdown loading />);
refreshed.rerender(<Breakdown />);
expect(screen.getByRole("button")).toHaveAttribute("aria-expanded", "true");
refreshed.rerender(<Breakdown scope="deliverability.placement" />);
expect(screen.getByRole("button")).toHaveAttribute("aria-expanded", "false");
refreshed.rerender(<Breakdown />);
expect(screen.getByRole("button")).toHaveAttribute("aria-expanded", "true");
fireEvent.click(screen.getByRole("button"));
refreshed.unmount();
render(<Breakdown />);
expect(screen.getByRole("button")).toHaveAttribute("aria-expanded", "false");
});
});
@@ -2,6 +2,8 @@
// deliverability page: the rate badge, daily landing columns, the rolling
// inbox-rate trend, the provider breakdown and the filter controls.
import React, { useState } from "react";
import useBrowseState from "@/hooks/useBrowseState";
import { placementGroup } from "@/lib/browse-accounts-analytics";
import { ChevronRightIcon } from "lucide-react";
import { DitherColumns, DitherStack, type DitherTone } from "@/components/ui/dither";
import { EmptyChart } from "@/components/ui/charts";
@@ -298,8 +300,8 @@ export function RateSpark({ values, width = 96, height = 22 }: { values: (number
// One row per recipient group; expanding it lists the hosts inside
// (Gmail vs Google Workspace, Outlook.com vs Microsoft 365).
export function ProviderBreakdown({ providers }: { providers: PlacementProvider[] }) {
const [open, setOpen] = useState<PlacementGroup | null>(null);
export function ProviderBreakdown({ providers, browseScope }: { providers: PlacementProvider[]; browseScope: string }) {
const [open, setOpen] = useBrowseState<PlacementGroup | null>(`${browseScope}.providerExpanded`, null, placementGroup.nullable());
if (providers.length === 0) {
return <p className="px-5 py-6 text-[12px] text-slate-400 text-center">No deliveries at any provider in this window.</p>;
}
@@ -1,6 +1,8 @@
// Workspace warmup placement on the deliverability page: inbox vs spam over
// time, per recipient provider, and every mailbox ranked worst first.
import { useMemo, useState, type ReactNode } from "react";
import { useMemo, type ReactNode } from "react";
import useBrowseState from "@/hooks/useBrowseState";
import { browseBoolean, placementGroupFilter } from "@/lib/browse-accounts-analytics";
import { useNavigate } from "@tanstack/react-router";
import { ChevronRightIcon } from "lucide-react";
import { EmptyBlock, SectionBar, Stat, StatStrip } from "@/components/layout/Page";
@@ -25,9 +27,9 @@ export default function WarmupPlacementSection({ days }: { days: number }) {
const { from, to } = useMemo(() => utcWindow(days), [days]);
const q = useWarmupPlacement(undefined, from, to);
const report = q.data;
const [group, setGroup] = useState<GroupFilter>("all");
const [attentionOnly, setAttentionOnly] = useState(false);
const [showAll, setShowAll] = useState(false);
const [group, setGroup] = useBrowseState<GroupFilter>("deliverability.placement.group", "all", placementGroupFilter);
const [attentionOnly, setAttentionOnly] = useBrowseState("deliverability.placement.attentionOnly", false, browseBoolean);
const [showAll, setShowAll] = useBrowseState("deliverability.placement.showAll", false, browseBoolean);
const navigate = useNavigate();
const groups = useMemo<PlacementGroup[]>(
@@ -121,7 +123,7 @@ export default function WarmupPlacementSection({ days }: { days: number }) {
</div>
<SectionBar label="Warmup placement by provider" count={report.providers.length || undefined} />
<ProviderBreakdown providers={report.providers} />
<ProviderBreakdown providers={report.providers} browseScope="deliverability.placement" />
<p className="px-5 py-3 text-[11.5px] text-slate-500 leading-relaxed border-t border-slate-200/60">
Warmup partner selection reads these numbers per mail host: a mailbox landing in spam at one host is sent
fewer partners there while the rate stays high, and more again once it recovers. It is never cut off
@@ -3,6 +3,8 @@
// allowance, and a seed never warms up or sends campaign mail.
import React from "react";
import useBrowseState from "@/hooks/useBrowseState";
import { browseString } from "@/lib/browse-accounts-analytics";
import { InboxIcon, Loader2Icon } from "lucide-react";
import toast from "react-hot-toast/headless";
import { Link } from "@tanstack/react-router";
@@ -26,7 +28,7 @@ const STATUS_LABEL: Record<string, string> = {
export default function SeedInboxes() {
const seeds = usePlacementSeeds();
const canManage = usePermission("MANAGE_EMAILS");
const [q, setQ] = React.useState("");
const [q, setQ] = useBrowseState("placement.seeds.search", "", browseString);
const rows = seeds.data ?? [];
const needle = q.trim().toLowerCase();
const shown = needle ? rows.filter((r) => r.email.toLowerCase().includes(needle) || r.label.toLowerCase().includes(needle)) : rows;
+34
View File
@@ -0,0 +1,34 @@
import { z } from "zod";
export const browseString = z.string();
export const browseBoolean = z.boolean();
export const analyticsRange = z.enum(["7d", "30d", "90d"]);
export const mailboxTab = z.enum(["overview", "deliverability", "analytics", "warmup", "sending", "settings"]);
export type MailboxTab = z.infer<typeof mailboxTab>;
export const domainTab = z.enum(["overview", "tracking", "redirect"]);
export const domainFilter = z.enum(["all", "attention", "vendor"]);
export const mailboxSort = z.strictObject({
by: z.enum(["mailbox", "status", "sent", "warmup", "inbox", "health"]),
reverse: z.boolean(),
}).nullable();
export const analyticsHiddenMetrics = z.array(z.enum(["sent", "opens", "clicks", "replies"]))
.max(3).refine((values) => new Set(values).size === values.length);
export const deliverabilityHiddenMetrics = z.array(z.enum(["bounces", "complaints", "opens", "replies", "sent"]))
.max(4).refine((values) => new Set(values).size === values.length);
export const deliverabilityView = z.strictObject({
range: analyticsRange,
hidden: z.array(z.enum(["chart", "totals", "placement", "providers", "mailboxes", "campaigns"]))
.max(6).refine((values) => new Set(values).size === values.length),
});
export const placementRange = z.enum(["7d", "14d", "30d", "90d"]);
export const placementGroup = z.enum(["google", "microsoft", "yahoo", "other"]);
export const placementGroupFilter = z.enum(["all", "google", "microsoft", "yahoo", "other"]);
export const batchTab = z.enum(["mailboxes", "domains", "providers", "recipients"]);
export const batchSenderSort = z.enum(["worst", "best", "email", "status"]);
export const batchSenderStatus = z.enum(["", "queued", "deferred", "running", "completed", "skipped", "failed", "cancelled"]);
export const importFilter = z.strictObject({
status: z.enum(["", "queued", "running", "connected", "updated", "skipped", "failed", "needs_signin", "cancelled"]),
cause: z.string(),
}).refine((value) => !value.cause || value.status === "failed");
export const importPickFilter = z.enum(["all", "new", "moving", "connected"]);
export const domainListLimit = z.number().finite().int().positive();