diff --git a/site/src/components/scenes/campaigns/EspScene.astro b/site/src/components/scenes/campaigns/EspScene.astro new file mode 100644 index 000000000..da6783c9b --- /dev/null +++ b/site/src/components/scenes/campaigns/EspScene.astro @@ -0,0 +1,53 @@ +--- +import ScaledStage from '../../ScaledStage.astro'; +import Glyph from '../Glyph.astro'; +import { WINDOW, EYEBROW } from '../../../lib/scene'; + +/** + * Settings > ESP matching (preferences/EspCoveragePanel.tsx): how many of the + * campaign's leads are on Google, Microsoft and other providers, and which + * mailboxes serve each, with Prefer same on. + */ +const rows = [ + { mark: 'google', label: 'Google recipients', leads: '742', via: [{ mark: 'google', n: 2 }] }, + { mark: 'microsoft', label: 'Microsoft recipients', leads: '402', via: [{ mark: 'microsoft', n: 1 }] }, +] as const; +--- + +
+
+
+

ESP matching

+

Send Google recipients from Google mailboxes and Microsoft from Microsoft.

+
+ Prefer same +
+
+
Coverage
+ {rows.map((r) => ( +
+ +
+
{r.label} · {r.leads} leads
+
+ via + {r.via.map((v) => ( + ×{v.n} + ))} + ×1any +
+
+ Covered +
+ ))} +
+ +
+
Other providers · 140 leads
+
viaAny mailbox in the pool
+
+ Any +
+
+
+
diff --git a/site/src/components/scenes/campaigns/FlowHeroScene.astro b/site/src/components/scenes/campaigns/FlowHeroScene.astro new file mode 100644 index 000000000..47154c3e2 --- /dev/null +++ b/site/src/components/scenes/campaigns/FlowHeroScene.astro @@ -0,0 +1,230 @@ +--- +import ScaledStage from '../../ScaledStage.astro'; +import Glyph from '../Glyph.astro'; +import Ico from './Ico.astro'; +import { WINDOW, FLOAT, EYEBROW, TAB } from '../../../lib/scene'; + +/** + * A running campaign's Steps tab (web/src/app/app/campaigns/[id]/layout.tsx + * and sequences/CampaignFlow.tsx): the header, the tab bar, and the flow + * canvas with its StepCards, waits beside the lines, branch pills, + buttons + * and zoom controls. Contacts travel the paths while a teammate works on a + * follow-up, and Today's sending plan (SendPlanCard) floats beside it. + */ +const tabs = [ + { label: 'Overview', icon: 'barChart' }, + { label: 'Leads', icon: 'users' }, + { label: 'Steps', icon: 'listChecks', active: true }, + { label: 'Schedule', icon: 'calendar' }, + { label: 'Settings', icon: 'settings2' }, +] as const; + +// Tree coordinates, laid out the way layoutFlow places them: 232x56 cards, +// three columns 264 apart, waits beside the line into a step. +const CW = 232; +const C = [116, 380, 644]; +const TONE = { + sky: 'bg-sky-50 text-sky-600 ring-sky-100', + violet: 'bg-violet-50 text-violet-600 ring-violet-100', + emerald: 'bg-emerald-50 text-emerald-600 ring-emerald-100', +}; +type Card = { x: number; y: number; icon: string; tone: string; title: string; kicker: string; summary: string; peer?: boolean }; +const cards: Card[] = [ + { x: C[1] - CW / 2, y: 0, icon: 'login', tone: TONE.violet, title: 'Contact enters the campaign', kicker: 'Start', summary: 'First email right away' }, + { x: C[1] - CW / 2, y: 84, icon: 'mail', tone: TONE.sky, title: 'Quick question', kicker: 'First email', summary: '3 variants' }, + { x: C[0] - CW / 2, y: 228, icon: 'handshake', tone: TONE.emerald, title: 'Create a deal', kicker: 'Action', summary: 'Stage: Interested' }, + { x: C[0] - CW / 2, y: 320, icon: 'send', tone: TONE.sky, title: 'Notify the team', kicker: 'Action', summary: 'Fires your webhooks' }, + { x: C[1] - CW / 2, y: 228, icon: 'mail', tone: TONE.sky, title: 'Case study', kicker: 'Reply in thread', summary: 'How Ramp cut onboarding' }, + { x: C[2] - CW / 2, y: 228, icon: 'mail', tone: TONE.sky, title: 'Follow-up', kicker: 'Reply in thread', summary: 'Bumping this up', peer: true }, + { x: C[2] - CW / 2, y: 320, icon: 'mail', tone: TONE.sky, title: 'Last note', kicker: 'Reply in thread', summary: 'Closing the loop' }, +]; +const branch = (sx: number, sy: number, tx: number, ty: number) => { + const busY = sy + 20; + if (sx === tx) return `M${sx},${sy} L${tx},${ty}`; + const r = 10; + const dir = tx >= sx ? 1 : -1; + return `M${sx},${sy} L${sx},${busY - r} Q${sx},${busY} ${sx + dir * r},${busY} L${tx - dir * r},${busY} Q${tx},${busY} ${tx},${busY + r} L${tx},${ty}`; +}; +const lines = [ + `M${C[1]},56 L${C[1]},84`, + branch(C[1], 140, C[0], 228), + branch(C[1], 140, C[1], 228), + branch(C[1], 140, C[2], 228), + `M${C[0]},284 L${C[0]},320`, + `M${C[0]},376 L${C[0]},398`, + `M${C[1]},284 L${C[1]},306`, + `M${C[2]},284 L${C[2]},320`, + `M${C[2]},376 L${C[2]},398`, +]; +const pills = [ + { x: C[0], label: 'Replied: positive', instant: true, cls: 'bg-emerald-50 text-emerald-700 ring-emerald-200/80' }, + { x: C[1], label: 'Opened within 3d', instant: true, cls: 'bg-emerald-50 text-emerald-700 ring-emerald-200/80' }, + { x: C[2], label: 'Otherwise', instant: false, cls: 'bg-white text-slate-500 ring-slate-200' }, +]; +const waits = [ + { x: C[2], y: 210, label: '3 days' }, + { x: C[2], y: 302, label: '4 days' }, +]; +const adds: [number, number][] = [[C[0], 409], [C[1], 317], [C[2], 409]]; + +// Dots ride these: most contacts wait for the follow-ups, a few branch off. +const pFollow = `M${C[1]},28 L${C[1]},160 L${C[2]},160 L${C[2]},256 L${C[2]},348 L${C[2]},398`; +const pReply = `M${C[1]},28 L${C[1]},160 L${C[0]},160 L${C[0]},256 L${C[0]},348`; +const pOpen = `M${C[1]},28 L${C[1]},160 L${C[1]},256 L${C[1]},306`; + +// Today's sending plan: four mailboxes whose caps add up to 200, one still +// easing out of warmup, which is the limit that sets the day. +const mailboxes = [ + { addr: 'alex@northwind.dev', sub: 'gap 10m', sent: 23, cap: 50 }, + { addr: 'sam@northwind.io', sub: 'gap 10m', sent: 19, cap: 50 }, + { addr: 'ops@northwind.co', sub: 'gap 10m', sent: 14, cap: 50 }, + { addr: 'mia@northwind.dev', sub: 'easing out of warmup · 3 clean days to 50', sent: 6, cap: 36, of: 50 }, +]; +--- + +
+ +
+
+ Campaigns +
+ Series A founders, Q3 + running + + MC + JR + +
+

cmp_8fK2pQ7rXa19

+
+
+ Pause + +
+
+ +
+ {tabs.map((t) => ( + + {t.label} + {'active' in t && } + + ))} +
+ +
+
+
+ Stop on reply + + 412 in flow · 1,284 leads +
+ +
+
+ + {lines.map((d) => )} + + {pills.map((p) => ( + + {p.label}{p.instant && instant} + + ))} + {waits.map((n) => ( + {n.label} + ))} + {adds.map(([x, y]) => ( + + ))} + {cards.map((c) => ( +
+ +
+
{c.title}
+
{c.kicker} · {c.summary}
+
+
+ ))} + + {[0, 1, 2].map((k) => ( + + ))} + + + + +
+ + Maya +
+
+ +
+ + 100% + + + +
+
+
+
+
+ + +
+
+
+ 186 + today + 62 sent · 124 to go +
+
+

Easing out of warmup is holding it below the 200 your settings allow.

+

Window open until 17:00 · London

+
+
+
4 mailboxes
+
+ {mailboxes.map((m, i) => ( +
+ + {m.addr} + {m.sub} + + {m.sent} / {m.cap}{m.of && of {m.of}} + Sending +
+ ))} +
+
+
+
+ + diff --git a/site/src/components/scenes/campaigns/GuardrailScene.astro b/site/src/components/scenes/campaigns/GuardrailScene.astro new file mode 100644 index 000000000..8a4e7b013 --- /dev/null +++ b/site/src/components/scenes/campaigns/GuardrailScene.astro @@ -0,0 +1,127 @@ +--- +import ScaledStage from '../../ScaledStage.astro'; +import Glyph from '../Glyph.astro'; +import Ico from './Ico.astro'; +import { WINDOW, FLOAT, EYEBROW, TAB } from '../../../lib/scene'; + +/** + * A campaign that stopped itself: the Settings tab's Auto-pause section + * (preferences/CampaignGuardrails) with the reason it tripped, and the live + * activity panel (TaskPreview) where the pause lands in the log. + */ +const tabs = [ + { label: 'Overview', icon: 'barChart' }, + { label: 'Leads', icon: 'users' }, + { label: 'Steps', icon: 'listChecks' }, + { label: 'Schedule', icon: 'calendar' }, + { label: 'Settings', icon: 'settings2', active: true }, +] as const; +const num = (v: string, suffix: string, w: string) => ({ v, suffix, w }); +const rates = [ + { label: 'Pause above bounce rate', ...num('5', '%', 'w-32') }, + { label: 'Pause above complaint rate', ...num('0.1', '%', 'w-32') }, + { label: 'Pause below reply rate', ...num('0', '%', 'w-32') }, +]; +const sample = [ + { label: 'Only after', ...num('50', 'sends', 'w-36') }, + { label: 'Measured over the last', ...num('7', 'days', 'w-36') }, +]; +const reason = 'Paused automatically: the bounce rate reached 5.4% across 312 sends, at or above the 5.0% limit set for this campaign.'; +const log = [ + { dot: 'bg-slate-300', msg: 'Sent Follow-up to dana@orbit.app from sam@northwind.io', t: '14m ago' }, + { dot: 'bg-amber-500', msg: 'Bounced: noah@quanta.dev, mailbox does not exist', t: '11m ago' }, + { dot: 'bg-slate-300', msg: 'Sent Quick question to eli@marlow.co from alex@northwind.dev', t: '9m ago' }, +]; +--- + +
+
+ Series A founders, Q3 + auto-paused + Start +
+
+ {tabs.map((t) => ( + + {t.label} + {'active' in t && } + + ))} +
+ +
+
+ +
+

{reason}

+

Sep 10, 2026, 3:15 PM

+
+
+ +
+
+

Auto-pause

+

Stop this campaign the moment its bounce, complaint, or reply rate leaves the band below. Checked every 15 minutes.

+
+ +
+ +
+ {[rates, sample].map((group) => ( +
+ {group.map((f) => ( +
+
{f.label}
+
+ {f.v} + {f.suffix} + + + + +
+
+ ))} +
+ ))} +

A rate of 0 turns that rule off. A window of 0 days measures the campaign’s whole history.

+
+
+
+ + +
+
+ Live activity + Paused + + Connected + +
+
Recent log
+
+
+ +

{reason}

