From fa8aeb187b27a885684b74f841f241f0dab50017 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:02:06 +0000 Subject: [PATCH 01/23] feat(skills): add send-transactional-email skill Teaches a coding agent to send one transactional email from server code with PostHog: verify a sender through the PostHog MCP first, keep the template as an HTML file in the repository, upload it by key, and send it with posthog-node or the HTTP API. Ships the transactional email API docs page as its reference. Co-Authored-By: Claude Opus 5.5 --- .../send-transactional-email/config.yaml | 12 ++ .../send-transactional-email/description.md | 116 ++++++++++++++++++ .../send-transactional-email-skill.test.js | 57 +++++++++ 3 files changed, 185 insertions(+) create mode 100644 context/skills/omnibus/send-transactional-email/config.yaml create mode 100644 context/skills/omnibus/send-transactional-email/description.md create mode 100644 scripts/lib/tests/send-transactional-email-skill.test.js diff --git a/context/skills/omnibus/send-transactional-email/config.yaml b/context/skills/omnibus/send-transactional-email/config.yaml new file mode 100644 index 00000000..9c30fee1 --- /dev/null +++ b/context/skills/omnibus/send-transactional-email/config.yaml @@ -0,0 +1,12 @@ +# Transactional email skill - one email to one person, from a template file in the repository +type: skill +template: description.md +category: workflows +description: Send transactional email from code with PostHog, such as a welcome email, a password reset, a signup confirmation, or a receipt. Use when asked to send an email from a Node.js app or other server code. It verifies a sender through the PostHog MCP, writes the email as an HTML template file in the repository, uploads it by key, and writes the send with posthog-node or the HTTP API. +tags: [workflows] +shared_docs: + - https://posthog.com/docs/workflows/transactional-email-api.md +variants: + - id: all + display_name: all supported platforms + tags: [] diff --git a/context/skills/omnibus/send-transactional-email/description.md b/context/skills/omnibus/send-transactional-email/description.md new file mode 100644 index 00000000..84d76337 --- /dev/null +++ b/context/skills/omnibus/send-transactional-email/description.md @@ -0,0 +1,116 @@ +# Send transactional email with PostHog + +Use this skill to send one email to one person from server code, such as a welcome email, a password reset, or a signup confirmation. The email lives in the repository as an HTML template file. PostHog stores it as a message template under a key, such as `welcome`, and sends it through Workflows. + +The transactional email API is in beta. PostHog enables it per project. + +## Instructions + +Follow these steps IN ORDER. Do not upload a template before STEP 3 is done: the first upload needs a verified default sender. + +STEP 1: Detect the platform. + - Find the server code that should send the email, such as the signup handler for a welcome email. + - Node.js (including Next.js route handlers and server actions) uses `posthog-node`. Any other server language uses the HTTP API. + - Never send from browser or mobile code. The send needs a secret key, and the key stays on the server. + - Look for lockfiles (pnpm-lock.yaml, package-lock.json, yarn.lock, bun.lockb) to pick the package manager. + +STEP 2: Check for a verified sender through the PostHog MCP. + - Call `integrations-list` with `kind=email`. A sender with `config.verified: true` can send. When the list shows senders without `config`, ask the person whether one is verified, or let the upload in STEP 5 tell you: `sender_unavailable` means there is no verified default sender. + - The first sender that verifies becomes the project's default sender, and the first upload uses the default sender. + - When a verified sender exists, go to STEP 4. + +STEP 3: Set up a sender when none is verified. + - First check that the PostHog MCP has the sender tools: `integrations-email-create`, `integrations-email-verify-create`, `integrations-domain-connect-check-retrieve`, and `integrations-domain-connect-apply-url-create`. When they are missing, go to the last bullet of this step. + - When the `setting-up-an-email-domain` skill is available, follow it. It covers the details. + - Ask which domain to send from. Recommend a subdomain such as `mail.example.com`. Use the person's own domain, never a free mailbox domain such as gmail.com. + - Create the sender, then get its DNS records with `integrations-email-verify-create`. + - PostHog never writes DNS. When the domain supports Domain Connect, give the person the URL from `integrations-domain-connect-apply-url-create`: they approve the records at their DNS host. Otherwise, give them the records as a table with type, name, value, and priority. + - Call `integrations-email-verify-create` again after the records are published, at least a minute apart, until `status` is `success`. + - **When the sender tools are missing or refused**, the agent setup for sender domains is not available to this person yet. Do not try other integration tools. Ask the person to verify a sender in PostHog: Workflows, then Channels, then add an email sender on their domain and follow the DNS steps (https://posthog.com/docs/workflows/configure-channels). Wait until they confirm that the sender shows as verified, then continue. + +STEP 4: Write the template as an HTML file in the repository. + - Put it next to the server code, for example `emails/welcome.html`. The file name without `.html` is the key, so use letters, digits, `-`, and `_` only. + - The `` is the subject. `<meta name="preheader" content="...">` is the optional inbox preview text. + - Use Liquid with `{{ variables.<name> }}` for every value the email shows, such as `{{ variables.first_name }}` or `{{ variables.reset_url }}`. + - Never use `{{ person.* }}`. A send doesn't load person properties, so those placeholders render empty. + +STEP 5: Upload the template with the PostHog MCP. + - Call `workflows-upload-email-template` with the key, a `name` such as "Welcome", the `<title>` text as `subject`, the `<meta name="preheader">` content as `preheader` (if any), and the whole file as `html`. + - Pass `source` with `repository` (such as `github.com/acme/app`), `path` (the file path in the repository), and `ref` (the current commit), so the person sees the file in PostHog next to the template. + - Expect `result: "created"` on the first upload, and `updated` or `unchanged` later. The template is now managed by code: edits happen in the file, not in PostHog. + - On `sender_unavailable`, the project has no verified default sender. Ask the person to click **Make default** next to their verified sender in the email channel settings, then upload again. + +STEP 6: Stop when the API is not enabled for the project. + - When the PostHog MCP has no `workflows-upload-email-template` or `workflows-send-email` tool, or an upload or send answers a plain `404` with the code `not_found`, the transactional email API is not enabled for this project. + - Stop and tell the person: the API is in beta, and they can ask PostHog in-app to enable it for their project. Do not retry, and do not work around it with other tools. + +STEP 7: Have the person create a project secret API key. + - Ask the person to create a project secret API key (it starts with `phs_`) with the `hog_flow:write` scope in their PostHog project settings. + - Read it from the `POSTHOG_PROJECT_SECRET_API_KEY` environment variable. Add the variable name to the project's env example file. Never hardcode the key, never commit it, and never ask the person to paste it into the chat. + +STEP 8: Write the send. + **Node.js:** install or upgrade `posthog-node` to a version that has `posthog.emails`. Reference the file once when the module loads, then send it: + + ```js + import { PostHog } from 'posthog-node' + + const posthog = new PostHog(process.env.POSTHOG_PROJECT_TOKEN, { + host: process.env.POSTHOG_HOST, + secretKey: process.env.POSTHOG_PROJECT_SECRET_API_KEY, + enableLocalEvaluation: false, + }) + const welcomeEmail = posthog.emails.templateFile(new URL('./emails/welcome.html', import.meta.url)) + + const { data, error } = await posthog.emails.send({ + template: welcomeEmail, + to: user.email, + data: { first_name: user.firstName }, + distinctId: user.id, + }) + ``` + + - Reuse the app's existing `posthog-node` client when it has one, and add `secretKey`. Keep `enableLocalEvaluation: false` unless the key also has `feature_flag:read` and the app uses local evaluation. + - When the project token or the host is not configured yet, get the token from the PostHog MCP `projects-get` tool (`api_token`). The host is `https://us.i.posthog.com` for US Cloud and `https://eu.i.posthog.com` for EU Cloud. Ask the person which one when you can't tell. + - `templateFile()` takes a `file:` URL or an absolute path. In CommonJS, use `path.join(__dirname, 'emails/welcome.html')`. + - The file reference keeps PostHog in step with the file: each send carries the file's content hash, and after an edit the next send uploads the file on its own. No deploy step is needed. + - Serverless bundles must ship the HTML file. See "Ship template files with serverless bundles" in the reference. + + **Other languages:** send by key over HTTP, with `Authorization: Bearer $POSTHOG_PROJECT_SECRET_API_KEY`. The host is the app host, `https://us.posthog.com` or `https://eu.posthog.com`: + + ``` + POST <host>/api/projects/@current/messaging/send/email/ + {"template": {"key": "welcome"}, "to": "jane@example.com", "identifiers": {"distinct_id": "user_42"}, "message_data": {"first_name": "Jane"}} + ``` + + - Also add an upload by key wherever the project deploys, such as a CI step or a release script: `PUT <host>/api/projects/@current/messaging_templates/by_key/<key>/` with `name`, `subject`, `html`, `preheader`, and `source`. Otherwise an edited file never reaches PostHog. + + For both: + - Pass every value the template reads in `data` (Node.js) or `message_data` (HTTP). + - Pass the app's user ID as `distinctId` (Node.js) or `identifiers.distinct_id` (HTTP), so the send and its delivery events show on that person. + - Sends are not idempotent: two calls send two emails. Do not retry a send after a timeout or a server error, and never call the send more than once for one event. + - To try it, run the code path once, or ask the person before you send a test email with `workflows-send-email` to an address they own. + +STEP 9: Handle errors by their code. + - `posthog-node` never throws on API errors. It returns `{ data, error }`. Branch on `error.code` and log `error.message`. + - Over HTTP, read `code` from the error body. New codes also carry `fix` and `docs_url`. + - Each code has a section with its fix in the reference. The common ones: + - `sender_unavailable`: no verified default sender. Go back to STEP 3. + - `not_found`: the API is not enabled for the project. See STEP 6. + - `template_not_found`: no template has this key. Upload it (STEP 5). + - `template_file_unreadable`: the deployment doesn't include the HTML file. + - `missing_secret_key`, `authentication_failed`, `permission_denied`: the `phs_` key is missing, or lacks `hog_flow:write`. + - `immutable`: the template is managed by code. Change the file and upload it, instead of editing the template in PostHog. + +## Reference files + +{references} + +The reference is the transactional email API page. It covers the template format, `posthog-node`, both routes, the content hash, and every error code. + +## Key principles + +- **Sender first**: verify a sender before the first upload. +- **The file is the source**: the HTML file in the repository is the template. Change the file, never the copy in PostHog. +- **Values from the send**: templates read `{{ variables.* }}` only. +- **Server only**: the `phs_` key comes from an environment variable on the server. +- **No retries**: sends are not idempotent. diff --git a/scripts/lib/tests/send-transactional-email-skill.test.js b/scripts/lib/tests/send-transactional-email-skill.test.js new file mode 100644 index 00000000..470ae143 --- /dev/null +++ b/scripts/lib/tests/send-transactional-email-skill.test.js @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +const CONFIG_DIR = join(process.cwd(), 'context'); +const DOCS_URL = 'https://posthog.com/docs/workflows/transactional-email-api.md'; +const DOCS_PAGE = '# Transactional email API\n\nSend one email to one person from your server code.\n'; + +async function buildSkill(outputDir) { + vi.resetModules(); + vi.stubEnv('DOCS_CACHE_TTL_MS', '0'); + vi.stubGlobal('fetch', async (url) => { + if (url !== DOCS_URL) return new Response('not found', { status: 404, statusText: 'Not Found' }); + return new Response(DOCS_PAGE, { status: 200 }); + }); + const { expandSkillGroups, generateSkill, loadSkillsConfig } = await import('../skill-generator.js'); + const skill = expandSkillGroups(loadSkillsConfig(CONFIG_DIR), CONFIG_DIR) + .find((candidate) => candidate.id === 'omnibus-send-transactional-email'); + expect(skill).toBeDefined(); + + await generateSkill({ + skill, + version: 'test', + repoRoot: process.cwd(), + configDir: CONFIG_DIR, + outputDir, + skipPatterns: { global: [], examples: {} }, + commandmentsConfig: { commandments: {} }, + skillTemplate: skill._template, + sharedDocs: skill._sharedDocs, + }); + return join(outputDir, 'omnibus-send-transactional-email'); +} + +describe('send-transactional-email skill', () => { + let outputDir; + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + if (outputDir) rmSync(outputDir, { recursive: true, force: true }); + }); + + it('ships the transactional email API docs page as its reference', async () => { + outputDir = mkdtempSync(join(tmpdir(), 'send-email-skill-')); + + const skillDir = await buildSkill(outputDir); + + const skillFile = readFileSync(join(skillDir, 'SKILL.md'), 'utf8'); + const referenceFile = join(skillDir, 'references', 'transactional-email-api.md'); + expect(skillFile).toMatch(/^---\nname: omnibus-send-transactional-email\n/); + expect(skillFile).toContain('`references/transactional-email-api.md`'); + expect(existsSync(referenceFile)).toBe(true); + expect(readFileSync(referenceFile, 'utf8')).toBe(DOCS_PAGE); + }); +}); From 5a1f8e193dec583ee83d3ae588b6e9b1c0d63b82 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:12:52 +0000 Subject: [PATCH 02/23] fix(skills): tighten send-transactional-email after review Check the PostHog MCP before any sender work, never recreate an existing sender, bound verification polling, set the default sender through a verify, ask before any test send, and cover Next.js and edge runtimes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../send-transactional-email/description.md | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/context/skills/omnibus/send-transactional-email/description.md b/context/skills/omnibus/send-transactional-email/description.md index 84d76337..35d20373 100644 --- a/context/skills/omnibus/send-transactional-email/description.md +++ b/context/skills/omnibus/send-transactional-email/description.md @@ -6,16 +6,18 @@ The transactional email API is in beta. PostHog enables it per project. ## Instructions -Follow these steps IN ORDER. Do not upload a template before STEP 3 is done: the first upload needs a verified default sender. +Follow these steps IN ORDER. Do not upload a template before a sender is verified: the first upload needs a verified default sender. -STEP 1: Detect the platform. +STEP 1: Detect the platform and check the PostHog MCP. - Find the server code that should send the email, such as the signup handler for a welcome email. - Node.js (including Next.js route handlers and server actions) uses `posthog-node`. Any other server language uses the HTTP API. - Never send from browser or mobile code. The send needs a secret key, and the key stays on the server. - Look for lockfiles (pnpm-lock.yaml, package-lock.json, yarn.lock, bun.lockb) to pick the package manager. + - Check which PostHog MCP tools exist. When there are no PostHog tools at all, the PostHog MCP is not connected or not signed in: ask the person to connect it, and wait. + - When other PostHog tools exist but `workflows-upload-email-template` and `workflows-send-email` don't, the transactional email API is not enabled for this project. Go to STEP 6 before the person does any sender work. STEP 2: Check for a verified sender through the PostHog MCP. - - Call `integrations-list` with `kind=email`. A sender with `config.verified: true` can send. When the list shows senders without `config`, ask the person whether one is verified, or let the upload in STEP 5 tell you: `sender_unavailable` means there is no verified default sender. + - Call `integrations-list` with `kind=email`, and read every page. A sender with `config.verified: true` can send. When the list shows senders without `config`, ask the person whether one is verified. - The first sender that verifies becomes the project's default sender, and the first upload uses the default sender. - When a verified sender exists, go to STEP 4. @@ -23,9 +25,12 @@ STEP 3: Set up a sender when none is verified. - First check that the PostHog MCP has the sender tools: `integrations-email-create`, `integrations-email-verify-create`, `integrations-domain-connect-check-retrieve`, and `integrations-domain-connect-apply-url-create`. When they are missing, go to the last bullet of this step. - When the `setting-up-an-email-domain` skill is available, follow it. It covers the details. - Ask which domain to send from. Recommend a subdomain such as `mail.example.com`. Use the person's own domain, never a free mailbox domain such as gmail.com. - - Create the sender, then get its DNS records with `integrations-email-verify-create`. - - PostHog never writes DNS. When the domain supports Domain Connect, give the person the URL from `integrations-domain-connect-apply-url-create`: they approve the records at their DNS host. Otherwise, give them the records as a table with type, name, value, and priority. - - Call `integrations-email-verify-create` again after the records are published, at least a minute apart, until `status` is `success`. + - Never create a sender for an address that already has one: creating it again marks it unverified. For an existing unverified sender, skip to its records. + - Create the sender with `integrations-email-create`, then get its DNS records with `integrations-email-verify-create`. + - PostHog never writes DNS. Call `integrations-domain-connect-check-retrieve` with the domain. When `supported` is true, give the person the URL from `integrations-domain-connect-apply-url-create`: they approve the records at their DNS host. Otherwise, give them the records that are not `success` as a table with type, name, value, and priority. + - Always include both SPF records, merged into an existing `v=spf1` record when there is one. Never add a second `_dmarc` record when the domain already has one. + - Call `integrations-email-verify-create` again after the records are published, at least a minute apart. `success` means done. `failed` means fix the records, then verify again. After about 15 minutes of `pending` with every record published, stop and tell the person that DNS is still propagating and they can ask you to check again later. + - A 403 that says the person lacks permissions in the project means a project admin must verify the sender. Do not retry. - **When the sender tools are missing or refused**, the agent setup for sender domains is not available to this person yet. Do not try other integration tools. Ask the person to verify a sender in PostHog: Workflows, then Channels, then add an email sender on their domain and follow the DNS steps (https://posthog.com/docs/workflows/configure-channels). Wait until they confirm that the sender shows as verified, then continue. STEP 4: Write the template as an HTML file in the repository. @@ -36,12 +41,15 @@ STEP 4: Write the template as an HTML file in the repository. STEP 5: Upload the template with the PostHog MCP. - Call `workflows-upload-email-template` with the key, a `name` such as "Welcome", the `<title>` text as `subject`, the `<meta name="preheader">` content as `preheader` (if any), and the whole file as `html`. - - Pass `source` with `repository` (such as `github.com/acme/app`), `path` (the file path in the repository), and `ref` (the current commit), so the person sees the file in PostHog next to the template. + - Pass `source` with `repository` (such as `github.com/example/app`), `path` (the file path in the repository), and `ref` (the current commit), so the person sees the file in PostHog next to the template. - Expect `result: "created"` on the first upload, and `updated` or `unchanged` later. The template is now managed by code: edits happen in the file, not in PostHog. - - On `sender_unavailable`, the project has no verified default sender. Ask the person to click **Make default** next to their verified sender in the email channel settings, then upload again. + - On `sender_unavailable`, the project has no verified default sender: + - When no sender is verified, go back to STEP 3. + - When a sender is verified, call `integrations-email-verify-create` on it once: a successful verify makes it the default when the project has none. Then upload again. + - When that is not possible or the upload still answers `sender_unavailable`, ask the person to click **Make default** next to their verified sender in the email channel settings, then upload again. STEP 6: Stop when the API is not enabled for the project. - - When the PostHog MCP has no `workflows-upload-email-template` or `workflows-send-email` tool, or an upload or send answers a plain `404` with the code `not_found`, the transactional email API is not enabled for this project. + - The transactional email API is not enabled for this project when other PostHog tools exist but the upload and send tools don't, or when an upload or send answers `404` without a code or with the code `not_found`. A `404` with `template_not_found` is different: see STEP 9. - Stop and tell the person: the API is in beta, and they can ask PostHog in-app to enable it for their project. Do not retry, and do not work around it with other tools. STEP 7: Have the person create a project secret API key. @@ -59,7 +67,7 @@ STEP 8: Write the send. secretKey: process.env.POSTHOG_PROJECT_SECRET_API_KEY, enableLocalEvaluation: false, }) - const welcomeEmail = posthog.emails.templateFile(new URL('./emails/welcome.html', import.meta.url)) + const welcomeEmail = posthog.emails.templateFile(new URL('./emails/welcome.html', import.meta.url), { name: 'Welcome' }) const { data, error } = await posthog.emails.send({ template: welcomeEmail, @@ -71,9 +79,11 @@ STEP 8: Write the send. - Reuse the app's existing `posthog-node` client when it has one, and add `secretKey`. Keep `enableLocalEvaluation: false` unless the key also has `feature_flag:read` and the app uses local evaluation. - When the project token or the host is not configured yet, get the token from the PostHog MCP `projects-get` tool (`api_token`). The host is `https://us.i.posthog.com` for US Cloud and `https://eu.i.posthog.com` for EU Cloud. Ask the person which one when you can't tell. + - Pass the same `name` as the upload in STEP 5. Without it, the next automatic upload renames the template to its subject. - `templateFile()` takes a `file:` URL or an absolute path. In CommonJS, use `path.join(__dirname, 'emails/welcome.html')`. + - In Next.js and other bundled or serverless code, `import.meta.url` no longer points next to the file. Use `path.join(process.cwd(), 'emails/welcome.html')`, and make the deployment ship the file: in Next.js, add `{ '/api/signup': ['./emails/**/*.html'] }` (the route that sends) to `outputFileTracingIncludes` in `next.config.js`. For Vercel and Netlify functions, see "Ship template files with serverless bundles" in the reference. + - Edge runtimes have no file system, so they take a key string only: `template: 'welcome'`. Upload the file from a build step or a Node.js script there. - The file reference keeps PostHog in step with the file: each send carries the file's content hash, and after an edit the next send uploads the file on its own. No deploy step is needed. - - Serverless bundles must ship the HTML file. See "Ship template files with serverless bundles" in the reference. **Other languages:** send by key over HTTP, with `Authorization: Bearer $POSTHOG_PROJECT_SECRET_API_KEY`. The host is the app host, `https://us.posthog.com` or `https://eu.posthog.com`: @@ -88,14 +98,14 @@ STEP 8: Write the send. - Pass every value the template reads in `data` (Node.js) or `message_data` (HTTP). - Pass the app's user ID as `distinctId` (Node.js) or `identifiers.distinct_id` (HTTP), so the send and its delivery events show on that person. - Sends are not idempotent: two calls send two emails. Do not retry a send after a timeout or a server error, and never call the send more than once for one event. - - To try it, run the code path once, or ask the person before you send a test email with `workflows-send-email` to an address they own. + - Every test sends a real email. Ask the person before any test send, whether you run the code path or call `workflows-send-email`, and send only to an address they own. STEP 9: Handle errors by their code. - `posthog-node` never throws on API errors. It returns `{ data, error }`. Branch on `error.code` and log `error.message`. - Over HTTP, read `code` from the error body. New codes also carry `fix` and `docs_url`. - Each code has a section with its fix in the reference. The common ones: - - `sender_unavailable`: no verified default sender. Go back to STEP 3. - - `not_found`: the API is not enabled for the project. See STEP 6. + - `sender_unavailable`: no verified default sender. Follow STEP 5. + - `not_found`, or a `404` without a code: the API is not enabled for the project. See STEP 6. - `template_not_found`: no template has this key. Upload it (STEP 5). - `template_file_unreadable`: the deployment doesn't include the HTML file. - `missing_secret_key`, `authentication_failed`, `permission_denied`: the `phs_` key is missing, or lacks `hog_flow:write`. From 3d85758e330b795355567c1ce81bd7bde89a041d Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:15:12 +0000 Subject: [PATCH 03/23] fix(skills): never rerun a test send to read its output Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- context/skills/omnibus/send-transactional-email/description.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/context/skills/omnibus/send-transactional-email/description.md b/context/skills/omnibus/send-transactional-email/description.md index 35d20373..ef2705f5 100644 --- a/context/skills/omnibus/send-transactional-email/description.md +++ b/context/skills/omnibus/send-transactional-email/description.md @@ -98,7 +98,7 @@ STEP 8: Write the send. - Pass every value the template reads in `data` (Node.js) or `message_data` (HTTP). - Pass the app's user ID as `distinctId` (Node.js) or `identifiers.distinct_id` (HTTP), so the send and its delivery events show on that person. - Sends are not idempotent: two calls send two emails. Do not retry a send after a timeout or a server error, and never call the send more than once for one event. - - Every test sends a real email. Ask the person before any test send, whether you run the code path or call `workflows-send-email`, and send only to an address they own. + - Every test sends a real email. Ask the person before any test send, whether you run the code path or call `workflows-send-email`, and send only to an address they own. Save the test run's full output to a file and read it from there: never run it again to see more output, because each run sends another email. STEP 9: Handle errors by their code. - `posthog-node` never throws on API errors. It returns `{ data, error }`. Branch on `error.code` and log `error.message`. From 711998df31fcb5fcb021b933bb76dc6c35dc22e5 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:19:31 +0000 Subject: [PATCH 04/23] fix(skills): verify an unknown sender instead of asking Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../skills/omnibus/send-transactional-email/description.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/context/skills/omnibus/send-transactional-email/description.md b/context/skills/omnibus/send-transactional-email/description.md index ef2705f5..de81e33f 100644 --- a/context/skills/omnibus/send-transactional-email/description.md +++ b/context/skills/omnibus/send-transactional-email/description.md @@ -17,7 +17,7 @@ STEP 1: Detect the platform and check the PostHog MCP. - When other PostHog tools exist but `workflows-upload-email-template` and `workflows-send-email` don't, the transactional email API is not enabled for this project. Go to STEP 6 before the person does any sender work. STEP 2: Check for a verified sender through the PostHog MCP. - - Call `integrations-list` with `kind=email`, and read every page. A sender with `config.verified: true` can send. When the list shows senders without `config`, ask the person whether one is verified. + - Call `integrations-list` with `kind=email`, and read every page. A sender with `config.verified: true` can send. When the list shows senders without `config`, call `integrations-email-verify-create` once on the sender for the address the person wants: `success` means it is verified (go to STEP 4), and `pending` or `failed` means STEP 3 from its records. When the sender tools are missing, ask the person whether a sender is verified, and let the upload in STEP 5 confirm it. - The first sender that verifies becomes the project's default sender, and the first upload uses the default sender. - When a verified sender exists, go to STEP 4. @@ -34,7 +34,7 @@ STEP 3: Set up a sender when none is verified. - **When the sender tools are missing or refused**, the agent setup for sender domains is not available to this person yet. Do not try other integration tools. Ask the person to verify a sender in PostHog: Workflows, then Channels, then add an email sender on their domain and follow the DNS steps (https://posthog.com/docs/workflows/configure-channels). Wait until they confirm that the sender shows as verified, then continue. STEP 4: Write the template as an HTML file in the repository. - - Put it next to the server code, for example `emails/welcome.html`. The file name without `.html` is the key, so use letters, digits, `-`, and `_` only. + - Put it next to the server code, for example `emails/welcome.html`. In Next.js and other bundled code, put the `emails/` folder at the project root. The file name without `.html` is the key, so use letters, digits, `-`, and `_` only. - The `<title>` is the subject. `<meta name="preheader" content="...">` is the optional inbox preview text. - Use Liquid with `{{ variables.<name> }}` for every value the email shows, such as `{{ variables.first_name }}` or `{{ variables.reset_url }}`. - Never use `{{ person.* }}`. A send doesn't load person properties, so those placeholders render empty. @@ -81,7 +81,7 @@ STEP 8: Write the send. - When the project token or the host is not configured yet, get the token from the PostHog MCP `projects-get` tool (`api_token`). The host is `https://us.i.posthog.com` for US Cloud and `https://eu.i.posthog.com` for EU Cloud. Ask the person which one when you can't tell. - Pass the same `name` as the upload in STEP 5. Without it, the next automatic upload renames the template to its subject. - `templateFile()` takes a `file:` URL or an absolute path. In CommonJS, use `path.join(__dirname, 'emails/welcome.html')`. - - In Next.js and other bundled or serverless code, `import.meta.url` no longer points next to the file. Use `path.join(process.cwd(), 'emails/welcome.html')`, and make the deployment ship the file: in Next.js, add `{ '/api/signup': ['./emails/**/*.html'] }` (the route that sends) to `outputFileTracingIncludes` in `next.config.js`. For Vercel and Netlify functions, see "Ship template files with serverless bundles" in the reference. + - In Next.js and other bundled or serverless code, `import.meta.url` no longer points next to the file. Use `path.join(process.cwd(), 'emails/welcome.html')`, and make the deployment ship the file: in Next.js, add `{ '/api/signup': ['./emails/**/*.html'] }` (the route that sends) to `outputFileTracingIncludes` in `next.config.js` (under `experimental` in Next.js 14). For Vercel and Netlify functions, see "Ship template files with serverless bundles" in the reference. - Edge runtimes have no file system, so they take a key string only: `template: 'welcome'`. Upload the file from a build step or a Node.js script there. - The file reference keeps PostHog in step with the file: each send carries the file's content hash, and after an edit the next send uploads the file on its own. No deploy step is needed. From 965a656ea1d4f7b6612186d2a8b0575842302dee Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:23:08 +0000 Subject: [PATCH 05/23] fix(skills): route a refused verify to the person Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../skills/omnibus/send-transactional-email/description.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/context/skills/omnibus/send-transactional-email/description.md b/context/skills/omnibus/send-transactional-email/description.md index de81e33f..c9970afe 100644 --- a/context/skills/omnibus/send-transactional-email/description.md +++ b/context/skills/omnibus/send-transactional-email/description.md @@ -17,7 +17,7 @@ STEP 1: Detect the platform and check the PostHog MCP. - When other PostHog tools exist but `workflows-upload-email-template` and `workflows-send-email` don't, the transactional email API is not enabled for this project. Go to STEP 6 before the person does any sender work. STEP 2: Check for a verified sender through the PostHog MCP. - - Call `integrations-list` with `kind=email`, and read every page. A sender with `config.verified: true` can send. When the list shows senders without `config`, call `integrations-email-verify-create` once on the sender for the address the person wants: `success` means it is verified (go to STEP 4), and `pending` or `failed` means STEP 3 from its records. When the sender tools are missing, ask the person whether a sender is verified, and let the upload in STEP 5 confirm it. + - Call `integrations-list` with `kind=email`, and read every page. A sender with `config.verified: true` can send. When the list shows senders without `config`, call `integrations-email-verify-create` once on the sender for the address the person wants: `success` means it is verified (go to STEP 4), and `pending` or `failed` means STEP 3 from its records. When the sender tools are missing, or the verify answers 403, ask the person whether a sender is verified, and let the upload in STEP 5 confirm it. - The first sender that verifies becomes the project's default sender, and the first upload uses the default sender. - When a verified sender exists, go to STEP 4. @@ -34,7 +34,7 @@ STEP 3: Set up a sender when none is verified. - **When the sender tools are missing or refused**, the agent setup for sender domains is not available to this person yet. Do not try other integration tools. Ask the person to verify a sender in PostHog: Workflows, then Channels, then add an email sender on their domain and follow the DNS steps (https://posthog.com/docs/workflows/configure-channels). Wait until they confirm that the sender shows as verified, then continue. STEP 4: Write the template as an HTML file in the repository. - - Put it next to the server code, for example `emails/welcome.html`. In Next.js and other bundled code, put the `emails/` folder at the project root. The file name without `.html` is the key, so use letters, digits, `-`, and `_` only. + - Put it next to the server code, for example `emails/welcome.html`. In Next.js and other bundled code, put the `emails/` folder at the app root, next to `next.config.js` or `package.json`. The file name without `.html` is the key, so use letters, digits, `-`, and `_` only. - The `<title>` is the subject. `<meta name="preheader" content="...">` is the optional inbox preview text. - Use Liquid with `{{ variables.<name> }}` for every value the email shows, such as `{{ variables.first_name }}` or `{{ variables.reset_url }}`. - Never use `{{ person.* }}`. A send doesn't load person properties, so those placeholders render empty. @@ -81,7 +81,7 @@ STEP 8: Write the send. - When the project token or the host is not configured yet, get the token from the PostHog MCP `projects-get` tool (`api_token`). The host is `https://us.i.posthog.com` for US Cloud and `https://eu.i.posthog.com` for EU Cloud. Ask the person which one when you can't tell. - Pass the same `name` as the upload in STEP 5. Without it, the next automatic upload renames the template to its subject. - `templateFile()` takes a `file:` URL or an absolute path. In CommonJS, use `path.join(__dirname, 'emails/welcome.html')`. - - In Next.js and other bundled or serverless code, `import.meta.url` no longer points next to the file. Use `path.join(process.cwd(), 'emails/welcome.html')`, and make the deployment ship the file: in Next.js, add `{ '/api/signup': ['./emails/**/*.html'] }` (the route that sends) to `outputFileTracingIncludes` in `next.config.js` (under `experimental` in Next.js 14). For Vercel and Netlify functions, see "Ship template files with serverless bundles" in the reference. + - In Next.js and other bundled or serverless code, `import.meta.url` no longer points next to the file. Use `path.join(process.cwd(), 'emails/welcome.html')`, and make the deployment ship the file: in Next.js, add `{ '/api/signup': ['./emails/**/*.html'] }` (the route that sends) to `outputFileTracingIncludes` in `next.config.js` (under `experimental` in Next.js 14 and earlier). For Vercel and Netlify functions, see "Ship template files with serverless bundles" in the reference. - Edge runtimes have no file system, so they take a key string only: `template: 'welcome'`. Upload the file from a build step or a Node.js script there. - The file reference keeps PostHog in step with the file: each send carries the file's content hash, and after an edit the next send uploads the file on its own. No deploy step is needed. From 5127da72970724912abdd62076c589d68d500cc1 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:34:04 +0000 Subject: [PATCH 06/23] feat(skills): record a template's file on upload and change it in the file The MCP upload passes source.repository from the origin remote without credentials, source.path from the repository root and source.ref from HEAD. A new section sends a change to a code-managed template to its file and leaves the upload to posthog-node or the deploy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../send-transactional-email/description.md | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/context/skills/omnibus/send-transactional-email/description.md b/context/skills/omnibus/send-transactional-email/description.md index c9970afe..96f88ea1 100644 --- a/context/skills/omnibus/send-transactional-email/description.md +++ b/context/skills/omnibus/send-transactional-email/description.md @@ -41,8 +41,13 @@ STEP 4: Write the template as an HTML file in the repository. STEP 5: Upload the template with the PostHog MCP. - Call `workflows-upload-email-template` with the key, a `name` such as "Welcome", the `<title>` text as `subject`, the `<meta name="preheader">` content as `preheader` (if any), and the whole file as `html`. - - Pass `source` with `repository` (such as `github.com/example/app`), `path` (the file path in the repository), and `ref` (the current commit), so the person sees the file in PostHog next to the template. - - Expect `result: "created"` on the first upload, and `updated` or `unchanged` later. The template is now managed by code: edits happen in the file, not in PostHog. + - Pass `source`, so PostHog and the next agent can find the file: + - `repository`: the URL from `git remote get-url origin` as `host/owner/name`. Drop the scheme, any user name or token, and the `.git` ending: `git@github.com:example/app.git` and `https://user:token@github.com/example/app.git` both become `github.com/example/app`. + - `path`: the file's path from the repository root (`git rev-parse --show-toplevel`), such as `apps/web/emails/welcome.html` in a monorepo. + - `ref`: the commit from `git rev-parse HEAD`. + - Leave out a field that git can't give, such as `repository` when there is no `origin`. + - When the app sends with `templateFile()` (STEP 8), posthog-node fills the same three fields on the uploads it makes, from CI and hosting variables or the git checkout. A field it can't find keeps the value this upload stored. + - Expect `result: "created"` on the first upload, and `updated` or `unchanged` later. The template is now managed by code: changes go through its file (see "Change an email the app already sends"). - On `sender_unavailable`, the project has no verified default sender: - When no sender is verified, go back to STEP 3. - When a sender is verified, call `integrations-email-verify-create` on it once: a successful verify makes it the default when the project has none. Then upload again. @@ -92,7 +97,7 @@ STEP 8: Write the send. {"template": {"key": "welcome"}, "to": "jane@example.com", "identifiers": {"distinct_id": "user_42"}, "message_data": {"first_name": "Jane"}} ``` - - Also add an upload by key wherever the project deploys, such as a CI step or a release script: `PUT <host>/api/projects/@current/messaging_templates/by_key/<key>/` with `name`, `subject`, `html`, `preheader`, and `source`. Otherwise an edited file never reaches PostHog. + - Also add an upload by key wherever the project deploys, such as a CI step or a release script: `PUT <host>/api/projects/@current/messaging_templates/by_key/<key>/` with `name`, `subject`, `html`, `preheader`, and `source` as in STEP 5. Otherwise an edited file never reaches PostHog. For both: - Pass every value the template reads in `data` (Node.js) or `message_data` (HTTP). @@ -109,7 +114,17 @@ STEP 9: Handle errors by their code. - `template_not_found`: no template has this key. Upload it (STEP 5). - `template_file_unreadable`: the deployment doesn't include the HTML file. - `missing_secret_key`, `authentication_failed`, `permission_denied`: the `phs_` key is missing, or lacks `hog_flow:write`. - - `immutable`: the template is managed by code. Change the file and upload it, instead of editing the template in PostHog. + - `immutable`: the template is managed by code. Change its file, as "Change an email the app already sends" says. + +## Change an email the app already sends + +A code-managed template changes in its file in the repository. You work in the repository, so edit the file yourself: + - Find the file from the template's `source.path`, or from the `templateFile()` call or upload that uses its key. When the person pastes a prompt from **Edit with your agent** on the template's page in PostHog, the prompt names the file and the key. + - Keep the format of STEP 4, and read only `{{ variables.* }}` values that the send passes. + - When the app sends with `templateFile()`, the change goes live with the first send after the person deploys it: posthog-node uploads the changed file then. Leave the upload to it, so the new version goes live with the code that is reviewed and deployed. Upload it yourself as in STEP 5 only when the person asks for the change to go live before that, and tell them that every running copy of the app sends the new version from then on. + - When the app sends by key, the upload step from STEP 8 uploads the file when the project deploys. When the project has no such step, upload the file again as in STEP 5, with a fresh `source`, once the person wants the change live. + - Change the template only through its file. PostHog's template update, patch, and design tools refuse a code-managed template with `403 immutable`. Never call `workflows-request-email-template-change` either: where the PostHog MCP has it, it starts a PostHog Code task for agents that can't reach the repository. + - Leave the template managed by code. Releasing it in PostHog is the person's decision, and the next upload from the file claims it back. ## Reference files From 99eaa9a7821de622ddec1a5171bb75b15c9be914 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:44:21 +0000 Subject: [PATCH 07/23] fix(skills): cover undo, early publish and no-repository changes A reverted file goes live only through an upload, because a send with an older published hash gets the newer content. Any send from the changed code publishes it. A by-key change is uploaded after the change. An agent without the repository tells the person or requests the change. The SDK always sends a path, so only repository and ref keep their stored value. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../send-transactional-email/description.md | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/context/skills/omnibus/send-transactional-email/description.md b/context/skills/omnibus/send-transactional-email/description.md index 96f88ea1..c3e98ced 100644 --- a/context/skills/omnibus/send-transactional-email/description.md +++ b/context/skills/omnibus/send-transactional-email/description.md @@ -46,7 +46,7 @@ STEP 5: Upload the template with the PostHog MCP. - `path`: the file's path from the repository root (`git rev-parse --show-toplevel`), such as `apps/web/emails/welcome.html` in a monorepo. - `ref`: the commit from `git rev-parse HEAD`. - Leave out a field that git can't give, such as `repository` when there is no `origin`. - - When the app sends with `templateFile()` (STEP 8), posthog-node fills the same three fields on the uploads it makes, from CI and hosting variables or the git checkout. A field it can't find keeps the value this upload stored. + - posthog-node fills the same three fields on its own uploads when the app sends with `templateFile()` (STEP 8). It reads them from CI and hosting variables or the git checkout. It always sends a `path`: without a checkout or CI workspace, the path is relative to the app's working directory. A `repository` or `ref` it can't find keeps the value this upload stored. - Expect `result: "created"` on the first upload, and `updated` or `unchanged` later. The template is now managed by code: changes go through its file (see "Change an email the app already sends"). - On `sender_unavailable`, the project has no verified default sender: - When no sender is verified, go back to STEP 3. @@ -118,14 +118,22 @@ STEP 9: Handle errors by their code. ## Change an email the app already sends -A code-managed template changes in its file in the repository. You work in the repository, so edit the file yourself: - - Find the file from the template's `source.path`, or from the `templateFile()` call or upload that uses its key. When the person pastes a prompt from **Edit with your agent** on the template's page in PostHog, the prompt names the file and the key. +A code-managed template changes in its file in the repository. + +When you can edit the repository, change the file yourself: + - Find the file by the template's key: the `templateFile()` call or the upload that uses it. The template's `source.path` points at it too, but posthog-node can store it relative to the app's directory. A prompt from **Edit with your agent** on the template's page in PostHog names the key, and the file when PostHog knows it. - Keep the format of STEP 4, and read only `{{ variables.* }}` values that the send passes. - - When the app sends with `templateFile()`, the change goes live with the first send after the person deploys it: posthog-node uploads the changed file then. Leave the upload to it, so the new version goes live with the code that is reviewed and deployed. Upload it yourself as in STEP 5 only when the person asks for the change to go live before that, and tell them that every running copy of the app sends the new version from then on. - - When the app sends by key, the upload step from STEP 8 uploads the file when the project deploys. When the project has no such step, upload the file again as in STEP 5, with a fresh `source`, once the person wants the change live. - - Change the template only through its file. PostHog's template update, patch, and design tools refuse a code-managed template with `403 immutable`. Never call `workflows-request-email-template-change` either: where the PostHog MCP has it, it starts a PostHog Code task for agents that can't reach the repository. + - Change the template only through its file. PostHog's template update, patch, and design tools refuse a code-managed template with `403 immutable`. - Leave the template managed by code. Releasing it in PostHog is the person's decision, and the next upload from the file claims it back. +How the change goes live: + - **`templateFile()`:** the first send from code with the changed file uploads it. Usually that is the deployed app, after review. A local run or a test send against the same project publishes it too, so say so when you ask before a test send. Leave the upload to posthog-node. + - **Live before the deploy:** upload the file as in STEP 5 only when the person asks for that. Tell them that every running copy of the app sends the new version from then on. + - **Undo:** a change back to content PostHog sent before goes live only through an upload as in STEP 5. A send from the reverted file gets the newer content, and posthog-node doesn't upload it. + - **By key:** a send never uploads. Upload the file again as in STEP 5 after the change, or leave it to the project's deploy step from STEP 8 when it has one. + +Without access to the repository, tell the person which file to change. Where the PostHog MCP has `workflows-request-email-template-change`, call it instead: it starts a PostHog Code task that changes the file and opens a pull request. An agent that can edit the file never calls it. + ## Reference files {references} From c128595a959f05da3748b4bc20c889c6bf17d033 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:48:09 +0000 Subject: [PATCH 08/23] fix(skills): scope undo to templateFile and order the by-key upload A revert to published content needs an upload only for templateFile(). A by-key change waits for the deploy step when there is one. A test through workflows-send-email publishes nothing, and an agent without the repository shares the PostHog Code task link. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../omnibus/send-transactional-email/description.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/context/skills/omnibus/send-transactional-email/description.md b/context/skills/omnibus/send-transactional-email/description.md index c3e98ced..08d484a6 100644 --- a/context/skills/omnibus/send-transactional-email/description.md +++ b/context/skills/omnibus/send-transactional-email/description.md @@ -88,7 +88,7 @@ STEP 8: Write the send. - `templateFile()` takes a `file:` URL or an absolute path. In CommonJS, use `path.join(__dirname, 'emails/welcome.html')`. - In Next.js and other bundled or serverless code, `import.meta.url` no longer points next to the file. Use `path.join(process.cwd(), 'emails/welcome.html')`, and make the deployment ship the file: in Next.js, add `{ '/api/signup': ['./emails/**/*.html'] }` (the route that sends) to `outputFileTracingIncludes` in `next.config.js` (under `experimental` in Next.js 14 and earlier). For Vercel and Netlify functions, see "Ship template files with serverless bundles" in the reference. - Edge runtimes have no file system, so they take a key string only: `template: 'welcome'`. Upload the file from a build step or a Node.js script there. - - The file reference keeps PostHog in step with the file: each send carries the file's content hash, and after an edit the next send uploads the file on its own. No deploy step is needed. + - The file reference keeps PostHog in step with the file: each send carries the file's content hash, and after an edit the next send uploads the file on its own. No deploy step is needed. A revert to an earlier version is the exception: see "Change an email the app already sends". **Other languages:** send by key over HTTP, with `Authorization: Bearer $POSTHOG_PROJECT_SECRET_API_KEY`. The host is the app host, `https://us.posthog.com` or `https://eu.posthog.com`: @@ -127,12 +127,12 @@ When you can edit the repository, change the file yourself: - Leave the template managed by code. Releasing it in PostHog is the person's decision, and the next upload from the file claims it back. How the change goes live: - - **`templateFile()`:** the first send from code with the changed file uploads it. Usually that is the deployed app, after review. A local run or a test send against the same project publishes it too, so say so when you ask before a test send. Leave the upload to posthog-node. + - **`templateFile()`:** the first send from code with the changed file uploads it. Usually that is the deployed app, after review. A local run of that code against the same project publishes it too. Say so when you ask before a test send from the code. Leave the upload to posthog-node. - **Live before the deploy:** upload the file as in STEP 5 only when the person asks for that. Tell them that every running copy of the app sends the new version from then on. - - **Undo:** a change back to content PostHog sent before goes live only through an upload as in STEP 5. A send from the reverted file gets the newer content, and posthog-node doesn't upload it. - - **By key:** a send never uploads. Upload the file again as in STEP 5 after the change, or leave it to the project's deploy step from STEP 8 when it has one. + - **Undo with `templateFile()`:** a revert to content PostHog published before goes live only through an upload as in STEP 5. A send from the reverted file gets the newer content, and posthog-node doesn't upload it. + - **By key:** a send never uploads. The project's deploy step from STEP 8 uploads the change. Without one, upload the file again as in STEP 5 after the change. -Without access to the repository, tell the person which file to change. Where the PostHog MCP has `workflows-request-email-template-change`, call it instead: it starts a PostHog Code task that changes the file and opens a pull request. An agent that can edit the file never calls it. +Without access to the repository, tell the person which file to change. Where the PostHog MCP has `workflows-request-email-template-change`, call it instead and share the task link: it starts a PostHog Code task that changes the file and opens a pull request. An agent that can edit the file never calls it. ## Reference files From 8b236c30c6774e3b77163ff626f3c242c041902c Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:52:28 +0000 Subject: [PATCH 09/23] fix(skills): name a published revert in STEP 8 and split the fallback Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../skills/omnibus/send-transactional-email/description.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/context/skills/omnibus/send-transactional-email/description.md b/context/skills/omnibus/send-transactional-email/description.md index 08d484a6..d5b813d3 100644 --- a/context/skills/omnibus/send-transactional-email/description.md +++ b/context/skills/omnibus/send-transactional-email/description.md @@ -88,7 +88,7 @@ STEP 8: Write the send. - `templateFile()` takes a `file:` URL or an absolute path. In CommonJS, use `path.join(__dirname, 'emails/welcome.html')`. - In Next.js and other bundled or serverless code, `import.meta.url` no longer points next to the file. Use `path.join(process.cwd(), 'emails/welcome.html')`, and make the deployment ship the file: in Next.js, add `{ '/api/signup': ['./emails/**/*.html'] }` (the route that sends) to `outputFileTracingIncludes` in `next.config.js` (under `experimental` in Next.js 14 and earlier). For Vercel and Netlify functions, see "Ship template files with serverless bundles" in the reference. - Edge runtimes have no file system, so they take a key string only: `template: 'welcome'`. Upload the file from a build step or a Node.js script there. - - The file reference keeps PostHog in step with the file: each send carries the file's content hash, and after an edit the next send uploads the file on its own. No deploy step is needed. A revert to an earlier version is the exception: see "Change an email the app already sends". + - The file reference keeps PostHog in step with the file: each send carries the file's content hash, and after an edit the next send uploads the file on its own. No deploy step is needed. A revert to content PostHog published before is the exception: see "Change an email the app already sends". **Other languages:** send by key over HTTP, with `Authorization: Bearer $POSTHOG_PROJECT_SECRET_API_KEY`. The host is the app host, `https://us.posthog.com` or `https://eu.posthog.com`: @@ -132,7 +132,7 @@ How the change goes live: - **Undo with `templateFile()`:** a revert to content PostHog published before goes live only through an upload as in STEP 5. A send from the reverted file gets the newer content, and posthog-node doesn't upload it. - **By key:** a send never uploads. The project's deploy step from STEP 8 uploads the change. Without one, upload the file again as in STEP 5 after the change. -Without access to the repository, tell the person which file to change. Where the PostHog MCP has `workflows-request-email-template-change`, call it instead and share the task link: it starts a PostHog Code task that changes the file and opens a pull request. An agent that can edit the file never calls it. +Without access to the repository, tell the person which file to change. Where the PostHog MCP has `workflows-request-email-template-change`, call it instead. It starts a PostHog Code task that changes the file and opens a pull request. Share the task link with the person. An agent that can edit the file never calls it. ## Reference files From c626ddf3f6cf1473509255d8ac1ad5813f6666c3 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:51:19 +0000 Subject: [PATCH 10/23] feat(skills): teach the flat send contract in send-transactional-email The skill now writes a local template in the app's code and sends it with templateKey, subject, html and templateData. The first send uploads it, so the MCP upload, templateFile() and the deploy-time upload by key go. Rendered content and templates made in PostHog (templateId) are the other two forms. Sends carry an idempotency key and retry with it. Job runners pass maxRetries: 0 and a key derived from the email's purpose. STEP 9 lists the contract's codes, including the 409 for a template edited in PostHog. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../send-transactional-email/config.yaml | 4 +- .../send-transactional-email/description.md | 124 +++++++++--------- 2 files changed, 65 insertions(+), 63 deletions(-) diff --git a/context/skills/omnibus/send-transactional-email/config.yaml b/context/skills/omnibus/send-transactional-email/config.yaml index 9c30fee1..e99c4a9d 100644 --- a/context/skills/omnibus/send-transactional-email/config.yaml +++ b/context/skills/omnibus/send-transactional-email/config.yaml @@ -1,8 +1,8 @@ -# Transactional email skill - one email to one person, from a template file in the repository +# Transactional email skill - one email to one person, from a template in the app's code type: skill template: description.md category: workflows -description: Send transactional email from code with PostHog, such as a welcome email, a password reset, a signup confirmation, or a receipt. Use when asked to send an email from a Node.js app or other server code. It verifies a sender through the PostHog MCP, writes the email as an HTML template file in the repository, uploads it by key, and writes the send with posthog-node or the HTTP API. +description: Send transactional email from code with PostHog, such as a welcome email, a password reset, a signup confirmation, or a receipt. Use when asked to send an email from a Node.js app or other server code. It verifies a sender through the PostHog MCP, writes the email as a local template in the app's code, and writes the send with posthog-node's emails.send or the HTTP API. The first send uploads the template. tags: [workflows] shared_docs: - https://posthog.com/docs/workflows/transactional-email-api.md diff --git a/context/skills/omnibus/send-transactional-email/description.md b/context/skills/omnibus/send-transactional-email/description.md index d5b813d3..c47ab563 100644 --- a/context/skills/omnibus/send-transactional-email/description.md +++ b/context/skills/omnibus/send-transactional-email/description.md @@ -1,12 +1,12 @@ # Send transactional email with PostHog -Use this skill to send one email to one person from server code, such as a welcome email, a password reset, or a signup confirmation. The email lives in the repository as an HTML template file. PostHog stores it as a message template under a key, such as `welcome`, and sends it through Workflows. +Use this skill to send one email to one person from server code, such as a welcome email, a password reset, or a signup confirmation. The email lives in the app's code as a local template: a subject and an HTML string, named by a key such as `welcome`. The first send uploads it to PostHog, and PostHog sends it through Workflows. The transactional email API is in beta. PostHog enables it per project. ## Instructions -Follow these steps IN ORDER. Do not upload a template before a sender is verified: the first upload needs a verified default sender. +Follow these steps IN ORDER. Do not send before a sender is verified: the first send of a local template uploads it, and that upload needs a verified default sender. STEP 1: Detect the platform and check the PostHog MCP. - Find the server code that should send the email, such as the signup handler for a welcome email. @@ -17,8 +17,8 @@ STEP 1: Detect the platform and check the PostHog MCP. - When other PostHog tools exist but `workflows-upload-email-template` and `workflows-send-email` don't, the transactional email API is not enabled for this project. Go to STEP 6 before the person does any sender work. STEP 2: Check for a verified sender through the PostHog MCP. - - Call `integrations-list` with `kind=email`, and read every page. A sender with `config.verified: true` can send. When the list shows senders without `config`, call `integrations-email-verify-create` once on the sender for the address the person wants: `success` means it is verified (go to STEP 4), and `pending` or `failed` means STEP 3 from its records. When the sender tools are missing, or the verify answers 403, ask the person whether a sender is verified, and let the upload in STEP 5 confirm it. - - The first sender that verifies becomes the project's default sender, and the first upload uses the default sender. + - Call `integrations-list` with `kind=email`, and read every page. A sender with `config.verified: true` can send. When the list shows senders without `config`, call `integrations-email-verify-create` once on the sender for the address the person wants: `success` means it is verified (go to STEP 4), and `pending` or `failed` means STEP 3 from its records. When the sender tools are missing, or the verify answers 403, ask the person whether a sender is verified, and let the first send in STEP 8 confirm it. + - The first sender that verifies becomes the project's default sender. A send without `from` uses the default sender. - When a verified sender exists, go to STEP 4. STEP 3: Set up a sender when none is verified. @@ -33,28 +33,20 @@ STEP 3: Set up a sender when none is verified. - A 403 that says the person lacks permissions in the project means a project admin must verify the sender. Do not retry. - **When the sender tools are missing or refused**, the agent setup for sender domains is not available to this person yet. Do not try other integration tools. Ask the person to verify a sender in PostHog: Workflows, then Channels, then add an email sender on their domain and follow the DNS steps (https://posthog.com/docs/workflows/configure-channels). Wait until they confirm that the sender shows as verified, then continue. -STEP 4: Write the template as an HTML file in the repository. - - Put it next to the server code, for example `emails/welcome.html`. In Next.js and other bundled code, put the `emails/` folder at the app root, next to `next.config.js` or `package.json`. The file name without `.html` is the key, so use letters, digits, `-`, and `_` only. - - The `<title>` is the subject. `<meta name="preheader" content="...">` is the optional inbox preview text. - - Use Liquid with `{{ variables.<name> }}` for every value the email shows, such as `{{ variables.first_name }}` or `{{ variables.reset_url }}`. - - Never use `{{ person.* }}`. A send doesn't load person properties, so those placeholders render empty. +STEP 4: Pick the form of the send. + - **Local template**, the default for a new email: the code holds the subject and the HTML with `{{ variables.* }}` placeholders, and each send passes the values in `templateData`. Write it as STEP 5 says. + - **Rendered content**: the app already renders the whole email for one person, for example a React Email component rendered with `render()` from `@react-email/render`. Send the rendered `html` without `templateData`. PostHog sends it as it is and never stores it. Skip STEP 5. + - **A template made in PostHog**: only when the person names one. Send it by its `templateId` with `templateData`. Skip STEP 5. -STEP 5: Upload the template with the PostHog MCP. - - Call `workflows-upload-email-template` with the key, a `name` such as "Welcome", the `<title>` text as `subject`, the `<meta name="preheader">` content as `preheader` (if any), and the whole file as `html`. - - Pass `source`, so PostHog and the next agent can find the file: - - `repository`: the URL from `git remote get-url origin` as `host/owner/name`. Drop the scheme, any user name or token, and the `.git` ending: `git@github.com:example/app.git` and `https://user:token@github.com/example/app.git` both become `github.com/example/app`. - - `path`: the file's path from the repository root (`git rev-parse --show-toplevel`), such as `apps/web/emails/welcome.html` in a monorepo. - - `ref`: the commit from `git rev-parse HEAD`. - - Leave out a field that git can't give, such as `repository` when there is no `origin`. - - posthog-node fills the same three fields on its own uploads when the app sends with `templateFile()` (STEP 8). It reads them from CI and hosting variables or the git checkout. It always sends a `path`: without a checkout or CI workspace, the path is relative to the app's working directory. A `repository` or `ref` it can't find keeps the value this upload stored. - - Expect `result: "created"` on the first upload, and `updated` or `unchanged` later. The template is now managed by code: changes go through its file (see "Change an email the app already sends"). - - On `sender_unavailable`, the project has no verified default sender: - - When no sender is verified, go back to STEP 3. - - When a sender is verified, call `integrations-email-verify-create` on it once: a successful verify makes it the default when the project has none. Then upload again. - - When that is not possible or the upload still answers `sender_unavailable`, ask the person to click **Make default** next to their verified sender in the email channel settings, then upload again. +STEP 5: Write the local template in the code. + - Put it in a module next to the server code, for example `emails/welcome.ts`, that exports a `subject` and an `html` string. Add a `text` string only when the person wants their own plain-text part: without it, PostHog derives one from the HTML. + - Pick the key that names the email, such as `welcome` or `password-reset`. Use letters, digits, `-`, and `_` only. PostHog groups the email's logs, metrics, and status under it. + - Use Liquid with `{{ variables.<name> }}` for every value the email shows, in the subject too, such as `{{ variables.first_name }}` or `{{ variables.reset_url }}`. + - Never use `{{ person.* }}`. A send doesn't load person properties, so those placeholders render empty. + - Never upload the template, and never create it in PostHog. The first send uploads it, and a send after a change uploads the new content. The template is then managed by code: changes go through the code (see "Change an email the app already sends"). STEP 6: Stop when the API is not enabled for the project. - - The transactional email API is not enabled for this project when other PostHog tools exist but the upload and send tools don't, or when an upload or send answers `404` without a code or with the code `not_found`. A `404` with `template_not_found` is different: see STEP 9. + - The transactional email API is not enabled for this project when other PostHog tools exist but the upload and send tools don't, or when a send by `templateKey` answers `404` with the code `not_found` or without a code. A send by `templateId` answers the same `404` when no template has that id: check the id with the person first. - Stop and tell the person: the API is in beta, and they can ask PostHog in-app to enable it for their project. Do not retry, and do not work around it with other tools. STEP 7: Have the person create a project secret API key. @@ -62,88 +54,98 @@ STEP 7: Have the person create a project secret API key. - Read it from the `POSTHOG_PROJECT_SECRET_API_KEY` environment variable. Add the variable name to the project's env example file. Never hardcode the key, never commit it, and never ask the person to paste it into the chat. STEP 8: Write the send. - **Node.js:** install or upgrade `posthog-node` to a version that has `posthog.emails`. Reference the file once when the module loads, then send it: + **Node.js:** install or upgrade `posthog-node` to a version that has `posthog.emails`. Send the local template from STEP 5: ```js import { PostHog } from 'posthog-node' + import * as welcomeEmail from './emails/welcome' const posthog = new PostHog(process.env.POSTHOG_PROJECT_TOKEN, { host: process.env.POSTHOG_HOST, secretKey: process.env.POSTHOG_PROJECT_SECRET_API_KEY, enableLocalEvaluation: false, }) - const welcomeEmail = posthog.emails.templateFile(new URL('./emails/welcome.html', import.meta.url), { name: 'Welcome' }) const { data, error } = await posthog.emails.send({ - template: welcomeEmail, to: user.email, - data: { first_name: user.firstName }, distinctId: user.id, + templateKey: 'welcome', + subject: welcomeEmail.subject, + html: welcomeEmail.html, + templateData: { first_name: user.firstName }, }) ``` + - Rendered content sends `templateKey`, `subject`, and the rendered `html` without `templateData`: `html: await render(<PasswordReset url={url} />)`. + - A template made in PostHog sends `templateId` and `templateData`, and no `templateKey`, `subject`, or `html`. - Reuse the app's existing `posthog-node` client when it has one, and add `secretKey`. Keep `enableLocalEvaluation: false` unless the key also has `feature_flag:read` and the app uses local evaluation. - When the project token or the host is not configured yet, get the token from the PostHog MCP `projects-get` tool (`api_token`). The host is `https://us.i.posthog.com` for US Cloud and `https://eu.i.posthog.com` for EU Cloud. Ask the person which one when you can't tell. - - Pass the same `name` as the upload in STEP 5. Without it, the next automatic upload renames the template to its subject. - - `templateFile()` takes a `file:` URL or an absolute path. In CommonJS, use `path.join(__dirname, 'emails/welcome.html')`. - - In Next.js and other bundled or serverless code, `import.meta.url` no longer points next to the file. Use `path.join(process.cwd(), 'emails/welcome.html')`, and make the deployment ship the file: in Next.js, add `{ '/api/signup': ['./emails/**/*.html'] }` (the route that sends) to `outputFileTracingIncludes` in `next.config.js` (under `experimental` in Next.js 14 and earlier). For Vercel and Netlify functions, see "Ship template files with serverless bundles" in the reference. - - Edge runtimes have no file system, so they take a key string only: `template: 'welcome'`. Upload the file from a build step or a Node.js script there. - - The file reference keeps PostHog in step with the file: each send carries the file's content hash, and after an edit the next send uploads the file on its own. No deploy step is needed. A revert to content PostHog published before is the exception: see "Change an email the app already sends". + - When `host` is a reverse proxy or a self-hosted instance, also set `appHost` to the PostHog app URL, such as `https://us.posthog.com`. posthog-node never sends the secret key to a proxy, and without `appHost` the send fails with `missing_app_host`. + - Leave out `from` to send from the default sender. Pass `from`, and `replyTo` if the person wants one, only with an address on a verified sender, such as `'Example <hello@mail.example.com>'`. - **Other languages:** send by key over HTTP, with `Authorization: Bearer $POSTHOG_PROJECT_SECRET_API_KEY`. The host is the app host, `https://us.posthog.com` or `https://eu.posthog.com`: + **Other languages:** send over HTTP to the app host, `https://us.posthog.com` or `https://eu.posthog.com`, with `Authorization: Bearer $POSTHOG_PROJECT_SECRET_API_KEY` and an `Idempotency-Key` header: ``` POST <host>/api/projects/@current/messaging/send/email/ - {"template": {"key": "welcome"}, "to": "jane@example.com", "identifiers": {"distinct_id": "user_42"}, "message_data": {"first_name": "Jane"}} + Idempotency-Key: welcome:user_42 + {"to": "jane@example.com", "identifiers": {"distinct_id": "user_42"}, "template_key": "welcome", "subject": "Welcome to Example", "html": "<p>Hi Jane</p>"} ``` - - Also add an upload by key wherever the project deploys, such as a CI step or a release script: `PUT <host>/api/projects/@current/messaging_templates/by_key/<key>/` with `name`, `subject`, `html`, `preheader`, and `source` as in STEP 5. Otherwise an edited file never reaches PostHog. + - That body sends rendered content. A local template sends `"template": {"key": "welcome", "hash": "<content hash>"}` and `message_data` instead, with the hash computed as the reference says. + - A local template answers `409 template_outdated` when PostHog doesn't hold its content yet, on its first send too. Upload it with `PUT <host>/api/projects/@current/messaging_templates/by_key/<key>/` and `if_code_managed: true`, then send again with the same `Idempotency-Key`. + - A template made in PostHog sends `"template": {"id": "<template id>"}` and `message_data`. For both: - - Pass every value the template reads in `data` (Node.js) or `message_data` (HTTP). - - Pass the app's user ID as `distinctId` (Node.js) or `identifiers.distinct_id` (HTTP), so the send and its delivery events show on that person. - - Sends are not idempotent: two calls send two emails. Do not retry a send after a timeout or a server error, and never call the send more than once for one event. + - Pass every value the template reads in `templateData` (Node.js) or `message_data` (HTTP). + - Pass the app's user ID as `distinctId` (Node.js) or `identifiers.distinct_id` (HTTP), so the send and its delivery events show on that person. The email's engagement events count as analytics events. + - Every send has an idempotency key, and a retry with the same key never sends a second email. posthog-node makes a key when you pass none, and retries network errors, throttling, and server outages with it. + - When a job runner or a queue retries the code that sends, a generated key doesn't survive the retry. Pass `maxRetries: 0` and an `idempotencyKey` derived from the email's purpose, such as `` `welcome:${user.id}` ``. Over HTTP, derive the `Idempotency-Key` the same way. - Every test sends a real email. Ask the person before any test send, whether you run the code path or call `workflows-send-email`, and send only to an address they own. Save the test run's full output to a file and read it from there: never run it again to see more output, because each run sends another email. STEP 9: Handle errors by their code. - - `posthog-node` never throws on API errors. It returns `{ data, error }`. Branch on `error.code` and log `error.message`. - - Over HTTP, read `code` from the error body. New codes also carry `fix` and `docs_url`. + - `posthog-node` never throws on API errors. It returns `{ data, error }`. Branch on `error.code`, and log `error.message`, `error.why`, and `error.fix`. `error.name` follows Resend's error names. + - Over HTTP, read `code`, `why`, `fix`, and `docs_url` from the error body. + - A send with warnings still sent. posthog-node logs each warning once and returns them in `data.warnings`. - Each code has a section with its fix in the reference. The common ones: - - `sender_unavailable`: no verified default sender. Follow STEP 5. - - `not_found`, or a `404` without a code: the API is not enabled for the project. See STEP 6. - - `template_not_found`: no template has this key. Upload it (STEP 5). - - `template_file_unreadable`: the deployment doesn't include the HTML file. - - `missing_secret_key`, `authentication_failed`, `permission_denied`: the `phs_` key is missing, or lacks `hog_flow:write`. - - `immutable`: the template is managed by code. Change its file, as "Change an email the app already sends" says. + - `sender_unavailable`: the first send of a local template found no verified default sender. Verify one as in STEP 2, or ask the person to click **Make default** next to their verified sender in the email channel settings. + - `sender_not_verified`, `sender_not_allowed`: `from` is not a verified sender. Leave `from` out, or verify its domain (STEP 3). + - `invalid_recipient`, `recipient_suppressed`: PostHog won't send to this address. Do not retry. + - `sending_suspended`, `template_paused`: sending is stopped for the project or the template. Tell the person, and do not retry. + - `not_found`: see STEP 6. + - `template_outdated`: posthog-node uploads the template and sends again on its own. Over HTTP, follow STEP 8. + - A `409` that says the template was edited in PostHog: nothing was sent. Ask the person whether to keep the code's version or PostHog's, and never switch to another key to get around it. + - `invalid_idempotent_request`: the key was already used for another email. Make the key name the email's whole purpose. + - `quota_limited`: the project is over its email quota. Do not retry. + - `missing_secret_key`, a `401`, or a `403`: the `phs_` key is missing, or lacks `hog_flow:write`. + - `missing_app_host`: set `appHost` (STEP 8). + - `immutable`: the template is managed by code. Change it in the code, as "Change an email the app already sends" says. ## Change an email the app already sends -A code-managed template changes in its file in the repository. +A local template changes in the code. -When you can edit the repository, change the file yourself: - - Find the file by the template's key: the `templateFile()` call or the upload that uses it. The template's `source.path` points at it too, but posthog-node can store it relative to the app's directory. A prompt from **Edit with your agent** on the template's page in PostHog names the key, and the file when PostHog knows it. - - Keep the format of STEP 4, and read only `{{ variables.* }}` values that the send passes. - - Change the template only through its file. PostHog's template update, patch, and design tools refuse a code-managed template with `403 immutable`. - - Leave the template managed by code. Releasing it in PostHog is the person's decision, and the next upload from the file claims it back. +When you can edit the repository, change the code yourself: + - Find the template by its key: the `templateKey` of the send. + - Keep the format of STEP 5, and read only `{{ variables.* }}` values that the send passes. + - Change the template only in the code. PostHog's template update, patch, and design tools refuse a code-managed template with `403 immutable`. How the change goes live: - - **`templateFile()`:** the first send from code with the changed file uploads it. Usually that is the deployed app, after review. A local run of that code against the same project publishes it too. Say so when you ask before a test send from the code. Leave the upload to posthog-node. - - **Live before the deploy:** upload the file as in STEP 5 only when the person asks for that. Tell them that every running copy of the app sends the new version from then on. - - **Undo with `templateFile()`:** a revert to content PostHog published before goes live only through an upload as in STEP 5. A send from the reverted file gets the newer content, and posthog-node doesn't upload it. - - **By key:** a send never uploads. The project's deploy step from STEP 8 uploads the change. Without one, upload the file again as in STEP 5 after the change. + - The first send with the changed content uploads it. Usually that is the deployed app, after review. + - A local run of that code against the same project publishes it too. Say so when you ask before a test send. + - A send can come back with the warning `template_superseded`, for example after a revert to content PostHog sent before. PostHog sent its newer content: follow the warning's `fix`. -Without access to the repository, tell the person which file to change. Where the PostHog MCP has `workflows-request-email-template-change`, call it instead. It starts a PostHog Code task that changes the file and opens a pull request. Share the task link with the person. An agent that can edit the file never calls it. +Without access to the repository, tell the person which code to change. ## Reference files {references} -The reference is the transactional email API page. It covers the template format, `posthog-node`, both routes, the content hash, and every error code. +The reference is the transactional email API page. It covers the send forms, `posthog-node`, the content hash, idempotency, and every error code. ## Key principles -- **Sender first**: verify a sender before the first upload. -- **The file is the source**: the HTML file in the repository is the template. Change the file, never the copy in PostHog. +- **Sender first**: verify a sender before the first send. +- **The code is the source**: the local template in the code is the email. Change the code, never the copy in PostHog. - **Values from the send**: templates read `{{ variables.* }}` only. - **Server only**: the `phs_` key comes from an environment variable on the server. -- **No retries**: sends are not idempotent. +- **One email per key**: retries reuse the idempotency key, so a retry never sends twice. From 05f2f104da95a39502aedd42eacc580a4f84da34 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 22 Oct 2026 04:04:41 +0000 Subject: [PATCH 11/23] feat(skills): add a Wizard section to send-transactional-email Inside the Wizard the agent asks through wizard_ask, writes the secret key with set_env_values, reaches PostHog tools through posthog_exec, and ends a blocked run with an [ABORT] reason the Wizard's transactional-email program matches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../omnibus/send-transactional-email/description.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/context/skills/omnibus/send-transactional-email/description.md b/context/skills/omnibus/send-transactional-email/description.md index c47ab563..f2108345 100644 --- a/context/skills/omnibus/send-transactional-email/description.md +++ b/context/skills/omnibus/send-transactional-email/description.md @@ -136,6 +136,18 @@ How the change goes live: Without access to the repository, tell the person which code to change. +## Inside the PostHog Wizard + +Read this section only when the `mcp__wizard-tools__wizard_ask` tool exists. The Wizard has no chat, so the person answers through its tools. + +- The PostHog tools of this skill run through `posthog_exec`. Check that a tool exists with `search <name>` before you decide it is missing. +- Ask every question of this skill with `wizard_ask`, never in plain text. Batch the questions of one step into one call. +- When the person did not say which email to send, ask them. Offer a welcome email after signup as the first choice. +- STEP 7: ask for the project secret API key with `wizard_ask` and `sensitive: true`, then write the answer with `mcp__wizard-tools__set_env_values` as `POSTHOG_PROJECT_SECRET_API_KEY`. The answer is a reference that only `set_env_values` can read, so the key never shows in the run. +- When STEP 6 stops the run, emit `[ABORT] transactional email api not enabled` and stop. +- When the project has no server code that can send, emit `[ABORT] no server code found` and stop. +- Ask before a test send as STEP 8 says. When the person says no, skip it. + ## Reference files {references} From db4baf89d2523f9735c4b5b41981091998609359 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 22 Oct 2026 04:38:40 +0000 Subject: [PATCH 12/23] fix(skills): treat missing tools as inconclusive inside the Wizard The Wizard's login may lack the workflows and integrations scopes, so a hidden tool there no longer stops the run as "API not enabled". The Wizard section names its tools as the runtime shows them, and replyTo no longer needs a verified sender. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../omnibus/send-transactional-email/description.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/context/skills/omnibus/send-transactional-email/description.md b/context/skills/omnibus/send-transactional-email/description.md index f2108345..703f9131 100644 --- a/context/skills/omnibus/send-transactional-email/description.md +++ b/context/skills/omnibus/send-transactional-email/description.md @@ -81,7 +81,8 @@ STEP 8: Write the send. - Reuse the app's existing `posthog-node` client when it has one, and add `secretKey`. Keep `enableLocalEvaluation: false` unless the key also has `feature_flag:read` and the app uses local evaluation. - When the project token or the host is not configured yet, get the token from the PostHog MCP `projects-get` tool (`api_token`). The host is `https://us.i.posthog.com` for US Cloud and `https://eu.i.posthog.com` for EU Cloud. Ask the person which one when you can't tell. - When `host` is a reverse proxy or a self-hosted instance, also set `appHost` to the PostHog app URL, such as `https://us.posthog.com`. posthog-node never sends the secret key to a proxy, and without `appHost` the send fails with `missing_app_host`. - - Leave out `from` to send from the default sender. Pass `from`, and `replyTo` if the person wants one, only with an address on a verified sender, such as `'Example <hello@mail.example.com>'`. + - Leave out `from` to send from the default sender. Pass `from` only with an address on a verified sender, such as `'Example <hello@mail.example.com>'`. + - Pass `replyTo` when replies should go to another address, such as a support mailbox. It takes one address or a list, and needs no verified sender. **Other languages:** send over HTTP to the app host, `https://us.posthog.com` or `https://eu.posthog.com`, with `Authorization: Bearer $POSTHOG_PROJECT_SECRET_API_KEY` and an `Idempotency-Key` header: @@ -138,13 +139,14 @@ Without access to the repository, tell the person which code to change. ## Inside the PostHog Wizard -Read this section only when the `mcp__wizard-tools__wizard_ask` tool exists. The Wizard has no chat, so the person answers through its tools. +Read this section only when the Wizard's `wizard_ask` tool exists. Some runtimes name its tools `mcp__wizard-tools__wizard_ask` and `mcp__wizard-tools__set_env_values`: use the names the runtime shows. The Wizard has no chat, so the person answers through its tools. -- The PostHog tools of this skill run through `posthog_exec`. Check that a tool exists with `search <name>` before you decide it is missing. +- The PostHog tools of this skill run through `posthog_exec`. Check that a tool exists with `search <name>`. +- The Wizard's login may not carry the scopes of the `workflows-*` and `integrations-*` tools, so a missing tool does not mean the API is off. Skip the last bullet of STEP 1. When the sender tools are missing, take the last bullet of STEP 3. - Ask every question of this skill with `wizard_ask`, never in plain text. Batch the questions of one step into one call. - When the person did not say which email to send, ask them. Offer a welcome email after signup as the first choice. -- STEP 7: ask for the project secret API key with `wizard_ask` and `sensitive: true`, then write the answer with `mcp__wizard-tools__set_env_values` as `POSTHOG_PROJECT_SECRET_API_KEY`. The answer is a reference that only `set_env_values` can read, so the key never shows in the run. -- When STEP 6 stops the run, emit `[ABORT] transactional email api not enabled` and stop. +- STEP 7: ask for the project secret API key with `wizard_ask` and `sensitive: true`, then pass the answer to `set_env_values` as `POSTHOG_PROJECT_SECRET_API_KEY`. The answer is a reference that only `set_env_values` can read, so the key never shows in the run. +- When a send answers the `404` of STEP 6, emit `[ABORT] transactional email api not enabled` and stop. - When the project has no server code that can send, emit `[ABORT] no server code found` and stop. - Ask before a test send as STEP 8 says. When the person says no, skip it. From 161a6fd23a76f8edc2ea8d6dd6333f928a0c599d Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 22 Oct 2026 04:41:58 +0000 Subject: [PATCH 13/23] fix(skills): let only a send's 404 stop a Wizard run in STEP 6 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- context/skills/omnibus/send-transactional-email/description.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/context/skills/omnibus/send-transactional-email/description.md b/context/skills/omnibus/send-transactional-email/description.md index 703f9131..ac9d6a13 100644 --- a/context/skills/omnibus/send-transactional-email/description.md +++ b/context/skills/omnibus/send-transactional-email/description.md @@ -142,7 +142,7 @@ Without access to the repository, tell the person which code to change. Read this section only when the Wizard's `wizard_ask` tool exists. Some runtimes name its tools `mcp__wizard-tools__wizard_ask` and `mcp__wizard-tools__set_env_values`: use the names the runtime shows. The Wizard has no chat, so the person answers through its tools. - The PostHog tools of this skill run through `posthog_exec`. Check that a tool exists with `search <name>`. -- The Wizard's login may not carry the scopes of the `workflows-*` and `integrations-*` tools, so a missing tool does not mean the API is off. Skip the last bullet of STEP 1. When the sender tools are missing, take the last bullet of STEP 3. +- The Wizard's login may not carry the scopes of the `workflows-*` and `integrations-*` tools, so a missing tool does not mean the API is off. Skip the last bullet of STEP 1, and in STEP 6 only a send's `404` shows that the API is off. When the sender tools are missing, take the last bullet of STEP 3. - Ask every question of this skill with `wizard_ask`, never in plain text. Batch the questions of one step into one call. - When the person did not say which email to send, ask them. Offer a welcome email after signup as the first choice. - STEP 7: ask for the project secret API key with `wizard_ask` and `sensitive: true`, then pass the answer to `set_env_values` as `POSTHOG_PROJECT_SECRET_API_KEY`. The answer is a reference that only `set_env_values` can read, so the key never shows in the run. From 35e917ceb9dcb057dfc4ead90d7960bf9c20b98e Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 22 Oct 2026 06:26:41 +0000 Subject: [PATCH 14/23] fix(skills): ship a variant's own reference folder in its bundle The migrate group keeps Statsig's mapping and SDK reference under references/statsig/, and its step files point there, but the generator copied only the top-level reference files. Copy references/<variant-id>/ into each variant's bundle, and nothing of the other variants. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- scripts/lib/skill-generator.js | 32 +++++++++++++ .../skill-generator-references-folder.test.js | 47 +++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/scripts/lib/skill-generator.js b/scripts/lib/skill-generator.js index 454142ac..9af3e045 100644 --- a/scripts/lib/skill-generator.js +++ b/scripts/lib/skill-generator.js @@ -594,6 +594,31 @@ function generateFrontmatter(skill, version) { return '---\n' + yaml.dump(frontmatter) + '---\n\n'; } +/** + * Copy the markdown files of a variant's own folder, `references/<variant-id>/`, + * into the bundle. A group keeps what differs per vendor or platform there, so + * each bundle ships only its own variant's files. Returns their listing entries. + */ +function copyVariantReferences({ sourceDir, targetDir, variantId, configDir }) { + if (!fs.existsSync(sourceDir)) return []; + + const files = fs.readdirSync(sourceDir, { withFileTypes: true }) + .filter(entry => entry.isFile() && entry.name.endsWith('.md')) + .sort((a, b) => a.name.localeCompare(b.name)); + fs.mkdirSync(targetDir, { recursive: true }); + + return files.map(file => { + const parsed = matter(fs.readFileSync(path.join(sourceDir, file.name), 'utf8')); + const body = expandPartials(parsed.content, configDir).replace(/^\n+/, '').replace(/\s+$/, ''); + fs.writeFileSync(path.join(targetDir, file.name), body, 'utf8'); + const heading = body.match(/^#\s+(.+)$/m)?.[1]; + return { + filename: `${variantId}/${file.name}`, + description: parsed.data.description || heading || file.name, + }; + }); +} + /** * Generate a complete skill package * @@ -717,6 +742,13 @@ async function generateSkill({ }); } } + + references.push(...copyVariantReferences({ + sourceDir: path.join(sourceReferencesDir, skill._shortId), + targetDir: path.join(referencesDir, skill._shortId), + variantId: skill._shortId, + configDir, + })); } // Helper to process a doc entry (string URL or {url, title} object). diff --git a/scripts/lib/tests/skill-generator-references-folder.test.js b/scripts/lib/tests/skill-generator-references-folder.test.js index d5108e5f..925f520b 100644 --- a/scripts/lib/tests/skill-generator-references-folder.test.js +++ b/scripts/lib/tests/skill-generator-references-folder.test.js @@ -69,4 +69,51 @@ describe('generateSkill local references', () => { expect(readFileSync(generatedRef, 'utf8')).toBe('# Product analytics best practices\n\nDetails'); expect(readFileSync(generatedSkill, 'utf8')).toContain('references/product-analytics.md'); }); + + it("ships a variant's own reference folder and no other variant's", async () => { + createFixture({ + skills: { + migrate: { + 'description.md': '# Migrate\n\n{references}\n', + references: { + '1-presence.md': '---\nnext_step: null\n---\n\n# Presence\n\nRead `references/<variant-id>/mapping.md`.', + statsig: { 'mapping.md': '# Statsig mapping' }, + amplitude: { 'mapping.md': '# Amplitude mapping' }, + }, + }, + }, + }, tmpDir); + + const config = { + migrate: { + type: 'skill', + template: 'description.md', + variants: [ + { id: 'statsig', display_name: 'Statsig' }, + { id: 'amplitude', display_name: 'Amplitude' }, + ], + }, + }; + + const statsig = expandSkillGroups(config, tmpDir).find((skill) => skill.id === 'migrate-statsig'); + const outputDir = join(tmpDir, 'out'); + + await generateSkill({ + skill: statsig, + version: 'test', + repoRoot: tmpDir, + configDir: tmpDir, + outputDir, + skipPatterns: { global: [], examples: {} }, + commandmentsConfig: { commandments: {} }, + skillTemplate: statsig._template, + sharedDocs: [], + }); + + const references = join(outputDir, 'migrate-statsig', 'references'); + expect(readFileSync(join(references, 'statsig', 'mapping.md'), 'utf8')).toBe('# Statsig mapping'); + expect(existsSync(join(references, 'amplitude'))).toBe(false); + expect(readFileSync(join(outputDir, 'migrate-statsig', 'SKILL.md'), 'utf8')) + .toContain('`references/statsig/mapping.md`'); + }); }); From bd7d5359bcd073659c8ff19b19a17f241a2d0600 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 22 Oct 2026 06:40:11 +0000 Subject: [PATCH 15/23] refactor(skills): share the email sender setup as a partial Move the sender setup of send-transactional-email's STEP 3 into context/shared/email-sender-setup.md, so the email migration skill includes the same text. A partial now takes the indentation of its {{> name}} line on every line, so it nests under a step. The built send skill is unchanged byte for byte. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- context/shared/email-sender-setup.md | 15 +++++++++++++++ .../send-transactional-email/description.md | 11 +---------- scripts/lib/skill-generator.js | 10 ++++++---- scripts/lib/tests/partial-includes.test.js | 8 ++++++++ 4 files changed, 30 insertions(+), 14 deletions(-) create mode 100644 context/shared/email-sender-setup.md diff --git a/context/shared/email-sender-setup.md b/context/shared/email-sender-setup.md new file mode 100644 index 00000000..bdf95e3c --- /dev/null +++ b/context/shared/email-sender-setup.md @@ -0,0 +1,15 @@ +--- +title: Set up an email sender +--- + +- First check that the PostHog MCP has the sender tools: `integrations-email-create`, `integrations-email-verify-create`, `integrations-domain-connect-check-retrieve`, and `integrations-domain-connect-apply-url-create`. When they are missing, go to the last bullet of this step. +- When the `setting-up-an-email-domain` skill is available, follow it. It covers the details. +- Ask which domain to send from. Recommend a subdomain such as `mail.example.com`. Use the person's own domain, never a free mailbox domain such as gmail.com. +- Never create a sender for an address that already has one: creating it again marks it unverified. For an existing unverified sender, skip to its records. +- Create the sender with `integrations-email-create`, then get its DNS records with `integrations-email-verify-create`. +- PostHog never writes DNS. Call `integrations-domain-connect-check-retrieve` with the domain. When `supported` is true, give the person the URL from `integrations-domain-connect-apply-url-create`: they approve the records at their DNS host. Otherwise, give them the records that are not `success` as a table with type, name, value, and priority. +- Always include both SPF records, merged into an existing `v=spf1` record when there is one. Never add a second `_dmarc` record when the domain already has one. +- Call `integrations-email-verify-create` again after the records are published, at least a minute apart. `success` means done. `failed` means fix the records, then verify again. After about 15 minutes of `pending` with every record published, stop and tell the person that DNS is still propagating and they can ask you to check again later. +- A 403 that says the person lacks permissions in the project means a project admin must verify the sender. Do not retry. +- **When the sender tools are missing or refused**, the agent setup for sender domains is not available to this person yet. Do not try other integration tools. Ask the person to verify a sender in PostHog: Workflows, then Channels, then add an email sender on their domain and follow the DNS steps (https://posthog.com/docs/workflows/configure-channels). Wait until they confirm that the sender shows as verified, then continue. + diff --git a/context/skills/omnibus/send-transactional-email/description.md b/context/skills/omnibus/send-transactional-email/description.md index ac9d6a13..07c45138 100644 --- a/context/skills/omnibus/send-transactional-email/description.md +++ b/context/skills/omnibus/send-transactional-email/description.md @@ -22,16 +22,7 @@ STEP 2: Check for a verified sender through the PostHog MCP. - When a verified sender exists, go to STEP 4. STEP 3: Set up a sender when none is verified. - - First check that the PostHog MCP has the sender tools: `integrations-email-create`, `integrations-email-verify-create`, `integrations-domain-connect-check-retrieve`, and `integrations-domain-connect-apply-url-create`. When they are missing, go to the last bullet of this step. - - When the `setting-up-an-email-domain` skill is available, follow it. It covers the details. - - Ask which domain to send from. Recommend a subdomain such as `mail.example.com`. Use the person's own domain, never a free mailbox domain such as gmail.com. - - Never create a sender for an address that already has one: creating it again marks it unverified. For an existing unverified sender, skip to its records. - - Create the sender with `integrations-email-create`, then get its DNS records with `integrations-email-verify-create`. - - PostHog never writes DNS. Call `integrations-domain-connect-check-retrieve` with the domain. When `supported` is true, give the person the URL from `integrations-domain-connect-apply-url-create`: they approve the records at their DNS host. Otherwise, give them the records that are not `success` as a table with type, name, value, and priority. - - Always include both SPF records, merged into an existing `v=spf1` record when there is one. Never add a second `_dmarc` record when the domain already has one. - - Call `integrations-email-verify-create` again after the records are published, at least a minute apart. `success` means done. `failed` means fix the records, then verify again. After about 15 minutes of `pending` with every record published, stop and tell the person that DNS is still propagating and they can ask you to check again later. - - A 403 that says the person lacks permissions in the project means a project admin must verify the sender. Do not retry. - - **When the sender tools are missing or refused**, the agent setup for sender domains is not available to this person yet. Do not try other integration tools. Ask the person to verify a sender in PostHog: Workflows, then Channels, then add an email sender on their domain and follow the DNS steps (https://posthog.com/docs/workflows/configure-channels). Wait until they confirm that the sender shows as verified, then continue. + {{> email-sender-setup}} STEP 4: Pick the form of the send. - **Local template**, the default for a new email: the code holds the subject and the HTML with `{{ variables.* }}` placeholders, and each send passes the values in `templateData`. Write it as STEP 5 says. diff --git a/scripts/lib/skill-generator.js b/scripts/lib/skill-generator.js index 9af3e045..35eb4697 100644 --- a/scripts/lib/skill-generator.js +++ b/scripts/lib/skill-generator.js @@ -55,16 +55,18 @@ function loadYaml(configPath) { // A line that is exactly `{{> name}}` is replaced at build time with the body // of `<configDir>/shared/name.md` (frontmatter stripped), so shared prose like // the MCP tool-calling grammar lives in one canonical file and every skill that -// includes it stays in sync. -const PARTIAL_DIRECTIVE = /^[ \t]*\{\{>\s*([a-z0-9-]+)\s*\}\}[ \t]*$/gm; +// includes it stays in sync. The directive's indentation applies to every line +// of the partial, so a partial can nest under a step. +const PARTIAL_DIRECTIVE = /^([ \t]*)\{\{>\s*([a-z0-9-]+)\s*\}\}[ \t]*$/gm; function expandPartials(body, configDir) { - return body.replace(PARTIAL_DIRECTIVE, (_match, name) => { + return body.replace(PARTIAL_DIRECTIVE, (_match, indent, name) => { const partialPath = path.join(configDir, 'shared', `${name}.md`); if (!fs.existsSync(partialPath)) { throw new Error(`Partial include {{> ${name}}} references missing file ${partialPath}`); } - return matter(fs.readFileSync(partialPath, 'utf8')).content.trim(); + const partial = matter(fs.readFileSync(partialPath, 'utf8')).content.trim(); + return partial.replace(/^(?=.)/gm, indent); }); } diff --git a/scripts/lib/tests/partial-includes.test.js b/scripts/lib/tests/partial-includes.test.js index 5ebaa8e6..3741c14a 100644 --- a/scripts/lib/tests/partial-includes.test.js +++ b/scripts/lib/tests/partial-includes.test.js @@ -42,6 +42,14 @@ describe('expandPartials', () => { expect(out).toContain('After.'); }); + it("indents every line of the partial as far as the directive, so it nests in a step's list", () => { + writeFileSync(join(tmpDir, 'shared', 'sender.md'), '---\ntitle: Sender\n---\n\n- First bullet.\n- Second bullet.\n'); + + const out = expandPartials('STEP 3: Set up a sender.\n {{> sender}}\n\nSTEP 4: Send.', tmpDir); + + expect(out).toBe('STEP 3: Set up a sender.\n - First bullet.\n - Second bullet.\n\nSTEP 4: Send.'); + }); + it('throws when the referenced partial does not exist', () => { expect(() => expandPartials('{{> missing-partial}}', tmpDir)).toThrow(/missing-partial/); }); From 45ae5d59aecefc47118148709c41c1ad43515bc3 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 22 Oct 2026 06:40:11 +0000 Subject: [PATCH 16/23] feat(skills): add the migrate-email skill with a Resend variant v0 prepares a move off Resend and changes no send call site. It finds every Resend send, classifies each one as ready to move or staying on Resend with a reason, sets up the PostHog sender next to Resend's DNS records, imports Resend's suppression list when the import tool exists, hands over the project secret key, checks the sending tier, and writes posthog-migrate-email-resend-report.md with a summary block. Steps are provider-neutral; references/resend/ holds what is specific to Resend. Statsig and Resend now publish as wizard migrate subcommands. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- AGENTS.md | 10 +- context/skills/migrate-email/config.yaml | 17 ++++ context/skills/migrate-email/description.md | 55 +++++++++++ .../migrate-email/references/1-detect.md | 38 ++++++++ .../migrate-email/references/2-preflight.md | 41 ++++++++ .../migrate-email/references/3-inventory.md | 61 ++++++++++++ .../migrate-email/references/4-sender.md | 38 ++++++++ .../references/5-suppressions.md | 39 ++++++++ .../skills/migrate-email/references/6-key.md | 32 +++++++ .../migrate-email/references/7-report.md | 76 +++++++++++++++ .../references/resend/account.md | 42 ++++++++ .../references/resend/mapping.md | 46 +++++++++ .../references/resend/sdk-reference.md | 90 +++++++++++++++++ context/skills/migrate/config.yaml | 2 +- scripts/lib/tests/migrate-email-skill.test.js | 96 +++++++++++++++++++ 15 files changed, 681 insertions(+), 2 deletions(-) create mode 100644 context/skills/migrate-email/config.yaml create mode 100644 context/skills/migrate-email/description.md create mode 100644 context/skills/migrate-email/references/1-detect.md create mode 100644 context/skills/migrate-email/references/2-preflight.md create mode 100644 context/skills/migrate-email/references/3-inventory.md create mode 100644 context/skills/migrate-email/references/4-sender.md create mode 100644 context/skills/migrate-email/references/5-suppressions.md create mode 100644 context/skills/migrate-email/references/6-key.md create mode 100644 context/skills/migrate-email/references/7-report.md create mode 100644 context/skills/migrate-email/references/resend/account.md create mode 100644 context/skills/migrate-email/references/resend/mapping.md create mode 100644 context/skills/migrate-email/references/resend/sdk-reference.md create mode 100644 scripts/lib/tests/migrate-email-skill.test.js diff --git a/AGENTS.md b/AGENTS.md index 9bbb4e2c..2d5c0779 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,11 +72,12 @@ longer exist — only some keep an alias. | `wizard integrate` | `wizard` (default flow) | command removed | | `wizard events-audit` | `wizard audit events` | moved into `audit` family | | `wizard audit` (single) | `wizard audit <subcommand>` | now a family — see audit subcommands below | +| `wizard migrate` (single) | `wizard migrate <vendor>` | becomes a family with the wizard's family command — see migrate subcommands below | | `wizard audit-3000` | *removed* | retired | | `wizard revenue` | `wizard revenue-analytics` | renamed (old `revenue` removed) | | `wizard upload-sourcemaps` | `wizard upload-source-maps` | renamed; `upload-sourcemaps` kept as alias | -**Audit subcommands** — the one family today backed by skills from this repo: +**Audit subcommands**, backed by skills from this repo: | Subcommand | Backing skill | |---|---| @@ -88,6 +89,13 @@ longer exist — only some keep an alias. | `wizard audit session-replay` | `audit-session-replay` | | `wizard audit web-analytics` | *(wizard-native, not a skill here)* | +**Migrate subcommands**, published as `cliEntries` with `parentCommand: migrate`. Until the wizard runs `migrate` as a family, bare `wizard migrate` keeps running `migrate-statsig`: + +| Subcommand | Backing skill | +|---|---| +| `wizard migrate statsig` | `migrate-statsig` | +| `wizard migrate resend` | `migrate-email-resend` | + **Commands vs. skills:** those audit subcommands **are** skills, promoted to commands via `cli: role: command`. A `role: skill` skill is reachable only via `wizard skill <id>`. Same machinery, two surfaces — so `wizard audit <subcommand>` diff --git a/context/skills/migrate-email/config.yaml b/context/skills/migrate-email/config.yaml new file mode 100644 index 00000000..6b0b83c3 --- /dev/null +++ b/context/skills/migrate-email/config.yaml @@ -0,0 +1,17 @@ +# Email migration skill group - move transactional email from another provider to PostHog, one variant per provider +type: skill +template: description.md +category: migrate +description: Migrate transactional email from {display_name} to PostHog. Use when asked to migrate from {display_name}, replace {display_name} with PostHog, or switch email providers. It finds every {display_name} send in the code, sets up the sender and DNS next to {display_name}'s records, imports {display_name}'s suppression list, hands over the PostHog secret key, and writes a migration report. It changes no send call site yet. +tags: [migration, workflows] +cli: + role: command + parentCommand: migrate +references: + preamble: "**Read ONLY this file.** Do not read any other reference file until this one tells you to." +shared_docs: + - https://posthog.com/docs/workflows/transactional-email-api.md +variants: + - id: resend + display_name: Resend + tags: [resend] diff --git a/context/skills/migrate-email/description.md b/context/skills/migrate-email/description.md new file mode 100644 index 00000000..174a82b6 --- /dev/null +++ b/context/skills/migrate-email/description.md @@ -0,0 +1,55 @@ +# Migrate transactional email from {display_name} to PostHog + +Use this skill to move an app's transactional email, such as welcome emails, password resets, and receipts, from {display_name} to PostHog. + +This version prepares the move and changes no send call site. It finds every {display_name} send, sets up the PostHog sender and its DNS records next to {display_name}'s, imports {display_name}'s suppression list, hands over the PostHog secret key, and writes a report. Both providers can send during the cutover. + +The transactional email API is in beta. PostHog enables it per project. + +## Workflow + +{workflow} + +Read the step files in order, one at a time. Each step names the next one. Do not read a step before the one before it tells you to. + +## Provider files + +The folder `references/<provider>/` holds everything specific to {display_name}. The steps tell you when to read each file: + +- `sdk-reference.md`: how {display_name} looks in code, so you can find every send. +- `mapping.md`: how each send field maps to PostHog, and what keeps a call site on {display_name}. +- `account.md`: {display_name}'s DNS records and how to export its suppression list. + +Everything you need is on disk. Do not fetch {display_name}'s docs. + +## Plan file + +The plan file `.posthog-email-migration-plan.md` lives at the project root. Step 3 writes it, the later steps update it, and the last step writes the report from it and deletes it. + +## Rules + +- Change no send call site. This version only prepares the move. +- Never touch DNS or the {display_name} account. Give the person the records and the links, and let them make the change. +- Never remove the {display_name} package, its client, or its API key. +- Never create, upload, or edit a template in PostHog. +- Never write a recipient's email address into a file in the repository, the plan, or the report. Count addresses instead. +- Keep keys in environment variables. Never hardcode a key, and never ask the person to paste one into the chat. +- Do not commit. The person reviews the diff and commits. + +## Inside the PostHog Wizard + +Read this section only when the Wizard's `wizard_ask` tool exists. Some runtimes name its tools `mcp__wizard-tools__wizard_ask` and `mcp__wizard-tools__set_env_values`: use the names the runtime shows. The Wizard has no chat, so the person answers through its tools. + +- The PostHog tools of this skill run through `posthog_exec`. Check that a tool exists with `search <name>`. +- The Wizard's login may not carry the scopes of the `workflows-*` and `integrations-*` tools, so a missing tool does not mean the API is off. Step 2 says how to tell. +- Ask every question with `wizard_ask`, never in plain text. Batch the questions of one step into one call. +- Emit the `[STATUS]` line each step names when you start it, and the `[ABORT]` line a step names when it stops the run. +- Ask for a key with `wizard_ask` and `sensitive: true`, then pass the answer to `set_env_values` with the variable name the step gives. The answer is a reference that only `set_env_values` can read, so the key never shows in the run. + +Outside the Wizard, skip the `[STATUS]` lines, and tell the person why you stopped instead of an `[ABORT]` line. + +## Reference files + +{references} + +`transactional-email-api.md` is the transactional email API page. It covers senders, the send forms, `posthog-node`, idempotency, and every error code. diff --git a/context/skills/migrate-email/references/1-detect.md b/context/skills/migrate-email/references/1-detect.md new file mode 100644 index 00000000..94881bc9 --- /dev/null +++ b/context/skills/migrate-email/references/1-detect.md @@ -0,0 +1,38 @@ +--- +title: Find the email provider in the code +next_step: 2-preflight.md +--- + +# Step 1, find the email provider in the code + +Discovery only. Do not change any file. + +## Status + +``` +[STATUS] Looking for the email provider +``` + +## Procedure + +Read the provider's `sdk-reference.md`. It lists the provider's packages, imports, clients, and HTTP host, and the packages that look related but are not the provider. + +The provider is in this project when at least one of these is true: + +- Its package is in a dependency manifest, such as `package.json`, `requirements.txt`, `pyproject.toml`, `Gemfile`, `composer.json`, or `go.mod`. +- Source code imports it or creates its client. +- Source code calls its HTTP host. + +Search the source code, not only the manifest: a package without a call site is not a migration. Skip `node_modules`, build output, and lockfiles. + +A rendering package alone, such as `@react-email/components`, is not the provider. It only tells you how some emails are rendered. + +When you find nothing, stop before any change: + +``` +[ABORT] no email provider sends found +``` + +Do not widen the search to other email providers. That outcome is correct, not a failure to work around. + +When you find the provider, note each file that uses it for Step 3, and continue. diff --git a/context/skills/migrate-email/references/2-preflight.md b/context/skills/migrate-email/references/2-preflight.md new file mode 100644 index 00000000..ffd88264 --- /dev/null +++ b/context/skills/migrate-email/references/2-preflight.md @@ -0,0 +1,41 @@ +--- +title: Check that the project can send +next_step: 3-inventory.md +--- + +# Step 2, check that the project can send + +Do not change any file. + +## Status + +``` +[STATUS] Checking the PostHog project +``` + +## The transactional email API + +Check which PostHog MCP tools exist. + +- When there are no PostHog tools at all, the PostHog MCP is not connected or not signed in. Ask the person to connect it, and wait. +- When other PostHog tools exist but `workflows-upload-email-template` and `workflows-send-email` don't, the transactional email API is not enabled for this project. Stop and tell the person: the API is in beta, and they can ask PostHog in-app to enable it for their project. Then emit: + +``` +[ABORT] transactional email api not enabled +``` + +- When both tools exist, the API is on. Record `api: on`. + +Inside the Wizard, a missing tool can mean that the login lacks its scope, so missing tools do not stop the run. Record `api: not confirmed` and continue. The report asks the person to confirm it. + +## The sending tier + +PostHog gives each project a sending allowance: how many emails it sends per hour and per day. A new project starts on a low tier, and the tier grows with low bounce and complaint rates. Emails above the allowance are not dropped, they are sent later. So a cutover with more mail than the tier allows delays email, such as password resets. + +- Ask the person how many emails the app sends in its busiest hour and on its busiest day. +- Ask them to compare these numbers with the "Sending allowance" in PostHog: Workflows, then Messaging, then Reputation. +- Record the answer as `tier: fits`, `tier: too low`, or `tier: not checked` when they don't know. Do not stop on `too low`: the report tells the person to move the sends in parts, or to ask PostHog about a higher tier, before cutover. + +## Write down + +Keep `api` and `tier` for Step 3, which writes them into the plan file. diff --git a/context/skills/migrate-email/references/3-inventory.md b/context/skills/migrate-email/references/3-inventory.md new file mode 100644 index 00000000..1755818f --- /dev/null +++ b/context/skills/migrate-email/references/3-inventory.md @@ -0,0 +1,61 @@ +--- +title: List and classify every send +next_step: 4-sender.md +--- + +# Step 3, list and classify every send + +List every place the code sends email through the provider, and decide for each one whether PostHog can send it. Change no source file. The only file you write is the plan file. + +## Status + +``` +[STATUS] Listing every send +[STATUS] Writing the migration plan +``` + +## Find the sends + +Read the provider's `sdk-reference.md` for the call shapes, and its `mapping.md` for the fields. + +A **site** is one place in the code that calls the provider to send, such as one `emails.send(...)` call or one HTTP request to the send endpoint. A helper that wraps the call is one site, however many places call the helper. Read the callers of a helper anyway: they decide values such as how many recipients a send has. + +For each site, read the surrounding code and record: + +- The file and the line of the call. +- How it sends: `sdk`, `http`, or `smtp`. +- The content: `template` for a template stored at the provider, `react` for a component the provider renders, `html`, or `text`. A site that sets both `html` and `text` is `html`. +- Every field the site sets, including fields set on the payload object after it is created and fields set through options, such as an idempotency key. +- Where `from` comes from: the literal address, or the name of the environment variable or config value that holds it. + +Also list every other call to the provider that is not a send, such as reading an email's status, contacts, or domains. These calls stay on the provider. Note what each one does, because a site whose code reads the status back stays on the provider too. + +## Classify each site + +Go through `mapping.md` field by field. A site **moves** when PostHog can send every field it sets. A site **stays** on the provider when any one field or feature keeps it there: record that field or feature as the reason. Never plan to drop a field. + +When the code passes a value you can't read, such as a list of recipients built at run time, follow it to its callers. When you still can't tell, the site stays, and the reason says what you could not check. + +## Write the plan file + +Write `.posthog-email-migration-plan.md` at the project root with three sections. Never write an email address of a recipient into it. A `from` address is fine. + +A phase checklist, in this order. Tick `preflight` with what Step 2 recorded, and `inventory` once the file is written. Later steps tick the rest. + +``` +- [x] preflight — api: <on|not confirmed> — tier: <fits|too low|not checked> +- [x] inventory — <sites found> sites, <sites that stay> stay on the provider +- [ ] sender +- [ ] suppressions +- [ ] key +``` + +A sites table, one row per site, in source tree order: + +``` +| site | file | line | sends | content | from | moves | reason | +``` + +`moves` is `yes` or `no`. `reason` is empty when the site moves. + +A list of the other provider calls, each with its file, line, and what it does. diff --git a/context/skills/migrate-email/references/4-sender.md b/context/skills/migrate-email/references/4-sender.md new file mode 100644 index 00000000..87a968c0 --- /dev/null +++ b/context/skills/migrate-email/references/4-sender.md @@ -0,0 +1,38 @@ +--- +title: Set up the senders and their DNS records +next_step: 5-suppressions.md +--- + +# Step 4, set up the senders and their DNS records + +PostHog sends from a verified sender: an address on a domain whose DNS records PostHog checked. Set up a sender for each domain the app sends from, next to the provider's records, so both providers can send during the cutover. Never change DNS yourself, and never change the provider account. + +## Status + +``` +[STATUS] Setting up the PostHog sender +``` + +## List the addresses + +Collect every `from` in the plan's sites table. When a `from` comes from an environment variable or a config value, read it from the example env file or the config, or ask the person for the address. A `from` can carry a name, such as `Example <hello@mail.example.com>`: the sender is the address in it. + +Group the addresses by domain. Each domain needs one verified sender. + +## Check for verified senders + +Call `integrations-list` with `kind=email`, and read every page. A sender with `config.verified: true` can send. When the list shows senders without `config`, call `integrations-email-verify-create` once on the sender for the address: `success` means it is verified, and `pending` or `failed` means you continue from its records below. When the sender tools are missing, or the verify answers 403, ask the person whether a sender is verified for each domain. + +The first sender that verifies becomes the project's default sender. A send without `from` uses it. + +## Set up each domain without a verified sender + +Read the provider's `account.md` first. It lists the records the provider keeps on the domain and the label PostHog must not take. Keep every one of the provider's records: removing one stops the provider's sends before the cutover is done. + +Send from the domain of the existing `from` address, so the emails keep their sender. Ask for another domain only when the person wants one. + +{{> email-sender-setup}} + +## Write down + +Tick the `sender` phase in the plan file: `- [x] sender — <verified domains>/<domains> verified — dns_ready: <true|false>`. `dns_ready` is `true` only when every domain has a verified sender. List each domain that is not verified, with what the person still has to do. diff --git a/context/skills/migrate-email/references/5-suppressions.md b/context/skills/migrate-email/references/5-suppressions.md new file mode 100644 index 00000000..02471b58 --- /dev/null +++ b/context/skills/migrate-email/references/5-suppressions.md @@ -0,0 +1,39 @@ +--- +title: Import the suppression list +next_step: 6-key.md +--- + +# Step 5, import the suppression list + +The provider keeps a suppression list: addresses that hard-bounced or marked an email as spam. It skips them on every send. PostHog keeps its own list and starts empty, so without an import the first PostHog sends go to these addresses again. That hurts the sending reputation of the project and the domain. Import the list before cutover. + +## Status + +``` +[STATUS] Importing the suppression list +``` + +## Check the import tool + +Look for the PostHog MCP tool `email-suppressions-add`, and read its schema with `info`. + +When it is missing, the import is not available to this person yet. Do not try other tools, and do not use the opt-out tools: opt-outs are marketing preferences, and transactional email ignores them. Tick the phase as `- [ ] suppressions — not imported: no import tool`, and continue to the next step. The report tells the person to add the addresses in PostHog (Workflows, then Messaging, then Suppression list) before cutover. + +## Get the list + +Read the provider's `account.md`. It says how to export the list, which entries to take, and how each entry maps to PostHog. + +- Prefer the export the person downloads from the provider's dashboard. Ask them where they saved the file. +- Read the list from the provider's API only when the person gives you a key that can read it, through an environment variable that `account.md` names. The app's own sending key usually can't. +- Keep the list out of the repository. Read the file where the person saved it, or write the API's answer to a file in the system's temporary folder, and delete that file when the import is done. + +## Import it + +- Send the entries to `email-suppressions-add` in batches of the size its schema allows. Give each entry the source and the reason `account.md` names. +- Repeating an import is safe: an address already on the list stays on it. +- Never print the addresses, and never write them into the plan or the report. Count them. +- When a call fails, read its error and fix the call. When it fails again, stop the import and record how many entries you imported. + +## Write down + +Tick the phase in the plan file: `- [x] suppressions — <imported>/<entries> imported`. When the list is empty, write `- [x] suppressions — empty list`. When the person has no export and no key, write `- [ ] suppressions — not imported: <why>`. diff --git a/context/skills/migrate-email/references/6-key.md b/context/skills/migrate-email/references/6-key.md new file mode 100644 index 00000000..0b4a0820 --- /dev/null +++ b/context/skills/migrate-email/references/6-key.md @@ -0,0 +1,32 @@ +--- +title: Hand over the PostHog secret key +next_step: 7-report.md +--- + +# Step 6, hand over the PostHog secret key + +PostHog's send needs a project secret API key on the server. The provider's key stays where it is until the cutover is done. + +## Status + +``` +[STATUS] Setting up the PostHog secret key +``` + +## The PostHog key + +- Ask the person to create a project secret API key (it starts with `phs_`) with the `hog_flow:write` scope in their PostHog project settings. +- The key goes into `POSTHOG_PROJECT_SECRET_API_KEY`, in the env file the app loads its provider key from, such as `.env` or `.env.local`. Check that git ignores that file. When it doesn't, ask the person where the key goes. +- Ask the person to add the key to that file themselves. Inside the Wizard, ask for it and store it as the "Inside the PostHog Wizard" section of `SKILL.md` says. +- Add the variable name, without a value, to the project's env example file when it has one. +- Never hardcode the key, never commit it, and never ask the person to paste it into the chat. + +## Every other place the provider key lives + +The provider's `sdk-reference.md` names its key's environment variable. Search the whole repository for that name, including CI workflows, deploy and hosting config, Docker and compose files, env example files, and docs. Skip `node_modules` and build output. + +List each place with its file and line. Each one needs `POSTHOG_PROJECT_SECRET_API_KEY` too before the cutover, for example as a CI secret or a hosting environment variable. Do not change these files: the person adds the secret where the platform stores it. + +## Write down + +Tick the phase in the plan file: `- [x] key — POSTHOG_PROJECT_SECRET_API_KEY set in <file>`, or `- [ ] key — not set: <why>`. Under the phase list, add the places of the provider key. diff --git a/context/skills/migrate-email/references/7-report.md b/context/skills/migrate-email/references/7-report.md new file mode 100644 index 00000000..0dfb9a33 --- /dev/null +++ b/context/skills/migrate-email/references/7-report.md @@ -0,0 +1,76 @@ +--- +title: Write the migration report +next_step: null +--- + +# Step 7, write the migration report + +The report comes from the plan file. Invent nothing. + +## Status + +``` +[STATUS] Writing the migration report +``` + +## Write the report + +Read `.posthog-email-migration-plan.md`. Write `posthog-migrate-email-<provider>-report.md` at the project root, where `<provider>` is the name of the provider folder under `references/`. Use the structure below, then delete the plan file. Never write a recipient's email address into the report. + +```markdown +# Email migration report: <provider name> to PostHog + +<One or two sentences: how many sites were found, how many can move, how many stay, and whether the sender and the suppression list are ready.> + +## Send call sites + +| File | Line | Content | Status | +|------|------|---------|--------| + +<One row per site. Status is "Ready to move" or "Stays on <provider name>: <reason>".> + +## Other <provider name> calls + +<Each call that is not a send, with its file, line, and what it does. These stay on <provider name>.> + +## Sender and DNS + +<Each sending domain: verified, or what the person still has to do, with the records or the Domain Connect link.> + +## Suppression list + +<How many addresses were imported, or why none were.> + +## PostHog secret key + +<Where POSTHOG_PROJECT_SECRET_API_KEY is set, and each other place the provider key lives that needs the PostHog key too.> + +## Sending tier + +<What the person found, or that it was not checked.> + +## Before cutover + +<A numbered list of everything the person still has to do. Include each unverified domain, a missing suppression import, each place that needs the PostHog key, a tier that is too low or not checked, an API that is not confirmed, and each site that stays with what would let it move. When nothing is left, write "Nothing left before cutover."> + +## Summary +``` + +End the report with the summary block, as the last thing in the file. The Wizard reads it, so keep the keys and their order: + +```yaml +from: <provider folder name> +sites_found: <every send site in the table> +sites_rewritten: <sites this run changed to send with PostHog> +sites_left: <sites that stay on the provider, each with a reason> +dns_ready: <true when every sending domain has a verified sender, else false> +suppressions_ready: <true when the list was imported or was empty, else false> +``` + +This version changes no site, so `sites_rewritten` is `0`. Sites that are ready to move count in `sites_found` only. + +After the report is written, print its path: + +``` +Created migration report: <absolute path of the report> +``` diff --git a/context/skills/migrate-email/references/resend/account.md b/context/skills/migrate-email/references/resend/account.md new file mode 100644 index 00000000..dfd42d4b --- /dev/null +++ b/context/skills/migrate-email/references/resend/account.md @@ -0,0 +1,42 @@ +# Resend account + +What the migration needs from the person's Resend account. The agent never changes the account: it reads what the person exports or allows. + +## DNS records + +A domain verified at Resend carries these records. Keep all of them until the cutover is done. + +| Record | Name | What it does | +|---|---|---| +| `TXT` (DKIM) | `resend._domainkey` | Signs Resend's mail | +| `MX` | `send` | Return path for Resend's bounces | +| `TXT` (SPF) | `send` | Allows Resend to send for the return path | +| `TXT` (DMARC) | `_dmarc` | Optional, shared by every provider on the domain | + +- `send` is Resend's default return-path label. A domain can use a custom one: the Resend dashboard shows it under Domains. +- PostHog's sender uses its own label for its MAIL FROM, `feedback` by default. Never give PostHog the label Resend uses. +- PostHog's DKIM records have their own names, so they sit next to `resend._domainkey`. +- Keep the one `_dmarc` record. Never add a second one. + +## Suppression list + +Resend suppresses an address after a hard bounce or a spam complaint, and keeps addresses the team added by hand. + +**Export from the dashboard (preferred).** In Resend: Emails, then Suppressions, then **Download CSV**. Read the header row to find the address column and the origin column. + +**Read from the API.** `GET https://api.resend.com/suppressions?limit=100` with `Authorization: Bearer $RESEND_SUPPRESSIONS_API_KEY`. + +- The answer is `{ "object": "list", "has_more": <bool>, "data": [{ "id", "email", "origin", "source_id", "created_at" }] }`. +- Page with `after=<id of the last entry>` until `has_more` is `false`. +- Resend allows 10 requests per second per team. Wait and retry on `429`. +- A sending-only key can't read the list. Ask the person to create a full-access key, store it in `RESEND_SUPPRESSIONS_API_KEY` for the import, and delete the key in Resend afterwards. + +**Map each entry.** + +| Resend `origin` | PostHog source | Reason | +|---|---|---| +| `bounce` | bounce | `Imported from Resend: hard bounce` | +| `complaint` | complaint | `Imported from Resend: spam complaint` | +| `manual` | manual | `Imported from Resend: added by hand` | + +**Leave out unsubscribed contacts.** A Resend contact with `unsubscribed: true` still gets transactional email, and PostHog's opt-outs apply to marketing messages only. They are not suppressions. diff --git a/context/skills/migrate-email/references/resend/mapping.md b/context/skills/migrate-email/references/resend/mapping.md new file mode 100644 index 00000000..eecb90fb --- /dev/null +++ b/context/skills/migrate-email/references/resend/mapping.md @@ -0,0 +1,46 @@ +# Resend to PostHog mapping + +This file decides, field by field, whether PostHog can send a Resend call site. A site moves only when every field it sets moves. Otherwise it stays on Resend, and the reason names the first field or feature that keeps it there. `transactional-email-api.md` has the PostHog side in full. + +## Fields + +| Resend | PostHog | A site stays on Resend when | +|---|---|---| +| `from` | `from` | Never. It needs a verified sender (Step 4) | +| `to` | `to` | `to` can hold more than one address. A list with exactly one address moves as that address | +| `subject` | `subject` | Never | +| `html` | `html` | Never | +| `text` | `text` | Never | +| `react` | `html`, rendered in the app | Never. The app renders the component | +| `template` | a local template in the app's code | The template uses syntax beyond variables and fallbacks | +| `cc`, `bcc` | `cc`, `bcc` | An entry carries a name, such as `Jane <jane@example.com>`, or there are more than 10 | +| `replyTo` / `reply_to` | `replyTo` / `reply_to` | Never | +| `headers` | `headers` | A header is one PostHog sets itself (`From`, `To`, `Cc`, `Bcc`, `Reply-To`, `Subject`, `Date`, `Message-ID`, `MIME-Version`, `Sender`, `Return-Path`, `Auto-Submitted`, any `Content-*`, `X-PostHog-*`, `X-SES-*`), a value has a line break or a non-ASCII character, or there are more than 10 | +| `tags` | `tags` | A name or value has characters other than ASCII letters, digits, `_`, and `-`, a name starts with `ph_`, or there are more than 10 | +| `attachments` | `attachments` | An attachment uses `path` (a URL Resend fetches), the files can be more than 10 or more than 512 KB in total, or a file type is one mailbox providers block, such as `.exe` | +| `idempotencyKey` / `Idempotency-Key` | `idempotencyKey` / `Idempotency-Key` | Never. The key keeps its value | +| `scheduledAt` / `scheduled_at` | none | Always | +| `topicId` / `topic_id` | none | Always | + +## Calls + +| Resend call | A site stays on Resend when | +|---|---| +| `emails.send`, `emails.create`, `Emails.send`, `POST /emails` | A field above keeps it | +| `batch.send`, `Batch.send`, `POST /emails/batch` | Always | +| SMTP through `smtp.resend.com` | Always. PostHog has no SMTP relay | +| Laravel's or Rails' `resend` mailer | Always. PostHog has no mailer driver | +| A send in a language other than JavaScript or TypeScript | Always, until the skill moves that language | + +## Code around the send + +| Code | A site stays on Resend when | +|---|---| +| `if (error.name === '...')` | The name is not one of PostHog's: `validation_error`, `missing_api_key`, `invalid_permission`, `not_found`, `concurrent_idempotent_requests`, `invalid_idempotent_request`, `rate_limit_exceeded`, `monthly_quota_exceeded`, `service_unavailable`, `application_error` | +| `emails.get(id)` and a branch on `last_event` | Always: the send and its status read stay together. PostHog's status is not `last_event` | +| `data.id` stored for a webhook or a status read | Always, when Resend webhooks or status reads use the id | + +## Content + +- `html`, `text`, and `react` content is rendered for one recipient before the send. PostHog sends it as it is. +- A `template` site needs the template's content from the Resend dashboard, because the code has only its id. PostHog's Liquid escapes HTML in a variable's value. A variable the app fills with HTML keeps the site on Resend. diff --git a/context/skills/migrate-email/references/resend/sdk-reference.md b/context/skills/migrate-email/references/resend/sdk-reference.md new file mode 100644 index 00000000..fa3cf5f0 --- /dev/null +++ b/context/skills/migrate-email/references/resend/sdk-reference.md @@ -0,0 +1,90 @@ +# Resend SDK reference + +This file says what Resend looks like in a codebase. It does not describe PostHog. `mapping.md` maps each field to PostHog. + +## Key + +The API key lives in `RESEND_API_KEY` by convention. Apps also use their own names, such as `PRIVATE_RESEND_API_KEY`: take the name the client reads. + +## Packages + +- Node.js: `resend`. +- Python: `resend` (PyPI). +- Ruby: `resend` (RubyGems), and `resend-rails`. +- PHP: `resend/resend-php`, and `resend/resend-laravel` for Laravel. +- Go: `github.com/resend/resend-go`. +- Elixir: `resend` (Hex). +- Java: `com.resend:resend-java`. +- .NET: `Resend`. +- Rust: `resend-rs`. + +These are not Resend, and alone they are not a migration: `@react-email/components`, `@react-email/render`, `react-email`, and `jsx-email` render emails, and `nodemailer` sends through any SMTP server. + +## Clients + +```js +import { Resend } from 'resend' +const resend = new Resend(process.env.RESEND_API_KEY) +``` + +```python +import resend +resend.api_key = os.environ["RESEND_API_KEY"] +``` + +Look for `new Resend(`, `from 'resend'`, `require('resend')`, `import resend`, `Resend::`, and `resend.NewClient(`. + +## Sends + +**Node.js.** `resend.emails.send(payload, options?)`, and its alias `resend.emails.create(...)`. The answer is `{ data, error }`: `data.id` is the email's id, and `error` has `name`, `message`, and `statusCode`. The SDK does not throw on API errors. + +```js +const { data, error } = await resend.emails.send({ + from: 'Example <hello@mail.example.com>', + to: ['jane@example.com'], + subject: 'Welcome', + html: '<p>Welcome</p>', +}, { idempotencyKey: `welcome:${user.id}` }) +``` + +**Python.** `resend.Emails.send(params)`, with the same fields in snake_case, and `options={"idempotency_key": ...}`. It raises on API errors. + +**Batch.** `resend.batch.send([...])` in Node.js and `resend.Batch.send([...])` in Python send up to 100 emails in one call. + +**HTTP.** `POST https://api.resend.com/emails` with `Authorization: Bearer <key>`, a JSON body with the snake_case fields, and an optional `Idempotency-Key` header. `POST https://api.resend.com/emails/batch` is the batch send. Look for `api.resend.com` in `fetch`, `axios`, `got`, `requests`, `httpx`, and `curl` calls. + +**SMTP.** Resend also takes mail over SMTP at `smtp.resend.com`, with the user `resend` and the API key as the password. Look for that host in Nodemailer transports, `MAIL_HOST` or `SMTP_HOST` settings, and framework mail config. + +**Frameworks.** Laravel's `resend` mailer (`MAIL_MAILER=resend`) and Rails' `resend` delivery method send every framework mail through Resend: each mailable or mailer that sends is a site. + +## Send fields + +| Node.js field | HTTP field | Value | +|---|---|---| +| `from` | `from` | One address, with an optional name: `Name <a@example.com>` | +| `to` | `to` | One address, or a list of up to 50 | +| `subject` | `subject` | String | +| `html` | `html` | String | +| `text` | `text` | String. Resend derives one from `html` when it is absent | +| `react` | (none) | A React element that the Node.js SDK renders to HTML | +| `cc`, `bcc` | `cc`, `bcc` | One address or a list | +| `replyTo` | `reply_to` | One address or a list | +| `headers` | `headers` | Object of custom headers | +| `tags` | `tags` | List of `{ name, value }` | +| `attachments` | `attachments` | List of `{ filename, content, path, contentType, contentId }`. `content` is a Buffer or base64, `path` is a URL Resend fetches | +| `scheduledAt` | `scheduled_at` | A time to send later | +| `topicId` | `topic_id` | A topic the recipient can unsubscribe from | +| `template` | `template` | `{ id, variables }`: a template stored at Resend | +| `idempotencyKey` option | `Idempotency-Key` header | Up to 256 characters, kept for 24 hours | + +## Templates stored at Resend + +A send with `template: { id: 'tmpl_...', variables: { FIRST_NAME: 'Jane' } }` renders a template from the Resend dashboard. Its content is not in the code. The template reads variables as `{{{FIRST_NAME}}}`, with an optional fallback: `{{{FIRST_NAME|there}}}`. + +## Status reads + +`resend.emails.get(id)` and `GET https://api.resend.com/emails/{id}` return the email with `last_event`, such as `delivered`, `bounced`, or `complained`. Code that branches on `last_event` reads Resend's delivery state. + +## Other APIs + +`resend.contacts`, `resend.audiences`, `resend.segments`, `resend.broadcasts`, `resend.domains`, `resend.apiKeys`, `resend.webhooks`, and their HTTP routes are not transactional sends. diff --git a/context/skills/migrate/config.yaml b/context/skills/migrate/config.yaml index c56409d5..d4e2cb5a 100644 --- a/context/skills/migrate/config.yaml +++ b/context/skills/migrate/config.yaml @@ -5,7 +5,7 @@ description: Migrate an existing analytics or feature-flag vendor to PostHog. Re tags: [migration] cli: role: command - command: migrate + parentCommand: migrate references: preamble: "**Read ONLY this file.** Do not read any other reference file until this one tells you to." shared_docs: [] diff --git a/scripts/lib/tests/migrate-email-skill.test.js b/scripts/lib/tests/migrate-email-skill.test.js new file mode 100644 index 00000000..29ca4ea6 --- /dev/null +++ b/scripts/lib/tests/migrate-email-skill.test.js @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'fs'; +import { join, relative } from 'path'; +import { tmpdir } from 'os'; + +const CONFIG_DIR = join(process.cwd(), 'context'); +const DOCS_URL = 'https://posthog.com/docs/workflows/transactional-email-api.md'; +const DOCS_PAGE = '# Transactional email API\n\nSend one email to one person from your server code.\n'; +const REFERENCE_PATH = /references\/[A-Za-z0-9_./-]+\.md/g; + +async function loadSkills() { + vi.resetModules(); + vi.stubEnv('DOCS_CACHE_TTL_MS', '0'); + vi.stubGlobal('fetch', async (url) => { + if (url !== DOCS_URL) return new Response('not found', { status: 404, statusText: 'Not Found' }); + return new Response(DOCS_PAGE, { status: 200 }); + }); + const generator = await import('../skill-generator.js'); + const skills = generator.expandSkillGroups(generator.loadSkillsConfig(CONFIG_DIR), CONFIG_DIR); + return { generator, skills }; +} + +async function buildSkill(skillId, outputDir) { + const { generator, skills } = await loadSkills(); + const skill = skills.find((candidate) => candidate.id === skillId); + expect(skill).toBeDefined(); + + await generator.generateSkill({ + skill, + version: 'test', + repoRoot: process.cwd(), + configDir: CONFIG_DIR, + outputDir, + skipPatterns: { global: [], examples: {} }, + commandmentsConfig: { commandments: {} }, + skillTemplate: skill._template, + sharedDocs: skill._sharedDocs, + }); + return join(outputDir, skillId); +} + +function markdownFiles(dir) { + return readdirSync(dir, { withFileTypes: true, recursive: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith('.md')) + .map((entry) => join(entry.parentPath, entry.name)); +} + +function unresolvedReferences(skillDir) { + const missing = []; + for (const file of markdownFiles(skillDir)) { + for (const reference of readFileSync(file, 'utf8').match(REFERENCE_PATH) ?? []) { + if (!existsSync(join(skillDir, reference))) missing.push(`${relative(skillDir, file)} → ${reference}`); + } + } + return missing; +} + +describe('migrate-email skill', () => { + let outputDir; + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + if (outputDir) rmSync(outputDir, { recursive: true, force: true }); + }); + + it.each(['migrate-email-resend', 'migrate-statsig'])( + 'builds %s as a bundle whose every reference path resolves', + async (skillId) => { + outputDir = mkdtempSync(join(tmpdir(), 'migrate-skill-')); + + const skillDir = await buildSkill(skillId, outputDir); + + expect(readFileSync(join(skillDir, 'SKILL.md'), 'utf8')).toMatch(new RegExp(`^---\\nname: ${skillId}\\n`)); + expect(unresolvedReferences(skillDir)).toEqual([]); + }, + ); + + it('bundles the send API reference and the Resend files without the send skill', async () => { + outputDir = mkdtempSync(join(tmpdir(), 'migrate-skill-')); + + const skillDir = await buildSkill('migrate-email-resend', outputDir); + + expect(readFileSync(join(skillDir, 'references', 'transactional-email-api.md'), 'utf8')).toBe(DOCS_PAGE); + expect(existsSync(join(skillDir, 'references', 'resend', 'sdk-reference.md'))).toBe(true); + expect(markdownFiles(skillDir).map((file) => readFileSync(file, 'utf8')).join('\n')).not.toMatch(/\{\{>|\{display_name\}|\{references\}/); + }); + + it('publishes Resend and Statsig as wizard migrate subcommands', async () => { + const { skills } = await loadSkills(); + const cliOf = (id) => skills.find((skill) => skill.id === id)?._cli; + + expect(cliOf('migrate-email-resend')).toEqual({ role: 'command', parentCommand: 'migrate', command: 'resend' }); + expect(cliOf('migrate-statsig')).toEqual({ role: 'command', parentCommand: 'migrate', command: 'statsig' }); + }); +}); From 0a48cff2263eede9054deb110fac5349cb908da1 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 22 Oct 2026 06:46:19 +0000 Subject: [PATCH 17/23] fix(skills): keep migrate-email's summary values bare and define a site Fixture runs wrote reasons into the summary block, which the Wizard can't parse, and counted a forwarding helper as one site or as each caller. The report step now shows bare values, and a site is the place that decides a send's content and fields. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../migrate-email/references/3-inventory.md | 5 +++- .../migrate-email/references/7-report.md | 23 ++++++++++++------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/context/skills/migrate-email/references/3-inventory.md b/context/skills/migrate-email/references/3-inventory.md index 1755818f..884e9005 100644 --- a/context/skills/migrate-email/references/3-inventory.md +++ b/context/skills/migrate-email/references/3-inventory.md @@ -18,7 +18,10 @@ List every place the code sends email through the provider, and decide for each Read the provider's `sdk-reference.md` for the call shapes, and its `mapping.md` for the fields. -A **site** is one place in the code that calls the provider to send, such as one `emails.send(...)` call or one HTTP request to the send endpoint. A helper that wraps the call is one site, however many places call the helper. Read the callers of a helper anyway: they decide values such as how many recipients a send has. +A **site** is one place in the code that decides what a send contains: its content and its fields. Usually that is the `emails.send(...)` call or the HTTP request itself. + +- When a helper builds the send from a few parameters, such as a subject and a template name, the helper is one site, however many places call it. Read its callers anyway: they decide values such as how many recipients a send has. +- When a helper only forwards a payload that its callers build, each caller is a site, and the helper is not. For each site, read the surrounding code and record: diff --git a/context/skills/migrate-email/references/7-report.md b/context/skills/migrate-email/references/7-report.md index 0dfb9a33..6869e754 100644 --- a/context/skills/migrate-email/references/7-report.md +++ b/context/skills/migrate-email/references/7-report.md @@ -56,18 +56,25 @@ Read `.posthog-email-migration-plan.md`. Write `posthog-migrate-email-<provider> ## Summary ``` -End the report with the summary block, as the last thing in the file. The Wizard reads it, so keep the keys and their order: +End the report with the summary block, as the last thing in the file. The Wizard parses it, so keep the keys, their order, and bare values: a whole number, `true`, `false`, or the provider folder name. Never add a comment or a reason to a value. The reasons are in the tables above. For example: ```yaml -from: <provider folder name> -sites_found: <every send site in the table> -sites_rewritten: <sites this run changed to send with PostHog> -sites_left: <sites that stay on the provider, each with a reason> -dns_ready: <true when every sending domain has a verified sender, else false> -suppressions_ready: <true when the list was imported or was empty, else false> +from: resend +sites_found: 3 +sites_rewritten: 0 +sites_left: 1 +dns_ready: false +suppressions_ready: true ``` -This version changes no site, so `sites_rewritten` is `0`. Sites that are ready to move count in `sites_found` only. +- `from`: the provider folder name. +- `sites_found`: every row of the sites table. +- `sites_rewritten`: the sites this run changed to send with PostHog. This version changes none, so it is `0`. +- `sites_left`: the sites that stay on the provider. +- `dns_ready`: `true` when every sending domain has a verified sender. +- `suppressions_ready`: `true` when the suppression list was imported or was empty. + +Sites that are ready to move count in `sites_found` only. After the report is written, print its path: From 8ba31c315c6cf9a4ade1e8baad41cf0eb14fe19b Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 22 Oct 2026 07:00:11 +0000 Subject: [PATCH 18/23] refactor(skills): share the posthog-node client setup as a partial Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- context/shared/email-node-client.md | 7 +++++++ .../skills/omnibus/send-transactional-email/description.md | 4 +--- 2 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 context/shared/email-node-client.md diff --git a/context/shared/email-node-client.md b/context/shared/email-node-client.md new file mode 100644 index 00000000..3f26ccb4 --- /dev/null +++ b/context/shared/email-node-client.md @@ -0,0 +1,7 @@ +--- +title: Configure the posthog-node client for email +--- + +- Reuse the app's existing `posthog-node` client when it has one, and add `secretKey`. Keep `enableLocalEvaluation: false` unless the key also has `feature_flag:read` and the app uses local evaluation. +- When the project token or the host is not configured yet, get the token from the PostHog MCP `projects-get` tool (`api_token`). The host is `https://us.i.posthog.com` for US Cloud and `https://eu.i.posthog.com` for EU Cloud. Ask the person which one when you can't tell. +- When `host` is a reverse proxy or a self-hosted instance, also set `appHost` to the PostHog app URL, such as `https://us.posthog.com`. posthog-node never sends the secret key to a proxy, and without `appHost` the send fails with `missing_app_host`. diff --git a/context/skills/omnibus/send-transactional-email/description.md b/context/skills/omnibus/send-transactional-email/description.md index 07c45138..0f3aa6a1 100644 --- a/context/skills/omnibus/send-transactional-email/description.md +++ b/context/skills/omnibus/send-transactional-email/description.md @@ -69,9 +69,7 @@ STEP 8: Write the send. - Rendered content sends `templateKey`, `subject`, and the rendered `html` without `templateData`: `html: await render(<PasswordReset url={url} />)`. - A template made in PostHog sends `templateId` and `templateData`, and no `templateKey`, `subject`, or `html`. - - Reuse the app's existing `posthog-node` client when it has one, and add `secretKey`. Keep `enableLocalEvaluation: false` unless the key also has `feature_flag:read` and the app uses local evaluation. - - When the project token or the host is not configured yet, get the token from the PostHog MCP `projects-get` tool (`api_token`). The host is `https://us.i.posthog.com` for US Cloud and `https://eu.i.posthog.com` for EU Cloud. Ask the person which one when you can't tell. - - When `host` is a reverse proxy or a self-hosted instance, also set `appHost` to the PostHog app URL, such as `https://us.posthog.com`. posthog-node never sends the secret key to a proxy, and without `appHost` the send fails with `missing_app_host`. + {{> email-node-client}} - Leave out `from` to send from the default sender. Pass `from` only with an address on a verified sender, such as `'Example <hello@mail.example.com>'`. - Pass `replyTo` when replies should go to another address, such as a support mailbox. It takes one address or a list, and needs no verified sender. From a4916b9bfaf1ee4013a68e6511fed3492e7168af Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 22 Oct 2026 07:00:11 +0000 Subject: [PATCH 19/23] feat(skills): move Resend sends to posthog-node in migrate-email The migrate-email skill now rewrites every call site PostHog can send: hosted Resend templates become local templates, React and html/text sites send rendered content, and the client carries migratedFrom. It checks the build and one email on one person before it writes the report. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- context/skills/migrate-email/config.yaml | 2 +- context/skills/migrate-email/description.md | 12 ++-- .../migrate-email/references/3-inventory.md | 4 ++ .../skills/migrate-email/references/6-key.md | 2 +- .../migrate-email/references/7-rewrite.md | 68 +++++++++++++++++++ .../migrate-email/references/8-verify.md | 48 +++++++++++++ .../references/{7-report.md => 9-report.md} | 30 +++++--- .../references/resend/account.md | 18 ++++- .../references/resend/mapping.md | 41 ++++++++++- .../references/resend/sdk-reference.md | 7 +- scripts/lib/tests/migrate-email-skill.test.js | 19 ++++++ 11 files changed, 227 insertions(+), 24 deletions(-) create mode 100644 context/skills/migrate-email/references/7-rewrite.md create mode 100644 context/skills/migrate-email/references/8-verify.md rename context/skills/migrate-email/references/{7-report.md => 9-report.md} (58%) diff --git a/context/skills/migrate-email/config.yaml b/context/skills/migrate-email/config.yaml index 6b0b83c3..bf496738 100644 --- a/context/skills/migrate-email/config.yaml +++ b/context/skills/migrate-email/config.yaml @@ -2,7 +2,7 @@ type: skill template: description.md category: migrate -description: Migrate transactional email from {display_name} to PostHog. Use when asked to migrate from {display_name}, replace {display_name} with PostHog, or switch email providers. It finds every {display_name} send in the code, sets up the sender and DNS next to {display_name}'s records, imports {display_name}'s suppression list, hands over the PostHog secret key, and writes a migration report. It changes no send call site yet. +description: Migrate transactional email from {display_name} to PostHog. Use when asked to migrate from {display_name}, replace {display_name} with PostHog, or switch email providers. It finds every {display_name} send in the code, sets up the sender and DNS next to {display_name}'s records, imports {display_name}'s suppression list, moves each send PostHog can take to posthog-node, and writes a migration report that lists the sends that stay. tags: [migration, workflows] cli: role: command diff --git a/context/skills/migrate-email/description.md b/context/skills/migrate-email/description.md index 174a82b6..a5fcb352 100644 --- a/context/skills/migrate-email/description.md +++ b/context/skills/migrate-email/description.md @@ -2,7 +2,7 @@ Use this skill to move an app's transactional email, such as welcome emails, password resets, and receipts, from {display_name} to PostHog. -This version prepares the move and changes no send call site. It finds every {display_name} send, sets up the PostHog sender and its DNS records next to {display_name}'s, imports {display_name}'s suppression list, hands over the PostHog secret key, and writes a report. Both providers can send during the cutover. +It finds every {display_name} send, sets up the PostHog sender and its DNS records next to {display_name}'s, imports {display_name}'s suppression list, and hands over the PostHog secret key. Then it moves every send PostHog can take to `posthog-node`, checks one email on one person, and writes a report. Sends PostHog can't take stay on {display_name} with a reason, so both providers send during the cutover. The transactional email API is in beta. PostHog enables it per project. @@ -17,8 +17,8 @@ Read the step files in order, one at a time. Each step names the next one. Do no The folder `references/<provider>/` holds everything specific to {display_name}. The steps tell you when to read each file: - `sdk-reference.md`: how {display_name} looks in code, so you can find every send. -- `mapping.md`: how each send field maps to PostHog, and what keeps a call site on {display_name}. -- `account.md`: {display_name}'s DNS records and how to export its suppression list. +- `mapping.md`: how each send field maps to PostHog, what keeps a call site on {display_name}, and how to rewrite a site that moves. +- `account.md`: {display_name}'s DNS records, and how to get its templates and its suppression list. Everything you need is on disk. Do not fetch {display_name}'s docs. @@ -28,10 +28,10 @@ The plan file `.posthog-email-migration-plan.md` lives at the project root. Step ## Rules -- Change no send call site. This version only prepares the move. +- Change only the call sites that move, and only in Step 7. Leave every site that stays as it is. - Never touch DNS or the {display_name} account. Give the person the records and the links, and let them make the change. -- Never remove the {display_name} package, its client, or its API key. -- Never create, upload, or edit a template in PostHog. +- Never remove the {display_name} package or its API key. The report tells the person when to remove them. +- Never create, upload, or edit a template in PostHog. A moved local template uploads on its first send. - Never write a recipient's email address into a file in the repository, the plan, or the report. Count addresses instead. - Keep keys in environment variables. Never hardcode a key, and never ask the person to paste one into the chat. - Do not commit. The person reviews the diff and commits. diff --git a/context/skills/migrate-email/references/3-inventory.md b/context/skills/migrate-email/references/3-inventory.md index 884e9005..452109d7 100644 --- a/context/skills/migrate-email/references/3-inventory.md +++ b/context/skills/migrate-email/references/3-inventory.md @@ -39,6 +39,8 @@ Go through `mapping.md` field by field. A site **moves** when PostHog can send e When the code passes a value you can't read, such as a list of recipients built at run time, follow it to its callers. When you still can't tell, the site stays, and the reason says what you could not check. +A `template` site needs the template's content to be classified. Read the provider's `account.md`, and ask the person once for every template the sites send: copy from the dashboard, or read through the API. Keep the content for Step 7, and never write it into the plan. When the person can't give the content, the site stays. + ## Write the plan file Write `.posthog-email-migration-plan.md` at the project root with three sections. Never write an email address of a recipient into it. A `from` address is fine. @@ -51,6 +53,8 @@ A phase checklist, in this order. Tick `preflight` with what Step 2 recorded, an - [ ] sender - [ ] suppressions - [ ] key +- [ ] rewrite +- [ ] verify ``` A sites table, one row per site, in source tree order: diff --git a/context/skills/migrate-email/references/6-key.md b/context/skills/migrate-email/references/6-key.md index 0b4a0820..0c39a80d 100644 --- a/context/skills/migrate-email/references/6-key.md +++ b/context/skills/migrate-email/references/6-key.md @@ -1,6 +1,6 @@ --- title: Hand over the PostHog secret key -next_step: 7-report.md +next_step: 7-rewrite.md --- # Step 6, hand over the PostHog secret key diff --git a/context/skills/migrate-email/references/7-rewrite.md b/context/skills/migrate-email/references/7-rewrite.md new file mode 100644 index 00000000..f107996d --- /dev/null +++ b/context/skills/migrate-email/references/7-rewrite.md @@ -0,0 +1,68 @@ +--- +title: Move every site that can move +next_step: 8-verify.md +--- + +# Step 7, move every site that can move + +Rewrite each site whose `moves` is `yes` in the plan to send with `posthog-node`. The provider's `mapping.md` has the rewrite of each field, call, and content form under "Rewrite". + +## Status + +``` +[STATUS] Setting up posthog-node for email +[STATUS] Moving the send call sites +``` + +## Before you change code + +- Change only the sites whose `moves` is `yes`. Never change a site that stays, and never change a line that a site that stays still needs. +- When no site moves, tick the phase in the plan with `none moved` and go to the next step. + +## Set up the client + +Install or upgrade `posthog-node` with the project's package manager, to a version that has `posthog.emails`. + +Configure one server-side client for email. Set `migratedFrom` to the provider folder name, once on the client, so PostHog knows which sends a migration moved: + +```js +import { PostHog } from 'posthog-node' + +export const posthog = new PostHog(process.env.POSTHOG_PROJECT_TOKEN, { + host: process.env.POSTHOG_HOST, + secretKey: process.env.POSTHOG_PROJECT_SECRET_API_KEY, + migratedFrom: 'resend', + enableLocalEvaluation: false, +}) +``` + +{{> email-node-client}} +- A reused client gets `migratedFrom` too. +- Put a new client in its own server-only module, next to the provider's client, and import it where the sites send. +- Use the env variable names the app already uses for the PostHog token and host. Add each new name, without a value, to the env example file. + +## Find the user ID + +PostHog shows each email on the person the send names with `distinctId`. Use the ID the app already identifies people with in PostHog: + +- Find the app's identify call, such as `posthog.identify(user.id)` in posthog-js, or the `distinctId` of its `posthog-node` captures. +- At each site, pass the same ID for the recipient, such as `distinctId: user.id`. Follow the recipient back through the callers when the site only has the address. +- When the recipient is not a user of the app, such as a team inbox, or the site has no way to know the user, leave `distinctId` out and note it in the plan row. +- When the app identifies no one in PostHog, use the app's own user ID, and note it for the report. + +## Rewrite each site + +For each site that moves, in plan order: + +- Rewrite the call as the provider's `mapping.md` says, for the site's content form: `html` and `text` content, a component the app renders, or a template stored at the provider. +- Give each send a `templateKey` that names the kind of email, such as `welcome` or `password-reset`, with letters, digits, `-`, and `_` only. Two sites that send the same email share a key. A template stored at the provider keeps the key `mapping.md` gives it. +- A template stored at the provider becomes a module that exports `subject` and `html`, and `text` when it has one, in an `emails/` folder next to the server code. The site imports it and passes `templateData`. +- Install `@react-email/render` with the project's package manager when a site renders a component and the app doesn't have it. +- Keep the site's idempotency key, error branches, logging, and return value working as before. Keep the code around the send unchanged where you can. +- Never add a field that the old call didn't set, apart from `templateKey` and `distinctId`. + +When a site turns out to need something `mapping.md` keeps on the provider, undo its change, set `moves` to `no` with the reason, and go on. + +## Write down + +Tick the phase in the plan: `- [x] rewrite — <n> sites moved`. Add a `key` column to the sites table with each moved site's `templateKey`. Under the table, note each site without a `distinctId`, each fallback that an empty string now replaces, each new dependency, and each new env variable name. diff --git a/context/skills/migrate-email/references/8-verify.md b/context/skills/migrate-email/references/8-verify.md new file mode 100644 index 00000000..61ee2c3a --- /dev/null +++ b/context/skills/migrate-email/references/8-verify.md @@ -0,0 +1,48 @@ +--- +title: Check the moved sites +next_step: 9-report.md +--- + +# Step 8, check the moved sites + +Check that the app still builds, its tests still pass, and one moved email reaches one person in PostHog. When no site moved in Step 7, tick the phase with `nothing to check` and go to the next step. + +## Status + +``` +[STATUS] Checking the moved sites +[STATUS] Sending a test email +``` + +## Build and tests + +- Run the project's typecheck, such as its `typecheck` script or `tsc --noEmit`, or its build when it has no typecheck. Fix each error in a line you changed. An error in a file you didn't change was there before: note it, and leave it. +- Run the tests that cover the files you changed. A test that mocks the provider's client for a moved site now mocks `posthog.emails.send` with the same assertions. Never delete or skip a test. +- Search the changed files for the provider's calls: a moved site has none left, and each moved site has one `posthog.emails.send`. + +## One email on one person + +The chain to prove: PostHog sends the email, records its engagement on the person, and that person's product events show next to it. + +Skip this part and record `not checked` with the reason when the API is not confirmed on, no sender is verified, or `POSTHOG_PROJECT_SECRET_API_KEY` is not set. + +1. Ask the person whether to send one test email. Say that it sends a real email, and that the first send of a local template uploads it to the PostHog project. When they say no, record `not checked: declined`. +2. Ask them to trigger one moved email in their local app while they are signed in as themselves, such as a signup or a password reset, so the email goes to an address they own. Ask them to tell you when it is sent and to open the email. +3. When the send fails, find its code in `transactional-email-api.md`, tell the person the fix, and record it. Never retry a send yourself. +4. Find the person's distinct ID: the value the moved site passed as `distinctId`. Run a SQL query through the PostHog MCP `execute-sql` tool for that distinct ID over the last hour: + + ```sql + SELECT event, timestamp FROM events + WHERE distinct_id = '<distinct id>' AND timestamp > now() - INTERVAL 1 HOUR + ORDER BY timestamp + ``` + +5. The chain holds when the result has `$workflows_email_sent`, an engagement event such as `$workflows_email_delivered` or `$workflows_email_opened`, and at least one product event of the app. Delivery events arrive after the provider reports back: when only the send shows, wait a minute and run the query once more. + +When the MCP has no SQL tool, ask the person to open their person in PostHog and tell you which of the three they see. + +Never write the address or the distinct ID into a file. + +## Write down + +Tick the phase in the plan: `- [x] verify — typecheck: <passed|failed|none>, tests: <passed|failed|none>, chain: <proven|partly: what showed|not checked: why>`. diff --git a/context/skills/migrate-email/references/7-report.md b/context/skills/migrate-email/references/9-report.md similarity index 58% rename from context/skills/migrate-email/references/7-report.md rename to context/skills/migrate-email/references/9-report.md index 6869e754..08441c30 100644 --- a/context/skills/migrate-email/references/7-report.md +++ b/context/skills/migrate-email/references/9-report.md @@ -3,7 +3,7 @@ title: Write the migration report next_step: null --- -# Step 7, write the migration report +# Step 9, write the migration report The report comes from the plan file. Invent nothing. @@ -20,14 +20,16 @@ Read `.posthog-email-migration-plan.md`. Write `posthog-migrate-email-<provider> ```markdown # Email migration report: <provider name> to PostHog -<One or two sentences: how many sites were found, how many can move, how many stay, and whether the sender and the suppression list are ready.> +<One or two sentences: how many sites were found, how many moved, how many stay, and whether the sender and the suppression list are ready.> ## Send call sites -| File | Line | Content | Status | -|------|------|---------|--------| +| File | Line | Content | Key | Status | +|------|------|---------|-----|--------| -<One row per site. Status is "Ready to move" or "Stays on <provider name>: <reason>".> +<One row per site. Key is the `templateKey` of a moved site. Status is "Moved", "Moved without distinctId: <why>", or "Stays on <provider name>: <reason>".> + +<Each template that moved into the code, with its module path, and each fallback that an empty string now replaces.> ## Other <provider name> calls @@ -45,13 +47,23 @@ Read `.posthog-email-migration-plan.md`. Write `posthog-migrate-email-<provider> <Where POSTHOG_PROJECT_SECRET_API_KEY is set, and each other place the provider key lives that needs the PostHog key too.> +## Checks + +<The typecheck, the tests, and the test email: what passed, what failed, and what was not checked and why. Say which of the send, the engagement event, and the product event showed on one person.> + ## Sending tier <What the person found, or that it was not checked.> ## Before cutover -<A numbered list of everything the person still has to do. Include each unverified domain, a missing suppression import, each place that needs the PostHog key, a tier that is too low or not checked, an API that is not confirmed, and each site that stays with what would let it move. When nothing is left, write "Nothing left before cutover."> +The moved sites send through PostHog as soon as this code is deployed. Everything in this list has to be done before that deploy. + +<A numbered list of everything the person still has to do. Include each unverified domain, a missing suppression import, each place that needs the PostHog key and each new env variable, a tier that is too low or not checked, an API that is not confirmed, a failed check, and each site that stays with what would let it move. When nothing is left, write "Nothing left before cutover."> + +## After cutover + +<What the person removes once the moved emails arrive from PostHog: when no site stays and no other provider call is left, the provider's package and its key in every place listed above. Otherwise, write that the provider stays for the sites and calls listed above.> ## Summary ``` @@ -61,7 +73,7 @@ End the report with the summary block, as the last thing in the file. The Wizard ```yaml from: resend sites_found: 3 -sites_rewritten: 0 +sites_rewritten: 2 sites_left: 1 dns_ready: false suppressions_ready: true @@ -69,13 +81,11 @@ suppressions_ready: true - `from`: the provider folder name. - `sites_found`: every row of the sites table. -- `sites_rewritten`: the sites this run changed to send with PostHog. This version changes none, so it is `0`. +- `sites_rewritten`: the sites this run moved to send with PostHog. - `sites_left`: the sites that stay on the provider. - `dns_ready`: `true` when every sending domain has a verified sender. - `suppressions_ready`: `true` when the suppression list was imported or was empty. -Sites that are ready to move count in `sites_found` only. - After the report is written, print its path: ``` diff --git a/context/skills/migrate-email/references/resend/account.md b/context/skills/migrate-email/references/resend/account.md index dfd42d4b..f4c38602 100644 --- a/context/skills/migrate-email/references/resend/account.md +++ b/context/skills/migrate-email/references/resend/account.md @@ -18,18 +18,32 @@ A domain verified at Resend carries these records. Keep all of them until the cu - PostHog's DKIM records have their own names, so they sit next to `resend._domainkey`. - Keep the one `_dmarc` record. Never add a second one. +## Full-access key + +The app's own Resend key is usually a sending-only key, and it can't read templates or the suppression list. When the person chooses the API over the dashboard, ask them to create a full-access key in Resend, store it in `RESEND_FULL_ACCESS_API_KEY` in the git-ignored env file, and delete the key in Resend after the migration. Never print the key, and never write it into another file. + +## Template content + +A site that sends a template stored at Resend needs the template's content. + +**Copy from the dashboard.** In Resend: Templates, then the template. Ask the person for its HTML, its plain text when it has one, its subject, its `from` and reply-to when they are set, and each variable's fallback. + +**Read from the API.** `GET https://api.resend.com/templates/{id or alias}` with `Authorization: Bearer $RESEND_FULL_ACCESS_API_KEY`. The answer holds `alias`, `name`, `subject`, `from`, `reply_to`, `html`, `text`, and `variables`, a list of `{ key, type, fallback_value }`. + +The content goes only into the template module the rewrite writes. Never write it into the plan or the report. + ## Suppression list Resend suppresses an address after a hard bounce or a spam complaint, and keeps addresses the team added by hand. **Export from the dashboard (preferred).** In Resend: Emails, then Suppressions, then **Download CSV**. Read the header row to find the address column and the origin column. -**Read from the API.** `GET https://api.resend.com/suppressions?limit=100` with `Authorization: Bearer $RESEND_SUPPRESSIONS_API_KEY`. +**Read from the API.** `GET https://api.resend.com/suppressions?limit=100` with `Authorization: Bearer $RESEND_FULL_ACCESS_API_KEY`. - The answer is `{ "object": "list", "has_more": <bool>, "data": [{ "id", "email", "origin", "source_id", "created_at" }] }`. - Page with `after=<id of the last entry>` until `has_more` is `false`. - Resend allows 10 requests per second per team. Wait and retry on `429`. -- A sending-only key can't read the list. Ask the person to create a full-access key, store it in `RESEND_SUPPRESSIONS_API_KEY` for the import, and delete the key in Resend afterwards. +- A sending-only key can't read the list. Use the full-access key below. **Map each entry.** diff --git a/context/skills/migrate-email/references/resend/mapping.md b/context/skills/migrate-email/references/resend/mapping.md index eecb90fb..649ef3f1 100644 --- a/context/skills/migrate-email/references/resend/mapping.md +++ b/context/skills/migrate-email/references/resend/mapping.md @@ -12,13 +12,13 @@ This file decides, field by field, whether PostHog can send a Resend call site. | `html` | `html` | Never | | `text` | `text` | Never | | `react` | `html`, rendered in the app | Never. The app renders the component | -| `template` | a local template in the app's code | The template uses syntax beyond variables and fallbacks | +| `template` | a local template in the app's code | Its content can't be read, it uses syntax beyond variables and fallbacks, or the app fills a variable with HTML | | `cc`, `bcc` | `cc`, `bcc` | An entry carries a name, such as `Jane <jane@example.com>`, or there are more than 10 | | `replyTo` / `reply_to` | `replyTo` / `reply_to` | Never | | `headers` | `headers` | A header is one PostHog sets itself (`From`, `To`, `Cc`, `Bcc`, `Reply-To`, `Subject`, `Date`, `Message-ID`, `MIME-Version`, `Sender`, `Return-Path`, `Auto-Submitted`, any `Content-*`, `X-PostHog-*`, `X-SES-*`), a value has a line break or a non-ASCII character, or there are more than 10 | | `tags` | `tags` | A name or value has characters other than ASCII letters, digits, `_`, and `-`, a name starts with `ph_`, or there are more than 10 | | `attachments` | `attachments` | An attachment uses `path` (a URL Resend fetches), the files can be more than 10 or more than 512 KB in total, or a file type is one mailbox providers block, such as `.exe` | -| `idempotencyKey` / `Idempotency-Key` | `idempotencyKey` / `Idempotency-Key` | Never. The key keeps its value | +| `idempotencyKey` / `Idempotency-Key` | `idempotencyKey` / `Idempotency-Key` | The key can hold a space or a non-ASCII character, or be longer than 255 characters. Otherwise the key keeps its value and its meaning: both providers keep a key for 24 hours | | `scheduledAt` / `scheduled_at` | none | Always | | `topicId` / `topic_id` | none | Always | @@ -43,4 +43,39 @@ This file decides, field by field, whether PostHog can send a Resend call site. ## Content - `html`, `text`, and `react` content is rendered for one recipient before the send. PostHog sends it as it is. -- A `template` site needs the template's content from the Resend dashboard, because the code has only its id. PostHog's Liquid escapes HTML in a variable's value. A variable the app fills with HTML keeps the site on Resend. +- A `template` site needs the template's content, because the code has only its id. `account.md` says how to get it. +- Resend inserts a variable's value as it is. PostHog's Liquid escapes HTML in a value, so `<b>` shows as text. A variable is HTML when the code fills it with markup or with a renderer's output. A variable the app fills with plain values, such as names, numbers, and URLs, renders the same. +- A template that holds anything other than `{{{VAR}}}` and `{{{VAR|fallback}}}` placeholders, such as a section, a loop, or a condition, stays on Resend. + +## Rewrite + +How a site that moves is written with `posthog-node`. Step 7 says when to use each part. + +**The call.** `resend.emails.send(payload, { idempotencyKey })` and `resend.emails.create(...)` become one `posthog.emails.send({ ... })`. The options object goes away: `idempotencyKey` moves into the payload. + +**The fields.** + +- `to: ['jane@example.com']` becomes `to: 'jane@example.com'`. A value that is a list with one entry becomes that entry. +- `from`, `subject`, `html`, `text`, `replyTo`, `cc`, `bcc`, `headers`, and `tags` keep their names and values. +- `attachments` keep `filename`, `content`, `contentType`, and `contentId`. +- Each send gets a `templateKey`, as Step 7 says. + +**React.** `react: <Welcome name={name} />` becomes `html: await render(<Welcome name={name} />)`, with `import { render } from '@react-email/render'`. Keep a `text` the site already passes. + +**HTTP.** A `fetch` or `axios` call to `https://api.resend.com/emails` becomes `posthog.emails.send` with the camelCase fields: `reply_to` becomes `replyTo`, and the `Idempotency-Key` header becomes `idempotencyKey`. Code that read the response status reads `error` instead. + +**Templates stored at Resend.** The template becomes a module in the app's code, in the format of the send skill's local template: a `subject` and an `html` string, and a `text` string when the template has one. + +- `{{{NAME}}}` becomes `{{ variables.NAME }}`. The name keeps its case. +- `{{{NAME|there}}}` becomes `{{ variables.NAME | default: "there" }}`. A variable with a `fallback_value` in the template's definition gets the same `default`. Escape a `"` in the fallback as `\"`. +- Liquid's `default` also replaces an empty string, which Resend inserts as it is. When the code can pass an empty string for a variable with a fallback, the email shows the fallback instead. Note it in the report. +- The site sends `templateKey`, `subject`, `html`, `text` when there is one, and `templateData` with the site's `variables` object unchanged. +- The site's own `subject`, `from`, and `replyTo` win over the template's. When the site has none, use the template's: put its `from` and `replyTo` on the send, and its subject in the module. +- The template's alias, or its name in lowercase with `-` between words, becomes the `templateKey` and the module's file name. + +**The answer.** posthog-node answers `{ data, error }` like Resend and never throws on an API error. + +- `data.id` is PostHog's message id. Code that logs it or stores it for the app's own use keeps working. +- `error.name` uses Resend's names, so `if (error.name === 'rate_limit_exceeded')` keeps working. `error.message` and `error.statusCode` keep their names. + +**The Resend client.** Keep `new Resend(...)` and its import while any site or other call still uses it. When nothing uses it, remove the import and the client, and keep the `resend` package and its key: the person removes them after the cutover. diff --git a/context/skills/migrate-email/references/resend/sdk-reference.md b/context/skills/migrate-email/references/resend/sdk-reference.md index fa3cf5f0..f88d47b2 100644 --- a/context/skills/migrate-email/references/resend/sdk-reference.md +++ b/context/skills/migrate-email/references/resend/sdk-reference.md @@ -79,7 +79,12 @@ const { data, error } = await resend.emails.send({ ## Templates stored at Resend -A send with `template: { id: 'tmpl_...', variables: { FIRST_NAME: 'Jane' } }` renders a template from the Resend dashboard. Its content is not in the code. The template reads variables as `{{{FIRST_NAME}}}`, with an optional fallback: `{{{FIRST_NAME|there}}}`. +A send with `template: { id: '...', variables: { PRODUCT: 'Laptop' } }` renders a template from the Resend dashboard. `id` is the template's id or its alias. Its content is not in the code. + +- The template reads variables as `{{{PRODUCT}}}`, with an optional fallback: `{{{PRODUCT|a laptop}}}`. A variable's definition in the template can also carry a `fallback_value`. +- A variable's type is `string` or `number`. +- The template can hold a default `from`, `subject`, and `reply_to`. A value the send passes wins over the default. +- A send with `template` passes no `html`, `text`, or `react`. ## Status reads diff --git a/scripts/lib/tests/migrate-email-skill.test.js b/scripts/lib/tests/migrate-email-skill.test.js index 29ca4ea6..3470410d 100644 --- a/scripts/lib/tests/migrate-email-skill.test.js +++ b/scripts/lib/tests/migrate-email-skill.test.js @@ -86,6 +86,25 @@ describe('migrate-email skill', () => { expect(markdownFiles(skillDir).map((file) => readFileSync(file, 'utf8')).join('\n')).not.toMatch(/\{\{>|\{display_name\}|\{references\}/); }); + it('moves the supported sites and checks them before it writes the report', async () => { + outputDir = mkdtempSync(join(tmpdir(), 'migrate-skill-')); + + const skillDir = await buildSkill('migrate-email-resend', outputDir); + const workflow = readFileSync(join(skillDir, 'SKILL.md'), 'utf8').match(/^\d+\. `references\/[^`]+`/gm); + + expect(workflow.map((step) => step.replace(/^\d+\. /, ''))).toEqual([ + '`references/1-detect.md`', + '`references/2-preflight.md`', + '`references/3-inventory.md`', + '`references/4-sender.md`', + '`references/5-suppressions.md`', + '`references/6-key.md`', + '`references/7-rewrite.md`', + '`references/8-verify.md`', + '`references/9-report.md`', + ]); + }); + it('publishes Resend and Statsig as wizard migrate subcommands', async () => { const { skills } = await loadSkills(); const cliOf = (id) => skills.find((skill) => skill.id === id)?._cli; From c3274f5f13ea0de404138d6c6cadb611fe3ac894 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 22 Oct 2026 07:07:57 +0000 Subject: [PATCH 20/23] fix(skills): keep migrate-email from splitting sends or faking the old provider in tests Fixture runs looped over or truncated a one-recipient list, and one run mocked posthog-node to rebuild a Resend request. The rewrite now narrows the helper's parameter to one address, and the verify step asserts on the fields posthog.emails.send gets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- context/skills/migrate-email/references/8-verify.md | 2 +- context/skills/migrate-email/references/resend/mapping.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/context/skills/migrate-email/references/8-verify.md b/context/skills/migrate-email/references/8-verify.md index 61ee2c3a..2bd615b8 100644 --- a/context/skills/migrate-email/references/8-verify.md +++ b/context/skills/migrate-email/references/8-verify.md @@ -17,7 +17,7 @@ Check that the app still builds, its tests still pass, and one moved email reach ## Build and tests - Run the project's typecheck, such as its `typecheck` script or `tsc --noEmit`, or its build when it has no typecheck. Fix each error in a line you changed. An error in a file you didn't change was there before: note it, and leave it. -- Run the tests that cover the files you changed. A test that mocks the provider's client for a moved site now mocks `posthog.emails.send` with the same assertions. Never delete or skip a test. +- Run the tests that cover the files you changed. A test that mocks the provider's client or intercepts its HTTP requests for a moved site now mocks `posthog.emails.send` and asserts on the fields it gets, with the same expected values. Never turn a PostHog send back into the provider's request shape to keep an old assertion. Never delete or skip a test. - Search the changed files for the provider's calls: a moved site has none left, and each moved site has one `posthog.emails.send`. ## One email on one person diff --git a/context/skills/migrate-email/references/resend/mapping.md b/context/skills/migrate-email/references/resend/mapping.md index 649ef3f1..9d34d55e 100644 --- a/context/skills/migrate-email/references/resend/mapping.md +++ b/context/skills/migrate-email/references/resend/mapping.md @@ -56,6 +56,7 @@ How a site that moves is written with `posthog-node`. Step 7 says when to use ea **The fields.** - `to: ['jane@example.com']` becomes `to: 'jane@example.com'`. A value that is a list with one entry becomes that entry. +- When a helper takes a list of recipients and every caller passes one address, change the parameter to one address and update the callers, so the type keeps a second address out. Never send one email per address in a loop, and never drop the addresses after the first. - `from`, `subject`, `html`, `text`, `replyTo`, `cc`, `bcc`, `headers`, and `tags` keep their names and values. - `attachments` keep `filename`, `content`, `contentType`, and `contentId`. - Each send gets a `templateKey`, as Step 7 says. From c1383c145a9d6ae636f31b220b3042be6c0e42c6 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 22 Oct 2026 07:14:55 +0000 Subject: [PATCH 21/23] fix(skills): handle suppressed recipients and dynamic subjects in migrate-email A moved site now treats recipient_suppressed like a send that went out, a site-built subject becomes a module placeholder so the template hash stays stable, fallbacks use single quotes, and the report warns that idempotency keys don't cross providers. The rules and Step 7 no longer contradict the mapping. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- context/skills/migrate-email/description.md | 2 +- context/skills/migrate-email/references/7-rewrite.md | 9 +++++---- .../skills/migrate-email/references/resend/mapping.md | 8 +++++--- scripts/lib/tests/migrate-email-skill.test.js | 2 +- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/context/skills/migrate-email/description.md b/context/skills/migrate-email/description.md index a5fcb352..f7cf35f2 100644 --- a/context/skills/migrate-email/description.md +++ b/context/skills/migrate-email/description.md @@ -28,7 +28,7 @@ The plan file `.posthog-email-migration-plan.md` lives at the project root. Step ## Rules -- Change only the call sites that move, and only in Step 7. Leave every site that stays as it is. +- Change send code only in Step 7, and fix it only in Step 8: the sites that move, and the helpers, callers, and tests they need. Leave every site that stays as it is. - Never touch DNS or the {display_name} account. Give the person the records and the links, and let them make the change. - Never remove the {display_name} package or its API key. The report tells the person when to remove them. - Never create, upload, or edit a template in PostHog. A moved local template uploads on its first send. diff --git a/context/skills/migrate-email/references/7-rewrite.md b/context/skills/migrate-email/references/7-rewrite.md index f107996d..1670e95c 100644 --- a/context/skills/migrate-email/references/7-rewrite.md +++ b/context/skills/migrate-email/references/7-rewrite.md @@ -23,7 +23,7 @@ Rewrite each site whose `moves` is `yes` in the plan to send with `posthog-node` Install or upgrade `posthog-node` with the project's package manager, to a version that has `posthog.emails`. -Configure one server-side client for email. Set `migratedFrom` to the provider folder name, once on the client, so PostHog knows which sends a migration moved: +Configure one server-side client for email. Set `migratedFrom` to the provider folder name, such as `resend`, once on the client, so PostHog knows which sends a migration moved: ```js import { PostHog } from 'posthog-node' @@ -31,7 +31,7 @@ import { PostHog } from 'posthog-node' export const posthog = new PostHog(process.env.POSTHOG_PROJECT_TOKEN, { host: process.env.POSTHOG_HOST, secretKey: process.env.POSTHOG_PROJECT_SECRET_API_KEY, - migratedFrom: 'resend', + migratedFrom: '<provider folder name>', enableLocalEvaluation: false, }) ``` @@ -59,10 +59,11 @@ For each site that moves, in plan order: - A template stored at the provider becomes a module that exports `subject` and `html`, and `text` when it has one, in an `emails/` folder next to the server code. The site imports it and passes `templateData`. - Install `@react-email/render` with the project's package manager when a site renders a component and the app doesn't have it. - Keep the site's idempotency key, error branches, logging, and return value working as before. Keep the code around the send unchanged where you can. -- Never add a field that the old call didn't set, apart from `templateKey` and `distinctId`. +- `posthog.emails.send` never throws on an API error: it returns `{ data, error }`. When the old call threw on an error, or the code checked an HTTP status, check `error` so the site fails the same way. +- Never add a field that the old call didn't set, apart from `templateKey`, `distinctId`, and the template defaults `mapping.md` names. When a site turns out to need something `mapping.md` keeps on the provider, undo its change, set `moves` to `no` with the reason, and go on. ## Write down -Tick the phase in the plan: `- [x] rewrite — <n> sites moved`. Add a `key` column to the sites table with each moved site's `templateKey`. Under the table, note each site without a `distinctId`, each fallback that an empty string now replaces, each new dependency, and each new env variable name. +Tick the phase in the plan: `- [x] rewrite — <n> sites moved`. Add a `key` column to the sites table with each moved site's `templateKey`. Under the table, note each site without a `distinctId`, each fallback that an empty string now replaces, each site that got a branch for a suppressed recipient, each idempotency key a retry across the deploy could send twice, each new dependency, and each new env variable name. diff --git a/context/skills/migrate-email/references/resend/mapping.md b/context/skills/migrate-email/references/resend/mapping.md index 9d34d55e..a44ffbf9 100644 --- a/context/skills/migrate-email/references/resend/mapping.md +++ b/context/skills/migrate-email/references/resend/mapping.md @@ -18,7 +18,7 @@ This file decides, field by field, whether PostHog can send a Resend call site. | `headers` | `headers` | A header is one PostHog sets itself (`From`, `To`, `Cc`, `Bcc`, `Reply-To`, `Subject`, `Date`, `Message-ID`, `MIME-Version`, `Sender`, `Return-Path`, `Auto-Submitted`, any `Content-*`, `X-PostHog-*`, `X-SES-*`), a value has a line break or a non-ASCII character, or there are more than 10 | | `tags` | `tags` | A name or value has characters other than ASCII letters, digits, `_`, and `-`, a name starts with `ph_`, or there are more than 10 | | `attachments` | `attachments` | An attachment uses `path` (a URL Resend fetches), the files can be more than 10 or more than 512 KB in total, or a file type is one mailbox providers block, such as `.exe` | -| `idempotencyKey` / `Idempotency-Key` | `idempotencyKey` / `Idempotency-Key` | The key can hold a space or a non-ASCII character, or be longer than 255 characters. Otherwise the key keeps its value and its meaning: both providers keep a key for 24 hours | +| `idempotencyKey` / `Idempotency-Key` | `idempotencyKey` / `Idempotency-Key` | The key can hold a space or a non-ASCII character, or be longer than 255 characters. Otherwise the key keeps its value, and PostHog keeps it for 24 hours as Resend does. The two providers don't share keys: a retry that runs after the deploy, of a send Resend already took, sends a second email. Note it in the report | | `scheduledAt` / `scheduled_at` | none | Always | | `topicId` / `topic_id` | none | Always | @@ -45,7 +45,7 @@ This file decides, field by field, whether PostHog can send a Resend call site. - `html`, `text`, and `react` content is rendered for one recipient before the send. PostHog sends it as it is. - A `template` site needs the template's content, because the code has only its id. `account.md` says how to get it. - Resend inserts a variable's value as it is. PostHog's Liquid escapes HTML in a value, so `<b>` shows as text. A variable is HTML when the code fills it with markup or with a renderer's output. A variable the app fills with plain values, such as names, numbers, and URLs, renders the same. -- A template that holds anything other than `{{{VAR}}}` and `{{{VAR|fallback}}}` placeholders, such as a section, a loop, or a condition, stays on Resend. +- A template that holds anything other than `{{{VAR}}}` and `{{{VAR|fallback}}}` placeholders, such as a section, a loop, or a condition, stays on Resend. So does a fallback that holds both `'` and `"`. ## Rewrite @@ -68,15 +68,17 @@ How a site that moves is written with `posthog-node`. Step 7 says when to use ea **Templates stored at Resend.** The template becomes a module in the app's code, in the format of the send skill's local template: a `subject` and an `html` string, and a `text` string when the template has one. - `{{{NAME}}}` becomes `{{ variables.NAME }}`. The name keeps its case. -- `{{{NAME|there}}}` becomes `{{ variables.NAME | default: "there" }}`. A variable with a `fallback_value` in the template's definition gets the same `default`. Escape a `"` in the fallback as `\"`. +- `{{{NAME|there}}}` becomes `{{ variables.NAME | default: 'there' }}`. A variable with a `fallback_value` in the template's definition gets the same `default`. Quote a fallback that holds a `'` with `"` instead. Liquid has no escape character in a quoted value. - Liquid's `default` also replaces an empty string, which Resend inserts as it is. When the code can pass an empty string for a variable with a fallback, the email shows the fallback instead. Note it in the report. - The site sends `templateKey`, `subject`, `html`, `text` when there is one, and `templateData` with the site's `variables` object unchanged. - The site's own `subject`, `from`, and `replyTo` win over the template's. When the site has none, use the template's: put its `from` and `replyTo` on the send, and its subject in the module. +- The module's subject is fixed text with placeholders. When the site builds its subject from values, such as `` `Order #${order.id}` ``, write the subject in the module as `Order #{{ variables.ORDER_ID }}` and add each value to `templateData`. Never send a subject that changes per email with `templateData`: PostHog hashes the subject with the template, so each email would upload a new version of the template. - The template's alias, or its name in lowercase with `-` between words, becomes the `templateKey` and the module's file name. **The answer.** posthog-node answers `{ data, error }` like Resend and never throws on an API error. - `data.id` is PostHog's message id. Code that logs it or stores it for the app's own use keeps working. - `error.name` uses Resend's names, so `if (error.name === 'rate_limit_exceeded')` keeps working. `error.message` and `error.statusCode` keep their names. +- Resend takes a send to a suppressed address and drops it later. PostHog refuses it with `error.code` `recipient_suppressed` (name `validation_error`), and refuses the whole send when a `cc` or `bcc` address is suppressed. When the site throws, retries, or reports a failure on any error, add `if (error?.code === 'recipient_suppressed')` before that branch and handle it the way the site handled a send that went out: log it and go on. Note each site that got this branch in the report. **The Resend client.** Keep `new Resend(...)` and its import while any site or other call still uses it. When nothing uses it, remove the import and the client, and keep the `resend` package and its key: the person removes them after the cutover. diff --git a/scripts/lib/tests/migrate-email-skill.test.js b/scripts/lib/tests/migrate-email-skill.test.js index 3470410d..22000cdd 100644 --- a/scripts/lib/tests/migrate-email-skill.test.js +++ b/scripts/lib/tests/migrate-email-skill.test.js @@ -86,7 +86,7 @@ describe('migrate-email skill', () => { expect(markdownFiles(skillDir).map((file) => readFileSync(file, 'utf8')).join('\n')).not.toMatch(/\{\{>|\{display_name\}|\{references\}/); }); - it('moves the supported sites and checks them before it writes the report', async () => { + it('runs the rewrite and verify steps between the key and the report', async () => { outputDir = mkdtempSync(join(tmpdir(), 'migrate-skill-')); const skillDir = await buildSkill('migrate-email-resend', outputDir); From 3d51135257b45545073fe636e0a291196fdbe52f Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 22 Oct 2026 07:17:02 +0000 Subject: [PATCH 22/23] fix(skills): keep a suppressed copy address from passing as a sent email Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- context/skills/migrate-email/references/resend/mapping.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/context/skills/migrate-email/references/resend/mapping.md b/context/skills/migrate-email/references/resend/mapping.md index a44ffbf9..ab634fcf 100644 --- a/context/skills/migrate-email/references/resend/mapping.md +++ b/context/skills/migrate-email/references/resend/mapping.md @@ -70,7 +70,7 @@ How a site that moves is written with `posthog-node`. Step 7 says when to use ea - `{{{NAME}}}` becomes `{{ variables.NAME }}`. The name keeps its case. - `{{{NAME|there}}}` becomes `{{ variables.NAME | default: 'there' }}`. A variable with a `fallback_value` in the template's definition gets the same `default`. Quote a fallback that holds a `'` with `"` instead. Liquid has no escape character in a quoted value. - Liquid's `default` also replaces an empty string, which Resend inserts as it is. When the code can pass an empty string for a variable with a fallback, the email shows the fallback instead. Note it in the report. -- The site sends `templateKey`, `subject`, `html`, `text` when there is one, and `templateData` with the site's `variables` object unchanged. +- The site sends `templateKey`, `subject`, `html`, `text` when there is one, and `templateData` with the site's `variables` object, plus the subject values below. - The site's own `subject`, `from`, and `replyTo` win over the template's. When the site has none, use the template's: put its `from` and `replyTo` on the send, and its subject in the module. - The module's subject is fixed text with placeholders. When the site builds its subject from values, such as `` `Order #${order.id}` ``, write the subject in the module as `Order #{{ variables.ORDER_ID }}` and add each value to `templateData`. Never send a subject that changes per email with `templateData`: PostHog hashes the subject with the template, so each email would upload a new version of the template. - The template's alias, or its name in lowercase with `-` between words, becomes the `templateKey` and the module's file name. @@ -79,6 +79,7 @@ How a site that moves is written with `posthog-node`. Step 7 says when to use ea - `data.id` is PostHog's message id. Code that logs it or stores it for the app's own use keeps working. - `error.name` uses Resend's names, so `if (error.name === 'rate_limit_exceeded')` keeps working. `error.message` and `error.statusCode` keep their names. -- Resend takes a send to a suppressed address and drops it later. PostHog refuses it with `error.code` `recipient_suppressed` (name `validation_error`), and refuses the whole send when a `cc` or `bcc` address is suppressed. When the site throws, retries, or reports a failure on any error, add `if (error?.code === 'recipient_suppressed')` before that branch and handle it the way the site handled a send that went out: log it and go on. Note each site that got this branch in the report. +- Resend takes a send to a suppressed address and drops it later. PostHog refuses it with `error.code` `recipient_suppressed` (name `validation_error`). When the site sets no `cc` or `bcc`, and it throws, retries, or reports a failure on any error, add `if (error?.code === 'recipient_suppressed')` before that branch and handle it the way the site handled a send that went out: log it and go on. Note each site that got this branch in the report. +- PostHog also refuses the whole send when a `cc` or `bcc` address is suppressed, where Resend still delivered to `to`. Give a site with `cc` or `bcc` no such branch: the error keeps failing the site, and the report says that a suppressed copy address now stops the email. **The Resend client.** Keep `new Resend(...)` and its import while any site or other call still uses it. When nothing uses it, remove the import and the client, and keep the `resend` package and its key: the person removes them after the cutover. From 4499957d7266d8f2c2d0e4db0b65b9a35ecb6f15 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 22 Oct 2026 07:18:15 +0000 Subject: [PATCH 23/23] fix(skills): warn that retrying a send with a suppressed copy address uses sending cap Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- context/skills/migrate-email/references/resend/mapping.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/context/skills/migrate-email/references/resend/mapping.md b/context/skills/migrate-email/references/resend/mapping.md index ab634fcf..4ceacdcc 100644 --- a/context/skills/migrate-email/references/resend/mapping.md +++ b/context/skills/migrate-email/references/resend/mapping.md @@ -80,6 +80,6 @@ How a site that moves is written with `posthog-node`. Step 7 says when to use ea - `data.id` is PostHog's message id. Code that logs it or stores it for the app's own use keeps working. - `error.name` uses Resend's names, so `if (error.name === 'rate_limit_exceeded')` keeps working. `error.message` and `error.statusCode` keep their names. - Resend takes a send to a suppressed address and drops it later. PostHog refuses it with `error.code` `recipient_suppressed` (name `validation_error`). When the site sets no `cc` or `bcc`, and it throws, retries, or reports a failure on any error, add `if (error?.code === 'recipient_suppressed')` before that branch and handle it the way the site handled a send that went out: log it and go on. Note each site that got this branch in the report. -- PostHog also refuses the whole send when a `cc` or `bcc` address is suppressed, where Resend still delivered to `to`. Give a site with `cc` or `bcc` no such branch: the error keeps failing the site, and the report says that a suppressed copy address now stops the email. +- PostHog also refuses the whole send when a `cc` or `bcc` address is suppressed, where Resend still delivered to `to`. Give a site with `cc` or `bcc` no such branch: the error keeps failing the site, and the report says that a suppressed copy address now stops the email, and that a job runner that retries the site repeats the refusal, each attempt using the project's sending cap. **The Resend client.** Keep `new Resend(...)` and its import while any site or other call still uses it. When nothing uses it, remove the import and the client, and keep the `resend` package and its key: the person removes them after the cutover.