From 6240d71308f6d4bfe8bf84961a2edec1b835d272 Mon Sep 17 00:00:00 2001 From: Ben Tucker Date: Fri, 30 Oct 2026 14:01:41 -0400 Subject: [PATCH 1/2] feat(mail): support repeat forever for APP-4640 --- .gitattributes | 1 + docs/commands.md | 6 ++++++ src/commands/mail.ts | 13 +++++++++++-- 3 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1fe2478 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.md text eol=lf diff --git a/docs/commands.md b/docs/commands.md index 4b958a2..6ee3bad 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -1356,3 +1356,9 @@ The summary counts changed prospects, prospects already in that lifecycle, and records that are not prospects. Bulk archive ends mail and keeps list membership; it closes open opportunities unless `--no-cascade` is supplied. A positional `prospect_...` ID continues to use the single-prospect operation. + +### Repeat forever + +Campaign step JSON accepts `repeat_forever: true` on the final step with `interval_days` of 21, 30, 45, 60, 90, 180, or 365. Earlier steps finish first. Read the cost estimate and enable auto-reload before sending; later pieces are charged when they go out. The CLI shows Forever for recurring steps; their bounded `repeat_count` is not a stopping limit. + +Use `dm mail settings update --body` with all three fields `default_single_send_interval_days`, `default_single_send_repeat_count`, and `default_single_send_repeat_forever` to save the workspace default for single postcards on web and mobile. This does not send mail or change existing campaigns. diff --git a/src/commands/mail.ts b/src/commands/mail.ts index 04ed02c..239f137 100644 --- a/src/commands/mail.ts +++ b/src/commands/mail.ts @@ -51,6 +51,7 @@ interface CampaignStep { delay_days: number; interval_days: number; repeat_count: number; + repeat_forever?: boolean; skip_if_responded: boolean; } @@ -101,6 +102,9 @@ interface CostEstimateResponse { price_per_card_cents: number; total_cost_cents: number; total_cost_dollars: number; + repeat_forever?: boolean; + repeat_interval_days?: number | null; + recurring_cost_cents?: number | null; pay_now_cost_cents: number; pay_later_cost_cents: number; wallet_balance_cents: number; @@ -373,7 +377,7 @@ export async function mailCampaignsGet(id: string, options: { json?: boolean }): name: s.step_name || '—', design: s.design_id ? truncate(s.design_id, 16) : '(prompt)', delay: `${s.delay_days}d`, - repeat: s.repeat_count, + repeat: s.repeat_forever ? `Forever, every ${s.interval_days}d` : s.repeat_count, })); printTable(stepRows, ['order', 'name', 'design', 'delay', 'repeat']); } @@ -601,7 +605,8 @@ export async function mailCampaignsCostEstimate( printKeyValue({ Recipients: e.recipient_count, 'Price Per Card': `$${(e.price_per_card_cents / 100).toFixed(2)}`, - 'Total Cost': `$${e.total_cost_dollars.toFixed(2)}`, + [e.repeat_forever ? 'Scheduled Cost' : 'Total Cost']: `${e.total_cost_dollars.toFixed(2)}`, + ...(e.repeat_forever ? { Repeats: `Every ${e.repeat_interval_days} days until stopped`, 'Each Repeat': `${((e.recurring_cost_cents ?? 0) / 100).toFixed(2)}` } : {}), 'Pay Now': `$${(e.pay_now_cost_cents / 100).toFixed(2)}`, 'Pay Later': `$${(e.pay_later_cost_cents / 100).toFixed(2)}`, 'Wallet Balance': `$${(e.wallet_balance_cents / 100).toFixed(2)}`, @@ -1399,6 +1404,9 @@ interface MailSettings { contact_frequency_limit_days: number | null; default_qr_code_url: string | null; default_signature_image_url: string | null; + default_single_send_interval_days?: number | null; + default_single_send_repeat_count?: number | null; + default_single_send_repeat_forever?: boolean | null; } interface MailSettingsResponse { @@ -1414,6 +1422,7 @@ function printMailSettings(s: MailSettings): void { : '— (1 day)', 'Default QR Code URL': s.default_qr_code_url ?? '—', 'Default Signature Image': s.default_signature_image_url ?? '—', + 'Default Postcard Schedule': s.default_single_send_repeat_forever ? `Repeat forever, every ${s.default_single_send_interval_days ?? 21} days` : s.default_single_send_interval_days === 0 ? 'Just once' : `${s.default_single_send_repeat_count ?? 6} sends, every ${s.default_single_send_interval_days ?? 21} days`, }); } From 81af2647cb248970390a1855ef97b95723dbc473 Mon Sep 17 00:00:00 2001 From: Jared5714 Date: Fri, 13 Nov 2026 09:57:36 -0600 Subject: [PATCH 2/2] fix(mail): show dollar signs on repeating campaign costs (APP-4640) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/commands/mail.ts | 4 ++-- tests/commands/mailCampaigns.test.ts | 30 ++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/commands/mail.ts b/src/commands/mail.ts index 5fc69b0..515c30e 100644 --- a/src/commands/mail.ts +++ b/src/commands/mail.ts @@ -605,8 +605,8 @@ export async function mailCampaignsCostEstimate( printKeyValue({ Recipients: e.recipient_count, 'Price Per Card': `$${(e.price_per_card_cents / 100).toFixed(2)}`, - [e.repeat_forever ? 'Scheduled Cost' : 'Total Cost']: `${e.total_cost_dollars.toFixed(2)}`, - ...(e.repeat_forever ? { Repeats: `Every ${e.repeat_interval_days} days until stopped`, 'Each Repeat': `${((e.recurring_cost_cents ?? 0) / 100).toFixed(2)}` } : {}), + [e.repeat_forever ? 'Scheduled Cost' : 'Total Cost']: `$${e.total_cost_dollars.toFixed(2)}`, + ...(e.repeat_forever ? { Repeats: `Every ${e.repeat_interval_days} days until stopped`, 'Each Repeat': `$${((e.recurring_cost_cents ?? 0) / 100).toFixed(2)}` } : {}), 'Pay Now': `$${(e.pay_now_cost_cents / 100).toFixed(2)}`, 'Pay Later': `$${(e.pay_later_cost_cents / 100).toFixed(2)}`, 'Wallet Balance': `$${(e.wallet_balance_cents / 100).toFixed(2)}`, diff --git a/tests/commands/mailCampaigns.test.ts b/tests/commands/mailCampaigns.test.ts index 1defa04..0332860 100644 --- a/tests/commands/mailCampaigns.test.ts +++ b/tests/commands/mailCampaigns.test.ts @@ -37,6 +37,7 @@ import { mailCampaignsAddProperties, mailCampaignsEnd, mailCampaignsArchive, + mailCampaignsCostEstimate, mailCampaignsEstimateAdd, mailCampaignsList, parsePropertyIds, @@ -185,6 +186,35 @@ describe('dm mail campaigns', () => { expect(process.exitCode).toBe(1); }); + const costEstimate = { + recipient_count: 10, + price_per_card_cents: 75, + total_cost_cents: 1500, + total_cost_dollars: 15, + pay_now_cost_cents: 750, + pay_later_cost_cents: 750, + wallet_balance_cents: 10000, + wallet_available_cents: 10000, + can_afford: true, + auto_reload_available: true, + }; + + it('shows the total cost of a finite campaign in dollars', async () => { + mockApiRequest.mockResolvedValueOnce({ data: costEstimate }); + await mailCampaignsCostEstimate('camp_1', {}); + expect(printKeyValue).toHaveBeenLastCalledWith(expect.objectContaining({ 'Total Cost': '$15.00' })); + }); + + it('shows the scheduled and repeat costs of a repeating campaign in dollars', async () => { + mockApiRequest.mockResolvedValueOnce({ + data: { ...costEstimate, repeat_forever: true, repeat_interval_days: 30, recurring_cost_cents: 750 }, + }); + await mailCampaignsCostEstimate('camp_1', {}); + expect(printKeyValue).toHaveBeenLastCalledWith( + expect.objectContaining({ 'Scheduled Cost': '$15.00', 'Each Repeat': '$7.50' }) + ); + }); + it('ends a campaign only after the person confirms', async () => { mockConfirm.mockResolvedValueOnce(false).mockResolvedValueOnce(true); mockApiRequest.mockResolvedValue({