Skip to content
Open
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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1800,6 +1800,24 @@ Check the campaign with `polymorfa campaign view`. When an operation ID is
returned, follow it with `polymorfa operation wait` or
`polymorfa operation watch`; see [Operations](#operations).

To create a template campaign, set `templateId` in `campaign.json`. Add
`variableMapping` when template variables use different recipient field names
or need a fallback:

```json
{
"name": "September launch",
"templateId": "template_123",
"variableMapping": {
"firstName": {"source": "name", "fallback": "friend"}
}
}
```

Launch refuses recipients whose variables have no value or fallback. Pass
`--skip-missing-variables` to skip those recipients instead; their result has
`lastError` `missing_variable`. The flag is never enabled by default.

Stop is terminal and requires `--yes`. A campaign that has an active delivery
run answers with an operation ID, as the other lifecycle commands do. A
campaign with no active run is cancelled immediately and answers without one;
Expand Down
12 changes: 12 additions & 0 deletions contracts/cli-reference.json
Original file line number Diff line number Diff line change
Expand Up @@ -17307,6 +17307,18 @@
"env": null,
"helpGroup": null
},
{
"name": "skip-missing-variables",
"char": null,
"type": "boolean",
"required": false,
"multiple": false,
"default": null,
"options": [],
"description": "Skip recipients missing a template variable with no value or fallback. Without this flag launch fails.",
"env": null,
"helpGroup": null
},
{
"name": "timeout",
"char": null,
Expand Down
9 changes: 5 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 @@ -6,7 +6,7 @@
"@modelcontextprotocol/server": "2.3.1",
"@napi-rs/keyring": "1.3.0",
"@oclif/core": "^4.13.5",
"@polymorfa/sdk": "0.1.0-dev.20261004024706",
"@polymorfa/sdk": "0.1.0-dev.20261010020302",
"jq-wasm": "3.0.0-jq-1.8.2",
"zod": "4.6.7"
},
Expand Down
5 changes: 5 additions & 0 deletions src/commands/campaign/launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ export default class CampaignLaunch extends BaseCommand {
'scheduled-at': Flags.string({
description: 'Launch time as epoch milliseconds or an ISO 8601 instant.',
}),
'skip-missing-variables': Flags.boolean({
description:
'Skip recipients missing a template variable with no value or fallback. Without this flag launch fails.',
}),
}

async run(): Promise<void> {
Expand All @@ -38,6 +42,7 @@ export default class CampaignLaunch extends BaseCommand {
flags['project-slug'],
args.campaign,
scheduledAt,
flags['skip-missing-variables'],
{idempotencyKey: idempotencyKey(flags['idempotency-key'])},
)
this.writeValue(
Expand Down
15 changes: 13 additions & 2 deletions src/services/campaigns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,16 @@ export async function launchCampaign(
projectSlug: string,
campaignId: string,
scheduledAt: number | undefined,
skipMissingVariables: boolean,
options: RequestOptions = {},
): Promise<CampaignResult<CampaignOperation>> {
const response = await client.campaigns.launch(
projectSlug,
campaignId,
scheduledAt === undefined ? {} : {scheduledAt},
{
...(scheduledAt === undefined ? {} : {scheduledAt}),
...(skipMissingVariables ? {skipMissingVariables: true} : {}),
},
options,
)
return result(response.data.data, response.metadata)
Expand Down Expand Up @@ -144,7 +148,9 @@ export async function listCampaignRecipients(
client: MessagingClient,
projectSlug: string,
campaignId: string,
params: ListCampaignRecipientsParams & {readonly reason?: string},
params: Omit<ListCampaignRecipientsParams, 'reason'> & {
readonly reason?: string
},
): Promise<CampaignResult<CampaignRecipientPageResult>> {
const response = await client.raw.request<{
data: readonly CampaignRecipient[]
Expand Down Expand Up @@ -233,6 +239,11 @@ export function renderCampaign(campaign: Campaign): string {
`ID\t${campaign.id}`,
`Name\t${campaign.name}`,
`Status\t${campaign.status}`,
`Template\t${campaign.templateId ?? ''}`,
...(campaign.variableMapping === undefined ||
campaign.variableMapping === null
? []
: [`Variable mapping\t${JSON.stringify(campaign.variableMapping)}`]),
`Recipients\t${campaign.recipientCount}`,
`Sent\t${campaign.sentCount}`,
`Delivered\t${campaign.deliveredCount}`,
Expand Down
24 changes: 24 additions & 0 deletions test/commands/campaigns.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ describe('Messaging campaign commands', () => {
recipientListId: 'recipients_1',
senderConfig: {session: 'session_1'},
templateId: 'template_1',
variableMapping: {firstName: {source: 'name', fallback: 'friend'}},
})
server = await startHttpServer({body: {data: campaign(), success: true}})

Expand All @@ -67,6 +68,7 @@ describe('Messaging campaign commands', () => {
recipientListId: 'recipients_1',
senderConfig: {session: 'session_1'},
templateId: 'template_1',
variableMapping: {firstName: {source: 'name', fallback: 'friend'}},
frequencyCap: {maxPer24Hours: 2, maxPer7Days: null},
})
expect(server.requests[0]).toMatchObject({
Expand Down Expand Up @@ -176,6 +178,28 @@ describe('Messaging campaign commands', () => {
expect(server.requests).toHaveLength(0)
})

test('passes the explicit missing-variable skip policy on launch', async () => {
server = await startHttpServer({
body: {
data: {...campaign(), operationId: 'operation_1', status: 'draft'},
success: true,
},
})

const result = await apiKeyEnvironment(() =>
runCommand(
'campaign launch campaign_1 --project-slug project_1 --skip-missing-variables --idempotency-key launch_skip_1',
),
)

expect(result.error).toBeUndefined()
expect(server.requests[0]).toMatchObject({
body: JSON.stringify({skipMissingVariables: true}),
method: 'POST',
url: '/messaging/projects/project_1/campaigns/campaign_1/launch',
})
})

test('pauses, resumes, and explicitly stops through distinct lifecycle routes', async () => {
server = await startHttpSequenceServer([
{body: {data: {...campaign(), operationId: 'op_pause'}, success: true}},
Expand Down
Loading