+ just now +
+ {log.slice(1, 2).map((l) => ( +
+ +

{l.msg}

+ {l.t} +
+ ))} +
+
+
+ + diff --git a/site/src/components/scenes/campaigns/Ico.astro b/site/src/components/scenes/campaigns/Ico.astro new file mode 100644 index 000000000..4792751ba --- /dev/null +++ b/site/src/components/scenes/campaigns/Ico.astro @@ -0,0 +1,56 @@ +--- +import { LUCIDE } from '../../../lib/lucide'; + +/** + * Lucide icons for the campaigns and sending scenes: the shared set in + * lib/lucide.ts plus the few these screens draw that it does not carry. + */ +const EXTRA = { + users: '', + listChecks: '', + settings2: '', + arrowLeft: '', + minus: '', + maximize: '', + trash: '', + eye: '', + pencilLine: '', + handshake: '', + trophy: '', + coffee: '', + sunrise: '', + sunset: '', + timer: '', + mailOpen: '', + pointer: '', + cornerUpLeft: '', + userMinus: '', + download: '', + upload: '', + chevronUp: '', + pauseCircle: '', + triangleAlert: '', + star: '', + key: '', + lock: '', + shuffle: '', + list: '', + underline: '', + image: '', + arrowRight: '', + copy: '', + calendarClock: '', +} as const; + +type Name = keyof typeof EXTRA | keyof typeof LUCIDE; +interface Props { + name: Name; + size?: number; + class?: string; + /** Stroke width, 2 by default as lucide draws it. */ + stroke?: number; +} +const { name, size = 14, class: className = '', stroke = 2 } = Astro.props; +const body = (EXTRA as Record)[name] ?? (LUCIDE as Record)[name] ?? ''; +--- + diff --git a/site/src/components/scenes/campaigns/LeadsScene.astro b/site/src/components/scenes/campaigns/LeadsScene.astro new file mode 100644 index 000000000..273ad334b --- /dev/null +++ b/site/src/components/scenes/campaigns/LeadsScene.astro @@ -0,0 +1,226 @@ +--- +import ScaledStage from '../../ScaledStage.astro'; +import Glyph from '../Glyph.astro'; +import Ico from './Ico.astro'; +import { WINDOW, FLOAT, EYEBROW, TAB, ACTION_GHOST, ACTION_PRIMARY } from '../../../lib/scene'; + +/** + * A campaign's Leads tab (ContactsTable in its campaign_leads view, columns + * from contacts/columns.tsx): linked segments, the status strip with its + * filter chips, and each lead's progress, engagement, current step and the + * one mailbox that sends their whole sequence. A held lead's panel floats + * over the list. + */ +const tabs = [ + { label: 'Overview', icon: 'barChart' }, + { label: 'Leads', icon: 'users', active: true }, + { label: 'Steps', icon: 'listChecks' }, + { label: 'Schedule', icon: 'calendar' }, + { label: 'Settings', icon: 'settings2' }, +] as const; + +type Status = 'active' | 'completed' | 'replied' | 'pending' | 'bounced' | 'paused' | 'unsubscribed'; +const META: Record = { + pending: { label: 'Queued', text: 'text-slate-500', icon: 'clock' }, + active: { label: 'Processing', text: 'text-sky-700' }, + completed: { label: 'Done', text: 'text-indigo-700', icon: 'check' }, + replied: { label: 'Replied', text: 'text-emerald-700', icon: 'cornerUpLeft' }, + bounced: { label: 'Bounced', text: 'text-rose-600', icon: 'triangleAlert' }, + paused: { label: 'Paused', text: 'text-violet-600', icon: 'pause' }, + unsubscribed: { label: 'Unsubscribed', text: 'text-slate-400', icon: 'x' }, +}; +type Lead = { + name: string; email: string; company: string; status: Status; init: string; tint: string; + o?: number | 'auto'; c?: number; r?: number; step?: string; sender?: string; last: string; sent: boolean; +}; +const leads: Lead[] = [ + { name: 'Jenna Carter', email: 'jenna@acme.io', company: 'Acme', status: 'replied', init: 'JC', tint: 'bg-emerald-100 text-emerald-700', o: 2, c: 1, r: 1, step: 'Follow-up', sender: 'alex@northwind.dev', last: '4m ago', sent: true }, + { name: 'Ravi Shah', email: 'ravi@stratech.com', company: 'Stratech', status: 'active', init: 'RS', tint: 'bg-sky-100 text-sky-700', o: 1, step: 'Quick question', sender: 'sam@northwind.io', last: '12m ago', sent: true }, + { name: 'Lina Park', email: 'lina@helio.so', company: 'Helio', status: 'paused', init: 'LP', tint: 'bg-violet-100 text-violet-700', o: 1, step: 'Follow-up', sender: 'ops@northwind.co', last: '2h ago', sent: true }, + { name: 'Tom Weber', email: 'tom@kraftwerk.de', company: 'Kraftwerk', status: 'active', init: 'TW', tint: 'bg-amber-100 text-amber-700', o: 'auto', step: 'Quick question', sender: 'mia@northwind.dev', last: '38m ago', sent: true }, + { name: 'Aman Patel', email: 'aman@ledgerly.com', company: 'Ledgerly', status: 'completed', init: 'AP', tint: 'bg-indigo-100 text-indigo-700', o: 3, c: 1, step: 'Last note', sender: 'alex@northwind.dev', last: 'Sep 3', sent: true }, + { name: 'Sofia Rossi', email: 'sofia@verde.it', company: 'Verde', status: 'pending', init: 'SR', tint: 'bg-slate-100 text-slate-600', last: 'Sep 9', sent: false }, + { name: 'Noah Brooks', email: 'noah@quanta.dev', company: 'Quanta', status: 'bounced', init: 'NB', tint: 'bg-rose-100 text-rose-700', step: 'Quick question', sender: 'sam@northwind.io', last: 'Sep 8', sent: true }, + { name: 'Karim Salah', email: 'karim@fieldnote.co', company: 'Fieldnote', status: 'unsubscribed', init: 'KS', tint: 'bg-slate-100 text-slate-500', o: 1, step: 'Follow-up', sender: 'ops@northwind.co', last: 'Sep 8', sent: true }, + { name: 'Maya Lopez', email: 'maya@brightline.io', company: 'Brightline', status: 'pending', init: 'ML', tint: 'bg-slate-100 text-slate-600', last: 'Sep 9', sent: false }, +]; +const chips = [ + { dot: 'bg-sky-500', label: 'Processing', n: '412', loader: true }, + { dot: 'bg-indigo-500', label: 'Done', n: '96' }, + { dot: 'bg-emerald-500', label: 'Replied', n: '61' }, + { dot: 'bg-slate-300', label: 'Queued', n: '640' }, + { dot: 'bg-rose-400', label: 'Bounced', n: '9' }, + { dot: 'bg-violet-400', label: 'Paused', n: '14' }, + { dot: 'bg-slate-300', label: 'Unsub', n: '52' }, +]; +const eng = [ + { icon: 'mailOpen', label: 'Opened', n: '380' }, + { icon: 'pointer', label: 'Clicked', n: '72' }, + { icon: 'cornerUpLeft', label: 'Replied', n: '61' }, +] as const; +// The status bar's shares of the 1,284 leads. +const bar = [ + ['bg-sky-500', 412], ['bg-indigo-500', 96], ['bg-emerald-500', 61], ['bg-slate-300', 640], + ['bg-rose-400', 9], ['bg-violet-400', 14], ['bg-slate-200', 52], +] as const; +const th = 'px-3 py-2 text-[10px] font-medium text-slate-400 uppercase tracking-[0.14em] text-left'; +const muted = (s: Status) => s === 'replied' || s === 'bounced' || s === 'unsubscribed'; +--- + +
+
+ Campaigns + / + Series A founders, Q3 + running +
+
+ {tabs.map((t) => ( + + {t.label} + {'active' in t && } + + ))} +
+ + +
+
+ Search leads… +
+ Filters +
+ From contacts + Import + Segments (2) + Export + Add lead +
+
+ + +
+ Linked segments + {[['Series A, EU', '742/742'], ['Founders hiring sales', '542/542']].map(([n, c]) => ( + + {n}{c} + + + ))} +
+ + +
+
+ {bar.map(([c, n]) =>
)} +
+
+ {chips.map((s) => ( + + {s.loader ? : } + {s.label}{s.n} + + ))} + + {eng.map((e) => ( + {e.label}{e.n} + ))} +
+
+ + Live + + 1,284 leads +
+
+ + + + + + + + + + + + + + + + + + {leads.map((l) => { + const m = META[l.status]; + const dim = muted(l.status); + const engCell = (v: number | 'auto' | undefined, icon: string) => + typeof v === 'number' && v > 0 + ? {v} + : v === 'auto' + ? auto + : l.sent ? — : null; + return ( + + + + + + + + + + + + + ); + })} + +
NameCompanyProgressOpenedClickedRepliedCurrent stepSenderLast activity
+
+ {l.init} +
+
{l.name}
+
{l.email}
+
+
+
{l.company} + + {m.icon ? : }{m.label} + + {engCell(l.o, 'mailOpen')}{engCell(l.c, 'pointer')}{engCell(l.r, 'cornerUpLeft')} + {l.step + ? {l.step} + : Not started} + + {l.sender ? {l.sender} : Not assigned} + {l.last}
+
+ + +
+
+ LP +
+
Lina Park
+
Series A founders, Q3 · via ops@northwind.co
+
+ Paused +
+
+
Out of office · until Sep 15, 9:00 AM
+
+ Automatic reply: “I’m away and back on 14 September.” +
+
Her next step keeps its wait, counted from the day she is back.
+
+
+ Resume now + Stop +
+
+
+ + diff --git a/site/src/components/scenes/campaigns/LimitsScene.astro b/site/src/components/scenes/campaigns/LimitsScene.astro new file mode 100644 index 000000000..e85d12a94 --- /dev/null +++ b/site/src/components/scenes/campaigns/LimitsScene.astro @@ -0,0 +1,47 @@ +--- +import ScaledStage from '../../ScaledStage.astro'; +import Glyph from '../Glyph.astro'; +import Ico from './Ico.astro'; +import { WINDOW, EYEBROW } from '../../../lib/scene'; + +/** + * Today's sending plan opened to "From your settings to today" (SendPlanCard): + * the mailbox caps added up, every limit that took something off, and what + * is still to go. The numbers add up the way the card's do. + */ +const limits = [ + { label: 'Campaign daily limit', n: '6 mailboxes', v: 60 }, + { label: 'Easing out of warmup', n: '1 mailbox', v: 15 }, + { label: 'Used by other campaigns', n: '2 mailboxes', v: 22 }, + { label: 'Minimum gap between sends', n: '1 mailbox', v: 9 }, +]; +--- + +
+
+ + 194 + today + + + 87 sent · 107 to go + Why +
+
+
From your settings to today
+
+
Mailbox caps added up300
+ {limits.map((l, i) => ( +
+ {l.label} + {l.n} + −{l.v} +
+ ))} +
Sent today−87
+
Still to go today107
+
+
Leads: 88 due now · 140 later today · 312 waiting on a step
+
+
+
diff --git a/site/src/components/scenes/campaigns/RotationScene.astro b/site/src/components/scenes/campaigns/RotationScene.astro new file mode 100644 index 000000000..3e002d70e --- /dev/null +++ b/site/src/components/scenes/campaigns/RotationScene.astro @@ -0,0 +1,60 @@ +--- +import ScaledStage from '../../ScaledStage.astro'; +import Glyph from '../Glyph.astro'; +import { WINDOW, EYEBROW } from '../../../lib/scene'; + +/** + * The campaign's Settings: Sending accounts by tag, and Inbox rotation as the + * OptionSelect draws it (preferences/CampaignEmails.tsx), Even spacing picked. + */ +const options = [ + { label: 'Even spacing', hint: 'Recommended. Always picks the mailbox that has been idle longest.', on: true }, + { label: 'Round-robin', hint: 'Cycles through your mailboxes in order (A, B, C, A).' }, + { label: 'Weighted', hint: 'Sends more from your healthiest, higher-limit mailboxes.' }, +]; +const boxes = [ + { mark: 'google', addr: 'alex@northwind.dev' }, + { mark: 'microsoft', addr: 'sam@northwind.io' }, + { mark: 'imap', addr: 'ops@northwind.co' }, +] as const; +--- + +
+
+
+ Sending accounts + tag: outbound +
+
+ {boxes.map((b, i) => ( + {b.addr} + ))} +
+
+
+

Inbox rotation

+

Picks the mailbox for a lead’s first email. Every follow-up comes from the same address.

+
+ {options.map((o) => ( +
+ + {o.label} + {o.hint} + + +
+ ))} +
+
+
+
+ + diff --git a/site/src/components/scenes/campaigns/ScheduleScene.astro b/site/src/components/scenes/campaigns/ScheduleScene.astro new file mode 100644 index 000000000..88d71b4cd --- /dev/null +++ b/site/src/components/scenes/campaigns/ScheduleScene.astro @@ -0,0 +1,109 @@ +--- +import ScaledStage from '../../ScaledStage.astro'; +import Glyph from '../Glyph.astro'; +import Ico from './Ico.astro'; +import { WINDOW, FLOAT, EYEBROW } from '../../../lib/scene'; + +/** + * The Schedule tab (schedule/page.tsx with WeekScheduleGrid): weekly sending + * windows, each day on its own, in the campaign's timezone. The First email + * setting (CampaignFirstEmail with EntryDelayPicker) floats over it. + */ +const DAY = 1440; +const ABBR = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; +const HOURS = [0, 6, 9, 12, 15, 18, 24]; +const windows: [number, number][][] = [ + [[540, 720], [780, 1020]], + [[540, 1020]], + [[510, 720], [840, 1080]], + [[540, 1020]], + [[540, 900]], + [], + [], +]; +const BODY = 300; +const GUTTER = 40; +const y = (m: number) => (m / DAY) * BODY; +const label = (h: number) => (h === 0 || h === 24 ? '12a' : h === 12 ? '12p' : h < 12 ? `${h}a` : `${h - 12}p`); +const fmt = (m: number) => { + const h = Math.floor(m / 60), mm = m % 60; + const h12 = h % 12 === 0 ? 12 : h % 12; + return `${h12}${mm ? `:${String(mm).padStart(2, '0')}` : ''}${h < 12 ? 'am' : 'pm'}`; +}; +--- + +
+
+ +
+
Weekly sending windows
+

7 windows across 5 days

+
+ + (UTC+01:00) Europe/London + +
+
+ Presets + {['Mon–Fri 9–5', 'Every day 9–5', 'Clear'].map((p) => {p})} +
+
+
+
+
+ {ABBR.map((d, i) => ( +
+ {d}{i === 2 && } +
+ ))} +
+
+
+ {HOURS.map((h) => ( + <> + {label(h)} +
+ + ))} +
+ {ABBR.map((_, i) => ( +
+ {windows[i].length === 0 && drag, or tap +} + {windows[i].map(([s, e]) => ( +
+ {fmt(s)}
{fmt(e)}
+
+ ))} +
+ ))} +
+
+

Each day is independent. Set different windows per day, or several in one day.

+
+
+ + +
+
+

First email

+ After a wait + 2 days +
+
+
+ + Contact joins + Added to the campaign +
+
+
+ waits 2 days +
+
+ + First email + In the next sending window +
+
+
+
diff --git a/site/src/components/scenes/campaigns/StepEditorScene.astro b/site/src/components/scenes/campaigns/StepEditorScene.astro new file mode 100644 index 000000000..ac241c729 --- /dev/null +++ b/site/src/components/scenes/campaigns/StepEditorScene.astro @@ -0,0 +1,136 @@ +--- +import ScaledStage from '../../ScaledStage.astro'; +import Glyph from '../Glyph.astro'; +import Ico from './Ico.astro'; +import { WINDOW, FLOAT, EYEBROW } from '../../../lib/scene'; + +/** + * An email step's editor with an A/B test running (sequences/StepEmailArms, + * StepSplitAllocator, the VariantEditor's stats row, EmailContentEditor and + * ContentScore): the traffic-split bar, Variant B selected with its numbers + * and the winner mark, its subject and body with merge fields. + */ +const arms = [ + { name: 'Original', pct: 34, color: 'bg-sky-500' }, + { name: 'Variant B', pct: 33, color: 'bg-violet-500', selected: true }, + { name: 'Variant C', pct: 33, color: 'bg-emerald-500' }, +]; +const tools = ['bold', 'italic', 'underline', '|', 'list', 'link', 'image', '|'] as const; +--- + +
+
+ Edit “Quick question” + + Delete + + +
+ +
+
+ +
+
+ {arms.map((a) => ( + + {a.name} {a.pct}% + + ))} + + +
+ Even + Variant +
+ + +
+
+
+
Variant name
+
Variant B
+
+ 33% of contacts + + + Save +
+ +
+ Winner + Sent 412 + Open 61.2% + Reply 8.4% + Bounce 0.7% +
+ +
+
+ Subject + +
+
Saw Company is hiring in Berlin
+
+ +
+
+ Body + + Edit + Preview + +
+
+
+ {tools.map((t) => t === '|' + ? + : + )} + + AI + + +
+
+

Hi First name,

+

Noticed Company posted six sales roles in Berlin this month. Teams that grow outbound that fast usually find their sender reputation is the first thing to crack.

+

We keep it healthy while you scale. Worth 15 minutes next week to compare notes?

+

Sender name

+
+
+
+
+
+
+
+ + +
+
+
Content check
+

Advisory deliverability score. It never blocks sending.

+
+
+
+ 84 + / 100 + Looks good +
+
+

No content issues found.

