import { createHash, createHmac, randomUUID } from "node:crypto"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { basename, extname, join } from "node:path"; import { ConvexHttpClient } from "convex/browser"; import { makeFunctionReference } from "convex/server"; import { EMAIL_INBOX_HELP, runEmailInboxCommand } from "./email-inbox"; import { assertNoUnknownFlags, getFlag, hasFlag, isHelpRequest, parseJson, } from "./lib/cli-args"; import { denchConfigDirectory } from "./lib/config-path.mjs"; import { waitForNativeEmail } from "./lib/native-email-wait"; export type EmailCliContext = { convex: ConvexHttpClient; args: string[]; jsonOutput: boolean; sessionToken?: string; bearerToken?: string; gatewayBaseUrl?: string; gatewayHeaders?: Record; /** Injectable read transport; it must honor the supplied observation deadline. */ readNativeReceipt?: ( draftId: string, signal: AbortSignal, ) => Promise; }; class EmailCliError extends Error { constructor( message: string, readonly code = "invalid_arguments", ) { super(message); } } type JsonRecord = Record; function normalizeGatewayBaseUrl(value: string): string { return value.replace(/\/+$/, "").replace(/\/v1$/, ""); } function resolveGatewayBaseUrl(ctx: EmailCliContext): string { const explicit = process.env.DENCH_GATEWAY_URL?.trim() || process.env.GATEWAY_URL?.trim(); if (explicit) return normalizeGatewayBaseUrl(explicit); if (ctx.gatewayBaseUrl?.trim()) { return normalizeGatewayBaseUrl(ctx.gatewayBaseUrl.trim()); } const host = process.env.DENCH_HOST?.trim(); if (host && /localhost|127\.0\.0\.1/.test(host)) { return "http://localhost:8787"; } return "https://gateway.merseoriginals.com"; } function requireBearerToken(ctx: EmailCliContext): string { const token = ctx.bearerToken?.trim() || process.env.DENCH_API_KEY?.trim(); if (!token) { throw new EmailCliError( "No Dench credential found. Set DENCH_API_KEY or run `dench signin`.", "login_required", ); } return token; } async function callGateway( ctx: EmailCliContext, method: "GET" | "POST", path: string, body?: JsonRecord, ) { if (method === "POST") assertEmailArgs(ctx); const baseUrl = resolveGatewayBaseUrl(ctx); const response = await fetch(`${baseUrl}${path}`, { method, headers: { accept: "application/json", authorization: `Bearer ${requireBearerToken(ctx)}`, ...(ctx.gatewayHeaders ?? {}), ...(method === "POST" ? { "content-type": "application/json" } : {}), }, ...(method === "POST" ? { body: JSON.stringify(body ?? {}) } : {}), }); const text = await response.text(); if (!response.ok) { throw new EmailCliError( `Email service returned ${response.status}: ${text.slice(0, 500)}`, "upstream_failed", ); } const payload = text ? JSON.parse(text) : {}; return payload; } async function callMutation( ctx: EmailCliContext, name: string, args: JsonRecord, ) { assertEmailArgs(ctx); const ref = makeFunctionReference<"mutation">(name); return await ctx.convex.mutation(ref, { ...args, sessionToken: ctx.sessionToken, } as never); } async function callQuery(ctx: EmailCliContext, name: string, args: JsonRecord) { const ref = makeFunctionReference<"query">(name); return await ctx.convex.query(ref, { ...args, sessionToken: ctx.sessionToken, } as never); } async function callAction( ctx: EmailCliContext, name: string, args: JsonRecord, ) { assertEmailArgs(ctx); const ref = makeFunctionReference<"action">(name); return await ctx.convex.action(ref, { ...args, sessionToken: ctx.sessionToken, } as never); } function out(ctx: EmailCliContext, payload: unknown, text: string) { if (ctx.jsonOutput) { console.log(JSON.stringify(payload, null, 2)); return; } console.log(text); } function requiredFlag(args: string[], name: string): string { const value = getFlag(args, name)?.trim(); if (!value) throw new EmailCliError(`Missing required ${name}`); return value; } /** Default page cap for internally paginated list commands. */ export const DEFAULT_MAX_PAGES = 1; const HARD_MAX_PAGES = 100; export type PaginatedEnvelope = { page: unknown[]; isDone: boolean; continueCursor: string | null; }; /** * Drain a Convex `{page, isDone, continueCursor}` pagination envelope for up * to `maxPages` pages. Aborts loudly (instead of looping forever) when the * server returns the same `continueCursor` twice or omits it while * `isDone` is false. */ export async function collectPaginated( fetchPage: (cursor: string | null) => Promise, options: { maxPages: number }, ): Promise<{ rows: unknown[]; pagesFetched: number; isDone: boolean; continueCursor: string | null; }> { const maxPages = Math.max( 1, Math.min(Math.floor(options.maxPages), HARD_MAX_PAGES), ); const seenCursors = new Set(); const rows: unknown[] = []; let cursor: string | null = null; let pagesFetched = 0; let isDone = false; while (pagesFetched < maxPages) { const result = await fetchPage(cursor); pagesFetched += 1; if (Array.isArray(result?.page)) rows.push(...result.page); isDone = result?.isDone === true; const next = typeof result?.continueCursor === "string" && result.continueCursor ? result.continueCursor : null; cursor = next; if (isDone) break; if (!next) { throw new EmailCliError( "Pagination error: server reported isDone=false but returned no continueCursor. Aborting instead of looping.", ); } if (seenCursors.has(next)) { throw new EmailCliError( `Pagination aborted: server returned the same continueCursor twice (${next.slice(0, 24)}…). Refusing to loop; re-run with a smaller --limit or report this.`, ); } seenCursors.add(next); } return { rows, pagesFetched, isDone, continueCursor: cursor }; } function maxPagesFlag(args: string[]): number { const raw = getFlag(args, "--max-pages"); if (raw === undefined) return DEFAULT_MAX_PAGES; const parsed = Number(raw); if (!Number.isFinite(parsed) || parsed < 1) { throw new EmailCliError("--max-pages must be a positive integer"); } return Math.floor(parsed); } type ResolvedCampaignMatch = { campaignId: string; name: string; status: string; recipientCount: number; }; async function resolveCampaignByNameQuery( ctx: EmailCliContext, name: string, ): Promise<{ matchType: string; matches: ResolvedCampaignMatch[] }> { const payload = (await callQuery( ctx, "functions/emailCampaigns:resolveCampaignByName", { name }, )) as JsonRecord; return { matchType: typeof payload.matchType === "string" ? payload.matchType : "none", matches: Array.isArray(payload.matches) ? (payload.matches as ResolvedCampaignMatch[]) : [], }; } function formatCampaignCandidates(matches: ResolvedCampaignMatch[]): string { return matches .map( (match) => ` ${match.campaignId} ${JSON.stringify(match.name)} status=${match.status} recipients=${match.recipientCount}`, ) .join("\n"); } /** * Resolve the target campaign for a subcommand: `--campaign ` wins; * otherwise `--name ""` is resolved via * resolveCampaignByName and must match exactly one campaign. */ async function resolveCampaignRef(ctx: EmailCliContext): Promise { const explicit = getFlag(ctx.args, "--campaign")?.trim(); if (explicit) return explicit; const name = getFlag(ctx.args, "--name")?.trim(); if (!name) { throw new EmailCliError('Pass --campaign or --name ""'); } const { matches } = await resolveCampaignByNameQuery(ctx, name); if (matches.length === 1) return matches[0].campaignId; if (matches.length === 0) { throw new EmailCliError( `No campaign matches name ${JSON.stringify(name)}. List campaigns with \`dench email campaign list\` or resolve with \`dench email campaign resolve --name "${name}"\`.`, ); } throw new EmailCliError( `Campaign name ${JSON.stringify(name)} is ambiguous (${matches.length} matches). Pass --campaign instead. Candidates:\n${formatCampaignCandidates(matches)}`, ); } async function resolveMailboxAccountIds( ctx: EmailCliContext, mailbox: string, ): Promise { const payload = await callQuery( ctx, "functions/emailMailboxAccounts:listAccounts", {}, ); const accounts = Array.isArray(payload) ? (payload as JsonRecord[]) : []; const needle = mailbox.trim().toLowerCase(); const match = accounts.find((row) => { const id = typeof row._id === "string" ? row._id : ""; const email = typeof row.email === "string" ? row.email.toLowerCase() : ""; return id === mailbox.trim() || email === needle; }); if (!match || typeof match._id !== "string") { throw new EmailCliError( `Connected inbox not found: ${mailbox}. Connect Gmail or Outlook in Outreach → Mailboxes.`, ); } return [match._id]; } async function campaignSenderArgs(ctx: EmailCliContext): Promise<{ identityId?: string; mailboxAccountIds?: string[]; }> { const identity = getFlag(ctx.args, "--identity")?.trim(); const mailbox = getFlag(ctx.args, "--mailbox")?.trim(); if (identity && mailbox) { throw new EmailCliError("Pass --mailbox or --identity, not both."); } if (mailbox) { return { mailboxAccountIds: await resolveMailboxAccountIds(ctx, mailbox) }; } if (identity) return { identityId: identity }; throw new EmailCliError( "Pass --mailbox (connected inbox) or --identity .", ); } function optionalJsonFlag(args: string[], name: string): unknown | undefined { const raw = getFlag(args, name); return raw === undefined ? undefined : parseJson(raw); } async function readTextFlag( args: string[], inlineFlag: string, fileFlag: string, ) { const inline = getFlag(args, inlineFlag); if (inline !== undefined) return inline; const path = getFlag(args, fileFlag); if (!path) return undefined; return await readFile(path, "utf8"); } async function readRecipients(path: string): Promise { const content = await readFile(path, "utf8"); const trimmed = content.trim(); if (!trimmed) return []; if (trimmed.startsWith("[")) { const parsed = JSON.parse(trimmed); if (!Array.isArray(parsed)) throw new EmailCliError("Recipients file must be an array or JSONL"); return parsed.map((row) => row && typeof row === "object" ? (row as JsonRecord) : { email: String(row) }, ); } return trimmed .split(/\r?\n/) .filter(Boolean) .map((line) => { const parsed = JSON.parse(line); return parsed && typeof parsed === "object" ? (parsed as JsonRecord) : { email: String(parsed) }; }); } function toRecipients(rows: JsonRecord[]) { return rows.map((row) => { const email = row.email ?? row.Email ?? row["Email Address"] ?? row.emailAddress; if (typeof email !== "string") { throw new EmailCliError("Each recipient row must include an email field"); } return { email, displayName: typeof row.name === "string" ? row.name : typeof row.displayName === "string" ? row.displayName : undefined, personalization: row.personalization ?? row, personEntryId: typeof row.personEntryId === "string" ? row.personEntryId : undefined, companyEntryId: typeof row.companyEntryId === "string" ? row.companyEntryId : undefined, }; }); } export function emailHelpText() { return `dench email — outbound email from a connected inbox (or Dench Emailing Service) Usage: ${EMAIL_INBOX_HELP} dench email draft create --to --subject --text [--mailbox ] [--request-id ] [--json] dench email draft show [--json] dench email draft update --revision [--to ] [--subject ] [--text ] [--mailbox ] [--json] dench email draft send --revision [--request-id ] [--wait |--no-wait] [--json] dench email draft senders [--json] # available Gmail/Outlook mailboxes dench email draft attach --revision --file [--content-type ] [--json] # up to 5 MB/file dench email draft signature [--json] # read signature HTML; insert visibly into the body # Identical native create/send invocations reuse a saved request ID. Use --request-id for another identical email. dench email send --to [--subject ] (--text | --html | --text-file | --html-file ) [--mailbox ] [--cc ] [--bcc ] [--request-id ] [--wait |--no-wait] [--json] # native default; observe the same receipt for 10s by default (--wait 0..60) dench email identity verify --from founder@example.com [--private] [--json] dench email identity verify --domain example.com [--from founder@example.com] [--private] [--json] dench email identity list [--domain example.com] [--json] dench email identity dns --identity [--json] dench email identity status --identity founder@example.com [--json] dench email identity status --identity-id [--json] dench email identity disable --identity [--json] dench email identity restore --identity [--json] dench email identity share --identity [--json] dench email identity restrict --identity [--json] dench email identity delete --identity [--json] dench email identity forward --identity --to inbox@you.com [--json] # forward this sender's sequence replies dench email identity forward --identity --off [--json] # turn forwarding off dench email send --from founder@example.com --to "Ada ,bob@y.com" --subject "Hi" (--html-file body.html | --text "...") [--cc ...] [--bcc ...] [--from-name "Ada"] [--reply-to support@x.com] [--reply-message-id ] [--json] dench email message status --message [--json] dench email message list [--limit 25] [--json] dench email template create --name "Intro" --subject "Hi {{firstName}}" --html-file body.html [--text-file body.txt] [--json] dench email template preview --template --data '{"firstName":"Ada"}' [--json] dench email campaign create --name "Q2 outreach" (--mailbox you@gmail.com | --identity ) --template [--json] dench email campaign resolve --name "Q2 outreach" [--json] # find campaign ids by (fuzzy) name; never creates dench email campaign list [--status sending] [--limit 25] [--max-pages 1] [--json] dench email campaign recipients add (--campaign | --name "") --file recipients.jsonl [--allow-duplicates] [--allow-unlinked] [--json] dench email campaign recipients list (--campaign | --name "") [--status queued] [--limit 50] [--max-pages 1] [--summary] [--json] dench email campaign recipients add-from-object (--campaign | --name "") --object prospect --email-field "Email" --entries id1,id2 [--allow-duplicates] [--json] dench email campaign preview (--campaign | --name "") [--sample 5] [--json] dench email campaign submit (--campaign | --name "") [--json] dench email campaign status (--campaign | --name "") [--json] dench email campaign pause|resume|cancel (--campaign | --name "") [--json] Campaign recipient safety (on by default): • recipients add / add-from-object SKIP addresses another workspace campaign or sequence already targets (duplicatesElsewhere) or already emailed — including campaigns that have COMPLETED (skippedAlreadySent). --allow-duplicates opts out. • recipients add rejects rows it cannot link to a CRM person/lead (rejected / unlinkedRecipients) — unlinked engagement never syncs to the CRM. Prefer recipients add-from-object, or pass --allow-unlinked to add them anyway. Pagination: list commands return a {page, isDone, continueCursor} envelope. --limit sets the page size; --max-pages (default 1, this command paginates internally up to that many pages) drains further pages and aborts with an error if the server ever repeats a continueCursor. When isDone=false, pass a larger --max-pages to fetch more. Sequences (automatic multi-step follow-ups): dench email sequence create --name "New-customer follow-up" (--mailbox you@gmail.com | --identity ) [--stop-on-click] [--no-stop-on-reply] [--json] dench email sequence list [--all] [--json] dench email sequence status --sequence [--json] dench email sequence steps add --sequence --subject "Hi {{firstName}}" --html-file step1.html [--delay-days 0] [--delay-hours 0] [--json] dench email sequence steps update --step [--subject ...] [--html-file ...] [--delay-days 3] [--json] dench email sequence steps remove --step [--json] dench email sequence steps reorder --sequence --steps id1,id2,id3 [--json] dench email sequence activate|deactivate --sequence [--json] dench email sequence enroll --sequence --file recipients.jsonl [--json] dench email sequence enroll-from-object --sequence --object prospect --email-field "Email" --entries id1,id2 [--json] dench email sequence enrollments list --sequence [--status active] [--limit 50] [--json] dench email sequence enrollment pause|resume|stop --enrollment [--json] dench email sequence archive --sequence [--json] Email + LinkedIn multichannel sequences (see \`dench campaign\` — the fuller surface: schedule, steps round-trip, fill from a CRM view, Now/Next, per-person controls, activity): dench email multichannel create --name "Founder outreach" [--timezone America/Los_Angeles] [--json] dench email multichannel list [--all] [--json] dench email multichannel status --sequence [--json] (--json adds per-person currentRuns[].hold / linkedinAction — why a LinkedIn step is waiting — lastTouches[] with the last delivered step, and connectedEntries[].lead { entryId, status }: the Lead each person moves — Status advances New → Contacted → Replied on its own. Every touch is also filed as an event on the lead's History tab; read them in the dashboard under the campaign's Activity feed.) dench email multichannel flow --sequence --file steps.json [--json] dench email multichannel enroll --sequence [--name Ada] [--email ada@x.com] [--linkedin-url https://linkedin.com/in/ada] [--json] dench email multichannel enroll-from-object --sequence --object lead --entries id1,id2 [--email-field Email] [--linkedin-field "POC LinkedIn URL"] [--relation-field Contact] [--reverse] [--allow-duplicates] [--json] dench email multichannel launch --sequence [--ack-linkedin-risk] [--json] dench email multichannel pause|resume|archive --sequence [--json] (see \`dench campaign help\` for the same campaigns with schedule, view-based add, launch checks and the Now/Next feed)`; } export async function runEmailCommand(ctx: EmailCliContext): Promise { if (isHelpRequest(ctx.args)) { const { commandHelp } = await import("./lib/command-help"); console.log(commandHelp(["email", ...ctx.args], emailHelpText())); return; } ctx.args = ctx.args.flatMap((arg) => { const split = arg.startsWith("--") ? arg.indexOf("=") : -1; return split > 2 ? [arg.slice(0, split), arg.slice(split + 1)] : [arg]; }); ctx.jsonOutput = hasFlag(ctx.args, "--json") || ctx.jsonOutput; const group = ctx.args.shift(); if (!group || group === "help" || group === "--help" || group === "-h") { console.log(emailHelpText()); return; } if (group === "inbox") { await runEmailInboxCommand(ctx); return; } if (group === "draft") { await runDraft(ctx); return; } if (group === "identity") { await runIdentity(ctx); return; } if (group === "send") { await runSend(ctx); return; } if (group === "message") { await runMessage(ctx); return; } if (group === "template") { await runTemplate(ctx); return; } if (group === "campaign") { await runCampaign(ctx); return; } if (group === "sequence") { await runSequence(ctx); return; } if (group === "multichannel") { await runMultichannel(ctx); return; } throw new EmailCliError(`Unknown dench email subcommand: ${group}`); } /** Parse a comma-separated list of CRM entry ids. */ function parseEntryIds(value: string | undefined): string[] { if (!value?.trim()) return []; return value .split(",") .map((id) => id.trim()) .filter(Boolean); } /** * Parse a comma-separated address list where each entry is either a bare * email or `Name `. */ function parseAddressList( value: string | undefined, ): Array<{ email: string; name?: string }> { if (!value?.trim()) return []; return value .split(",") .map((entry) => entry.trim()) .filter(Boolean) .map((entry) => { const match = entry.match(/^(.*)<([^<>\s]+@[^<>\s]+)>$/); if (match) { const name = match[1].trim().replace(/^"|"$/g, "").trim(); return { email: match[2].trim(), name: name || undefined }; } return { email: entry }; }); } async function runSend(ctx: EmailCliContext) { const wait = waitFlag(ctx.args); if (!ctx.args.includes("--from")) { const content = { ...(await draftContent(ctx)), mailbox: getFlag(ctx.args, "--mailbox"), threadId: getFlag(ctx.args, "--thread") ?? process.env.DENCH_THREAD_ID, mode: "send", }; if (!Array.isArray(content.to) || content.to.length === 0) throw new EmailCliError("Sending requires --to "); const created = await createNativeEmail(ctx, content); const payload = wait ? await observeNativeEmail(ctx, created, wait) : created; out(ctx, payload, JSON.stringify(payload, null, 2)); return; } const from = requiredFlag(ctx.args, "--from"); const to = parseAddressList(requiredFlag(ctx.args, "--to")); if (to.length === 0) { throw new EmailCliError( 'No valid --to recipients. Use --to "Ada ,bob@y.com"', ); } const cc = parseAddressList(getFlag(ctx.args, "--cc")); const bcc = parseAddressList(getFlag(ctx.args, "--bcc")); const htmlBody = await readTextFlag(ctx.args, "--html", "--html-file"); const textBody = await readTextFlag(ctx.args, "--text", "--text-file"); if (!htmlBody && !textBody) { throw new EmailCliError( "Provide a body: --html/--html-file and/or --text/--text-file", ); } const payload = (await callAction( ctx, "functions/emailCampaignsNode:sendEmail", { fromEmail: from, fromName: getFlag(ctx.args, "--from-name"), to, cc: cc.length > 0 ? cc : undefined, bcc: bcc.length > 0 ? bcc : undefined, replyToEmail: getFlag(ctx.args, "--reply-to"), subject: requiredFlag(ctx.args, "--subject"), htmlBody, textBody, replyToMessageId: getFlag(ctx.args, "--reply-message-id"), configurationSetName: getFlag(ctx.args, "--configuration-set"), }, )) as JsonRecord; out( ctx, payload, `Sent to ${to.length + cc.length + bcc.length} recipient(s). Message: ${payload.messageId}\nCheck opens: dench email message status --message ${payload.messageId}`, ); } function waitFlag(args: string[]): number { const noWait = hasFlag(args, "--no-wait"); const raw = getFlag(args, "--wait"); if (raw !== undefined && noWait) throw new EmailCliError("Use --wait or --no-wait, not both"); const seconds = raw === undefined ? 10 : Number(raw); if ( raw?.trim() === "" || !Number.isFinite(seconds) || seconds < 0 || seconds > 60 ) throw new EmailCliError( "--wait must be a finite number of seconds from 0 to 60", ); return noWait ? 0 : seconds * 1000; } function observeNativeEmail( ctx: EmailCliContext, receipt: unknown, waitMs: number, ) { return waitForNativeEmail( receipt, ctx.readNativeReceipt ?? ((draftId, signal) => { const reader = new ConvexHttpClient(ctx.convex.url, { logger: false, fetch: new Proxy(fetch, { apply(target, _receiver, [input, init]) { return target(input, { ...init, signal }); }, }), }); return reader.query( makeFunctionReference<"query">("functions/emailDrafts:get"), { draftId, sessionToken: ctx.sessionToken }, ); }), waitMs, ); } /** Persist before HTTP. Identical invocations recover the same request even * after the CLI process dies; an explicit new --request-id starts another email. */ async function createNativeEmail(ctx: EmailCliContext, content: JsonRecord) { let requestId = getFlag(ctx.args, "--request-id"); assertEmailArgs(ctx); if (!requestId) { // The credential keys the receipt fingerprint; it is never serialized into // receipt data. Different sessions must not reuse one another's request IDs. const fingerprint = ctx.sessionToken ? createHmac("sha256", ctx.sessionToken) : createHash("sha256"); const key = fingerprint .update(JSON.stringify([ctx.convex.url, content])) .digest("hex"); const directory = join(denchConfigDirectory(), "email-requests"); await mkdir(directory, { recursive: true, mode: 0o700 }); const path = join(directory, `${key}.json`); const proposed = randomUUID(); try { await writeFile(path, JSON.stringify({ requestId: proposed }), { flag: "wx", mode: 0o600, }); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; } const saved = JSON.parse(await readFile(path, "utf8")) as { requestId: string; }; if (typeof saved.requestId !== "string" || !saved.requestId) throw new EmailCliError("Invalid local email request receipt"); requestId = saved.requestId; } return await callMutation(ctx, "functions/emailDrafts:create", { ...content, requestId, }); } function assertEmailArgs(ctx: EmailCliContext) { assertNoUnknownFlags(ctx.args, "email"); if (ctx.args.length) throw new EmailCliError(`Unexpected argument: ${ctx.args[0]}`); } function draftIdArg(ctx: EmailCliContext) { const positional = ctx.args[0] && !ctx.args[0].startsWith("--") ? ctx.args.shift() : undefined; return positional ?? requiredFlag(ctx.args, "--draft"); } function revisionArg(ctx: EmailCliContext) { const revision = Number(requiredFlag(ctx.args, "--revision")); if (!Number.isSafeInteger(revision) || revision < 1) throw new EmailCliError( "--revision must be a positive integer from draft show", ); return revision; } async function draftContent(ctx: EmailCliContext) { const to = getFlag(ctx.args, "--to"); const cc = getFlag(ctx.args, "--cc"); const bcc = getFlag(ctx.args, "--bcc"); const subject = getFlag(ctx.args, "--subject"); const htmlBody = await readTextFlag(ctx.args, "--html", "--html-file"); const textBody = await readTextFlag(ctx.args, "--text", "--text-file"); return { ...(to !== undefined ? { to: parseAddressList(to) } : {}), ...(cc !== undefined ? { cc: parseAddressList(cc) } : {}), ...(bcc !== undefined ? { bcc: parseAddressList(bcc) } : {}), ...(subject !== undefined ? { subject } : {}), ...(htmlBody !== undefined ? { htmlBody } : {}), ...(textBody !== undefined ? { textBody } : {}), }; } async function runDraft(ctx: EmailCliContext) { const sub = ctx.args.shift(); let result: unknown; if (sub === "create") { result = await createNativeEmail(ctx, { ...(await draftContent(ctx)), mailbox: getFlag(ctx.args, "--mailbox"), threadId: getFlag(ctx.args, "--thread") ?? process.env.DENCH_THREAD_ID, }); } else if (sub === "show" || sub === "signature") { const draftId = draftIdArg(ctx); assertEmailArgs(ctx); result = sub === "show" ? await callQuery(ctx, "functions/emailDrafts:get", { draftId }) : await callAction(ctx, "functions/emailDraftsNode:loadSignature", { draftId, }); } else if (sub === "update" || sub === "send") { const wait = sub === "send" ? waitFlag(ctx.args) : 0; const draftId = draftIdArg(ctx); const expectedRevision = revisionArg(ctx); const args = sub === "update" ? { draftId, expectedRevision, patch: { ...(await draftContent(ctx)), mailboxAccountId: getFlag(ctx.args, "--mailbox"), }, } : { draftId, expectedRevision, requestId: getFlag(ctx.args, "--request-id"), }; assertEmailArgs(ctx); result = await callMutation(ctx, `functions/emailDrafts:${sub}`, args); if (wait) result = await observeNativeEmail(ctx, result, wait); } else if (sub === "attach") { const draftId = draftIdArg(ctx); const expectedRevision = revisionArg(ctx); const file = requiredFlag(ctx.args, "--file"); const contentType = getFlag(ctx.args, "--content-type") ?? ( { ".txt": "text/plain", ".csv": "text/csv", ".pdf": "application/pdf", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".json": "application/json", ".html": "text/html", } as Record )[extname(file).toLowerCase()] ?? "application/octet-stream"; assertEmailArgs(ctx); const bytes = await readFile(file); if (bytes.length > 5 * 1024 * 1024) throw new EmailCliError("Attachments can be up to 5 MB each"); const draft = (await callQuery(ctx, "functions/emailDrafts:get", { draftId, })) as { revision: number; attachments: unknown[]; canEdit: boolean }; if (draft.revision !== expectedRevision) throw new EmailCliError("Draft changed. Refresh before attaching a file"); if (!draft.canEdit) throw new EmailCliError("This draft is read-only"); const attachment = await callAction( ctx, "functions/emailDraftsNode:uploadAttachment", { draftId, name: basename(file), contentType, contentBase64: bytes.toString("base64"), }, ); result = await callMutation(ctx, "functions/emailDrafts:update", { draftId, expectedRevision, patch: { attachments: [...draft.attachments, attachment] }, }); } else if (sub === "senders") { assertEmailArgs(ctx); result = await callQuery(ctx, "functions/emailDrafts:listSenders", {}); } else { throw new EmailCliError( "Usage: dench email draft create|show|update|send|senders|attach|signature. update/send/attach require --revision from draft show. attach: --file [--content-type ]. create: --to --subject --text/--html [--mailbox ] [--request-id ] [--thread ].", ); } out(ctx, result, JSON.stringify(result, null, 2)); } async function runMessage(ctx: EmailCliContext) { const sub = ctx.args.shift(); if (sub === "status") { const messageId = requiredFlag(ctx.args, "--message"); const payload = (await callQuery( ctx, "functions/emailCampaigns:getMessage", { messageId }, )) as JsonRecord; out(ctx, payload, JSON.stringify(payload, null, 2)); return; } if (sub === "list") { const limitRaw = getFlag(ctx.args, "--limit"); const status = getFlag(ctx.args, "--status"); const payload = (await callQuery( ctx, "functions/emailCampaigns:listMessages", { limit: limitRaw ? Number(limitRaw) : undefined, status, }, )) as JsonRecord[]; const text = payload.length === 0 ? "No raw messages sent yet. Use `dench email send`." : payload .map( (message) => `${message.messageId} ${message.status} from=${message.fromEmail} subject=${JSON.stringify(message.subject)} opens=${message.openCount} clicks=${message.clickCount}`, ) .join("\n"); out(ctx, { ok: true, messages: payload }, text); return; } throw new EmailCliError("Usage: dench email message status|list"); } type DnsRecord = { type: string; name: string; value: string; purpose: string; }; function buildDnsRecords(domain: string, dkimTokens: string[]): DnsRecord[] { return [ ...dkimTokens.map((token) => ({ type: "CNAME", name: `${token}._domainkey.${domain}`, value: `${token}.dkim.amazonses.com`, purpose: "DKIM (required for domain verification)", })), { type: "TXT", name: domain, value: "v=spf1 include:amazonses.com ~all", purpose: "SPF (recommended)", }, { type: "TXT", name: `_dmarc.${domain}`, value: `v=DMARC1; p=none; rua=mailto:postmaster@${domain}`, purpose: "DMARC (recommended)", }, ]; } function asDnsRecords(value: unknown): DnsRecord[] { if (!Array.isArray(value)) return []; return value.filter( (record): record is DnsRecord => Boolean(record) && typeof record === "object" && typeof (record as DnsRecord).type === "string" && typeof (record as DnsRecord).name === "string" && typeof (record as DnsRecord).value === "string", ); } function formatDnsRecords(records: DnsRecord[]): string { if (records.length === 0) return ""; const lines = ["DNS records to add:"]; for (const record of records) { lines.push(` ${record.type} ${record.name}`); lines.push(` value: ${record.value}`); if (record.purpose) lines.push(` (${record.purpose})`); } return lines.join("\n"); } async function listIdentitiesQuery( ctx: EmailCliContext, domain?: string, ): Promise { const payload = await callQuery( ctx, "functions/emailCampaigns:listIdentities", domain ? { domain } : {}, ); return Array.isArray(payload) ? (payload as JsonRecord[]) : []; } async function runIdentity(ctx: EmailCliContext) { const sub = ctx.args.shift(); if (sub === "verify") { const domain = getFlag(ctx.args, "--domain")?.trim().toLowerCase(); let from = getFlag(ctx.args, "--from")?.trim(); if (!from) { if (!domain) throw new EmailCliError("Missing required --from"); // Domain-only verify: reuse the sender mailbox already registered // on this domain so the user doesn't have to repeat --from. const existing = await listIdentitiesQuery(ctx, domain); const preferred = existing.find( (identity) => identity.denchVerificationStatus === "verified", ) ?? existing[0]; if (!preferred || typeof preferred.fromEmail !== "string") { throw new EmailCliError( `No sender registered on ${domain} yet. Pass --from to register one.`, ); } from = preferred.fromEmail; } const type = domain ? "domain" : "email"; const identity = domain ?? from; const configurationSetName = getFlag(ctx.args, "--configuration-set"); const created = await callMutation( ctx, "functions/emailCampaigns:createIdentity", { type, domain, fromEmail: from, fromName: getFlag(ctx.args, "--from-name"), replyToEmail: getFlag(ctx.args, "--reply-to"), configurationSetName, ...(hasFlag(ctx.args, "--private") ? { sharing: "restricted" } : {}), }, ); const verification = (await callAction( ctx, "functions/emailCampaignsNode:beginSenderVerification", { identityId: (created as JsonRecord).identityId }, )) as JsonRecord; const dnsRecords = asDnsRecords(verification.dnsRecords); const text = [ `Verification started for ${identity}`, typeof verification.message === "string" ? verification.message : "", formatDnsRecords(dnsRecords), ] .filter(Boolean) .join("\n"); out(ctx, { ok: true, created, verification }, text); return; } if (sub === "list") { const domain = getFlag(ctx.args, "--domain")?.trim().toLowerCase(); const identities = await listIdentitiesQuery(ctx, domain); const text = identities.length === 0 ? "No sending identities. Verify a sender in Outreach → Newsletters." : identities .map( (identity) => `${identity.identityId} ${identity.fromEmail} type=${identity.type}` + `${identity.domain ? ` domain=${identity.domain}` : ""}` + ` delivery=${identity.deliveryVerificationStatus} workspace=${identity.denchVerificationStatus}` + ` sharing=${identity.sharing ?? "org"}` + `${identity.forwardRepliesTo ? ` forward=${identity.forwardRepliesTo}` : ""}` + `${identity.disabled ? " DISABLED" : ""}`, ) .join("\n"); out(ctx, { ok: true, identities }, text); return; } if (sub === "share" || sub === "restrict") { const identity = requiredFlag(ctx.args, "--identity"); const sharing = sub === "restrict" ? "restricted" : "org"; const payload = await callMutation( ctx, "functions/emailCampaigns:setIdentitySharing", { identity, sharing }, ); out( ctx, payload, sharing === "restricted" ? `Restricted ${identity} to its owner and workspace admins.` : `Shared ${identity} with the workspace.`, ); return; } if (sub === "disable" || sub === "restore" || sub === "delete") { const identity = requiredFlag(ctx.args, "--identity"); const payload = (await callMutation( ctx, sub === "disable" ? "functions/emailCampaigns:disableIdentity" : sub === "restore" ? "functions/emailCampaigns:restoreIdentity" : "functions/emailCampaigns:deleteIdentity", { identity }, )) as JsonRecord; const rows = ( Array.isArray(payload.disabled) ? payload.disabled : Array.isArray(payload.restored) ? payload.restored : Array.isArray(payload.deleted) ? payload.deleted : [] ) as JsonRecord[]; const verb = sub === "disable" ? "Disabled" : sub === "restore" ? "Restored" : "Deleted"; const suffix = sub === "delete" && rows.length > 0 ? "\nDeleted senders must be re-verified before they can send again." : ""; const text = rows.length === 0 ? `${verb} 0 identities.` : `${verb} ${rows.length} sender(s):\n${rows .map((row) => ` ${row.fromEmail} (${row.identityId})`) .join("\n")}${suffix}`; out(ctx, payload, text); return; } if (sub === "forward") { // Configure where a sender's managed sequence replies are forwarded. // `--to ` turns it on (composed from the verified sender, Reply-To = // the prospect); `--off` clears it. Replies are always still captured // in-app regardless — forwarding is an extra copy to a chosen inbox. const identity = requiredFlag(ctx.args, "--identity"); const off = hasFlag(ctx.args, "--off"); const to = off ? "" : (getFlag(ctx.args, "--to")?.trim() ?? ""); if (!off && !to) { throw new Error( "Provide --to to forward this sender's replies, or --off to turn forwarding off.", ); } const payload = (await callMutation( ctx, "functions/emailCampaigns:setSenderReplyForward", { identity, forwardRepliesTo: to }, )) as JsonRecord; const forwardTo = typeof payload.forwardRepliesTo === "string" ? payload.forwardRepliesTo : null; const text = forwardTo ? `Replies to ${identity}'s sequences will be forwarded to ${forwardTo}.` : `Reply forwarding turned off for ${identity}.`; out(ctx, { ok: true, ...payload }, text); return; } if (sub === "dns") { const identity = requiredFlag(ctx.args, "--identity"); if (!identity.includes("@") && !identity.includes(".")) { // Identity id: the refresh action returns formatted dnsRecords. const payload = (await callAction( ctx, "functions/emailCampaignsNode:refreshIdentityStatus", { identityId: identity }, )) as JsonRecord; const records = asDnsRecords(payload.dnsRecords); const text = records.length > 0 ? formatDnsRecords(records) : payload.domainVerified === true ? `Domain ${payload.domain} is verified — no DNS records pending.` : "No DNS records available yet. Run `dench email identity verify --domain ` first."; out(ctx, { ok: true, ...payload }, text); return; } const domain = identity.includes("@") ? identity.slice(identity.lastIndexOf("@") + 1).toLowerCase() : identity.toLowerCase(); const status = (await callGateway( ctx, "GET", `/v1/email/identities/${encodeURIComponent(domain)}/status`, )) as JsonRecord; const dkim = status.dkimAttributes && typeof status.dkimAttributes === "object" ? (status.dkimAttributes as JsonRecord) : {}; const tokens = Array.isArray(dkim.Tokens) ? dkim.Tokens.filter( (token): token is string => typeof token === "string", ) : []; const records = buildDnsRecords(domain, tokens); const verified = status.verifiedForSendingStatus === true; const payload: JsonRecord = { ok: true, domain, domainVerified: verified, dnsRecords: records, }; if ( !verified && status.verificationInfo && typeof status.verificationInfo === "object" ) { payload.verificationInfo = status.verificationInfo; } const text = verified ? `Domain ${domain} is verified — DNS records below are already in place.\n${formatDnsRecords(records)}` : tokens.length > 0 ? formatDnsRecords(records) : `Domain ${domain} has no verification records yet. Run \`dench email identity verify --domain ${domain}\` first.`; out(ctx, payload, text); return; } if (sub === "status") { const identityId = getFlag(ctx.args, "--identity-id"); if (identityId) { const payload = await callAction( ctx, "functions/emailCampaignsNode:refreshIdentityStatus", { identityId }, ); out(ctx, payload, JSON.stringify(payload, null, 2)); return; } const identity = requiredFlag(ctx.args, "--identity"); const payload = await callGateway( ctx, "GET", `/v1/email/identities/${encodeURIComponent(identity)}/status`, ); const status = payload as JsonRecord; const shaped: JsonRecord = { ok: status.ok === true, identity: status.identity, deliveryVerified: status.verifiedForSendingStatus === true, deliveryVerificationStatus: status.verificationStatus, }; // Pending domains: surface when the provider last polled DNS and // why it failed, so callers know whether to fix records or wait. if ( status.verifiedForSendingStatus !== true && status.verificationInfo && typeof status.verificationInfo === "object" ) { shaped.verificationInfo = status.verificationInfo; } out(ctx, shaped, JSON.stringify(shaped, null, 2)); return; } throw new EmailCliError( "Usage: dench email identity verify|list|dns|status|disable|restore|share|restrict|delete", ); } async function runTemplate(ctx: EmailCliContext) { const sub = ctx.args.shift(); if (sub === "create") { const htmlBody = await readTextFlag(ctx.args, "--html", "--html-file"); if (!htmlBody) throw new EmailCliError("Missing --html or --html-file"); const textBody = await readTextFlag(ctx.args, "--text", "--text-file"); const payload = await callMutation( ctx, "functions/emailCampaigns:createTemplate", { name: requiredFlag(ctx.args, "--name"), subject: requiredFlag(ctx.args, "--subject"), htmlBody, textBody, }, ); out( ctx, payload, `Template created: ${(payload as JsonRecord).templateId}`, ); return; } if (sub === "preview") { const templateId = requiredFlag(ctx.args, "--template"); const data = optionalJsonFlag(ctx.args, "--data"); const payload = await callQuery( ctx, "functions/emailCampaigns:previewTemplate", { templateId, data }, ); out(ctx, payload, JSON.stringify(payload, null, 2)); return; } throw new EmailCliError("Usage: dench email template create|preview"); } async function runCampaign(ctx: EmailCliContext) { const sub = ctx.args.shift(); if (sub === "create") { const sender = await campaignSenderArgs(ctx); const scheduledAt = getFlag(ctx.args, "--scheduled-at"); const maxRecipients = getFlag(ctx.args, "--max-recipients"); const payload = await callMutation( ctx, "functions/emailCampaigns:createCampaign", { name: requiredFlag(ctx.args, "--name"), ...sender, templateId: requiredFlag(ctx.args, "--template"), scheduledAt: scheduledAt ? Date.parse(scheduledAt) : undefined, maxRecipients: maxRecipients ? Number(maxRecipients) : undefined, }, ); out( ctx, payload, `Campaign created: ${(payload as JsonRecord).campaignId}`, ); return; } if (sub === "resolve") { const name = requiredFlag(ctx.args, "--name"); const payload = await resolveCampaignByNameQuery(ctx, name); const text = payload.matches.length === 0 ? `No campaign matches ${JSON.stringify(name)}.` : `${payload.matches.length} match(es) [${payload.matchType}]:\n${formatCampaignCandidates(payload.matches)}`; out(ctx, { ok: true, ...payload }, text); return; } if (sub === "list") { const limitRaw = getFlag(ctx.args, "--limit"); const status = getFlag(ctx.args, "--status"); const maxPages = maxPagesFlag(ctx.args); const numItems = limitRaw ? Number(limitRaw) : 25; const result = await collectPaginated( async (cursor) => (await callQuery(ctx, "functions/emailCampaigns:listCampaigns", { paginationOpts: { numItems, cursor }, status, })) as PaginatedEnvelope, { maxPages }, ); const payload = { page: result.rows, isDone: result.isDone, continueCursor: result.continueCursor, pagesFetched: result.pagesFetched, }; out(ctx, payload, JSON.stringify(payload, null, 2)); return; } if (sub === "recipients") { const nested = ctx.args.shift(); if (nested === "list") { const limitRaw = getFlag(ctx.args, "--limit"); const status = getFlag(ctx.args, "--status"); const summary = hasFlag(ctx.args, "--summary"); const maxPages = maxPagesFlag(ctx.args); const campaignId = await resolveCampaignRef(ctx); const numItems = limitRaw ? Number(limitRaw) : 50; const result = await collectPaginated( async (cursor) => (await callQuery(ctx, "functions/emailCampaigns:listRecipients", { campaignId, paginationOpts: { numItems, cursor }, status, // --summary skips the per-row CRM person attachment server-side. includePeople: summary ? false : undefined, })) as PaginatedEnvelope, { maxPages }, ); const payload = { page: result.rows, isDone: result.isDone, continueCursor: result.continueCursor, pagesFetched: result.pagesFetched, }; out(ctx, payload, JSON.stringify(payload, null, 2)); return; } if (nested === "add-from-object") { const payload = await callMutation( ctx, "functions/emailCampaigns:addRecipientsFromCrm", { campaignId: await resolveCampaignRef(ctx), objectName: requiredFlag(ctx.args, "--object"), emailFieldName: getFlag(ctx.args, "--email-field"), entryIds: parseEntryIds(requiredFlag(ctx.args, "--entries")), // Cross-campaign duplicate + already-sent check is ON by default; // --allow-duplicates opts out. checkDuplicatesAcrossWorkspace: hasFlag( ctx.args, "--allow-duplicates", ) ? false : undefined, }, ); out(ctx, payload, JSON.stringify(payload, null, 2)); return; } if (nested !== "add") { throw new EmailCliError( "Usage: dench email campaign recipients add|list|add-from-object", ); } const campaignId = await resolveCampaignRef(ctx); const file = getFlag(ctx.args, "--file"); const rows = file ? await readRecipients(file) : ((optionalJsonFlag(ctx.args, "--data") as JsonRecord[] | undefined) ?? []); if (rows.length === 0) throw new EmailCliError("No recipients provided"); const allowUnlinked = hasFlag(ctx.args, "--allow-unlinked"); const payload = (await callMutation( ctx, "functions/emailCampaigns:addRecipients", { campaignId, recipients: toRecipients(rows), // Cross-campaign duplicate + already-sent check is ON by default; // --allow-duplicates opts out. checkDuplicatesAcrossWorkspace: hasFlag(ctx.args, "--allow-duplicates") ? false : undefined, allowUnlinked: allowUnlinked ? true : undefined, }, )) as JsonRecord; const rejected = Array.isArray(payload.rejected) ? (payload.rejected as string[]) : []; const shaped: JsonRecord = { ...payload, added: payload.added, skippedAlreadySent: payload.alreadySent ?? 0, skippedSuppressed: payload.suppressed ?? 0, skippedInvalid: payload.invalid ?? 0, skippedInCampaign: payload.duplicates ?? 0, skippedInOtherLiveCampaigns: payload.duplicatesElsewhere ?? 0, unlinkedRecipients: rejected.length, }; const warnings: string[] = []; if (rejected.length > 0) { warnings.push( `WARNING: unlinkedRecipients: ${rejected.length} — these rows could not be linked to a CRM person/lead and were NOT added; engagement for unlinked recipients will NOT sync to the CRM. Prefer \`recipients add-from-object\` (CRM-linked) or re-run with --allow-unlinked to add them anyway.`, ...rejected.slice(0, 20).map((email) => ` rejected: ${email}`), ...(rejected.length > 20 ? [` … and ${rejected.length - 20} more`] : []), ); } if ((shaped.skippedAlreadySent as number) > 0) { warnings.push( `Skipped ${shaped.skippedAlreadySent} recipient(s) already emailed by another campaign in this workspace (pass --allow-duplicates to re-send).`, ); } if (warnings.length > 0) shaped.warnings = warnings; out(ctx, shaped, [JSON.stringify(shaped, null, 2), ...warnings].join("\n")); return; } if (sub === "preview") { const sampleRaw = getFlag(ctx.args, "--sample"); const payload = await callQuery( ctx, "functions/emailCampaigns:previewCampaign", { campaignId: await resolveCampaignRef(ctx), sample: sampleRaw ? Number(sampleRaw) : undefined, }, ); out(ctx, payload, JSON.stringify(payload, null, 2)); return; } if (sub === "submit") { const payload = await callMutation( ctx, "functions/emailCampaigns:submitCampaign", { campaignId: await resolveCampaignRef(ctx), approvalId: getFlag(ctx.args, "--approval"), }, ); out(ctx, payload, JSON.stringify(payload, null, 2)); return; } if (sub === "status" || sub === "get") { const payload = await callQuery( ctx, "functions/emailCampaigns:getCampaign", { campaignId: await resolveCampaignRef(ctx), }, ); out(ctx, payload, JSON.stringify(payload, null, 2)); return; } if (sub === "pause" || sub === "resume" || sub === "cancel") { const fn = sub === "pause" ? "pauseCampaign" : sub === "resume" ? "resumeCampaign" : "cancelCampaign"; const payload = await callMutation(ctx, `functions/emailCampaigns:${fn}`, { campaignId: await resolveCampaignRef(ctx), }); out(ctx, payload, JSON.stringify(payload, null, 2)); return; } throw new EmailCliError( "Usage: dench email campaign create|resolve|list|recipients|preview|submit|status|pause|resume|cancel", ); } async function runSequence(ctx: EmailCliContext) { const sub = ctx.args.shift(); if (sub === "create") { const stopOnReply = !hasFlag(ctx.args, "--no-stop-on-reply"); const sender = await campaignSenderArgs(ctx); const payload = await callMutation( ctx, "functions/emailSequences:createSequence", { name: requiredFlag(ctx.args, "--name"), ...sender, replyToMode: getFlag(ctx.args, "--reply-to-mode"), stopOnReply, stopOnClick: hasFlag(ctx.args, "--stop-on-click"), }, ); out( ctx, payload, `Sequence created: ${(payload as JsonRecord).sequenceId}`, ); return; } if (sub === "update") { const payload = await callMutation( ctx, "functions/emailSequences:updateSequence", { sequenceId: requiredFlag(ctx.args, "--sequence"), name: getFlag(ctx.args, "--name"), identityId: getFlag(ctx.args, "--identity"), replyToMode: getFlag(ctx.args, "--reply-to-mode"), }, ); out(ctx, payload, JSON.stringify(payload, null, 2)); return; } if (sub === "activate" || sub === "deactivate") { const payload = await callMutation( ctx, "functions/emailSequences:setSequenceActive", { sequenceId: requiredFlag(ctx.args, "--sequence"), active: sub === "activate", }, ); out(ctx, payload, JSON.stringify(payload, null, 2)); return; } if (sub === "archive") { const payload = await callMutation( ctx, "functions/emailSequences:archiveSequence", { sequenceId: requiredFlag(ctx.args, "--sequence") }, ); out(ctx, payload, JSON.stringify(payload, null, 2)); return; } if (sub === "list") { const payload = await callQuery( ctx, "functions/emailSequences:listSequences", { includeArchived: hasFlag(ctx.args, "--all") }, ); out(ctx, payload, JSON.stringify(payload, null, 2)); return; } if (sub === "status") { const payload = await callQuery( ctx, "functions/emailSequences:getSequence", { sequenceId: requiredFlag(ctx.args, "--sequence") }, ); out(ctx, payload, JSON.stringify(payload, null, 2)); return; } if (sub === "steps") { const nested = ctx.args.shift(); if (nested === "add") { const htmlBody = await readTextFlag(ctx.args, "--html", "--html-file"); if (!htmlBody) throw new EmailCliError("Missing --html or --html-file"); const delayDaysRaw = getFlag(ctx.args, "--delay-days"); const delayHoursRaw = getFlag(ctx.args, "--delay-hours"); const delayHours = (delayDaysRaw ? Number(delayDaysRaw) * 24 : 0) + (delayHoursRaw ? Number(delayHoursRaw) : 0); const payload = await callMutation( ctx, "functions/emailSequences:addStep", { sequenceId: requiredFlag(ctx.args, "--sequence"), subject: requiredFlag(ctx.args, "--subject"), htmlBody, textBody: await readTextFlag(ctx.args, "--text", "--text-file"), delayHours, }, ); out(ctx, payload, `Step added: ${(payload as JsonRecord).stepId}`); return; } if (nested === "update") { const delayDaysRaw = getFlag(ctx.args, "--delay-days"); const delayHoursRaw = getFlag(ctx.args, "--delay-hours"); const delayHours = delayDaysRaw || delayHoursRaw ? (delayDaysRaw ? Number(delayDaysRaw) * 24 : 0) + (delayHoursRaw ? Number(delayHoursRaw) : 0) : undefined; const payload = await callMutation( ctx, "functions/emailSequences:updateStep", { stepId: requiredFlag(ctx.args, "--step"), subject: getFlag(ctx.args, "--subject"), htmlBody: await readTextFlag(ctx.args, "--html", "--html-file"), textBody: await readTextFlag(ctx.args, "--text", "--text-file"), delayHours, }, ); out(ctx, payload, JSON.stringify(payload, null, 2)); return; } if (nested === "remove") { const payload = await callMutation( ctx, "functions/emailSequences:removeStep", { stepId: requiredFlag(ctx.args, "--step") }, ); out(ctx, payload, JSON.stringify(payload, null, 2)); return; } if (nested === "reorder") { const payload = await callMutation( ctx, "functions/emailSequences:reorderSteps", { sequenceId: requiredFlag(ctx.args, "--sequence"), orderedStepIds: parseEntryIds(requiredFlag(ctx.args, "--steps")), }, ); out(ctx, payload, JSON.stringify(payload, null, 2)); return; } throw new EmailCliError( "Usage: dench email sequence steps add|update|remove|reorder", ); } if (sub === "enroll") { const file = getFlag(ctx.args, "--file"); const rows = file ? await readRecipients(file) : ((optionalJsonFlag(ctx.args, "--data") as JsonRecord[] | undefined) ?? []); if (rows.length === 0) throw new EmailCliError("No recipients provided"); const payload = await callMutation(ctx, "functions/emailSequences:enroll", { sequenceId: requiredFlag(ctx.args, "--sequence"), recipients: toRecipients(rows), }); out(ctx, payload, JSON.stringify(payload, null, 2)); return; } if (sub === "enroll-from-object") { const payload = await callMutation( ctx, "functions/emailSequences:enrollFromCrm", { sequenceId: requiredFlag(ctx.args, "--sequence"), objectName: requiredFlag(ctx.args, "--object"), emailFieldName: getFlag(ctx.args, "--email-field"), entryIds: parseEntryIds(requiredFlag(ctx.args, "--entries")), }, ); out(ctx, payload, JSON.stringify(payload, null, 2)); return; } if (sub === "enrollments") { const nested = ctx.args.shift(); if (nested !== "list") { throw new EmailCliError("Usage: dench email sequence enrollments list"); } const limitRaw = getFlag(ctx.args, "--limit"); const payload = await callQuery( ctx, "functions/emailSequences:listEnrollments", { sequenceId: requiredFlag(ctx.args, "--sequence"), paginationOpts: { numItems: limitRaw ? Number(limitRaw) : 50, cursor: null, }, status: getFlag(ctx.args, "--status"), }, ); out(ctx, payload, JSON.stringify(payload, null, 2)); return; } if (sub === "enrollment") { const nested = ctx.args.shift(); const fn = nested === "pause" ? "pauseEnrollment" : nested === "resume" ? "resumeEnrollment" : nested === "stop" ? "stopEnrollment" : null; if (!fn) { throw new EmailCliError( "Usage: dench email sequence enrollment pause|resume|stop --enrollment ", ); } const payload = await callMutation(ctx, `functions/emailSequences:${fn}`, { enrollmentId: requiredFlag(ctx.args, "--enrollment"), }); out(ctx, payload, JSON.stringify(payload, null, 2)); return; } throw new EmailCliError( "Usage: dench email sequence create|update|activate|deactivate|archive|list|status|steps|enroll|enroll-from-object|enrollments|enrollment", ); } async function runMultichannel(ctx: EmailCliContext) { const sub = ctx.args.shift(); if (sub === "create") { const payload = await callMutation( ctx, "functions/outreachSequences:createSequence", { name: requiredFlag(ctx.args, "--name"), timezone: getFlag(ctx.args, "--timezone") ?? Intl.DateTimeFormat().resolvedOptions().timeZone, }, ); out(ctx, payload, JSON.stringify(payload, null, 2)); return; } if (sub === "list") { const payload = await callQuery( ctx, "functions/outreachSequences:listSequences", { includeArchived: hasFlag(ctx.args, "--all") }, ); out(ctx, payload, JSON.stringify(payload, null, 2)); return; } if (sub === "status") { const sequenceId = requiredFlag(ctx.args, "--sequence"); const [detail, analytics] = await Promise.all([ callQuery(ctx, "functions/outreachSequences:getSequence", { sequenceId }), callAction(ctx, "functions/outreachSequences:computeSequenceAnalytics", { sequenceId, }), ]); const payload = { detail, analytics }; out(ctx, payload, JSON.stringify(payload, null, 2)); return; } if (sub === "flow") { const raw = await readFile(requiredFlag(ctx.args, "--file"), "utf8"); const steps = JSON.parse(raw); if (!Array.isArray(steps)) { throw new EmailCliError("Flow file must contain a JSON array of steps"); } const payload = await callMutation( ctx, "functions/outreachSequences:replaceSteps", { sequenceId: requiredFlag(ctx.args, "--sequence"), steps, }, ); out(ctx, payload, JSON.stringify(payload, null, 2)); return; } if (sub === "enroll") { const email = getFlag(ctx.args, "--email"); const linkedinProfileUrl = getFlag(ctx.args, "--linkedin-url"); if (!email && !linkedinProfileUrl) { throw new EmailCliError("Pass --email and/or --linkedin-url"); } const payload = await callMutation( ctx, "functions/outreachSequences:addEnrollment", { sequenceId: requiredFlag(ctx.args, "--sequence"), displayName: getFlag(ctx.args, "--name"), email, linkedinProfileUrl, checkDuplicatesAcrossWorkspace: !hasFlag( ctx.args, "--allow-duplicates", ), }, ); out(ctx, payload, JSON.stringify(payload, null, 2)); return; } if (sub === "enroll-from-object") { const entryIds = parseEntryIds(requiredFlag(ctx.args, "--entries")); if (entryIds.length === 0) { throw new EmailCliError("Pass at least one CRM entry id in --entries"); } const payload = await callMutation( ctx, "functions/outreachSequences:enrollFromCrm", { sequenceId: requiredFlag(ctx.args, "--sequence"), objectName: requiredFlag(ctx.args, "--object"), emailFieldName: getFlag(ctx.args, "--email-field"), linkedinUrlFieldName: getFlag(ctx.args, "--linkedin-field"), relationFieldName: getFlag(ctx.args, "--relation-field"), relationDirection: hasFlag(ctx.args, "--reverse") ? "reverse" : undefined, entryIds, checkDuplicatesAcrossWorkspace: !hasFlag( ctx.args, "--allow-duplicates", ), }, ); out(ctx, payload, JSON.stringify(payload, null, 2)); return; } if (sub === "launch") { const sequenceId = requiredFlag(ctx.args, "--sequence"); const preview = (await callQuery( ctx, "functions/outreachSequences:previewLaunch", { sequenceId }, )) as JsonRecord; if (preview.ready !== true || typeof preview.fingerprint !== "string") { out(ctx, preview, JSON.stringify(preview, null, 2)); throw new EmailCliError("Sequence is not ready to launch"); } const payload = await callMutation( ctx, "functions/outreachSequences:launch", { sequenceId, fingerprint: preview.fingerprint, linkedinRiskAcknowledged: hasFlag(ctx.args, "--ack-linkedin-risk"), }, ); out(ctx, payload, JSON.stringify(payload, null, 2)); return; } if (sub === "pause" || sub === "resume" || sub === "archive") { const fn = sub === "pause" ? "pauseSequence" : sub === "resume" ? "resumeSequence" : "archiveSequence"; const payload = await callMutation( ctx, `functions/outreachSequences:${fn}`, { sequenceId: requiredFlag(ctx.args, "--sequence") }, ); out(ctx, payload, JSON.stringify(payload, null, 2)); return; } throw new EmailCliError( "Usage: dench email multichannel create|list|status|flow|enroll|enroll-from-object|launch|pause|resume|archive", ); }