import { randomUUID } from "node:crypto"; import { open, readFile, rm, stat, writeFile } from "node:fs/promises"; import type { ConvexHttpClient } from "convex/browser"; import { makeFunctionReference } from "convex/server"; import { assertIanaTimezone, consumeScheduleFlags, consumeSenderPoolFlags, formatWeekdays, hasScheduleChanges, launchStage, localTimezone, MAX_CAMPAIGN_PEOPLE, type SendSchedule, schedulePatchFromFlags, } from "../campaign"; import { apiHostForDenchHost } from "../host"; import { splitListValue } from "../leads"; import { campaignAudienceFiltersSchema } from "./api-schemas"; import { COLUMN_MAP_USAGE, type CsvColumn, detectDelimiter, MAX_CSV_CHARS, MAX_CSV_RECIPIENTS, parseColumnMap, parseRecipientTable, type RecipientTable, recipientsFromTable, withDetectedFullName, } from "./campaign-csv"; import { excludeFlagsFor, FILTER_KEY_ALIASES, SEARCH_LIST_FLAGS, SEARCH_RANGE_FLAGS, SEARCH_TEXT_FLAGS, usesSearchFlags, } from "./campaign-filters"; import { campaignReferenceResult } from "./campaign-reference"; import { assertNoUnknownFlags, getFlag, hasFlag, parseLimit, shift, } from "./cli-args"; import { apiOperations } from "./command-registry"; import { FIND_FILTER_OPTION_CATALOG } from "./find-filter-options.generated"; import { closestMatch } from "./levenshtein"; import { backendErrorText } from "./public-error"; import { formatCents, SEARCH_RESULT_ROW_CENTS, searchRowsCents, } from "./search-pricing"; type Context = { convex: ConvexHttpClient; args: string[]; jsonOutput: boolean; sessionToken?: string; host?: string; workspaceSlug?: string; resolveCampaign: (reference: string) => Promise; }; type CampaignRef = { _id: string; name: string; status: string; totalEnrolled?: number; }; function value(args: string[], flag: string): string | undefined { const present = args.includes(flag); const result = getFlag(args, flag); if (present && (!result || result.startsWith("--"))) throw new Error(`${flag} needs a value`); return result; } function finishArgs(args: string[], command: string) { assertNoUnknownFlags(args, `campaign ${command}`); if (args.length) throw new Error( `Unexpected arguments for campaign ${command}: ${args.join(" ")}`, ); } async function json(text: string): Promise { const raw = text.startsWith("@") ? await readFile(text.slice(1), "utf8") : text; try { return JSON.parse(raw); } catch { throw new Error("Expected valid JSON, or @path/to/file.json"); } } async function objectInput(args: string[]): Promise> { const data = value(args, "--data"); const file = value(args, "--file"); if (data && file) throw new Error("Use --data or --file, not both"); const result = data ? await json(data) : file ? await json(`@${file}`) : {}; if (!result || typeof result !== "object" || Array.isArray(result)) throw new Error("Expected a JSON object"); return result as Record; } function validate(operationId: string, input: Record) { const operation = apiOperations.find( (candidate) => candidate.id === operationId, ); if (!operation) throw new Error(`Unknown campaign operation: ${operationId}`); const parsed = operation.requestSchema.safeParse(input); if (!parsed.success) throw new Error( parsed.error.issues .map((issue) => `${issue.path.join(".") || "input"}: ${issue.message}`) .join("; "), ); return { operation, input: parsed.data as Record }; } async function invoke( ctx: Context, operationId: string, raw: Record, ): Promise { const { operation, input } = validate(operationId, raw); const backend = operation.backend; if (backend.kind === "convex") { const args = { ...(backend.transformArgs ? backend.transformArgs(input) : input), ...backend.fixedArgs, ...(backend.tokenArg && ctx.sessionToken ? { [backend.tokenArg]: ctx.sessionToken } : {}), }; if (backend.functionType === "query") return ctx.convex.query( makeFunctionReference<"query">(backend.functionName), args, ); if (backend.functionType === "mutation") return ctx.convex.mutation( makeFunctionReference<"mutation">(backend.functionName), args, ); return ctx.convex.action( makeFunctionReference<"action">(backend.functionName), args, ); } if (backend.kind !== "custom" || backend.handler !== "campaignBuilder") throw new Error("Unsupported campaign operation"); if (!ctx.sessionToken) throw new Error( "Sign in with dench signin before using the campaign builder", ); const path = operation.path.replace(/\{([^}]+)\}/g, (_, key: string) => encodeURIComponent(String(input[key])), ); const response = await fetch( `${apiHostForDenchHost(ctx.host ?? process.env.DENCH_HOST)}/api/v1${path}`, { method: operation.method, headers: { "Content-Type": "application/json", Authorization: `Bearer ${ctx.sessionToken}`, }, body: JSON.stringify(input), redirect: "error", signal: AbortSignal.timeout(120_000), }, ); const body = (await response.json()) as Record; if (!response.ok) throw new Error( typeof body.error === "string" ? body.error : JSON.stringify(body), ); return body; } function print(ctx: Context, result: unknown, summary?: string) { if (summary && !ctx.jsonOutput) console.error(summary); console.log(JSON.stringify(result, null, 2)); } function notice(message: string) { console.error(`Note: ${message}`); } type Recipient = { email?: string; linkedinProfileUrl?: string; displayName?: string; personalization?: Record; }; type RecipientField = "email" | "linkedinProfileUrl" | "displayName"; const RECIPIENT_ALIASES: Record = { email: "email", emailaddress: "email", workemail: "email", linkedinprofileurl: "linkedinProfileUrl", linkedinurl: "linkedinProfileUrl", linkedin: "linkedinProfileUrl", linkedinprofile: "linkedinProfileUrl", displayname: "displayName", name: "displayName", fullname: "displayName", }; const MAX_PEOPLE_FILE_RECIPIENTS = 300; /** * People per `addEnrollments` call, as the app's Add people sends them * (`DIRECT_ENROLL_CHUNK`): a row that matches or creates a Lead is costly, * and a 50-person call ran past the mutation limit on staging and was * also the long transaction background CRM work kept conflicting with. */ const PEOPLE_BATCH_SIZE = 20; /** Rows `add --list` reads at most looking past duplicates (50 pages). */ const MAX_LIST_ROWS_READ = 5000; function aliasKey(key: string): string { return key.toLowerCase().replace(/[^a-z0-9]/g, ""); } function isScalar(value: unknown): value is string | number | boolean { return ( typeof value === "string" || typeof value === "boolean" || (typeof value === "number" && Number.isFinite(value)) ); } function isBlank(value: unknown): boolean { return ( value === undefined || value === null || (typeof value === "string" && !value.trim()) ); } export function recipientFromRow(row: unknown, label: string): Recipient { if (!row || typeof row !== "object" || Array.isArray(row)) throw new Error( `${label} must be an object like {"email":"ada@example.com","linkedinProfileUrl":"https://linkedin.com/in/ada","displayName":"Ada Lovelace"}`, ); const recipient: Recipient = {}; const seen: Partial> = {}; const extra: Record = {}; let personalization: Record | undefined; let first = ""; let last = ""; for (const [key, value] of Object.entries(row as Record)) { if (key === "personalization") { if (isBlank(value)) continue; if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error( `${label}.personalization must be an object of merge values, e.g. {"Company":"Acme"}`, ); personalization = {}; for (const [name, item] of Object.entries( value as Record, )) { if (isBlank(item)) continue; if (!isScalar(item)) throw new Error( `${label}.personalization.${name} must be text, a number or true/false`, ); personalization[name] = item; } continue; } const field = RECIPIENT_ALIASES[aliasKey(key)]; if (field) { if (isBlank(value)) continue; if (typeof value !== "string") throw new Error(`${label}.${key} must be text`); const trimmed = value.trim(); const previous = seen[field]; if (previous !== undefined && recipient[field] !== trimmed) throw new Error( `${label} has both ${previous} and ${key} with different values; keep one`, ); seen[field] = key; recipient[field] = trimmed; continue; } if (isBlank(value)) continue; if (!isScalar(value)) throw new Error( `${label}.${key} is a list or object. Only text, numbers and true/false become merge values; flatten it, or pass the recipients array from dench campaign search`, ); const kept = typeof value === "string" ? value.trim() : value; if (typeof kept === "string" && aliasKey(key) === "firstname") first = kept; if (typeof kept === "string" && aliasKey(key) === "lastname") last = kept; extra[/^(crm|person):./i.test(key) ? key : `crm:${key}`] = kept; } if (!recipient.displayName) { const composed = [first, last].filter(Boolean).join(" "); if (composed) recipient.displayName = composed; } const merged = { ...extra, ...personalization }; if (Object.keys(merged).length) recipient.personalization = merged; return recipient; } export function recipientsFromPeopleFile( parsed: unknown, file: string, ): Recipient[] { const rows = Array.isArray(parsed) ? parsed : parsed && typeof parsed === "object" && "recipients" in parsed ? (parsed as { recipients: unknown }).recipients : null; if (!Array.isArray(rows)) { const keys = parsed && typeof parsed === "object" ? Object.keys(parsed) : []; throw new Error( `${file} must be a JSON array of people or a dench campaign search result with a recipients array${keys.length ? ` (found keys: ${keys.slice(0, 8).join(", ")})` : ""}`, ); } if (!rows.length || rows.length > MAX_PEOPLE_FILE_RECIPIENTS) throw new Error( `${file} must contain 1-${MAX_PEOPLE_FILE_RECIPIENTS} people (found ${rows.length}). A campaign holds at most ${MAX_PEOPLE_FILE_RECIPIENTS}.`, ); return rows.map((row, index) => recipientFromRow(row, `recipients[${index}]`), ); } const BATCH_COUNTS = [ "added", "requested", "duplicates", "duplicatesElsewhere", "suppressed", "invalid", "skippedNoIdentity", "skippedMissingInfo", ] as const; type BatchCounts = Record<(typeof BATCH_COUNTS)[number], number>; function batchCounts(result: unknown): BatchCounts { const record = (result ?? {}) as Record; return Object.fromEntries( BATCH_COUNTS.map((key) => [ key, typeof record[key] === "number" ? (record[key] as number) : 0, ]), ) as BatchCounts; } function considered(counts: BatchCounts): number { return ( counts.added + counts.duplicates + counts.duplicatesElsewhere + counts.suppressed + counts.invalid + counts.skippedNoIdentity + counts.skippedMissingInfo ); } const FILTER_KEYS = Object.keys(campaignAudienceFiltersSchema.shape); const SEARCH_LIMIT = { search: 100, add: 50 } as const; function resolveFilterKey(normalized: string): string | undefined { return ( FILTER_KEYS.find((key) => aliasKey(key) === normalized) ?? FILTER_KEY_ALIASES[normalized] ?? (normalized.length >= 4 ? FILTER_KEYS.find((key) => aliasKey(key).startsWith(normalized)) : undefined) ?? closestMatch(normalized, FILTER_KEYS) ?? undefined ); } function unknownFilterMessage(key: string): string { const normalized = aliasKey(key); const stem = normalized .replace( /^(exclude[ds]?|excluding|exclusions?|not|no|without|omit|except)(?=[a-z])/, "", ) .replace(/(?<=[a-z])(exclude[ds]?|excluding|exclusions?|not)$/, ""); const stemTarget = stem !== normalized ? resolveFilterKey(stem) : undefined; if ( stemTarget || /^(exclude[ds]?|excluding|exclusions?|blocklist|blacklist)$/.test( normalized, ) ) return `"${key}" is not a filter. To exclude values, put {"value":"…","exclude":true} inside that filter's list, e.g. {"${stemTarget ?? "industries"}":[{"value":"${stemTarget ? "…" : "Staffing and Recruiting"}","exclude":true}]}.`; const target = resolveFilterKey(normalized); return `Unknown search filter "${key}"${target ? `. Did you mean "${target}"?` : "."}`; } function vocabularyFor(catalogKey: string | undefined): string[] | undefined { return catalogKey ? FIND_FILTER_OPTION_CATALOG[catalogKey]?.groups.flatMap( (group) => group.values, ) : undefined; } function splitFlagValue( flag: string, key: string, raw: string, catalogKey: string | undefined, ): string[] { const parts = splitListValue(raw, vocabularyFor(catalogKey)); if (parts.length < 2 || key === "companyDomains") return parts; const entry = catalogKey ? FIND_FILTER_OPTION_CATALOG[catalogKey] : undefined; const known = new Set((vocabularyFor(catalogKey) ?? []).map(itemText)); if ( entry?.kind === "enum" && parts.every((part) => known.has(itemText(part))) ) return parts; notice( `${flag} "${raw.trim()}" was split at commas into ${parts.length} values: ${parts.map((part) => `"${part}"`).join(", ")}. If a comma belongs inside one value, wrap it in double quotes: ${flag} '"${parts[0]}, ${parts[1]}"' (or use --filters JSON).`, ); return parts; } function itemText(item: unknown): string { const text = typeof item === "string" ? item : item && typeof item === "object" ? String((item as { value?: unknown }).value ?? "") : ""; return text.trim().toLowerCase(); } function onlyExclusions(filters: Record): boolean { const entries = Object.entries(filters).filter( ([key]) => !["exactMatch", "preferCache"].includes(key), ); if (!entries.length) return false; return entries.every( ([, list]) => Array.isArray(list) && list.length > 0 && list.every( (item) => item && typeof item === "object" && (item as { exclude?: unknown }).exclude === true, ), ); } function rangeValue(raw: string, flag: string): { min?: number; max?: number } { const parts = raw.split(":"); // One min:max pair. A third part ("2:10:99", "2::99") was silently // dropped, sometimes with the upper bound. if (parts.length > 2) throw new Error(`${flag} takes one min:max pair, e.g. 2:10, 2: or :10`); const [minRaw = "", maxRaw = ""] = parts; const part = (text: string, label: string) => { const trimmed = text.trim(); if (!trimmed) return undefined; const parsed = Number(trimmed); if (!Number.isInteger(parsed) || parsed < 0) throw new Error( `${flag} ${label} must be a whole number of 0 or more (min:max, e.g. 2:10)`, ); return parsed; }; const min = part(minRaw, "min"); const max = part(maxRaw, "max"); if (min === undefined && max === undefined) throw new Error(`${flag} needs min:max, e.g. 2:10, 2: or :10`); if (min !== undefined && max !== undefined && min > max) throw new Error(`${flag} min (${min}) is greater than max (${max})`); return { ...(min !== undefined ? { min } : {}), ...(max !== undefined ? { max } : {}), }; } async function searchRequest(args: string[], command: "search" | "add") { const filtersRaw = value(args, "--filters"); const searchRaw = value(args, "--search"); if (filtersRaw !== undefined && searchRaw !== undefined) throw new Error( "Use --filters or --search, not both; they take the same JSON", ); const raw = filtersRaw ?? searchRaw; const parsed = raw ? await json(raw) : {}; if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error( 'Search filters must be a JSON object, e.g. {"titles":["Founder"],"locations":["United States"]}', ); const filters = { ...parsed } as Record; const unknown = Object.keys(filters).filter( (key) => !FILTER_KEYS.includes(key), ); if (unknown.length) throw new Error( `${unknown.map(unknownFilterMessage).join("\n")}\nAllowed filters: ${FILTER_KEYS.join(", ")}.`, ); for (const [flag, key, catalogKey] of SEARCH_LIST_FLAGS) { const values: unknown[] = []; const excluded: string[] = []; while (args.includes(flag)) { const selected = value(args, flag) ?? ""; values.push(...splitFlagValue(flag, key, selected, catalogKey)); } for (const excludeFlag of excludeFlagsFor(flag)) { while (args.includes(excludeFlag)) { const selected = value(args, excludeFlag) ?? ""; excluded.push( ...splitFlagValue(excludeFlag, key, selected, catalogKey), ); } } if (!values.length && !excluded.length) continue; const existing = filters[key]; if (existing !== undefined && !Array.isArray(existing)) throw new Error( `"${key}" in the JSON must be a list to combine with ${flag}`, ); const kept = new Set( ((existing as unknown[] | undefined) ?? []) .filter( (item) => item && typeof item === "object" && (item as { exclude?: unknown }).exclude === true, ) .map((item) => itemText(item)), ); const added = excluded.filter((item) => { const text = itemText(item); if (kept.has(text)) return false; kept.add(text); return true; }); const list = [ ...((existing as unknown[] | undefined) ?? []), ...values, ...added.map((item) => ({ value: item, exclude: true })), ]; const includedText = new Set( list .filter( (item) => !( item && typeof item === "object" && (item as { exclude?: unknown }).exclude === true ), ) .map((item) => itemText(item)), ); const clash = [...kept].find((text) => includedText.has(text)); if (clash) throw new Error( `"${clash}" is both included and excluded in ${key}; keep one`, ); filters[key] = list; } const single = (flag: string, key: string, next: unknown) => { if (filters[key] !== undefined) throw new Error(`"${key}" is set in the JSON and by ${flag}; keep one`); filters[key] = next; }; for (const [flag, key] of SEARCH_TEXT_FLAGS) { const selected = value(args, flag); if (selected !== undefined) single(flag, key, selected); } for (const [flag, key] of SEARCH_RANGE_FLAGS) { const selected = value(args, flag); if (selected !== undefined) single(flag, key, rangeValue(selected, flag)); } if (hasFlag(args, "--exact")) filters.exactMatch = true; if (!Object.keys(filters).length) throw new Error( `Provide --filters JSON or at least one filter flag, e.g. dench campaign ${command === "add" ? "add " : "search"} --titles Founder --locations "United States"`, ); if (onlyExclusions(filters)) throw new Error( `A search needs something to look for, not only values to leave out (${Object.keys(filters).join(", ")}). Add an include filter, e.g. --titles Founder.`, ); const cap = SEARCH_LIMIT[command]; let limit: ReturnType; try { limit = parseLimit(value(args, "--limit"), { defaultValue: 50, cap, command: `campaign ${command}`, }); } catch (error) { throw new Error(error instanceof Error ? error.message : String(error)); } if (limit.clamped) notice( `--limit ${limit.requested} is above the ${cap} per call maximum for campaign ${command}; using ${cap}. Continue with --page-token for more.`, ); const pageToken = value(args, "--page-token"); return { filters, limit: limit.effective, ...(pageToken ? { pageToken } : {}), }; } const LIVE_EFFECT: Record = { active: "people added now start its steps right away and real messages go out", paused: "people added now start its steps as soon as it resumes", completed: "adding people restarts it, and their steps and real messages start right away", }; function liveEffect(status: string): string { return LIVE_EFFECT[status] ?? "people added now start its steps"; } async function addableCampaign(ctx: Context, reference: string) { const campaign = await ctx.resolveCampaign(reference); if (campaign.status === "archived") throw new Error( `"${campaign.name}" is archived; people can't be added to it.`, ); return campaign; } type SearchPage = Record & { estimatedCents: number | null; chargedCents: number | null; }; function withSearchCharge(result: Record): SearchPage { const { creditsCharged: _providerCredits, ...page } = result; const cents = (value: unknown) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null; return { ...page, estimatedCents: cents(page.estimatedCents), chargedCents: cents(page.chargedCents), }; } function searchChargeLabel(page: SearchPage): string { return page.chargedCents === null ? "charge not reported" : `charged ${formatCents(page.chargedCents)}`; } function pageParts(page: SearchPage) { const rows = Array.isArray(page.rows) ? page.rows : []; const recipients = Array.isArray(page.recipients) ? page.recipients : []; const metadata = page.metadata && typeof page.metadata === "object" ? (page.metadata as Record) : {}; const nextPageToken = typeof metadata.nextPageToken === "string" && metadata.nextPageToken ? metadata.nextPageToken : null; return { rows, recipients, metadata, nextPageToken }; } function costNotice(rows: number) { notice( `Search cost: up to ${plural(rows, "row", "rows")} × ${SEARCH_RESULT_ROW_CENTS}¢ = ${formatCents(searchRowsCents(rows))}, billed per row returned.`, ); } function outputOptions(args: string[]) { const raw = hasFlag(args, "--raw"); const out = value(args, "--out"); const force = hasFlag(args, "--force"); if (raw && out) throw new Error("--out already writes the full result; drop --raw"); if (force && !out) throw new Error("--force only applies to --out"); return { raw, out, force }; } function writeFailure(error: unknown): string { const code = (error as { code?: unknown } | null)?.code; if (code === "ENOENT") return "its folder does not exist"; if (code === "ENOTDIR") return "part of the path is not a folder"; if (code === "EACCES" || code === "EPERM" || code === "EROFS") return "no permission to write there"; if (code === "ENOSPC") return "the disk is full"; return error instanceof Error ? error.message : String(error); } async function claimOut(output: { out?: string; force: boolean }) { const file = output.out; if (!file) return; const existing = await stat(file).catch(() => null); if (existing?.isDirectory()) throw new Error( `--out ${file} is a folder; give a file name. Nothing was searched or charged.`, ); if (existing && !output.force) throw new Error( `${file} already exists and --out would replace it. Use another file name (for the next page, e.g. audience-2.json) or add --force to replace it. Nothing was searched or charged.`, ); try { await (await open(file, "a")).close(); } catch (error) { throw new Error( `Cannot write ${file}: ${writeFailure(error)}. Nothing was searched or charged.`, ); } if (!existing) await rm(file, { force: true }); } async function saveOut(file: string, payload: unknown): Promise { try { await writeFile(file, `${JSON.stringify(payload, null, 2)}\n`); return null; } catch (error) { return writeFailure(error); } } function searchOutput(page: SearchPage) { const { rows, recipients, metadata, nextPageToken } = pageParts(page); return { count: rows.length, rows, recipients, metadata, nextPageToken, estimatedCents: page.estimatedCents, chargedCents: page.chargedCents, }; } function searchView( output: { raw: boolean; out?: string }, scope: Record, page: SearchPage, full: Record, saveError: string | null, ) { if (saveError) return { ...full, saveError }; if (output.raw) return full; return { ...scope, ...(output.out ? savedOutput(output.out, page) : searchOutput(page)), }; } function savedOutput(file: string, page: SearchPage) { const { rows, recipients, nextPageToken } = pageParts(page); return { savedTo: file, count: rows.length, recipientCount: recipients.length, nextPageToken, estimatedCents: page.estimatedCents, chargedCents: page.chargedCents, }; } function addCounts(result: Record) { return { ...batchCounts(result), cappedAtLimit: typeof result.cappedAtLimit === "number" ? result.cappedAtLimit : null, }; } function searchSummary(page: SearchPage) { const { rows, recipients, nextPageToken } = pageParts(page); return `Found ${plural(rows.length, "person", "people")} (${recipients.length} with an email or LinkedIn URL) · ${searchChargeLabel(page)}${nextPageToken ? ` · more: --page-token ${nextPageToken}` : ""}`; } function allValues(args: string[], flag: string): string[] { const found: string[] = []; while (args.includes(flag)) found.push(value(args, flag) ?? ""); return found; } async function add(ctx: Context) { const file = value(ctx.args, "--people-file"); const csv = value(ctx.args, "--csv"); const list = value(ctx.args, "--list"); const maps = allValues(ctx.args, "--map"); const allowDuplicates = hasFlag(ctx.args, "--allow-duplicates"); const allowActive = hasFlag(ctx.args, "--allow-active"); const preview = hasFlag(ctx.args, "--preview"); const sources = [ ["--people-file", file], ["--csv", csv], ["--list", list], ].filter(([, given]) => given !== undefined); if (sources.length > 1) throw new Error( `Use one of ${sources.map(([flag]) => flag).join(", ")}, not several`, ); if (maps.length && csv === undefined) throw new Error("--map only applies to --csv"); if (list === undefined && ctx.args.includes("--enrichment")) throw new Error("--enrichment only applies to --list"); if (list !== undefined) { const enrichment = value(ctx.args, "--enrichment"); const limitRaw = value(ctx.args, "--limit"); if (usesSearchFlags(ctx.args)) throw new Error("Use --list or search filters, not both"); const reference = shift(ctx.args, "campaign id or name"); finishArgs(ctx.args, "add"); return addList(ctx, list, reference, { allowDuplicates, preview, enrichment, limitRaw, }); } if (csv !== undefined) { if (usesSearchFlags(ctx.args)) throw new Error("Use --csv or search filters, not both"); const reference = shift(ctx.args, "campaign id or name"); finishArgs(ctx.args, "add"); return addCsv(ctx, csv, maps, reference, { allowDuplicates, preview }); } if (file && (preview || usesSearchFlags(ctx.args))) throw new Error("Use --people-file or search filters, not both"); const input = file ? null : await searchRequest(ctx.args, "add"); const output = file ? null : outputOptions(ctx.args); const reference = shift(ctx.args, "campaign id or name"); finishArgs(ctx.args, "add"); if (input && output) { const operation = preview ? "campaigns.audience.search" : "campaigns.people.addFromSearch"; const body = { ...input, ...(!preview ? { checkDuplicatesAcrossWorkspace: !allowDuplicates } : {}), }; validate(operation, { ...body, sequenceId: "pending" }); await claimOut(output); const campaign = await addableCampaign(ctx, reference); if (!preview && campaign.status !== "draft" && !allowActive) throw new Error( `"${campaign.name}" is ${campaign.status}, so ${liveEffect(campaign.status)}. Nothing was searched or charged. Re-run with --allow-active to search and add anyway, or with --preview to only look.`, ); const remaining = typeof campaign.totalEnrolled === "number" ? Math.max(0, MAX_CAMPAIGN_PEOPLE - campaign.totalEnrolled) : undefined; if (!preview && remaining === 0) throw new Error( `"${campaign.name}" is full (${MAX_CAMPAIGN_PEOPLE} people). Nothing was searched or charged.`, ); costNotice( preview ? body.limit : Math.min(body.limit, remaining ?? body.limit), ); const page = withSearchCharge( (await invoke(ctx, operation, { ...body, sequenceId: campaign._id, })) as Record, ); const { nextPageToken } = pageParts(page); const scope = { sequenceId: campaign._id, campaignStatus: campaign.status }; const full = { ...scope, ...page, nextPageToken }; const saveError = output.out ? await saveOut(output.out, full) : null; if (output.out && saveError) notice( `Could not save ${output.out}: ${saveError}. ${preview ? "The search was already billed" : "The people were already added and billed"}, so the full result is printed here instead; keep it. Running this again would search and bill again.`, ); if (preview) return print( ctx, searchView(output, scope, page, full, saveError), searchSummary(page), ); const counts = addCounts(page); if (counts.cappedAtLimit !== null) notice(`The campaign is full at ${counts.cappedAtLimit} people.`); const found = typeof page.found === "number" ? page.found : 0; const result = { ...scope, ...counts, found, skippedSearchProfiles: typeof page.skippedSearchProfiles === "number" ? page.skippedSearchProfiles : 0, ...(page.missingInfoSample ? { missingInfoSample: page.missingInfoSample } : {}), nextPageToken, estimatedCents: page.estimatedCents, chargedCents: page.chargedCents, ...(output.out && !saveError ? { savedTo: output.out } : {}), }; return print( ctx, saveError ? { ...full, saveError } : output.raw ? full : result, `Added ${counts.added} of ${plural(found, "person", "people")} found to "${campaign.name}" (${campaign.status}) · ${searchChargeLabel(page)}${nextPageToken ? ` · more: --page-token ${nextPageToken}` : ""}`, ); } if (!file) throw new Error("Pass --people-file, --csv or --search"); return addPeopleFile(ctx, file, reference, allowDuplicates); } async function addPeopleFile( ctx: Context, file: string, reference: string, allowDuplicates: boolean, ) { const raw = await readFile(file, "utf8").catch(() => { throw new Error(`Cannot read ${file}`); }); let parsed: unknown; try { parsed = JSON.parse(raw); } catch { throw new Error(`${file} is not valid JSON`); } const recipients = recipientsFromPeopleFile(parsed, file); return addRecipients(ctx, recipients, file, reference, allowDuplicates); } function columnSummary( columns: CsvColumn[], /** Channels switched off with `--map =none`. */ off: { email?: boolean; linkedin?: boolean } = {}, ): string[] { const names = (role: CsvColumn["role"]) => columns .filter((column) => column.role === role) .map((column) => column.label); const parts: string[] = []; const email = names("email"); if (off.email) parts.push("email off"); else if (email.length) parts.push(`email = ${email.join(", ")}`); const linkedin = names("linkedin"); if (off.linkedin) parts.push("LinkedIn off"); else if (linkedin.length) parts.push(`LinkedIn = ${linkedin.join(", ")}`); if (!email.length && !linkedin.length && !off.email && !off.linkedin) parts.push("no email or LinkedIn column"); const person = [ ...names("full name"), ...names("first name"), ...names("last name"), ]; parts.push( person.length ? `name = ${person.join(" + ")}` : 'no name column found (point one at it with --map name="")', ); return parts; } const LIST_ENRICHMENT = [ "all", "enriched", "in_progress", "no_contacts", "failed", "not_enriched", ] as const; type ListRowsPage = { title?: string; rowCount?: number; page?: Array<{ index: number; row?: unknown }>; isDone?: boolean; continueCursor?: string | null; }; function listRowSummary(row: unknown): Record { if (!row || typeof row !== "object") return {}; const record = row as Record; const summary: Record = {}; for (const key of ["name", "title", "company", "linkedin", "email"]) { const found = record[key]; if (typeof found === "string" && found.trim()) summary[key] = found.trim(); } return summary; } function listRowRecipient(row: unknown, index: number): Recipient { if (!row || typeof row !== "object" || Array.isArray(row)) throw new Error(`List row ${index + 1} is not a person record`); const record = row as Record; if (record.record_type && record.record_type !== "person") throw new Error( "Choose a people list. Use a company list to find its people first.", ); return recipientFromRow( { name: record.name ?? record.fullName, email: record.email, linkedinProfileUrl: record.linkedin ?? record.linkedinUrl, Company: record.company, "Job Title": record.title, Website: record.website, Location: record.location, Photo: record.avatarUrl, personalization: record.personalization, }, `List row ${index + 1}`, ); } async function addList( ctx: Context, artifactId: string, reference: string, options: { allowDuplicates: boolean; preview: boolean; enrichment?: string; limitRaw?: string; }, ) { if ( options.enrichment !== undefined && !(LIST_ENRICHMENT as readonly string[]).includes(options.enrichment) ) throw new Error( `--enrichment must be one of ${LIST_ENRICHMENT.join(", ")}, got "${options.enrichment}"`, ); const campaign = await addableCampaign(ctx, reference); const room = typeof campaign.totalEnrolled === "number" ? Math.max(0, MAX_CAMPAIGN_PEOPLE - campaign.totalEnrolled) : MAX_CAMPAIGN_PEOPLE; if (!options.preview && room === 0) throw new Error( `"${campaign.name}" is full (${MAX_CAMPAIGN_PEOPLE} people). Nothing was imported.`, ); let limit: ReturnType; try { limit = parseLimit(options.limitRaw, { defaultValue: MAX_CAMPAIGN_PEOPLE, cap: MAX_CAMPAIGN_PEOPLE, command: "campaign add --list", }); } catch (error) { throw new Error(error instanceof Error ? error.message : String(error)); } // How many people to add: the --limit, the campaign cap and its room. const wanted = Math.min(limit.effective, room || MAX_CAMPAIGN_PEOPLE); const clamped = options.limitRaw !== undefined && wanted < limit.requested; if (clamped) notice( limit.clamped ? `--limit ${limit.requested} is above the ${MAX_CAMPAIGN_PEOPLE}-person campaign maximum; adding up to ${wanted}.` : `--limit ${limit.requested} is more than "${campaign.name}" has room for; adding up to ${wanted}.`, ); const limitInfo = clamped ? { limitClamped: true, limitRequested: limit.requested, limitEffective: wanted, } : {}; // Rows are read a page at a time as they're needed. Adding keeps reading // past people who turn out to be duplicates, so a list whose first rows // are already in the campaign still reaches the eligible rows after them. const queue: Recipient[] = []; const sample: Array> = []; let title = ""; let rowCount = 0; let rowsRead = 0; let cursor: string | undefined; let exhausted = false; const readUntil = async (count: number) => { while ( queue.length < count && !exhausted && rowsRead < MAX_LIST_ROWS_READ ) { const page = (await invoke(ctx, "lists.rows", { artifactId, limit: 100, ...(cursor ? { cursor } : {}), ...(options.enrichment ? { enrichment: options.enrichment } : {}), })) as ListRowsPage; title = page.title ?? title; rowCount = page.rowCount ?? rowCount; for (const entry of page.page ?? []) { rowsRead += 1; queue.push(listRowRecipient(entry.row, entry.index)); if (sample.length < 5) sample.push(listRowSummary(entry.row)); } exhausted = Boolean(page.isDone || !page.continueCursor); cursor = exhausted ? undefined : (page.continueCursor ?? undefined); } }; await readUntil(options.preview ? wanted : PEOPLE_BATCH_SIZE); if (!queue.length) throw new Error( `${title ? `"${title}"` : artifactId} has no rows${options.enrichment ? ` with enrichment "${options.enrichment}"` : ""} to add (${rowCount} saved). See dench lists rows ${artifactId}.`, ); if (options.preview) { const previewed = queue.slice(0, wanted); return print( ctx, { sequenceId: campaign._id, campaignStatus: campaign.status, ...limitInfo, list: { artifactId, title, rowCount, rows: previewed.length }, sample, }, `${plural(previewed.length, "row", "rows")} of "${title}" would be added to "${campaign.name}"; nothing was imported. People already in the campaign are skipped, and later rows take their places.`, ); } const totals: BatchCounts & { cappedAtLimit: number | null } = { ...batchCounts({}), cappedAtLimit: null, }; const perBatch: Array> = []; let missingInfoSample: unknown; let attempted = 0; const list = () => ({ artifactId, title, rowCount, rows: attempted }); while (totals.added < wanted) { const size = Math.min(PEOPLE_BATCH_SIZE, wanted - totals.added); await readUntil(size); const chunk = queue.splice(0, size); if (!chunk.length) break; const offset = attempted; attempted += chunk.length; let result: Record; try { result = (await invoke(ctx, "campaigns.people.addBatch", { sequenceId: campaign._id, newRecordsObject: "lead", recipients: chunk, checkDuplicatesAcrossWorkspace: !options.allowDuplicates, })) as Record; } catch (error) { const message = backendErrorText(error); print(ctx, { sequenceId: campaign._id, campaignStatus: campaign.status, ...totals, ...limitInfo, list: list(), batches: perBatch, failedBatch: { offset, size: chunk.length }, error: message, }); throw new Error( `Adding people ${offset + 1}-${offset + chunk.length} failed: ${message}. ${totals.added} ${totals.added === 1 ? "person was" : "people were"} added before it. Run the same command again: existing leads are reused and people already added count as duplicates.`, ); } const counts = batchCounts(result); for (const key of BATCH_COUNTS) totals[key] += counts[key]; missingInfoSample ??= result.missingInfoSample; const capped = typeof result.cappedAtLimit === "number" ? result.cappedAtLimit : null; perBatch.push({ batch: perBatch.length + 1, offset, size: chunk.length, ...counts, cappedAtLimit: capped, }); if (capped !== null) { totals.cappedAtLimit = capped; break; } } if (totals.cappedAtLimit !== null) notice(`The campaign is full at ${totals.cappedAtLimit} people.`); if (campaign.status !== "draft" && totals.added > 0) notice( `"${campaign.name}" is ${campaign.status}, so ${liveEffect(campaign.status)} for the ${plural(totals.added, "person", "people")} just added.`, ); return print( ctx, { sequenceId: campaign._id, campaignStatus: campaign.status, ...totals, ...(missingInfoSample ? { missingInfoSample } : {}), ...limitInfo, batches: perBatch, list: list(), }, `Added ${totals.added} of ${plural(attempted, "row", "rows")} from "${title}" to "${campaign.name}" (${campaign.status}).`, ); } function csvFailure(file: string, error: string, table: RecipientTable): Error { const hint = table.columnLabels.length ? ` Columns found: ${table.columnLabels.join(", ")}. Point a column at a field with --map, ${COLUMN_MAP_USAGE}` : ""; return new Error(`${file}: ${error}${hint}`); } async function addCsv( ctx: Context, file: string, maps: string[], reference: string, options: { allowDuplicates: boolean; preview: boolean }, ) { const text = await readFile(file, "utf8").catch(() => { throw new Error(`Cannot read ${file}`); }); if (text.length > MAX_CSV_CHARS) throw new Error( `${file} is larger than 5 MB. A campaign holds ${MAX_CAMPAIGN_PEOPLE} people at most: keep the columns you need and split the rows.`, ); const table = parseRecipientTable(text, detectDelimiter(text)); const mapping = withDetectedFullName(table, parseColumnMap(maps, table)); const result = recipientsFromTable(table, mapping); const columns = result.columns.map( ({ index, label, sample, role, mergeKey }) => ({ column: label, index, sample, role, ...(mergeKey ? { mergeKey } : {}), }), ); if (result.error) throw csvFailure(file, result.error, table); const csv = { file, rows: result.rows, people: result.recipients.length, rowsWithoutEmailOrLinkedin: result.noChannel, repeatedRows: result.duplicates, hasHeader: result.hasHeader, columns, }; const read = columnSummary(result.columns, { email: mapping.email === null, linkedin: mapping.linkedin === null, }); const mergeColumns = result.columns.filter( (column) => column.role === "merge value", ); if (result.truncated) notice( `${file} has more than ${MAX_CSV_RECIPIENTS} people; only the first ${MAX_CSV_RECIPIENTS} were read. Split the file to add the rest.`, ); if (result.noChannel > 0) notice( `${plural(result.noChannel, "row has", "rows have")} no usable email or LinkedIn profile URL and ${result.noChannel === 1 ? "was" : "were"} left out.`, ); if (result.duplicates > 0) notice( `${plural(result.duplicates, "row repeats", "rows repeat")} an earlier person's email or LinkedIn profile URL; anything new on ${result.duplicates === 1 ? "it" : "them"} was added to that person.`, ); if (table.raggedRows) notice( `${plural(table.raggedRows, "row has", "rows have")} more cells than the header row, so the columns may not line up; the extra cells became their own merge values. Check ${file} if that looks wrong.`, ); notice( `Read ${file}: ${read.join("; ")}${mergeColumns.length ? `; ${plural(mergeColumns.length, "other column becomes", "other columns become")} merge values (${mergeColumns.map((column) => `{{${column.mergeKey?.replace(/^crm:/, "") ?? column.label}}}`).join(", ")})` : ""}.`, ); if (options.preview) return print( ctx, { ...csv, truncated: result.truncated, sample: result.recipients.slice(0, 5), }, `${plural(result.recipients.length, "person", "people")} in ${file}; nothing was added.`, ); return addRecipients( ctx, result.recipients, file, reference, options.allowDuplicates, { csv }, ); } async function addRecipients( ctx: Context, recipients: Recipient[], file: string, reference: string, allowDuplicates: boolean, extra: Record = {}, ) { const noIdentity = recipients.flatMap((recipient, index) => recipient.email || recipient.linkedinProfileUrl ? [] : [`recipients[${index}]`], ); if (noIdentity.length === recipients.length) throw new Error( `Nobody in ${file} has an email or a LinkedIn profile URL, so nobody can be added. Give each person "email" and/or "linkedinProfileUrl" (also read from linkedinUrl or linkedin).`, ); if (noIdentity.length) notice( `${noIdentity.length} of ${recipients.length} people have no email or LinkedIn profile URL and will be skipped: ${noIdentity.slice(0, 5).join(", ")}${noIdentity.length > 5 ? ", …" : ""}`, ); const batches: Array> = []; for ( let offset = 0; offset < recipients.length; offset += PEOPLE_BATCH_SIZE ) { const body = { recipients: recipients.slice(offset, offset + PEOPLE_BATCH_SIZE), newRecordsObject: "lead", checkDuplicatesAcrossWorkspace: !allowDuplicates, }; validate("campaigns.people.addBatch", { ...body, sequenceId: "pending" }); batches.push(body); } const campaign = await addableCampaign(ctx, reference); const sequenceId = campaign._id; const campaignStatus = campaign.status; const totals: BatchCounts & { cappedAtLimit: number | null } = { ...batchCounts({}), cappedAtLimit: null, }; const perBatch: Array> = []; let missingInfoSample: unknown; let reached = 0; for (const [index, batch] of batches.entries()) { const offset = index * PEOPLE_BATCH_SIZE; const size = (batch.recipients as unknown[]).length; let result: Record; try { result = (await invoke(ctx, "campaigns.people.addBatch", { ...batch, sequenceId, })) as Record; } catch (error) { const message = backendErrorText(error); print(ctx, { sequenceId, campaignStatus, ...totals, batches: perBatch, failedBatch: { batch: index + 1, of: batches.length, offset, size }, notAttempted: recipients.length - offset, error: message, }); throw new Error( `Batch ${index + 1} of ${batches.length} (people ${offset + 1}-${offset + size}) failed: ${message}. ${totals.added} ${totals.added === 1 ? "person was" : "people were"} added before it; that batch and any after it were not confirmed. Re-running the same file is safe: people already added count as duplicates.`, ); } const counts = batchCounts(result); for (const key of BATCH_COUNTS) totals[key] += counts[key]; missingInfoSample ??= result.missingInfoSample; reached = offset + considered(counts); const capped = typeof result.cappedAtLimit === "number" ? result.cappedAtLimit : null; perBatch.push({ batch: index + 1, offset, size, ...counts, cappedAtLimit: capped, }); if (capped !== null) { totals.cappedAtLimit = capped; break; } } const notConsidered = totals.cappedAtLimit === null ? 0 : recipients.length - reached; if (totals.cappedAtLimit !== null) notice( `The campaign is full at ${totals.cappedAtLimit} people; ${notConsidered} from ${file} were not added.`, ); if (totals.skippedNoIdentity > noIdentity.length) notice( `${totals.skippedNoIdentity} skipped for having no usable email or LinkedIn profile URL.`, ); if (totals.invalid > 0) notice( `${totals.invalid} skipped because their email or LinkedIn profile URL is not valid.`, ); if (campaignStatus !== "draft" && totals.added > 0) notice( `"${campaign.name}" is ${campaignStatus}, so ${liveEffect(campaignStatus)} for the ${plural(totals.added, "person", "people")} just added.`, ); return print( ctx, { sequenceId, campaignStatus, ...totals, ...(notConsidered ? { notConsidered } : {}), ...(missingInfoSample ? { missingInfoSample } : {}), batches: perBatch, ...extra, }, `Added ${totals.added} of ${recipients.length} people from ${file}.`, ); } type CampaignSettings = { references?: unknown[]; _id: string; name: string; status: string; timezone?: string; sendSchedules?: SendSchedule[]; dailySendCap?: number; stopOnReply?: boolean; startsAt?: number; endsAt?: number; emailMailboxAccountIds?: string[]; linkedinAccountIds?: string[]; }; async function readCampaign( ctx: Context, sequenceId: string, ): Promise { const detail = (await invoke(ctx, "campaigns.get", { sequenceId })) as { sequence?: CampaignSettings; } | null; if (!detail?.sequence) throw new Error( `Campaign ${sequenceId} could not be read back. Run dench campaign list to find it.`, ); return { ...detail.sequence, references: (detail as { references?: unknown[] }).references, }; } function plural(count: number, one: string, many: string) { return `${count} ${count === 1 ? one : many}`; } async function draft(ctx: Context) { const name = value(ctx.args, "--name"); const requestId = value(ctx.args, "--request-id"); const flags = consumeScheduleFlags(ctx.args); const senderPools = consumeSenderPoolFlags(ctx.args); finishArgs(ctx.args, "draft"); const clientRequestId = (requestId ?? randomUUID()).trim(); if (clientRequestId.length < 8 || clientRequestId.length > 64) throw new Error( `--request-id must be 8-64 characters (got ${clientRequestId.length}). Reuse one stable id, e.g. us-founders-2026-09-30, so a retry returns the same draft.`, ); const timezone = assertIanaTimezone(flags.timezone ?? localTimezone()); schedulePatchFromFlags(flags, { fallbackTimezone: timezone }); // The chosen senders go in with the draft, so one that can't be used fails // before anything is created. const { sequenceId } = (await invoke(ctx, "campaigns.drafts.create", { clientRequestId, ...(name ? { name } : {}), timezone, ...senderPools, })) as { sequenceId: string }; let campaign = await readCampaign(ctx, sequenceId); const schedule = schedulePatchFromFlags(flags, { fallbackTimezone: campaign.timezone ?? timezone, existingSchedules: campaign.sendSchedules, }); if ( schedule.sendSchedules && JSON.stringify(schedule.sendSchedules) === JSON.stringify(campaign.sendSchedules) ) delete schedule.sendSchedules; const patch = { ...schedule, ...senderPools }; if (hasScheduleChanges(patch)) { if (campaign.status === "draft") { try { await invoke(ctx, "campaigns.update", { sequenceId, ...patch }); } catch (error) { // The draft exists: say which, and that the same --request-id // returns it, so a retry fixes it rather than creating another. const message = backendErrorText(error); print(ctx, { sequenceId, clientRequestId, status: campaign.status, settingsSaved: false, error: message, }); throw new Error( `Created draft "${campaign.name}" [${sequenceId}], but its settings weren't saved: ${message}. Re-run with --request-id ${clientRequestId} and corrected flags to update the same draft, or use dench campaign set ${sequenceId}.`, ); } campaign = await readCampaign(ctx, sequenceId); } else { notice( `"${campaign.name}" already exists for --request-id ${clientRequestId} and is ${campaign.status}, so its settings were not changed. Change them with dench campaign set.`, ); } } // The draft starts with the caller's own accounts. A channel with none of // their own takes the accounts teammates shared with them, named, rather // than leaving a campaign that can't launch. let usesShared = false; if ( campaign.status === "draft" && !senderPools.emailMailboxAccountIds && !senderPools.linkedinAccountIds ) { const senders = await usableSenders(ctx).catch(() => null); const fill: Record = {}; if (!campaign.emailMailboxAccountIds?.length && senders?.mailboxes.length) fill.emailMailboxAccountIds = senders.mailboxes.map((row) => row.id); if (!campaign.linkedinAccountIds?.length && senders?.linkedin.length) fill.linkedinAccountIds = senders.linkedin.map((row) => row.id); if (senders && Object.keys(fill).length) { try { await invoke(ctx, "campaigns.update", { sequenceId, ...fill }); campaign = await readCampaign(ctx, sequenceId); usesShared = true; for (const line of [ ...(fill.emailMailboxAccountIds ? sharedSenderNotes(senders.mailboxes, "email") : []), ...(fill.linkedinAccountIds ? sharedSenderNotes(senders.linkedin, "LinkedIn") : []), ]) notice(line); } catch (error) { notice( `A shared account couldn't be attached: ${backendErrorText(error)}`, ); } } } const emailMailboxAccountIds = campaign.emailMailboxAccountIds ?? []; const linkedinAccountIds = campaign.linkedinAccountIds ?? []; if (!emailMailboxAccountIds.length && !linkedinAccountIds.length) notice( "No sending account is attached, and none is connected that you can use. dench tool connect gmail (or outlook, linkedin) prints a sign-in link; a teammate's account works once they share it with you. Then: dench campaign set --email-mailboxes --linkedin-accounts (ids from dench campaign accounts).", ); else if ( !senderPools.emailMailboxAccountIds && !senderPools.linkedinAccountIds ) notice( `${usesShared ? "Sending from" : "Sending from every connected account you own:"} ${plural(emailMailboxAccountIds.length, "mailbox", "mailboxes")} and ${plural(linkedinAccountIds.length, "LinkedIn account", "LinkedIn accounts")}${usesShared ? ", including the shared ones above" : ""}. Choose with --email-mailboxes / --linkedin-accounts (ids from dench campaign accounts).`, ); const sendSchedules = campaign.sendSchedules ?? []; const windows = sendSchedules .map( (window) => `${formatWeekdays(window.weekdays)} ${window.startHour}-${window.endHour} ${window.timezone}`, ) .join("; "); return print( ctx, campaignReferenceResult( ctx, { references: campaign.references, sequenceId, name: campaign.name, status: campaign.status, clientRequestId, timezone: campaign.timezone ?? timezone, sendSchedules, dailySendCap: campaign.dailySendCap ?? null, stopOnReply: campaign.stopOnReply !== false, ...(campaign.startsAt ? { startsAt: campaign.startsAt } : {}), ...(campaign.endsAt ? { endsAt: campaign.endsAt } : {}), emailMailboxAccountIds, linkedinAccountIds, }, sequenceId, campaign.name, ), `Draft "${campaign.name}" [${sequenceId}] · ${windows || "no send window"} · ${plural(emailMailboxAccountIds.length, "mailbox", "mailboxes")}, ${plural(linkedinAccountIds.length, "LinkedIn account", "LinkedIn accounts")} · request id ${clientRequestId}`, ); } // ─── builder run: the whole New campaign flow in one command ────────────── /** The outreach half of a run file; everything else is the draft's setup. */ const RUN_OUTREACH_KEYS = [ "brief", "steps", "sending", "approvalMode", ] as const; const RUN_POLL_MS = 4_000; const RUN_WAIT_SECONDS = 600; type RunDraft = { status: "editing" | "building" | "ready" | "failed" | "created"; leadCount: number; error?: string; errorCode?: string; sequenceId?: string; people: Array<{ displayName: string; title?: string; companyName: string }>; draftSteps?: unknown[]; workflowSteps?: unknown[]; sending?: Record; approvalMode?: "auto" | "manual"; brief?: Record; emails?: { finding: number; missing: number }; name?: string; }; type SenderRow = { id: string; label: string; owner: string | null; isOwner: boolean; }; function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function senderQuery(ctx: Context, name: string): Promise { return ctx.convex.query(makeFunctionReference<"query">(name), { sessionToken: ctx.sessionToken, } as never); } /** * The accounts this person can send from, their own first. A channel with * none of their own uses accounts teammates shared with them, and says so. */ async function usableSenders(ctx: Context) { const [mailRows, linkedinRows] = await Promise.all([ senderQuery(ctx, "functions/emailMailboxAccounts:listAccounts"), senderQuery(ctx, "functions/linkedinAccounts:listSenderAccounts"), ]); const mailboxes: SenderRow[] = ( Array.isArray(mailRows) ? (mailRows as Record[]) : [] ) .filter( (row) => row.status === "running" && (row.isOwner === true || row.visibility !== "private"), ) .map((row) => ({ id: String(row._id), label: String(row.email ?? "mailbox"), owner: typeof row.ownerName === "string" ? row.ownerName : null, isOwner: row.isOwner === true, })); const linkedin: SenderRow[] = ( Array.isArray(linkedinRows) ? (linkedinRows as Record[]) : [] ) .filter((row) => row.status === "running") .map((row) => ({ id: String(row.linkedinAccountId), label: String(row.displayName ?? "LinkedIn account"), owner: typeof row.ownerName === "string" ? row.ownerName : null, isOwner: row.isOwner === true, })); const pick = (rows: SenderRow[]) => { const own = rows.filter((row) => row.isOwner); return own.length ? own : rows; }; return { mailboxes: pick(mailboxes), linkedin: pick(linkedin) }; } function sharedSenderNotes(rows: SenderRow[], channel: string): string[] { return rows .filter((row) => !row.isOwner) .map( (row) => `Sending ${channel} from ${row.label}${row.owner ? `, ${row.owner}'s account shared with you` : ", an account shared with you"}.`, ); } function failedBuildMessage(draft: RunDraft, draftId: string): string { if (draft.errorCode === "out_of_credits") return `Finding people stopped: the workspace is out of credits. Add credits (dench billing topup --amount 10 prints a link), then run dench campaign builder run --draft ${draftId} again.`; return `Finding people failed${draft.error ? `: ${draft.error}` : ""}. Change the setup with dench campaign builder save, then run dench campaign builder run --draft ${draftId} again.`; } async function builderRun(ctx: Context, input: Record) { const resumeId = value(ctx.args, "--draft"); const launch = hasFlag(ctx.args, "--launch"); const ackLinkedinRisk = hasFlag(ctx.args, "--ack-linkedin-risk"); const paused = hasFlag(ctx.args, "--paused"); const name = value(ctx.args, "--name"); const timezoneFlag = value(ctx.args, "--timezone"); const waitFlag = value(ctx.args, "--wait"); finishArgs(ctx.args, "builder run"); if (launch && paused) throw new Error("Use --launch or --paused, not both"); const waitSeconds = waitFlag === undefined ? RUN_WAIT_SECONDS : Number(waitFlag); if (!Number.isFinite(waitSeconds) || waitSeconds < 30 || waitSeconds > 1800) throw new Error("--wait takes 30-1800 seconds"); const deadline = Date.now() + waitSeconds * 1000; const timezone = assertIanaTimezone(timezoneFlag ?? localTimezone()); const outreach: Record = {}; const setup: Record = {}; for (const [key, field] of Object.entries(input)) { if ((RUN_OUTREACH_KEYS as readonly string[]).includes(key)) outreach[key] = field; else setup[key] = field; } const notes: string[] = []; // 1. The setup: a new draft, or the one being resumed. let draftId = resumeId; if (!draftId) { if (!setup.clientRequestId) setup.clientRequestId = randomUUID(); // A list left out means none, as in the app; the save needs every one. if (setup.targeting && typeof setup.targeting === "object") setup.targeting = { titles: [], industries: [], companySizes: [], locations: [], excludes: [], ...(setup.targeting as Record), }; if (setup.screen === undefined) setup.screen = setup.source === "lookalike" || setup.lookalike ? "lookalike" : "target"; const saved = (await invoke(ctx, "campaigns.builder.save", setup)) as { draftId?: string; }; if (!saved?.draftId) throw new Error("The campaign setup wasn't saved"); draftId = saved.draftId; // Saved before the billable build: a build that fails or outlasts --wait // is resumed with --draft alone, which must read these back, not defaults. if (Object.keys(outreach).length) await invoke(ctx, "campaigns.builder.outreach", { draftId, ...(outreach.brief ? { brief: outreach.brief } : {}), ...(outreach.sending ? { sending: outreach.sending } : {}), ...(outreach.approvalMode ? { approvalMode: outreach.approvalMode } : {}), ...(Array.isArray(outreach.steps) ? { steps: outreach.steps, outreachMode: "manual" } : {}), }); } else if (Object.keys(setup).length) { throw new Error( "--draft resumes a saved setup as it is; change it with dench campaign builder save first", ); } const get = async () => (await invoke(ctx, "campaigns.builder.get", { draftId })) as RunDraft; let draft = await get(); // 2. Find the people (billable), unless this draft already has them. if (draft.status === "editing" || draft.status === "failed") { notice( "Finding people now. This is billed to the workspace, like the app's build.", ); const started = (await invoke(ctx, "campaigns.builder.build", { draftId, })) as { started?: boolean }; if (started?.started === false && draft.status !== "failed") notice("A build was already running for this draft; waiting for it."); draft = await get(); if (draft.status === "editing") throw new Error( `The setup can't be built yet: it needs at least one signal (or a lookalike) and someone to look for. Fix it with dench campaign builder save, then run dench campaign builder run --draft ${draftId}.`, ); } let reported = -1; while (draft.status === "building") { if (Date.now() > deadline) throw new Error( `Still finding people after ${waitSeconds}s (${draft.leadCount} so far). Nothing is lost: run dench campaign builder run --draft ${draftId} to carry on.`, ); if (draft.leadCount !== reported) { reported = draft.leadCount; notice(`Finding people… ${draft.leadCount} so far.`); } await sleep(RUN_POLL_MS); draft = await get(); } if (draft.status === "failed") throw new Error(failedBuildMessage(draft, draftId)); if (draft.status === "ready" && draft.leadCount === 0) throw new Error( `No one matched this setup yet. Widen the titles, places or signals with dench campaign builder save (clientRequestId of this draft), then run dench campaign builder run --draft ${draftId}.`, ); // 3. The messages and who sends them, unless the campaign already exists. if (draft.status !== "created") { const hasSteps = Boolean( draft.draftSteps?.length || draft.workflowSteps?.length, ); // A resumed run has no input: the brief saved before the build stands in. const brief = outreach.brief ?? draft.brief; if (!Array.isArray(outreach.steps) && !hasSteps && brief) { notice("Writing the sequence from the brief (AI credits apply)."); await invoke(ctx, "campaigns.builder.sequence", { draftId, brief }); } else if (!Array.isArray(outreach.steps) && !hasSteps) { notes.push( "No brief or steps were given, so it uses Dench's default signal sequence. Pass a brief (goal, helpWith, tone, channels) to write one for this audience.", ); } let sending = (outreach.sending ?? draft.sending) as | Record | undefined; if (!sending) { const senders = await usableSenders(ctx); notes.push( ...sharedSenderNotes(senders.mailboxes, "email"), ...sharedSenderNotes(senders.linkedin, "LinkedIn"), ); if (!senders.mailboxes.length && !senders.linkedin.length) notes.push( "No mailbox or LinkedIn account can send for you yet: dench tool connect gmail (or outlook, linkedin) prints a sign-in link.", ); sending = { emailMailboxAccountIds: senders.mailboxes.map((row) => row.id), linkedinAccountIds: senders.linkedin.map((row) => row.id), timezone, startHour: 9, endHour: 17, weekdays: [1, 2, 3, 4, 5], dailySendCap: 30, }; } await invoke(ctx, "campaigns.builder.outreach", { draftId, sending, approvalMode: outreach.approvalMode ?? draft.approvalMode ?? "manual", ...(Array.isArray(outreach.steps) ? { steps: outreach.steps, outreachMode: "manual" } : {}), }); } // 4. Create the campaign. Emails can still be arriving for a moment. let created: { sequenceId: string; enrolled?: number } | undefined; while (!created) { try { created = (await invoke(ctx, "campaigns.builder.create", { draftId, timezone, ...(name ? { name } : {}), ...(paused ? { paused: true } : {}), })) as { sequenceId: string; enrolled?: number }; } catch (error) { const message = backendErrorText(error); if (!/Still finding/.test(message) || Date.now() > deadline) throw error; await sleep(RUN_POLL_MS); } } const campaign = await readCampaign(ctx, created.sequenceId); // 5. Check it, and launch it when asked: the app's "Launch campaign". const stage = await launchStage( ctx, { _id: created.sequenceId, name: campaign.name }, { launch, ackLinkedinRisk }, ); const people = (draft.people ?? []).map((person) => [person.displayName, person.title, person.companyName] .filter(Boolean) .join(", "), ); const status = stage.launched ? "active" : campaign.status; const summary = [ `Created "${campaign.name}" [${created.sequenceId}] with ${plural(stage.audienceCount, "person", "people")} and ${plural(stage.stepCount, "step", "steps")}.`, ...notes, ...stage.lines, ]; return print( ctx, campaignReferenceResult( ctx, { references: campaign.references, draftId, sequenceId: created.sequenceId, name: campaign.name, status, people: { count: stage.audienceCount, sample: people.slice(0, 10) }, stepCount: stage.stepCount, emailMailboxAccountIds: campaign.emailMailboxAccountIds ?? [], linkedinAccountIds: campaign.linkedinAccountIds ?? [], ...(draft.emails?.missing ? { leftOutWithoutEmail: draft.emails.missing } : {}), launch: { ready: stage.ready, launched: stage.launched, needsLinkedinAck: stage.needsLinkedinAck, blockers: stage.blockers, warnings: stage.warnings, next: stage.next, }, notes, }, created.sequenceId, campaign.name, ), summary.join("\n"), ); } export async function runCampaignAgentCommand( ctx: Context, command: string, ): Promise { if (command === "add") return add(ctx); if (command === "draft") return draft(ctx); if (command === "search") { const input = await searchRequest(ctx.args, "search"); const output = outputOptions(ctx.args); assertNoUnknownFlags(ctx.args, "campaign search"); const reference = ctx.args.shift(); finishArgs(ctx.args, command); validate("campaigns.audience.search", input); await claimOut(output); const sequenceId = reference ? (await ctx.resolveCampaign(reference))._id : undefined; costNotice(input.limit); const page = withSearchCharge( (await invoke(ctx, "campaigns.audience.search", { ...input, ...(sequenceId ? { sequenceId } : {}), })) as Record, ); const scope = sequenceId ? { sequenceId } : {}; const full = { ...scope, ...page, nextPageToken: pageParts(page).nextPageToken, }; const saveError = output.out ? await saveOut(output.out, full) : null; if (output.out && saveError) notice( `Could not save ${output.out}: ${saveError}. The search was already billed, so the full result is printed here instead; keep it. Running this again would search and bill again.`, ); return print( ctx, searchView(output, scope, page, full, saveError), searchSummary(page), ); } if (command === "queue") { const reference = shift(ctx.args, "campaign id or name"); finishArgs(ctx.args, "queue"); const sequenceId = (await ctx.resolveCampaign(reference))._id; return print(ctx, await invoke(ctx, "campaigns.queue", { sequenceId })); } const verb = shift(ctx.args, `${command} subcommand`); const input = await objectInput(ctx.args); if (command === "sources") { const operations: Record = { get: "get", signals: "signals", set: "update", preview: "preview", run: "run", overview: "overview", pending: "pending", approve: "review", reject: "review", }; const operation = operations[verb]; if (!operation) throw new Error( "Use campaign sources get|signals|overview|set|preview|run|pending|approve|reject", ); const signal = value(ctx.args, "--signal"); if (signal) input.signal = signal; const approval = value(ctx.args, "--approval"); if (approval) input.approvalMode = approval; if (operation === "review") { input.decision = verb; const people = value(ctx.args, "--people"); if (people) input.enrollmentIds = splitListValue(people); } const reference = shift(ctx.args, "campaign id or name"); finishArgs(ctx.args, "sources"); validate(`campaigns.sources.${operation}`, { ...input, sequenceId: "pending", }); const sequenceId = (await ctx.resolveCampaign(reference))._id; return print( ctx, await invoke(ctx, `campaigns.sources.${operation}`, { ...input, sequenceId, }), ); } if (verb === "run") return builderRun(ctx, input); const verbs = new Set([ "profile", "save", "get", "build", "create", "opener", "autofill", "estimate", "drafts", "resume", "discard", "outreach", "sequence", "lookalike", "voices", ]); if (!verbs.has(verb)) throw new Error( "Use campaign builder run|profile|autofill|estimate|lookalike|voices|save|get|build|opener|outreach|sequence|create|drafts|resume|discard", ); const name = value(ctx.args, "--name"); const timezone = value(ctx.args, "--timezone"); const description = value(ctx.args, "--description"); const text = value(ctx.args, "--text"); if (name) input.name = name; if (timezone) input.timezone = timezone; if (description) input.description = description; if (text !== undefined) input.opener = text; if (verb === "create" && hasFlag(ctx.args, "--paused")) input.paused = true; if (verb === "lookalike") { const url = value(ctx.args, "--linkedin-url"); if (url) input.linkedinUrl = url; } if (verb === "voices") { const topics = value(ctx.args, "--topics"); const market = value(ctx.args, "--market"); if (topics) input.topics = splitListValue(topics); if (market) input.market = market; } if (verb === "outreach") { const approval = value(ctx.args, "--approval"); const mode = value(ctx.args, "--mode"); if (approval) input.approvalMode = approval; if (mode) input.outreachMode = mode; } if (verb === "sequence" && !input.brief) { const brief = { goal: value(ctx.args, "--goal"), helpWith: value(ctx.args, "--help-with"), tone: value(ctx.args, "--tone") ?? "conversational", channels: value(ctx.args, "--channels") ?? "both", }; if (brief.goal || brief.helpWith) input.brief = { ...brief, helpWith: brief.helpWith ?? "" }; } if ( [ "get", "build", "create", "opener", "resume", "discard", "outreach", "sequence", ].includes(verb) && !input.draftId ) input.draftId = shift(ctx.args, "draft id"); if (verb === "opener" && !input.leadId) input.leadId = shift(ctx.args, "draft lead id"); if (verb === "save" && !input.clientRequestId) input.clientRequestId = randomUUID(); // A save from here is deliberate: like the app's "Save and exit" it gets // a screen, so `builder drafts` lists it and it keeps for two weeks. // (Without one the server treats it as a passing autosave: unlisted, // deleted after a day.) if (verb === "save" && input.screen === undefined) input.screen = input.source === "lookalike" || input.lookalike ? "lookalike" : "target"; finishArgs(ctx.args, "builder"); const result = await invoke(ctx, `campaigns.builder.${verb}`, input); if (verb === "outreach") { // The save itself returns nothing; show what the draft now holds. const draft = (await invoke(ctx, "campaigns.builder.get", { draftId: input.draftId, })) as RunDraft & { steps?: unknown[] }; return print(ctx, { saved: true, draftId: input.draftId, steps: draft.workflowSteps ?? draft.draftSteps ?? draft.steps ?? [], sending: draft.sending ?? null, approvalMode: draft.approvalMode ?? "manual", next: `dench campaign builder create ${input.draftId}`, }); } if (verb === "estimate") { const estimate = result as { count?: number | null; error?: string }; if (estimate?.error === "out_of_credits") notice( "No estimate: the workspace is out of credits. dench billing topup --amount 10 prints a link to add some.", ); else if (estimate && estimate.count === null) notice( "No estimate for this setup right now (too little to count, or the count is busy). It doesn't block building.", ); } return print(ctx, result); }