+
+
+
+ + diff --git a/site/src/components/scenes/campaigns/ThreadScene.astro b/site/src/components/scenes/campaigns/ThreadScene.astro new file mode 100644 index 000000000..b8edbe602 --- /dev/null +++ b/site/src/components/scenes/campaigns/ThreadScene.astro @@ -0,0 +1,144 @@ +--- +import ScaledStage from '../../ScaledStage.astro'; +import Glyph from '../Glyph.astro'; +import Ico from './Ico.astro'; +import { WINDOW, FLOAT, EYEBROW } from '../../../lib/scene'; + +/** + * The contact's own inbox: the first email and the follow-up are one + * conversation, because the follow-up replies on the thread. Her answer + * lands at the bottom on a loop, and the lead's campaign panel (the contact + * drawer's Activity tab) flips from waiting on step three to "Replied, + * sending stopped". + */ +const steps = [ + { name: 'Quick question', kind: 'Email 1', marks: ['Sent', 'Opened'] }, + { name: 'Follow-up', kind: 'Email 2 · reply in thread', marks: ['Sent', 'Opened'] }, + { name: 'Last note', kind: 'Email 3 · reply in thread', marks: [] }, +]; +--- + +
+ +
+ + + + + + + + 3 of 1,204 +
+ +
+
+ Quick question about Acme + Inbox +
+ +
+ +
+ A + Alex Kim + Hi Jenna, saw Acme just opened a Berlin office. Teams growing that fast… + Mon, Sep 7 +
+ + +
+
+ A +
+
+ Alex Kim + <alex@northwind.dev> +
+
to me
+
+ Thu, Sep 10, 9:40 AM + +
+
+

Hi Jenna,

+

Following up in case this got buried. Happy to share how two teams your size kept outbound landing while they doubled their sending.

+

Worth a quick call this week?

+

Alex

+
+
+ + +
+
+ J +
+
+ me +
+
to Alex
+
+ 11:03 AM +
+
Thursday 10am ET works for me. Send an invite over?
+
+
+
+
+ + +
+
+
+
Series A founders, Q3
+
via alex@northwind.dev
+
+ + Processing + Replied + +
+
+
Flow
+ {steps.map((s, i) => ( +
+ +
+
{s.name}
+
{s.kind}
+
+ + {s.marks.map((m) => {m})} + {i === 1 && Replied} + +
+ ))} +
+
+ Waiting 4 days after Follow-up + Replied, sending stopped +
+
+
+ + diff --git a/site/src/components/scenes/deliverability/AdvisorScene.astro b/site/src/components/scenes/deliverability/AdvisorScene.astro new file mode 100644 index 000000000..32399f419 --- /dev/null +++ b/site/src/components/scenes/deliverability/AdvisorScene.astro @@ -0,0 +1,148 @@ +--- +import ScaledStage from '../../ScaledStage.astro'; +import Glyph from '../Glyph.astro'; +import Ico from '../warmup/Ico.astro'; +import { WINDOW, EYEBROW, TOPBAR } from '../../../lib/scene'; + +/** + * The Advisor on the Accounts page (AdvisorSummaryBar + AdvisorCard): one + * summary line, expanded because something is critical, then a card per + * finding with its fix. The finding with a one-click fix is open in the fix + * drawer (AdvisorFixDrawer) on its "What changes" step. Titles, details and + * remedies are the detectors' own wording (internal/app/advisor). + */ +type Sev = 'critical' | 'high' | 'medium' | 'low'; +const CHIP: Record = { + critical: 'bg-rose-500/10 text-rose-700 border-rose-500/20', + high: 'bg-orange-500/10 text-orange-700 border-orange-500/20', + medium: 'bg-amber-500/10 text-amber-700 border-amber-500/25', + low: 'bg-sky-500/10 text-sky-700 border-sky-500/20', +}; +const DOT: Record = { critical: 'bg-rose-500', high: 'bg-orange-500', medium: 'bg-amber-500', low: 'bg-sky-500' }; +const LABEL: Record = { critical: 'Critical', high: 'Needs attention', medium: 'Worth fixing', low: 'Suggestion' }; +const cards: { sev: Sev; title: string; detail: string; action: string; auto?: boolean }[] = [ + { sev: 'critical', title: 'sales@brightco.io is missing DMARC', detail: 'The sending domain for sales@brightco.io has no valid DMARC record. Google\'s bulk sender rules require SPF, DKIM, and DMARC to be aligned.', action: 'How to fix' }, + { sev: 'high', title: 'lea@northwind.io is landing in spam 14% of the time', detail: 'Over the last 7 days 7 of 50 warmup messages from lea@northwind.io delivered at Google, Microsoft and Yahoo went to spam.', action: 'Fix' }, + { sev: 'medium', title: 'ops@northwind.co has no verified tracking domain', detail: 'ops@northwind.co is sending cold campaigns without its own verified tracking domain, so open pixels and click links go out on a shared host.', action: 'How to fix' }, + { sev: 'low', title: 'growth@acme.co is capped above the safe band', detail: 'growth@acme.co is capped at 80 cold emails a day, above the 50/day safe band.', action: 'Fix', auto: true }, +]; +--- + +
+
+ Accounts + + 12 mailboxes +
+
+
+
+ + 4 mailboxes need urgent attention · each one is flagged on its row + + {([['critical', 2], ['high', 1], ['medium', 1], ['low', 1]] as [Sev, number][]).map(([s, n]) => ( + {n} + ))} + + Hide +
+
+ +
+
+ + sales@brightco.io is bouncing 6.2% of its sends + How to fix + + +
+
+

31 of 500 sends from sales@brightco.io bounced in the last 30 days. Amazon SES asks senders to stay under 5% and can pause sending at 10%; mailbox providers read a high bounce rate as a sender who does not know who they are mailing.

+
+

What to do

+

This is a list problem, not a mailbox problem. Verify the contacts in the campaigns this mailbox sends for before sending more, and remove the domains that bounce repeatedly.

+
+
+

What we measured

+
+ {[['Bounce rate', '6.2%'], ['Sends, 30 days', '500'], ['Bounces, 30 days', '31']].map(([k, v]) => ( + {k}{v} + ))} +
+
+
+
+ Critical + Deliverability +
+ Useful? +
+
+
+ {cards.map((c, i) => ( +
+ +
+
+ {c.title} + {c.auto && Auto} +
+

{c.detail}

+
+ {c.action} + + +
+ ))} +
+
+
+
+ + +
+
+
+
+ {LABEL.low} + growth@acme.co +
+

growth@acme.co is capped above the safe band

+
+ +
+
+ {['Why', 'What changes', 'Done'].map((s, i) => ( +
+
+ {s} +
+ ))} +
+
+
+

Daily cold cap

+
+ 80/day + + 50/day +
+
+

Bring the cap back to 50/day unless you have reviewed this mailbox's complaint and placement numbers specifically and decided to carry the risk.

+

Undo stays available after it is applied.

+
+
+ Back + Set the cap to 50/day +
+
+
+ + diff --git a/site/src/components/scenes/deliverability/DomainsScene.astro b/site/src/components/scenes/deliverability/DomainsScene.astro new file mode 100644 index 000000000..3e8ff5be0 --- /dev/null +++ b/site/src/components/scenes/deliverability/DomainsScene.astro @@ -0,0 +1,141 @@ +--- +import ScaledStage from '../../ScaledStage.astro'; +import Glyph from '../Glyph.astro'; +import Ico from '../warmup/Ico.astro'; +import { WINDOW, FLOAT, EYEBROW } from '../../../lib/scene'; + +/** + * Sending domains (web/src/app/app/emails/domains/page.tsx): authentication, + * tracking host and website redirect for every domain the workspace sends + * from. A failing domain is open in the drawer, with the DMARC record the + * Advisor hands over to paste at the registrar. + */ +type Tone = 'emerald' | 'amber' | 'rose' | 'slate'; +const PILL: Record = { + emerald: { pill: 'bg-emerald-50 text-emerald-700 ring-emerald-200/70', dot: 'bg-emerald-500' }, + amber: { pill: 'bg-amber-50 text-amber-700 ring-amber-200/70', dot: 'bg-amber-500' }, + rose: { pill: 'bg-rose-50 text-rose-700 ring-rose-200/70', dot: 'bg-rose-500' }, + slate: { pill: 'bg-slate-50 text-slate-500 ring-slate-200', dot: 'bg-slate-300' }, +}; +type Pill = [Tone, string]; +const domains: { d: string; mark: 'google' | 'microsoft' | 'imap'; n: number; auth: Pill; track: Pill; redirect: Pill; open?: boolean }[] = [ + { d: 'northwind.dev', mark: 'google', n: 5, auth: ['emerald', 'Authenticated'], track: ['emerald', 'link.northwind.dev'], redirect: ['emerald', 'northwind.com'] }, + { d: 'northwind.io', mark: 'microsoft', n: 3, auth: ['emerald', 'Authenticated'], track: ['emerald', 'link.northwind.io'], redirect: ['emerald', 'northwind.com'] }, + { d: 'brightco.io', mark: 'google', n: 2, auth: ['rose', 'Auth failing'], track: ['amber', 'Waiting for DNS'], redirect: ['slate', 'None'], open: true }, + { d: 'helio.gen', mark: 'microsoft', n: 2, auth: ['emerald', 'Authenticated'], track: ['emerald', 'link.helio.gen'], redirect: ['emerald', 'helio.com'] }, + { d: 'acme.co', mark: 'google', n: 4, auth: ['emerald', 'Authenticated'], track: ['amber', '2 of 4 mailboxes'], redirect: ['emerald', 'acme.com'] }, + { d: 'northwind.co', mark: 'imap', n: 1, auth: ['slate', 'Not checked'], track: ['slate', 'Shared host'], redirect: ['slate', 'None'] }, +]; +const stats = [ + { label: 'Domains', value: '6', sub: 'sending mail' }, + { label: 'Mailboxes', value: '17', sub: 'across 6 domains' }, + { label: 'Authenticated', value: '4', of: 6, tone: 'bg-emerald-500' }, + { label: 'Tracking verified', value: '3', of: 6, tone: 'bg-sky-500' }, +]; +const grid = 'grid grid-cols-[14px_minmax(0,1.5fr)_64px_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_14px] items-center gap-x-3'; +const auth = [ + { k: 'SPF', state: 'Found', on: true, what: 'Lists the servers allowed to send for the domain.' }, + { k: 'DKIM', state: 'Not confirmed', on: false, what: 'Signs each message so it cannot be altered on the way.' }, + { k: 'DMARC', state: 'Not found', on: false, missing: true, what: 'Tells receivers what to do with mail that fails the other two.' }, +]; +--- + +
+
+ Accounts + + Sending domains + Authentication, tracking and redirects +
+
+ {stats.map((s) => ( +
+
{s.label}
+
+ {s.value} + {s.of && of {s.of}} +
+ {s.of + ?
+ :
{s.sub}
} +
+ ))} +
+
+ {[['All', '6', true], ['Needs attention', '3', false], ['Vendor managed', '0', false]].map(([l, n, a]) => ( + + {l} + {n} + {a && } + + ))} +
+
+ + {['Domain', 'Mailboxes', 'Authentication', 'Tracking', 'Redirect'].map((h, i) => {h})} + +
+ {domains.map((d) => ( +
+ +
+ + {d.d} +
+ {d.n} + {[d.auth, d.track, d.redirect].map(([t, label]) => ( + + {label} + + ))} + +
+ ))} +
+ + +
+
+ +
+
brightco.io
+
2 mailboxes · Google Workspace
+
+ Auth failing +
+
+
Authentication
+
+ {auth.map((a) => ( +
+ + {a.on ? : } + +
+
{a.k}{a.state}
+

{a.what}

+
+
+ ))} +
+
Paste this
+
+ {[['DMARC record type', 'TXT'], ['DMARC host', '_dmarc.brightco.io'], ['DMARC value', 'v=DMARC1; p=none; rua=mailto:dmarc@brightco.io']].map(([k, v], i) => ( +
+ {k} + {v} + +
+ ))} +
+
+
+
+ + diff --git a/site/src/components/scenes/deliverability/HealthScene.astro b/site/src/components/scenes/deliverability/HealthScene.astro new file mode 100644 index 000000000..a956390a3 --- /dev/null +++ b/site/src/components/scenes/deliverability/HealthScene.astro @@ -0,0 +1,80 @@ +--- +import ScaledStage from '../../ScaledStage.astro'; +import Glyph from '../Glyph.astro'; +import Ico from '../warmup/Ico.astro'; +import { WINDOW, FLOAT, EYEBROW, SECTIONBAR, STAT, STAT_VALUE, STAT_SUB } from '../../../lib/scene'; + +/** + * Deliverability > Warmup inbox placement (WarmupPlacementSection.tsx): the + * workspace's numbers, then every mailbox ranked worst first with its daily + * rate, spam count and band. A mailbox on watch is open in a small card. + */ +type Band = 'good' | 'fair' | 'poor'; +const BAND: Record = { + good: { label: 'Healthy', chip: 'bg-emerald-50 text-emerald-700 border-emerald-200', dot: 'bg-emerald-500', text: 'text-emerald-600', stroke: '#10b981' }, + fair: { label: 'Watch', chip: 'bg-amber-50 text-amber-700 border-amber-200', dot: 'bg-amber-500', text: 'text-amber-600', stroke: '#f59e0b' }, + poor: { label: 'Landing in spam', chip: 'bg-rose-50 text-rose-700 border-rose-200', dot: 'bg-rose-500', text: 'text-rose-600', stroke: '#f43f5e' }, +}; +const rows: { email: string; rate: number; band: Band; obs: number; spam: number; spark: number[] }[] = [ + { email: 'dev@sunriselabs.io', rate: 77, band: 'poor', obs: 48, spam: 11, spark: [94, 92, 90, 88, 85, 83, 80, 79, 77] }, + { email: 'lea@northwind.io', rate: 86, band: 'fair', obs: 50, spam: 7, spark: [96, 95, 94, 92, 90, 89, 88, 87, 86] }, + { email: 'ml@helio.gen', rate: 88.5, band: 'fair', obs: 61, spam: 7, spark: [84, 85, 85, 86, 87, 87, 88, 88, 88.5] }, + { email: 'ops@northwind.co', rate: 92, band: 'good', obs: 74, spam: 6, spark: [90, 91, 90, 92, 91, 92, 93, 92, 92] }, + { email: 'hello@helio.gen', rate: 94, band: 'good', obs: 88, spam: 5, spark: [93, 94, 93, 94, 95, 94, 94, 93, 94] }, + { email: 'sam@northwind.io', rate: 95, band: 'good', obs: 92, spam: 5, spark: [95, 94, 96, 95, 95, 96, 95, 95, 95] }, + { email: 'sales@brightco.io', rate: 96, band: 'good', obs: 101, spam: 4, spark: [97, 96, 96, 95, 96, 97, 96, 96, 96] }, + { email: 'alex@northwind.dev', rate: 97, band: 'good', obs: 104, spam: 3, spark: [96, 97, 97, 98, 97, 97, 98, 97, 97] }, +]; +const sparkPath = (v: number[]) => v.map((y, i) => `${i ? 'L' : 'M'} ${(i / (v.length - 1)) * 64} ${18 - ((y - 70) / 30) * 16}`).join(' '); +--- + +
+
+ Warmup inbox placement + + All + Google + Microsoft + Yahoo + +
+
+
Non-spam share · 7 days
93.6%
Healthy
+
Classified receipts
1204
of 1,262 submissions
+
Spam
77
6.4% · at the big three only
+
+
+ Mailboxes by inbox rate + 12 + Below 90% only · 3 +
+ {rows.map((m, i) => ( +
+ + {m.email} + + {m.obs} obs + {m.spam} spam + {m.rate}% + {BAND[m.band].label} + +
+ ))} +
+ + +
+
+ +
+
dev@sunriselabs.io is throttled
+
23% spam at Google, Microsoft and Yahoo
+
+
+
+
Warmup
Half volume
+
Cold sending
Half volume
+
+
Lifts on its own below 15%.
+
+
diff --git a/site/src/components/scenes/deliverability/PlacementHeroScene.astro b/site/src/components/scenes/deliverability/PlacementHeroScene.astro new file mode 100644 index 000000000..1957b72fe --- /dev/null +++ b/site/src/components/scenes/deliverability/PlacementHeroScene.astro @@ -0,0 +1,221 @@ +--- +import HeroStage from '../warmup/HeroStage.astro'; +import AppShellMock from '../../AppShellMock.astro'; +import Glyph from '../Glyph.astro'; +import Ico from '../warmup/Ico.astro'; +import { FLOAT, EYEBROW, SECTIONBAR } from '../../../lib/scene'; + +/** + * A finished placement test (web/src/app/app/placement/[id]/page.tsx) inside + * the app shell: the folder breakdown, the result per provider family and + * every seed's verdict, which arrive one by one the first time the scene is + * seen. The notification the test sends when it finishes floats over the nav. + */ +type Folder = 'inbox' | 'promotions' | 'spam' | 'missing'; +const FOLDER: Record = { + inbox: { label: 'Inbox', chip: 'bg-emerald-50 text-emerald-700 border-emerald-200', dot: 'bg-emerald-500' }, + promotions: { label: 'Promotions', chip: 'bg-violet-50 text-violet-700 border-violet-200', dot: 'bg-violet-500' }, + spam: { label: 'Spam', chip: 'bg-rose-50 text-rose-700 border-rose-200', dot: 'bg-rose-500' }, + missing: { label: 'Not observed by timeout', chip: 'bg-slate-100 text-slate-700 border-slate-200', dot: 'bg-slate-500' }, +}; +// 20 copies: 16 inbox, 2 Promotions, 1 spam, 1 never arrived. +const families = [ + { name: 'Gmail', inbox: 3, tabs: 1, spam: 0, missing: 0 }, + { name: 'Google Workspace', inbox: 3, tabs: 1, spam: 0, missing: 0 }, + { name: 'Outlook.com', inbox: 2, tabs: 0, spam: 1, missing: 0 }, + { name: 'Microsoft 365', inbox: 3, tabs: 0, spam: 0, missing: 0 }, + { name: 'Yahoo Mail', inbox: 2, tabs: 0, spam: 0, missing: 1 }, + { name: 'iCloud', inbox: 3, tabs: 0, spam: 0, missing: 0 }, +]; +const seeds: { seed: string; family: string; seen: string; folder: Folder }[] = [ + { seed: 'a***@gmail.com', family: 'Gmail', seen: 'seen Oct 8, 2:16 PM', folder: 'inbox' }, + { seed: 'm***@outlook.com', family: 'Outlook.com', seen: 'seen Oct 8, 2:17 PM', folder: 'inbox' }, + { seed: 'r***@lumenhq.com', family: 'Google Workspace', seen: 'seen Oct 8, 2:18 PM', folder: 'inbox' }, + { seed: 't***@yahoo.com', family: 'Yahoo Mail', seen: 'seen Oct 8, 2:19 PM', folder: 'inbox' }, + { seed: 'k***@contoso-group.com', family: 'Microsoft 365', seen: 'seen Oct 8, 2:20 PM', folder: 'inbox' }, + { seed: 'e***@icloud.com', family: 'iCloud', seen: 'seen Oct 8, 2:22 PM', folder: 'inbox' }, + { seed: 's***@gmail.com', family: 'Gmail', seen: 'seen Oct 8, 2:23 PM', folder: 'promotions' }, + { seed: 'd***@hotmail.com', family: 'Outlook.com', seen: 'seen Oct 8, 2:24 PM', folder: 'spam' }, + { seed: 'p***@fieldworks.io', family: 'Google Workspace', seen: 'seen Oct 8, 2:25 PM', folder: 'inbox' }, + { seed: 'l***@yahoo.com', family: 'Yahoo Mail', seen: 'sent Oct 8, 2:26 PM', folder: 'missing' }, + { seed: 'b***@northpeak.co', family: 'Microsoft 365', seen: 'seen Oct 8, 2:28 PM', folder: 'inbox' }, + { seed: 'n***@me.com', family: 'iCloud', seen: 'seen Oct 8, 2:29 PM', folder: 'inbox' }, +]; +const cells = [ + { label: 'Inbox', rate: '80%', tone: 'text-emerald-600', dot: 'bg-emerald-500', sub: '16 copies' }, + { label: 'Gmail tabs', rate: '10%', tone: 'text-violet-600', dot: 'bg-violet-500', sub: '2 copies, 2 Promotions, 0 other tabs' }, + { label: 'Spam', rate: '5%', tone: 'text-rose-600', dot: 'bg-rose-500', sub: '1 copy' }, + { label: 'Not observed', rate: '5%', tone: 'text-slate-600', dot: 'bg-slate-500', sub: '1 copy, Not seen within 2 hours' }, +]; +const pct = (n: number, d: number) => `${Math.round((n / d) * 100)}%`; +const rateTone = (r: number) => (r >= 0.8 ? 'text-emerald-600' : r >= 0.6 ? 'text-amber-600' : 'text-rose-600'); +const bar = (f: { inbox: number; tabs: number; spam: number; missing: number }) => { + const t = f.inbox + f.tabs + f.spam + f.missing; + return [ + { w: f.inbox / t, c: 'ph-em' }, + { w: f.tabs / t, c: 'ph-vi' }, + { w: f.spam / t, c: 'ph-ro' }, + { w: f.missing / t, c: 'ph-sl' }, + ].filter((s) => s.w > 0); +}; +--- + +
+
+ +
+
+ Placement tests +
+
+
+
+
Quick question about Brightline
+ Completed + Untracked +
+
+ From alex@northwind.dev + Shared panel + Started Oct 8, 2:14 PM + Finished Oct 8, 2:41 PM + Q4 outbound +
+
+
+ + +
+
+ {cells.map((c, i) => ( +
+
{c.label}
+
{c.rate}
+
{c.sub}
+
+ ))} +
+
+
+
+
+
+
+
+

