diff --git a/README.md b/README.md index b9fff3902..83906d779 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,22 @@ 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, 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'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 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..1bbef8ba3 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 sending-transactional-emails skillId', () => { + transactionalEmailCommand.handler!(makeArgv({ debug: true })); + const [config] = mockRunWizard.mock.calls[0] as [{ skillId?: string }]; + expect(config.skillId).toBe('sending-transactional-emails'); + }); + 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..8f4af1f96 --- /dev/null +++ b/src/cli/commands/transactional-email.ts @@ -0,0 +1,8 @@ +import { config as transactionalEmailConfig } from '@programs/transactional-email'; + +import type { Command } from './command'; +import { nativeCommandFactory } from './factories/native-command-factory'; + +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..4af9af3fc --- /dev/null +++ b/src/programs/transactional-email/__tests__/transactional-email.test.ts @@ -0,0 +1,23 @@ +import { config } from '@programs/transactional-email'; + +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'])( + '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].docsUrl).toBe(DOCS_URL); + }, + ); +}); diff --git a/src/programs/transactional-email/index.ts b/src/programs/transactional-email/index.ts new file mode 100644 index 000000000..c8e7db22f --- /dev/null +++ b/src/programs/transactional-email/index.ts @@ -0,0 +1,60 @@ +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'; + +const SKILL_ID = 'sending-transactional-emails'; +const REPORT_FILE = 'posthog-transactional-email-report.md'; +const DOCS_URL = 'https://posthog.com/docs/workflows/transactional-emails'; + +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.', + docsUrl: DOCS_URL, + }, + { + 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.', + docsUrl: DOCS_URL, + }, +]; + +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: 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 ' + + '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: { + sequence: Sequence.linear, + harness: Harness.pi, + model: GPT5_6_SOL_MODEL, + thinkingLevel: 'medium', + }, +}; 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/services/session/__tests__/migrated-from.test.ts b/src/services/session/__tests__/migrated-from.test.ts new file mode 100644 index 000000000..52482771b --- /dev/null +++ b/src/services/session/__tests__/migrated-from.test.ts @@ -0,0 +1,45 @@ +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((key: string, value: unknown) => { + tags[key] = value; + }), + }, + sessionProperties: vi.fn(() => ({})), +})); + +function runSkills(...skillIds: (string | null)[]) { + const store = new SessionStore(buildSession({})); + for (const skillId of skillIds) store.setSkillId(skillId); +} + +describe('migrated_from', () => { + 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', () => { + runSkills('migrate-email-resend'); + + expect(tags.migrated_from).toBe('resend'); + }); + + it('leaves a run of any other skill without the tag', () => { + runSkills('migrate-statsig'); + + 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 cd5f66322..6a5a1c61c 100644 --- a/src/services/session/session-store.ts +++ b/src/services/session/session-store.ts @@ -296,6 +296,10 @@ export class SessionStore { setSkillId(skillId: string | null): void { this.set('skillId', skillId); + analytics.setTag( + 'migrated_from', + emailProviderMigratedFrom(skillId) ?? undefined, + ); } setUnsupportedVersion( @@ -633,3 +637,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; +} diff --git a/src/tui/__tests__/__snapshots__/flow-traces.test.ts.snap b/src/tui/__tests__/__snapshots__/flow-traces.test.ts.snap index bf151471a..c5010df0c 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 exit", + "from": "outro", + "program": "transactional-email", + }, + ], + "program": "transactional-email", + "screens": [ + "agent-skill-intro", + "health-check", + "auth", + "ai-opt-in", + "run", + "outro", + "exit", + ], + "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. */