From e7a17e2b47189f5cf0484e416d53dc85b7a844a0 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 26 Sep 2026 12:15:34 +0200 Subject: [PATCH 1/2] feat(cli): expose campaign template variable policy --- README.md | 18 ++++++++++++++++++ src/commands/campaign/launch.ts | 5 +++++ src/services/campaigns.ts | 11 ++++++++++- test/commands/campaigns.test.ts | 24 ++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3845eaa7..d1bcd440 100644 --- a/README.md +++ b/README.md @@ -1355,6 +1355,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; diff --git a/src/commands/campaign/launch.ts b/src/commands/campaign/launch.ts index f70af460..2bceeff4 100644 --- a/src/commands/campaign/launch.ts +++ b/src/commands/campaign/launch.ts @@ -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 { @@ -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( diff --git a/src/services/campaigns.ts b/src/services/campaigns.ts index 89a2b155..9dfb8ef0 100644 --- a/src/services/campaigns.ts +++ b/src/services/campaigns.ts @@ -73,12 +73,16 @@ export async function launchCampaign( projectSlug: string, campaignId: string, scheduledAt: number | undefined, + skipMissingVariables: boolean, options: RequestOptions = {}, ): Promise> { 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) @@ -212,6 +216,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}`, diff --git a/test/commands/campaigns.test.ts b/test/commands/campaigns.test.ts index 41c71447..4adae084 100644 --- a/test/commands/campaigns.test.ts +++ b/test/commands/campaigns.test.ts @@ -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}}) @@ -67,6 +68,7 @@ describe('Messaging campaign commands', () => { recipientListId: 'recipients_1', senderConfig: {session: 'session_1'}, templateId: 'template_1', + variableMapping: {firstName: {source: 'name', fallback: 'friend'}}, }) expect(server.requests[0]).toMatchObject({ method: 'POST', @@ -175,6 +177,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}}, From cbb7a8ebb649017589972137e729d90496af9e52 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Fri, 6 Nov 2026 05:18:59 +0200 Subject: [PATCH 2/2] Pin an SDK prerelease that types campaign variableMapping 0.1.0-dev.20261004024706 (the main pin) lacks Campaign.variableMapping and 0.1.0-dev.20261017182310 drops apiKeys and projectTokens, so pin 0.1.0-dev.20261010020302, the published build that has both. Its recipient list reason is a closed union without frequency_cap, so the CLI keeps its own string reason parameter. Refresh the CLI reference. --- contracts/cli-reference.json | 12 ++++++++++++ package-lock.json | 9 +++++---- package.json | 2 +- src/services/campaigns.ts | 4 +++- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/contracts/cli-reference.json b/contracts/cli-reference.json index 432e7abe..44206d6e 100644 --- a/contracts/cli-reference.json +++ b/contracts/cli-reference.json @@ -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, diff --git a/package-lock.json b/package-lock.json index ba9ccc6c..404e05c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,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" }, @@ -1022,9 +1022,10 @@ } }, "node_modules/@polymorfa/sdk": { - "version": "0.1.0-dev.20261004024706", - "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20261004024706.tgz", - "integrity": "sha512-lCgzm4tzkAoy+cl8oyMo+qLrujaBgVwJccqyAKRRpPbGnUVluG3BA7C6+7/wVkckcmo2b1csc+eCU/tPBngx0Q==", + "version": "0.1.0-dev.20261010020302", + "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20261010020302.tgz", + "integrity": "sha512-9ZA5NjZmRSvojsl+SJa90FoiuYcPitRho1EfOVif515Fr4TY5WH7X3Kx3s1mq9tEJMH7mZ8zCTCvIKP/mjT/+g==", + "license": "MIT", "engines": { "node": ">=20" } diff --git a/package.json b/package.json index a0f048b0..aa1f6f62 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/services/campaigns.ts b/src/services/campaigns.ts index 1987d437..2a5c0270 100644 --- a/src/services/campaigns.ts +++ b/src/services/campaigns.ts @@ -148,7 +148,9 @@ export async function listCampaignRecipients( client: MessagingClient, projectSlug: string, campaignId: string, - params: ListCampaignRecipientsParams & {readonly reason?: string}, + params: Omit & { + readonly reason?: string + }, ): Promise> { const response = await client.raw.request<{ data: readonly CampaignRecipient[]