20 of 20 copies were sent and classified. Rates are shares of the copies that were sent.

+
+
+ +
+ +
+
+ By provider + 6 + + {(['inbox', 'promotions', 'spam', 'missing'] as Folder[]).map((f) => ( + {f === 'missing' ? 'Not observed' : FOLDER[f].label} + ))} + +
+ + + + + + + + + + + + + + {families.map((f, i) => { + const t = f.inbox + f.tabs + f.spam + f.missing; + return ( + + + + + + + + + + ); + })} + +
ProviderInboxTabsSpamMissingCopies
{f.name} +
+ {bar(f).map((s, j) =>
)} +
+
{pct(f.inbox, t)}{pct(f.tabs, t)}{pct(f.spam, t)}{pct(f.missing, t)}{t}/{t}
+
Content check
+
+
+ 88 + out of 100 + Looks good +
+

Nothing in the copy stands out to a spam filter.

+

The same rules the step editor checks, run on the copy that was tested. Placement also depends on the sender's reputation, which no content check sees.

+
+
+ + +
+
+ Seed inboxes + 20 +
+

Addresses on a shared panel are partly hidden.

+
    + {seeds.map((s, i) => ( +
  • +
    +
    {s.seed}
    +
    {s.family}, {s.seen}
    +
    + + {FOLDER[s.folder].label} + +
  • + ))} +
+
+
+
+
+
+
+ + +
+
+ + Placement test finished + now +
+
+
Quick question about Brightline
+
+ 80% + reached the inbox +
+
+
+
+

16 of 20 copies reached the inbox, 2 a Gmail tab, 1 spam and 1 never arrived.

+
+
+
+ + diff --git a/site/src/components/scenes/deliverability/SendersScene.astro b/site/src/components/scenes/deliverability/SendersScene.astro new file mode 100644 index 000000000..21c5f0626 --- /dev/null +++ b/site/src/components/scenes/deliverability/SendersScene.astro @@ -0,0 +1,124 @@ +--- +import ScaledStage from '../../ScaledStage.astro'; +import Glyph from '../Glyph.astro'; +import Ico from '../warmup/Ico.astro'; +import { WINDOW, FLOAT, EYEBROW, TOPBAR, SECTIONBAR, ACTION_GHOST, ACTION_PRIMARY } from '../../../lib/scene'; + +/** + * The Accounts page with each mailbox's sends today against its own cap, + * counting up as campaigns run. Over it, one mailbox's Sending tab + * (SendingBehaviorTab.tsx): the workday it rolled for today. + */ +type Row = { addr: string; name: string; provider: string; mark: 'google' | 'microsoft' | 'imap'; sent: number; cap: number; inbox: number; health: number; resting?: boolean; tick?: number; selected?: boolean }; +const rows: Row[] = [ + { addr: 'alex@northwind.dev', name: 'Alex Kim', provider: 'Google Workspace', mark: 'google', sent: 31, cap: 50, inbox: 97, health: 96, tick: 2900 }, + { addr: 'sam@northwind.io', name: 'Sam Ortiz', provider: 'Microsoft 365', mark: 'microsoft', sent: 18, cap: 50, inbox: 95, health: 93, selected: true }, + { addr: 'ops@northwind.co', name: 'Ops team', provider: 'SMTP / IMAP', mark: 'imap', sent: 22, cap: 50, inbox: 92, health: 90, tick: 3600 }, + { addr: 'hello@helio.gen', name: 'Lena Fischer', provider: 'Microsoft 365', mark: 'microsoft', sent: 27, cap: 50, inbox: 94, health: 94, tick: 4100 }, + { addr: 'growth@acme.co', name: 'Maya Okonkwo', provider: 'Google Workspace', mark: 'google', sent: 12, cap: 30, inbox: 97, health: 96, tick: 5200 }, + { addr: 'sales@brightco.io', name: 'Ana Silva', provider: 'Google Workspace', mark: 'google', sent: 34, cap: 50, inbox: 96, health: 95 }, + { addr: 'leo@northwind.dev', name: 'Leo Park', provider: 'Google Workspace', mark: 'google', sent: 29, cap: 50, inbox: 98, health: 98, tick: 3300 }, + { addr: 'partners@acme.co', name: 'Ravi Shah', provider: 'Google Workspace', mark: 'google', sent: 0, cap: 50, inbox: 95, health: 94, resting: true }, +]; +const th = 'px-3 py-2 text-[10px] font-medium text-slate-400 uppercase tracking-[0.14em] text-left'; +--- + +
+
+ Accounts + + 8 mailboxes +
+ Sending domains + Add account +
+
+
+ Mailboxes + 8 +
Search by email…
+
+ + + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + + ))} + +
MailboxStatusSent todayInboxHealth
+
+ +
+
{r.addr}
+
{r.name} · {r.provider}
+
+
+
+ {r.resting ? ( + Resting + ) : ( + Sending + )} + +
+ + + {r.tick ? {r.sent} : r.sent}/{r.cap} + + +
+
{r.inbox}%Healthy {r.health}
+
+ + +
+
+ + sam@northwind.io + Sending +
+
+
+ +
+
Variable sending plan
+
Hours, volume and spacing re-rolled each day
+
+
+ +
+
+
+ Today's workday + Europe/Berlin +
+
+ 09:14–17:41 + 12:38–13:16 + 90s–7m apart + max 7/hour +
+
+ 18 of 31 sent + 13 left in plan +
+
+
+
+
diff --git a/site/src/components/scenes/deliverability/SuppressionScene.astro b/site/src/components/scenes/deliverability/SuppressionScene.astro new file mode 100644 index 000000000..2bad3f0a7 --- /dev/null +++ b/site/src/components/scenes/deliverability/SuppressionScene.astro @@ -0,0 +1,100 @@ +--- +import ScaledStage from '../../ScaledStage.astro'; +import Glyph from '../Glyph.astro'; +import Ico from '../warmup/Ico.astro'; +import { WINDOW, FLOAT, EYEBROW, TOPBAR, SECTIONBAR, ACTION_PRIMARY, TAB } from '../../../lib/scene'; + +/** + * Contacts > Suppression list (web/src/app/app/contacts/suppressions/page.tsx). + * A reply asking to stop lands in the inbox and, a moment later, its sender + * arrives at the top of the list, on a loop. + */ +type Entry = { who: string; domain?: boolean; reason: string; source: string; mine: boolean; when: string }; +const incoming: Entry = { who: 'karim@stratech.io', reason: 'Replied asking to stop', source: 'Unsubscribed', mine: true, when: 'Oct 9, 2:52 PM' }; +const entries: Entry[] = [ + { who: 'r.patel@quarry.co', reason: 'Hard bounce: the mailbox does not exist', source: 'Bounced', mine: true, when: 'Oct 9, 11:04 AM' }, + { who: 'acme.co', domain: true, reason: 'Existing customer', source: 'Added by hand', mine: false, when: 'Oct 8, 4:30 PM' }, + { who: 'm.lopez@brightline.io', reason: 'Complaint report from the recipient’s provider', source: 'Spam complaint', mine: true, when: 'Oct 8, 9:12 AM' }, + { who: 'jonas@helio.gen', reason: 'One-click unsubscribe from the mail client', source: 'Unsubscribed', mine: true, when: 'Oct 7, 6:41 PM' }, + { who: 'legal@northwind.com', reason: 'Our own legal team', source: 'Added by hand', mine: false, when: 'Oct 2, 10:05 AM' }, + { who: 'ops@verdant.co', reason: 'Do-not-contact list from the CRM', source: 'Imported', mine: false, when: 'Sep 29, 3:18 PM' }, +]; +const tabs = [ + { label: 'All contacts', icon: 'users' }, + { label: 'Segments', icon: 'layers' }, + { label: 'Labels', icon: 'tag' }, + { label: 'Suppression list', icon: 'ban', active: true }, +] as const; +--- + +
+
+ {tabs.map((t) => ( + + {t.icon === 'users' || t.icon === 'ban' ? : }{t.label} + {'active' in t && } + + ))} +
+
+ Suppression list + + Addresses and domains no campaign will email + Add +
+
+ Entries + 214 +
Search addresses or domains…
+
+
+
+ {[incoming, ...entries].map((e, i) => ( +
+ {e.domain ? : } +
+
{e.domain ? `@${e.who}` : e.who}
+
{e.reason}
+
+ {e.source} + {e.when} + +
+ ))} +
+
+
+ + +
+
+ + Reply to sam@northwind.io + Unsubscribe +
+
+
Karim SalahRe: Quick question about Stratech
+

