Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
*.md text eol=lf
*.mjs text eol=lf
6 changes: 6 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -1358,6 +1358,12 @@ 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.

### International Owner

Property search, count and export accept `international_owner` in the `--body` filters array. Use `true` for foreign mailing addresses, `false` to exclude them, or omit the filter for Any. The source is `MailingForeignAddressInd`: Canada (`C`) and other foreign addresses (`Y`) are international; null values and US territories are excluded.
Expand Down
13 changes: 11 additions & 2 deletions src/commands/mail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ interface CampaignStep {
delay_days: number;
interval_days: number;
repeat_count: number;
repeat_forever?: boolean;
skip_if_responded: boolean;
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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']);
}
Expand Down Expand Up @@ -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)}`,
Expand Down Expand Up @@ -1445,6 +1450,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 {
Expand All @@ -1460,6 +1468,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`,
});
}

Expand Down
30 changes: 30 additions & 0 deletions tests/commands/mailCampaigns.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
mailCampaignsAddProperties,
mailCampaignsEnd,
mailCampaignsArchive,
mailCampaignsCostEstimate,
mailCampaignsEstimateAdd,
mailCampaignsList,
parsePropertyIds,
Expand Down Expand Up @@ -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({
Expand Down
2 changes: 1 addition & 1 deletion tests/release-version-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ describe('version check before staging and master', () => {
writeFileSync(join(checkout, 'npm-alias/package.json'), JSON.stringify({ version, dependencies: { '@dealmachine/cli': version } }));
writeFileSync(join(checkout, 'package-lock.json'), JSON.stringify({ version, packages: { '': { version } } }));
writeFileSync(join(checkout, 'src/version.ts'), `export const CLI_VERSION = '${version}';\n`);
symlinkSync(join(root, 'scripts'), join(temp, 'linked-scripts'));
symlinkSync(join(root, 'scripts'), join(temp, 'linked-scripts'), process.platform === 'win32' ? 'junction' : 'dir');
const run = spawnSync(process.execPath, [join(temp, 'linked-scripts', 'check-release-version.mjs'), checkout], {
encoding: 'utf8',
env: { PATH: process.env.PATH, GITHUB_BASE_REF: 'staging' },
Expand Down
Loading