Merge remote-tracking branch 'origin/main' into fix/cold-email-sending-defaults

This commit is contained in:
Matthew Meszaros
2026-08-31 05:39:46 -07:00
6 changed files with 318 additions and 146 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"skipReview": "AUTOMATIC"
}
+1 -1
View File
@@ -982,7 +982,7 @@ Auth: **Scope** `READ_CRM` · **Org permission** `view_contacts`
## Segments
Segments are saved contact audiences: a list of conditions plus per-contact manual overrides. Membership is evaluated live on every read, so a segment never needs rebuilding. Every segment endpoint takes the contact scopes, except enrolling into a campaign, which writes leads and takes `WRITE_CAMPAIGNS`.
Segments are saved contact audiences: a list of conditions plus per-contact manual overrides. Membership is evaluated live on every read, so a segment never needs rebuilding. Every segment endpoint takes the contact scopes, except enrolling into a campaign, which writes leads and takes `WRITE_CAMPAIGNS`. Endpoints that operate on an existing segment address it by its `id`; besides `GET /segments`, the dashboard shows that ID on the segment page header (click to copy) and in the row menu of the Segments tab.
A segment object:
+7
View File
@@ -52,6 +52,12 @@ Sequences can pin as well: the **Add to segment** and **Remove from segment** ac
- **Duplicate**: the segment menu copies a definition to start a variation from.
- **Search and export**: the contact search and export accept `segment_ids`, so anything that takes a contact filter can be scoped to a segment.
## Segments in the API
Everything above can be driven from the [API](/api/reference/contacts/#segments): list, create, update and delete segments, pin contacts in or out, look up a contact's segments, and enrol a segment into a campaign. Contact search and export take `segment_ids` to scope any contact query to a segment, so an external system (a signup form, a CRM sync) can keep a segment current and let campaigns pick it up from there.
API calls address a segment by its ID. It is shown at the bottom of the segment page header (click it to copy), in the **Copy segment ID** entry of a segment's row menu on the Segments tab, and in every segment the API returns. Reads take the `READ_CONTACTS` key scope, writes `WRITE_CONTACTS`, and enrolling into a campaign `WRITE_CAMPAIGNS`.
<Callout type="info" title="Segments and categories">
Categories are labels you put on a contact. Segments are rules that read those labels (and everything else) to decide who belongs. Use a category to mark a fact about a contact, and a segment to describe an audience.
</Callout>
@@ -68,4 +74,5 @@ Categories are labels you put on a contact. Segments are rules that read those l
<Card title="Contacts and CRM" href="/guides/contacts-crm/" />
<Card title="Campaigns" href="/guides/campaigns/" />
<Card title="Analytics" href="/guides/analytics/" />
<Card title="Segments API reference" href="/api/reference/contacts/#segments" />
</Cards>
@@ -3,7 +3,7 @@
import React from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { ArrowLeftIcon, ChevronDownIcon, MegaphoneIcon, PencilIcon, Trash2Icon } from "lucide-react";
import { ArrowLeftIcon, CheckIcon, ChevronDownIcon, CopyIcon, MegaphoneIcon, PencilIcon, Trash2Icon } from "lucide-react";
import toast from "react-hot-toast";
import ContactsTable from "@/components/app/contacts/ContactsTable";
@@ -94,6 +94,7 @@ function SegmentDetail() {
</div>
{s.description && <p className="text-[12px] text-slate-500 mt-0.5">{s.description}</p>}
<ConditionSummary conditions={s.conditions} match={s.match} specs={fields.data ?? []} included={s.included_count} excluded={s.excluded_count} />
<SegmentIdChip id={s.id} />
</div>
<div className="flex items-center gap-1.5 shrink-0">
<button
@@ -134,6 +135,39 @@ function SegmentDetail() {
);
}
// The segment's ID, click to copy: it is what the API takes (segments CRUD,
// members, contact search segment_ids), so integrators need it at hand.
function SegmentIdChip({ id }: { id: string }) {
const [copied, setCopied] = React.useState(false);
async function copy() {
try {
await navigator.clipboard.writeText(id);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
toast.error("Could not copy");
}
}
return (
<>
<button
type="button"
onClick={copy}
aria-label={copied ? "Segment ID copied" : "Copy segment ID"}
title="Copy segment ID"
className="mt-1 inline-flex items-center gap-1.5 max-w-full font-mono text-[10.5px] text-slate-400 hover:text-slate-700 transition-colors"
>
<span className="uppercase tracking-[0.14em] font-sans font-medium text-[9.5px]">ID</span>
<span className="truncate">{id}</span>
{copied ? <CheckIcon className="w-3 h-3 text-emerald-600 shrink-0" /> : <CopyIcon className="w-3 h-3 shrink-0" />}
</button>
<span className="sr-only" role="status" aria-live="polite">
{copied ? "Segment ID copied" : ""}
</span>
</>
);
}
// Pinned contacts. Excluded ones never show in the member list, so this is
// the only place they can be seen and released.
function OverridesPanel({ segment }: { segment: Segment }) {
@@ -92,6 +92,16 @@ function SegmentsList() {
setEditorOpen(true);
}
// The ID is what the segments API takes; copying needs no write permission.
async function copyId(s: Segment) {
try {
await navigator.clipboard.writeText(s.id);
toast.success("Segment ID copied");
} catch {
toast.error("Could not copy");
}
}
function askDelete(s: Segment) {
confirm.show(`Delete the segment "${s.name}"? Contacts themselves are kept.`, async () => {
try {
@@ -202,6 +212,7 @@ function SegmentsList() {
<PopoverMenuItem onSelect={guarded(() => openEdit(s))}>Edit conditions</PopoverMenuItem>
<PopoverMenuItem onSelect={campaignGuarded(() => setCampaignFor(s))}>Add to campaign</PopoverMenuItem>
<PopoverMenuItem onSelect={guarded(() => duplicate(s))}>Duplicate</PopoverMenuItem>
<PopoverMenuItem onSelect={() => copyId(s)}>Copy segment ID</PopoverMenuItem>
<PopoverMenuSeparator />
<PopoverMenuItem onSelect={guarded(() => askDelete(s))}>Delete</PopoverMenuItem>
</PopoverMenuContent>
+261 -144
View File
@@ -605,31 +605,32 @@ function Section({ section, first = false }: { section: NavSection; first?: bool
* Anatomy:
*
* ┌──────────────────────────────────┐
* │ ● LIVE 42 of 50 / day │ ← status dot + label + cap pace
* │ 8 mailboxes sending now │ ← mailbox composition
* │ ▂▃▅▇▆▄▂ ▂▃▅▇▆▄▂ │ ← optional 24h sparkline
* │ 128 of 400 sent today │ ← hero number (scrubs on hover)
* │ ━━━━━━━───────── │ ← capacity meter (today vs cap)
* │ ∿∿∿∿∿∿ │ ← 14-day area sparkline
* │ ✉ 8 ● 5 ⬇ 3 │ ← mailboxes · active · unread
* └──────────────────────────────────┘
*
* Reads as ambient telemetry: even when idle, it tells you "system is
* up, n mailboxes ready." Clicking jumps to analytics. The dot pulses
* when at least one mailbox is actively warming or sending.
* Reads as ambient telemetry: even when idle, it tells you "n mailboxes,
* n sent today." Clicking jumps to analytics; hovering a day on the
* sparkline swaps the hero number to that day. There is deliberately no
* LIVE/OFFLINE status row: the numbers ticking realtime already say the
* system is up, so the panel spends its pixels on the data instead.
*
* Data sources at this layer:
* - useAppStore.emails → mailbox count, active count
* - useAppStore.connectionStatus → online/offline state
* - useDashboard("30d") daily_trend → today's sent volume + the sparkline
* (shares the dashboard page's query cache; realtime invalidation keeps
* it current)
*
* The capacity denominator is a derived cap based on mailbox count × 50
* (default cold cap from internal/config/constants.go).
* The capacity denominator sums each mailbox's configured campaign_limit
* (default 50/day, from internal/config/constants.go).
*/
function LivePanel() {
const emails = useAppStore((s) => s.emails);
const connection = useAppStore((s) => s.connectionStatus);
const latencyMs = useAppStore((s) => s.wsLatencyMs);
const unseenCount = useAppStore((s) => s.unseenCount);
const dash = useDashboard("30d");
const [hovered, setHovered] = useState<number | null>(null);
const { active, mailboxes, capacity } = useMemo(() => {
const m = emails.length;
@@ -637,167 +638,283 @@ function LivePanel() {
const st = mailboxDisplayStatus(e);
return st === "healthy" || st === "warming";
}).length;
return { active: a, mailboxes: m, capacity: m * 50 };
// Capacity = the sum of each mailbox's configured daily campaign
// limit (default 50/day), not a flat count × 50 — a tuned-down or
// raised mailbox should move the meter's denominator.
const cap = emails.reduce((sum, e) => sum + (e.campaign_limit ?? 50), 0);
return { active: a, mailboxes: m, capacity: cap };
}, [emails]);
const { sentToday, trend } = useMemo(() => {
const days = dash.data?.daily_trend ?? [];
const todayKey = new Date().toISOString().slice(0, 10);
const today = days.find((d) => d.date?.slice(0, 10) === todayKey);
return {
sentToday: today?.sent ?? 0,
trend: days.slice(-14).map((d) => d.sent),
};
// daily_trend only contains days that had sends; rebuild a continuous
// last-14-days axis (zero-filling the gaps) so the sparkline's x
// spacing is honest — otherwise a quiet week would be silently
// squeezed out and two distant days would read as adjacent.
const byDate = new Map(
(dash.data?.daily_trend ?? []).map((d) => [d.date?.slice(0, 10), d.sent]),
);
const out: { date: string; sent: number }[] = [];
const now = new Date();
for (let i = 13; i >= 0; i--) {
const d = new Date(now);
d.setUTCDate(now.getUTCDate() - i);
const key = d.toISOString().slice(0, 10);
out.push({ date: key, sent: byDate.get(key) ?? 0 });
}
return { sentToday: out[out.length - 1].sent, trend: out };
}, [dash.data]);
const live = connection === "connected";
// Connected == green, always. When quiet we say READY (not the old "IDLE",
// which with a gray dot read as "not connected"); when a mailbox is warming
// or sending we say LIVE and pulse. Only a real disconnect is gray.
const label =
connection === "disconnected"
? "OFFLINE"
: connection === "connecting"
? "CONNECTING"
: active > 0
? "LIVE"
: "READY";
const dotClass =
connection === "disconnected"
? "bg-slate-300"
: connection === "connecting"
? "bg-amber-500"
: "bg-emerald-500";
const labelTone =
connection === "disconnected"
? "text-slate-400"
: connection === "connecting"
? "text-amber-600"
: "text-emerald-600";
// Latency bucketing: <100ms great, <300ms okay, ≥300ms poor.
const latencyTone =
latencyMs == null
? "text-slate-400"
: latencyMs < 100
? "text-emerald-600"
: latencyMs < 300
? "text-amber-600"
: "text-red-500";
const scrub = hovered != null ? trend[hovered] : undefined;
const pct = capacity > 0 ? Math.min(100, (sentToday / capacity) * 100) : 0;
return (
<Link
to="/app/analytics"
className="group block mx-2 mt-2 mb-3 rounded-md bg-white/80 hover:bg-white border border-slate-200/70 hover:border-slate-300 px-2.5 py-2 transition-colors"
className="group block mx-2 mt-2 mb-3 rounded-md bg-white/80 hover:bg-white border border-slate-200/70 hover:border-slate-300 pt-2 overflow-hidden transition-colors"
>
<div className="flex items-center gap-1.5">
<span className="relative inline-flex shrink-0">
<span
className={cn(
"w-1.5 h-1.5 rounded-full",
dotClass,
connection === "connecting" && "animate-pulse",
)}
/>
{/* Active mailboxes ping; a quiet-but-connected workspace gets a
slow breathing glow so "READY" reads alive, not stuck. */}
{live && active > 0 ? (
<span className="absolute inset-0 rounded-full bg-emerald-500/40 animate-ping" />
) : live ? (
<span className="absolute -inset-[3px] rounded-full bg-emerald-400/50 status-breathe" />
) : null}
</span>
<span
className={cn(
"text-[10px] uppercase tracking-[0.14em] font-semibold",
labelTone,
)}
>
{label}
</span>
<span
className={cn(
"ml-auto font-mono text-[10px] tabular-nums",
latencyTone,
)}
title={latencyMs != null ? `Websocket roundtrip` : "Not connected"}
>
{latencyMs != null ? `${latencyMs}ms` : "—"}
</span>
</div>
<div className="mt-1.5 flex items-baseline gap-1.5">
<span className="text-[15px] text-slate-900 tabular-nums leading-none">
{mailboxes}
</span>
<span className="text-[11px] text-slate-500">
{mailboxes === 1 ? "mailbox" : "mailboxes"}
</span>
{active > 0 && (
<span className="ml-auto text-[10.5px] text-emerald-600 tabular-nums">
{active} active
</span>
{/* Hero: today's sends against the derived daily cap. While the
sparkline is being scrubbed it shows the hovered day instead. */}
<div className="px-2.5 flex items-baseline gap-1.5 whitespace-nowrap">
{scrub ? (
<>
<span className="text-[19px] font-semibold text-slate-900 leading-none">
{scrub.sent.toLocaleString()}
</span>
<span className="text-[10.5px] text-slate-500">
sent {formatTrendDay(scrub.date)}
</span>
</>
) : (
<>
<AnimatedNumber
value={sentToday}
className="text-[19px] font-semibold text-slate-900 leading-none"
/>
<span className="text-[10.5px] text-slate-500">
{capacity > 0
? `of ${capacity.toLocaleString()} sent today`
: "sent today"}
</span>
</>
)}
</div>
<div className="mt-1.5 flex items-center justify-between gap-2 text-[10.5px]">
<span className="text-slate-400">Inbox</span>
{/* Capacity meter: same-ramp track so the unfilled part still reads
as "room left today", not as a broken bar. */}
<div
className="mt-1.5 px-2.5"
title={
capacity > 0
? `${sentToday} of ${capacity} daily capacity used`
: "Connect a mailbox to start sending"
}
>
<div className="h-1 rounded-full bg-sky-100 overflow-hidden">
<div
className="h-full rounded-full bg-sky-500 transition-[width] duration-700 ease-out"
style={{ width: `${pct}%` }}
/>
</div>
</div>
<Sparkline points={trend} hovered={hovered} onHover={setHovered} />
{/* Glance chips: mailboxes · active senders · unread inbox. Icons
carry the labels (title attrs spell them out) so this stays one
quiet row instead of two label/value text lines. */}
<div className="border-t border-slate-100 px-2.5 py-1.5 flex items-center gap-3 text-[10.5px]">
<span
className="inline-flex items-center gap-1 text-slate-500"
title={`${mailboxes} ${mailboxes === 1 ? "mailbox" : "mailboxes"} connected`}
>
<MailIcon className="w-3 h-3 text-slate-400" />
<span className="font-mono tabular-nums">{mailboxes}</span>
</span>
{active > 0 && (
<span
className="inline-flex items-center gap-1 text-emerald-600"
title={`${active} warming or sending`}
>
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
<span className="font-mono tabular-nums">{active}</span>
</span>
)}
<span
className={cn(
"font-mono tabular-nums",
"ml-auto inline-flex items-center gap-1",
unseenCount > 0 ? "text-sky-600" : "text-slate-400",
)}
title={`${unseenCount} unread in inbox`}
>
{unseenCount > 99 ? "99+" : unseenCount} unread
<InboxIcon className="w-3 h-3" />
<span className="font-mono tabular-nums">
{unseenCount > 99 ? "99+" : unseenCount}
</span>
</span>
</div>
<div className="mt-1 flex items-center justify-between gap-2 text-[10.5px]">
<span className="text-slate-400">Today</span>
<span
className={cn(
"font-mono tabular-nums",
sentToday > 0 ? "text-slate-600" : "text-slate-400",
)}
>
{sentToday}/{capacity || "—"}
</span>
</div>
<Sparkline values={trend} />
</Link>
);
}
/** "2026-08-30" → "Aug 30" for the sparkline scrub readout. */
function formatTrendDay(iso: string): string {
const d = new Date(iso);
return Number.isNaN(d.getTime())
? iso
: d.toLocaleDateString(undefined, { month: "short", day: "numeric", timeZone: "UTC" });
}
// Sparkline geometry. Width matches the card's inner width (sidebar w-64
// minus mx-2 and borders) so preserveAspectRatio="none" barely distorts
// the dots; side padding keeps markers clear of the overflow-hidden edges.
const SPARK_W = 238;
const SPARK_H = 34;
const SPARK_PAD_X = 6;
const SPARK_PAD_TOP = 6;
const SPARK_PAD_BOTTOM = 3;
/**
* Sparkline — 14 thin vertical bars, the last two weeks of send volume
* from the dashboard daily trend, normalized to the busiest day. Days
* with volume render sky; empty days stay a faint slate baseline.
* Sparkline — the last two weeks of send volume as a smooth area line
* (Catmull-Rom smoothing, gradient wash under the stroke, end-of-series
* dot with a surface ring). Full-bleed across the card; the chips row's
* top border underneath doubles as the baseline. Invisible per-day hit
* columns report the hovered day via onHover so the hero number above
* scrubs with the cursor.
*/
function Sparkline({ values }: { values: number[] }) {
const bars = useMemo(() => {
const padded =
values.length >= 14
? values.slice(-14)
: [...Array.from({ length: 14 - values.length }, () => 0), ...values];
const max = Math.max(...padded, 1);
return padded.map((v) => Math.round((v / max) * 100));
}, [values]);
function Sparkline({
points,
hovered,
onHover,
}: {
points: { date: string; sent: number }[];
hovered: number | null;
onHover: (i: number | null) => void;
}) {
const { linePath, areaPath, dots, hasVolume } = useMemo(() => {
const n = points.length;
const baseY = SPARK_H - SPARK_PAD_BOTTOM;
if (n < 2) {
return {
linePath: "",
areaPath: "",
dots: [] as { x: number; y: number }[],
hasVolume: false,
};
}
const max = Math.max(...points.map((p) => p.sent), 1);
const span = SPARK_W - SPARK_PAD_X * 2;
const usable = baseY - SPARK_PAD_TOP;
const pts = points.map((p, i) => ({
x: SPARK_PAD_X + (i / (n - 1)) * span,
y: baseY - (p.sent / max) * usable,
}));
// Catmull-Rom → cubic bezier; control ys are clamped so a spike next
// to a flat run never overshoots the frame.
const clamp = (y: number) =>
Math.min(baseY, Math.max(SPARK_PAD_TOP, y));
let d = `M ${pts[0].x} ${pts[0].y}`;
for (let i = 0; i < n - 1; i++) {
const p0 = pts[i - 1] ?? pts[i];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[i + 2] ?? p2;
const c1x = p1.x + (p2.x - p0.x) / 6;
const c1y = clamp(p1.y + (p2.y - p0.y) / 6);
const c2x = p2.x - (p3.x - p1.x) / 6;
const c2y = clamp(p2.y - (p3.y - p1.y) / 6);
d += ` C ${c1x} ${c1y}, ${c2x} ${c2y}, ${p2.x} ${p2.y}`;
}
return {
linePath: d,
areaPath: `${d} L ${pts[n - 1].x} ${baseY} L ${pts[0].x} ${baseY} Z`,
dots: pts,
hasVolume: points.some((p) => p.sent > 0),
};
}, [points]);
const n = points.length;
const step = n > 1 ? (SPARK_W - SPARK_PAD_X * 2) / (n - 1) : 0;
const hoverDot = hovered != null ? dots[hovered] : undefined;
const endDot = dots[dots.length - 1];
return (
<div className="mt-2 flex items-end gap-0.5 h-4">
{bars.map((v, i) => (
<div
key={i}
className={cn(
"flex-1 rounded-sm transition-colors",
v > 0
? "bg-sky-300 group-hover:bg-sky-400"
: "bg-slate-200 group-hover:bg-slate-300",
)}
style={{ height: `${Math.max(8, v)}%`, minHeight: "2px" }}
<svg
viewBox={`0 0 ${SPARK_W} ${SPARK_H}`}
preserveAspectRatio="none"
aria-hidden
className={cn(
"mt-1 block w-full h-[34px]",
hasVolume ? "text-sky-500" : "text-slate-300",
)}
onMouseLeave={() => onHover(null)}
>
<defs>
<linearGradient id="livepanel-spark-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="currentColor" stopOpacity="0.18" />
<stop offset="100%" stopColor="currentColor" stopOpacity="0.02" />
</linearGradient>
</defs>
{linePath && hasVolume && (
<path d={areaPath} fill="url(#livepanel-spark-fill)" />
)}
{linePath && (
<path
d={linePath}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
))}
</div>
)}
{/* Hover scrub: hairline + marker on the hovered day. */}
{hoverDot && (
<>
<line
x1={hoverDot.x}
y1={SPARK_PAD_TOP - 4}
x2={hoverDot.x}
y2={SPARK_H - SPARK_PAD_BOTTOM}
className="stroke-slate-200"
strokeWidth="1"
vectorEffect="non-scaling-stroke"
/>
<circle
cx={hoverDot.x}
cy={hoverDot.y}
r="3"
fill="currentColor"
className="stroke-white"
strokeWidth="1.5"
/>
</>
)}
{/* End-of-series marker (today), ringed in the surface color. */}
{endDot && hovered == null && (
<circle
cx={endDot.x}
cy={endDot.y}
r="2.5"
fill="currentColor"
className="stroke-white"
strokeWidth="1.5"
/>
)}
{/* Invisible per-day hit columns driving the scrub. */}
{n >= 2 &&
points.map((_, i) => (
<rect
key={i}
x={SPARK_PAD_X + i * step - step / 2}
y={0}
width={step}
height={SPARK_H}
fill="transparent"
onMouseEnter={() => onHover(i)}
/>
))}
</svg>
);
}