Please take me off your list.

+
Suppressed in every campaign
+
+
+
+ + diff --git a/site/src/components/scenes/sending/ClocksScene.astro b/site/src/components/scenes/sending/ClocksScene.astro new file mode 100644 index 000000000..070957abe --- /dev/null +++ b/site/src/components/scenes/sending/ClocksScene.astro @@ -0,0 +1,134 @@ +--- +import ScaledStage from '../../ScaledStage.astro'; +import Glyph from '../Glyph.astro'; +import Ico from '../campaigns/Ico.astro'; +import { WINDOW, FLOAT, EYEBROW } from '../../../lib/scene'; + +/** + * Settings > Sending > Send-time optimization (settings/sending/page.tsx): + * the switch, contact timezones, the fallback, weekends and the delivery + * hours grid. Over it, one contact's day: the campaign window, the mailbox + * workday and her own hours, and the stretch where all three agree. + */ +const nav = [ + { group: 'Account', items: ['Profile', 'Notifications', 'Security'] }, + { group: 'Workspace', items: ['Members', 'Teams', 'Workspace', 'Sending', 'Website tracking', 'Inbox tagging'] }, +]; +const rows = [ + { label: 'Use the recipient’s local hours', desc: 'On. Sends aim for the hours below.' }, + { label: 'Read each contact’s timezone', desc: 'Uses the contact’s timezone field, then the country its email domain points at.' }, +]; +const picked = [9, 10, 11, 14, 15, 16]; +const fmtHour = (h: number) => (h === 0 ? '12am' : h === 12 ? '12pm' : h < 12 ? `${h}am` : `${h - 12}pm`); +const toggle = ''; + +// One contact's day on a London axis, 06:00 to 22:00. She is in New York, so +// her 9am to noon is 14:00 to 17:00 here and her afternoon hours fall at 19:00. +const A0 = 6 * 60, A1 = 22 * 60, AW = 270; +const ax = (m: number) => ((m - A0) / (A1 - A0)) * AW; +const span = (a: number, b: number) => `left:${ax(a)}px; width:${ax(b) - ax(a)}px`; +const lanes = [ + { k: 'Campaign window', sub: '08:00 to 18:00', parts: [[8 * 60, 18 * 60]], c: 'bg-slate-300' }, + { k: 'Mailbox workday', sub: '09:14 to 17:41', parts: [[9 * 60 + 14, 12 * 60 + 32], [13 * 60 + 19, 17 * 60 + 41]], c: 'bg-sky-300' }, + { k: 'Dana’s hours', sub: 'America/New_York', parts: [[14 * 60, 17 * 60], [19 * 60, 22 * 60]], c: 'bg-violet-300' }, +]; +--- + +
+
+ {nav.map((g, gi) => ( +
+
{g.group}
+ {g.items.map((it) => ( +
+ {it} +
+ ))} +
+ ))} +
+ +
+
+

Sending

+

When campaign mail goes out, measured in your recipient’s day.

+
+
+
+

Send-time optimization

+

Hold each campaign email until it lands inside the hours you pick, in the recipient’s own timezone. It only ever delays a send.

+
+
+ {rows.map((r) => ( +
+
+
{r.label}
+
{r.desc}
+
+ +
+ ))} +
+
+
Fallback timezone
+
Used when a contact’s timezone cannot be worked out.
+
+ America/New_York +
+
+
+
Skip weekends
+
Push a send that would land on Saturday or Sunday to the next weekday.
+
+ +
+
+
Delivery hours
+
+
+ {Array.from({ length: 24 }, (_, h) => ( + {fmtHour(h)} + ))} +
+

Mail arrives around 9am-11am, 2pm-4pm for each recipient.

+
+
+
+
+
+
+ + +
+
+ DW + dana@orbit.app · America/New_York + London time +
+
+ {lanes.map((l) => ( +
+
+
{l.k}
+
{l.sub}
+
+
+ {l.parts.map(([a, b]) => )} +
+
+ ))} +
+
+
Can send
+
14:00 to 17:00
+
+
+ +
+
+
+ {[6, 10, 14, 18, 22].map((h) => {String(h).padStart(2, '0')}:00)} +
+
+
+
diff --git a/site/src/components/scenes/sending/PathScene.astro b/site/src/components/scenes/sending/PathScene.astro new file mode 100644 index 000000000..50aa15b36 --- /dev/null +++ b/site/src/components/scenes/sending/PathScene.astro @@ -0,0 +1,128 @@ +--- +import ScaledStage from '../../ScaledStage.astro'; +import Glyph from '../Glyph.astro'; +import Ico from '../campaigns/Ico.astro'; +import { WINDOW, FLOAT, EYEBROW } from '../../../lib/scene'; + +/** + * How a campaign email leaves: a worker signs in to each mailbox at its own + * provider (the Gmail API, Microsoft Graph, or SMTP for anything else, as + * internal/app/worker/wmail/send.go does), the provider delivers from its own + * servers, and the recipient sees mail from your mailbox and your domain. + */ +const calls = [ + { t: '09:41:07', from: 'alex@northwind.dev', to: 'gmail.googleapis.com', op: 'messages.send', code: '200' }, + { t: '09:43:52', from: 'sam@northwind.io', to: 'graph.microsoft.com', op: 'sendMail', code: '202' }, + { t: '09:46:15', from: 'ops@northwind.co', to: 'smtp.northwind.co:587', op: 'AUTH · DATA', code: '250' }, + { t: '09:49:30', from: 'mia@northwind.dev', to: 'gmail.googleapis.com', op: 'messages.send', code: '200' }, +]; +const providers = [ + { mark: 'google', name: 'Google Workspace', via: 'Gmail API', on: true }, + { mark: 'microsoft', name: 'Microsoft 365', via: 'Microsoft Graph' }, + { mark: 'imap', name: 'Your SMTP host', via: 'SMTP' }, +] as const; +// Stage coordinates for the connectors and the mail moving along them. +const ys = [195, 287, 379]; +const signIn = ys.map((y) => `M404,190 C448,190 446,${y} 492,${y}`); +const deliver = "M776,195 C820,195 812,214 862,214"; +const route = "M404,190 C448,190 446,195 492,195 L776,195 C820,195 812,214 862,214"; +--- + + + + {signIn.map((d, i) => )} + + + + + 1 · signs in + 2 · delivers + + +
+
+ +
+
Warmbly worker
+
worker-fra-2 · eu-central
+
+ healthy +
+
+
Signs in from
203.0.113.24
+
Mailboxes
38
+
alex@northwind.dev
on this worker for 214 days
+
+
+ {calls.map((c, i) => ( +
+ {c.t} + {c.to} + {c.op} + {c.code} +
+ ))} +
+
+ + +
+
Your mailbox provider
+ {providers.map((p) => ( +
+ +
+
{p.name}
+
Reached through {p.via}
+
Delivers from its own servers
+
+
+ ))} +
+ + +
+
+ + + + +
+
+
Quick question about Orbit
+
+ A +
+
Alex Kim <alex@northwind.dev>
+
to me
+
+
+
+
+
from:
Alex Kim <alex@northwind.dev>
+
to:
dana@orbit.app
+
date:
Sep 14, 2026, 9:41 AM
+
mailed-by:
northwind.dev
+
signed-by:
northwind.dev
+
security:
Standard encryption (TLS)
+
+
+
+

Hi Dana,

+

Saw Orbit just opened a second office in Austin. Teams growing that fast usually find outbound is the first thing to strain.

+
+
+
+
+ + diff --git a/site/src/components/scenes/sending/RangesScene.astro b/site/src/components/scenes/sending/RangesScene.astro new file mode 100644 index 000000000..c762ca918 --- /dev/null +++ b/site/src/components/scenes/sending/RangesScene.astro @@ -0,0 +1,129 @@ +--- +import ScaledStage from '../../ScaledStage.astro'; +import Glyph from '../Glyph.astro'; +import Ico from '../campaigns/Ico.astro'; +import { WINDOW, FLOAT, EYEBROW, TAB } from '../../../lib/scene'; + +/** + * Further down the Sending tab: the ranges themselves (lunch, daily target, + * hourly ceiling, delay), as PairFields of our NumberInput with steppers. + * Over it, the workday each morning rolls out of them, one day after another. + */ +const tabs = [ + { label: 'Overview', icon: 'gauge' }, + { label: 'Deliverability', icon: 'mailCheck' }, + { label: 'Analytics', icon: 'barChart' }, + { label: 'Warmup', icon: 'flame' }, + { label: 'Sending', icon: 'clock', active: true }, + { label: 'Settings', icon: 'settings' }, +] as const; +const volume = [ + { label: 'Daily cold-email target range', a: '28', b: '32', suffix: '/ day', hint: 'Chosen once per local day and kept stable across retries. Mailbox and campaign maximums can lower it.' }, + { label: 'Cold emails per hour', a: '5', b: '9', suffix: '/ hour', hint: 'Stops a whole day’s allowance landing in one burst before lunch.' }, + { label: 'Delay between emails', a: '90', b: '420', suffix: 'seconds', hint: 'Drawn fresh for every send, so the intervals stay irregular. Currently 1m 30s to 7m.' }, +]; +// Three mornings, three different days out of the same ranges. +const rolls = [ + { day: 'Mon', hours: ['09:14', '17:41'], lunch: '12:32–13:19', hourly: 7, target: 31 }, + { day: 'Tue', hours: ['09:22', '17:19'], lunch: '12:08–12:51', hourly: 6, target: 29 }, + { day: 'Wed', hours: ['09:05', '17:52'], lunch: '13:02–13:58', hourly: 8, target: 32 }, +]; +const clockField = 'h-9 px-2.5 rounded-md border border-slate-200 bg-white text-[12.5px] text-slate-900 tabular-nums flex items-center w-full'; +--- + +
+
+ {tabs.map((t) => ( + + {t.label} + {'active' in t && } + + ))} +
+
+
+
+ Lunch break + +
+
+
+
Starts somewhere between
+
12:00to13:30
+
+
+
Lasts
+
+ {['30', '60'].map((v, i) => ( + <> + {i === 1 && to} + + {v} + {i === 1 && minutes} + + + + ))} +
+
+
+
+ +
+
Volume and spacing
+ {volume.map((f) => ( +
+
{f.label}
+
+ {[f.a, f.b].map((v, i) => ( + <> + {i === 1 && to} + + {v} + {i === 1 && {f.suffix}} + + + + ))} +
+

{f.hint}

+
+ ))} +
+
+
+ + +
+
+ {rolls.map((r, i) => ( +
+
+ Today’s workday + {r.day} +
+
+
{r.hours[0]}–{r.hours[1]}
+
{r.lunch}
+
max {r.hourly}/hourtarget {r.target}
+
+
+ ))} +
+
+ Saved ranges apply from the next local day. +
+
+
+ + diff --git a/site/src/components/scenes/sending/VolumeScene.astro b/site/src/components/scenes/sending/VolumeScene.astro new file mode 100644 index 000000000..a6407f184 --- /dev/null +++ b/site/src/components/scenes/sending/VolumeScene.astro @@ -0,0 +1,95 @@ +--- +import ScaledStage from '../../ScaledStage.astro'; +import Glyph from '../Glyph.astro'; +import { WINDOW, FLOAT, EYEBROW, TOPBAR, SECTIONBAR, ACTION_GHOST, ACTION_PRIMARY } from '../../../lib/scene'; + +/** + * Accounts (web/src/app/app/emails/page.tsx) narrowed to one campaign's + * senders: each mailbox in its own timezone with its own rolled day, and the + * campaign's day as the sum of them (SendPlanCard's working, floating). + */ +type Row = { addr: string; mark: 'google' | 'microsoft' | 'imap'; tz: string; hours: string; plan: number; sent: number; warming?: boolean; closed?: boolean }; +const rows: Row[] = [ + { addr: 'alex@northwind.dev', mark: 'google', tz: 'London', hours: '09:14–17:41', plan: 31, sent: 18 }, + { addr: 'sam@northwind.io', mark: 'microsoft', tz: 'London', hours: '09:22–17:19', plan: 29, sent: 16 }, + { addr: 'ops@northwind.co', mark: 'imap', tz: 'Berlin', hours: '09:08–17:30', plan: 30, sent: 21 }, + { addr: 'lea@northwind.io', mark: 'microsoft', tz: 'New York', hours: '09:17–17:48', plan: 28, sent: 2 }, + { addr: 'jon@northwind.dev', mark: 'google', tz: 'Denver', hours: '09:11–17:26', plan: 32, sent: 0, closed: true }, + { addr: 'mia@northwind.dev', mark: 'google', tz: 'London', hours: '09:05–17:52', plan: 5, sent: 4, warming: true }, +]; +const total = rows.reduce((s, r) => s + r.plan, 0); +const sentTotal = rows.reduce((s, r) => s + r.sent, 0); +const th = 'px-3 py-2 text-[10px] font-medium text-slate-400 uppercase tracking-[0.14em] text-left'; +--- + +
+
+ Accounts + + tag: outbound +
+ Sending domains + Add account +
+
+
+ Mailboxes + 6 + Each in its own workday +
+ + + + + + + + + + + {rows.map((r, i) => ( + + + + + + + ))} +
MailboxWorkday todaySent today
+
+ +
+
{r.addr}
+
+ {r.warming + ? Warming · 5 cold a day at most + : Cap 50 · rolled {r.plan} today} +
+
+
+
+
{r.hours}
+
{r.tz}{r.closed ? ' · opens later' : ''}
+
+
+
+ {i === 0 ? 18 : r.sent}/{r.plan} +
+
+
+ + +
+
+ {total} + today + From your settings to today +
+
+
Mailbox caps added up300
+
Sending behaviour plan5 mailboxes−100
+
Easing out of warmup1 mailbox−45
+
Today’s plan{total}
+
+
+
diff --git a/site/src/components/scenes/sending/WorkdayScene.astro b/site/src/components/scenes/sending/WorkdayScene.astro new file mode 100644 index 000000000..40a33924d --- /dev/null +++ b/site/src/components/scenes/sending/WorkdayScene.astro @@ -0,0 +1,184 @@ +--- +import ScaledStage from '../../ScaledStage.astro'; +import Glyph from '../Glyph.astro'; +import Ico from '../campaigns/Ico.astro'; +import { WINDOW, FLOAT, EYEBROW, TAB } from '../../../lib/scene'; + +/** + * The mailbox drawer's Sending tab (emails/SendingBehaviorTab.tsx): the + * Variable sending plan switch, today's rolled workday (hours, break, + * spacing band, hourly ceiling, how much of the target is spent) drawn on + * the day it describes, then the working days and workday ranges below. + * The campaign's plan floats over it with this mailbox's share of the day. + */ +const tabs = [ + { label: 'Overview', icon: 'gauge' }, + { label: 'Deliverability', icon: 'mailCheck' }, + { label: 'Analytics', icon: 'barChart' }, + { label: 'Warmup', icon: 'flame' }, + { label: 'Sending', icon: 'clock', active: true }, + { label: 'Settings', icon: 'settings' }, +] as const; + +// Today's roll, local to Europe/London, drawn on a 06:00 to 20:00 axis. +const A0 = 6 * 60, A1 = 20 * 60; +const day = { start: 9 * 60 + 14, end: 17 * 60 + 41, lunch: 12 * 60 + 32, lunchEnd: 13 * 60 + 19, target: 31, hourly: 7 }; +const now = 14 * 60 + 24; +const W = 652; +const x = (m: number) => ((m - A0) / (A1 - A0)) * W; +const clock = (m: number) => `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(Math.round(m % 60)).padStart(2, '0')}`; +// 31 sends paced across the open minutes with jitter; a seed keeps every build identical. +let seed = 11; +const rand = () => ((seed = (seed * 16807) % 2147483647) / 2147483647); +const open = day.end - day.start - (day.lunchEnd - day.lunch); +const sends: number[] = []; +for (let i = 0; i < day.target; i++) { + let m = day.start + ((i + 0.5) / day.target) * open + (rand() - 0.5) * 9; + if (m >= day.lunch) m += day.lunchEnd - day.lunch; + sends.push(Math.round(m)); +} +const sent = sends.filter((t) => t < now).length; +const nextSend = sends.find((t) => t >= now) ?? now; +const left = day.target - sent; +const hours = [6, 8, 10, 12, 14, 16, 18, 20]; +const weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; +const field = 'h-9 px-2.5 rounded-md border border-slate-200 bg-white text-[12.5px] text-slate-900 tabular-nums flex items-center w-full'; +--- + +
+
+
+ AK + +
+
+
alex@northwind.dev
+
Google Workspace
+
+ active + +
+
+ {tabs.map((t) => ( + + {t.label} + {'active' in t && } + + ))} +
+ +
+ +
+
+ +
+
Variable sending plan
+
Hours, volume and spacing are re-rolled each day
+
+
+ +
+ + +
+
+ Today’s workday + Europe/London +
+
+ {clock(day.start)}–{clock(day.end)} + {clock(day.lunch)}–{clock(day.lunchEnd)} + 1m 30s–7m apart + max {day.hourly}/hour +
+
+ {sent} of {day.target} sent + {left} left in plan +
+
+ + +
+
+
+
+ {sends.map((t, i) => ( + + ))} + +
+ {clock(now)} +
+
+
+ {hours.map((h) => {String(h).padStart(2, '0')}:00)} +
+
+ Sent + Planned + Lunch + next send {clock(nextSend)} +
+
+
+ + +
+
Working days
+
+ {weekdays.map((d, i) => ( + + {d} + + + ))} +
+

