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
39 changes: 35 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1669,6 +1669,8 @@ project slug because the source routes are slug-scoped:
```shell
polymorfa campaign list --project-slug launch-project
polymorfa campaign create --project-slug launch-project --input campaign.json
polymorfa campaign update 0198a2cb-0000-7000-8000-0000000000e3 \
--project-slug launch-project --input campaign-patch.json
polymorfa campaign view campaign_123 --project-slug launch-project
polymorfa campaign analytics campaign_123 --project-slug launch-project

Expand All @@ -1677,13 +1679,17 @@ polymorfa campaign launch campaign_123 \
--scheduled-at 2026-09-01T12:00:00Z
polymorfa campaign pause campaign_123 --project-slug launch-project
polymorfa campaign resume campaign_123 --project-slug launch-project
polymorfa campaign reschedule campaign_123 \
--project-slug launch-project \
--scheduled-at 2026-09-02T12:00:00Z
polymorfa campaign reschedule campaign_123 --project-slug launch-project --now
polymorfa campaign stop campaign_123 --project-slug launch-project --yes
polymorfa campaign requeue campaign_123 \
--project-slug launch-project \
--include-skipped-error
```

Launch, pause, and resume return accepted durable work; they do not wait for
Launch, pause, resume, and reschedule return accepted durable work; they do not wait for
the state transition. Stop returns accepted work for an active delivery run,
or reports an immediate cancellation with no operation ID when none is active.
Check the campaign with `polymorfa campaign view`. When an operation ID is
Expand All @@ -1699,6 +1705,15 @@ Launch times accept epoch milliseconds or timezone-qualified ISO 8601
instants. List returns one complete newest-first array with no cursor. Requeue
is synchronous and reports only the number moved.

Update reads one JSON object with at least one of `name`, `recipientListId`,
`senderConfig`, or `scheduledAt`. Send only fields to change. Set
`recipientListId` or `scheduledAt` to `null` to detach the audience or start at
launch. A non-null `scheduledAt` must be integer Unix milliseconds within the
JavaScript Date range. The API refuses audience and start-time changes after launch. A sender
change after launch requires the sending numbers to pass live eligibility
checks. If a PATCH result is lost, view the campaign before deciding whether
to send another update.

Create, launch, pause, resume and stop accept `--idempotency-key` to deduplicate
the same request for 24 hours. A successful request repeated with the same key
returns `409 idempotency_completed`, not the original response. Changed input
Expand Down Expand Up @@ -1734,6 +1749,12 @@ polymorfa campaign recipients list campaign_123 \
--project-slug launch-project \
--status skipped \
--limit 100

polymorfa campaign recipients export campaign_123 \
--project-slug launch-project \
--status failed \
--reason send_failed \
--output recipient-outcomes.csv
```

One append accepts at most 1,000 entries. `--csv` reads the file locally and
Expand All @@ -1759,8 +1780,18 @@ result arrives, list the recipients before appending again.

List returns `{data, page}`. Pass `page.nextCursor` back as `--cursor` while
`page.hasMore` is true; the CLI prints the next cursor in human output. Filter
with `--status queued|sending|sent|delivered|read|failed|skipped`. A recipient
suppressed by an opt-out appears as `skipped` with `lastError` `opted_out`.
with `--status queued|sending|sent|delivered|read|failed|skipped` and `--reason`
for a normalized failure reason. A recipient suppressed by an opt-out appears
as `skipped` with `failureReason` and `lastError` `opted_out`.

`recipients export` fetches up to 1,000 rows per API page and writes each page
as it arrives, under one CSV header. It writes to standard output unless
`--output` names a file; an existing file requires `--force`. Use `--max-pages`
to stop early, then pass the cursor printed on standard error back with
`--cursor` and the same filters. If a later page fails, the pages already
written stay and the cursor to resume from is printed. The export is CSV only:
`--json` is refused, and `--quiet` needs `--output`. Each page reflects
recipient outcomes when that page was read.

#### Campaign conversions (beta)

Expand Down Expand Up @@ -1811,7 +1842,7 @@ once and never retried; after a lost response, run `view` before creating
again. Attach works only before a campaign launches. Flow form steps work on
Official numbers only.

The source exposes no campaign update, delete, validate, archive, duplicate,
The source exposes no campaign delete, validate, archive, duplicate,
event, history, or replay route on the Messaging surface. The CLI does not
substitute similarly named Platform campaign methods for these gaps. To follow
or stop a campaign's operation, use the `operation` commands in
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"@modelcontextprotocol/server": "2.0.0",
"@napi-rs/keyring": "1.3.0",
"@oclif/core": "^4.13.5",
"@polymorfa/sdk": "0.1.0-dev.20261004043942",
"@polymorfa/sdk": "0.1.0-dev.20261005230914",
"jq-wasm": "3.0.0-jq-1.8.2",
"zod": "4.2.0"
},
Expand Down
146 changes: 146 additions & 0 deletions src/commands/campaign/recipients/export.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import {Args, Flags} from '@oclif/core'
import {PolymorfaConfigurationError} from '@polymorfa/sdk'
import {open, stat} from 'node:fs/promises'
import {resolve} from 'node:path'

import {BaseCommand} from '../../../base-command.js'
import {
CampaignRecipientExportInterrupted,
exportCampaignRecipients,
recipientFailureReason,
recipientStatus,
RECIPIENT_FAILURE_REASONS,
RECIPIENT_STATUSES,
} from '../../../services/campaigns.js'

export default class CampaignRecipientsExport extends BaseCommand {
static override args = {
campaign: Args.string({description: 'Campaign ID.', required: true}),
}

static override description = `Export campaign recipient outcomes as CSV.

