From 6c1b0ff6d0f36040b3e1408aa550e625ce1ba40d Mon Sep 17 00:00:00 2001 From: Dave Oster Date: Wed, 14 Oct 2026 09:46:08 -0500 Subject: [PATCH 1/5] Mail campaigns: add properties, estimate the add, end, and the five statuses (FW-2295) Adds dm mail campaigns add-properties (estimate shown and confirmed before anything is added; agents need --yes), estimate-add and end (permanent, confirmed), the --accepting-properties list filter and the draft, ready, active, paused and ended statuses. Documents the Commands and Playbook steps. Co-Authored-By: Claude Opus 5.5 --- docs/commands.md | 29 +++ playbook/PLAYBOOK.md | 16 ++ src/commands/mail.ts | 263 +++++++++++++++++++++++++++ src/index.ts | 81 ++++++++- src/lib/confirm.ts | 30 +++ tests/commands/mailCampaigns.test.ts | 214 ++++++++++++++++++++++ 6 files changed, 628 insertions(+), 5 deletions(-) create mode 100644 src/lib/confirm.ts create mode 100644 tests/commands/mailCampaigns.test.ts diff --git a/docs/commands.md b/docs/commands.md index 0745199..eb56bbd 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -25,6 +25,7 @@ This package has **zero** `@dealmachine/*` dependencies -- it is a self-containe - [Comps](#comps-commands) -- comparable property analysis - [Lists](#lists-commands) -- `search`, `create`, `get`, `update`, `delete`, `build`, `import`, `items`, `add`, `remove`, `export` - [Driving](#driving-commands) -- `list`, `get` + - [Mail campaigns](#mail-campaign-commands) -- `list`, `create`, `get`, `add-properties`, `estimate-add`, `pause`, `resume`, `end` - [Filters](#filters-commands) -- list available search filters - [Fields](#fields-commands) -- list available data fields - [Activity](#activity-commands) -- `search`, `get` @@ -861,6 +862,34 @@ dm prospects list --source driving Drive history is read-only. Use `dm prospects` for lifecycle, notes, and tags on linked Prospects. +### Mail Campaign Commands + +A mail campaign is a sequence of mail steps. Campaigns created through the API or the app's campaign creator keep accepting properties while they are Ready, Active or Paused. Each added property starts at step 1 on its own schedule; properties already in the campaign are not changed. + +Statuses are `draft`, `ready`, `active`, `paused` and `ended`. Ready means nothing is queued to mail. Completed and Cancelled campaigns from before this change show as `ended`; `--status completed` and `--status cancelled` still work as filters. + +```bash +dm mail campaigns list --accepting-properties # Campaigns you can add properties to now +dm mail campaigns list --status ended --json +dm mail campaigns create --body '{"name":"Seller follow-up","steps":[{"design_id":"des_123"}]}' # No audience: saved as Ready, no charge +dm mail campaigns estimate-add camp_abc123 --count 25 +dm mail campaigns add-properties camp_abc123 --property-ids 101 102 --recipient owner +dm mail campaigns add-properties camp_abc123 --property-ids 101,102 --recipient resident --yes --json +dm mail campaigns pause camp_abc123 +dm mail campaigns end camp_abc123 +``` + +| Command | What it does | +| --- | --- | +| `add-properties --property-ids --recipient owner\|resident` | Shows the charge due today and later, asks for confirmation, then adds up to 5,000 properties. Prints the added count and each skipped property with its reason (already in the campaign, Do Not Contact, no mailing address, duplicate mailing address, archived prospect, not found). | +| `estimate-add --count ` | Shows what adding `n` properties costs: charged today, charged later, pieces per property and whether your wallet covers it. Nothing is added. | +| `end ` | Ends the campaign permanently after a confirmation. Unsent mail stops; the campaign cannot be paused, resumed or added to. Mail already printed still delivers. | +| `list --accepting-properties` | Lists only campaigns that properties can be added to now. | + +`--recipient owner` mails the owner's mailing address; `--recipient resident` mails the current resident at the property address. Pieces going out today are charged when added; later pieces are charged as they go out. + +`add-properties` and `end` need `--yes` when run by an agent, a script, with `--json`, or without a terminal. Without it, `add-properties` prints the estimate and adds nothing, and `end` stops with an error. `delete ` still cancels a campaign without a confirmation and gives the same result as `end`. + ### Filters Commands #### `dm filters` diff --git a/playbook/PLAYBOOK.md b/playbook/PLAYBOOK.md index 98b9b2a..023af0a 100644 --- a/playbook/PLAYBOOK.md +++ b/playbook/PLAYBOOK.md @@ -695,6 +695,22 @@ dm lists export --anchor person dm lists export --fields estimated_value,owner_full_name --anchor property ``` +### Mail Campaigns (adding properties costs mail credits) + +```bash +dm mail campaigns list --accepting-properties --json # Campaigns that can take properties now +dm mail campaigns estimate-add --count 25 --json # Charge due today and later (FREE) +dm mail campaigns add-properties --property-ids 101 102 --recipient owner --yes --json +dm mail campaigns end --yes # Permanent +``` + +- Statuses: `draft`, `ready`, `active`, `paused`, `ended`. Completed and Cancelled show as `ended`. +- Ask the user owner or resident before adding. Each added property starts at step 1. +- Always run `estimate-add` first and tell the user the charge due today (`pay_today_cents`) and later. Pass `--yes` only after they approve. Without `--yes`, `add-properties` returns the estimate and adds nothing. +- Report `added_count` and every skipped property with its reason. +- Ending is permanent: unsent mail stops and the campaign can never be resumed or added to. Confirm with the user before `end --yes`. +- Create a campaign with no audience to save it as Ready with no charge, then add properties later. + ### Activity (FREE) ```bash diff --git a/src/commands/mail.ts b/src/commands/mail.ts index aaf0863..fa88743 100644 --- a/src/commands/mail.ts +++ b/src/commands/mail.ts @@ -14,6 +14,7 @@ import { truncate, createSpinner, } from '../lib/output.js'; +import { confirmPrompt, isNonInteractive } from '../lib/confirm.js'; // ============================================================================ // Types @@ -34,6 +35,10 @@ interface Campaign { updated_at: string; launched_at: string | null; completed_at: string | null; + /** True when the campaign was built to take properties after it starts. */ + is_accepting_properties?: boolean; + /** True when properties can be added right now (Ready, Active or Paused). */ + can_add_properties?: boolean; steps: CampaignStep[]; } @@ -105,6 +110,40 @@ interface CostEstimateResponse { }; } +interface AddPropertiesEstimateResponse { + data: { + campaign_id: string; + status: string; + property_count: number; + pay_today_cents: number; + pay_later_cents: number; + total_cost_cents: number; + pieces_per_property: number; + wallet_available_cents: number; + auto_reload_enabled: boolean; + can_afford: boolean; + requires_auto_reload: boolean; + steps: unknown[]; + }; +} + +interface AddPropertiesResponse { + data: { + campaign_id: string; + status: string; + added_count: number; + skipped: { property_id: string; reason: string }[]; + enqueued_count: number; + scheduled_count: number; + }; +} + +interface EndCampaignResponse { + data: Campaign; + stopped_mail_count?: number; + already_printed_count?: number; +} + interface Design { id: string; name: string; @@ -207,6 +246,7 @@ interface WalletPricingResponse { export async function mailCampaignsList(options: { status?: string; + acceptingProperties?: boolean; search?: string; page?: string; perPage?: string; @@ -216,6 +256,7 @@ export async function mailCampaignsList(options: { const data = await apiRequest('/mail/campaigns', { query: { ...(options.status && { status: options.status }), + ...(options.acceptingProperties && { accepting_properties: true }), ...(options.search && { search: options.search }), ...(options.page && { page: parseInt(options.page, 10) }), ...(options.perPage && { per_page: parseInt(options.perPage, 10) }), @@ -310,6 +351,7 @@ export async function mailCampaignsGet(id: string, options: { json?: boolean }): Name: c.name, Description: c.description || '—', Status: c.status, + 'Accepts Properties': c.can_add_properties ? 'yes' : 'no', Audience: c.audience_kind || '—', Recipients: c.total_recipients, Sent: c.total_sent, @@ -566,6 +608,227 @@ export async function mailCampaignsCostEstimate( console.log(); } +// ============================================================================ +// Campaigns: Add properties +// ============================================================================ + +const MAX_PROPERTIES_PER_ADD = 5000; + +const SKIP_REASON_LABELS: Record = { + already_in_campaign: 'Already in this campaign', + do_not_contact: 'Marked Do Not Contact', + no_mailing_address: 'No complete mailing address', + duplicate_mailing_address: 'Same mailing address as another recipient', + archived_prospect: 'Archived prospect', + not_found: 'Property not found', +}; + +function failInput(message: string): void { + console.error(chalk.red(`Error: ${message}`)); + process.exitCode = 1; +} + +function dollars(cents: number): string { + return `$${(cents / 100).toFixed(2)}`; +} + +/** Accept "1,2 3" style input from one or more --property-ids values. */ +export function parsePropertyIds(values: string | string[] | undefined): string[] { + const raw = (Array.isArray(values) ? values : values ? [values] : []) + .flatMap((value) => value.split(/[\s,]+/)) + .map((value) => value.trim()) + .filter(Boolean); + const invalid = raw.filter((value) => !/^[1-9]\d*$/.test(value)); + if (invalid.length > 0) { + throw new Error(`Property IDs must be positive whole numbers. Not valid: ${invalid.slice(0, 5).join(', ')}`); + } + const ids = [...new Set(raw)]; + if (ids.length === 0) throw new Error('Pass at least one property ID with --property-ids.'); + if (ids.length > MAX_PROPERTIES_PER_ADD) { + throw new Error(`Add at most ${MAX_PROPERTIES_PER_ADD} properties at a time. You passed ${ids.length}.`); + } + return ids; +} + +function printAddEstimate(e: AddPropertiesEstimateResponse['data']): void { + printHeader('Cost to Add Properties'); + printKeyValue({ + 'Campaign ID': e.campaign_id, + Status: e.status, + Properties: e.property_count, + 'Pieces Per Property': e.pieces_per_property, + 'Charged Today': dollars(e.pay_today_cents), + 'Charged Later': dollars(e.pay_later_cents), + 'Total Cost': dollars(e.total_cost_cents), + 'Wallet Available': dollars(e.wallet_available_cents), + 'Auto-reload': e.auto_reload_enabled ? 'on' : 'off', + 'Can Afford': e.can_afford ? 'yes' : 'no', + }); + if (e.requires_auto_reload) { + console.log(); + console.log(chalk.yellow(' This campaign has later mail. Turn on auto-reload before adding properties.')); + } + console.log(chalk.dim(' Properties already in the campaign or skipped are not charged.')); +} + +async function fetchAddEstimate(id: string, count: number) { + return apiRequest( + `/mail/campaigns/${encodeURIComponent(id)}/properties/estimate`, + { query: { count } } + ); +} + +export async function mailCampaignsEstimateAdd( + id: string, + options: { count?: string; json?: boolean } +): Promise { + const count = Number(options.count); + if (!Number.isInteger(count) || count < 1 || count > MAX_PROPERTIES_PER_ADD) { + failInput(`--count must be a whole number from 1 to ${MAX_PROPERTIES_PER_ADD}.`); + return; + } + const spinner = createSpinner('Estimating cost...').start(); + const data = await fetchAddEstimate(id, count); + spinner.stop(); + + if (options.json) { + printJson(data); + return; + } + printAddEstimate(data.data); + console.log(); +} + +export async function mailCampaignsAddProperties( + id: string, + options: { propertyIds?: string | string[]; recipient?: string; yes?: boolean; json?: boolean } +): Promise { + if (options.recipient !== 'owner' && options.recipient !== 'resident') { + failInput('Choose who receives the mail with --recipient owner or --recipient resident.'); + return; + } + let propertyIds: string[]; + try { + propertyIds = parsePropertyIds(options.propertyIds); + } catch (err) { + failInput(err instanceof Error ? err.message : String(err)); + return; + } + + if (!options.yes) { + const spinner = createSpinner('Estimating cost...').start(); + const estimate = await fetchAddEstimate(id, propertyIds.length); + spinner.stop(); + + if (isNonInteractive(options)) { + if (options.json) { + printJson({ ...estimate, added: false, confirmation_required: true }); + } else { + printAddEstimate(estimate.data); + console.log(); + } + console.error( + `No properties were added. Review the charge due today, get the user's approval, then rerun dm mail campaigns add-properties ${id} with --yes.` + ); + return; + } + + printAddEstimate(estimate.data); + console.log(); + const confirmed = await confirmPrompt( + `Add ${propertyIds.length} propert${propertyIds.length === 1 ? 'y' : 'ies'} and charge ${dollars(estimate.data.pay_today_cents)} today?` + ); + if (!confirmed) { + console.log(chalk.dim(' No properties were added.')); + return; + } + } + + const spinner = createSpinner('Adding properties...').start(); + const data = await apiRequest( + `/mail/campaigns/${encodeURIComponent(id)}/properties`, + { method: 'POST', body: { property_ids: propertyIds, recipient: options.recipient } } + ); + spinner.stop(); + + if (options.json) { + printJson(data); + return; + } + + const r = data.data; + printHeader('Properties Added'); + printKeyValue({ + 'Campaign ID': r.campaign_id, + Status: r.status, + Added: r.added_count, + Skipped: r.skipped.length, + 'Pieces Going Out Now': r.enqueued_count, + 'Pieces Scheduled Later': r.scheduled_count, + }); + if (r.skipped.length > 0) { + console.log(); + printTable( + r.skipped.map((s) => ({ + property: s.property_id, + reason: SKIP_REASON_LABELS[s.reason] ?? s.reason, + })), + ['property', 'reason'] + ); + } + console.log(); +} + +// ============================================================================ +// Campaigns: End +// ============================================================================ + +export async function mailCampaignsEnd( + id: string, + options: { yes?: boolean; json?: boolean } +): Promise { + if (!options.yes) { + if (isNonInteractive(options)) { + console.error( + `Ending a campaign is permanent: its unsent mail stops and it can never be resumed or added to. Get the user's approval, then rerun dm mail campaigns end ${id} with --yes.` + ); + process.exitCode = 1; + return; + } + console.log( + chalk.yellow( + ' Ending is permanent. Unsent mail stops, and the campaign can never be resumed or added to again. Mail already printed still delivers.' + ) + ); + const confirmed = await confirmPrompt(`End campaign ${id}?`); + if (!confirmed) { + console.log(chalk.dim(' Campaign not ended.')); + return; + } + } + + const spinner = createSpinner('Ending campaign...').start(); + const data = await apiRequest( + `/mail/campaigns/${encodeURIComponent(id)}/end`, + { method: 'POST', body: {} } + ); + spinner.stop(); + + if (options.json) { + printJson(data); + return; + } + + console.log(chalk.green(` Campaign ${id} ended.`)); + if (data.stopped_mail_count !== undefined) { + console.log(chalk.dim(` Stopped ${data.stopped_mail_count} unsent piece(s).`)); + } + if (data.already_printed_count) { + console.log(chalk.dim(` ${data.already_printed_count} piece(s) already printed will still deliver.`)); + } + console.log(); +} + // ============================================================================ // Designs — List // ============================================================================ diff --git a/src/index.ts b/src/index.ts index 52592c5..973bc2c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -81,6 +81,9 @@ import { mailCampaignsRecipients, mailCampaignsAnalytics, mailCampaignsCostEstimate, + mailCampaignsAddProperties, + mailCampaignsEstimateAdd, + mailCampaignsEnd, mailDesignsList, mailDesignsCreate, mailDesignsGet, @@ -2175,7 +2178,11 @@ mailCampaignsCmd .command('list') .alias('ls') .description('List mail campaigns') - .option('--status ', 'Filter by status: draft, active, paused, completed, cancelled') + .option( + '--status ', + 'Filter by status: draft, ready, active, paused, ended (completed and cancelled still work)' + ) + .option('--accepting-properties', 'Only campaigns that properties can be added to now') .option('--search ', 'Search by campaign name') .option('-p, --page ', 'Page number') .option('--per-page ', 'Results per page') @@ -2186,7 +2193,10 @@ mailCampaignsCmd Examples: dm mail campaigns list --json dm mail camp ls --status active - dm mail campaigns list --search "Austin" --per-page 50` + dm mail campaigns list --accepting-properties --json + dm mail campaigns list --search "Austin" --per-page 50 + +Statuses: draft, ready, active, paused, ended. Completed and Cancelled campaigns show as ended.` ) .action(async (options) => { await mailCampaignsList(options); @@ -2205,7 +2215,9 @@ Examples: dm mail campaigns create -f campaign.json --json dm mail campaigns create --body '{"name":"Austin Owners","property_ids":[123],"address_to":"owner","steps":[{"design_prompt":"Professional postcard"}]}' -Note: Set "launch": true in body to send immediately, or leave as draft.` +Note: Set "launch": true in body to send immediately, or leave as draft. +Leave out property_ids, contact_ids and list_ids to save a Ready campaign with no properties +and no charge. Add properties later with: dm mail campaigns add-properties ` ) .action(async (options) => { await mailCampaignsCreate(options); @@ -2245,7 +2257,7 @@ Note: Only draft campaigns can be updated.` mailCampaignsCmd .command('delete ') - .description('Cancel a campaign') + .description('Cancel a campaign (same as end, without the confirmation)') .option('--dry-run', 'Preview what would be cancelled without making changes') .option('--json', 'Output as JSON') .addHelpText( @@ -2290,7 +2302,7 @@ Note: Verify wallet balance first with: dm mail wallet balance` mailCampaignsCmd .command('pause ') - .description('Pause an active campaign') + .description('Pause an active or ready campaign') .option('--json', 'Output as JSON') .addHelpText( 'after', @@ -2361,6 +2373,65 @@ Examples: await mailCampaignsCostEstimate(id, options); }); +mailCampaignsCmd + .command('add-properties ') + .description('Add properties to a Ready, Active or Paused campaign; each starts at step 1') + .requiredOption( + '--property-ids ', + 'Property IDs, space or comma separated (up to 5,000)' + ) + .requiredOption('--recipient ', 'Who receives the mail: owner or resident') + .option('--yes', 'Skip the confirmation (required for agents, scripts and --json)') + .option('--json', 'Output as JSON') + .addHelpText( + 'after', + ` +Examples: + dm mail campaigns add-properties camp_abc123 --property-ids 101 102 --recipient owner + dm mail campaigns add-properties camp_abc123 --property-ids 101,102 --recipient resident --yes --json + +Shows the charge due today and later, then asks before adding. Without --yes, agents and +scripts get the estimate only and nothing is added. Pieces going out today are charged now; +later pieces are charged as they go out. Skipped properties are listed with a reason.` + ) + .action(async (id, options) => { + await mailCampaignsAddProperties(id, options); + }); + +mailCampaignsCmd + .command('estimate-add ') + .description('Estimate the cost of adding properties to a campaign') + .requiredOption('--count ', 'Number of properties to add (1 to 5,000)') + .option('--json', 'Output as JSON') + .addHelpText( + 'after', + ` +Examples: + dm mail campaigns estimate-add camp_abc123 --count 25 --json` + ) + .action(async (id, options) => { + await mailCampaignsEstimateAdd(id, options); + }); + +mailCampaignsCmd + .command('end ') + .description('End a campaign permanently') + .option('--yes', 'Skip the confirmation (required for agents, scripts and --json)') + .option('--json', 'Output as JSON') + .addHelpText( + 'after', + ` +Examples: + dm mail campaigns end camp_abc123 + dm mail campaigns end camp_abc123 --yes --json + +Ending is permanent. Unsent mail stops and the campaign cannot be paused, resumed or added to. +Mail already printed still delivers. To reuse its steps, create a new campaign.` + ) + .action(async (id, options) => { + await mailCampaignsEnd(id, options); + }); + // ── Designs ── const mailDesignsCmd = mailCmd.command('designs').description('Manage postcard designs'); diff --git a/src/lib/confirm.ts b/src/lib/confirm.ts new file mode 100644 index 0000000..cd0bb89 --- /dev/null +++ b/src/lib/confirm.ts @@ -0,0 +1,30 @@ +/** + * Confirmation before a Command changes something that cannot be undone or + * that spends money. Interactive users answer a yes/no question. Agents, + * scripts and --json callers must pass --yes instead. + */ + +import { createInterface } from 'node:readline/promises'; +import { isQuiet } from './output.js'; + +export function isNonInteractive(options: { json?: boolean } = {}): boolean { + return ( + options.json === true || + isQuiet() || + process.env.DM_AGENT === '1' || + process.env.CI === 'true' || + process.stdin.isTTY !== true || + process.stdout.isTTY !== true + ); +} + +/** Ask a yes/no question on the terminal. Anything but y or yes is a no. */ +export async function confirmPrompt(question: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + const answer = await rl.question(`${question} (y/N) `); + return /^y(es)?$/i.test(answer.trim()); + } finally { + rl.close(); + } +} diff --git a/tests/commands/mailCampaigns.test.ts b/tests/commands/mailCampaigns.test.ts new file mode 100644 index 0000000..5088319 --- /dev/null +++ b/tests/commands/mailCampaigns.test.ts @@ -0,0 +1,214 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockApiRequest, mockConfirm, mockNonInteractive } = vi.hoisted(() => ({ + mockApiRequest: vi.fn(), + mockConfirm: vi.fn(), + mockNonInteractive: vi.fn(() => false), +})); + +vi.mock('../../src/lib/client.js', () => ({ + apiRequest: mockApiRequest, + formatDate: () => 'Oct 14, 2026', +})); +vi.mock('../../src/lib/confirm.js', () => ({ + confirmPrompt: mockConfirm, + isNonInteractive: mockNonInteractive, +})); +vi.mock('../../src/lib/output.js', () => ({ + isQuiet: vi.fn(() => false), + parseRequestBody: vi.fn(), + printJson: vi.fn(), + printHeader: vi.fn(), + printTable: vi.fn(), + printKeyValue: vi.fn(), + printPagination: vi.fn(), + truncate: vi.fn((value: string) => value), + createSpinner: vi.fn(() => ({ + start() { + return this; + }, + stop: vi.fn(), + text: '', + })), +})); + +import { printJson, printKeyValue, printTable } from '../../src/lib/output.js'; +import { + mailCampaignsAddProperties, + mailCampaignsEnd, + mailCampaignsEstimateAdd, + mailCampaignsList, + parsePropertyIds, +} from '../../src/commands/mail.js'; + +const estimate = { + data: { + campaign_id: 'camp_1', + status: 'active', + property_count: 2, + pay_today_cents: 250, + pay_later_cents: 500, + total_cost_cents: 750, + pieces_per_property: 3, + wallet_available_cents: 10000, + auto_reload_enabled: true, + can_afford: true, + requires_auto_reload: false, + steps: [], + }, +}; + +const addResult = { + data: { + campaign_id: 'camp_1', + status: 'active', + added_count: 1, + skipped: [{ property_id: '102', reason: 'do_not_contact' }], + enqueued_count: 1, + scheduled_count: 2, + }, +}; + +let errorSpy: ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + mockNonInteractive.mockReturnValue(false); + process.exitCode = undefined; + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'log').mockImplementation(() => {}); +}); + +afterEach(() => { + process.exitCode = undefined; + vi.restoreAllMocks(); +}); + +describe('dm mail campaigns', () => { + it('lists only campaigns that accept properties with the new status values', async () => { + mockApiRequest.mockResolvedValue({ + data: [], + pagination: { page: 1, per_page: 25, total: 0, has_more: false }, + }); + await mailCampaignsList({ status: 'ready', acceptingProperties: true, json: true }); + expect(mockApiRequest).toHaveBeenCalledWith('/mail/campaigns', { + query: { status: 'ready', accepting_properties: true }, + }); + }); + + it('shows the charge due today and adds properties only after confirmation', async () => { + mockApiRequest.mockResolvedValueOnce(estimate).mockResolvedValueOnce(addResult); + mockConfirm.mockResolvedValue(true); + + await mailCampaignsAddProperties('camp_1', { propertyIds: ['101,102'], recipient: 'owner' }); + + expect(mockApiRequest).toHaveBeenNthCalledWith(1, '/mail/campaigns/camp_1/properties/estimate', { + query: { count: 2 }, + }); + expect(printKeyValue).toHaveBeenCalledWith( + expect.objectContaining({ 'Charged Today': '$2.50', 'Charged Later': '$5.00' }) + ); + expect(mockConfirm).toHaveBeenCalledWith('Add 2 properties and charge $2.50 today?'); + expect(mockApiRequest).toHaveBeenNthCalledWith(2, '/mail/campaigns/camp_1/properties', { + method: 'POST', + body: { property_ids: ['101', '102'], recipient: 'owner' }, + }); + expect(printTable).toHaveBeenCalledWith( + [{ property: '102', reason: 'Marked Do Not Contact' }], + ['property', 'reason'] + ); + }); + + it('adds nothing when the person declines', async () => { + mockApiRequest.mockResolvedValueOnce(estimate); + mockConfirm.mockResolvedValue(false); + await mailCampaignsAddProperties('camp_1', { propertyIds: ['101'], recipient: 'resident' }); + expect(mockApiRequest).toHaveBeenCalledTimes(1); + }); + + it('returns only the estimate to agents that did not pass --yes', async () => { + mockNonInteractive.mockReturnValue(true); + mockApiRequest.mockResolvedValueOnce(estimate); + + await mailCampaignsAddProperties('camp_1', { + propertyIds: ['101', '102'], + recipient: 'owner', + json: true, + }); + + expect(mockApiRequest).toHaveBeenCalledTimes(1); + expect(mockConfirm).not.toHaveBeenCalled(); + expect(printJson).toHaveBeenCalledWith( + expect.objectContaining({ added: false, confirmation_required: true, data: estimate.data }) + ); + expect(errorSpy.mock.calls.join(' ')).toContain('No properties were added'); + }); + + it('adds immediately with --yes', async () => { + mockApiRequest.mockResolvedValueOnce(addResult); + await mailCampaignsAddProperties('camp_1', { + propertyIds: ['101'], + recipient: 'owner', + yes: true, + json: true, + }); + expect(mockApiRequest).toHaveBeenCalledTimes(1); + expect(mockApiRequest).toHaveBeenCalledWith('/mail/campaigns/camp_1/properties', { + method: 'POST', + body: { property_ids: ['101'], recipient: 'owner' }, + }); + expect(printJson).toHaveBeenCalledWith(addResult); + }); + + it('rejects a missing recipient and bad property IDs before calling the API', async () => { + await mailCampaignsAddProperties('camp_1', { propertyIds: ['101'], yes: true }); + await mailCampaignsAddProperties('camp_1', { propertyIds: ['abc'], recipient: 'owner', yes: true }); + expect(mockApiRequest).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(() => parsePropertyIds(Array(5001).fill(0).map((_, i) => String(i + 1)))).toThrow( + 'at most 5000' + ); + expect(parsePropertyIds(['5 5', '6'])).toEqual(['5', '6']); + }); + + it('estimates adding a number of properties', async () => { + mockApiRequest.mockResolvedValueOnce(estimate); + await mailCampaignsEstimateAdd('camp_1', { count: '25', json: true }); + expect(mockApiRequest).toHaveBeenCalledWith('/mail/campaigns/camp_1/properties/estimate', { + query: { count: 25 }, + }); + await mailCampaignsEstimateAdd('camp_1', { count: '0' }); + expect(mockApiRequest).toHaveBeenCalledTimes(1); + expect(process.exitCode).toBe(1); + }); + + it('ends a campaign only after the person confirms', async () => { + mockConfirm.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + mockApiRequest.mockResolvedValue({ + data: { id: 'camp_1', status: 'ended' }, + stopped_mail_count: 4, + already_printed_count: 1, + }); + + await mailCampaignsEnd('camp_1', {}); + expect(mockApiRequest).not.toHaveBeenCalled(); + + await mailCampaignsEnd('camp_1', {}); + expect(mockApiRequest).toHaveBeenCalledWith('/mail/campaigns/camp_1/end', { + method: 'POST', + body: {}, + }); + }); + + it('refuses to end a campaign for an agent without --yes', async () => { + mockNonInteractive.mockReturnValue(true); + await mailCampaignsEnd('camp_1', { json: true }); + expect(mockApiRequest).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(errorSpy.mock.calls.join(' ')).toContain('permanent'); + + mockApiRequest.mockResolvedValue({ data: { id: 'camp_1', status: 'ended' } }); + await mailCampaignsEnd('camp_1', { json: true, yes: true }); + expect(mockApiRequest).toHaveBeenCalledTimes(1); + }); +}); From 109816c69e01c10d83e7868b8b02e32ba477a72f Mon Sep 17 00:00:00 2001 From: Dave Oster Date: Wed, 14 Oct 2026 11:37:33 -0500 Subject: [PATCH 2/5] Mail campaigns: add-properties without --yes exits 1, paused and auto-reload notes (FW-2295) Run by an agent or script without --yes, add-properties prints the estimate (with confirmation_required in JSON), adds nothing and now exits with code 1, as end does, so the run is not read as a success. The estimate notes that a paused campaign charges nothing today and charges each piece after it resumes, and names when auto-reload is needed. Create help, the Command reference and the Playbook explain the opt-in accepts_properties field. Co-Authored-By: Claude Opus 5.5 --- docs/commands.md | 8 ++++---- playbook/PLAYBOOK.md | 4 ++-- src/commands/mail.ts | 10 +++++++++- src/index.ts | 4 +++- tests/commands/mailCampaigns.test.ts | 2 ++ 5 files changed, 20 insertions(+), 8 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index eb56bbd..21a7c52 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -864,7 +864,7 @@ Drive history is read-only. Use `dm prospects` for lifecycle, notes, and tags on ### Mail Campaign Commands -A mail campaign is a sequence of mail steps. Campaigns created through the API or the app's campaign creator keep accepting properties while they are Ready, Active or Paused. Each added property starts at step 1 on its own schedule; properties already in the campaign are not changed. +A mail campaign is a sequence of mail steps. Campaigns built in the app's campaign creator, campaigns saved as Ready, and campaigns created with `"accepts_properties": true` keep accepting properties while they are Ready, Active or Paused. Other campaigns created with an audience work as before and end when their mail is done. Each added property starts at step 1 on its own schedule; properties already in the campaign are not changed. Statuses are `draft`, `ready`, `active`, `paused` and `ended`. Ready means nothing is queued to mail. Completed and Cancelled campaigns from before this change show as `ended`; `--status completed` and `--status cancelled` still work as filters. @@ -882,13 +882,13 @@ dm mail campaigns end camp_abc123 | Command | What it does | | --- | --- | | `add-properties --property-ids --recipient owner\|resident` | Shows the charge due today and later, asks for confirmation, then adds up to 5,000 properties. Prints the added count and each skipped property with its reason (already in the campaign, Do Not Contact, no mailing address, duplicate mailing address, archived prospect, not found). | -| `estimate-add --count ` | Shows what adding `n` properties costs: charged today, charged later, pieces per property and whether your wallet covers it. Nothing is added. | +| `estimate-add --count ` | Shows what adding `n` properties costs: charged today, charged later, pieces per property and whether your wallet covers it. For a paused campaign nothing is charged today; each piece is charged as it goes out after you resume it. Nothing is added. | | `end ` | Ends the campaign permanently after a confirmation. Unsent mail stops; the campaign cannot be paused, resumed or added to. Mail already printed still delivers. | | `list --accepting-properties` | Lists only campaigns that properties can be added to now. | -`--recipient owner` mails the owner's mailing address; `--recipient resident` mails the current resident at the property address. Pieces going out today are charged when added; later pieces are charged as they go out. +`--recipient owner` mails the owner's mailing address; `--recipient resident` mails the current resident at the property address. Pieces going out today are charged when added; later pieces are charged as they go out. Auto-reload must be on when the campaign has more than one step, a step that repeats, or any delay. -`add-properties` and `end` need `--yes` when run by an agent, a script, with `--json`, or without a terminal. Without it, `add-properties` prints the estimate and adds nothing, and `end` stops with an error. `delete ` still cancels a campaign without a confirmation and gives the same result as `end`. +`add-properties` and `end` need `--yes` when run by an agent, a script, with `--json`, or without a terminal. Without it, `add-properties` prints the estimate (with `"confirmation_required": true` in `--json` output), adds nothing and exits with code 1; `end` stops with an error and exits with code 1. `end` refuses a draft, which has not started; cancel a draft with `delete `. `delete ` cancels a campaign without a confirmation, gives the same result as `end` for a started campaign, and succeeds without changes for a campaign that has already ended. ### Filters Commands diff --git a/playbook/PLAYBOOK.md b/playbook/PLAYBOOK.md index 023af0a..6e7eb67 100644 --- a/playbook/PLAYBOOK.md +++ b/playbook/PLAYBOOK.md @@ -706,10 +706,10 @@ dm mail campaigns end --yes # Permanent - Statuses: `draft`, `ready`, `active`, `paused`, `ended`. Completed and Cancelled show as `ended`. - Ask the user owner or resident before adding. Each added property starts at step 1. -- Always run `estimate-add` first and tell the user the charge due today (`pay_today_cents`) and later. Pass `--yes` only after they approve. Without `--yes`, `add-properties` returns the estimate and adds nothing. +- Always run `estimate-add` first and tell the user the charge due today (`pay_today_cents`) and later. Pass `--yes` only after they approve. Without `--yes`, `add-properties` returns the estimate, adds nothing and exits with code 1. For a paused campaign nothing is charged today; pieces are charged as they go out after it resumes. - Report `added_count` and every skipped property with its reason. - Ending is permanent: unsent mail stops and the campaign can never be resumed or added to. Confirm with the user before `end --yes`. -- Create a campaign with no audience to save it as Ready with no charge, then add properties later. +- Create a campaign with no audience to save it as Ready with no charge, then add properties later. To add properties later to a campaign created with an audience, include `"accepts_properties": true` when you create it. ### Activity (FREE) diff --git a/src/commands/mail.ts b/src/commands/mail.ts index fa88743..04ed02c 100644 --- a/src/commands/mail.ts +++ b/src/commands/mail.ts @@ -123,6 +123,8 @@ interface AddPropertiesEstimateResponse { auto_reload_enabled: boolean; can_afford: boolean; requires_auto_reload: boolean; + /** True for a paused campaign: nothing is charged today; pieces are charged as they go out after it resumes. */ + charged_on_resume?: boolean; steps: unknown[]; }; } @@ -664,9 +666,13 @@ function printAddEstimate(e: AddPropertiesEstimateResponse['data']): void { 'Auto-reload': e.auto_reload_enabled ? 'on' : 'off', 'Can Afford': e.can_afford ? 'yes' : 'no', }); + if (e.charged_on_resume) { + console.log(); + console.log(chalk.dim(' This campaign is paused. Nothing is charged today; each piece is charged as it goes out after you resume it.')); + } if (e.requires_auto_reload) { console.log(); - console.log(chalk.yellow(' This campaign has later mail. Turn on auto-reload before adding properties.')); + console.log(chalk.yellow(' This campaign has more than one step, a repeating step, or a delay. Turn on auto-reload before adding properties.')); } console.log(chalk.dim(' Properties already in the campaign or skipped are not charged.')); } @@ -730,6 +736,8 @@ export async function mailCampaignsAddProperties( console.error( `No properties were added. Review the charge due today, get the user's approval, then rerun dm mail campaigns add-properties ${id} with --yes.` ); + // Nothing was added, so a script must not read this as success. + process.exitCode = 1; return; } diff --git a/src/index.ts b/src/index.ts index 973bc2c..aa02af8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2216,8 +2216,10 @@ Examples: dm mail campaigns create --body '{"name":"Austin Owners","property_ids":[123],"address_to":"owner","steps":[{"design_prompt":"Professional postcard"}]}' Note: Set "launch": true in body to send immediately, or leave as draft. +Set "accepts_properties": true to keep adding properties after the campaign starts. Leave out property_ids, contact_ids and list_ids to save a Ready campaign with no properties -and no charge. Add properties later with: dm mail campaigns add-properties ` +and no charge; it always accepts properties. Add properties later with: +dm mail campaigns add-properties ` ) .action(async (options) => { await mailCampaignsCreate(options); diff --git a/tests/commands/mailCampaigns.test.ts b/tests/commands/mailCampaigns.test.ts index 5088319..bfb2215 100644 --- a/tests/commands/mailCampaigns.test.ts +++ b/tests/commands/mailCampaigns.test.ts @@ -142,6 +142,8 @@ describe('dm mail campaigns', () => { expect.objectContaining({ added: false, confirmation_required: true, data: estimate.data }) ); expect(errorSpy.mock.calls.join(' ')).toContain('No properties were added'); + // Nothing was added, so scripts and agents see a failure, as end does. + expect(process.exitCode).toBe(1); }); it('adds immediately with --yes', async () => { From 7afed00793da8338c50cee66647fc7f6ffec0b7e Mon Sep 17 00:00:00 2001 From: Dave Oster Date: Wed, 14 Oct 2026 18:52:03 -0500 Subject: [PATCH 3/5] CLI shows which field a validation error rejected (FW-2295) Campaign Endpoints now return validation errors as "Validation failed" with details.issues. The CLI printed only the message, so an unsupported --status said "Validation failed" without naming the problem. Print each issue's field and message under the error. Co-Authored-By: Claude Opus 5.5 --- src/lib/client.ts | 10 ++++++++++ tests/lib/client.test.ts | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/lib/client.ts b/src/lib/client.ts index 6e5a036..490f4ba 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -92,6 +92,16 @@ export async function apiRequest( const code = error.error?.code ? ` [${error.error.code}]` : ''; console.error(chalk.red(`Error: ${message}${code}`)); console.error(chalk.dim(` ${method} ${path} -> ${response.status}`)); + // Validation errors name each rejected field in details.issues; show them so + // "Validation failed" says what to change (for example an unsupported --status). + const issues = (error.error?.details as { issues?: unknown } | undefined)?.issues; + if (Array.isArray(issues)) { + for (const issue of issues as Array<{ path?: unknown; message?: unknown }>) { + if (typeof issue?.message !== 'string') continue; + const field = typeof issue.path === 'string' && issue.path ? `${issue.path}: ` : ''; + console.error(chalk.yellow(` ${field}${issue.message}`)); + } + } if (response.status === 401) { console.error(chalk.dim(' Run `dm login` to re-authenticate.')); } else if (response.status === 402 && error.error?.code === 'prospect_limit_reached') { diff --git a/tests/lib/client.test.ts b/tests/lib/client.test.ts index f207db0..eb8074d 100644 --- a/tests/lib/client.test.ts +++ b/tests/lib/client.test.ts @@ -44,3 +44,40 @@ it('requires login after stored credentials are removed even if the environment expect(process.exit).toHaveBeenCalledWith(1); expect(fetch).not.toHaveBeenCalled(); }); + +it('names each rejected field when the API returns a validation error', async () => { + readConfig.mockReturnValue({ apiKey: 'stored-account-key' }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 400, + json: async () => ({ + error: { + code: 'validation_error', + message: 'Validation failed', + details: { + issues: [ + { + path: 'status', + message: 'status must be one of draft, ready, active, paused, ended, completed, cancelled', + }, + ], + }, + }, + }), + }) + ); + const printed: string[] = []; + vi.spyOn(console, 'error').mockImplementation((line: unknown) => { + printed.push(String(line)); + }); + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + + await expect(apiRequest('/mail/campaigns', { query: { status: 'finished' } })).rejects.toThrow('exit'); + expect(printed.join('\n')).toContain( + 'status: status must be one of draft, ready, active, paused, ended, completed, cancelled' + ); +}); From 97a948333e0e4ea1a273aba31f1ffba03c4fcc57 Mon Sep 17 00:00:00 2001 From: Dave Oster Date: Wed, 14 Oct 2026 20:41:24 -0500 Subject: [PATCH 4/5] CLI 0.5.0-rc.0: mail campaign add-properties, estimate-add and end (FW-2295) Co-Authored-By: Claude Opus 5.5 --- npm-alias/package.json | 4 ++-- package-lock.json | 4 ++-- package.json | 2 +- src/version.ts | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/npm-alias/package.json b/npm-alias/package.json index 9151826..1a2b919 100644 --- a/npm-alias/package.json +++ b/npm-alias/package.json @@ -1,6 +1,6 @@ { "name": "dealmachine", - "version": "0.4.1", + "version": "0.5.0-rc.0", "description": "Short install alias for the DealMachine property intelligence CLI", "author": "DealMachine", "license": "MIT", @@ -23,7 +23,7 @@ "LICENSE" ], "dependencies": { - "@dealmachine/cli": "0.4.1" + "@dealmachine/cli": "0.5.0-rc.0" }, "engines": { "node": ">=18" diff --git a/package-lock.json b/package-lock.json index a38dde4..a9a05a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@dealmachine/cli", - "version": "0.4.1", + "version": "0.5.0-rc.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@dealmachine/cli", - "version": "0.4.1", + "version": "0.5.0-rc.0", "license": "MIT", "dependencies": { "chalk": "^5.3.0", diff --git a/package.json b/package.json index b5777d8..1092518 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dealmachine/cli", - "version": "0.4.1", + "version": "0.5.0-rc.0", "description": "DealMachine CLI for property intelligence, people lookup, and enrichment through the public API", "author": "DealMachine", "license": "MIT", diff --git a/src/version.ts b/src/version.ts index c6f8f26..3c776a6 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ -export const CLI_VERSION = '0.4.1'; +export const CLI_VERSION = '0.5.0-rc.0'; export const CLI_USER_AGENT = `dm-cli/${CLI_VERSION}`; From ceddd161b9cf9f5b474f1750ce89de46e16e1eb4 Mon Sep 17 00:00:00 2001 From: Dave Oster Date: Thu, 15 Oct 2026 09:51:39 -0500 Subject: [PATCH 5/5] Prepare stable CLI 0.5.0 with staged mail Commands --- npm-alias/package.json | 4 ++-- package-lock.json | 4 ++-- package.json | 2 +- src/version.ts | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/npm-alias/package.json b/npm-alias/package.json index 1a2b919..522720e 100644 --- a/npm-alias/package.json +++ b/npm-alias/package.json @@ -1,6 +1,6 @@ { "name": "dealmachine", - "version": "0.5.0-rc.0", + "version": "0.5.0", "description": "Short install alias for the DealMachine property intelligence CLI", "author": "DealMachine", "license": "MIT", @@ -23,7 +23,7 @@ "LICENSE" ], "dependencies": { - "@dealmachine/cli": "0.5.0-rc.0" + "@dealmachine/cli": "0.5.0" }, "engines": { "node": ">=18" diff --git a/package-lock.json b/package-lock.json index a9a05a5..345bb89 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@dealmachine/cli", - "version": "0.5.0-rc.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@dealmachine/cli", - "version": "0.5.0-rc.0", + "version": "0.5.0", "license": "MIT", "dependencies": { "chalk": "^5.3.0", diff --git a/package.json b/package.json index 1092518..9f529fa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dealmachine/cli", - "version": "0.5.0-rc.0", + "version": "0.5.0", "description": "DealMachine CLI for property intelligence, people lookup, and enrichment through the public API", "author": "DealMachine", "license": "MIT", diff --git a/src/version.ts b/src/version.ts index 3c776a6..8f41652 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ -export const CLI_VERSION = '0.5.0-rc.0'; +export const CLI_VERSION = '0.5.0'; export const CLI_USER_AGENT = `dm-cli/${CLI_VERSION}`;