Days this mailbox sends anything at all, cold outreach and warmup alike.

+
+ + +
+
Workday
+ {[['sunrise', 'Starts somewhere between', '09:03', '09:27'], ['sunset', 'Finishes somewhere between', '17:18', '17:56']].map(([icon, label, a, b]) => ( +
+
{label}
+
{a}to{b}
+
+ ))} +
+
+
+ + +
+
+
+ Today’s sending plan + Series A founders, Q3 +
+
+ Sending behaviour plan + 1 mailbox + −19 +
+
+
+ + alex@northwind.dev + gap 2m + + {sent} / {day.target} of 50 + Sending +
+
+
+ + diff --git a/site/src/components/scenes/warmup/ConversationScene.astro b/site/src/components/scenes/warmup/ConversationScene.astro new file mode 100644 index 000000000..6824a7552 --- /dev/null +++ b/site/src/components/scenes/warmup/ConversationScene.astro @@ -0,0 +1,170 @@ +--- +import ScaledStage from '../../ScaledStage.astro'; +import Glyph from '../Glyph.astro'; +import Ico from './Ico.astro'; +import { WINDOW, FLOAT, EYEBROW } from '../../../lib/scene'; + +/** + * A warmup thread as a pool partner's own mail client shows it: plain text, + * labelled as a simulated diagnostic (the content bank in + * internal/tasks/template.go), filed in the Warmbly folder. The card shows + * what Warmbly checked when the copy arrived, one step at a time. + */ +const folders = [ + { icon: 'inbox', name: 'Inbox', n: '24' }, + { icon: 'star', name: 'Starred' }, + { icon: 'send', name: 'Sent' }, + { icon: 'file', name: 'Drafts', n: '2' }, + { icon: 'archive', name: 'Archive' }, + { icon: 'alert', name: 'Spam' }, + { icon: 'folder', name: 'Warmbly', n: '6', active: true }, +] as const; +const threads = [ + { who: 'Alex Kim', n: 3, subject: 'Simulated diagnostic: fictional clock', preview: 'Agreed. The example is closed; no real meeting was scheduled.', time: '2:41 PM', active: true }, + { who: 'Ravi Shah', n: 2, subject: 'Simulated diagnostic: sample count', preview: 'There are 5 cards in the hypothetical sample: 3 blue and 2 green.', time: '1:58 PM' }, + { who: 'me, Lena', n: 2, subject: 'Simulated diagnostic: conditional wording', preview: 'The hypothetical status is pending review: no approval exists…', time: '11:20 AM' }, + { who: 'Max Weber', n: 1, subject: 'Simulated diagnostic: summary correction', preview: 'A fictional checklist has 12 items: 9 marked complete and 3…', time: 'Oct 8' }, + { who: 'me, Kai', n: 3, subject: 'Simulated diagnostic: plaintext label review', preview: 'Yes. Status: unknown and Next step: review are written in the…', time: 'Oct 8' }, + { who: 'Sam Ortiz', n: 1, subject: 'Simulated diagnostic: fixed-offset date note', preview: 'On 2026-10-08 the fictional clock reads 09:00 UTC, or 11:00…', time: 'Oct 7' }, +] as const; +const checks = [ + { k: 'Token', v: 'Matched, now spent', icon: 'key' }, + { k: 'Bound to', v: 'dana@helio.gen', icon: 'user' }, + { k: 'Arrived in', v: 'Inbox', icon: 'inbox', chip: true }, + { k: 'Mail host', v: 'Google Workspace', icon: 'server' }, + { k: 'Then filed to', v: 'Warmbly folder', icon: 'folder' }, +] as const; +--- + +
+ +
+ + Mail +
Search mail
+ dana@helio.genDH +
+
+
+ Compose + {folders.map((f) => ( +
+ {f.icon === 'star' || f.icon === 'file' || f.icon === 'folder' ? : } + {f.name} + {'n' in f && {f.n}} +
+ ))} +
+ +
+
+ Warmbly + Warmup mail, in and out +
+ {threads.map((t) => ( +
+
+ {t.who} + {t.n > 1 && {t.n}} + {t.time} +
+
{t.subject}
+
{t.preview}
+
+ ))} +
+ +
+
+ Simulated diagnostic: fictional clock + Warmbly +
+
+
+
+ AK + Alex Kim + alex@northwind.dev + 10:12 AM +
+

Simulated diagnostic. In this fictional example, the clock reads 14:00, not 15:00. Which time does the example use?

+
+
+
+ DH + Dana Hart + to Alex + 12:47 PM +
+

The fictional example uses 14:00, not 15:00.

+
+
+
+ AK + Alex Kim + Re: Simulated diagnostic: fictional clock + 2:41 PM +
+

Agreed. The example is closed; no real meeting was scheduled.

+
+ text/plain + no tracking pixel +
+
+
+ Reply + Forward +
+
+
+
+
+ + +
+
+ +
+
Delivery verified
+
alex@northwind.dev → dana@helio.gen
+
+ Counted +
+
+ {checks.map((c, i) => ( +
+ {c.k} + + {'chip' in c + ? {c.v} + : {c.v}} + + + + + +
+ ))} +
+
The same token in any other inbox counts for nothing.
+
+
+ + diff --git a/site/src/components/scenes/warmup/DrawerHead.astro b/site/src/components/scenes/warmup/DrawerHead.astro new file mode 100644 index 000000000..2b322b91a --- /dev/null +++ b/site/src/components/scenes/warmup/DrawerHead.astro @@ -0,0 +1,53 @@ +--- +import Glyph from '../Glyph.astro'; +import { TAB } from '../../../lib/scene'; + +/** + * The mailbox drawer header and tab bar (web/src/components/app/emails/ + * InboxDetails.tsx), with any tab active and the status chip the mailbox + * really carries. + */ +type TabName = 'Overview' | 'Deliverability' | 'Analytics' | 'Warmup' | 'Sending' | 'Settings'; +interface Props { + addr: string; + provider: string; + mark: 'google' | 'microsoft' | 'imap'; + active: TabName; + status?: 'active' | 'paused'; +} +const { addr, provider, mark, active, status = 'active' } = Astro.props; +const initials = addr.slice(0, 2).toUpperCase(); +const tabs = [ + { label: 'Overview', icon: 'gauge' }, + { label: 'Deliverability', icon: 'mailCheck' }, + { label: 'Analytics', icon: 'barChart' }, + { label: 'Warmup', icon: 'flame' }, + { label: 'Sending', icon: 'clock' }, + { label: 'Settings', icon: 'settings' }, +] as const; +const chip = status === 'active' + ? 'border-emerald-100 bg-emerald-50 text-emerald-700' + : 'border-amber-100 bg-amber-50 text-amber-700'; +--- +
+
+ {initials} + +
+
+
{addr}
+
{provider}
+
+ {status} + +
+
+
+ {tabs.map((t) => ( + + {t.label} + {t.label === active && } + + ))} +
+
diff --git a/site/src/components/scenes/warmup/HeroStage.astro b/site/src/components/scenes/warmup/HeroStage.astro new file mode 100644 index 000000000..74a68e7bc --- /dev/null +++ b/site/src/components/scenes/warmup/HeroStage.astro @@ -0,0 +1,31 @@ +--- +/** + * A full-width hero scene: the same fixed-size, scaled-as-one-picture stage + * as ScaledStage, with the photo loaded first and at the page's full width. + * It carries ScaledStage's classes, so that component's script (on the same + * page) keeps it fitted, pauses it off screen and plays its `enter-*` pieces; + * the inline script only sets the scale before first paint. + */ +import Photo from '../../Photo.astro'; + +interface Props { + w: number; + h: number; + photo: string; + class?: string; +} +const { w, h, photo, class: className = '' } = Astro.props; +--- +
+ + + +
diff --git a/site/src/components/scenes/warmup/Ico.astro b/site/src/components/scenes/warmup/Ico.astro new file mode 100644 index 000000000..f26d0f2e0 --- /dev/null +++ b/site/src/components/scenes/warmup/Ico.astro @@ -0,0 +1,48 @@ +--- +/** + * Lucide icons the shared lib/lucide.ts does not carry, drawn the same way + * as Glyph. Used by the warmup and deliverability page scenes. + */ +const PATHS = { + arrowLeft: '', + arrowRight: '', + chevronRight: '', + copy: '', + ban: '', + coffee: '', + timer: '', + eyeOff: '', + list: '', + refresh: '', + xCircle: '', + helpCircle: '', + minus: '', + cursorClick: '', + thumbsUp: '', + thumbsDown: '', + zap: '', + shieldAlert: '', + key: '', + folder: '', + star: '', + file: '', + trash: '', + users: '', + bell: '', + square: '', + undo: '', + paperclip: '', + shuffle: '', + hourglass: '', +} as const; + +export type IcoName = keyof typeof PATHS; + +interface Props { + name: IcoName; + size?: number; + class?: string; +} +const { name, size = 14, class: className = '' } = Astro.props; +--- + diff --git a/site/src/components/scenes/warmup/PlacementWatchScene.astro b/site/src/components/scenes/warmup/PlacementWatchScene.astro new file mode 100644 index 000000000..a72028c95 --- /dev/null +++ b/site/src/components/scenes/warmup/PlacementWatchScene.astro @@ -0,0 +1,121 @@ +--- +import ScaledStage from '../../ScaledStage.astro'; +import DrawerHead from './DrawerHead.astro'; +import Glyph from '../Glyph.astro'; +import { WINDOW, FLOAT, EYEBROW } from '../../../lib/scene'; + +/** + * The drawer's Deliverability tab (MailboxPlacementTab.tsx) for a mailbox on + * watch: the inbox ring, its band, where warmup mail landed each day and the + * rate per recipient provider. Over it, an Accounts row for a mailbox that + * crossed into throttled, with the reason the pool wrote for it. + */ +// Inbox, other tabs, spam per day over the last 14 days. +const days = [ + [6, 0, 0], [7, 1, 0], [6, 0, 0], [7, 0, 0], [6, 1, 0], [7, 0, 0], [8, 0, 0], + [6, 0, 1], [6, 1, 1], [7, 0, 1], [5, 0, 2], [6, 1, 1], [6, 0, 1], [6, 1, 1], +]; +const max = 9; +const providers = [ + { mark: 'google', name: 'Google', pct: 95, spam: '5%', n: 21 }, + { mark: 'microsoft', name: 'Microsoft', pct: 74, spam: '26%', n: 19 }, + { mark: 'yahoo', name: 'Yahoo & AOL', pct: 90, spam: '10%', n: 10 }, +] as const; +const R = 36; +const C = 2 * Math.PI * R; +const tone = (p: number) => (p >= 90 ? 'text-emerald-600' : p >= 80 ? 'text-amber-600' : 'text-rose-600'); +--- + +
+ + +
+
+ + + + +
+ 86% + inbox +
+
+
+
+ Watch + Pool standing: watch +
+

43 of 50 warmup emails at Google, Microsoft and Yahoo reached the inbox over the last 7 days.

+

7 in spam · 3 in other tabs · 90%+ is healthy