The command reads cursor pages with the same filters and writes one header. Use --max-pages to stop early; the remaining cursor is printed on standard error. Resume with --cursor and the same filters. This read reflects each recipient when its page was fetched.`

static override examples = [
'<%= config.bin %> <%= command.id %> campaign_1 --project-slug launch --output recipients.csv',
'<%= config.bin %> <%= command.id %> campaign_1 --project-slug launch --status failed --reason send_failed --max-pages 1',
]

static override flags = {
cursor: Flags.string({
description: 'Continue from an earlier export cursor.',
}),
force: Flags.boolean({description: 'Overwrite an existing --output file.'}),
limit: Flags.integer({
description: 'Rows per API page.',
min: 1,
max: 1000,
default: 1000,
}),
'max-pages': Flags.integer({
description: 'Stop after this many pages.',
min: 1,
max: 500,
}),
output: Flags.string({
char: 'o',
description: 'Write CSV to this file instead of standard output.',
}),
'project-slug': Flags.string({
description: 'Project slug in the Messaging API path.',
required: true,
}),
reason: Flags.string({
description: 'Filter by failure reason.',
options: [...RECIPIENT_FAILURE_REASONS],
}),
status: Flags.string({
description: 'Filter by delivery status.',
options: [...RECIPIENT_STATUSES],
}),
}

async run(): Promise<void> {
const {args, flags} = await this.parse(CampaignRecipientsExport)
const cursor = flags.cursor?.trim()
if (cursor !== undefined && (cursor.length === 0 || cursor.length > 1024))
throw new PolymorfaConfigurationError(
'--cursor must be 1 to 1024 characters.',
'cursor',
)
const outputPath =
flags.output && flags.output !== '-' ? resolve(flags.output) : undefined
// The export is CSV only. Refuse modes it cannot honour before reading
// any recipient, so --quiet never prints recipient data.
const mode = this.outputMode(flags)
if (mode === 'json')
throw new PolymorfaConfigurationError(
'Recipient export writes CSV; --json is not supported. Use campaign recipients list --json for JSON.',
'json',
)
if (mode === 'quiet' && outputPath === undefined)
throw new PolymorfaConfigurationError(
'--quiet would discard the CSV; pass --output to write it to a file.',
'quiet',
)
if (outputPath && !flags.force) {
try {
await stat(outputPath)
throw new PolymorfaConfigurationError(
'The output file already exists; pass --force to replace it.',
'output',
)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
}
const file = outputPath
? await open(outputPath, flags.force ? 'w' : 'wx')
: undefined
const write = file
? async (chunk: string) => {
await file.write(chunk)
}
: async (chunk: string) => {
// Wait for the pipe to drain so a slow reader bounds memory.
if (!process.stdout.write(chunk))
await new Promise<void>((done) =>
process.stdout.once('drain', () => done()),
)
}
let result
try {
result = await exportCampaignRecipients(
await this.messagingClient(flags, {serverApiKeyOnly: 'Campaign'}),
flags['project-slug'],
args.campaign,
{
...(cursor === undefined ? {} : {cursor}),
limit: flags.limit,
...(flags.status === undefined
? {}
: {status: recipientStatus(flags.status)!}),
...(flags.reason === undefined
? {}
: {reason: recipientFailureReason(flags.reason)!}),
},
write,
flags['max-pages'] ?? 500,
)
} catch (error) {
if (error instanceof CampaignRecipientExportInterrupted) {
// The pages already written stay; resume after them.
process.stderr.write(
`Wrote ${error.pages} page(s) before the export stopped. Continue with --cursor ${error.nextCursor}\n`,
)
throw error.cause
}
throw error
} finally {
await file?.close()
}
if (result.nextCursor !== null)
process.stderr.write(`Continue with --cursor ${result.nextCursor}\n`)
}
}
9 changes: 9 additions & 0 deletions src/commands/campaign/recipients/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import {Args, Flags} from '@oclif/core'
import {BaseCommand} from '../../../base-command.js'
import {
listCampaignRecipients,
recipientFailureReason,
recipientStatus,
RECIPIENT_FAILURE_REASONS,
renderCampaignRecipients,
RECIPIENT_STATUSES,
} from '../../../services/campaigns.js'
Expand Down Expand Up @@ -31,6 +33,10 @@ export default class CampaignRecipientsList extends BaseCommand {
description: 'Project slug in the Messaging API path.',
required: true,
}),
reason: Flags.string({
description: 'Filter by the normalized failure reason.',
options: [...RECIPIENT_FAILURE_REASONS],
}),
status: Flags.string({
description:
'Filter by delivery status. Use skipped to find recipients suppressed by an opt-out.',
Expand All @@ -50,6 +56,9 @@ export default class CampaignRecipientsList extends BaseCommand {
...(flags.status === undefined
? {}
: {status: recipientStatus(flags.status)!}),
...(flags.reason === undefined
? {}
: {reason: recipientFailureReason(flags.reason)!}),
},
)
this.writeValue(result, renderCampaignRecipients(result.data), flags)
Expand Down
79 changes: 79 additions & 0 deletions src/commands/campaign/reschedule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import {Args, Flags} from '@oclif/core'
import {PolymorfaValidationError} from '@polymorfa/sdk'

import {BaseCommand} from '../../base-command.js'
import {idempotencyKey} from '../../input/idempotency.js'
import {
parseCampaignSchedule,
renderCampaignOperation,
rescheduleCampaign,
} from '../../services/campaigns.js'

export default class CampaignReschedule extends BaseCommand {
static override args = {
campaign: Args.string({description: 'Campaign ID.', required: true}),
}

static override description =
'Change a launched campaign start time before it begins sending, or start it now.'

static override flags = {
'idempotency-key': Flags.string({
description:
'Idempotency-Key header. Generated per invocation when omitted; reuse an explicit key across process retries (24-hour retention).',
}),
now: Flags.boolean({
description: 'Start the scheduled campaign now.',
}),
'project-slug': Flags.string({
description: 'Project slug in the Messaging API path.',
required: true,
}),
'scheduled-at': Flags.string({
description:
'New start time as epoch milliseconds or an ISO 8601 instant.',
}),
}

async run(): Promise<void> {
const {args, flags} = await this.parse(CampaignReschedule)
if (flags.now === (flags['scheduled-at'] !== undefined)) {
throw new PolymorfaValidationError(
'Specify exactly one of --scheduled-at or --now.',
{code: 'invalid_campaign_schedule'},
)
}

const scheduledAt = flags.now
? null
: parseCampaignSchedule(flags['scheduled-at'])
if (scheduledAt === undefined) {
throw new PolymorfaValidationError(
'Specify exactly one of --scheduled-at or --now.',
{code: 'invalid_campaign_schedule'},
)
}
if (
scheduledAt !== null &&
(scheduledAt <= 0 || scheduledAt > 8_640_000_000_000_000)
) {
throw new PolymorfaValidationError(
'Scheduled time must be a positive, representable Unix timestamp in milliseconds.',
{code: 'invalid_campaign_schedule'},
)
}

const result = await rescheduleCampaign(
await this.messagingClient(flags, {serverApiKeyOnly: 'Campaign'}),
flags['project-slug'],
args.campaign,
scheduledAt,
{idempotencyKey: idempotencyKey(flags['idempotency-key'])},
)
this.writeValue(
result,
renderCampaignOperation('reschedule', result.data),
flags,
)
}
}
Loading