From 6fca57b07b95821f42cb1777ae6b857e13ef1f7c Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 22 Oct 2026 04:26:33 +0000 Subject: [PATCH 01/11] feat(transactional-email): add a command that sets up the first email send `npx @posthog/wizard transactional-email` runs the context-mill skill omnibus-send-transactional-email: the setup agent checks for a verified sender, writes the project secret API key to the env file, and adds the first send with posthog-node or the HTTP API. The intro spell book of a plain run lists it, and the skill's two [ABORT] reasons end the run with a next step. Co-Authored-By: Claude Opus 5.5 --- README.md | 14 +++++ src/cli/__tests__/programs-cli.test.ts | 7 +++ src/cli/commands/transactional-email.ts | 9 ++++ src/cli/index.ts | 2 + .../__tests__/program-registry.test.ts | 1 + .../program-runner/program-registry.ts | 3 ++ .../__tests__/transactional-email.test.ts | 15 ++++++ src/programs/transactional-email/index.ts | 48 +++++++++++++++++ .../transactional-email/tsconfig.json | 4 ++ src/programs/tsconfig.json | 1 + .../__snapshots__/flow-traces.test.ts.snap | 53 +++++++++++++++++++ .../__tests__/intro-menu.test.ts | 1 + .../posthog-integration/intro-menu.ts | 1 + 13 files changed, 159 insertions(+) create mode 100644 src/cli/commands/transactional-email.ts create mode 100644 src/programs/transactional-email/__tests__/transactional-email.test.ts create mode 100644 src/programs/transactional-email/index.ts create mode 100644 src/programs/transactional-email/tsconfig.json diff --git a/README.md b/README.md index b9fff3902..61b94358f 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,20 @@ npx @posthog/wizard@latest revenue-analytics Requires PostHog and Stripe SDKs already installed. Supports `--ci` with the same flags as the main wizard. +### Transactional email + +Send your first transactional email from your server code, such as a welcome +email after signup: + +```bash +npx @posthog/wizard@latest transactional-email +``` + +The setup agent checks for a verified sender, asks for a project secret API key +and writes it to your env file, and adds the send with `posthog-node` or the +HTTP API. The transactional email API is in beta and must be enabled for your +project. + ### Data Warehouse Detect data sources your project already uses (Postgres, MySQL, MongoDB, diff --git a/src/cli/__tests__/programs-cli.test.ts b/src/cli/__tests__/programs-cli.test.ts index f90d72959..cbec1ba81 100644 --- a/src/cli/__tests__/programs-cli.test.ts +++ b/src/cli/__tests__/programs-cli.test.ts @@ -23,6 +23,7 @@ import { migrateCommand } from '../commands/migrate'; import { mcpAnalyticsCommand } from '../commands/mcp-analytics'; import { replayVisionCommand } from '../commands/replay-vision'; import { revenueCommand } from '../commands/revenue'; +import { transactionalEmailCommand } from '../commands/transactional-email'; import { warehouseCommand } from '../commands/warehouse'; import { uploadSourcemapsCommand } from '../commands/upload-sourcemaps'; import { selfDrivingCommand } from '../commands/self-driving'; @@ -202,6 +203,12 @@ describe('flat skill commands', () => { expect(config.skillId).toBe('revenue-analytics-setup'); }); + test('transactional-email dispatches with omnibus-send-transactional-email skillId', () => { + transactionalEmailCommand.handler!(makeArgv({ debug: true })); + const [config] = mockRunWizard.mock.calls[0] as [{ skillId?: string }]; + expect(config.skillId).toBe('omnibus-send-transactional-email'); + }); + test('mcp-analytics dispatches with mcp-analytics skillId', () => { mcpAnalyticsCommand.handler!(makeArgv({ debug: true })); const [config] = mockRunWizard.mock.calls[0] as [{ skillId?: string }]; diff --git a/src/cli/commands/transactional-email.ts b/src/cli/commands/transactional-email.ts new file mode 100644 index 000000000..11e374d96 --- /dev/null +++ b/src/cli/commands/transactional-email.ts @@ -0,0 +1,9 @@ +import { config as transactionalEmailConfig } from '@programs/transactional-email'; + +import type { Command } from './command'; +import { nativeCommandFactory } from './factories/native-command-factory'; + +/** `wizard transactional-email`: send the first transactional email from the project's server code. */ +export const transactionalEmailCommand: Command = nativeCommandFactory( + transactionalEmailConfig, +); diff --git a/src/cli/index.ts b/src/cli/index.ts index 4fc62d033..220548474 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -17,6 +17,7 @@ import { slackCommand } from './commands/slack'; import { uploadSourcemapsCommand } from './commands/upload-sourcemaps'; import { errorTrackingCommand } from './commands/error-tracking'; import { featureFlagsCommand } from './commands/feature-flags'; +import { transactionalEmailCommand } from './commands/transactional-email'; import { skillCommand } from './commands/skill'; /** Register every command and run the one `process.argv` names. */ @@ -38,6 +39,7 @@ export function runCli(): void { .use(uploadSourcemapsCommand) .use(errorTrackingCommand) .use(featureFlagsCommand) + .use(transactionalEmailCommand) .use(skillCommand) .init(); } diff --git a/src/programs/program-runner/__tests__/program-registry.test.ts b/src/programs/program-runner/__tests__/program-registry.test.ts index c72801147..57a2d4bde 100644 --- a/src/programs/program-runner/__tests__/program-registry.test.ts +++ b/src/programs/program-runner/__tests__/program-registry.test.ts @@ -90,6 +90,7 @@ describe('program bindings', () => { 'error-tracking': orchestratorOnPi, 'feature-flags': orchestratorOnPi, 'agent-skill': linearOnSol, + 'transactional-email': linearOnSol, }); }); }); diff --git a/src/programs/program-runner/program-registry.ts b/src/programs/program-runner/program-registry.ts index af3ce5f1b..a3cb66c72 100644 --- a/src/programs/program-runner/program-registry.ts +++ b/src/programs/program-runner/program-registry.ts @@ -25,6 +25,7 @@ import { config as sourceMaps } from '@programs/error-tracking-upload-source-map import { config as errorTracking } from '@programs/error-tracking'; import { config as featureFlags } from '@programs/feature-flags'; import { config as agentSkill } from '@programs/agent-skill'; +import { config as transactionalEmail } from '@programs/transactional-email'; const [audit, eventsAudit] = auditPrograms; @@ -45,6 +46,7 @@ export const PROGRAM_REGISTRY = [ replayVision, aiObservability, metrics, + transactionalEmail, ] as const satisfies readonly ProgramConfig[]; /** @@ -69,6 +71,7 @@ export const Program = { ReplayVision: replayVision.id, AiObservability: aiObservability.id, Metrics: metrics.id, + TransactionalEmail: transactionalEmail.id, } as const; /** diff --git a/src/programs/transactional-email/__tests__/transactional-email.test.ts b/src/programs/transactional-email/__tests__/transactional-email.test.ts new file mode 100644 index 000000000..857f5424f --- /dev/null +++ b/src/programs/transactional-email/__tests__/transactional-email.test.ts @@ -0,0 +1,15 @@ +import { TRANSACTIONAL_EMAIL_ABORT_CASES } from '@programs/transactional-email'; + +describe('TRANSACTIONAL_EMAIL_ABORT_CASES', () => { + // The exact `[ABORT] ` strings of the send-transactional-email skill's + // "Inside the PostHog Wizard" section (context-mill), prefix stripped. + it.each(['transactional email api not enabled', 'no server code found'])( + 'matches the "%s" abort reason exactly once', + (reason) => { + const matched = TRANSACTIONAL_EMAIL_ABORT_CASES.filter((c) => + c.match.test(reason), + ); + expect(matched).toHaveLength(1); + }, + ); +}); diff --git a/src/programs/transactional-email/index.ts b/src/programs/transactional-email/index.ts new file mode 100644 index 000000000..2d4be0095 --- /dev/null +++ b/src/programs/transactional-email/index.ts @@ -0,0 +1,48 @@ +import type { AbortCase } from '@agent/types'; +import { createSkillProgram } from '../shared/skill-program'; +import type { ProgramConfig } from '../program-step'; + +const SKILL_ID = 'omnibus-send-transactional-email'; +const REPORT_FILE = 'posthog-transactional-email-report.md'; + +/** The `[ABORT]` reasons of the skill's "Inside the PostHog Wizard" section (context-mill). */ +export const TRANSACTIONAL_EMAIL_ABORT_CASES: AbortCase[] = [ + { + match: /^transactional email api not enabled$/i, + message: "Transactional email isn't enabled for this project", + body: + 'The transactional email API is in beta, and PostHog turns it on per ' + + 'project. Ask PostHog in the app to enable it for your project, then ' + + 'run this command again.', + }, + { + match: /^no server code found$/i, + message: 'No server code found', + body: + 'A transactional email is sent from server code, because the send needs ' + + "a secret key. The setup agent didn't find server code in this project. " + + 'Run this command again from the project that runs your server.', + }, +]; + +/** `wizard transactional-email`: verify a sender, set the secret key, and write the first send from code. */ +export const config: ProgramConfig = createSkillProgram({ + skillId: SKILL_ID, + command: 'transactional-email', + id: 'transactional-email', + description: 'Send your first transactional email from code', + integrationLabel: 'transactional-email', + customPrompt: + `Set up transactional email in this project with the \`${SKILL_ID}\` ` + + 'skill: verify a sender, set the project secret API key, and write the ' + + 'first send. Follow the skill\'s "Inside the PostHog Wizard" section. ' + + `Write the final report to ./${REPORT_FILE}: the email the app sends, the ` + + 'files you changed, the environment variables every deployment needs, ' + + 'and what the person still has to do.', + successMessage: `Transactional email is set up. View the report at ./${REPORT_FILE}`, + reportFile: REPORT_FILE, + docsUrl: 'https://posthog.com/docs/workflows/transactional-email-api', + spinnerMessage: 'Setting up transactional email...', + estimatedDurationMinutes: 5, + abortCases: TRANSACTIONAL_EMAIL_ABORT_CASES, +}); diff --git a/src/programs/transactional-email/tsconfig.json b/src/programs/transactional-email/tsconfig.json new file mode 100644 index 000000000..79601a848 --- /dev/null +++ b/src/programs/transactional-email/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../tsconfig.no-tui.json", + "references": [{ "path": "../tsconfig.core.json" }] +} diff --git a/src/programs/tsconfig.json b/src/programs/tsconfig.json index 336303790..d9bc2c54c 100644 --- a/src/programs/tsconfig.json +++ b/src/programs/tsconfig.json @@ -9,6 +9,7 @@ { "path": "mcp-analytics" }, { "path": "metrics" }, { "path": "migration" }, { "path": "posthog-integration" }, { "path": "replay-vision" }, { "path": "revenue-analytics" }, { "path": "self-driving" }, + { "path": "transactional-email" }, { "path": "warehouse-source" }, { "path": "web-analytics-doctor" } ] } diff --git a/src/tui/__tests__/__snapshots__/flow-traces.test.ts.snap b/src/tui/__tests__/__snapshots__/flow-traces.test.ts.snap index bf151471a..f5ecfa962 100644 --- a/src/tui/__tests__/__snapshots__/flow-traces.test.ts.snap +++ b/src/tui/__tests__/__snapshots__/flow-traces.test.ts.snap @@ -1016,6 +1016,59 @@ exports[`flow traces per program > slack (node) 1`] = ` } `; +exports[`flow traces per program > transactional-email (node) 1`] = ` +{ + "events": [ + { + "event": "screen health-check", + "from": "agent-skill-intro", + "program": "transactional-email", + }, + { + "event": "screen auth", + "from": "health-check", + "program": "transactional-email", + }, + { + "event": "screen run", + "from": "auth", + "program": "transactional-email", + }, + { + "event": "screen ai-opt-in", + "from": "run", + "program": "transactional-email", + }, + { + "event": "screen run", + "from": "ai-opt-in", + "program": "transactional-email", + }, + { + "event": "screen outro", + "from": "run", + "program": "transactional-email", + }, + { + "event": "screen keep-skills", + "from": "outro", + "program": "transactional-email", + }, + ], + "program": "transactional-email", + "screens": [ + "agent-skill-intro", + "health-check", + "auth", + "ai-opt-in", + "run", + "outro", + "keep-skills", + ], + "stoppedOn": null, +} +`; + exports[`flow traces per program > warehouse-source (node) 1`] = ` { "events": [ diff --git a/src/tui/programs/posthog-integration/__tests__/intro-menu.test.ts b/src/tui/programs/posthog-integration/__tests__/intro-menu.test.ts index fd4674551..9aa8c162a 100644 --- a/src/tui/programs/posthog-integration/__tests__/intro-menu.test.ts +++ b/src/tui/programs/posthog-integration/__tests__/intro-menu.test.ts @@ -132,6 +132,7 @@ describe('introEntries', () => { 'ai-observability', 'metrics', 'revenue-analytics-setup', + 'transactional-email', ]); }); diff --git a/src/tui/programs/posthog-integration/intro-menu.ts b/src/tui/programs/posthog-integration/intro-menu.ts index 677670b54..3d7fff56e 100644 --- a/src/tui/programs/posthog-integration/intro-menu.ts +++ b/src/tui/programs/posthog-integration/intro-menu.ts @@ -31,6 +31,7 @@ const INTRO_ENTRIES = [ 'ai-observability', 'metrics', 'revenue-analytics-setup', + 'transactional-email', ]; /** One spell book row: the id the intro hands off to, the words that run it, and its help line. */ From 8e6f184e6f034590565b0dedb836c7081ae0a720 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:53:56 +0000 Subject: [PATCH 02/11] fix(transactional-email): follow main's program contract and skill cleanup Import `ProgramConfig` from `@programs/program-contract`, where the services refactor moved it, and take the outro's `exit` step from the skill cleanup in the flow trace. Co-Authored-By: Claude Opus 5.5 --- src/programs/transactional-email/index.ts | 2 +- src/tui/__tests__/__snapshots__/flow-traces.test.ts.snap | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/programs/transactional-email/index.ts b/src/programs/transactional-email/index.ts index 2d4be0095..0885ab97e 100644 --- a/src/programs/transactional-email/index.ts +++ b/src/programs/transactional-email/index.ts @@ -1,6 +1,6 @@ import type { AbortCase } from '@agent/types'; import { createSkillProgram } from '../shared/skill-program'; -import type { ProgramConfig } from '../program-step'; +import type { ProgramConfig } from '@programs/program-contract'; const SKILL_ID = 'omnibus-send-transactional-email'; const REPORT_FILE = 'posthog-transactional-email-report.md'; diff --git a/src/tui/__tests__/__snapshots__/flow-traces.test.ts.snap b/src/tui/__tests__/__snapshots__/flow-traces.test.ts.snap index f5ecfa962..c5010df0c 100644 --- a/src/tui/__tests__/__snapshots__/flow-traces.test.ts.snap +++ b/src/tui/__tests__/__snapshots__/flow-traces.test.ts.snap @@ -1050,7 +1050,7 @@ exports[`flow traces per program > transactional-email (node) 1`] = ` "program": "transactional-email", }, { - "event": "screen keep-skills", + "event": "screen exit", "from": "outro", "program": "transactional-email", }, @@ -1063,7 +1063,7 @@ exports[`flow traces per program > transactional-email (node) 1`] = ` "ai-opt-in", "run", "outro", - "keep-skills", + "exit", ], "stoppedOn": null, } From 6654b67502c0a92c57e38755254fca5a9f623d75 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sat, 31 Oct 2026 10:10:10 +0000 Subject: [PATCH 03/11] feat(analytics): say which provider a migrate-email run moves from A run of a migrate-email- skill sets migrated_from, so the setup wizard finished event names the provider. Co-Authored-By: Claude Opus 5.5 --- .../session/__tests__/migrated-from.test.ts | 31 +++++++++++++++++++ src/services/session/session-store.ts | 8 +++++ 2 files changed, 39 insertions(+) create mode 100644 src/services/session/__tests__/migrated-from.test.ts diff --git a/src/services/session/__tests__/migrated-from.test.ts b/src/services/session/__tests__/migrated-from.test.ts new file mode 100644 index 000000000..76356fd0a --- /dev/null +++ b/src/services/session/__tests__/migrated-from.test.ts @@ -0,0 +1,31 @@ +import { analytics } from '@utils/analytics'; +import { SessionStore } from '../session-store'; +import { buildSession } from '../wizard-session'; + +vi.mock('@utils/analytics.js', () => ({ + analytics: { setTag: vi.fn() }, + sessionProperties: vi.fn(() => ({})), +})); + +function runSkill(skillId: string) { + new SessionStore(buildSession({})).setSkillId(skillId); +} + +describe('migrated_from', () => { + beforeEach(() => vi.mocked(analytics.setTag).mockClear()); + + it('tags a run of an email migration skill with the provider it moves from', () => { + runSkill('migrate-email-resend'); + + expect(analytics.setTag).toHaveBeenCalledWith('migrated_from', 'resend'); + }); + + it('leaves a run of any other skill without the tag', () => { + runSkill('migrate-statsig'); + + expect(analytics.setTag).not.toHaveBeenCalledWith( + 'migrated_from', + expect.anything(), + ); + }); +}); diff --git a/src/services/session/session-store.ts b/src/services/session/session-store.ts index cd5f66322..f44676a9c 100644 --- a/src/services/session/session-store.ts +++ b/src/services/session/session-store.ts @@ -296,6 +296,8 @@ export class SessionStore { setSkillId(skillId: string | null): void { this.set('skillId', skillId); + const migratedFrom = emailProviderMigratedFrom(skillId); + if (migratedFrom) analytics.setTag('migrated_from', migratedFrom); } setUnsupportedVersion( @@ -633,3 +635,9 @@ export function applyAgentProgress( } } } + +const EMAIL_MIGRATION_SKILL = /^migrate-email-(.+)$/; + +function emailProviderMigratedFrom(skillId: string | null): string | null { + return skillId?.match(EMAIL_MIGRATION_SKILL)?.[1] ?? null; +} From 1c077af6280fddf3e490de5934d4f11a04bf06e9 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sat, 31 Oct 2026 10:10:15 +0000 Subject: [PATCH 04/11] fix(transactional-email): link the transactional emails docs Co-Authored-By: Claude Opus 5.5 --- src/programs/transactional-email/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/programs/transactional-email/index.ts b/src/programs/transactional-email/index.ts index 0885ab97e..3b6c464b7 100644 --- a/src/programs/transactional-email/index.ts +++ b/src/programs/transactional-email/index.ts @@ -41,7 +41,7 @@ export const config: ProgramConfig = createSkillProgram({ 'and what the person still has to do.', successMessage: `Transactional email is set up. View the report at ./${REPORT_FILE}`, reportFile: REPORT_FILE, - docsUrl: 'https://posthog.com/docs/workflows/transactional-email-api', + docsUrl: 'https://posthog.com/docs/workflows/transactional-emails', spinnerMessage: 'Setting up transactional email...', estimatedDurationMinutes: 5, abortCases: TRANSACTIONAL_EMAIL_ABORT_CASES, From a90f038b72b4b61cee4e2be37871876287a3d6be Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sun, 1 Nov 2026 13:03:59 +0000 Subject: [PATCH 05/11] fix(analytics): drop migrated_from when a run moves on from an email migration The tag stayed on the process after the skill changed, so a later skill's events still said the run migrated from the email provider. Every skill change now sets or clears it. Co-Authored-By: Claude Opus 5.5 --- .../session/__tests__/migrated-from.test.ts | 38 +++++++++++++------ src/services/session/session-store.ts | 6 ++- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/src/services/session/__tests__/migrated-from.test.ts b/src/services/session/__tests__/migrated-from.test.ts index 76356fd0a..52482771b 100644 --- a/src/services/session/__tests__/migrated-from.test.ts +++ b/src/services/session/__tests__/migrated-from.test.ts @@ -1,31 +1,45 @@ -import { analytics } from '@utils/analytics'; import { SessionStore } from '../session-store'; import { buildSession } from '../wizard-session'; +const tags = vi.hoisted(() => ({} as Record)); + vi.mock('@utils/analytics.js', () => ({ - analytics: { setTag: vi.fn() }, + analytics: { + setTag: vi.fn((key: string, value: unknown) => { + tags[key] = value; + }), + }, sessionProperties: vi.fn(() => ({})), })); -function runSkill(skillId: string) { - new SessionStore(buildSession({})).setSkillId(skillId); +function runSkills(...skillIds: (string | null)[]) { + const store = new SessionStore(buildSession({})); + for (const skillId of skillIds) store.setSkillId(skillId); } describe('migrated_from', () => { - beforeEach(() => vi.mocked(analytics.setTag).mockClear()); + beforeEach(() => { + for (const key of Object.keys(tags)) delete tags[key]; + }); it('tags a run of an email migration skill with the provider it moves from', () => { - runSkill('migrate-email-resend'); + runSkills('migrate-email-resend'); - expect(analytics.setTag).toHaveBeenCalledWith('migrated_from', 'resend'); + expect(tags.migrated_from).toBe('resend'); }); it('leaves a run of any other skill without the tag', () => { - runSkill('migrate-statsig'); + runSkills('migrate-statsig'); - expect(analytics.setTag).not.toHaveBeenCalledWith( - 'migrated_from', - expect.anything(), - ); + expect(tags.migrated_from).toBeUndefined(); }); + + it.each([['migrate-statsig'], [null]])( + 'drops the tag when the run moves on from an email migration to %s', + (nextSkillId) => { + runSkills('migrate-email-resend', nextSkillId); + + expect(tags.migrated_from).toBeUndefined(); + }, + ); }); diff --git a/src/services/session/session-store.ts b/src/services/session/session-store.ts index f44676a9c..6a5a1c61c 100644 --- a/src/services/session/session-store.ts +++ b/src/services/session/session-store.ts @@ -296,8 +296,10 @@ export class SessionStore { setSkillId(skillId: string | null): void { this.set('skillId', skillId); - const migratedFrom = emailProviderMigratedFrom(skillId); - if (migratedFrom) analytics.setTag('migrated_from', migratedFrom); + analytics.setTag( + 'migrated_from', + emailProviderMigratedFrom(skillId) ?? undefined, + ); } setUnsupportedVersion( From 6da6fc9d1e7f912d017fb368e154344a7c5ea2d9 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:41:59 +0000 Subject: [PATCH 06/11] fix(transactional-email): link the docs from both abort outros Both abort cases carry the transactional emails docs link, so the error outro of a matched abort shows where to go next. The test reads the cases through the program's run config, so dropping them from the config fails it. The program sets its binding explicitly, and the added comments go. Co-Authored-By: Claude Opus 5.5 --- src/cli/commands/transactional-email.ts | 1 - .../__tests__/transactional-email.test.ts | 25 ++++++--- src/programs/transactional-email/index.ts | 51 ++++++++++--------- 3 files changed, 45 insertions(+), 32 deletions(-) diff --git a/src/cli/commands/transactional-email.ts b/src/cli/commands/transactional-email.ts index 11e374d96..8f4af1f96 100644 --- a/src/cli/commands/transactional-email.ts +++ b/src/cli/commands/transactional-email.ts @@ -3,7 +3,6 @@ import { config as transactionalEmailConfig } from '@programs/transactional-emai import type { Command } from './command'; import { nativeCommandFactory } from './factories/native-command-factory'; -/** `wizard transactional-email`: send the first transactional email from the project's server code. */ export const transactionalEmailCommand: Command = nativeCommandFactory( transactionalEmailConfig, ); diff --git a/src/programs/transactional-email/__tests__/transactional-email.test.ts b/src/programs/transactional-email/__tests__/transactional-email.test.ts index 857f5424f..2f1e57d95 100644 --- a/src/programs/transactional-email/__tests__/transactional-email.test.ts +++ b/src/programs/transactional-email/__tests__/transactional-email.test.ts @@ -1,15 +1,24 @@ -import { TRANSACTIONAL_EMAIL_ABORT_CASES } from '@programs/transactional-email'; +import { config } from '@programs/transactional-email'; -describe('TRANSACTIONAL_EMAIL_ABORT_CASES', () => { - // The exact `[ABORT] ` strings of the send-transactional-email skill's - // "Inside the PostHog Wizard" section (context-mill), prefix stripped. +const DOCS_URL = 'https://posthog.com/docs/workflows/transactional-emails'; + +function abortCases() { + const run = config.run; + if (!run || typeof run === 'function') { + throw new Error('expected a static run object'); + } + return run.abortCases ?? []; +} + +describe('transactional-email abort cases', () => { it.each(['transactional email api not enabled', 'no server code found'])( - 'matches the "%s" abort reason exactly once', + 'shows the "%s" abort with its own message and the transactional email docs', (reason) => { - const matched = TRANSACTIONAL_EMAIL_ABORT_CASES.filter((c) => - c.match.test(reason), - ); + const matched = abortCases().filter((c) => c.match.test(reason)); + expect(matched).toHaveLength(1); + expect(matched[0].message).toBeTruthy(); + expect(matched[0].docsUrl).toBe(DOCS_URL); }, ); }); diff --git a/src/programs/transactional-email/index.ts b/src/programs/transactional-email/index.ts index 3b6c464b7..2e0705700 100644 --- a/src/programs/transactional-email/index.ts +++ b/src/programs/transactional-email/index.ts @@ -1,12 +1,13 @@ +import { DEFAULT_BINDING } from '@agent'; import type { AbortCase } from '@agent/types'; import { createSkillProgram } from '../shared/skill-program'; import type { ProgramConfig } from '@programs/program-contract'; const SKILL_ID = 'omnibus-send-transactional-email'; const REPORT_FILE = 'posthog-transactional-email-report.md'; +const DOCS_URL = 'https://posthog.com/docs/workflows/transactional-emails'; -/** The `[ABORT]` reasons of the skill's "Inside the PostHog Wizard" section (context-mill). */ -export const TRANSACTIONAL_EMAIL_ABORT_CASES: AbortCase[] = [ +const TRANSACTIONAL_EMAIL_ABORT_CASES: AbortCase[] = [ { match: /^transactional email api not enabled$/i, message: "Transactional email isn't enabled for this project", @@ -14,6 +15,7 @@ export const TRANSACTIONAL_EMAIL_ABORT_CASES: AbortCase[] = [ 'The transactional email API is in beta, and PostHog turns it on per ' + 'project. Ask PostHog in the app to enable it for your project, then ' + 'run this command again.', + docsUrl: DOCS_URL, }, { match: /^no server code found$/i, @@ -22,27 +24,30 @@ export const TRANSACTIONAL_EMAIL_ABORT_CASES: AbortCase[] = [ 'A transactional email is sent from server code, because the send needs ' + "a secret key. The setup agent didn't find server code in this project. " + 'Run this command again from the project that runs your server.', + docsUrl: DOCS_URL, }, ]; -/** `wizard transactional-email`: verify a sender, set the secret key, and write the first send from code. */ -export const config: ProgramConfig = createSkillProgram({ - skillId: SKILL_ID, - command: 'transactional-email', - id: 'transactional-email', - description: 'Send your first transactional email from code', - integrationLabel: 'transactional-email', - customPrompt: - `Set up transactional email in this project with the \`${SKILL_ID}\` ` + - 'skill: verify a sender, set the project secret API key, and write the ' + - 'first send. Follow the skill\'s "Inside the PostHog Wizard" section. ' + - `Write the final report to ./${REPORT_FILE}: the email the app sends, the ` + - 'files you changed, the environment variables every deployment needs, ' + - 'and what the person still has to do.', - successMessage: `Transactional email is set up. View the report at ./${REPORT_FILE}`, - reportFile: REPORT_FILE, - docsUrl: 'https://posthog.com/docs/workflows/transactional-emails', - spinnerMessage: 'Setting up transactional email...', - estimatedDurationMinutes: 5, - abortCases: TRANSACTIONAL_EMAIL_ABORT_CASES, -}); +export const config: ProgramConfig = { + ...createSkillProgram({ + skillId: SKILL_ID, + command: 'transactional-email', + id: 'transactional-email', + description: 'Send your first transactional email from code', + integrationLabel: 'transactional-email', + customPrompt: + `Set up transactional email in this project with the \`${SKILL_ID}\` ` + + 'skill: verify a sender, set the project secret API key, and write the ' + + 'first send. Follow the skill\'s "Inside the PostHog Wizard" section. ' + + `Write the final report to ./${REPORT_FILE}: the email the app sends, the ` + + 'files you changed, the environment variables every deployment needs, ' + + 'and what the person still has to do.', + successMessage: `Transactional email is set up. View the report at ./${REPORT_FILE}`, + reportFile: REPORT_FILE, + docsUrl: DOCS_URL, + spinnerMessage: 'Setting up transactional email...', + estimatedDurationMinutes: 5, + abortCases: TRANSACTIONAL_EMAIL_ABORT_CASES, + }), + binding: DEFAULT_BINDING, +}; From df5cfbd3648af527b70dafbbd08ffbeda97ee975 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:50:21 +0000 Subject: [PATCH 07/11] refactor(transactional-email): spell out the program's binding The program names Pi, the linear sequence and its model, so a change to the default binding no longer moves it off the sequence its abort cases need. The abort test drops a check the type already makes. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/transactional-email.test.ts | 3 +-- src/programs/transactional-email/index.ts | 9 +++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/programs/transactional-email/__tests__/transactional-email.test.ts b/src/programs/transactional-email/__tests__/transactional-email.test.ts index 2f1e57d95..4af9af3fc 100644 --- a/src/programs/transactional-email/__tests__/transactional-email.test.ts +++ b/src/programs/transactional-email/__tests__/transactional-email.test.ts @@ -12,12 +12,11 @@ function abortCases() { describe('transactional-email abort cases', () => { it.each(['transactional email api not enabled', 'no server code found'])( - 'shows the "%s" abort with its own message and the transactional email docs', + 'links the transactional email docs from the "%s" abort', (reason) => { const matched = abortCases().filter((c) => c.match.test(reason)); expect(matched).toHaveLength(1); - expect(matched[0].message).toBeTruthy(); expect(matched[0].docsUrl).toBe(DOCS_URL); }, ); diff --git a/src/programs/transactional-email/index.ts b/src/programs/transactional-email/index.ts index 2e0705700..1064c6cd6 100644 --- a/src/programs/transactional-email/index.ts +++ b/src/programs/transactional-email/index.ts @@ -1,4 +1,4 @@ -import { DEFAULT_BINDING } from '@agent'; +import { GPT5_6_SOL_MODEL, Harness, Sequence } from '@shared/constants'; import type { AbortCase } from '@agent/types'; import { createSkillProgram } from '../shared/skill-program'; import type { ProgramConfig } from '@programs/program-contract'; @@ -49,5 +49,10 @@ export const config: ProgramConfig = { estimatedDurationMinutes: 5, abortCases: TRANSACTIONAL_EMAIL_ABORT_CASES, }), - binding: DEFAULT_BINDING, + binding: { + sequence: Sequence.linear, + harness: Harness.pi, + model: GPT5_6_SOL_MODEL, + thinkingLevel: 'medium', + }, }; From 5ceda4a8cce086f13603e5cfbb6da4993ee3dd35 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:06:38 +0000 Subject: [PATCH 08/11] fix(transactional-email): run the sending-transactional-emails skill The program ran omnibus-send-transactional-email, which no menu publishes. Run sending-transactional-emails, the PostHog/posthog skill that PostHog/context-mill now publishes, and ask for what it sets up: a transactional template in PostHog and its template ID in the report. Co-Authored-By: Claude Opus 5.5 --- README.md | 8 ++++---- src/cli/__tests__/programs-cli.test.ts | 4 ++-- src/programs/transactional-email/index.ts | 13 +++++++------ 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 61b94358f..1af3074fd 100644 --- a/README.md +++ b/README.md @@ -131,10 +131,10 @@ email after signup: npx @posthog/wizard@latest transactional-email ``` -The setup agent checks for a verified sender, asks for a project secret API key -and writes it to your env file, and adds the send with `posthog-node` or the -HTTP API. The transactional email API is in beta and must be enabled for your -project. +The setup agent checks for a verified sender and a transactional template in +PostHog, asks for a project secret API key and writes it to your env file, and +adds the send with `posthog-node`, the `posthog` Python SDK, or the HTTP API. +Transactional emails are in beta and must be enabled for your project. ### Data Warehouse diff --git a/src/cli/__tests__/programs-cli.test.ts b/src/cli/__tests__/programs-cli.test.ts index cbec1ba81..1bbef8ba3 100644 --- a/src/cli/__tests__/programs-cli.test.ts +++ b/src/cli/__tests__/programs-cli.test.ts @@ -203,10 +203,10 @@ describe('flat skill commands', () => { expect(config.skillId).toBe('revenue-analytics-setup'); }); - test('transactional-email dispatches with omnibus-send-transactional-email skillId', () => { + test('transactional-email dispatches with sending-transactional-emails skillId', () => { transactionalEmailCommand.handler!(makeArgv({ debug: true })); const [config] = mockRunWizard.mock.calls[0] as [{ skillId?: string }]; - expect(config.skillId).toBe('omnibus-send-transactional-email'); + expect(config.skillId).toBe('sending-transactional-emails'); }); test('mcp-analytics dispatches with mcp-analytics skillId', () => { diff --git a/src/programs/transactional-email/index.ts b/src/programs/transactional-email/index.ts index 1064c6cd6..9caa2dcd6 100644 --- a/src/programs/transactional-email/index.ts +++ b/src/programs/transactional-email/index.ts @@ -3,7 +3,7 @@ import type { AbortCase } from '@agent/types'; import { createSkillProgram } from '../shared/skill-program'; import type { ProgramConfig } from '@programs/program-contract'; -const SKILL_ID = 'omnibus-send-transactional-email'; +const SKILL_ID = 'sending-transactional-emails'; const REPORT_FILE = 'posthog-transactional-email-report.md'; const DOCS_URL = 'https://posthog.com/docs/workflows/transactional-emails'; @@ -37,11 +37,12 @@ export const config: ProgramConfig = { integrationLabel: 'transactional-email', customPrompt: `Set up transactional email in this project with the \`${SKILL_ID}\` ` + - 'skill: verify a sender, set the project secret API key, and write the ' + - 'first send. Follow the skill\'s "Inside the PostHog Wizard" section. ' + - `Write the final report to ./${REPORT_FILE}: the email the app sends, the ` + - 'files you changed, the environment variables every deployment needs, ' + - 'and what the person still has to do.', + 'skill: a verified sender, a transactional template, the project secret ' + + 'API key, and the first send. Follow the skill\'s "Inside the PostHog ' + + `Wizard" section. Write the final report to ./${REPORT_FILE}: the email ` + + 'the app sends and its template ID, the files you changed, the ' + + 'environment variables every deployment needs, and what the person ' + + 'still has to do.', successMessage: `Transactional email is set up. View the report at ./${REPORT_FILE}`, reportFile: REPORT_FILE, docsUrl: DOCS_URL, From cc90dfc456b4c4efb0e33d3b99e3b2fab8c19c1a Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:19:11 +0000 Subject: [PATCH 09/11] feat(transactional-email): keep the email in code by default The setup prompt asked the agent for a transactional template. The sending-transactional-emails skill now keeps the email in the app's code by default and uses a PostHog template only when the person picks it, so the prompt and README say the same. The report names where the email's content lives and its template key or ID. Co-Authored-By: Claude Opus 5.5 --- README.md | 8 +++++--- src/programs/transactional-email/index.ts | 6 ++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 1af3074fd..c2da12da4 100644 --- a/README.md +++ b/README.md @@ -131,9 +131,11 @@ email after signup: npx @posthog/wizard@latest transactional-email ``` -The setup agent checks for a verified sender and a transactional template in -PostHog, asks for a project secret API key and writes it to your env file, and -adds the send with `posthog-node`, the `posthog` Python SDK, or the HTTP API. +The setup agent checks for a verified sender, writes the email in your code by +default (or as a PostHog template, if you pick that so people who don't edit +code can change the copy), asks for a project secret API key and writes it to +your env file, and adds the send with `posthog-node`, the `posthog` Python SDK, +or the HTTP API. Transactional emails are in beta and must be enabled for your project. ### Data Warehouse diff --git a/src/programs/transactional-email/index.ts b/src/programs/transactional-email/index.ts index 9caa2dcd6..a7d7257c5 100644 --- a/src/programs/transactional-email/index.ts +++ b/src/programs/transactional-email/index.ts @@ -37,10 +37,12 @@ export const config: ProgramConfig = { integrationLabel: 'transactional-email', customPrompt: `Set up transactional email in this project with the \`${SKILL_ID}\` ` + - 'skill: a verified sender, a transactional template, the project secret ' + + "skill: a verified sender, the email in the app's code by default or " + + 'as a PostHog template when the person picks that, the project secret ' + 'API key, and the first send. Follow the skill\'s "Inside the PostHog ' + `Wizard" section. Write the final report to ./${REPORT_FILE}: the email ` + - 'the app sends and its template ID, the files you changed, the ' + + 'the app sends, where its content lives and its template key or ID, ' + + 'the files you changed, the ' + 'environment variables every deployment needs, and what the person ' + 'still has to do.', successMessage: `Transactional email is set up. View the report at ./${REPORT_FILE}`, From f95c37b15034dbc4aa491e2102b1e4c39f7d70e3 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:31:28 +0000 Subject: [PATCH 10/11] refactor(transactional-email): leave the code-or-template choice to the skill The prompt restated the skill's choice between code and a PostHog template. It now lists only what to set up, so the skill alone owns that choice and its default. Co-Authored-By: Claude Opus 5.5 --- src/programs/transactional-email/index.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/programs/transactional-email/index.ts b/src/programs/transactional-email/index.ts index a7d7257c5..c8e7db22f 100644 --- a/src/programs/transactional-email/index.ts +++ b/src/programs/transactional-email/index.ts @@ -37,9 +37,8 @@ export const config: ProgramConfig = { integrationLabel: 'transactional-email', customPrompt: `Set up transactional email in this project with the \`${SKILL_ID}\` ` + - "skill: a verified sender, the email in the app's code by default or " + - 'as a PostHog template when the person picks that, the project secret ' + - 'API key, and the first send. Follow the skill\'s "Inside the PostHog ' + + 'skill: a verified sender, the email, the project secret API key, and ' + + 'the first send. Follow the skill\'s "Inside the PostHog ' + `Wizard" section. Write the final report to ./${REPORT_FILE}: the email ` + 'the app sends, where its content lives and its template key or ID, ' + 'the files you changed, the ' + From 8cb8ad7ea46795b99c4fb0eb28d90389be464a1d Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 5 Nov 2026 09:43:34 +0000 Subject: [PATCH 11/11] docs(transactional-email): name every backend's SDK in the README The skill sends with PostHog's SDK for the backend's language, eight in all, not only Node.js and Python. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c2da12da4..83906d779 100644 --- a/README.md +++ b/README.md @@ -134,8 +134,8 @@ npx @posthog/wizard@latest transactional-email The setup agent checks for a verified sender, writes the email in your code by default (or as a PostHog template, if you pick that so people who don't edit code can change the copy), asks for a project secret API key and writes it to -your env file, and adds the send with `posthog-node`, the `posthog` Python SDK, -or the HTTP API. +your env file, and adds the send with PostHog's SDK for your backend's +language, or the HTTP API. Transactional emails are in beta and must be enabled for your project. ### Data Warehouse