/** * `dench campaign ` — outreach campaigns (email + LinkedIn steps * on one schedule) from the terminal, and therefore from chat. * * Most subcommands call the outreach functions through the authenticated * client; the signal builder calls `/api/v1/campaigns/builder/*` over HTTP. * `dench email multichannel` drives the same rows and keeps working — this * namespace is the fuller surface (search and add people, schedule, steps * round-trip, the Now/Next feed, per-person controls, the activity log). * * `` is an id or an exact, case-insensitive name. A name shared by * several live campaigns is an error that lists the ids. * * Auth: outreach functions need a workspace member's credentials (a saved * `dench signin` session, a member-bound `DENCH_API_KEY`, or an agent session * created by a member). An organization-only key is refused server-side. */ import { readFile } from "node:fs/promises"; import type { ConvexHttpClient } from "convex/browser"; import { makeFunctionReference } from "convex/server"; import { collectPaginated } from "./email"; import { usesSearchFlags } from "./lib/campaign-filters"; import { LINE_CENTS_PER_PERSON, LinesFileError, type LineTemplate, linesTemplateFromFile, normalizedLineKey, } from "./lib/campaign-lines"; import { campaignReferenceResult } from "./lib/campaign-reference"; import { checkCampaignSteps, hasHtmlAndTextBody, hasWorkflowShape, isBranchStep, planStepEdits, STEP_TYPES, StepEditError, staleHtmlSteps, staleHtmlWarning, stepToInput, WORKFLOW_END, } from "./lib/campaign-steps"; import { branchExitLabel, personAddress, type TimelineResult, timelineSteps, } from "./lib/campaign-timeline"; import { assertNoUnknownFlags, CliArgError, getFlag, hasFlag, shift as shiftRaw, } from "./lib/cli-args"; import { cliVersion } from "./lib/cli-version"; import { formatCents } from "./lib/search-pricing"; import { boundWorkspaceReferences, type WorkspaceReferences, withWorkspaceReferenceEnvelopeUrls, } from "./lib/workspace-references"; export class CampaignCliError extends Error {} export function unknownSubcommandMessage( subcommand: string, version: string, ): string { return `Unknown campaign subcommand: ${subcommand}. Run 'dench campaign help'. This is dench ${version}; a newer CLI may have it. Update with 'npm install -g @dench.com/cli@latest' or run 'npx -y -p @dench.com/cli@latest dench campaign ${subcommand} ...'.`; } type JsonRecord = Record; type CampaignCliContext = { convex: ConvexHttpClient; args: string[]; jsonOutput: boolean; sessionToken?: string; host?: string; workspaceSlug?: string; }; const OUTREACH = "functions/outreachSequences"; const MATERIALIZE_VIEW = "functions/crm/objects:materializeView"; /** Mirrors convex/lib/outreach/enrollBatch.ts; the CLI package cannot import from convex/. */ export const CRM_ENROLL_CHUNK_SIZE = 100; /** How many people one campaign can hold (MAX_CAMPAIGN_PEOPLE on the server). */ export const MAX_CAMPAIGN_PEOPLE = 300; /** Page size used when reading a CRM view; 100 keeps rows under the byte budget. */ export const VIEW_PAGE_SIZE = 100; const MAX_VIEW_PAGES = 50; const PEOPLE_PREVIEW_LIMIT = 50; const DEFAULT_EVENTS_PAGE_SIZE = 50; const MAX_EVENTS_PAGE_SIZE = 200; // ─── Shapes (subset of the server's return values that the CLI reads) ───── export type SendSchedule = { name: string; timezone: string; startHour: number; endHour: number; weekdays: number[]; }; export type CampaignRow = { _id: string; name: string; status: string; stopOnReply?: boolean; emailMailboxAccountIds?: string[]; linkedinAccountIds?: string[]; timezone?: string; startsAt?: number; endsAt?: number; sendSchedules?: SendSchedule[]; dailySendCap?: number; stepCount?: number; stepModel?: string; totalEnrolled?: number; activeCount?: number; pausedCount?: number; finishedCount?: number; repliedCount?: number; failedCount?: number; stoppedCount?: number; launchedAt?: number; completedAt?: number; lastError?: string; ownerName?: string; isOwner?: boolean; createdAt?: number; personalizationTemplate?: { variables?: Array<{ key: string }> }; }; type Enrollment = { _id: string; displayName?: string; status: string; currentStepIndex?: number; nextRunAt?: number; lastError?: string; repliedAt?: number; repliedChannel?: string; linkedinProfileUrl?: string; entryId?: string; leadEntryId?: string; firstContactedAt?: number; }; type LinkedinHold = { reason: string; until?: number }; type CurrentRun = { status?: string; type?: string; stepIndex?: number; waitUntil?: number; /** A wait for acceptance: when it stops waiting either way. */ timeoutAt?: number; retryAt?: number; lastError?: string; hold?: LinkedinHold; linkedinAction?: { kind?: string; status?: string; lastError?: string }; }; type CampaignDetail = { sequence: CampaignRow; steps: Array; enrollments: Enrollment[]; currentRuns?: Record; lastTouches?: Record; connectedEntries?: Record< string, { entryId: string; objectName: string; label: string; lead: { entryId: string; status: string | null } | null; } >; isOwner?: boolean; }; type CampaignAnalytics = { audience?: number; contacted?: number; linkedinInvited?: number; linkedinConnected?: number; linkedinMessaged?: number; linkedinReplied?: number; active?: number; paused?: number; finished?: number; replied?: number; failed?: number; stopped?: number; emailSent?: number; opened?: number; clicked?: number; linkedinSent?: number; replyRate?: number; /** Workflow branch steps: how many people took each exit. */ branchStats?: Array<{ stepId: string; yes: number; no: number }>; truncated?: boolean; }; type CampaignLive = { status: string; usesEmail: boolean; usesLinkedin: boolean; emailsToday: number; dailySendCap: number | null; nextEmailAt: number | null; now: { kind: string; enrollmentId: string; personName: string; startedAt: number; } | null; next: { kind: string; enrollmentId: string | null; personName: string | null; at: number; hold: LinkedinHold | null; /** Waiting for them to accept: when the wait ends either way. */ acceptanceDeadline?: number; /** An If / else: what it checks, e.g. "Has an email address". */ label?: string; } | null; paused: { reason: string; detail: string | null; resumesAt: number | null; } | null; senders: Array<{ displayName: string; ownerName: string | null; status: string; statusReason: string | null; timezone: string; windowStartMinutes: number; windowEndMinutes: number; dailyInviteLimit: number; weeklyInviteLimit: number; dailyMessageLimit: number; usage: { invitesToday: number; messagesToday: number; invitesThisWeek: number; }; inWindow: boolean; nextWindowOpenAt: number | null; }>; }; type LaunchBlockerGroup = { code: string; scope?: string; title: string; hint?: string; stepIndex?: number; people: Array<{ enrollmentId?: string; name: string; fields?: Array<{ key: string; label: string }>; }>; count: number; }; export type ExplainedBlocker = LaunchBlockerGroup & { reasons: string[]; fix: string[]; }; type LaunchReadiness = { ready: boolean; fingerprint?: string; blockingReasons?: string[]; blockers?: LaunchBlockerGroup[]; warnings?: string[]; stepCount?: number; audienceCount?: number; usesEmail?: boolean; usesLinkedin?: boolean; }; export type EnrollFromCrmResult = { added: number; requested: number; cappedAtLimit: number | null; duplicates: number; merged: number; duplicatesElsewhere: number; suppressed: number; invalid: number; skippedNoIdentity: number; skippedSample: number; skippedMissingInfo: number; missingInfoSample?: { displayName?: string; missingKeys: string[] }; }; // ─── Plumbing ───────────────────────────────────────────────────────────── function shift(args: string[], expected: string): string { let value: string; try { value = shiftRaw(args, expected); } catch (error) { if (error instanceof CliArgError) throw new CampaignCliError(error.message); throw error; } if (isFlagToken(value)) { throw new CampaignCliError( `Missing ${expected}: it is positional, so write it before or after the flags, e.g. dench campaign get "Founder outreach" --json`, ); } return value; } function isFlagToken(value: string): boolean { return value.startsWith("--") || /^-[A-Za-z]/.test(value); } function unknownFlags(args: readonly string[], command: string): void { const short = args.find((token) => /^-[A-Za-z]/.test(token)); if (short) { throw new CampaignCliError( `Unknown flag for ${command}: ${short}. Run \`dench campaign help\` for the full flag list.`, ); } try { assertNoUnknownFlags(args, command); } catch (error) { if (error instanceof CliArgError) throw new CampaignCliError(error.message); throw error; } // Every caller reads its positionals first, so anything left is a typo or // a value missing its flag; refuse it before a write. const extra = args.find((token) => token !== "--"); if (extra !== undefined) { throw new CampaignCliError( `Unexpected argument for ${command}: ${JSON.stringify(extra)}. Quote a name with spaces, or check for a value missing its flag. Run \`dench campaign help\` for usage.`, ); } } async function callQuery( ctx: CampaignCliContext, name: string, args: JsonRecord, ): Promise { const ref = makeFunctionReference<"query">(name); return await ctx.convex.query(ref, { ...args, sessionToken: ctx.sessionToken, } as never); } async function callMutation( ctx: CampaignCliContext, name: string, args: JsonRecord, ): Promise { const ref = makeFunctionReference<"mutation">(name); return await ctx.convex.mutation(ref, { ...args, sessionToken: ctx.sessionToken, } as never); } async function callAction( ctx: CampaignCliContext, name: string, args: JsonRecord, ): Promise { return await ctx.convex.action(makeFunctionReference<"action">(name), { ...args, sessionToken: ctx.sessionToken, } as never); } function out( ctx: CampaignCliContext, payload: unknown, text: string | string[], ): void { if (ctx.jsonOutput) { console.log(JSON.stringify(payload, null, 2)); return; } console.log(Array.isArray(text) ? text.join("\n") : text); } /** Warnings go to stderr so `--json` stdout stays parseable. */ function warn(ctx: CampaignCliContext, message: string): void { if (!ctx.jsonOutput) console.error(`Warning: ${message}`); } function requiredFlag(args: string[], name: string): string { const value = valueFlag(args, name)?.trim(); if (!value) throw new CampaignCliError(`Missing required ${name}`); return value; } /** * `getFlag` for a flag that carries a value. A flag left at the end of the * line, or followed by another flag, is a mistake — dropping it silently * would report success for a change that never happened. */ function valueFlag(args: string[], name: string): string | undefined { if (!args.includes(name)) return undefined; const value = getFlag(args, name); if (value === undefined || value.startsWith("--")) { throw new CampaignCliError(`${name} needs a value`); } return value; } /** Every occurrence of a repeatable flag, in order. */ function getAllFlags(args: string[], name: string): string[] { const values: string[] = []; while (args.includes(name)) { const value = valueFlag(args, name); if (value !== undefined) values.push(value); } return values; } // ─── Pure parsers (exported for tests) ──────────────────────────────────── const DAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; const DAY_ALIASES: Record = { sun: 0, sunday: 0, mon: 1, monday: 1, tue: 2, tues: 2, tuesday: 2, wed: 3, weds: 3, wednesday: 3, thu: 4, thur: 4, thurs: 4, thursday: 4, fri: 5, friday: 5, sat: 6, saturday: 6, }; const DAY_GROUPS: Record = { weekdays: [1, 2, 3, 4, 5], weekday: [1, 2, 3, 4, 5], weekends: [0, 6], weekend: [0, 6], daily: [0, 1, 2, 3, 4, 5, 6], everyday: [0, 1, 2, 3, 4, 5, 6], "every-day": [0, 1, 2, 3, 4, 5, 6], }; const WINDOW_HELP = 'Write a send window as " -", e.g. "Mon-Fri 9-18", "Mon,Wed,Fri 10-16", "Weekdays 9am-5pm". Hours are whole hours, 0-24.'; function dayIndex(token: string): number { const day = DAY_ALIASES[token.toLowerCase()]; if (day === undefined) { throw new CampaignCliError( `Unknown day "${token}" in --window. ${WINDOW_HELP}`, ); } return day; } function parseDays(part: string): number[] { const days = new Set(); for (const token of part.split(",")) { const cleaned = token.trim(); if (!cleaned) continue; const group = DAY_GROUPS[cleaned.toLowerCase()]; if (group) { for (const day of group) days.add(day); continue; } const range = cleaned.match(/^([A-Za-z]+)\s*[-–]\s*([A-Za-z]+)$/); if (range) { const from = dayIndex(range[1]); const to = dayIndex(range[2]); let day = from; days.add(day); while (day !== to) { day = (day + 1) % 7; days.add(day); } continue; } days.add(dayIndex(cleaned)); } if (days.size === 0) { throw new CampaignCliError( `--window needs at least one day. ${WINDOW_HELP}`, ); } return [...days].sort((a, b) => a - b); } function parseHour(token: string, edge: "start" | "end"): number { const match = token .trim() .toLowerCase() .match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/); if (!match) { throw new CampaignCliError( `Cannot read the ${edge} hour "${token}". ${WINDOW_HELP}`, ); } let hour = Number(match[1]); const minutes = match[2] ? Number(match[2]) : 0; if (minutes !== 0) { throw new CampaignCliError( `Send windows use whole hours; "${token}" has minutes. ${WINDOW_HELP}`, ); } const meridiem = match[3]; if (meridiem) { if (hour < 1 || hour > 12) { throw new CampaignCliError( `"${token}" is not a valid clock hour. ${WINDOW_HELP}`, ); } if (meridiem === "am") hour = hour === 12 ? 0 : hour; else hour = hour === 12 ? 12 : hour + 12; } const max = edge === "start" ? 23 : 24; if (hour < 0 || hour > max) { throw new CampaignCliError( `The ${edge} hour must be between 0 and ${max}, got "${token}". ${WINDOW_HELP}`, ); } return hour; } /** "Mon-Fri" for a consecutive run, otherwise "Mon, Wed, Fri". */ export function formatWeekdays(weekdays: readonly number[]): string { const days = [...new Set(weekdays)].sort((a, b) => a - b); if (days.length === 0) return "no days"; if (days.length === 7) return "Every day"; const consecutive = days.length > 2 && days.every((day, i) => i === 0 || day === days[i - 1] + 1); if (consecutive) { return `${DAY_LABELS[days[0]]}-${DAY_LABELS[days[days.length - 1]]}`; } return days.map((day) => DAY_LABELS[day]).join(", "); } export type WindowSpec = { name: string; startHour: number; endHour: number; weekdays: number[]; }; /** * `"Mon-Fri 9-18"`, `"Mon,Wed,Fri 10-16"`, `"Weekdays 9am-5pm"` → * `{ name, startHour, endHour, weekdays }`. The timezone is added by the * caller so several windows can share one `--timezone`. */ export function parseWindowSpec(spec: string): WindowSpec { const trimmed = spec.trim().replace(/\s+/g, " "); const split = trimmed.lastIndexOf(" "); if (split === -1) { throw new CampaignCliError( `Cannot read the send window "${spec}". ${WINDOW_HELP}`, ); } const daysPart = trimmed.slice(0, split); const hoursPart = trimmed.slice(split + 1); const hours = hoursPart.split(/[-–]/).map((part) => part.trim()); if (hours.length !== 2) { throw new CampaignCliError( `The send window "${spec}" needs a start and an end hour. ${WINDOW_HELP}`, ); } const weekdays = parseDays(daysPart); const startHour = parseHour(hours[0], "start"); const endHour = parseHour(hours[1], "end"); if (endHour <= startHour) { throw new CampaignCliError( `The send window "${spec}" must end after it starts. ${WINDOW_HELP}`, ); } return { name: `${formatWeekdays(weekdays)} ${startHour}-${endHour}`, startHour, endHour, weekdays, }; } export function buildSendSchedules( specs: readonly string[], timezone: string, ): SendSchedule[] { if (specs.length === 0) { throw new CampaignCliError(`Pass at least one --window. ${WINDOW_HELP}`); } if (specs.length > 8) { throw new CampaignCliError("Use at most eight --window values."); } const tz = assertIanaTimezone(timezone); return specs.map((spec) => ({ ...parseWindowSpec(spec), timezone: tz })); } export function assertIanaTimezone(timezone: string): string { const tz = timezone.trim(); try { new Intl.DateTimeFormat("en-US", { timeZone: tz }).format(); } catch { throw new CampaignCliError( `"${timezone}" is not an IANA timezone. Use one like America/Los_Angeles or Europe/London.`, ); } return tz; } export function businessHoursSchedule(timezone: string): SendSchedule { return { name: "Business hours", timezone, startHour: 9, endHour: 18, weekdays: [1, 2, 3, 4, 5], }; } export { STEP_TYPES }; export type StepsFile = { steps: JsonRecord[]; warnings: string[] }; export type StepModel = "linear" | "workflow"; /** * The model to save steps as: the file's `stepModel`, else a workflow when * the campaign is one or the steps have keys, paths or logic steps. * Undefined leaves a linear campaign linear. */ function stepModelFor( steps: readonly unknown[], fileModel: unknown, campaignModel: StepModel | undefined, source: string, ): StepModel | undefined { if ( fileModel !== undefined && fileModel !== "linear" && fileModel !== "workflow" ) { throw new CampaignCliError( `${source}: "stepModel" must be "workflow" or "linear".`, ); } if (campaignModel === "workflow" && fileModel === "linear") { throw new CampaignCliError( `This campaign is a workflow, so it can't go back to a plain list of steps. Leave "stepModel" out, or start a new campaign with dench campaign draft and set the steps there (dench campaign duplicate keeps the workflow).`, ); } const shaped = steps.findIndex(hasWorkflowShape); if (fileModel === "linear" && shaped >= 0) { throw new CampaignCliError( `Step ${shaped + 1}: condition, wait_for_connection, "key", "next", "yesNext" and "noNext" only work in a workflow. Set "stepModel": "workflow" (or leave it out) to save these steps as one.`, ); } if (fileModel === "workflow" || campaignModel === "workflow" || shaped >= 0) return "workflow"; return fileModel === "linear" ? "linear" : undefined; } /** * Parse a steps file: a JSON array of step objects (or `{ "steps": [...] }`, * optionally with `"stepModel"`). * Checks the shape the server validates so a typo is caught here with a * step number instead of a Convex validator message. */ export function readStepsJson( raw: string, source = "The steps file", options: { allowSingleBraces?: boolean; stepModel?: StepModel } = {}, ): StepsFile & { stepModel?: StepModel } { let parsed: unknown; try { parsed = JSON.parse(raw); } catch { throw new CampaignCliError(`${source} is not valid JSON.`); } const wrapped = parsed && typeof parsed === "object" && !Array.isArray(parsed) && Array.isArray((parsed as JsonRecord).steps) ? (parsed as JsonRecord) : null; const steps = Array.isArray(parsed) ? parsed : wrapped ? (wrapped.steps as unknown[]) : null; if (!steps) { throw new CampaignCliError( `${source} must be a JSON array of steps (or an object with a "steps" array).`, ); } if (steps.length === 0) { throw new CampaignCliError(`${source} has no steps.`); } const stepModel = stepModelFor( steps, wrapped?.stepModel, options.stepModel, source, ); const check = checkCampaignSteps(steps, { allowSingleBraces: options.allowSingleBraces, singleBraceOverride: "--allow-single-braces", workflow: stepModel === "workflow", }); if (check.errors.length > 0) { throw new CampaignCliError( check.errors.map((issue) => issue.message).join("\n"), ); } return { steps: steps as JsonRecord[], warnings: check.warnings, ...(stepModel ? { stepModel } : {}), }; } export function parseStepsJson( raw: string, source = "The steps file", options: { allowSingleBraces?: boolean; stepModel?: StepModel } = {}, ): JsonRecord[] { return readStepsJson(raw, source, options).steps; } export { stepToInput }; /** * A `--start`/`--end` value. A timestamp with an offset is an instant * already; a bare date (`2026-09-03`) or a date-time without an offset * (`2026-09-03T09:00`) stays wall-clock time until the campaign's timezone * is known, because 09:00 in Los Angeles is not 09:00 in UTC (nor wherever * the CLI happens to run). */ export type WhenFlag = number | { date: string; time?: string }; export type ScheduleFlags = { timezone?: string; dailySendCap?: number; stopOnReply?: boolean; startsAt?: WhenFlag | null; endsAt?: WhenFlag | null; windows: string[]; }; /** * ISO 8601 only: a date, optionally `T` + time, optionally an offset. Every * part is checked, so February 30 or 25:00 is refused rather than rolled * over by `Date.parse`, and other formats (09/03/2026) are refused outright. */ const ISO_WHEN = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(\.\d{1,3})?)?(Z|[+-]\d{2}:?\d{2})?)?$/; function isCalendarDate(year: number, month: number, day: number): boolean { const utc = new Date(Date.UTC(year, month - 1, day)); return ( utc.getUTCFullYear() === year && utc.getUTCMonth() === month - 1 && utc.getUTCDate() === day ); } function parseWhenFlag(value: string, flag: string): WhenFlag { const trimmed = value.trim(); const match = trimmed.match(ISO_WHEN); if (!match) { throw new CampaignCliError( `${flag} must be an ISO 8601 date or date-time, e.g. 2026-09-03, 2026-09-03T09:00 (in the campaign's timezone) or 2026-09-03T09:00:00-07:00 (got "${value}").`, ); } const [, year, month, day, hour, minute, second, , offset] = match; if (!isCalendarDate(Number(year), Number(month), Number(day))) { throw new CampaignCliError(`${flag}: "${value}" is not a calendar date.`); } const date = `${year}-${month}-${day}`; if (hour === undefined) return { date }; if ( Number(hour) > 23 || Number(minute) > 59 || Number(second ?? 0) > 59 || (offset !== undefined && offset !== "Z" && (Number(offset.slice(1, 3)) > 14 || Number(offset.slice(-2)) > 59)) ) { throw new CampaignCliError(`${flag}: "${value}" is not a valid time.`); } const time = `${hour}:${minute}:${second ?? "00"}`; // No offset: the campaign's timezone decides the instant, never the // machine the CLI runs on. if (offset === undefined) return { date, time }; const parsed = Date.parse(trimmed); if (!Number.isFinite(parsed)) { throw new CampaignCliError(`${flag}: "${value}" is not a valid date-time.`); } return parsed; } /** The zone's UTC offset at `ms`, in milliseconds (positive east of UTC). */ function timezoneOffsetMs(ms: number, timeZone: string): number { const parts = new Intl.DateTimeFormat("en-US", { timeZone, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", }).formatToParts(new Date(ms)); const read = (type: string) => Number(parts.find((part) => part.type === type)?.value ?? 0); const wallClockAsUtc = Date.UTC( read("year"), read("month") - 1, read("day"), read("hour"), read("minute"), read("second"), ); return wallClockAsUtc - (ms - (ms % 1000)); } const DAY_MS = 86_400_000; /** * The instant a wall-clock time (`date` YYYY-MM-DD, `time` HH:MM[:SS]) has * in `timezone`, as epoch ms. A time a DST change repeats takes its first * instant; a time it skips (some zones skip midnight) takes the first instant * after the gap, which is still on that date. */ export function zonedTime( date: string, timezone: string, time = "00:00:00", ): number { const timeZone = assertIanaTimezone(timezone); const [year, month, day] = date.split("-").map(Number); const [hour = 0, minute = 0, second = 0] = time.split(":").map(Number); const wall = Date.UTC(year, month - 1, day, hour, minute, second); // The offsets in force around that wall time; each one that maps back to // it is a reading of it. const offsets = [ ...new Set( [wall - DAY_MS, wall, wall + DAY_MS].map((ms) => timezoneOffsetMs(ms, timeZone), ), ), ]; const readings = offsets .map((offset) => wall - offset) .filter((ms) => timezoneOffsetMs(ms, timeZone) === wall - ms); if (readings.length) return Math.min(...readings); // Skipped: the clock jumped from the smaller offset to the larger one // somewhere in [wall - larger, wall - smaller]. The jump is the answer. const before = Math.min(...offsets); let lo = wall - Math.max(...offsets); let hi = wall - before; while (hi - lo > 1000) { const mid = Math.floor((lo + hi) / 2); if (timezoneOffsetMs(mid, timeZone) === before) lo = mid; else hi = mid; } return hi - (hi % 1000); } /** Midnight at the start of `date` (YYYY-MM-DD) in `timezone`, as epoch ms. */ export function zonedMidnight(date: string, timezone: string): number { return zonedTime(date, timezone); } function resolveWhen(when: WhenFlag | null, timezone: string): number | null { if (when === null || typeof when === "number") return when; return zonedTime(when.date, timezone, when.time); } /** Orders two values of the same kind (both instants, or both wall-clock). */ function whenOrder(when: WhenFlag): number { return typeof when === "number" ? when : Date.parse(`${when.date}T${when.time ?? "00:00:00"}Z`); } const END_BEFORE_START = "--end must be after --start."; export type SenderPoolPatch = { emailMailboxAccountIds?: string[]; linkedinAccountIds?: string[]; }; /** Omission preserves the current model; an explicit empty list clears a pool. */ export function consumeSenderPoolFlags(args: string[]): SenderPoolPatch { const patch: SenderPoolPatch = {}; for (const [flag, key] of [ ["--email-mailboxes", "emailMailboxAccountIds"], ["--linkedin-accounts", "linkedinAccountIds"], ] as const) { const value = valueFlag(args, flag); if (value === undefined) continue; const raw = value.trim(); let ids: unknown; if (raw.toLowerCase() === "none") { ids = []; } else if (raw.startsWith("[")) { try { ids = JSON.parse(raw); } catch { throw new CampaignCliError( `${flag} must be a valid JSON array of account ids.`, ); } } else { ids = raw.split(","); } if ( !Array.isArray(ids) || ids.some((id) => typeof id !== "string" || !id.trim()) ) { throw new CampaignCliError( `${flag} needs comma-separated account ids or a JSON string array. Use [] or none to clear the pool.`, ); } const unique = [...new Set((ids as string[]).map((id) => id.trim()))]; // Mirrors normalizeSenderPool on the backend; validate before any RPC. if (unique.length > 50) throw new CampaignCliError( `${flag} accepts at most 50 senders per channel.`, ); patch[key] = unique; } return patch; } /** * Drain the schedule flags shared by `create` and `set`: * `--timezone`, `--daily-cap`, `--stop-on-reply|--no-stop-on-reply`, * `--start|--clear-start`, `--end|--clear-end`, repeatable `--window`. */ export function consumeScheduleFlags(args: string[]): ScheduleFlags { const flags: ScheduleFlags = { windows: getAllFlags(args, "--window") }; const timezone = valueFlag(args, "--timezone"); if (timezone !== undefined) { if (!timezone.trim()) { throw new CampaignCliError( "--timezone needs an IANA timezone like America/New_York", ); } flags.timezone = timezone.trim(); } const cap = valueFlag(args, "--daily-cap"); if (cap !== undefined) { const parsed = Number(cap); if (!Number.isInteger(parsed) || parsed < 1 || parsed > 100) { throw new CampaignCliError( `--daily-cap must be a whole number from 1 to 100 (got "${cap}").`, ); } flags.dailySendCap = parsed; } const stopOn = hasFlag(args, "--stop-on-reply"); const stopOff = hasFlag(args, "--no-stop-on-reply"); if (stopOn && stopOff) { throw new CampaignCliError( "Pass either --stop-on-reply or --no-stop-on-reply, not both.", ); } if (stopOn) flags.stopOnReply = true; if (stopOff) flags.stopOnReply = false; const start = valueFlag(args, "--start"); const clearStart = hasFlag(args, "--clear-start"); if (start !== undefined && clearStart) { throw new CampaignCliError( "Pass either --start or --clear-start, not both.", ); } if (start !== undefined) flags.startsAt = parseWhenFlag(start, "--start"); if (clearStart) flags.startsAt = null; const end = valueFlag(args, "--end"); const clearEnd = hasFlag(args, "--clear-end"); if (end !== undefined && clearEnd) { throw new CampaignCliError("Pass either --end or --clear-end, not both."); } if (end !== undefined) flags.endsAt = parseWhenFlag(end, "--end"); if (clearEnd) flags.endsAt = null; // A bare date and a full timestamp can only be compared once the timezone // is known (schedulePatchFromFlags); two of the same kind fail fast here. if ( flags.startsAt != null && flags.endsAt != null && typeof flags.startsAt === typeof flags.endsAt && whenOrder(flags.endsAt) <= whenOrder(flags.startsAt) ) { throw new CampaignCliError(END_BEFORE_START); } return flags; } export type SchedulePatch = { stopOnReply?: boolean; dailySendCap?: number; startsAt?: number | null; endsAt?: number | null; sendSchedules?: SendSchedule[]; }; /** * Turn parsed flags into the `updateDraft` patch. `--window` values take the * `--timezone` (or the campaign's current one). A bare `--timezone` moves the * existing windows to that zone, or installs business hours when there are * none yet. */ export function schedulePatchFromFlags( flags: ScheduleFlags, options: { fallbackTimezone: string; existingSchedules?: SendSchedule[] }, ): SchedulePatch { const patch: SchedulePatch = {}; if (flags.stopOnReply !== undefined) patch.stopOnReply = flags.stopOnReply; if (flags.dailySendCap !== undefined) patch.dailySendCap = flags.dailySendCap; const timezone = flags.timezone ?? options.fallbackTimezone; if (flags.startsAt !== undefined) { patch.startsAt = resolveWhen(flags.startsAt, timezone); } if (flags.endsAt !== undefined) { patch.endsAt = resolveWhen(flags.endsAt, timezone); } if ( typeof patch.startsAt === "number" && typeof patch.endsAt === "number" && patch.endsAt <= patch.startsAt ) { throw new CampaignCliError(END_BEFORE_START); } if (flags.windows.length > 0) { patch.sendSchedules = buildSendSchedules(flags.windows, timezone); } else if (flags.timezone !== undefined) { const existing = options.existingSchedules ?? []; patch.sendSchedules = existing.length > 0 ? existing.map((schedule) => ({ ...schedule, timezone })) : [businessHoursSchedule(timezone)]; } return patch; } export function hasScheduleChanges(patch: SchedulePatch): boolean { return Object.keys(patch).length > 0; } export function localTimezone(): string { return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } // ─── Formatting ─────────────────────────────────────────────────────────── function fmtWhen(ms: number | null | undefined): string { if (typeof ms !== "number" || !Number.isFinite(ms)) return "—"; return new Date(ms).toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short", }); } function fmtRelative(ms: number, now = Date.now()): string { const delta = ms - now; const abs = Math.abs(delta); const unit = abs < 60_000 ? `${Math.max(1, Math.round(abs / 1000))}s` : abs < 3_600_000 ? `${Math.round(abs / 60_000)}m` : abs < 86_400_000 ? `${Math.round(abs / 3_600_000)}h` : `${Math.round(abs / 86_400_000)}d`; return delta >= 0 ? `in ${unit}` : `${unit} ago`; } function fmtWhenRelative(ms: number | null | undefined): string { if (typeof ms !== "number" || !Number.isFinite(ms)) return "—"; return `${fmtWhen(ms)} (${fmtRelative(ms)})`; } function fmtMinutes(minutes: number): string { const h = Math.floor(minutes / 60); const m = minutes % 60; return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`; } function fmtHours(hours: number): string { if (hours % 24 === 0 && hours >= 24) return `${hours / 24}d`; return `${hours}h`; } function pct(value: number | undefined): string { if (typeof value !== "number" || !Number.isFinite(value)) return "0%"; return `${Math.round(value * 1000) / 10}%`; } const STATUS_LABELS: Record = { draft: "draft", active: "active", paused: "paused", completed: "completed", archived: "archived", }; const STEP_LABELS: Record = { email: "Email", wait: "Wait", linkedin_connection: "Connect on LinkedIn", linkedin_message: "LinkedIn message", linkedin_visit: "Visit profile", linkedin_like_post: "React to a post", linkedin_inmail: "LinkedIn InMail", condition: "If / else", wait_for_connection: "Wait for them to accept", }; type LabeledStep = { type: string; waitHours?: unknown; label?: unknown }; /** A step's name; If / else steps use the server's label of what they check. */ function stepLabel(step: LabeledStep): string { if (step.type === "wait") { const hours = typeof step.waitHours === "number" ? step.waitHours : 0; return `Wait ${fmtHours(hours)}`; } if (isBranchStep(step.type) && typeof step.label === "string" && step.label) return step.label; return STEP_LABELS[step.type] ?? step.type; } type WorkflowStep = LabeledStep & { _id?: unknown; key?: unknown; next?: unknown; yesNext?: unknown; noNext?: unknown; connectionTimeoutHours?: unknown; }; /** * One line per workflow step, numbered as stored: where each exit leads, * shown only where it is not simply the following step. */ export function workflowStepLines(steps: readonly WorkflowStep[]): string[] { const keyIndex = new Map(); steps.forEach((step, index) => { if (typeof step.key === "string" && !keyIndex.has(step.key)) keyIndex.set(step.key, index); }); const target = (to: unknown): string => { if (to === undefined || to === WORKFLOW_END) return "End"; const index = typeof to === "string" ? keyIndex.get(to) : undefined; return index === undefined ? `"${String(to)}" (not a step)` : `${stepLabel(steps[index])} (step ${index + 1})`; }; return steps.map((step, index) => { let name = stepLabel(step); if ( step.type === "wait_for_connection" && typeof step.connectionTimeoutHours === "number" ) name += ` (up to ${fmtHours(step.connectionTimeoutHours)})`; if (isBranchStep(step.type)) return `${index + 1}. ${name} → ${branchExitLabel(step.type, "yes")}: ${target(step.yesNext)} · ${branchExitLabel(step.type, "no")}: ${target(step.noNext)}`; const next = step.next; const plain = next === undefined || (next === WORKFLOW_END ? index === steps.length - 1 : keyIndex.get(String(next)) === index + 1); return plain ? `${index + 1}. ${name}` : `${index + 1}. ${name} → ${target(next)}`; }); } function describeSteps( steps: readonly WorkflowStep[], workflow: boolean, ): string[] { if (!workflow) return [ `Steps (${steps.length}): ${steps.map((step) => stepLabel(step)).join(" → ")}`, ]; return [ `Steps (${steps.length}, a workflow with paths):`, ...workflowStepLines(steps).map((line) => ` ${line}`), ]; } const HOLD_TEXT: Record = { checking: "checking whether they are already connected", spacing: "spacing out sends so they look human", outside_window: "outside the sender's working hours", campaign_schedule: "outside the campaign's send window", daily_cap: "the sender's daily LinkedIn limit is reached", weekly_cap: "the sender's weekly connection limit is reached", circuit_break: "paused after LinkedIn errors", account_paused: "the LinkedIn account is paused", provider_retry: "LinkedIn asked us to retry later", }; export function describeHold( hold: LinkedinHold | null | undefined, ): string | null { if (!hold) return null; const text = HOLD_TEXT[hold.reason] ?? hold.reason.replace(/_/g, " "); return hold.until ? `${text} (until ${fmtWhenRelative(hold.until)})` : text; } const PAUSED_TEXT: Record = { campaign_paused: "the campaign is paused", account_disconnected: "a sending account is disconnected", email_daily_cap: "today's email cap is reached", ...HOLD_TEXT, }; const NOW_VERBS: Record = { profile: "Visiting the LinkedIn profile of", invite: "Sending a connection request to", message: "Sending a LinkedIn message to", like: "Reacting to a recent post by", inmail: "Sending an InMail to", withdraw: "Withdrawing the pending invitation for", email: "Sending an email to", }; const NEXT_VERBS: Record = { profile: "Visit the LinkedIn profile of", visit: "Visit the LinkedIn profile of", invite: "Send a connection request to", message: "Send a LinkedIn message to", like: "React to a recent post by", inmail: "Send an InMail to", withdraw: "Withdraw the pending invitation for", email: "Send an email to", wait: "Wait for", check: "Run an If / else check for", }; const TOUCH_LABELS: Record = { email: "email sent", linkedin_connection: "invite sent", linkedin_message: "LinkedIn message sent", linkedin_visit: "profile visited", linkedin_like_post: "post reaction sent", linkedin_inmail: "InMail sent", wait: "wait finished", condition: "If / else checked", wait_for_connection: "acceptance wait finished", }; const RUN_STATUS_TEXT: Record = { queued: "queued", running: "sending", waiting: "waiting", retry: "retrying", }; function describeSchedule(row: CampaignRow): string { const schedules = row.sendSchedules ?? []; const windows = schedules.length > 0 ? schedules .map( (s) => `${formatWeekdays(s.weekdays)} ${s.startHour}-${s.endHour} ${s.timezone}`, ) .join("; ") : "no send window yet"; const parts = [windows]; if (typeof row.dailySendCap === "number") { parts.push(`up to ${row.dailySendCap} emails/day`); } parts.push( row.stopOnReply === false ? "keeps going after a reply" : "stops on reply", ); if (row.startsAt) parts.push(`starts ${fmtWhen(row.startsAt)}`); if (row.endsAt) parts.push(`ends ${fmtWhen(row.endsAt)}`); return parts.join(" · "); } function describePerson( enrollment: Enrollment, detail: CampaignDetail, ): { line: string; json: JsonRecord } { const id = String(enrollment._id); const run = detail.currentRuns?.[id]; const touch = detail.lastTouches?.[id]; const connected = detail.connectedEntries?.[id]; const name = enrollment.displayName?.trim() || connected?.label || "Someone"; const hold = describeHold(run?.hold); const bits: string[] = [enrollment.status]; if (run?.status && enrollment.status === "active") { // An If / else or acceptance check reads the profile; it sends nothing. const runText = isBranchStep(run.type) && (run.status === "queued" || run.status === "running") ? "checking" : (RUN_STATUS_TEXT[run.status] ?? run.status); const stepDoc = typeof run.stepIndex === "number" ? detail.steps.find((step) => step.order === run.stepIndex) : undefined; const stepText = run.type ? isBranchStep(run.type) && stepDoc?.type === run.type ? stepLabel(stepDoc) : (STEP_LABELS[run.type] ?? run.type) : "step"; bits.push( `${runText}: ${isBranchStep(run.type) ? stepText : stepText.toLowerCase()}`, ); if ( run.type === "wait_for_connection" && run.status === "waiting" && run.timeoutAt ) { bits.push(`stops waiting ${fmtWhenRelative(run.timeoutAt)}`); } else if (run.status === "waiting" && run.waitUntil) { bits.push(`until ${fmtWhenRelative(run.waitUntil)}`); } else if (run.status === "retry" && run.retryAt) { bits.push(`retry ${fmtRelative(run.retryAt)}`); } } if (touch) { bits.push( `last: ${TOUCH_LABELS[touch.type] ?? touch.type} ${fmtRelative(touch.at)}`, ); } if (hold) bits.push(`hold: ${hold}`); const lastError = run?.lastError ?? enrollment.lastError; if (lastError && !hold) bits.push(`note: ${lastError}`); if (connected?.lead?.status) bits.push(`lead: ${connected.lead.status}`); return { line: ` ${name} — ${bits.join(" · ")} [${id}]`, json: { enrollmentId: id, name, status: enrollment.status, currentStepIndex: enrollment.currentStepIndex, run: run ? { status: run.status, type: run.type, waitUntil: run.waitUntil, ...(run.timeoutAt !== undefined ? { timeoutAt: run.timeoutAt } : {}), retryAt: run.retryAt, lastError: run.lastError, } : null, hold: run?.hold ?? null, holdText: hold, lastTouch: touch ?? null, lastError: lastError ?? null, lead: connected?.lead ?? null, record: connected ? { entryId: connected.entryId, objectName: connected.objectName, label: connected.label, } : null, linkedinProfileUrl: enrollment.linkedinProfileUrl, }, }; } // ─── Campaign resolution ────────────────────────────────────────────────── async function listCampaigns( ctx: CampaignCliContext, includeArchived: boolean, ): Promise { const rows = await callQuery(ctx, `${OUTREACH}:listSequences`, { includeArchived, }); return Array.isArray(rows) ? (rows as CampaignRow[]) : []; } /** * `` → the campaign row. Exact id first, then an exact * case-insensitive name (live campaigns win over archived ones of the same * name), then a direct read for ids older than the 200 most recent rows. */ export async function resolveCampaign( ctx: CampaignCliContext, reference: string, ): Promise { const needle = reference.trim(); if (!needle) throw new CampaignCliError("Pass a campaign id or name."); const rows = await listCampaigns(ctx, true); const byId = rows.find((row) => String(row._id) === needle); if (byId) return byId; const lowered = needle.toLowerCase(); const byName = rows.filter( (row) => row.name.trim().toLowerCase() === lowered, ); const live = byName.filter((row) => row.status !== "archived"); const candidates = live.length > 0 ? live : byName; if (candidates.length === 1) return candidates[0]; if (candidates.length > 1) { const list = candidates .map( (row) => ` ${row._id} (${row.status}${row.ownerName ? `, ${row.ownerName}` : ""}${row.createdAt ? `, created ${fmtWhen(row.createdAt)}` : ""})`, ) .join("\n"); throw new CampaignCliError( `${candidates.length} campaigns are named "${needle}". Use the id instead:\n${list}`, ); } const detail = (await callQuery(ctx, `${OUTREACH}:getSequence`, { sequenceId: needle, })) as CampaignDetail | null; if (detail?.sequence) return detail.sequence; throw new CampaignCliError( `No campaign called "${needle}". Run \`dench campaign list --all\` to see every campaign.`, ); } async function loadDetail( ctx: CampaignCliContext, sequenceId: string, ): Promise { const detail = (await callQuery(ctx, `${OUTREACH}:getSequence`, { sequenceId, })) as CampaignDetail | null; if (!detail?.sequence) throw new CampaignCliError("Campaign not found"); return detail; } // ─── Subcommands ────────────────────────────────────────────────────────── async function runList(ctx: CampaignCliContext): Promise { const includeArchived = hasFlag(ctx.args, "--all"); const withReferences = hasFlag(ctx.args, "--with-references"); unknownFlags(ctx.args, "campaign list"); const response = withReferences ? await callQuery(ctx, `${OUTREACH}:listSequencesWithReferences`, { includeArchived, }) : await listCampaigns(ctx, includeArchived); const envelope = response as { data?: CampaignRow[]; } & Partial; const rows = Array.isArray(response) ? (response as CampaignRow[]) : (envelope.data ?? []); const payload = withReferences ? withWorkspaceReferenceEnvelopeUrls( { data: rows, ...(envelope.references ? { references: envelope.references, referenceCoverage: envelope.referenceCoverage, } : boundWorkspaceReferences( rows.flatMap( (row) => campaignReferenceResult(ctx, {}, String(row._id), row.name) .references, ), )), }, ctx, ) : rows; if (rows.length === 0) { out( ctx, payload, includeArchived ? 'No campaigns yet. Create one with: dench campaign create --name "..."' : 'No live campaigns. Add --all to include archived ones, or create one with: dench campaign create --name "..."', ); return; } const lines = rows.map((row) => { const status = (STATUS_LABELS[row.status] ?? row.status).padEnd(9); const people = `${row.totalEnrolled ?? 0} people`; const replied = `${row.repliedCount ?? 0} replied`; const owner = row.ownerName ? ` · ${row.ownerName}` : ""; return `${status} ${row.name} — ${people}, ${replied}${owner} [${row._id}]`; }); out(ctx, payload, lines); } async function runCreate(ctx: CampaignCliContext): Promise { const name = requiredFlag(ctx.args, "--name"); const flags = consumeScheduleFlags(ctx.args); const senderPools = consumeSenderPoolFlags(ctx.args); unknownFlags(ctx.args, "campaign create"); const timezone = flags.timezone ?? localTimezone(); // Every flag is parsed before the first write, so a bad --window cannot // leave a half-configured draft behind. const patch = schedulePatchFromFlags(flags, { fallbackTimezone: timezone }); // createSequence already installs business hours in that timezone; only // explicit windows are worth a second write. if (flags.windows.length === 0) delete patch.sendSchedules; const created = (await callMutation(ctx, `${OUTREACH}:createSequence`, { name, timezone, ...senderPools, })) as { sequenceId: string; references?: unknown[] }; if (hasScheduleChanges(patch)) { try { await callMutation(ctx, `${OUTREACH}:updateDraft`, { sequenceId: created.sequenceId, ...patch, }); } catch (error) { // The draft exists; say which one, so the schedule can be set on it // rather than creating another. const reason = error instanceof Error ? error.message : String(error); if (ctx.jsonOutput) { out( ctx, { sequenceId: created.sequenceId, name, status: "draft", scheduleSaved: false, error: reason, }, [], ); } throw new CampaignCliError( `Created draft campaign "${name}" [${created.sequenceId}], but its schedule wasn't saved: ${reason}. Set it with: dench campaign set ${created.sequenceId} `, ); } } const schedules = patch.sendSchedules ?? [businessHoursSchedule(timezone)]; const payload = { sequenceId: created.sequenceId, name, status: "draft", ...senderPools, timezone, sendSchedules: schedules, ...(patch.dailySendCap !== undefined ? { dailySendCap: patch.dailySendCap } : {}), ...(patch.stopOnReply !== undefined ? { stopOnReply: patch.stopOnReply } : {}), ...(patch.startsAt !== undefined ? { startsAt: patch.startsAt } : {}), ...(patch.endsAt !== undefined ? { endsAt: patch.endsAt } : {}), }; out( ctx, campaignReferenceResult( ctx, { ...payload, references: created.references }, created.sequenceId, name, ), [ `Created draft campaign "${name}" [${created.sequenceId}]`, `Send window: ${schedules .map( (s) => `${formatWeekdays(s.weekdays)} ${s.startHour}-${s.endHour} ${s.timezone}`, ) .join("; ")}`, "Next: dench campaign steps set --file steps.json", 'Add people: dench campaign search --titles Founder --limit 25 --out audience.json, then dench campaign add --people-file audience.json (or --csv, --list, --object lead --view "")', "Then: dench campaign launch --check", ], ); } async function runDuplicate(ctx: CampaignCliContext): Promise { const name = valueFlag(ctx.args, "--name")?.trim(); if (name === "") { throw new CampaignCliError("--name needs the name for the copy"); } const reference = shift(ctx.args, "campaign id or name"); unknownFlags(ctx.args, "campaign duplicate"); const row = await resolveCampaign(ctx, reference); const created = (await callMutation(ctx, `${OUTREACH}:duplicateSequence`, { sequenceId: row._id, })) as { sequenceId: string; references?: unknown[] }; if (name !== undefined) { await callMutation(ctx, `${OUTREACH}:updateDraft`, { sequenceId: created.sequenceId, name, }); } const copyName = name ?? `${row.name} copy`; const steps = row.stepCount ?? 0; out( ctx, campaignReferenceResult( ctx, { references: created.references, sequenceId: created.sequenceId, name: copyName, status: "draft", duplicatedFrom: row._id, stepCount: steps, people: 0, }, created.sequenceId, copyName, ), [ `Duplicated "${row.name}" as the draft "${copyName}" [${created.sequenceId}]: ${steps} ${steps === 1 ? "step" : "steps"}, the same schedule, senders and personalized lines, its lead source (paused), and nobody in it yet.`, `Next: dench campaign add ${created.sequenceId} ... (up to ${MAX_CAMPAIGN_PEOPLE} people), then dench campaign launch ${created.sequenceId} --check`, ], ); } async function runGet(ctx: CampaignCliContext): Promise { const reference = shift(ctx.args, "campaign id or name"); unknownFlags(ctx.args, "campaign get"); const row = await resolveCampaign(ctx, reference); const sequenceId = String(row._id); let analyticsError: string | undefined; const [detail, analytics, live] = await Promise.all([ loadDetail(ctx, sequenceId), ( callAction(ctx, `${OUTREACH}:computeSequenceAnalytics`, { sequenceId, }) as Promise ).catch((error: unknown) => { analyticsError = error instanceof Error ? error.message : String(error); return null; }), callQuery(ctx, `${OUTREACH}:getSequenceLive`, { sequenceId, }) as Promise, ]); const sequence = detail.sequence; const lines: string[] = []; lines.push( `${sequence.name} — ${STATUS_LABELS[sequence.status] ?? sequence.status} [${sequence._id}]`, ); lines.push(`Schedule: ${describeSchedule(sequence)}`); const steps = [...detail.steps].sort( (a, b) => (a.order ?? 0) - (b.order ?? 0), ); const workflow = sequence.stepModel === "workflow"; if (steps.length === 0) lines.push( "Steps: none yet — dench campaign steps set --file steps.json", ); else lines.push(...describeSteps(steps, workflow)); if (sequence.lastError) lines.push(`Last error: ${sequence.lastError}`); lines.push("", "Funnel"); if (analyticsError) { lines.push( ` Funnel numbers are unavailable right now (${analyticsError}). Run dench campaign get again in a minute.`, ); } const a = analytics ?? {}; const audience = a.audience ?? detail.enrollments.length; lines.push(` Enrolled ${audience}`); lines.push( ` In flight ${(a.active ?? 0) + (a.paused ?? 0)} (active ${a.active ?? 0}, paused ${a.paused ?? 0})`, ); lines.push(` Contacted ${a.contacted ?? 0}`); if (live?.usesLinkedin || (a.linkedinInvited ?? 0) > 0) { lines.push( ` LinkedIn invited ${a.linkedinInvited ?? 0} · accepted ${a.linkedinConnected ?? 0} · messaged ${a.linkedinMessaged ?? 0}`, ); } if (live?.usesEmail || (a.emailSent ?? 0) > 0) { lines.push( ` Email sent ${a.emailSent ?? 0} · opened ${a.opened ?? 0} · clicked ${a.clicked ?? 0}`, ); } lines.push( ` Replied ${a.replied ?? 0} (${pct(a.replyRate)} of contacted)`, ); lines.push( ` Done finished ${a.finished ?? 0} · stopped ${a.stopped ?? 0} · failed ${a.failed ?? 0}`, ); lines.push(...branchStatLines(steps, a.branchStats)); if (a.truncated) { lines.push( " (counts are approximate — this campaign has more history than one read can hold)", ); } lines.push("", "Now / Next"); if (live?.now) { lines.push( ` Now: ${NOW_VERBS[live.now.kind] ?? live.now.kind} ${live.now.personName} (since ${fmtWhen(live.now.startedAt)})`, ); } else if (sequence.status === "active") { lines.push(" Now: nothing is being sent right now"); } if (live?.next) lines.push(` Next: ${describeNext(live.next)}`); else if (sequence.status === "active") { lines.push( " Next: nothing scheduled — everyone is finished, waiting on a reply, or stopped", ); } else if (sequence.status === "draft") { lines.push( " Next: check it is ready — dench campaign launch --check (launch only after the user says yes)", ); } if (live?.paused) { const reason = PAUSED_TEXT[live.paused.reason] ?? live.paused.reason.replace(/_/g, " "); const detailText = live.paused.detail ? ` (${live.paused.detail})` : ""; const resumes = live.paused.resumesAt ? ` · resumes ${fmtWhenRelative(live.paused.resumesAt)}` : ""; lines.push(` Held: ${reason}${detailText}${resumes}`); } if (live?.usesEmail) { const cap = live.dailySendCap ?? "∞"; const next = live.nextEmailAt ? ` · next email ${fmtWhenRelative(live.nextEmailAt)}` : ""; lines.push(` Emails today: ${live.emailsToday}/${cap}${next}`); } for (const sender of live?.senders ?? []) { const statusText = sender.statusReason ? `${sender.status}: ${sender.statusReason}` : sender.status; const window = `${fmtMinutes(sender.windowStartMinutes)}-${fmtMinutes(sender.windowEndMinutes)} ${sender.timezone}`; const opens = sender.inWindow ? "in window" : `opens ${fmtWhenRelative(sender.nextWindowOpenAt)}`; lines.push( ` Sender ${sender.displayName}${sender.ownerName ? ` (${sender.ownerName})` : ""}: ${statusText} · invites ${sender.usage.invitesToday}/${sender.dailyInviteLimit} today, ${sender.usage.invitesThisWeek}/${sender.weeklyInviteLimit} this week · messages ${sender.usage.messagesToday}/${sender.dailyMessageLimit} today · ${window} (${opens})`, ); } const enrollments = detail.enrollments; const shown = enrollments.slice(0, PEOPLE_PREVIEW_LIMIT); lines.push("", `People (${shown.length} of ${enrollments.length})`); if (enrollments.length === 0) { lines.push( " nobody yet — dench campaign search --titles Founder --limit 25 --out audience.json, then dench campaign add --people-file audience.json", ); } for (const enrollment of shown) lines.push(describePerson(enrollment, detail).line); if (enrollments.length > shown.length) { lines.push( ` … ${enrollments.length - shown.length} more: dench campaign people `, ); } out( ctx, campaignReferenceResult( ctx, { detail, analytics, live, references: (detail as CampaignDetail & Partial) .references, ...(analyticsError ? { analyticsError } : {}), }, sequenceId, sequence.name, ), lines, ); } /** How many people took each exit of the workflow's branch steps. */ function branchStatLines( steps: readonly WorkflowStep[], stats: CampaignAnalytics["branchStats"], ): string[] { const lines: string[] = []; for (const stat of stats ?? []) { const index = steps.findIndex((step) => String(step._id) === stat.stepId); if (index < 0) continue; const step = steps[index]; lines.push( `${lines.length ? " " : " Paths "} step ${index + 1} ${stepLabel(step)} → ${branchExitLabel(step.type, "yes")} ${stat.yes} · ${branchExitLabel(step.type, "no")} ${stat.no}`, ); } return lines; } export function describeNext(next: NonNullable): string { const hold = describeHold(next.hold); const held = hold ? ` — on hold: ${hold}` : ""; if (typeof next.acceptanceDeadline === "number") return `Wait for ${next.personName ?? "them"} to accept the connection request — next check ${fmtWhenRelative(next.at)}, stops waiting ${fmtWhenRelative(next.acceptanceDeadline)}${held}`; const who = next.personName ? ` ${next.personName}` : ""; if (next.kind === "check" && next.label) return `Check "${next.label}" for${who || " the next person"} at ${fmtWhenRelative(next.at)}${held}`; return `${NEXT_VERBS[next.kind] ?? next.kind}${who} at ${fmtWhenRelative(next.at)}${held}`; } async function runSet(ctx: CampaignCliContext): Promise { const name = valueFlag(ctx.args, "--name")?.trim(); if (name === "") { throw new CampaignCliError("--name needs the new campaign name"); } const flags = consumeScheduleFlags(ctx.args); const senderPools = consumeSenderPoolFlags(ctx.args); const reference = shift(ctx.args, "campaign id or name"); unknownFlags(ctx.args, "campaign set"); const row = await resolveCampaign(ctx, reference); const patch = { ...schedulePatchFromFlags(flags, { fallbackTimezone: row.timezone ?? row.sendSchedules?.[0]?.timezone ?? localTimezone(), existingSchedules: row.sendSchedules, }), ...senderPools, }; if (name === undefined && !hasScheduleChanges(patch)) { throw new CampaignCliError( "Nothing to change. Pass at least one of --name, --timezone, --window, --daily-cap, --email-mailboxes, --linkedin-accounts, --stop-on-reply/--no-stop-on-reply, --start/--clear-start, --end/--clear-end.", ); } if (row.status === "archived") { throw new CampaignCliError( `This campaign is archived and can't be changed. Start again from a copy: dench campaign duplicate ${row._id}`, ); } // One updateDraft carries the rename too, so the server accepts or rejects // the whole change set at once. const update = { ...(name !== undefined ? { name } : {}), ...patch }; await callMutation(ctx, `${OUTREACH}:updateDraft`, { sequenceId: row._id, ...update, }); const changed = Object.keys(update); const payload = { ok: true, sequenceId: row._id, changed, patch, ...(name !== undefined ? { name } : {}), }; out(ctx, payload, [ `Updated "${name ?? row.name}" (${changed.join(", ")}).`, ...(patch.sendSchedules ? [ `Send window: ${patch.sendSchedules .map( (s) => `${formatWeekdays(s.weekdays)} ${s.startHour}-${s.endHour} ${s.timezone}`, ) .join("; ")}`, ] : []), ]); } const STEPS_ACTIONS: Record = { get: "get", show: "get", set: "set", replace: "set", edit: "edit", update: "edit", }; async function runSteps(ctx: CampaignCliContext): Promise { const file = valueFlag(ctx.args, "--file"); const allowSingleBraces = hasFlag(ctx.args, "--allow-single-braces"); const withIds = hasFlag(ctx.args, "--with-ids"); let action = STEPS_ACTIONS[ctx.args[0] ?? ""]; if (action) ctx.args.shift(); else action = file ? "set" : "get"; const reference = shift(ctx.args, "campaign id or name"); unknownFlags(ctx.args, `campaign steps ${action}`); if (withIds && action !== "get") { throw new CampaignCliError( "--with-ids is for steps get. steps edit reads stepId and updatedAt from the file.", ); } const row = await resolveCampaign(ctx, reference); if (action === "get") { const detail = await loadDetail(ctx, String(row._id)); const steps = [...detail.steps] .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) .map((step) => withIds ? { stepId: String(step._id), updatedAt: step.updatedAt, ...stepToInput(step), } : stepToInput(step), ); // A workflow says so, so the file saves back as one even without paths. const printed = detail.sequence.stepModel === "workflow" ? { stepModel: "workflow", steps } : steps; console.log(JSON.stringify(printed, null, 2)); return; } if (!file) { throw new CampaignCliError( "Missing required --file steps.json (a JSON array of steps).", ); } if (action === "edit") return runStepsEdit(ctx, row, file, allowSingleBraces); if (row.status !== "draft") { throw new CampaignCliError( `steps set only works while a campaign is a draft. After launch, change a step's text or wait in place: dench campaign steps get ${row._id} --with-ids > steps.json, edit it, then dench campaign steps edit ${row._id} --file steps.json (the number, order and types of steps stay fixed).`, ); } const raw = await readFile(file, "utf8").catch(() => { throw new CampaignCliError(`Cannot read ${file}`); }); const { steps, warnings, stepModel } = readStepsJson(raw, file, { allowSingleBraces, stepModel: row.stepModel === "workflow" ? "workflow" : undefined, }); if (steps.some(hasHtmlAndTextBody)) { const stored = (await loadDetail(ctx, String(row._id))).steps; for (const index of staleHtmlSteps(steps, stored)) { delete steps[index].emailHtmlBody; warnings.push(staleHtmlWarning(index)); } } const result = (await callMutation(ctx, `${OUTREACH}:replaceSteps`, { sequenceId: row._id, steps, ...(stepModel ? { stepModel } : {}), })) as { ok: true; stepCount: number }; const workflow = stepModel === "workflow"; // A workflow is stored in path order, which can differ from the file's. const saved = workflow ? sortedSteps(await loadDetail(ctx, String(row._id))) : (steps as WorkflowStep[]); out( ctx, { ...result, sequenceId: row._id, ...(stepModel ? { stepModel } : {}), steps, warnings, }, [ workflow ? `Saved ${result.stepCount} steps on "${row.name}" as a workflow:` : `Saved ${result.stepCount} steps on "${row.name}": ${saved .map((step) => stepLabel(step)) .join(" → ")}`, ...(workflow ? workflowStepLines(saved).map((line) => ` ${line}`) : []), ...warnings.map((warning) => `Warning: ${warning}`), ], ); } function sortedSteps(detail: CampaignDetail): CampaignDetail["steps"] { return [...detail.steps].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); } function stepsInFile(parsed: unknown): unknown[] | null { if (Array.isArray(parsed)) return parsed; if ( parsed && typeof parsed === "object" && Array.isArray((parsed as JsonRecord).steps) ) return (parsed as JsonRecord).steps as unknown[]; return null; } async function runStepsEdit( ctx: CampaignCliContext, row: CampaignRow, file: string, allowSingleBraces: boolean, ): Promise { if (row.status === "archived") { throw new CampaignCliError( `"${row.name}" is archived, so its steps can't change. Start again from a copy: dench campaign duplicate ${row._id}`, ); } const raw = await readFile(file, "utf8").catch(() => { throw new CampaignCliError(`Cannot read ${file}`); }); let parsed: unknown; try { parsed = JSON.parse(raw); } catch { throw new CampaignCliError(`${file} is not valid JSON.`); } const entries = stepsInFile(parsed); if (!entries) { throw new CampaignCliError( `${file} must be a JSON array of steps (or an object with a "steps" array), like dench campaign steps get ${row._id} --with-ids prints.`, ); } const detail = await loadDetail(ctx, String(row._id)); const workflow = detail.sequence.stepModel === "workflow"; const status = detail.sequence.status ?? row.status; const draft = status === "draft"; let plan: ReturnType; try { plan = planStepEdits(detail.steps, entries, { allowSingleBraces, singleBraceOverride: "--allow-single-braces", campaign: String(row._id), draft, ...(workflow ? { workflow } : {}), }); } catch (error) { if (error instanceof StepEditError) throw new CampaignCliError(error.message); throw error; } const warningLines = plan.warnings.map((warning) => `Warning: ${warning}`); if (plan.changes.length === 0) { out( ctx, { ok: true, sequenceId: row._id, status, changed: [], warnings: plan.warnings, }, [ `No step in ${file} differs from "${row.name}", so nothing was saved.`, ...warningLines, ], ); return; } if (draft) { // The versions this edit was planned against: a step another editor // saved since the read makes the replace fail rather than undo it. const expectedSteps = sortedSteps(detail).map((step) => ({ id: String(step._id), updatedAt: Number(step.updatedAt), })); try { await callMutation(ctx, `${OUTREACH}:replaceSteps`, { sequenceId: row._id, steps: plan.steps, expectedSteps, ...(workflow || plan.steps.some(hasWorkflowShape) ? { stepModel: "workflow" } : {}), }); } catch (error) { const message = error instanceof Error ? error.message : String(error); if (/changed in another editor/.test(message)) { throw new CampaignCliError( `"${row.name}"'s steps changed since this edit read them, so nothing was saved. Read them again with dench campaign steps get ${row._id} --with-ids, redo the edit, and run it again.`, ); } throw error; } } else { await callMutation(ctx, `${OUTREACH}:updateSteps`, { sequenceId: row._id, steps: plan.updates, }); } out( ctx, { ok: true, sequenceId: row._id, status, saved: draft ? "replaced" : "updated in place", changed: plan.changes, warnings: plan.warnings, }, [ `Updated ${plan.changes.length} of ${plan.steps.length} steps on "${row.name}"${draft ? "" : " for sends from now on; messages already sent are unchanged"}:`, ...plan.changes.map( (change) => ` Step ${change.step} (${stepLabel(plan.steps[change.step - 1] as LabeledStep)}): ${change.fields.join(", ")}`, ), ...warningLines, ], ); } function emptyTotals(): EnrollFromCrmResult { return { added: 0, requested: 0, cappedAtLimit: null, duplicates: 0, merged: 0, duplicatesElsewhere: 0, suppressed: 0, invalid: 0, skippedNoIdentity: 0, skippedSample: 0, skippedMissingInfo: 0, }; } function addTotals( into: EnrollFromCrmResult, result: EnrollFromCrmResult, ): void { into.added += result.added ?? 0; into.requested += result.requested ?? 0; into.duplicates += result.duplicates ?? 0; into.merged += result.merged ?? 0; into.duplicatesElsewhere += result.duplicatesElsewhere ?? 0; into.suppressed += result.suppressed ?? 0; into.invalid += result.invalid ?? 0; into.skippedNoIdentity += result.skippedNoIdentity ?? 0; into.skippedSample += result.skippedSample ?? 0; into.skippedMissingInfo += result.skippedMissingInfo ?? 0; if (result.cappedAtLimit != null) into.cappedAtLimit = result.cappedAtLimit; if (!into.missingInfoSample && result.missingInfoSample) { into.missingInfoSample = result.missingInfoSample; } } /** Split ids into server-sized chunks, in order. */ export function chunkIds( items: readonly T[], size: number = CRM_ENROLL_CHUNK_SIZE, ): T[][] { const chunks: T[][] = []; for (let start = 0; start < items.length; start += size) { chunks.push(items.slice(start, start + size)); } return chunks; } type CrmEnrollOptions = { objectName: string; emailFieldName?: string; linkedinUrlFieldName?: string; nameFieldName?: string; relationFieldName?: string; relationDirection?: "reverse"; checkDuplicatesAcrossWorkspace: boolean; }; async function enrollEntryIds( ctx: CampaignCliContext, sequenceId: string, entryIds: readonly string[], options: CrmEnrollOptions, ): Promise { const totals = emptyTotals(); let chunks = 0; let considered = 0; for (const chunk of chunkIds(entryIds)) { const result = (await callMutation(ctx, `${OUTREACH}:enrollFromCrm`, { sequenceId, ...options, entryIds: chunk, })) as EnrollFromCrmResult; chunks += 1; considered += chunk.length; addTotals(totals, result); if (result.cappedAtLimit != null) break; } return { ...totals, chunks, notConsidered: entryIds.length - considered }; } type ViewSlice = { entries?: Array<{ id?: unknown; entry_id?: unknown }>; totalCount?: number; truncated?: boolean; pageByteTruncated?: boolean; }; /** Every entry id a saved view shows, paged at VIEW_PAGE_SIZE until totalCount. */ export async function collectViewEntryIds( ctx: CampaignCliContext, objectName: string, viewName: string, ): Promise<{ ids: string[]; totalCount: number; truncated: boolean }> { const ids: string[] = []; const seen = new Set(); let totalCount = 0; let truncated = false; for (let page = 1; page <= MAX_VIEW_PAGES; page += 1) { const slice = (await callQuery(ctx, MATERIALIZE_VIEW, { objectName, viewName, page, pageSize: VIEW_PAGE_SIZE, includeReverseRelations: false, omitHiddenFields: true, })) as ViewSlice | null; if (!slice) { throw new CampaignCliError( `CRM object "${objectName}" not found. Run dench crm objects list.`, ); } const entries = Array.isArray(slice.entries) ? slice.entries : []; totalCount = typeof slice.totalCount === "number" ? slice.totalCount : ids.length + entries.length; truncated = truncated || slice.truncated === true || slice.pageByteTruncated === true; for (const row of entries) { const id = typeof row.id === "string" ? row.id : row.entry_id; if (typeof id === "string" && id && !seen.has(id)) { seen.add(id); ids.push(id); } } if (entries.length === 0 || ids.length >= totalCount) break; } return { ids, totalCount, truncated }; } function describeEnrollTotals( campaignName: string, totals: EnrollFromCrmResult & { notConsidered?: number }, extra: { truncated?: boolean; viewCount?: number } = {}, ): string[] { const lines = [ `Added ${totals.added} ${totals.added === 1 ? "person" : "people"} to "${campaignName}" (${totals.requested} requested).`, ]; const alreadyIn = totals.duplicates; if (alreadyIn > 0) { lines.push( ` Already in this campaign: ${alreadyIn}${totals.merged > 0 ? ` (${totals.merged} gained a missing email or LinkedIn URL)` : ""}`, ); } if (totals.duplicatesElsewhere > 0) { lines.push( ` Enrolled in another campaign, skipped: ${totals.duplicatesElsewhere} (pass --allow-duplicates to add anyway)`, ); } if (totals.suppressed > 0) lines.push( ` Unsubscribed or bounced before, skipped: ${totals.suppressed}`, ); const missingContact = totals.skippedNoIdentity + totals.invalid; if (missingContact > 0) { lines.push( ` Missing a usable email or LinkedIn URL, skipped: ${missingContact} (check --email-field / --linkedin-field)`, ); } if (totals.skippedMissingInfo > 0) { const sample = totals.missingInfoSample ? ` — e.g. ${totals.missingInfoSample.displayName ?? "one person"} lacks ${totals.missingInfoSample.missingKeys.join(", ")}` : ""; lines.push( ` Missing merge fields the steps use, skipped: ${totals.skippedMissingInfo}${sample}`, ); } if (totals.cappedAtLimit != null) { lines.push( ` Campaign is full: stopped at ${totals.cappedAtLimit} people${totals.notConsidered ? ` (${totals.notConsidered} not considered)` : ""}.`, ); } if (extra.truncated) { lines.push( ` Warning: the view holds more rows than the CRM lists in one read (${extra.viewCount ?? "1000+"} rows); rows past that were not considered. Narrow the view and run add again.`, ); } return lines; } async function runAdd(ctx: CampaignCliContext): Promise { if (usesSearchFlags(ctx.args)) return runAgentCommand(ctx, "add"); const objectName = valueFlag(ctx.args, "--object"); const viewName = valueFlag(ctx.args, "--view"); const entriesRaw = valueFlag(ctx.args, "--entries"); const displayName = valueFlag(ctx.args, "--name"); const email = valueFlag(ctx.args, "--email"); const linkedinProfileUrl = valueFlag(ctx.args, "--linkedin-url"); const emailFieldName = valueFlag(ctx.args, "--email-field"); const linkedinUrlFieldName = valueFlag(ctx.args, "--linkedin-field"); const nameFieldName = valueFlag(ctx.args, "--name-field"); const relationFieldName = valueFlag(ctx.args, "--relation-field"); const reverse = hasFlag(ctx.args, "--reverse"); const allowDuplicates = hasFlag(ctx.args, "--allow-duplicates"); const reference = shift(ctx.args, "campaign id or name"); unknownFlags(ctx.args, "campaign add"); const usage = 'Usage: dench campaign add --titles Founder --locations "United States" | --search \'{"titles":["Founder"]}\' | --people-file audience.json | --csv people.csv | --list | --object --view "" | --object --entries id1,id2 | [--name "Ada"] --email ada@x.com and/or --linkedin-url https://linkedin.com/in/ada'; const row = await resolveCampaign(ctx, reference); if (row.status === "archived") { throw new CampaignCliError( "People can't be added to an archived campaign.", ); } if (objectName) { if (displayName) { throw new CampaignCliError( `--name adds one person and can't be combined with --object. Use --name-field to pick the name column. ${usage}`, ); } if (viewName && entriesRaw) { throw new CampaignCliError( `Pass either --view or --entries, not both. ${usage}`, ); } if (!viewName && !entriesRaw) { throw new CampaignCliError( `--object needs --view "" or --entries id1,id2. ${usage}`, ); } const options: CrmEnrollOptions = { objectName, emailFieldName, linkedinUrlFieldName, nameFieldName, relationFieldName, relationDirection: reverse ? "reverse" : undefined, checkDuplicatesAcrossWorkspace: !allowDuplicates, }; let entryIds: string[]; let view: { totalCount: number; truncated: boolean } | undefined; if (viewName) { const collected = await collectViewEntryIds(ctx, objectName, viewName); entryIds = collected.ids; view = { totalCount: collected.totalCount, truncated: collected.truncated, }; if (entryIds.length === 0) { out( ctx, { ...emptyTotals(), chunks: 0, notConsidered: 0, view: { name: viewName, ...view }, }, `The view "${viewName}" on ${objectName} has no rows, so nobody was added.`, ); return; } if (collected.truncated) { warn( ctx, `the view "${viewName}" could not be read in full; ${entryIds.length} of ${collected.totalCount} rows were considered. Split it into smaller views to add the rest.`, ); } } else { entryIds = (entriesRaw ?? "") .split(",") .map((id) => id.trim()) .filter(Boolean); if (entryIds.length === 0) { throw new CampaignCliError( "Pass at least one CRM entry id in --entries", ); } } const totals = await enrollEntryIds( ctx, String(row._id), entryIds, options, ); const payload = { ...totals, sequenceId: row._id, objectName, ...(viewName ? { view: { name: viewName, ...view } } : {}), }; out( ctx, payload, describeEnrollTotals(row.name, totals, { truncated: view?.truncated, viewCount: view?.totalCount, }), ); return; } if (viewName || entriesRaw) { throw new CampaignCliError( `--view and --entries need --object . ${usage}`, ); } if (!email && !linkedinProfileUrl) { throw new CampaignCliError( `Pass --email and/or --linkedin-url for one person, --object with --view/--entries for CRM records, or search filters (--titles, --search), --people-file, --csv or --list. ${usage}`, ); } const result = (await callMutation(ctx, `${OUTREACH}:addEnrollment`, { sequenceId: row._id, displayName, email, linkedinProfileUrl, checkDuplicatesAcrossWorkspace: !allowDuplicates, })) as { enrollmentId: string }; out(ctx, { ...result, sequenceId: row._id }, [ `Added ${displayName ?? email ?? linkedinProfileUrl} to "${row.name}" [${result.enrollmentId}]`, ]); } const STEP_BLOCKER_REASONS: Record = { email_sender_missing: /sender is missing|needs a sender mailbox/i, linkedin_sender_missing: /sender is missing|needs a sender account/i, email_sender_foreign: /sender is private to another member/i, linkedin_sender_foreign: /sender is private to another member/i, email_sender_disconnected: /sender must be reconnected/i, linkedin_sender_disconnected: /sender must be reconnected/i, reply_without_prior_email: /replies to a previous email/i, workflow_path_invalid: /isn't a step in this campaign|goes back to|makes a loop|has nothing on its|can't also have a single next step|only If \/ else and Wait (?:until they|for them to) accept steps can branch|nobody will reach it|share the id|marks where a path stops|only work in a workflow campaign/i, wait_for_connection_without_invite: /without a LinkedIn connection request before it/i, opened_check_without_email: /reaches it without an email/i, open_tracking_unavailable: /opens aren't tracked/i, condition_field_missing: /which your leads don't have/i, }; function blockerReasons( group: LaunchBlockerGroup, reasons: readonly string[], ): string[] { if (typeof group.stepIndex !== "number") return []; // "step 3", or one of a list: "Steps 1 and 3 share the id …". const step = new RegExp( `\\bsteps? (?:\\d+(?:, | and ))*${group.stepIndex + 1}\\b`, "i", ); const forStep = reasons.filter((reason) => step.test(reason)); const own = STEP_BLOCKER_REASONS[group.code]; if (own) return forStep.filter((reason) => own.test(reason)); return forStep.filter( (reason) => !Object.values(STEP_BLOCKER_REASONS).some((pattern) => pattern.test(reason), ), ); } const STEPS_FIX = (id: string) => [ `dench campaign steps get ${id} > steps.json`, `dench campaign steps set ${id} --file steps.json`, ]; const BLOCKER_FIXES: Record string[]> = { workflow_path_invalid: STEPS_FIX, wait_for_connection_without_invite: STEPS_FIX, opened_check_without_email: STEPS_FIX, open_tracking_unavailable: STEPS_FIX, condition_field_missing: (id) => [ "dench crm fields list lead", ...STEPS_FIX(id), ], no_steps: STEPS_FIX, too_many_steps: STEPS_FIX, step_invalid: STEPS_FIX, reply_without_prior_email: STEPS_FIX, person_empty_subject: STEPS_FIX, person_empty_email_body: STEPS_FIX, person_empty_linkedin_message: STEPS_FIX, person_linkedin_message_too_long: STEPS_FIX, person_missing_email_fields: STEPS_FIX, person_missing_linkedin_fields: STEPS_FIX, too_many_prospects: (id) => [ `dench campaign people ${id}`, `dench campaign remove ${id} --person `, ], linkedin_duplicate_conflict: (id) => [ `dench campaign people ${id}`, `dench campaign remove ${id} --person `, "dench campaign archive ", "dench campaign stop --person ", ], person_duplicate_sequence: () => [ "dench campaign list", "dench campaign archive ", "dench campaign stop --person ", ], no_prospects: (id) => [ `dench campaign add ${id} --titles "" --locations "<location>"`, `dench campaign add ${id} --people-file audience.json`, ], missing_business_hours: (id) => [ `dench campaign set ${id} --timezone <IANA timezone> --window "Mon-Fri 9-18"`, ], email_sender_missing: (id) => [ "dench campaign accounts", `dench campaign set ${id} --email-mailboxes <emailMailboxAccountId>`, ], email_sender_foreign: (id) => [ "dench campaign accounts", `dench campaign set ${id} --email-mailboxes <emailMailboxAccountId>`, ], email_sender_disconnected: (id) => [ "dench campaign accounts", `dench campaign set ${id} --email-mailboxes <emailMailboxAccountId>`, ], linkedin_sender_missing: (id) => [ "dench campaign accounts", `dench campaign set ${id} --linkedin-accounts <linkedinAccountId>`, ], linkedin_sender_foreign: (id) => [ "dench campaign accounts", `dench campaign set ${id} --linkedin-accounts <linkedinAccountId>`, ], linkedin_sender_disconnected: (id) => [ "dench campaign accounts", `dench campaign set ${id} --linkedin-accounts <linkedinAccountId>`, ], }; const REMOVABLE_BLOCKERS = new Set([ "audience_already_started", "person_no_channel", "person_invalid_email", "person_suppressed", "person_duplicate_sequence", "person_missing_email_fields", "person_missing_linkedin_fields", ]); const MAX_REMOVE_FIXES = 5; function removeFixes(group: LaunchBlockerGroup, sequenceId: string): string[] { const ids = group.people.flatMap((person) => person.enrollmentId ? [person.enrollmentId] : [], ); if (!ids.length) return [`dench campaign remove ${sequenceId} --person <enrollmentId>`]; return [ ...ids .slice(0, MAX_REMOVE_FIXES) .map((id) => `dench campaign remove ${sequenceId} --person ${id}`), ...(ids.length > MAX_REMOVE_FIXES ? [`dench campaign people ${sequenceId}`] : []), ]; } const PERSON_SET_FIXES: Record< string, (person: LaunchBlockerGroup["people"][number]) => string > = { person_no_channel: () => "--email <address> and/or --linkedin-url <url>", person_invalid_email: () => "--email <address>", person_missing_email_fields: (person) => fieldFlags(person), person_missing_linkedin_fields: (person) => fieldFlags(person), }; function fieldFlags(person: LaunchBlockerGroup["people"][number]): string { const keys = (person.fields ?? []).map((field) => field.key); return keys.length ? keys.map((key) => `--field "${key}=<value>"`).join(" ") : '--field "<Field>=<value>"'; } function personSetFixes( group: LaunchBlockerGroup, sequenceId: string, ): string[] { const flags = PERSON_SET_FIXES[group.code]; if (!flags) return []; return group.people .filter((person) => person.enrollmentId) .slice(0, MAX_REMOVE_FIXES) .map( (person) => `dench campaign person set ${sequenceId} --person ${person.enrollmentId} ${flags(person)}`, ); } export function explainBlockers( readiness: LaunchReadiness, sequenceId: string, ): ExplainedBlocker[] { const reasons = readiness.blockingReasons ?? []; const groups = readiness.blockers ?? []; if (!groups.length) return reasons.map((reason) => ({ code: "blocked", title: reason, people: [], count: 1, reasons: [], fix: [], })); return groups.map((group) => ({ ...group, reasons: blockerReasons(group, reasons), fix: [ ...(BLOCKER_FIXES[group.code]?.(sequenceId) ?? []), ...personSetFixes(group, sequenceId), ...(REMOVABLE_BLOCKERS.has(group.code) ? removeFixes(group, sequenceId) : []), ], })); } function describeBlockers(blockers: readonly ExplainedBlocker[]): string[] { const lines: string[] = []; for (const group of blockers) { const step = typeof group.stepIndex === "number" ? ` (step ${group.stepIndex + 1})` : ""; const names = group.people.slice(0, 3).map((person) => { const fields = (person.fields ?? []).map((field) => field.label); return fields.length ? `${person.name} (missing ${fields.join(", ")})` : person.name; }); const more = group.people.length > 3 ? `, +${group.people.length - 3} more` : ""; const who = names.length > 0 ? ` — ${names.join(", ")}${more}` : ""; lines.push(` - ${group.title}${step}${who}`); for (const reason of group.reasons) lines.push(` why: ${reason}`); if (group.hint) lines.push(` ${group.hint}`); for (const command of group.fix) lines.push(` fix: ${command}`); } return lines; } function readinessCounts(readiness: LaunchReadiness): string { const channels = [ readiness.usesEmail ? "email" : "", readiness.usesLinkedin ? "LinkedIn" : "", ].filter(Boolean); const people = readiness.audienceCount ?? 0; const steps = readiness.stepCount ?? 0; return `${people} ${people === 1 ? "person" : "people"}, ${steps} ${steps === 1 ? "step" : "steps"}${channels.length ? ` (${channels.join(" and ")})` : ""}`; } function launchCommand(row: CampaignRow, readiness: LaunchReadiness): string { return `dench campaign launch ${row._id}${readiness.usesLinkedin ? " --ack-linkedin-risk" : ""}`; } async function runLaunchCheck( ctx: CampaignCliContext, row: CampaignRow, readiness: LaunchReadiness, ): Promise<void> { const ready = readiness.ready === true && typeof readiness.fingerprint === "string"; const blockers = explainBlockers(readiness, String(row._id)); const warnings = readiness.warnings ?? []; const payload = { sequenceId: row._id, name: row.name, status: row.status, ready, fingerprint: readiness.fingerprint ?? null, audienceCount: readiness.audienceCount ?? 0, stepCount: readiness.stepCount ?? 0, usesEmail: readiness.usesEmail === true, usesLinkedin: readiness.usesLinkedin === true, needsLinkedinAck: readiness.usesLinkedin === true, blockers, warnings, next: ready ? launchCommand(row, readiness) : `dench campaign launch ${row._id} --check`, }; const lines = ready ? [`"${row.name}" is ready to launch: ${readinessCounts(readiness)}.`] : [ `"${row.name}" is not ready to launch (${blockers.length} ${blockers.length === 1 ? "blocker" : "blockers"}; ${readinessCounts(readiness)}):`, ...describeBlockers(blockers), ]; if (warnings.length) lines.push("Warnings:", ...warnings.map((warning) => ` - ${warning}`)); lines.push(`Fingerprint: ${readiness.fingerprint ?? "none"}`); lines.push( ready ? `Nothing was launched. After the user says yes: ${launchCommand(row, readiness)}${readiness.usesLinkedin ? " (LinkedIn steps need the account owner to accept the LinkedIn risk)" : ""}` : `Nothing was launched. Fix the blockers, then run: dench campaign launch ${row._id} --check`, ); out(ctx, payload, lines); if (!ready) throw new CampaignCliError( `"${row.name}" is not ready to launch: ${blockers.length} ${blockers.length === 1 ? "blocker" : "blockers"}. Nothing was launched.`, ); } async function runLaunch(ctx: CampaignCliContext): Promise<void> { const check = hasFlag(ctx.args, "--check"); const ack = hasFlag(ctx.args, "--ack-linkedin-risk"); const reference = shift(ctx.args, "campaign id or name"); unknownFlags(ctx.args, "campaign launch"); const row = await resolveCampaign(ctx, reference); if (row.status !== "draft") { throw new CampaignCliError( row.status === "paused" ? `"${row.name}" is paused, not a draft. Use: dench campaign resume <campaign>` : `Only a draft can be launched. "${row.name}" is ${row.status}.`, ); } const readiness = (await callQuery(ctx, `${OUTREACH}:previewLaunch`, { sequenceId: row._id, })) as LaunchReadiness; if (check) return runLaunchCheck(ctx, row, readiness); if (readiness.ready !== true || typeof readiness.fingerprint !== "string") { const lines = [ `"${row.name}" is not ready to launch:`, ...describeBlockers(explainBlockers(readiness, String(row._id))), ]; if ((readiness.warnings ?? []).length > 0) { lines.push( "Warnings:", ...(readiness.warnings ?? []).map((w) => ` - ${w}`), ); } out(ctx, { ...readiness, sequenceId: row._id }, lines); throw new CampaignCliError( "Campaign is not ready to launch. Fix the items above, then run launch again.", ); } if (readiness.usesLinkedin && !ack) { throw new CampaignCliError( "This campaign sends connection requests or messages through a connected LinkedIn account, which LinkedIn's terms discourage and can get the account restricted. Re-run with --ack-linkedin-risk once the account owner accepts that.", ); } const result = (await callMutation(ctx, `${OUTREACH}:launch`, { sequenceId: row._id, fingerprint: readiness.fingerprint, linkedinRiskAcknowledged: ack, })) as { ok: true; status: string }; const payload = { ...result, sequenceId: row._id, audienceCount: readiness.audienceCount, stepCount: readiness.stepCount, warnings: readiness.warnings ?? [], }; const lines = [ `Launched "${row.name}" — ${readiness.audienceCount ?? 0} people, ${readiness.stepCount ?? 0} steps.`, `Watch it: dench campaign get ${row._id}`, ]; if ((readiness.warnings ?? []).length > 0) { lines.push( "Warnings:", ...(readiness.warnings ?? []).map((w) => ` - ${w}`), ); } out(ctx, payload, lines); } export type LaunchStage = { ready: boolean; launched: boolean; audienceCount: number; stepCount: number; needsLinkedinAck: boolean; blockers: ExplainedBlocker[]; warnings: string[]; /** What to run next, or null once launched. */ next: string | null; lines: string[]; }; /** * The end of every build: check readiness and, when asked, launch — the * app's "Launch campaign". Never throws for a blocked campaign; the result * says what blocks it and how to fix each thing. */ export async function launchStage( ctx: Pick<CampaignCliContext, "convex" | "sessionToken">, row: Pick<CampaignRow, "_id" | "name">, opts: { launch: boolean; ackLinkedinRisk: boolean }, ): Promise<LaunchStage> { const cli = { ...ctx, args: [], jsonOutput: true }; const check = async () => (await callQuery(cli, `${OUTREACH}:previewLaunch`, { sequenceId: row._id, })) as LaunchReadiness; let readiness = await check(); const stage = (launched: boolean, extra: string[] = []): LaunchStage => { const ready = readiness.ready === true && typeof readiness.fingerprint === "string"; const blockers = explainBlockers(readiness, String(row._id)); const counts = readinessCounts(readiness); const ack = readiness.usesLinkedin === true; const next = launched ? null : ready ? `dench campaign launch ${row._id}${ack ? " --ack-linkedin-risk" : ""}` : `dench campaign launch ${row._id} --check`; const lines = launched ? [`Launched "${row.name}": ${counts}.`] : ready ? [`"${row.name}" is ready to launch: ${counts}. Nothing was sent yet.`] : [ `"${row.name}" is not ready to launch (${blockers.length} ${blockers.length === 1 ? "blocker" : "blockers"}; ${counts}):`, ...describeBlockers(blockers), ]; lines.push(...extra); if (readiness.warnings?.length) lines.push( "Warnings:", ...readiness.warnings.map((warning) => ` - ${warning}`), ); if (next) lines.push(`Next: ${next}`); return { ready, launched, audienceCount: readiness.audienceCount ?? 0, stepCount: readiness.stepCount ?? 0, needsLinkedinAck: ack, blockers, warnings: readiness.warnings ?? [], next, lines, }; }; if (!opts.launch || readiness.ready !== true) return stage(false); if (readiness.usesLinkedin && !opts.ackLinkedinRisk) return stage(false, [ "Not launched: it sends through a LinkedIn account, which LinkedIn's terms discourage and can get the account restricted. Launch it with --ack-linkedin-risk once the account owner accepts that.", ]); // Work the build scheduled can still settle between the check and the // launch, which changes the fingerprint; one fresh check covers it. for (let attempt = 0; ; attempt++) { try { await callMutation(cli, `${OUTREACH}:launch`, { sequenceId: row._id, fingerprint: readiness.fingerprint ?? "", linkedinRiskAcknowledged: opts.ackLinkedinRisk, }); return stage(true); } catch (error) { const message = errorText(error); if ( attempt > 0 || !/Something changed since the last check/.test(message) ) return stage(false, [`Not launched: ${message}`]); readiness = await check(); if (readiness.ready !== true) return stage(false); } } } async function runLifecycle( ctx: CampaignCliContext, action: "pause" | "resume" | "archive", invoked: string = action, ): Promise<void> { const reference = shift(ctx.args, "campaign id or name"); unknownFlags(ctx.args, `campaign ${invoked}`); const row = await resolveCampaign(ctx, reference); const already = action === "pause" ? row.status === "paused" : action === "resume" ? row.status === "active" : row.status === "archived"; if (already) { out( ctx, { ok: true, sequenceId: row._id, status: row.status, changed: false }, [`"${row.name}" is already ${row.status}.`], ); return; } if (action === "pause" && row.status !== "active") { throw new CampaignCliError( `Only an active campaign can be paused. "${row.name}" is ${row.status}.`, ); } if (action === "resume" && row.status !== "paused") { throw new CampaignCliError( row.status === "draft" ? `"${row.name}" is a draft, not a paused campaign. Check it with: dench campaign launch ${row._id} --check, then launch it once the user says yes.` : `Only a paused campaign can be resumed. "${row.name}" is ${row.status}.`, ); } const fn = action === "pause" ? "pauseSequence" : action === "resume" ? "resumeSequence" : "archiveSequence"; await callMutation(ctx, `${OUTREACH}:${fn}`, { sequenceId: row._id }); const status = action === "pause" ? "paused" : action === "resume" ? "active" : "archived"; const verb = action === "pause" ? "Paused" : action === "resume" ? "Resumed" : "Archived"; out(ctx, { ok: true, sequenceId: row._id, status, changed: true }, [ `${verb} "${row.name}".${action === "archive" ? " Everyone still in flight was stopped." : ""}`, ...(invoked === "delete" ? [ "Campaigns are archived, not erased: it leaves dench campaign list, and its people, leads and activity stay. dench campaign list --all still shows it.", ] : []), ]); } const ENROLLMENT_STATUSES = [ "draft", "active", "paused", "finished", "replied", "stopped", "bounced", "complained", "unsubscribed", "failed", ]; async function runPeople(ctx: CampaignCliContext): Promise<void> { const status = valueFlag(ctx.args, "--status")?.trim().toLowerCase(); const reference = shift(ctx.args, "campaign id or name"); unknownFlags(ctx.args, "campaign people"); if (status !== undefined && !ENROLLMENT_STATUSES.includes(status)) { throw new CampaignCliError( `Unknown --status "${status}". Use one of: ${ENROLLMENT_STATUSES.join(", ")}.`, ); } const row = await resolveCampaign(ctx, reference); const detail = await loadDetail(ctx, String(row._id)); const enrollments = status ? detail.enrollments.filter((e) => e.status === status) : detail.enrollments; const people = enrollments.map((enrollment) => describePerson(enrollment, detail), ); const header = `${enrollments.length} ${enrollments.length === 1 ? "person" : "people"}${status ? ` with status ${status}` : ""} in "${row.name}"`; out( ctx, { sequenceId: row._id, count: enrollments.length, people: people.map((p) => p.json), }, [header, ...people.map((p) => p.line)], ); } function resolvePerson(detail: CampaignDetail, reference: string): Enrollment { const needle = reference.trim(); const byId = detail.enrollments.find((e) => String(e._id) === needle); if (byId) return byId; const lowered = needle.toLowerCase(); const byName = detail.enrollments.filter( (e) => (e.displayName ?? "").trim().toLowerCase() === lowered, ); if (byName.length === 1) return byName[0]; if (byName.length > 1) { throw new CampaignCliError( `${byName.length} people in this campaign are called "${needle}". Use the enrollment id from dench campaign people:\n${byName .map((e) => ` ${e._id} (${e.status})`) .join("\n")}`, ); } throw new CampaignCliError( `"${needle}" is not in this campaign. Run dench campaign people <campaign> for ids and names.`, ); } async function removeEnrollment( ctx: CampaignCliContext, enrollmentId: string, ): Promise<void> { await callMutation(ctx, `${OUTREACH}:removeDraftEnrollment`, { enrollmentId, }); } async function runRemove(ctx: CampaignCliContext): Promise<void> { const person = requiredFlag(ctx.args, "--person"); const reference = shift(ctx.args, "campaign id or name"); unknownFlags(ctx.args, "campaign remove"); const row = await resolveCampaign(ctx, reference); if (row.status !== "draft") { throw new CampaignCliError( `Only a draft campaign's people can be removed; "${row.name}" is ${row.status}. To make sure nothing more is sent to someone, use: dench campaign stop ${row._id} --person <enrollmentId>`, ); } const detail = await loadDetail(ctx, String(row._id)); const enrollment = resolvePerson(detail, person); const name = enrollment.displayName?.trim() || enrollment._id; if (enrollment.firstContactedAt !== undefined) { throw new CampaignCliError( `${name} has already been contacted, so they can't be removed. Use: dench campaign stop ${row._id} --person ${enrollment._id}`, ); } await removeEnrollment(ctx, enrollment._id); out( ctx, { ok: true, sequenceId: row._id, enrollmentId: enrollment._id, status: "removed", changed: true, }, [ `Removed ${name} from the draft "${row.name}". Nothing had been sent to them, and their place in the ${MAX_CAMPAIGN_PEOPLE}-person cap is free again.`, ], ); } const PERSON_SET_USAGE = 'dench campaign person set <campaign> --person <enrollmentId|name> [--email ada@new.com] [--linkedin-url https://linkedin.com/in/ada] [--field "Job Title=CTO" ...]'; /** A field reference may be pasted directly from either merge-token syntax. */ function unwrappedFieldReference(reference: string): string { const value = reference.trim(); if ( (value.startsWith("{{") && value.endsWith("}}")) || (value.startsWith("[[") && value.endsWith("]]")) ) { return value.slice(2, -2).trim(); } return value; } export function parseFieldFlags( values: readonly string[], ): Array<{ key: string; value: string }> { return values.map((raw) => { const at = raw.indexOf("="); const key = at === -1 ? "" : unwrappedFieldReference(raw.slice(0, at)); const value = at === -1 ? "" : raw.slice(at + 1).trim(); if (!key || !value) { throw new CampaignCliError( `--field "${raw}" needs a name and a value, e.g. --field "Job Title=CTO" for {{Job Title}}.`, ); } return { key, value }; }); } function errorText(error: unknown): string { if ( error && typeof error === "object" && "data" in error && typeof (error as { data: unknown }).data === "string" ) return (error as { data: string }).data; return error instanceof Error ? error.message : String(error); } async function runPersonSet(ctx: CampaignCliContext): Promise<void> { const person = requiredFlag(ctx.args, "--person"); const email = valueFlag(ctx.args, "--email")?.trim(); const linkedinProfileUrl = valueFlag(ctx.args, "--linkedin-url")?.trim(); const fields = parseFieldFlags(getAllFlags(ctx.args, "--field")); const reference = shift(ctx.args, "campaign id or name"); unknownFlags(ctx.args, "campaign person set"); if (email === "" || linkedinProfileUrl === "") { throw new CampaignCliError( `--email and --linkedin-url need a value. ${PERSON_SET_USAGE}`, ); } if (!email && !linkedinProfileUrl && fields.length === 0) { throw new CampaignCliError( `Nothing to change. Pass --email, --linkedin-url and/or --field "Name=Value". ${PERSON_SET_USAGE}`, ); } const row = await resolveCampaign(ctx, reference); const detail = await loadDetail(ctx, String(row._id)); const enrollment = resolvePerson(detail, person); const name = enrollment.displayName?.trim() || enrollment._id; if (enrollment.status !== "draft") { throw new CampaignCliError( `${name} is ${enrollment.status}. A person's email, LinkedIn URL and fields can only change before the campaign launches. To reach them at a new address, stop them and add them again: dench campaign stop ${row._id} --person ${enrollment._id}, then dench campaign add ${row._id} --email <address> and/or --linkedin-url <url>.`, ); } const lineKeys = new Set( (row.personalizationTemplate?.variables ?? []).map((variable) => normalizedLineKey(variable.key), ), ); const line = fields.find((field) => lineKeys.has(normalizedLineKey(field.key)), ); if (line) { throw new CampaignCliError( `{{${line.key}}} is a personalized line, not a merge field. Set it with: dench campaign personalize edit ${row._id} --person ${enrollment._id} --line ${line.key} --text "${line.value}". Nothing was saved.`, ); } const saved: string[] = []; const save = async (label: string, fn: string, args: JsonRecord) => { try { await callMutation(ctx, `${OUTREACH}:${fn}`, { enrollmentId: enrollment._id, ...args, }); } catch (error) { throw new CampaignCliError( `Could not save ${label}: ${errorText(error)}${saved.length ? ` Already saved: ${saved.join(", ")}.` : " Nothing was saved."}`, ); } saved.push(label); }; if (email) await save("the email", "updateEnrollmentEmail", { email }); if (linkedinProfileUrl) await save("the LinkedIn URL", "updateEnrollmentLinkedin", { linkedinProfileUrl, }); for (const field of fields) await save(`{{${field.key}}}`, "updateEnrollmentMergeField", field); const parts = [ ...(email ? [`email ${email}`] : []), ...(linkedinProfileUrl ? [`LinkedIn ${linkedinProfileUrl}`] : []), ...fields.map((field) => `{{${field.key}}} = ${field.value}`), ]; out( ctx, { ok: true, sequenceId: row._id, enrollmentId: enrollment._id, updated: { ...(email ? { email } : {}), ...(linkedinProfileUrl ? { linkedinProfileUrl } : {}), ...(fields.length ? { fields: Object.fromEntries( fields.map((field) => [field.key, field.value]), ), } : {}), }, }, [ `Updated ${name} in "${row.name}": ${parts.join(" · ")}.`, `Check the campaign again: dench campaign launch ${row._id} --check`, ], ); } const PERSON_SHOW_USAGE = "dench campaign person show <campaign> --person <enrollmentId|name>"; const SENT_TEXT_LINES = 6; async function runPersonShow(ctx: CampaignCliContext): Promise<void> { const person = requiredFlag(ctx.args, "--person"); const reference = shift(ctx.args, "campaign id or name"); unknownFlags(ctx.args, "campaign person show"); const row = await resolveCampaign(ctx, reference); const detail = await loadDetail(ctx, String(row._id)); const enrollment = resolvePerson(detail, person); const timeline = (await callQuery(ctx, `${OUTREACH}:timeline`, { enrollmentId: enrollment._id, })) as TimelineResult; const steps = timelineSteps(timeline); const described = describePerson(enrollment, detail); const address = personAddress(timeline.enrollment); const stepDocs = new Map( timeline.steps.map((step) => [String(step._id), step]), ); const lines = [described.line]; const contact = [address.email, address.linkedinProfileUrl].filter(Boolean); if (contact.length) lines.push(` ${contact.join(" · ")}`); if (!steps.length) { lines.push(" Nothing has happened for this person yet."); } for (const step of steps) { const doc = stepDocs.get(step.stepId); const label = stepLabel(doc ?? { type: step.type }); const when = step.at === null ? "" : ` · ${fmtRelative(step.at)}`; const until = step.acceptanceDeadline !== undefined ? ` · stops waiting ${fmtWhenRelative(step.acceptanceDeadline)}` : ""; lines.push( ` Step ${step.step} · ${label} · ${[step.outcome, ...step.detail].join(" · ")}${when}${until}`, ); if (step.subject) lines.push(` Subject: ${step.subject}`); if (step.text) { const body = step.text.split("\n"); lines.push( ...body.slice(0, SENT_TEXT_LINES).map((line) => ` ${line}`), ); if (body.length > SENT_TEXT_LINES) lines.push(" …"); } if (step.error) lines.push(` ${step.error}`); } out( ctx, { sequenceId: row._id, ...described.json, ...address, steps, }, lines, ); } async function runPersonCommand(ctx: CampaignCliContext): Promise<void> { const verb = ctx.args.shift(); if (verb === "set") return runPersonSet(ctx); if (verb === "show" || verb === "get" || verb === "history") return runPersonShow(ctx); throw new CampaignCliError( `Use one of:\n ${PERSON_SET_USAGE}\n ${PERSON_SHOW_USAGE}`, ); } const PERSONALIZE = "functions/outreachPersonalization"; type PersonalizationStatus = { template: (LineTemplate & { version?: number }) | null; counts: { done: number; pending: number; failed: number; total: number }; paused: boolean; notice: { code: string; at: number } | null; suggestedContext: { sells?: string }; estimatedCentsPerPerson: number; eligible: number; }; type BackfillResult = { version: number; eligible: number; estimatedCents: number; needsConfirm: boolean; queued: number; }; const PERSONALIZE_USAGE = [ "dench campaign personalize get <campaign>", "dench campaign personalize set <campaign> --file lines.json", "dench campaign personalize write <campaign> [--all] [--yes]", 'dench campaign personalize edit <campaign> --person <enrollmentId|name> --line <key> --text "..."', "dench campaign personalize rewrite <campaign> --person <enrollmentId|name> [--line <key>]", "dench campaign personalize clear <campaign>", ]; function plural(count: number, one: string, many: string): string { return `${count} ${count === 1 ? one : many}`; } async function personalizationStatus( ctx: CampaignCliContext, sequenceId: string, ): Promise<PersonalizationStatus> { return (await callQuery(ctx, `${PERSONALIZE}:getStatus`, { sequenceId, })) as PersonalizationStatus; } function linesText(row: CampaignRow, status: PersonalizationStatus): string[] { const template = status.template; if (!template?.variables.length) { return [ `"${row.name}" has no personalized lines.`, `Add up to 5 AI-written lines, about ${formatCents(status.estimatedCentsPerPerson || LINE_CENTS_PER_PERSON)} a person: dench campaign personalize set ${row._id} --file lines.json`, ]; } const lines = [ `Personalized lines in "${row.name}" (${template.variables.length}):`, ]; for (const variable of template.variables) { lines.push( ` {{${variable.key}}} ${variable.label} — up to ${variable.maxChars ?? 200} characters${variable.fallbackFrom ? `; without a line, uses {{${variable.fallbackFrom}}}` : ""}`, ` instructions: ${variable.prompt}`, ` fallback: ${variable.fallback}`, ); } lines.push(`What you sell: ${template.context.sells}`); if (template.context.offer) lines.push(`Offer: ${template.context.offer}`); const { done, pending, failed, total } = status.counts; lines.push( `Written for ${done} of ${plural(total, "person", "people")}${pending ? ` · ${pending} ${status.paused ? "waiting for the campaign to resume" : "being written"}` : ""}${failed ? ` · ${failed} using the fallback` : ""} · about ${formatCents(status.estimatedCentsPerPerson)} a person`, ); if (status.eligible > 0 && pending === 0) lines.push( `${plural(status.eligible, "person needs", "people need")} lines: dench campaign personalize write ${row._id}`, ); if (status.notice?.code === "insufficient_credits") lines.push( `Writing stopped because the workspace ran out of credits. Add credits, then run dench campaign personalize write ${row._id}`, ); return lines; } function backfillText(row: CampaignRow, result: BackfillResult): string { if (result.needsConfirm) return `${plural(result.eligible, "person needs", "people need")} lines, about ${formatCents(result.estimatedCents)}. Nothing was written yet; confirm with: dench campaign personalize write ${row._id} --yes`; if (result.queued > 0) return `Writing lines for ${plural(result.queued, "person", "people")} now, about ${formatCents(result.estimatedCents)}. Messages wait up to 2 hours for a person's line, then use its fallback.`; return "Nobody needs a new line right now."; } async function readJsonFile(file: string): Promise<unknown> { const raw = await readFile(file, "utf8").catch(() => { throw new CampaignCliError(`Cannot read ${file}`); }); try { return JSON.parse(raw); } catch { throw new CampaignCliError(`${file} is not valid JSON.`); } } function writableForLines(row: CampaignRow): void { if (row.status === "archived") { throw new CampaignCliError( `"${row.name}" is archived, so its personalized lines can't change. Start again from a copy: dench campaign duplicate ${row._id}`, ); } } function noLines(row: CampaignRow): CampaignCliError { return new CampaignCliError( `"${row.name}" has no personalized lines. Add them first: dench campaign personalize set ${row._id} --file lines.json`, ); } function resolveLineKey( template: LineTemplate | null, reference: string, row: CampaignRow, ): string { const variables = template?.variables ?? []; if (!variables.length) throw noLines(row); const wanted = normalizedLineKey(unwrappedFieldReference(reference)); const match = variables.find( (variable) => normalizedLineKey(variable.key) === wanted || normalizedLineKey(variable.label) === wanted, ); if (!match) { throw new CampaignCliError( `"${reference}" is not one of the personalized lines in "${row.name}". Use one of: ${variables.map((variable) => variable.key).join(", ")}.`, ); } return match.key; } async function personalizeGet(ctx: CampaignCliContext): Promise<void> { const reference = shift(ctx.args, "campaign id or name"); unknownFlags(ctx.args, "campaign personalize get"); const row = await resolveCampaign(ctx, reference); const status = await personalizationStatus(ctx, String(row._id)); out(ctx, { sequenceId: row._id, ...status }, linesText(row, status)); } async function personalizeSet(ctx: CampaignCliContext): Promise<void> { const file = requiredFlag(ctx.args, "--file"); const reference = shift(ctx.args, "campaign id or name"); unknownFlags(ctx.args, "campaign personalize set"); const parsed = await readJsonFile(file); const row = await resolveCampaign(ctx, reference); writableForLines(row); const status = await personalizationStatus(ctx, String(row._id)); let template: LineTemplate; try { template = linesTemplateFromFile(parsed, { existing: status.template, suggestedSells: status.suggestedContext.sells, source: file, }); } catch (error) { if (error instanceof LinesFileError) throw new CampaignCliError(error.message); throw error; } const result = (await callMutation(ctx, `${PERSONALIZE}:setTemplate`, { sequenceId: row._id, template, })) as BackfillResult; const keys = template.variables.map((variable) => `{{${variable.key}}}`); out(ctx, { ok: true, sequenceId: row._id, template, ...result }, [ `Saved ${plural(template.variables.length, "personalized line", "personalized lines")} on "${row.name}": ${keys.join(", ")}. Put each one in your steps where that line belongs.`, backfillText(row, result), ]); } async function personalizeClear(ctx: CampaignCliContext): Promise<void> { const reference = shift(ctx.args, "campaign id or name"); unknownFlags(ctx.args, "campaign personalize clear"); const row = await resolveCampaign(ctx, reference); writableForLines(row); await callMutation(ctx, `${PERSONALIZE}:setTemplate`, { sequenceId: row._id, template: null, }); out(ctx, { ok: true, sequenceId: row._id, template: null }, [ `Turned off personalized lines on "${row.name}". Lines not yet written were canceled.`, ]); } async function personalizeWrite(ctx: CampaignCliContext): Promise<void> { const all = hasFlag(ctx.args, "--all"); const yes = hasFlag(ctx.args, "--yes"); const reference = shift(ctx.args, "campaign id or name"); unknownFlags(ctx.args, "campaign personalize write"); const row = await resolveCampaign(ctx, reference); writableForLines(row); const result = (await callMutation(ctx, `${PERSONALIZE}:backfill`, { sequenceId: row._id, mode: all ? "all" : "missing", ...(yes ? { confirmed: true } : {}), })) as BackfillResult; const payload = { sequenceId: row._id, mode: all ? "all" : "missing", ...result, }; if (result.needsConfirm) { out(ctx, payload, [ `${all ? "Rewriting" : "Writing"} lines for ${plural(result.eligible, "person", "people")} in "${row.name}" costs about ${formatCents(result.estimatedCents)}. Nothing was written.`, ]); throw new CampaignCliError( `Confirm the cost with: dench campaign personalize write ${row._id}${all ? " --all" : ""} --yes`, ); } out(ctx, payload, [backfillText(row, result)]); } async function personalizeEdit(ctx: CampaignCliContext): Promise<void> { const person = requiredFlag(ctx.args, "--person"); const line = requiredFlag(ctx.args, "--line"); if (!ctx.args.includes("--text")) { throw new CampaignCliError( 'Missing required --text "..." (an empty --text "" undoes your edit).', ); } const text = valueFlag(ctx.args, "--text") ?? ""; const reference = shift(ctx.args, "campaign id or name"); unknownFlags(ctx.args, "campaign personalize edit"); const row = await resolveCampaign(ctx, reference); const [status, detail] = await Promise.all([ personalizationStatus(ctx, String(row._id)), loadDetail(ctx, String(row._id)), ]); const key = resolveLineKey(status.template, line, row); const enrollment = resolvePerson(detail, person); const name = enrollment.displayName?.trim() || enrollment._id; await callMutation(ctx, `${PERSONALIZE}:updateAiVariable`, { enrollmentId: enrollment._id, key, value: text, }); const cleared = !text.trim(); out( ctx, { ok: true, sequenceId: row._id, enrollmentId: enrollment._id, key, value: cleared ? null : text.trim(), }, [ cleared ? `Undid the edit to ${name}'s {{${key}}} in "${row.name}". They get the fallback until a line is written: dench campaign personalize rewrite ${row._id} --person ${enrollment._id} --line ${key}` : `Set ${name}'s {{${key}}} in "${row.name}". Braces are removed and text past the line's length is cut.`, ], ); } async function personalizeRewrite(ctx: CampaignCliContext): Promise<void> { const person = requiredFlag(ctx.args, "--person"); const line = valueFlag(ctx.args, "--line"); const reference = shift(ctx.args, "campaign id or name"); unknownFlags(ctx.args, "campaign personalize rewrite"); const row = await resolveCampaign(ctx, reference); writableForLines(row); const [status, detail] = await Promise.all([ personalizationStatus(ctx, String(row._id)), loadDetail(ctx, String(row._id)), ]); if (!status.template?.variables.length) throw noLines(row); const key = line === undefined ? undefined : resolveLineKey(status.template, line, row); const enrollment = resolvePerson(detail, person); const name = enrollment.displayName?.trim() || enrollment._id; const result = (await callMutation( ctx, `${PERSONALIZE}:rewriteForEnrollment`, { enrollmentId: enrollment._id, ...(key ? { key } : {}) }, )) as { queued: boolean }; const what = key ? `{{${key}}}` : "lines"; out( ctx, { ok: true, sequenceId: row._id, enrollmentId: enrollment._id, ...(key ? { key } : {}), queued: result.queued, }, [ result.queued ? `Writing new ${what} for ${name} in "${row.name}", about ${formatCents(status.estimatedCentsPerPerson)}. See them with: dench campaign preview ${row._id} --person ${enrollment._id}` : `New ${what} for ${name} are already being written.`, ], ); } async function runPersonalize(ctx: CampaignCliContext): Promise<void> { const verb = ctx.args.shift(); switch (verb) { case "get": case "show": case "status": return personalizeGet(ctx); case "set": return personalizeSet(ctx); case "clear": case "off": return personalizeClear(ctx); case "write": case "backfill": return personalizeWrite(ctx); case "edit": return personalizeEdit(ctx); case "rewrite": return personalizeRewrite(ctx); default: throw new CampaignCliError( `Use one of:\n ${PERSONALIZE_USAGE.join("\n ")}`, ); } } async function runSync(ctx: CampaignCliContext): Promise<void> { const reference = shift(ctx.args, "campaign id or name"); unknownFlags(ctx.args, "campaign sync"); const row = await resolveCampaign(ctx, reference); if (row.status === "archived" || row.status === "completed") { out( ctx, { ok: true, sequenceId: row._id, status: row.status, updated: 0 }, [ `"${row.name}" is ${row.status}, so nobody in it can use new contact details. Nothing changed.`, ], ); return; } const result = (await callMutation( ctx, `${OUTREACH}:syncEnrollmentsFromRecords`, { sequenceId: row._id }, )) as { updated: number }; out( ctx, { ok: true, sequenceId: row._id, status: row.status, updated: result.updated, }, [ result.updated === 0 ? `Nobody in "${row.name}" had anything new on their CRM record.` : `Updated ${result.updated} ${result.updated === 1 ? "person" : "people"} in "${row.name}" from their CRM records.`, "Only empty details are filled (email, LinkedIn URL, name and merge fields); nothing already in the campaign is overwritten.", ], ); } async function runResumeAll(ctx: CampaignCliContext): Promise<void> { const reference = shift(ctx.args, "campaign id or name"); unknownFlags(ctx.args, "campaign resume-person"); const row = await resolveCampaign(ctx, reference); if (row.status !== "active") { throw new CampaignCliError( row.status === "paused" ? `"${row.name}" is paused. Resume the campaign first: dench campaign resume ${row._id}` : `Only people in an active campaign can be resumed. "${row.name}" is ${row.status}.`, ); } const result = (await callMutation( ctx, `${OUTREACH}:resumePausedEnrollments`, { sequenceId: row._id }, )) as { resumed: number }; out(ctx, { ok: true, sequenceId: row._id, resumed: result.resumed }, [ result.resumed === 0 ? `Nobody in "${row.name}" was paused, so nothing changed.` : `Resumed ${result.resumed} paused ${result.resumed === 1 ? "person" : "people"} in "${row.name}". Their next step is scheduled now.`, ]); } async function runPerson( ctx: CampaignCliContext, action: "stop" | "resume-person", ): Promise<void> { if (action === "resume-person" && hasFlag(ctx.args, "--all")) { if (ctx.args.includes("--person")) { throw new CampaignCliError( "Pass either --person or --all to resume-person, not both.", ); } return runResumeAll(ctx); } if (action === "resume-person" && !ctx.args.includes("--person")) { throw new CampaignCliError( "Missing required --person <enrollmentId|name>, or --all to resume every paused person.", ); } const person = requiredFlag(ctx.args, "--person"); const reference = shift(ctx.args, "campaign id or name"); unknownFlags(ctx.args, `campaign ${action}`); const row = await resolveCampaign(ctx, reference); const detail = await loadDetail(ctx, String(row._id)); const enrollment = resolvePerson(detail, person); const name = enrollment.displayName?.trim() || enrollment._id; if (action === "stop") { if (row.status === "draft" && enrollment.firstContactedAt === undefined) { await removeEnrollment(ctx, enrollment._id); out( ctx, { ok: true, enrollmentId: enrollment._id, status: "removed", changed: true, }, [ `Removed ${name} from the draft "${row.name}" instead of stopping them: nothing had been sent, and a stopped person would block the launch.`, ], ); return; } if ( [ "finished", "stopped", "replied", "bounced", "complained", "unsubscribed", "failed", ].includes(enrollment.status) ) { out( ctx, { ok: true, enrollmentId: enrollment._id, status: enrollment.status, changed: false, }, [`${name} is already ${enrollment.status}; nothing more will be sent.`], ); return; } await callMutation(ctx, `${OUTREACH}:stopEnrollment`, { enrollmentId: enrollment._id, }); out( ctx, { ok: true, enrollmentId: enrollment._id, status: "stopped", changed: true, }, [ `Stopped ${name} in "${row.name}". They will get nothing more from this campaign.`, ], ); return; } if (enrollment.status !== "paused") { throw new CampaignCliError( `${name} is ${enrollment.status}, not paused. Only a paused person can be resumed.`, ); } if (row.status !== "active") { throw new CampaignCliError( `Resume the campaign first: dench campaign resume ${row._id}`, ); } await callMutation(ctx, `${OUTREACH}:resumeEnrollment`, { enrollmentId: enrollment._id, }); out( ctx, { ok: true, enrollmentId: enrollment._id, status: "active", changed: true }, [`Resumed ${name} in "${row.name}".`], ); } const EVENT_LABELS: Record<string, string> = { enrolled: "added to the campaign", launched: "campaign launched", paused: "campaign paused", resumed: "campaign resumed", completed: "campaign completed", revived: "campaign picked back up", archived: "campaign archived", profile_visited: "profile visited", invite_sent: "connection request sent", message_sent: "LinkedIn message sent", email_sent: "email sent", connection_accepted: "accepted the connection", reply_received: "replied", step_skipped: "step skipped", step_failed: "step failed", person_paused: "paused", person_resumed: "resumed", person_stopped: "stopped", person_finished: "finished every step", bounced: "email bounced", unsubscribed: "unsubscribed", manual_review: "needs a look", }; type EventRow = { occurredAt: number; kind: string; channel?: string; personName?: string; detail?: string; actorName?: string; stepIndex?: number; }; type MessagePreview = { channel: "email" | "linkedin"; subject?: string; textBody?: string; htmlBody?: string; messageBody?: string; fromName?: string; fromEmail?: string; toName: string; toEmail?: string; missingFields: string[]; warnings: string[]; skippedReason?: string; personalized?: Array<{ key: string; label: string; value: string; state: string; }>; }; const PREVIEW = "functions/outreachMessagePreview"; const LINE_STATE_TEXT: Record<string, string> = { written: "written", edited: "edited by hand", writing: "being written; the fallback shows until then", paused: "waiting for the campaign to resume; the fallback shows until then", fallback: "nothing specific found, so the fallback is used", not_written: "not written yet; the fallback shows until then", }; function htmlToText(html: string): string { return html .replace(/<\s*br\s*\/?\s*>/gi, "\n") .replace(/<\/(p|div|h[1-6]|li|tr)>/gi, "\n") .replace(/<[^>]+>/g, "") .replace(/ /g, " ") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, '"') .replace(/'/g, "'") .replace(/&/g, "&") .replace(/\n{3,}/g, "\n\n") .trim(); } function indent(text: string): string[] { return text.split("\n").map((line) => ` ${line}`); } function previewLines( stepNumber: number, step: { type: string; waitHours?: unknown }, preview: MessagePreview, ): string[] { const from = preview.fromName || preview.fromEmail ? ` · from ${preview.fromName ?? ""}${preview.fromEmail ? `${preview.fromName ? " " : ""}<${preview.fromEmail}>` : ""}` : ""; const to = preview.channel === "email" && preview.toEmail ? ` to ${preview.toEmail}` : ""; const lines = [`Step ${stepNumber} · ${stepLabel(step)}${from}${to}`]; if (preview.skippedReason) lines.push(` skipped: ${preview.skippedReason}`); if (preview.channel === "email") { lines.push(` Subject: ${preview.subject ?? ""}`); const body = preview.textBody?.trim() || htmlToText(preview.htmlBody ?? ""); lines.push(...indent(body || "(empty)")); } else { lines.push(...indent(preview.messageBody?.trim() || "(empty)")); } for (const line of preview.personalized ?? []) lines.push( ` {{${line.key}}} ${line.label}: ${LINE_STATE_TEXT[line.state] ?? line.state}`, ); if (preview.missingFields.length) lines.push( ` missing: ${preview.missingFields.map((field) => `{{${field}}}`).join(", ")}`, ); for (const warning of preview.warnings) lines.push(` note: ${warning}`); return lines; } async function runPreview(ctx: CampaignCliContext): Promise<void> { const personRef = valueFlag(ctx.args, "--person"); const stepRaw = valueFlag(ctx.args, "--step"); const reference = shift(ctx.args, "campaign id or name"); unknownFlags(ctx.args, "campaign preview"); const row = await resolveCampaign(ctx, reference); const detail = await loadDetail(ctx, String(row._id)); const steps = [...detail.steps].sort( (a, b) => (a.order ?? 0) - (b.order ?? 0), ); const sendable = steps .map((step, index) => ({ step, number: index + 1 })) .filter(({ step }) => step.type !== "wait" && !isBranchStep(step.type)); if (!sendable.length) { throw new CampaignCliError( `"${row.name}" has no email or LinkedIn steps to preview yet. Add them: dench campaign steps set ${row._id} --file steps.json`, ); } let chosen = sendable; if (stepRaw !== undefined) { const number = Number(stepRaw); if (!Number.isInteger(number) || number < 1 || number > steps.length) { throw new CampaignCliError( `--step must be a step number from 1 to ${steps.length} (see dench campaign steps get ${row._id}).`, ); } chosen = sendable.filter((entry) => entry.number === number); if (!chosen.length) { const kind = steps[number - 1]?.type; throw new CampaignCliError( `Step ${number} is ${kind === "condition" ? "an If / else" : kind === "wait_for_connection" ? "a Wait for them to accept" : "a wait"}, so it sends nothing. Steps with a message: ${sendable.map((entry) => entry.number).join(", ")}.`, ); } } if (!detail.enrollments.length) { throw new CampaignCliError( `Nobody is in "${row.name}" yet, so there is no one to preview for. Add people first: dench campaign add ${row._id} ...`, ); } const enrollment = personRef ? resolvePerson(detail, personRef) : detail.enrollments[0]; const name = enrollment.displayName?.trim() || enrollment._id; const rendered = await Promise.all( chosen.map(async ({ step, number }) => ({ step, number, preview: (await callQuery(ctx, `${PREVIEW}:preview`, { sequenceId: row._id, enrollmentId: enrollment._id, stepId: step._id, stepIndex: step.order ?? number - 1, })) as MessagePreview, })), ); const firstPerson = personRef ? "" : " (the first person; pick someone with --person)"; const lines = [ detail.sequence.stepModel === "workflow" ? `The messages on every path of "${row.name}", for ${name}${firstPerson}. They get only the ones on the path they take:` : `What ${name} gets from "${row.name}"${firstPerson}:`, ]; for (const { step, number, preview } of rendered) lines.push("", ...previewLines(number, step, preview)); out( ctx, { sequenceId: row._id, enrollmentId: enrollment._id, person: name, steps: rendered.map(({ step, number, preview }) => ({ step: number, stepId: step._id, type: step.type, ...preview, })), }, lines, ); } const ACTIVITY = "functions/crm/activity"; const REPLIES_DEFAULT_LIMIT = 20; const REPLIES_MAX_LIMIT = 50; const REPLY_LOOKUP_CONCURRENCY = 5; const CHANNEL_KINDS: Record<string, string[]> = { email: ["email_in", "email_out"], linkedin: ["linkedin_in", "linkedin_out"], }; type LedgerThread = { type: string; thread?: { threadKey: string; title?: string }; }; type LedgerMessage = { kind: string; occurredAt: number; direction?: "inbound" | "outbound"; from: string; snippet: string; }; type InboundReply = { at: number; channel: "email" | "linkedin"; from: string; text: string; subject?: string; answered: boolean; }; function parseSince(raw: string): number { const relative = /^(\d+)([hdw])$/i.exec(raw.trim()); if (relative) { const unit = relative[2].toLowerCase(); const span = unit === "h" ? 3_600_000 : unit === "d" ? 86_400_000 : 604_800_000; return Date.now() - Number(relative[1]) * span; } const parsed = Date.parse(raw); if (Number.isNaN(parsed)) { throw new CampaignCliError( "--since must look like 12h, 7d, 2w or a date such as 2026-09-01", ); } return parsed; } async function latestInbound( ctx: CampaignCliContext, places: ReadonlyArray<{ entryId: string; objectName: string }>, channel: string | undefined, ): Promise<InboundReply | null> { let best: InboundReply | null = null; for (const place of places) { let feed: { items?: LedgerThread[] }; try { feed = (await callQuery(ctx, `${ACTIVITY}:listForEntry`, { objectName: place.objectName, entryId: place.entryId, order: "desc", limit: 3, kinds: (channel && CHANNEL_KINDS[channel]) || [ ...CHANNEL_KINDS.email, ...CHANNEL_KINDS.linkedin, ], })) as { items?: LedgerThread[] }; } catch { continue; } for (const item of feed.items ?? []) { if (item.type !== "thread" || !item.thread) continue; let messages: LedgerMessage[]; try { messages = (await callQuery(ctx, `${ACTIVITY}:listThreadMessages`, { objectName: place.objectName, entryId: place.entryId, threadKey: item.thread.threadKey, limit: 20, })) as LedgerMessage[]; } catch { continue; } const inbound = [...messages] .reverse() .find((message) => message.direction === "inbound"); if (!inbound || (best && inbound.occurredAt <= best.at)) continue; best = { at: inbound.occurredAt, channel: inbound.kind.startsWith("linkedin") ? "linkedin" : "email", from: inbound.from, text: inbound.snippet, ...(item.thread.title ? { subject: item.thread.title } : {}), answered: messages.some( (message) => message.direction === "outbound" && message.occurredAt > inbound.occurredAt, ), }; } } return best; } async function runReplies(ctx: CampaignCliContext): Promise<void> { const limitRaw = valueFlag(ctx.args, "--limit"); const sinceRaw = valueFlag(ctx.args, "--since"); const personRef = valueFlag(ctx.args, "--person"); const reference = shift(ctx.args, "campaign id or name"); unknownFlags(ctx.args, "campaign replies"); const limit = limitRaw === undefined ? REPLIES_DEFAULT_LIMIT : Number(limitRaw); if (!Number.isInteger(limit) || limit < 1 || limit > REPLIES_MAX_LIMIT) { throw new CampaignCliError( `--limit must be a whole number from 1 to ${REPLIES_MAX_LIMIT}`, ); } const since = sinceRaw === undefined ? undefined : parseSince(sinceRaw); const row = await resolveCampaign(ctx, reference); const detail = await loadDetail(ctx, String(row._id)); const only = personRef ? resolvePerson(detail, personRef) : null; const replied = detail.enrollments .filter( (enrollment) => (enrollment.status === "replied" || enrollment.repliedAt != null) && (!only || enrollment._id === only._id) && (since === undefined || (enrollment.repliedAt ?? 0) >= since), ) .sort((a, b) => (b.repliedAt ?? 0) - (a.repliedAt ?? 0)); const shown = replied.slice(0, limit); const people: Array<{ enrollment: Enrollment; name: string; reply: InboundReply | null; }> = []; for (let start = 0; start < shown.length; start += REPLY_LOOKUP_CONCURRENCY) { people.push( ...(await Promise.all( shown .slice(start, start + REPLY_LOOKUP_CONCURRENCY) .map(async (enrollment) => { const connected = detail.connectedEntries?.[String(enrollment._id)]; const places = [ connected ? { entryId: connected.entryId, objectName: connected.objectName, } : null, connected?.lead ? { entryId: connected.lead.entryId, objectName: "lead" } : null, ].filter( ( place, index, all, ): place is { entryId: string; objectName: string; } => place !== null && all.findIndex((other) => other?.entryId === place.entryId) === index, ); return { enrollment, name: enrollment.displayName?.trim() || connected?.label || enrollment._id, reply: await latestInbound( ctx, places, enrollment.repliedChannel, ), }; }), )), ); } const header = `${replied.length} ${replied.length === 1 ? "person" : "people"} replied to "${row.name}"${since !== undefined ? ` since ${fmtWhen(since)}` : ""}${replied.length > shown.length ? ` (showing the newest ${shown.length}; raise --limit, up to ${REPLIES_MAX_LIMIT})` : replied.length ? " (newest first)" : ""}`; const lines = [header]; for (const { enrollment, name, reply } of people) { const connected = detail.connectedEntries?.[String(enrollment._id)]; const bits = [ `replied${enrollment.repliedChannel ? ` by ${enrollment.repliedChannel}` : ""}${enrollment.repliedAt ? ` ${fmtRelative(enrollment.repliedAt)}` : ""}`, ]; if (connected?.lead?.status) bits.push(`lead: ${connected.lead.status}`); if (reply && !reply.answered) bits.push("not answered yet"); lines.push(` ${name} — ${bits.join(" · ")} [${enrollment._id}]`); if (reply) { lines.push( ...reply.text.split("\n").map((line) => ` ${line}`), ` — ${reply.channel}${reply.subject ? `, "${reply.subject}"` : ""}, ${fmtWhen(reply.at)}`, ); } else { lines.push( " (the message text is not in the activity ledger yet; dench crm activity <object> <entryId> shows what has arrived)", ); } } out( ctx, { sequenceId: row._id, count: replied.length, shown: shown.length, replies: people.map(({ enrollment, name, reply }) => { const connected = detail.connectedEntries?.[String(enrollment._id)]; return { enrollmentId: enrollment._id, name, status: enrollment.status, repliedAt: enrollment.repliedAt ?? null, repliedChannel: enrollment.repliedChannel ?? null, lead: connected?.lead ?? null, record: connected ? { entryId: connected.entryId, objectName: connected.objectName } : null, reply, }; }), }, lines, ); } type Membership = { enrollment: { _id: string; sequenceId: string; displayName?: string; status: string; currentStepIndex?: number; repliedAt?: number; }; sequenceName: string; isOwner?: boolean; senders?: { email?: { label: string; status: string } | null; linkedin?: { label: string; status: string } | null; }; }; async function runWhere(ctx: CampaignCliContext): Promise<void> { const entryId = shift(ctx.args, "CRM record id"); unknownFlags(ctx.args, "campaign where"); const memberships = (await callQuery( ctx, `${OUTREACH}:listEnrollmentsForEntry`, { entryId }, )) as Membership[]; const lines = [ memberships.length ? `This record is in ${memberships.length} ${memberships.length === 1 ? "campaign" : "campaigns"} (newest first):` : "This record is not in any campaign.", ]; for (const { enrollment, sequenceName, senders } of memberships) { const bits = [enrollment.status]; if (typeof enrollment.currentStepIndex === "number") bits.push(`step ${enrollment.currentStepIndex + 1}`); if (enrollment.repliedAt) bits.push("replied"); const from = [senders?.email?.label, senders?.linkedin?.label].filter( Boolean, ); if (from.length) bits.push(`sent from ${from.join(" + ")}`); lines.push( ` ${sequenceName} — ${bits.join(" · ")} [${enrollment.sequenceId}, person ${enrollment._id}]`, ); } out( ctx, { entryId, count: memberships.length, campaigns: memberships.map(({ enrollment, sequenceName, senders }) => ({ sequenceId: enrollment.sequenceId, name: sequenceName, enrollmentId: enrollment._id, status: enrollment.status, currentStepIndex: enrollment.currentStepIndex ?? null, repliedAt: enrollment.repliedAt ?? null, senders: senders ?? null, })), }, lines, ); } type PipelineCounts = { total: number; statuses: Array<{ name: string; color: string; count: number }>; other: number; }; async function runPipeline(ctx: CampaignCliContext): Promise<void> { const reference = shift(ctx.args, "campaign id or name"); unknownFlags(ctx.args, "campaign pipeline"); const row = await resolveCampaign(ctx, reference); const counts = (await callQuery( ctx, `${OUTREACH}:getSequenceLeadStatusCounts`, { sequenceId: row._id, }, )) as PipelineCounts | null; if (!counts) throw new CampaignCliError("Campaign not found"); const width = Math.max( 5, ...counts.statuses.map((status) => status.name.length), ); const lines = [ `Where ${counts.total} ${counts.total === 1 ? "person" : "people"} in "${row.name}" stand in the lead pipeline:`, ...counts.statuses.map( (status) => ` ${status.name.padEnd(width)} ${status.count}`, ), ]; if (counts.other > 0) lines.push( ` ${"other".padEnd(width)} ${counts.other} (no lead, or a status outside the pipeline)`, ); out(ctx, { sequenceId: row._id, ...counts }, lines); } async function runEvents(ctx: CampaignCliContext): Promise<void> { const limitRaw = valueFlag(ctx.args, "--limit"); const maxPagesRaw = valueFlag(ctx.args, "--max-pages"); const reference = shift(ctx.args, "campaign id or name"); unknownFlags(ctx.args, "campaign events"); const limit = limitRaw === undefined ? DEFAULT_EVENTS_PAGE_SIZE : Number(limitRaw); if (!Number.isInteger(limit) || limit < 1 || limit > MAX_EVENTS_PAGE_SIZE) { throw new CampaignCliError( `--limit must be a whole number from 1 to ${MAX_EVENTS_PAGE_SIZE}.`, ); } const maxPages = maxPagesRaw === undefined ? 1 : Number(maxPagesRaw); if (!Number.isInteger(maxPages) || maxPages < 1) { throw new CampaignCliError("--max-pages must be a positive whole number."); } const row = await resolveCampaign(ctx, reference); const result = await collectPaginated( async (cursor) => (await callQuery(ctx, `${OUTREACH}:listSequenceEvents`, { sequenceId: row._id, paginationOpts: { numItems: limit, cursor }, })) as { page: unknown[]; isDone: boolean; continueCursor: string | null; }, { maxPages }, ); const events = result.rows as EventRow[]; const lines = events.map((event) => { const who = event.personName ? `${event.personName}: ` : ""; const label = EVENT_LABELS[event.kind] ?? event.kind.replace(/_/g, " "); const step = typeof event.stepIndex === "number" ? ` (step ${event.stepIndex + 1})` : ""; const detail = event.detail ? ` — ${event.detail}` : ""; const actor = event.actorName ? ` (by ${event.actorName})` : ""; return `${fmtWhen(event.occurredAt)} ${who}${label}${step}${detail}${actor}`; }); if (lines.length === 0) lines.push(`No activity yet on "${row.name}".`); if (!result.isDone) { lines.push( `… more: dench campaign events ${row._id} --max-pages ${maxPages + 1}`, ); } out( ctx, { sequenceId: row._id, events, isDone: result.isDone, continueCursor: result.continueCursor, pagesFetched: result.pagesFetched, }, lines, ); } type MailboxRow = { _id: string; email?: string; provider?: string; status?: string; statusReason?: string | null; ownerName?: string; isOwner?: boolean; visibility?: string; remainingToday?: number; warmupStatus?: string; readyAt?: number; dailyColdSendCap?: number; }; const WARMUP_TEXT: Record<string, string> = { ready: "warm-up done", skipped: "warm-up skipped", warming: "warming up", pending_auth: "warm-up waiting for the owner to finish connecting it", error: "warm-up needs the owner's attention", }; function describeWarmup(mailbox: { warmupStatus: string | null; readyAt: number | null; }): string | null { if (!mailbox.warmupStatus) return null; const text = WARMUP_TEXT[mailbox.warmupStatus] ?? mailbox.warmupStatus.replace(/_/g, " "); return mailbox.warmupStatus === "warming" && mailbox.readyAt ? `${text} until ${fmtWhen(mailbox.readyAt)}` : text; } type LinkedinSenderRow = { linkedinAccountId: string; displayName?: string; status?: string; ownerName?: string; isOwner?: boolean; visibility?: string; remainingToday?: { invites?: number; messages?: number; profiles?: number }; remainingInvitesThisWeek?: number; availability?: string; }; function remainingCapacity(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : null; } /** * The sending accounts a pool (or legacy step) can name. Both queries accept * the CLI token and scope to the acting member's usable connections. Never * fall back to the browser-only LinkedIn settings query or provider ids. */ async function runAccounts(ctx: CampaignCliContext): Promise<void> { unknownFlags(ctx.args, "campaign accounts"); const [rows, linkedinRows] = await Promise.all([ callQuery(ctx, "functions/emailMailboxAccounts:listAccounts", {}), callQuery(ctx, "functions/linkedinAccounts:listSenderAccounts", {}), ]); const mailboxes = (Array.isArray(rows) ? (rows as MailboxRow[]) : []).map( (row) => ({ emailMailboxAccountId: row._id, email: row.email ?? null, provider: row.provider ?? null, status: row.status ?? null, statusReason: row.statusReason ?? null, ownerName: row.ownerName ?? null, isOwner: row.isOwner === true, visibility: row.visibility ?? null, remainingToday: remainingCapacity(row.remainingToday), dailyColdSendCap: remainingCapacity(row.dailyColdSendCap), warmupStatus: row.warmupStatus ?? null, readyAt: typeof row.readyAt === "number" ? row.readyAt : null, }), ); // An allowlist on the client as well as the query's return validator keeps // provider fields out of JSON output, including when servers are upgraded. const linkedinAccounts = ( Array.isArray(linkedinRows) ? (linkedinRows as LinkedinSenderRow[]) : [] ).map((row) => ({ linkedinAccountId: row.linkedinAccountId, displayName: row.displayName ?? "LinkedIn account", status: row.status ?? null, ownerName: row.ownerName ?? null, isOwner: row.isOwner === true, visibility: row.visibility ?? null, remainingToday: { invites: remainingCapacity(row.remainingToday?.invites), messages: remainingCapacity(row.remainingToday?.messages), profiles: remainingCapacity(row.remainingToday?.profiles), }, remainingInvitesThisWeek: remainingCapacity(row.remainingInvitesThisWeek), availability: row.availability ?? "unknown", })); const lines = [ `Mailboxes (${mailboxes.length}) — use these ids with --email-mailboxes:`, ...mailboxes.map((m) => { const warmup = describeWarmup(m); return ` ${m.email ?? "(no address)"} — ${m.provider ?? "mailbox"} · ${m.status ?? "unknown"}${m.statusReason ? ` (${m.statusReason})` : ""}${warmup ? ` · ${warmup}` : ""}${m.remainingToday === null ? "" : ` · ${m.remainingToday}${m.dailyColdSendCap === null ? "" : ` of ${m.dailyColdSendCap}`} cold emails left today`}${m.ownerName && !m.isOwner ? ` · ${m.ownerName}` : ""}${m.visibility === "private" ? " · private" : ""} [${m.emailMailboxAccountId}]`; }), ]; if (mailboxes.length === 0) { lines.push( " none — connect Gmail or Outlook under Outreach → Accounts in the Dench app", ); } lines.push( "", `LinkedIn accounts (${linkedinAccounts.length}) — use these ids with --linkedin-accounts:`, ...linkedinAccounts.map((account) => { const remaining = account.remainingToday; const availability = HOLD_TEXT[account.availability] ?? account.availability; return ` ${account.displayName} — ${availability} · ${remaining.invites ?? "?"} invites, ${remaining.messages ?? "?"} messages, ${remaining.profiles ?? "?"} profile views remaining today · ${account.remainingInvitesThisWeek ?? "?"} invites remaining this week${account.ownerName && !account.isOwner ? ` · ${account.ownerName}` : ""}${account.visibility === "private" ? " · private" : ""} [${account.linkedinAccountId}]`; }), ); if (linkedinAccounts.length === 0) { lines.push( " none — connect LinkedIn under Outreach → Accounts in the Dench app", ); } const note = "Capacity is a usage snapshot before queued work and campaign limits. A mailbox's daily cap and warm-up, and a LinkedIn account's limits and hours, are set by its owner in the Dench app. Pool changes require access for both you and the campaign owner; launch and dispatch revalidate availability."; lines.push("", note); out( ctx, { mailboxes, linkedinAccounts, note, }, lines, ); } // ─── Help ───────────────────────────────────────────────────────────────── const SUBCOMMAND_ALIASES: Record<string, string> = { ls: "list", today: "queue", new: "create", status: "get", show: "get", update: "set", configure: "set", enroll: "add", start: "launch", activity: "events", senders: "accounts", delete: "archive", copy: "duplicate", clone: "duplicate", memberships: "where", personalization: "personalize", lines: "personalize", variables: "personalize", }; export function campaignHelp(subcommand?: string): void { const text = campaignHelpText(); const name = subcommand ? (SUBCOMMAND_ALIASES[subcommand] ?? subcommand) : undefined; const lines = text.split("\n"); const usage: string[] = []; lines.forEach((line, index) => { if (!name || !line.startsWith(` dench campaign ${name} `)) return; usage.push(line); for (const next of lines.slice(index + 1)) { if (!next.startsWith(" ")) break; usage.push(next); } }); if (!usage.length) { console.log(text); return; } console.log( `Usage:\n${usage.join("\n")}\n\n<campaign> is an id or an exact (case-insensitive) name. Run 'dench campaign help' for everything.`, ); } export function campaignHelpText(): string { return `Usage: dench campaign <subcommand> [--json] Outreach campaigns send email and LinkedIn steps to a list of people on one schedule. <campaign> is an id or an exact (case-insensitive) name. Start here (references/CAMPAIGNS.md has the full recipe, from a brief to launch): A new campaign that finds its own people (signals or a lookalike), in one command: dench campaign builder run --file campaign.json --timezone <tz> [--launch [--ack-linkedin-risk]] Saves the setup, finds the people, writes the sequence from "brief", picks your senders, creates the campaign and checks it. --launch also launches it when nothing blocks it; otherwise the result lists each blocker with its fix. Every run ends with a created campaign, not a half-built draft. A fixed list (CSV, CRM view, pasted people or a one-time search): 1. dench campaign accounts sender ids: mailboxes and LinkedIn accounts 2. dench campaign draft --name "..." --timezone <tz> [--email-mailboxes ..] [--linkedin-accounts ..] 3. dench campaign steps set <campaign> --file steps.json 4. dench campaign search --filters @filters.json --not-industries "..." --limit 25 --out audience.json 5. dench campaign add <campaign> --people-file audience.json, then dench campaign preview <campaign> 6. dench campaign launch <campaign> --check, then launch (now, when the request asked for it) Browse: dench campaign list [--all] [--with-references] # live campaigns; --all includes archived dench campaign get <campaign> # status, schedule, funnel, Now/Next, holds, people dench campaign people <campaign> [--status active|paused|finished|replied|stopped|bounced|failed|draft|complained|unsubscribed] dench campaign events <campaign> [--limit 50] [--max-pages 1] # activity log, newest first dench campaign preview <campaign> [--person <enrollmentId|name>] [--step N] # the exact messages one person gets Renders every email and LinkedIn step (or step N) with their fields and personalized lines filled in, and says what is missing or would be skipped. Nothing is sent. Without --person it uses the first person. dench campaign person show <campaign> --person <enrollmentId|name> # what the campaign did for one person, step by step Each step with its outcome (sent, opened, clicked, skipped and why, waiting, failed), when it happened, and the subject and start of the text that was sent. Read-only. dench campaign replies <campaign> [--person <enrollmentId|name>] [--since 7d] [--limit 20] # who replied, with their latest reply Lists the people who replied, newest first, with the text of their latest inbound email or LinkedIn message (up to 400 characters, quoted history removed) and whether you have answered. Read-only. dench campaign pipeline <campaign> # how many of its people are at each lead Status (New, Contacted, Replied, ...) dench campaign where <entryId> # the campaigns a CRM record (person or lead) is in, and its status in each dench campaign accounts # email + LinkedIn sender ids, warm-up, availability and capacity Create and configure: dench campaign create --name "Founder outreach" [--timezone America/Los_Angeles] \\ [--window "Mon-Fri 9-18"] [--window "Sat 10-12"] [--daily-cap 30] \\ [--email-mailboxes id1,id2] [--linkedin-accounts id1,id2] \\ [--stop-on-reply|--no-stop-on-reply] [--start 2026-09-03T09:00:00-07:00] [--end 2026-10-01] dench campaign set <campaign> [--name "New name"] [--timezone <tz>] [--window "<spec>" ...] \\ [--email-mailboxes id1,id2|none] [--linkedin-accounts id1,id2|none] \\ [--daily-cap 1-100] [--stop-on-reply|--no-stop-on-reply] [--start <ISO>|--clear-start] [--end <ISO>|--clear-end] dench campaign duplicate <campaign> [--name "Founder outreach 2"] # new draft: same steps, schedule, senders and lines, no people Over ${MAX_CAMPAIGN_PEOPLE} people: duplicate, then add the next page of the search (--page-token) to the copy. dench campaign steps get <campaign> [--with-ids] # prints steps as JSON you can edit; --with-ids adds stepId and updatedAt A workflow prints {"stepModel":"workflow","steps":[...]} with each step's key and paths; steps set takes it back as is. dench campaign steps set <campaign> --file steps.json [--allow-single-braces] # replaces every step; drafts only dench campaign steps edit <campaign> --file steps.json [--allow-single-braces] # changes text and waits in place, also after launch Keeps the number, order and type of every step. Each step in the file is its full new content: leaving out a subject, body, note, onlyIf or emailReplyToPrevious clears it (senders, LinkedIn wait settings, workflow paths and If / else settings are kept). Steps match by order (a workflow's by key, in any order), or by stepId from steps get --with-ids to edit only some; a step saved since you read it is refused, not overwritten. Messages already sent, or already being prepared, keep their text. After launch a workflow's paths (key, next, yesNext, noNext) and what an If / else checks (check, conditionField, conditionOperator) stay fixed; conditionValue can change. Fill it: dench campaign draft [--name "New campaign"] [--request-id <8-64 char stable id>] [--timezone <tz>] \\ [--window "Mon-Fri 9-18" ...] [--daily-cap 30] [--email-mailboxes id1,id2|none] [--linkedin-accounts id1,id2|none] \\ [--stop-on-reply|--no-stop-on-reply] [--start <ISO>] [--end <ISO>] # same settings as create; a retry returns the same draft dench campaign search [<campaign>] --filters '{"titles":["Founder"]}' [--titles Founder,CEO] [--limit 50] [--page-token <token>] \\ [--out audience.json [--force]] [--raw] dench campaign add <campaign> --search '{"titles":["Founder"],"locations":["United States"]}' [--limit 50] [--preview] [--allow-duplicates] [--allow-active] dench campaign add <campaign> --titles Founder --locations "United States" [--headcount 11-50] [--limit 50] [--page-token <token>] [--allow-active] \\ [--out added.json [--force]] [--raw] dench campaign add <campaign> --people-file audience.json [--allow-duplicates] dench campaign add <campaign> --csv people.csv [--map "email=Work Email" ...] [--preview] [--allow-duplicates] Reads a spreadsheet export the way the app's Add people does: the email and LinkedIn columns are found from what they hold, whatever they are called; the name comes from First name + Last name columns or from one Name, Full name or Contact column, found by its header; every other column becomes a merge value ({{Company}}, {{Job Title}}). --map email|linkedin|name|first|last="<header or column letter>" overrides a guess (none switches a field off). --preview shows how each column is read and adds nobody. Comma, semicolon and tab files work, up to 500 people. dench campaign add <campaign> --list <artifactId> [--limit 100] [--enrichment enriched] [--preview] [--allow-duplicates] Adds people from a saved list (dench lists; made by dench leads find --save-as) without searching or paying again: new people become Leads during enrollment, with custom variables preserved. --enrichment keeps only rows at that stage (all, enriched, in_progress, no_contacts, failed, not_enriched); --preview shows the rows and adds nobody. dench campaign add <campaign> --object lead --view "Q3 targets" [--email-field Email] [--linkedin-field "POC LinkedIn URL"] \\ [--name-field Name] [--allow-duplicates] dench campaign add <campaign> --object people --entries id1,id2 [--relation-field Contact] [--reverse] dench campaign add <campaign> [--name "Ada Lovelace"] --email ada@x.com and/or --linkedin-url https://linkedin.com/in/ada Personalized lines (written by AI for each person, about 3 cents a person; up to 5 per campaign): dench campaign personalize get <campaign> # the lines, their {{keys}}, and how many are written dench campaign personalize set <campaign> --file lines.json # {"sells":"...","lines":[{"label":"Company hook","instructions":"...","fallback":"..."}]} Optional per line: key (else made from the label), maxChars 20-300, fallbackFrom (a field to use before the fallback). Saving writes lines for up to 100 people right away; more waits for personalize write --yes. Use each as {{key}} in steps. dench campaign personalize write <campaign> [--all] [--yes] # write missing lines (--all rewrites everyone); --yes confirms the cost dench campaign personalize edit <campaign> --person <enrollmentId|name> --line <key> --text "..." # your own text; --text "" undoes it dench campaign personalize rewrite <campaign> --person <enrollmentId|name> [--line <key>] # a fresh line for one person dench campaign personalize clear <campaign> # turn lines off (take the {{keys}} out of the steps first) Campaign builder (the app's New campaign flow; references/CAMPAIGNS.md walks it): dench campaign builder run --file campaign.json [--timezone <tz>] [--name "..."] [--launch [--ack-linkedin-risk]|--paused] [--wait 600] The whole flow: save -> build (billable) -> wait for people -> sequence from "brief" (AI credits) -> senders (yours; a teammate's shared account when you have none on a channel) -> create -> launch check -> launch with --launch. campaign.json is draft.json plus optional "brief", "steps", "sending" and "approvalMode". --draft <draftId> carries on a saved or interrupted run instead of saving a new one. dench campaign builder profile # workspace ICP, suggested targeting, signals and terms, connected senders dench campaign builder autofill --description "VP Sales at US software companies" # description -> targeting dench campaign builder estimate --file estimate.json # {"kind":"people"|"week","targeting":{...},"signals":[...]} dench campaign builder lookalike --linkedin-url <best lead's LinkedIn URL> # lookalike setup to edit; also --data '{"suggest":{...}}'|'{"rescope":{...}}' dench campaign builder voices --topics "AI for sales,outbound" [--market "..."] # LinkedIn experts to watch dench campaign builder save --file draft.json # source, name, targeting, signals, signalTerms, findMoreMatching, lookalike dench campaign builder build <draftId> # finds the first people and writes messages; poll builder get dench campaign builder get <draftId> dench campaign builder opener <draftId> <leadId> --text "Opening line" dench campaign builder sequence <draftId> --goal "Book a demo" --help-with "..." [--tone conversational|warm|direct|formal] [--channels both|email|linkedin] dench campaign builder outreach <draftId> --file outreach.json [--mode ai|manual] [--approval auto|manual] outreach.json: {"brief":{...},"steps":[...],"sending":{"emailMailboxAccountIds":[],"linkedinAccountIds":[], "timezone":"America/New_York","startHour":9,"endHour":17,"weekdays":[1,2,3,4,5],"dailySendCap":30}} dench campaign builder create <draftId> [--name "New campaign"] [--timezone <tz>] [--paused] dench campaign builder drafts # saved drafts that were never created dench campaign builder resume <draftId> # a saved draft's setup dench campaign builder discard <draftId> Signals on a campaign (the Sources tab): dench campaign sources overview <campaign> # each signal's status, last and next run, people found, problems dench campaign sources get <campaign> dench campaign sources signals <campaign> dench campaign sources set <campaign> --data '{"cadence":"weekly"}' [--approval auto|manual] Also: status active|paused, findMoreMatching, targeting, {"signal":{"signal":"hiring","enabled":true}}, {"terms":{"signal":"post_engagement","values":["..."]}}. One change per call is safest. dench campaign sources preview <campaign> --signal hiring dench campaign sources run <campaign> # Run now: search the signals now (live campaigns, 6 an hour) dench campaign sources pending <campaign> # people waiting for approval (manual approval) dench campaign sources approve <campaign> --people <enrollmentId,...> dench campaign sources reject <campaign> --people <enrollmentId,...> dench campaign queue <campaign> # who goes out today, warmest first --filters and --search take the same JSON with every people filter (@file.json works) on both search and add. The dench leads find filter flags (--titles, --locations, --industries, --seniorities, --headcount, --functions, --company-domains, --company-names, --company-keywords, --profile-keywords, --skills, --years-in-role, --exact, ...) add to it: list flags append to the JSON lists, so {"value":"Staffing and Recruiting","exclude":true} exclusions stay. Leave values out with --not-<flag> on any list flag (--exclude-<flag> works too), e.g. --not-industries "Staffing and Recruiting" --not-company-keywords agency --not-domains example.com; each value is sent as {"value":"...","exclude":true}, and a search still needs at least one include filter. Names with a comma that match a known value stay whole; any other comma splits a value in two, so wrap a value that contains one in double quotes inside the shell quotes (--titles '"Vice President, Information Systems"') or use --filters JSON. A Note on stderr shows how a value was split (not for domains or exact filter values). --limit is at most 100 for search and 50 per add; continue with --page-token. Search and --preview cost 1 cent per person returned; preview means no enrollment, not free search. The most a call can cost prints on stderr before it runs, and results carry estimatedCents and chargedCents. Output lists each person once (rows and recipients) with nextPageToken on top; --raw adds the raw people records and --out <file> writes the full result to a file you can pass to --people-file. --out is checked before anything is billed: its folder must exist and the file must not (--force replaces it, so give each page its own name). If it still cannot be written, the result prints on stdout and is not lost. --people-file accepts a recipient array or the JSON response from campaign search. It adds already-fetched people without another search. Searching and adding never launch a campaign: run launch --check, then launch once the user says yes. Adding people to a launched campaign starts their steps; a search add into a campaign that is not a draft needs --allow-active. Every add result has campaignStatus. Run it: dench campaign launch <campaign> --check # readiness only, never launches: blockers with why + fix, warnings, fingerprint; exit 1 when blocked dench campaign launch <campaign> [--ack-linkedin-risk] # launches as soon as it is ready (real messages go out); prints blockers otherwise; start is the same command dench campaign pause <campaign> dench campaign resume <campaign> dench campaign archive <campaign> # stops everyone still in flight dench campaign delete is the same command: like Delete in the app, it archives (nothing is erased; leads and activity stay) dench campaign stop <campaign> --person <enrollmentId|name> # nothing more is sent; in a draft, a never-contacted person is removed instead dench campaign remove <campaign> --person <enrollmentId|name> # drafts only: drop someone never contacted, freeing their place dench campaign resume-person <campaign> --person <enrollmentId|name> | --all # --all: every paused person, e.g. after a reconnect dench campaign person set <campaign> --person <enrollmentId|name> [--email ada@new.com] [--linkedin-url <url>] \\ [--field "Job Title=CTO" ...] # before launch: fix an address or fill a {{merge field}}; --field repeats dench campaign sync <campaign> # fill people's empty email, LinkedIn URL, name and fields from their CRM records Send window spec (--window, repeatable, all in --timezone): "Mon-Fri 9-18" "Mon,Wed,Fri 10-16" "Weekdays 9am-5pm" "Sat-Sun 10-14" --start / --end take ISO 8601. A bare date (2026-10-01) is midnight at the start of that day in the campaign's timezone, and a time without an offset (2026-10-01T09:00) is that time there too; add an offset to pin the instant. steps.json: a JSON array of steps, in order. Types: email, wait, linkedin_visit, linkedin_connection, linkedin_message, linkedin_like_post, linkedin_inmail, condition (If / else) and wait_for_connection (Wait for them to accept). See references/CAMPAIGNS.md for every field. Workflows (branching paths): give steps a "key" and say where each goes. A condition or wait_for_connection step needs "yesNext" and "noNext" (Yes / No, or Accepted / Not accepted); any other step may have "next" (default: the step after it). Each points at another step's "key", or "end" to stop there. condition needs "check": linkedin_connected, has_email, opened_last_email or field_value (with "conditionField", "conditionOperator" is|is_not|contains|is_empty|is_not_empty and, except for the empty checks, "conditionValue"). wait_for_connection waits connectionTimeoutHours = 24-2160 (1-90 days, default 720) and needs a linkedin_connection before it on every path. Steps with keys, paths or these types save the campaign as a workflow ("stepModel": "workflow"); a workflow stays one, and in a workflow a LinkedIn message does not wait for the accept on its own. steps set checks keys and paths before saving; the rest (every step reachable, paths only moving forward) is checked when it saves. Post reactions: likeReaction = like|celebrate|support|love|insightful|funny (default like); likeMaxPostAgeDays = 1-365 (default 30); likeCount = 1-3 recent posts (default 1). likeSkipPreviousWait = true checks for eligible posts before the immediately preceding wait starts; no eligible posts skips that wait and the like while keeping following waits (omitted defaults false). likeSkipNextWait = true by default, skipping consecutive following waits when the reaction itself is skipped. Set false to keep those waits. InMail: linkedinMessageBody required (1-1900 chars), inmailSubject optional (up to 200 chars). Connected people skip InMail automatically; do not set onlyIf. Connection requests: withdrawAfterHours = 168-2160 (7-90 days); omitted or null means no automatic withdrawal. Only invitations sent by the campaign are withdrawn. Message acceptance waits: connectionTimeoutHours = 1-2160 (default 720), only with linkedinMessageMode=wait_until_connected (the default after a connection). steps set refuses a field the step type would drop (onlyIf off email/post-like, a body on a visit, a reply-in-thread first email), a missing subject or body, a note over 300, message over 3000 or InMail over 1900 characters, out-of-range step settings, a wait of 0 or over 2160 hours, and {TOKEN} single braces, which would go out as typed (use {{TOKEN}}; --allow-single-braces keeps literal braces). It warns when several LinkedIn messages after a connection request each wait 30 days for the accept. An email with both emailHtmlBody and emailTextBody sends the HTML, so when you edit only the text, the HTML copied from steps get is dropped (with a warning) and the new text is sent. Sender pools: --email-mailboxes and --linkedin-accounts select accounts once for the campaign (emailMailboxAccountIds / linkedinAccountIds in the API). Accepts comma-separated ids or a JSON array; [] or none clears a pool. Omit a flag to leave that channel unchanged. Only your accounts and accounts shared with you can be selected. Launch checks availability. A person's sender stays fixed for their conversation. Legacy step senders are preserved until a pool is configured. A campaign holds at most ${MAX_CAMPAIGN_PEOPLE} people. steps set only works on drafts. After launch, change a step's text or wait time with steps edit; the schedule, daily cap and stop-on-reply stay editable with set. Leads move New → Contacted → Replied on their own as steps go out. Auth: a workspace member's credentials (dench signin, a member DENCH_API_KEY, or an agent session). An organization-only key cannot run outreach. `; } // ─── Entry point ────────────────────────────────────────────────────────── async function runAgentCommand(ctx: CampaignCliContext, command: string) { const { runCampaignAgentCommand } = await import("./lib/campaign-agent"); return runCampaignAgentCommand( { ...ctx, resolveCampaign: (reference) => resolveCampaign(ctx, reference), }, command, ); } export async function runCampaignCommand(opts: { convex: ConvexHttpClient; args: string[]; sessionToken?: string; jsonOutput?: boolean; host?: string; workspaceSlug?: string; }): Promise<void> { const args = [...opts.args]; const jsonOutput = hasFlag(args, "--json") || opts.jsonOutput === true; const ctx: CampaignCliContext = { convex: opts.convex, args, jsonOutput, sessionToken: opts.sessionToken, host: opts.host, workspaceSlug: opts.workspaceSlug, }; const subcommand = args.shift(); if ( !subcommand || subcommand === "help" || subcommand === "--help" || subcommand === "-h" ) { campaignHelp(); return; } if (args.some((token) => token === "--help" || token === "-h")) { campaignHelp(subcommand); return; } switch (subcommand) { case "draft": case "search": case "sources": case "builder": case "queue": return runAgentCommand(ctx, subcommand); case "today": return runAgentCommand(ctx, "queue"); case "list": case "ls": return await runList(ctx); case "create": case "new": return await runCreate(ctx); case "duplicate": case "copy": case "clone": return await runDuplicate(ctx); case "get": case "status": case "show": return await runGet(ctx); case "set": case "update": case "configure": return await runSet(ctx); case "steps": return await runSteps(ctx); case "add": case "enroll": return await runAdd(ctx); case "launch": case "start": return await runLaunch(ctx); case "pause": return await runLifecycle(ctx, "pause"); case "resume": return await runLifecycle(ctx, "resume"); case "archive": return await runLifecycle(ctx, "archive"); case "delete": return await runLifecycle(ctx, "archive", "delete"); case "people": return await runPeople(ctx); case "stop": return await runPerson(ctx, "stop"); case "remove": return await runRemove(ctx); case "resume-person": return await runPerson(ctx, "resume-person"); case "person": return await runPersonCommand(ctx); case "sync": return await runSync(ctx); case "personalize": case "personalization": case "lines": case "variables": return await runPersonalize(ctx); case "preview": return await runPreview(ctx); case "replies": return await runReplies(ctx); case "pipeline": return await runPipeline(ctx); case "where": case "memberships": return await runWhere(ctx); case "events": case "activity": return await runEvents(ctx); case "accounts": case "senders": return await runAccounts(ctx); default: throw new CampaignCliError( unknownSubcommandMessage(subcommand, await cliVersion()), ); } }