+
+
+ +
+
+ Where it landed each day + + Inbox + Other tabs + Spam + +
+
+ {days.map(([inbox, tabs, spam], i) => ( +
+
+ {tabs > 0 &&
} + {spam > 0 &&
} +
+ ))} +
+
+
Sep 26Oct 9
+
+ +
+
By recipient provider
+
+ {providers.map((p) => ( +
+ + {p.name} +
+
+
+
+ {p.pct}% + {p.spam} spam + {p.n} +
+ ))} +
+
+
+ + +
+
+
+ +
+
dev@sunriselabs.io
+
Noah Grant · Microsoft 365
+
+ Throttled +
+
+ Warmup 6/13 + Inbox 77% + Health At risk 71 +
+

23% of warmup mail delivered at Google, Microsoft and Yahoo landed in spam over 7 days (48 delivered). Warmup and cold sending run at half volume with wider spacing until it is below 15%, then return to normal on their own.

+
+
+
+ + diff --git a/site/src/components/scenes/warmup/PoolStandingScene.astro b/site/src/components/scenes/warmup/PoolStandingScene.astro new file mode 100644 index 000000000..91ebe2b63 --- /dev/null +++ b/site/src/components/scenes/warmup/PoolStandingScene.astro @@ -0,0 +1,106 @@ +--- +import ScaledStage from '../../ScaledStage.astro'; +import DrawerHead from './DrawerHead.astro'; +import Glyph from '../Glyph.astro'; +import Ico from './Ico.astro'; +import { WINDOW, FLOAT, EYEBROW } from '../../../lib/scene'; + +/** + * A quarantined mailbox on the drawer's Warmup tab: the banner + * (WarmupBanBanner) with its reason, dates and appeal, then the pool standing + * the Warmup reputation block shows. Beside it, the bands a standing moves + * through, with this one lit. + */ +const bands = [ + { label: 'Healthy', dot: 'bg-emerald-500', when: 'Nothing over a line', does: 'Full volume' }, + { label: 'Watch', dot: 'bg-amber-400', when: 'Spam 10% · complaints 0.03%', does: 'Slowed a little' }, + { label: 'Throttled', dot: 'bg-amber-500', when: 'Spam 20%', does: 'Half volume, lifts itself' }, + { label: 'Quarantined', dot: 'bg-rose-500', when: 'Complaints 0.10% · bounces 5%', does: 'Out 7 days, cold paused', current: true }, + { label: 'Blocked', dot: 'bg-rose-700', when: 'Complaints 0.30% · bounces 10%', does: 'Out 30 days' }, +]; +--- + +
+ +
+
+
+
+ +
+
+ Blocked from warmup + Quarantined +
+

bounce rate 6.2% exceeded quarantine threshold

+
+ Blocked Oct 9 + Until Oct 16 +
+ Appeal this block +
+
+
+
+ +
+
+
+ Warmup reputation + Premium pool +
+ Quarantined +
+

bounce rate 6.2% exceeded quarantine threshold

+

Paused from the pool until Oct 16.

+
+ +
+
+
+
Domain authentication
+

SPF, DKIM and DMARC records for brightco.io.

+
+ Check +
+
+ +
+
Ramp configuration
+
+
Starting volume
10 emails / day
+
Maximum volume
40 emails / day
+
+
+
+
+ + +
+
+
Pool standing
+
The worst signal sets the band.
+
+
+ {bands.map((b, i) => ( +
+
+ + {b.label} + {b.does} +
+
{b.when}
+
+ ))} +
+
+
+ + diff --git a/site/src/components/scenes/warmup/PoolsScene.astro b/site/src/components/scenes/warmup/PoolsScene.astro new file mode 100644 index 000000000..74e2420fa --- /dev/null +++ b/site/src/components/scenes/warmup/PoolsScene.astro @@ -0,0 +1,131 @@ +--- +import ScaledStage from '../../ScaledStage.astro'; +import Glyph from '../Glyph.astro'; +import Ico from './Ico.astro'; +import { WINDOW, FLOAT, EYEBROW, SECTIONBAR } from '../../../lib/scene'; + +/** + * Who one premium mailbox can write to today, laid out the way the partner + * draw in internal/repository/pg_warmup.go reads it: its own tier, proven + * free mailboxes from other workspaces, each inbox's daily receive cap and + * how far behind it is on what it sent. The card shows the other direction: + * which paying inboxes a free mailbox may write back to. + */ +type Row = { + addr: string; host: string; mark: 'google' | 'microsoft' | 'imap' | 'yahoo'; + pool: 'premium' | 'free'; origin: string; got: number; cap: number; + owed?: number; weight?: string; out?: string; note?: string; +}; +const rows: Row[] = [ + { addr: 'lena@sprucebio.com', host: 'Google Workspace', mark: 'google', pool: 'premium', origin: 'Own tier', got: 12, cap: 36, owed: 14, weight: '2.6' }, + { addr: 'dana@helio.gen', host: 'Google Workspace', mark: 'google', pool: 'premium', origin: 'Own tier', got: 18, cap: 52, owed: 9, weight: '1.9' }, + { addr: 'kai@orbitlabs.io', host: 'Microsoft 365', mark: 'microsoft', pool: 'free', origin: 'Borrowed', got: 9, cap: 30, owed: 6, weight: '1.6', note: 'In the pool 41 days, healthy' }, + { addr: 'max@quarry.co', host: 'Microsoft 365', mark: 'microsoft', pool: 'premium', origin: 'Own tier', got: 31, cap: 60, owed: 0, weight: '1.0' }, + { addr: 'ines@verdant.co', host: 'Yahoo Mail', mark: 'yahoo', pool: 'free', origin: 'Borrowed', got: 14, cap: 28, owed: 0, weight: '1.0', note: 'In the pool 12 days, healthy' }, + { addr: 'ravi@stratech.io', host: 'Microsoft 365', mark: 'microsoft', pool: 'premium', origin: 'Own tier', got: 60, cap: 60, out: 'Full for today' }, + { addr: 'tom@fernway.io', host: 'Google Workspace', mark: 'google', pool: 'free', origin: 'Not offered', got: 0, cap: 24, out: 'On watch' }, +]; +const th = 'px-3 py-2 text-left text-[10px] font-medium uppercase tracking-[0.14em] text-slate-400'; +--- + +
+
+ Warmup partners + + alex@northwind.dev + Premium pool +
+
+ Candidates + 7 + · 5 offered today · drawn by weight +
+ + + + + + + + + + + + {rows.map((r, i) => { + const off = !!r.out; + const pct = Math.round((r.got / r.cap) * 100); + return ( + + + + + + + + ); + })} + +
PartnerPoolReceived todayOwedWeight
+
+ +
+
{r.addr}
+
{r.note ?? r.host}
+
+
+
+ + + {r.pool === 'premium' ? 'Premium' : 'Free'} + + {r.origin === 'Not offered' ? '' : r.origin} + +
+
+
= 100 ? 'bg-rose-400' : 'bg-sky-400'}`} style={`width:${pct}%; --d:${300 + i * 60}ms`}>
+
+ {r.got}/{r.cap} +
+
+ {off ? ( + {r.out} + ) : r.owed ? ( + +{r.owed} behind + ) : ( + even + )} + + {off ? — : ( + 1 ? 'bg-emerald-50 text-emerald-700' : 'text-slate-500'}`}>×{r.weight} + )} +
+
+ + +
+
+ +
+
kai@orbitlabs.io
+
Free pool · may write back to
+
+
+
+
+ + alex@northwind.dev + wrote 2 days ago +
+
+ + sam@northwind.io + no mail in 14 days +
+
+
+
+ + diff --git a/site/src/components/scenes/warmup/RampScene.astro b/site/src/components/scenes/warmup/RampScene.astro new file mode 100644 index 000000000..6087c056f --- /dev/null +++ b/site/src/components/scenes/warmup/RampScene.astro @@ -0,0 +1,106 @@ +--- +import ScaledStage from '../../ScaledStage.astro'; +import DrawerHead from './DrawerHead.astro'; +import Glyph from '../Glyph.astro'; +import { WINDOW, FLOAT, EYEBROW } from '../../../lib/scene'; + +/** + * The mailbox drawer's Analytics tab (AnalyticsTab in InboxDetails.tsx): + * exchange totals, then sent against target per day as the dashboard's + * dithered bars over solid target bars. Day 9 shows the ramp holding after + * a spam placement, with the notice the Warmup tab shows for it. + */ +// Default ramp (10, +1 a day). A spam landing on day 9 cuts the target by a +// quarter for two days and pauses the increase for three, without catch-up. +const DAYS = 25; +const target = (d: number) => { + if (d === 9 || d === 10) return 13; + if (d === 11) return 17; + return d < 9 ? 10 + (d - 1) : 10 + (d - 1) - 3; +}; +const series = Array.from({ length: DAYS }, (_, i) => { + const d = i + 1; + const t = target(d); + const sent = d === DAYS ? 22 : t - (d % 6 === 4 ? 1 : 0); + return { d, t, sent }; +}); +const totalSent = series.reduce((n, s) => n + s.sent, 0); +const received = Math.round(totalSent * 0.94); +const replies = Math.round(totalSent * 0.29); +const max = 32; +const H = 124; +const stats = [ + { label: 'Total sent', value: totalSent, sub: `${(totalSent / DAYS).toFixed(1)}/active day` }, + { label: 'Received', value: received, sub: '94% of what it sent', accent: true }, + { label: 'Replies', value: replies, sub: `${((replies / totalSent) * 100).toFixed(1)}% reply rate` }, + { label: 'Target met', value: '98%', sub: 'of planned volume' }, +]; +--- + +
+ + +
+ {stats.map((s) => ( +
+
{s.label}
+
{s.value}
+
{s.sub}
+
+ ))} +
+
Days active
+
{DAYS}
+
Sep 15 → Oct 9
+
+
+ +
+
+ Daily volume + + Sent + Target + +
+
+ {series.map((s, i) => ( +
+
+
+
+ ))} +
+
+
Sep 15Today
+

Sep 23: 13 sent / 13 target · 12 received · 4 replies

+
+
+ + +
+
+ +
+

Warmup volume held back

+

1 warmup email landed in spam in the last 48 hours out of 17 sent. Today's target is cut by a quarter and the daily increase is paused. It resumes on its own if nothing else lands in spam.

+
+
+
Sep 23 · day 9 of the ramp
+
+
+ + diff --git a/site/src/components/scenes/warmup/WarmupHeroScene.astro b/site/src/components/scenes/warmup/WarmupHeroScene.astro new file mode 100644 index 000000000..9de95a265 --- /dev/null +++ b/site/src/components/scenes/warmup/WarmupHeroScene.astro @@ -0,0 +1,298 @@ +--- +import HeroStage from './HeroStage.astro'; +import DrawerHead from './DrawerHead.astro'; +import Glyph from '../Glyph.astro'; +import Ico from './Ico.astro'; +import { WINDOW, FLOAT, EYEBROW, TOPBAR, SECTIONBAR, ACTION_GHOST, ACTION_PRIMARY, STAT, STAT_VALUE, STAT_SUB } from '../../../lib/scene'; + +/** + * The Accounts page (web/src/app/app/emails/page.tsx) with its Warmup column + * counting live, and the mailbox drawer (InboxDetails.tsx) open on the + * Warmup tab: lifecycle, today's progress, pool standing, ramp settings and + * warmup hours. Warmup activity streams in over the bottom of the list. + */ +type Status = 'active' | 'warming' | 'resting'; +type Row = { + addr: string; name: string; provider: string; mark: 'google' | 'microsoft' | 'imap'; + status: Status; sent?: number; warm: [number, number] | 'Health-check' | 'Paused'; + tick?: number; inbox?: number; collecting?: string; health: [string, number]; selected?: boolean; +}; +const rows: Row[] = [ + { addr: 'alex@northwind.dev', name: 'Alex Kim', provider: 'Google Workspace', mark: 'google', status: 'warming', warm: [19, 27], inbox: 97, health: ['healthy', 97], selected: true }, + { addr: 'sam@northwind.io', name: 'Sam Ortiz', provider: 'Microsoft 365', mark: 'microsoft', status: 'active', sent: 31, warm: [4, 5], inbox: 95, health: ['healthy', 93] }, + { addr: 'mia@northwind.dev', name: 'Mia Chen', provider: 'Google Workspace', mark: 'google', status: 'warming', warm: [14, 23], tick: 3100, inbox: 96, health: ['healthy', 96] }, + { addr: 'ops@northwind.co', name: 'Ops team', provider: 'SMTP / IMAP', mark: 'imap', status: 'active', sent: 18, warm: 'Health-check', inbox: 92, health: ['healthy', 90] }, + { addr: 'jon@northwind.dev', name: 'Jon Reyes', provider: 'Google Workspace', mark: 'google', status: 'warming', warm: [8, 12], tick: 4300, collecting: '7/20', health: ['healthy', 100] }, + { addr: 'lea@northwind.io', name: 'Lea Novak', provider: 'Microsoft 365', mark: 'microsoft', status: 'warming', warm: [9, 18], tick: 5200, inbox: 86, health: ['risk', 78] }, + { addr: 'hello@helio.gen', name: 'Lena Fischer', provider: 'Microsoft 365', mark: 'microsoft', status: 'active', sent: 22, warm: [5, 5], inbox: 94, health: ['healthy', 94] }, + { addr: 'kai@northwind.dev', name: 'Kai Brandt', provider: 'Google Workspace', mark: 'google', status: 'warming', warm: [22, 31], tick: 2700, inbox: 98, health: ['healthy', 98] }, + { addr: 'sales@brightco.io', name: 'Ana Silva', provider: 'Google Workspace', mark: 'google', status: 'active', sent: 27, warm: [3, 5], inbox: 96, health: ['healthy', 96] }, + { addr: 'team@coastline.io', name: 'Sofia Ruiz', provider: 'SMTP / IMAP', mark: 'imap', status: 'resting', warm: 'Paused', inbox: 90, health: ['healthy', 90] }, + { addr: 'growth@acme.co', name: 'Maya Okonkwo', provider: 'Google Workspace', mark: 'google', status: 'active', sent: 25, warm: [4, 5], inbox: 97, health: ['healthy', 96] }, +]; +const stats = [ + { label: 'Total', value: '12', sub: 'connected' }, + { label: 'Healthy', value: '10', sub: 'sending now', pulse: true }, + { label: 'Warming', value: '6', sub: 'ramping up' }, + { label: 'Needs attention', value: '1', sub: 'paused or failing', last: true }, +]; +const feed = [ + { icon: 'send', tone: 'text-sky-600 bg-sky-50', text: 'Sent to dana@helio.gen', sub: 'Google Workspace · 2:41 PM' }, + { icon: 'inbox', tone: 'text-emerald-600 bg-emerald-50', text: 'Arrived in the inbox', sub: 'from ravi@stratech.io · Microsoft 365' }, + { icon: 'reply', tone: 'text-violet-600 bg-violet-50', text: 'Replied to lena@sprucebio.com', sub: 'Thread of 3 · 2:26 PM' }, + { icon: 'send', tone: 'text-sky-600 bg-sky-50', text: 'Sent to max@quarry.co', sub: 'Microsoft 365 · 2:09 PM' }, + { icon: 'inbox', tone: 'text-emerald-600 bg-emerald-50', text: 'Arrived in the inbox', sub: 'from kai@orbitlabs.io · Google Workspace' }, +] as const; +const ramp = [ + { label: 'Starting volume', value: '10', suffix: 'emails / day', hint: 'Emails per day when warmup begins.' }, + { label: 'Daily increase', value: '1', suffix: '+ / day', hint: 'Added each day as reputation builds.' }, + { label: 'Maximum volume', value: '40', suffix: 'emails / day', hint: 'The ceiling the ramp grows toward.' }, + { label: 'Reply rate', value: '30', suffix: '%', hint: 'Share of warmup mail that gets a reply.' }, +]; +const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; +const th = 'px-3 py-2 text-[10px] font-medium text-slate-400 uppercase tracking-[0.14em] text-left'; +const healthTone = (h: string) => h === 'healthy' + ? { dot: 'bg-emerald-500', text: 'text-emerald-700', label: 'Healthy' } + : { dot: 'bg-amber-500', text: 'text-amber-600', label: 'At risk' }; +--- + + +
+
+ Accounts + + 12 mailboxes +
+ Sending domains + Add account +
+
+
+ {stats.map((s) => ( +
+
+ {s.label} + {s.pulse && } +
+
{s.value}
+
{s.sub}
+
+ ))} +
+
+ Mailboxes + 12 +
+
+ + Search by email… +
+
+
+ + + + + + + + + + + + + + {rows.map((r) => { + const h = healthTone(r.health[0]); + return ( + + + + + + + + + + ); + })} + +
MailboxStatusSent todayWarmupInboxHealth
+ {r.selected + ? + : } + +
+ + + +
+
{r.addr}
+
{r.name} · {r.provider}
+
+
+
+ {r.status === 'active' && ( + + + Active + + )} + {r.status === 'warming' && ( + + Warming + + )} + {r.status === 'resting' && ( + + Resting + + )} + + {r.sent != null ? ( + + + {r.sent}/50 + + ) : —} + + {Array.isArray(r.warm) ? ( + + + {r.tick ? {r.warm[0]} : r.warm[0]}/{r.warm[1]} + + ) : r.warm === 'Paused' ? ( + Paused + ) : ( + Health-check + )} + + {r.inbox ? ( + = 90 ? 'text-emerald-600' : 'text-amber-600'}`}> + = 90 ? 'bg-emerald-500' : 'bg-amber-500'}`}>{r.inbox}% + + ) : ( + {r.collecting} + )} + + + + {h.label} {r.health[1]} + +
+
+ + +
+ +
+
+
+ +
+
Warming up
+
Day 18 of the ramp · plain-text exchanges with the pool
+
+
+
+ Pause + Stop +
+
+ +
+
Today
+
Sending 19 of 27 · day 18
+
+
+ +
+
+
+ Warmup reputation + Premium pool +
+ Healthy +
+
+ Sent to 41 partners + 33 domains + 26 workspaces + Received 38 from 35 partners + last 7 days +
+
+ +
+
Ramp configuration
+
+ {ramp.map((f) => ( +
+
{f.label}
+
+ {f.value} + {f.suffix} + + + + +
+

{f.hint}

+
+ ))} +
+
+ +
+
Sending window
+
+ 08:00 + to + 20:00 + + {days.map((d, i) => ( + {d} + ))} + +
+
+
+
+ + +
+
+ Warmup activity + Live +
+
+
+ {[...feed, ...feed].map((f) => ( +
+ +
+
{f.text}
+
{f.sub}
+
+
+ ))} +
+
+
+ Never shown in your unibox +
+
+
+ + diff --git a/site/src/pages/campaigns.astro b/site/src/pages/campaigns.astro index 466379938..70251d660 100644 --- a/site/src/pages/campaigns.astro +++ b/site/src/pages/campaigns.astro @@ -1,733 +1,184 @@ --- import Layout from '../layouts/Layout.astro'; -import HeroAtmosphere from '../components/HeroAtmosphere.astro'; import Icon from '../components/Icon.astro'; import CTA from '../components/CTA.astro'; +import PillarPosts from '../components/PillarPosts.astro'; +import SectionIntro from '../components/page/SectionIntro.astro'; +import Rule from '../components/page/Rule.astro'; +import Faq from '../components/page/Faq.astro'; +import FlowHeroScene from '../components/scenes/campaigns/FlowHeroScene.astro'; +import StepEditorScene from '../components/scenes/campaigns/StepEditorScene.astro'; +import ThreadScene from '../components/scenes/campaigns/ThreadScene.astro'; +import LeadsScene from '../components/scenes/campaigns/LeadsScene.astro'; +import ScheduleScene from '../components/scenes/campaigns/ScheduleScene.astro'; +import GuardrailScene from '../components/scenes/campaigns/GuardrailScene.astro'; +import LimitsScene from '../components/scenes/campaigns/LimitsScene.astro'; +import RotationScene from '../components/scenes/campaigns/RotationScene.astro'; +import EspScene from '../components/scenes/campaigns/EspScene.astro'; -// =========================================================================== -// Campaigns page. The centrepiece is a faithful, static recreation of the -// in-product sequence builder (web/src/components/app/campaigns/sequences): -// cards with an icon tile, a kicker, a title and a one-line summary; branch -// pills heading one column per path; and wait chips on the lines into each -// step. Numbers are real defaults from the codebase: -// internal/config/constants.go (50/day cap, 600s gap) -// internal/scheduler/campaign_scheduler.go -// web sequence builder (wait_after 0..60 days, weight 1..100) -// -// Astro JSX caveat: never put a literal `<`, `>` or `{{ }}` inside a {} -// expression or in markup. Template tokens are written as {`{{.Field}}`}. -// =========================================================================== +// Behaviour from docs/content/docs/guides/campaigns.mdx and sequences.mdx. -// --- Step types legend ------------------------------------------------------- -const stepTypes = [ - { - icon: 'mail', name: 'Email step', tag: 'sends copy', - badge: 'bg-sky-100 text-sky-600 ring-sky-200/70', - desc: 'Sends one templated email. Holds the subject, the body, any A/B variants, and the wait before it goes out.', - }, - { - icon: 'gitBranch', name: 'Branch', tag: 'routes contacts', - badge: 'bg-sky-100 text-sky-600 ring-sky-200/70', - desc: 'Splits the path on a signal: opened, clicked, replied, or a share at random. Each signal gets its own column, and Otherwise takes everyone else.', - }, - { - icon: 'shuffle', name: 'Action', tag: 'side effect', - badge: 'bg-violet-100 text-violet-600 ring-violet-200/70', - desc: 'No email. Add a tag, create a task or deal, move a CRM stage, notify a channel, or unsubscribe the contact.', - }, - { - icon: 'flag', name: 'End', tag: 'ends a path', - badge: 'bg-rose-100 text-rose-600 ring-rose-200/70', - desc: 'Closes the sequence for that contact. A path with nothing under it ends there, and a condition can end it outright.', - }, +const writing = [ + ['Variables', 'First name, company, any custom field, and the sending mailbox’s own name'], + ['Plain text', 'Switch a campaign to plain text, with no HTML part at all'], + ['Attachments', 'Upload files to one step and they go out with that step only'], +]; +const threading = [ + ['One mailbox', 'Rotation picks the mailbox for the first email, and it sends the rest'], + ['Out of office', 'An auto-reply is not a reply. It holds the next step until they are back'], + ['New thread', 'Turn off Reply in thread on a step to open a fresh conversation'], ]; -// --- Branch routing signals -------------------------------------------------- -const routing = [ - { sig: 'replied', instant: true, desc: 'The moment a reply lands. Usually pulls the contact off the rest of the sequence.' }, - { sig: 'opened', instant: true, desc: 'As the open returns, or after a set number of days if you would rather wait and decide.' }, - { sig: 'clicked', instant: true, desc: 'A click on any tracked link, instantly or inside a window you choose.' }, - { sig: 'not opened', instant: false, desc: 'No open inside the window. The classic break-up trigger.' }, - { sig: 'random %', instant: false, desc: 'Send a share of contacts down a different path. A/B at the level of the whole flow.' }, - { sig: 'always', instant: false, desc: 'An unconditional else. Everyone who reaches it takes this path.' }, +const card = 'mt-6 text-[21px] md:text-[23px] tracking-[-0.02em] text-slate-900'; +const cardBody = 'mt-2 max-w-xl text-[16px] md:text-[17px] leading-[1.55] text-slate-500'; + +const faq: [string, string][] = [ + ['Do follow-ups come from the same mailbox?', 'Yes. Rotation chooses a mailbox for a contact’s first email, and that mailbox sends the rest of their sequence, as replies in the same thread. Three emails from three addresses is not a conversation.'], + ['What happens when someone replies?', 'With stop on reply on, the rest of the cold sequence stops for that contact, while any reply branch on the email they answered still runs. Out-of-office replies don’t count as replies; they hold the next step until the person is back.'], + ['What can a branch check?', 'Whether the contact opened or clicked within a window of 1 to 60 days, or didn’t; whether they replied, and whether the reply was positive, negative or neutral; the intent read from their reply, such as wants pricing, not now or wrong person; whether the reply was an out-of-office message; or a random share of contacts. Conditions are checked left to right and the first match wins.'], + ['How many A/B variants can a step have?', 'Up to five on top of the original, with any traffic split you like. Each variant reports sends, opens, replies and bounces, and Warmbly names a winner once the numbers support one.'], + ['How many emails does a campaign send a day?', 'The sum of its mailboxes’ budgets. Each mailbox sends at most 50 cold emails a day by default (a newly connected one plans about 30), and a campaign’s own daily limit can only lower that. A mailbox still warming up sends at most 5 cold emails a day. Today’s sending plan on the campaign shows the number and every limit that set it.'], + ['Can a campaign pause itself?', 'Yes. Turn on auto-pause and a campaign stops once its bounce rate reaches 5% or its complaint rate 0.10% (the defaults, and both can be changed), or its reply rate drops below a floor you set, after a minimum number of sends.'], + ['Does it send in the recipient’s timezone?', 'Campaigns send inside the weekly windows you set, in the campaign’s timezone. Turn on send-time optimization and each email also waits for the recipient’s own local hours.'], + ['Can I send plain text only?', 'Yes. You can write in the rich editor or switch a campaign to plain text, which many cold senders prefer.'], ]; - -// --- A/B variants ------------------------------------------------------------ -const variants = [ - { name: 'Original', weight: 60, subject: 'Quick question, {{.FirstName}}', original: true }, - { name: 'Variant B', weight: 25, subject: '{{.Company}} + a 20-minute look', original: false }, - { name: 'Variant C', weight: 15, subject: 'worth closing the loop?', original: false }, -]; - -// --- Sender pool ------------------------------------------------------------- -const senderPool = [ - { addr: 'ben@acme.com', role: 'AE', cap: 50, today: 31 }, - { addr: 'sara@acme.com', role: 'AE', cap: 50, today: 28 }, - { addr: 'mark@acme.com', role: 'BDR', cap: 50, today: 34 }, - { addr: 'kai@acme.com', role: 'BDR', cap: 50, today: 26 }, -]; - -// --- Auto-stop rules --------------------------------------------------------- -const autoStop = [ - ['Replied', 'The reply pulls the contact off the sequence the instant it lands. Everyone else keeps going.'], - ['Goal met', 'A booked meeting or a won deal closes the sequence for that contact automatically.'], - ['Hard bounce', 'The recipient is suppressed across the whole workspace. Nothing else ever queues to them.'], - ['Unsubscribed', 'A reply-to-opt-out line or unsubscribe link in every email, plus RFC 8058 one-click. Suppression is workspace-wide.'], - ['Reply: STOP', 'Opt-out phrases in a reply are caught and suppressed without anyone in the loop.'], - ['Manual suppress', 'An operator or an uploaded list suppresses the contact across every sequence at once.'], -]; - -// --- Limits / defaults ------------------------------------------------------- -const limits = [ - { k: 'Wait between steps', v: '0 to 60 days', why: 'Set per step. Same-day follow-ups read as automation, so most steps sit a few business days apart.' }, - { k: 'Branch signals', v: 'open · click · reply', why: 'Route on a real engagement signal, inside a window you set or the instant it happens.' }, - { k: 'A/B split', v: 'weight 1 to 100', why: 'Add variants to any email step. Volume splits by weight and we track which one earns the reply.' }, - { k: 'Per-mailbox cap', v: '50 / day', why: 'Default cold budget per mailbox. Raise it toward 100 once the mailbox has earned it.' }, - { k: 'Send spacing', v: '600s minimum', why: 'At least ten minutes between two sends from the same mailbox. No bursts.' }, - { k: 'Business hours', v: 'recipient TZ', why: 'Steps land inside the recipient working window, not the sender clock.' }, -]; - -// --- Templating fields ------------------------------------------------------- -const mergeFields = ['{{.FirstName}}', '{{.Company}}', '{{.Role}}', '{{.Trigger}}', '{{.City}}']; - -// --- FAQ --------------------------------------------------------------------- -const faq = [ - ['Can a branch route on more than one signal?', - 'Each branch takes one path on one signal: opened, clicked, replied, or a random share. Chain branches to combine them. The then and else paths can each point at any step, an action, or a stop.'], - ['What does "instant" mean on a branch?', - 'Reply, open and click can route the moment they happen instead of waiting for the next scheduled step. A reply can pull the contact off the sequence and notify the owner within seconds.'], - ['How do A/B variants get chosen?', - 'Each contact is assigned a variant at the step by a stable weighted split, so a retry always lands on the same copy. We report which variant earned the reply.'], - ['Where does a sequence actually send from?', - 'A pool of your mailboxes, not one. The scheduler round-robins leads across the pool under each mailbox cap, the 600s spacing rule, and its current health band, so no single mailbox carries the campaign. A lead keeps the mailbox that sent its first email, so every follow-up comes from the same address.'], - ['What stops a sequence for a contact?', - 'A reply, a met goal, a bounce, an unsubscribe, a STOP reply, or a manual suppression. Suppression is workspace-wide, so another sequence never picks the contact back up by accident.'], -]; - -const totalToday = senderPool.reduce((a, m) => a + m.today, 0); -const totalCap = senderPool.reduce((a, m) => a + m.cap, 0); --- - -
- - - -