From f034ecd8794e88e010f90e6faaa6c617ded707eb 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 01/22] 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// into each variant's bundle, and nothing of the other variants. Co-Authored-By: Claude Opus 5.5 --- 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 454142acd..9af3e0455 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//`, + * 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 d5108e5f8..925f520be 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//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 177e7414815c7dcb830e128647371eadad887d38 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 02/22] 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 --- context/shared/email-sender-setup.md | 15 +++++++++++++++ scripts/lib/skill-generator.js | 10 ++++++---- scripts/lib/tests/partial-includes.test.js | 8 ++++++++ 3 files changed, 29 insertions(+), 4 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 000000000..bdf95e3c5 --- /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/scripts/lib/skill-generator.js b/scripts/lib/skill-generator.js index 9af3e0455..35eb4697e 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 `/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 5ebaa8e66..3741c14a3 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 8169d7ec86ab68871010032ea15222f77611d0e6 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 03/22] 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 --- 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 9bbb4e2c6..2d5c0779e 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 ` | now a family — see audit subcommands below | +| `wizard migrate` (single) | `wizard migrate ` | 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 `. Same machinery, two surfaces — so `wizard audit ` diff --git a/context/skills/migrate-email/config.yaml b/context/skills/migrate-email/config.yaml new file mode 100644 index 000000000..6b0b83c37 --- /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 000000000..174a82b6a --- /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//` 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 `. +- 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 000000000..94881bc9c --- /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 000000000..ffd88264d --- /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 000000000..1755818f0 --- /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: — tier: +- [x] inventory — sites, 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 000000000..87a968c06 --- /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 `: 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 — dns_ready: `. `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 000000000..02471b58b --- /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`. When the list is empty, write `- [x] suppressions — empty list`. When the person has no export and no key, write `- [ ] suppressions — not imported: `. 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 000000000..0b4a08208 --- /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 `, or `- [ ] key — not set: `. 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 000000000..0dfb9a330 --- /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--report.md` at the project root, where `` 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: to PostHog + + + +## Send call sites + +| File | Line | Content | Status | +|------|------|---------|--------| + +: ".> + +## Other calls + +.> + +## Sender and DNS + + + +## Suppression list + + + +## PostHog secret key + + + +## Sending tier + + + +## 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: +sites_found: +sites_rewritten: +sites_left: +dns_ready: +suppressions_ready: +``` + +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: +``` 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 000000000..dfd42d4b5 --- /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": , "data": [{ "id", "email", "origin", "source_id", "created_at" }] }`. +- Page with `after=` 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 000000000..eecb90fb0 --- /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 `, 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 000000000..fa3cf5f02 --- /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 ', + to: ['jane@example.com'], + subject: 'Welcome', + html: '

Welcome

', +}, { 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 `, 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 ` | +| `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 c56409d57..d4e2cb5a8 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 000000000..29ca4ea6c --- /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 5e019aa3ba673a1980571f0ee27c3fac61695675 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 04/22] 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 --- .../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 1755818f0..884e90052 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 0dfb9a330..6869e754e 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- ## 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: -sites_found: -sites_rewritten: -sites_left: -dns_ready: -suppressions_ready: +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 6e902dc5ffc67178253e4a5682de11ef4169be11 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sat, 31 Oct 2026 09:52:37 +0000 Subject: [PATCH 05/22] fix(skills): point migrate-email at the transactional email docs and the sending skill The send-transactional-email skill is gone from this stack, so migrate-email bundles the transactional email pages and names the sending-transactional-emails skill for the general move. Co-Authored-By: Claude Opus 5.5 --- context/skills/migrate-email/config.yaml | 3 ++- context/skills/migrate-email/description.md | 4 +++- .../references/resend/mapping.md | 2 +- scripts/lib/tests/migrate-email-skill.test.js | 19 +++++++++++++------ 4 files changed, 19 insertions(+), 9 deletions(-) diff --git a/context/skills/migrate-email/config.yaml b/context/skills/migrate-email/config.yaml index 6b0b83c37..2accfcd59 100644 --- a/context/skills/migrate-email/config.yaml +++ b/context/skills/migrate-email/config.yaml @@ -10,7 +10,8 @@ cli: 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 + - https://posthog.com/docs/workflows/transactional-emails.md + - https://posthog.com/docs/workflows/transactional-emails/api.md variants: - id: resend display_name: Resend diff --git a/context/skills/migrate-email/description.md b/context/skills/migrate-email/description.md index 174a82b6a..7bb85a8d2 100644 --- a/context/skills/migrate-email/description.md +++ b/context/skills/migrate-email/description.md @@ -52,4 +52,6 @@ Outside the Wizard, skip the `[STATUS]` lines, and tell the person why you stopp {references} -`transactional-email-api.md` is the transactional email API page. It covers senders, the send forms, `posthog-node`, idempotency, and every error code. +`transactional-emails.md` is the transactional email overview. It covers how to create a transactional email and its API key, idempotency, and the error codes. `api.md` covers the send route, including content from your code, and the checks of a send. + +When the `sending-transactional-emails` skill is available, read it too. Its `moving-from-other-providers.md` moves any provider's sends to a PostHog template. This skill adds what is specific to {display_name}: it finds every send, sets up the sender next to {display_name}'s DNS records, imports {display_name}'s suppression list, and hands over the key. diff --git a/context/skills/migrate-email/references/resend/mapping.md b/context/skills/migrate-email/references/resend/mapping.md index eecb90fb0..8649ddbd8 100644 --- a/context/skills/migrate-email/references/resend/mapping.md +++ b/context/skills/migrate-email/references/resend/mapping.md @@ -1,6 +1,6 @@ # 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. +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. `api.md` has the PostHog side in full. ## Fields diff --git a/scripts/lib/tests/migrate-email-skill.test.js b/scripts/lib/tests/migrate-email-skill.test.js index 29ca4ea6c..8ffca7b6e 100644 --- a/scripts/lib/tests/migrate-email-skill.test.js +++ b/scripts/lib/tests/migrate-email-skill.test.js @@ -4,16 +4,18 @@ 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 DOCS_PAGES = { + 'https://posthog.com/docs/workflows/transactional-emails.md': '# Transactional emails\n\nSend one email to one person.\n', + 'https://posthog.com/docs/workflows/transactional-emails/api.md': '# Send transactional emails with the API\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 }); + if (!(url in DOCS_PAGES)) return new Response('not found', { status: 404, statusText: 'Not Found' }); + return new Response(DOCS_PAGES[url], { status: 200 }); }); const generator = await import('../skill-generator.js'); const skills = generator.expandSkillGroups(generator.loadSkillsConfig(CONFIG_DIR), CONFIG_DIR); @@ -76,12 +78,17 @@ describe('migrate-email skill', () => { }, ); - it('bundles the send API reference and the Resend files without the send skill', async () => { + it('bundles the transactional email docs and the Resend files', 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(readFileSync(join(skillDir, 'references', 'transactional-emails.md'), 'utf8')).toBe( + DOCS_PAGES['https://posthog.com/docs/workflows/transactional-emails.md'], + ); + expect(readFileSync(join(skillDir, 'references', 'api.md'), 'utf8')).toBe( + DOCS_PAGES['https://posthog.com/docs/workflows/transactional-emails/api.md'], + ); 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\}/); }); From 0286e235e3c0d2ac05e102d3e3ab130e1b721513 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sun, 1 Nov 2026 13:02:02 +0000 Subject: [PATCH 06/22] fix(skills): check migrate-email's API access with the real tools and PostHog's reply-to rules The preflight looked for two MCP tools that don't exist. It now looks for the make-transactional pair, and a missing pair records the API as not confirmed instead of stopping, because it can also mean a missing scope. The mapping now keeps a site on Resend for reply-to addresses with a name or more than 10, and lists every reserved header and the header and tag length limits. Co-Authored-By: Claude Opus 5.5 --- .../skills/migrate-email/references/2-preflight.md | 11 +++-------- .../skills/migrate-email/references/resend/mapping.md | 6 +++--- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/context/skills/migrate-email/references/2-preflight.md b/context/skills/migrate-email/references/2-preflight.md index ffd88264d..a643d9be2 100644 --- a/context/skills/migrate-email/references/2-preflight.md +++ b/context/skills/migrate-email/references/2-preflight.md @@ -18,15 +18,10 @@ Do not change any file. 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: +- When `workflows-make-email-template-transactional-prepare` and `workflows-make-email-template-transactional-execute` exist, the API is on. Record `api: on`. +- When other PostHog tools exist but that pair doesn't, either the login lacks the `hog_flow:write` scope, or transactional email is not on for the project yet. A missing tool cannot tell these apart. Tell the person both causes, record `api: not confirmed`, and continue. The report asks them to confirm it before cutover. -``` -[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. +Do not call a tool that changes anything to find out. ## The sending tier diff --git a/context/skills/migrate-email/references/resend/mapping.md b/context/skills/migrate-email/references/resend/mapping.md index 8649ddbd8..097d84c87 100644 --- a/context/skills/migrate-email/references/resend/mapping.md +++ b/context/skills/migrate-email/references/resend/mapping.md @@ -14,9 +14,9 @@ This file decides, field by field, whether PostHog can send a Resend call site. | `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 `, 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 | +| `replyTo` / `reply_to` | `replyTo` / `reply_to`, as a list | An entry carries a name, such as `Jane `, there are more than 10, or the code builds the value at run time and you cannot tell that it holds bare addresses only. A single address moves as a list of one | +| `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`, `DKIM-Signature`, `Received`, any `Content-*`, `X-PostHog-*`, `X-SES-*`), a name is longer than 126 characters, a value is longer than 870 characters or 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 `-` or is longer than 256 characters, 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 | From 41ca66aa8bb64d6db4e1ea454ae1c0651099b327 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:37:20 +0000 Subject: [PATCH 07/22] fix(skills): detect resend over smtp, stop without sends, and record partial imports in migrate-email Step 1 now admits the SMTP host and a framework mailer and needs at least one send; step 3 stops when it finds no site. A suppression import that stops partway stays unticked and lands in the report's "Before cutover" list. A template site needs its content from the person or stays on Resend. The sender setup is inlined into step 4, which keeps the existing `from` domain and records an unverified domain instead of waiting. AGENTS.md says the migrate subcommands run once the wizard runs migrate as a family. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- context/shared/email-sender-setup.md | 15 --------------- .../skills/migrate-email/references/1-detect.md | 11 +++++++---- .../migrate-email/references/3-inventory.md | 8 ++++++++ .../skills/migrate-email/references/4-sender.md | 10 +++++++++- .../migrate-email/references/5-suppressions.md | 2 +- .../skills/migrate-email/references/7-report.md | 4 ++-- .../migrate-email/references/resend/mapping.md | 2 +- 8 files changed, 29 insertions(+), 25 deletions(-) delete mode 100644 context/shared/email-sender-setup.md diff --git a/AGENTS.md b/AGENTS.md index 2d5c0779e..45bd33c7b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,7 +89,7 @@ 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`: +**Migrate subcommands**, published as `cliEntries` with `parentCommand: migrate`. They run once the wizard runs `migrate` as a family. Until then, `wizard migrate` stays a flat command that runs `migrate-statsig`, and the wizard rejects a vendor after it: | Subcommand | Backing skill | |---|---| diff --git a/context/shared/email-sender-setup.md b/context/shared/email-sender-setup.md deleted file mode 100644 index bdf95e3c5..000000000 --- a/context/shared/email-sender-setup.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -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/migrate-email/references/1-detect.md b/context/skills/migrate-email/references/1-detect.md index 94881bc9c..fd4a47d30 100644 --- a/context/skills/migrate-email/references/1-detect.md +++ b/context/skills/migrate-email/references/1-detect.md @@ -15,19 +15,22 @@ Discovery only. Do not change any file. ## 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. +Read the provider's `sdk-reference.md`. It lists the provider's packages, imports, clients, send calls, HTTP host, SMTP host, and framework mailers, 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. +- Source code, mail config, or an example env file names its SMTP host or its framework mailer. -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. +Search the source code, not only the manifest. Skip `node_modules`, build output, and lockfiles. + +The migration needs at least one send: a send call, a request to the send route, or mail through the SMTP host or the framework mailer. A package without a send is not a migration, and neither are calls that only read contacts, domains, or an email's status. 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: +When you find no send, stop before any change: ``` [ABORT] no email provider sends found @@ -35,4 +38,4 @@ When you find nothing, stop before any change: 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. +When you find a send, note each file that uses the provider for Step 3, and continue. diff --git a/context/skills/migrate-email/references/3-inventory.md b/context/skills/migrate-email/references/3-inventory.md index 884e90052..174ffc477 100644 --- a/context/skills/migrate-email/references/3-inventory.md +++ b/context/skills/migrate-email/references/3-inventory.md @@ -33,12 +33,20 @@ For each site, read the surrounding code and record: 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. +When you find no site, stop before you write the plan file: + +``` +[ABORT] no email provider sends found +``` + ## 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. +A `template` site needs the template's content to be classified. Ask the person once for a copy of every template the sites send, from the provider's dashboard, and never write it into the plan. When the person can't give the content, the site stays, and the reason is `template content not checked`. + ## 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. diff --git a/context/skills/migrate-email/references/4-sender.md b/context/skills/migrate-email/references/4-sender.md index 87a968c06..e5901d7d7 100644 --- a/context/skills/migrate-email/references/4-sender.md +++ b/context/skills/migrate-email/references/4-sender.md @@ -31,7 +31,15 @@ Read the provider's `account.md` first. It lists the records the provider keeps 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}} +- 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. +- When the `setting-up-an-email-domain` skill is available, read it for the details of the records. Where it differs from this step, this step wins. +- 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 verified. `failed` means fix the records, then verify again. After about 15 minutes of `pending` with every record published, tell the person that DNS is still propagating and that they can ask you to check again later, record the domain as not verified, and continue. +- A 403 that says the person lacks permissions in the project means a project admin must verify the sender. Do not retry: record the domain as not verified, and continue. +- When the sender tools are missing or refused, do not try other integration tools. Ask the person to add a sender on the domain in PostHog: Workflows, then Channels, then add an email sender, and follow the DNS steps (https://posthog.com/docs/workflows/configure-channels). Record the domain as not verified unless they confirm it shows as verified, and continue. ## Write down diff --git a/context/skills/migrate-email/references/5-suppressions.md b/context/skills/migrate-email/references/5-suppressions.md index 02471b58b..916a21439 100644 --- a/context/skills/migrate-email/references/5-suppressions.md +++ b/context/skills/migrate-email/references/5-suppressions.md @@ -36,4 +36,4 @@ Read the provider's `account.md`. It says how to export the list, which entries ## Write down -Tick the phase in the plan file: `- [x] suppressions — / imported`. When the list is empty, write `- [x] suppressions — empty list`. When the person has no export and no key, write `- [ ] suppressions — not imported: `. +Tick the phase in the plan file only when every entry is imported: `- [x] suppressions — / imported`. When the list is empty, write `- [x] suppressions — empty list`. When the import stopped partway, write `- [ ] suppressions — / imported: `. When the person has no export and no key, write `- [ ] suppressions — not imported: `. diff --git a/context/skills/migrate-email/references/7-report.md b/context/skills/migrate-email/references/7-report.md index 6869e754e..eaa7a69ee 100644 --- a/context/skills/migrate-email/references/7-report.md +++ b/context/skills/migrate-email/references/7-report.md @@ -51,7 +51,7 @@ Read `.posthog-email-migration-plan.md`. Write `posthog-migrate-email- ## Before cutover -
+ ## Summary ``` @@ -72,7 +72,7 @@ suppressions_ready: true - `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. +- `suppressions_ready`: `true` when every entry of the suppression list was imported, or the list was empty. Sites that are ready to move count in `sites_found` only. diff --git a/context/skills/migrate-email/references/resend/mapping.md b/context/skills/migrate-email/references/resend/mapping.md index 097d84c87..8a27a39c7 100644 --- a/context/skills/migrate-email/references/resend/mapping.md +++ b/context/skills/migrate-email/references/resend/mapping.md @@ -12,7 +12,7 @@ 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 `, or there are more than 10 | | `replyTo` / `reply_to` | `replyTo` / `reply_to`, as a list | An entry carries a name, such as `Jane `, there are more than 10, or the code builds the value at run time and you cannot tell that it holds bare addresses only. A single address moves as a list of one | | `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`, `DKIM-Signature`, `Received`, any `Content-*`, `X-PostHog-*`, `X-SES-*`), a name is longer than 126 characters, a value is longer than 870 characters or has a line break or a non-ASCII character, or there are more than 10 | From 0e526b923515d9b0ba3a765c6498d6ae3b04f2b8 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:37:20 +0000 Subject: [PATCH 08/22] refactor(scripts): ship variant references through the local reference loop The selected variant's `references//` files now go through the same loop as the root reference files, so the separate copy helper is gone. The partial expander drops the indentation it gained for the sender partial, which nothing includes any more, and the bundle test drops the migrate-statsig case that guarded nothing. Co-Authored-By: Claude Opus 5.5 --- scripts/lib/skill-generator.js | 69 ++++++------------- scripts/lib/tests/migrate-email-skill.test.js | 15 ++-- scripts/lib/tests/partial-includes.test.js | 8 --- 3 files changed, 27 insertions(+), 65 deletions(-) diff --git a/scripts/lib/skill-generator.js b/scripts/lib/skill-generator.js index 35eb4697e..c5b355059 100644 --- a/scripts/lib/skill-generator.js +++ b/scripts/lib/skill-generator.js @@ -55,18 +55,16 @@ function loadYaml(configPath) { // A line that is exactly `{{> name}}` is replaced at build time with the body // of `/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. 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; +// includes it stays in sync. +const PARTIAL_DIRECTIVE = /^[ \t]*\{\{>\s*([a-z0-9-]+)\s*\}\}[ \t]*$/gm; function expandPartials(body, configDir) { - return body.replace(PARTIAL_DIRECTIVE, (_match, indent, name) => { + return body.replace(PARTIAL_DIRECTIVE, (_match, name) => { const partialPath = path.join(configDir, 'shared', `${name}.md`); if (!fs.existsSync(partialPath)) { throw new Error(`Partial include {{> ${name}}} references missing file ${partialPath}`); } - const partial = matter(fs.readFileSync(partialPath, 'utf8')).content.trim(); - return partial.replace(/^(?=.)/gm, indent); + return matter(fs.readFileSync(partialPath, 'utf8')).content.trim(); }); } @@ -596,29 +594,12 @@ function generateFrontmatter(skill, version) { return '---\n' + yaml.dump(frontmatter) + '---\n\n'; } -/** - * Copy the markdown files of a variant's own folder, `references//`, - * 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 }) +function markdownReferenceNames(dir, prefix) { + if (!fs.existsSync(dir)) return []; + return fs.readdirSync(dir, { 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, - }; - }); + .map(entry => path.posix.join(prefix, entry.name)) + .sort((a, b) => a.localeCompare(b)); } /** @@ -694,21 +675,22 @@ async function generateSkill({ // Group config injects a shared `preamble`; per-file `next_step` frontmatter drives continuation links. const sourceReferencesDir = path.join(configDir, 'skills', ...skill._group.split('/'), 'references'); if (fs.existsSync(sourceReferencesDir)) { - const localReferences = fs.readdirSync(sourceReferencesDir, { withFileTypes: true }) - .filter(entry => entry.isFile() && entry.name.endsWith('.md')) - .sort((a, b) => a.name.localeCompare(b.name)); + const localReferences = [ + ...markdownReferenceNames(sourceReferencesDir, ''), + ...markdownReferenceNames(path.join(sourceReferencesDir, skill._shortId), skill._shortId), + ]; const refsConfig = skill._references || {}; for (const reference of localReferences) { - const sourcePath = path.join(sourceReferencesDir, reference.name); + const sourcePath = path.join(sourceReferencesDir, reference); const parsed = matter(fs.readFileSync(sourcePath, 'utf8')); const nextFile = parsed.data.next_step; const isWorkflowStep = 'next_step' in parsed.data; let body = expandPartials(parsed.content, configDir).replace(/^\n+/, '').replace(/\s+$/, ''); const headingMatch = body.match(/^#\s+(.+)$/m); - const displayTitle = parsed.data.title || headingMatch?.[1] || reference.name; - const displayDescription = parsed.data.description || headingMatch?.[1] || reference.name; + const displayTitle = parsed.data.title || headingMatch?.[1] || reference; + const displayDescription = parsed.data.description || headingMatch?.[1] || reference; if (nextFile) { if (refsConfig.preamble && headingMatch) { @@ -726,31 +708,22 @@ async function generateSkill({ ? `---\n${yaml.dump(emittedFrontmatter, { lineWidth: -1 })}---\n\n${body}` : body; - fs.writeFileSync( - path.join(referencesDir, reference.name), - fileContent, - 'utf8' - ); + const targetPath = path.join(referencesDir, reference); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, fileContent, 'utf8'); references.push({ - filename: reference.name, + filename: reference, description: displayDescription, }); if (isWorkflowStep) { workflowSteps.push({ - filename: reference.name, + filename: reference, title: displayTitle, }); } } - - 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/migrate-email-skill.test.js b/scripts/lib/tests/migrate-email-skill.test.js index 8ffca7b6e..14044d5ef 100644 --- a/scripts/lib/tests/migrate-email-skill.test.js +++ b/scripts/lib/tests/migrate-email-skill.test.js @@ -66,17 +66,14 @@ describe('migrate-email skill', () => { 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-')); + it('builds migrate-email-resend as a bundle whose every reference path resolves', async () => { + outputDir = mkdtempSync(join(tmpdir(), 'migrate-skill-')); - const skillDir = await buildSkill(skillId, outputDir); + const skillDir = await buildSkill('migrate-email-resend', outputDir); - expect(readFileSync(join(skillDir, 'SKILL.md'), 'utf8')).toMatch(new RegExp(`^---\\nname: ${skillId}\\n`)); - expect(unresolvedReferences(skillDir)).toEqual([]); - }, - ); + expect(readFileSync(join(skillDir, 'SKILL.md'), 'utf8')).toMatch(/^---\nname: migrate-email-resend\n/); + expect(unresolvedReferences(skillDir)).toEqual([]); + }); it('bundles the transactional email docs and the Resend files', async () => { outputDir = mkdtempSync(join(tmpdir(), 'migrate-skill-')); diff --git a/scripts/lib/tests/partial-includes.test.js b/scripts/lib/tests/partial-includes.test.js index 3741c14a3..5ebaa8e66 100644 --- a/scripts/lib/tests/partial-includes.test.js +++ b/scripts/lib/tests/partial-includes.test.js @@ -42,14 +42,6 @@ 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 4ad8c6e8a7b89c5d8673442af13deb03172ee968 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:49:59 +0000 Subject: [PATCH 09/22] fix(skills): let migrate-email move on when DNS waits and read templates from files Step 4 records a domain as not verified, with its records or Domain Connect link, when the person can't publish them during the run. Step 3 asks the person to save each template from the dashboard to a file outside the repository and reads it, since the Wizard can't take pasted HTML. Co-Authored-By: Claude Opus 5.5 --- context/skills/migrate-email/references/3-inventory.md | 2 +- context/skills/migrate-email/references/4-sender.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/context/skills/migrate-email/references/3-inventory.md b/context/skills/migrate-email/references/3-inventory.md index 174ffc477..160f32877 100644 --- a/context/skills/migrate-email/references/3-inventory.md +++ b/context/skills/migrate-email/references/3-inventory.md @@ -45,7 +45,7 @@ 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. Ask the person once for a copy of every template the sites send, from the provider's dashboard, and never write it into the plan. When the person can't give the content, the site stays, and the reason is `template content not checked`. +A `template` site needs the template's content to be classified. Ask the person once to copy every template the sites send from the provider's dashboard into files outside the repository, and to tell you where they saved them. Read those files, and never write the content into the plan. When the person can't give the content, the site stays, and the reason is `template content not checked`. ## Write the plan file diff --git a/context/skills/migrate-email/references/4-sender.md b/context/skills/migrate-email/references/4-sender.md index e5901d7d7..10dee3458 100644 --- a/context/skills/migrate-email/references/4-sender.md +++ b/context/skills/migrate-email/references/4-sender.md @@ -38,6 +38,7 @@ Send from the domain of the existing `from` address, so the emails keep their se - 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 verified. `failed` means fix the records, then verify again. After about 15 minutes of `pending` with every record published, tell the person that DNS is still propagating and that they can ask you to check again later, record the domain as not verified, and continue. +- When the person can't publish the records during this run, such as when another team owns DNS, record the domain as not verified with its records or its Domain Connect link, and continue. - A 403 that says the person lacks permissions in the project means a project admin must verify the sender. Do not retry: record the domain as not verified, and continue. - When the sender tools are missing or refused, do not try other integration tools. Ask the person to add a sender on the domain in PostHog: Workflows, then Channels, then add an email sender, and follow the DNS steps (https://posthog.com/docs/workflows/configure-channels). Record the domain as not verified unless they confirm it shows as verified, and continue. From 8f77354c203d8e7e4efcd377d3a0725872dc5fd1 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:02:22 +0000 Subject: [PATCH 10/22] fix(skills): keep cc, bcc, headers, tags and attachments sites on Resend, and move templates only to PostHog The first PostHog release sends a template made in PostHog by template id, or rendered html and text. A Resend template site moves only once the person makes the template in PostHog, or stays on Resend for now. Co-Authored-By: Claude Opus 5.5 --- .../skills/migrate-email/references/3-inventory.md | 2 +- .../skills/migrate-email/references/7-report.md | 2 +- .../migrate-email/references/resend/mapping.md | 14 ++++++++------ 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/context/skills/migrate-email/references/3-inventory.md b/context/skills/migrate-email/references/3-inventory.md index 160f32877..2def92d75 100644 --- a/context/skills/migrate-email/references/3-inventory.md +++ b/context/skills/migrate-email/references/3-inventory.md @@ -45,7 +45,7 @@ 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. Ask the person once to copy every template the sites send from the provider's dashboard into files outside the repository, and to tell you where they saved them. Read those files, and never write the content into the plan. When the person can't give the content, the site stays, and the reason is `template content not checked`. +A `template` site moves only to a template made in PostHog, and the person makes that template. Ask the person once, for every template the sites send, whether to move it to PostHog or keep it on the provider for now. A template they keep stays, and the reason is `kept on for now`. For each template they move, ask them to copy it from the provider's dashboard into a file outside the repository, and to tell you where they saved it. Read those files to classify the site, and never write the content into the plan. When the person can't give the content, the site stays, and the reason is `template content not checked`. ## Write the plan file diff --git a/context/skills/migrate-email/references/7-report.md b/context/skills/migrate-email/references/7-report.md index eaa7a69ee..e7f4c39ce 100644 --- a/context/skills/migrate-email/references/7-report.md +++ b/context/skills/migrate-email/references/7-report.md @@ -51,7 +51,7 @@ Read `.posthog-email-migration-plan.md`. Write `posthog-migrate-email- ## Before cutover - + ## Summary ``` diff --git a/context/skills/migrate-email/references/resend/mapping.md b/context/skills/migrate-email/references/resend/mapping.md index 8a27a39c7..0630178d0 100644 --- a/context/skills/migrate-email/references/resend/mapping.md +++ b/context/skills/migrate-email/references/resend/mapping.md @@ -12,12 +12,12 @@ 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 | 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 `, or there are more than 10 | +| `template` | a template made in PostHog, sent by its `templateId` with `templateData` | The person keeps it on Resend for now, its content can't be read, it uses syntax beyond variables and fallbacks, the app fills a variable with HTML, or the site sets `from`, `replyTo`, or `subject` next to it | +| `cc`, `bcc` | none | Always | | `replyTo` / `reply_to` | `replyTo` / `reply_to`, as a list | An entry carries a name, such as `Jane `, there are more than 10, or the code builds the value at run time and you cannot tell that it holds bare addresses only. A single address moves as a list of one | -| `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`, `DKIM-Signature`, `Received`, any `Content-*`, `X-PostHog-*`, `X-SES-*`), a name is longer than 126 characters, a value is longer than 870 characters or 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 `-` or is longer than 256 characters, 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` | +| `headers` | none | Always | +| `tags` | none | Always | +| `attachments` | none | Always | | `idempotencyKey` / `Idempotency-Key` | `idempotencyKey` / `Idempotency-Key` | Never. The key keeps its value | | `scheduledAt` / `scheduled_at` | none | Always | | `topicId` / `topic_id` | none | Always | @@ -43,4 +43,6 @@ 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 moves only to a template made in PostHog. This skill never makes one: the person makes it from the Resend template, makes it transactional, and gives you its template ID. Until then the site stays on Resend. +- The check needs the template's content from the Resend dashboard, because the code has only its id. In the PostHog template, `{{{NAME}}}` becomes `{{ variables.NAME }}`, and `{{{NAME|there}}}` becomes `{{ variables.NAME | default: 'there' }}`. PostHog's Liquid escapes HTML in a variable's value, so a variable the app fills with HTML keeps the site on Resend. +- The PostHog template carries its own sender, reply-to address, and subject. A site that sets `from`, `replyTo`, or `subject` next to `template` stays on Resend. From 5c8a34b8b86a5d521ad389f83c37a82d2266fdad Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:26:55 +0000 Subject: [PATCH 11/22] fix(migrate-email): ask for the PostHog template ID and sender before a template site moves Review round 1 on #450: - Step 3 checks a template site's other fields before it asks about the template, and asks for the PostHog template ID and the address it sends from. A template without an ID stays: PostHog template not made yet. The address goes in the from column, so Step 4 sets up its domain. - mapping.md quotes a fallback that holds ' with ", carries a definition-level fallback_value, notes that default replaces an empty string, and keeps a fallback with both quotes or HTML on Resend. - The report asks the person to test each PostHog template before cutover. Co-Authored-By: Claude Opus 5.5 --- context/skills/migrate-email/references/3-inventory.md | 4 +++- context/skills/migrate-email/references/7-report.md | 2 +- context/skills/migrate-email/references/resend/mapping.md | 6 +++++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/context/skills/migrate-email/references/3-inventory.md b/context/skills/migrate-email/references/3-inventory.md index 2def92d75..8ad9ba5ec 100644 --- a/context/skills/migrate-email/references/3-inventory.md +++ b/context/skills/migrate-email/references/3-inventory.md @@ -45,7 +45,9 @@ 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 moves only to a template made in PostHog, and the person makes that template. Ask the person once, for every template the sites send, whether to move it to PostHog or keep it on the provider for now. A template they keep stays, and the reason is `kept on for now`. For each template they move, ask them to copy it from the provider's dashboard into a file outside the repository, and to tell you where they saved it. Read those files to classify the site, and never write the content into the plan. When the person can't give the content, the site stays, and the reason is `template content not checked`. +A `template` site moves only to a template made in PostHog, and the person makes that template. Check the site's other fields first: a site that another field keeps on the provider stays with that reason, and needs no question about its template. Ask the person once, for every other template the sites send, whether to move it to PostHog or keep it on the provider for now. A template they keep stays, and the reason is `kept on for now`. For each template they move, ask them to copy it from the provider's dashboard into a file outside the repository, and to tell you where they saved it. Read those files to classify the site, and never write the content into the plan. When the person can't give the content, the site stays, and the reason is `template content not checked`. + +A template whose content passes the checks moves only once the person has made it in PostHog. Ask them once, for every such template, to make it in PostHog from the provider's template, make it transactional, and give you its template ID and the address it sends from, usually the provider template's sender. Tell them how its placeholders change, as `mapping.md` says under "Content". Record each template ID with its site under the sites table, and write the address in the site's `from` column, so Step 4 sets up a sender for its domain. A template without an ID stays, and the reason is `PostHog template not made yet`. ## Write the plan file diff --git a/context/skills/migrate-email/references/7-report.md b/context/skills/migrate-email/references/7-report.md index e7f4c39ce..8a321c0f0 100644 --- a/context/skills/migrate-email/references/7-report.md +++ b/context/skills/migrate-email/references/7-report.md @@ -51,7 +51,7 @@ Read `.posthog-email-migration-plan.md`. Write `posthog-migrate-email- ## Before cutover - + ## Summary ``` diff --git a/context/skills/migrate-email/references/resend/mapping.md b/context/skills/migrate-email/references/resend/mapping.md index 0630178d0..31be4fd5e 100644 --- a/context/skills/migrate-email/references/resend/mapping.md +++ b/context/skills/migrate-email/references/resend/mapping.md @@ -44,5 +44,9 @@ 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 moves only to a template made in PostHog. This skill never makes one: the person makes it from the Resend template, makes it transactional, and gives you its template ID. Until then the site stays on Resend. -- The check needs the template's content from the Resend dashboard, because the code has only its id. In the PostHog template, `{{{NAME}}}` becomes `{{ variables.NAME }}`, and `{{{NAME|there}}}` becomes `{{ variables.NAME | default: 'there' }}`. PostHog's Liquid escapes HTML in a variable's value, so a variable the app fills with HTML keeps the site on Resend. +- The check needs the template's content from the Resend dashboard, because the code has only its id. +- In the PostHog template, `{{{NAME}}}` becomes `{{ variables.NAME }}`, and the name keeps its case. `{{{NAME|there}}}` becomes `{{ variables.NAME | default: 'there' }}`, and 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. +- Resend inserts a variable's value as it is. PostHog's Liquid escapes HTML in a value, so `` shows as text. A variable is HTML when the code fills it with markup or with a renderer's output, and such a variable keeps the site on Resend. 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. So does a fallback that holds both `'` and `"`, and a fallback that holds HTML, such as a link: Liquid escapes the fallback too, so the markup shows as text. - The PostHog template carries its own sender, reply-to address, and subject. A site that sets `from`, `replyTo`, or `subject` next to `template` stays on Resend. From efa493348628cc8f01b1afc53b3e02d6f0f25141 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:39:13 +0000 Subject: [PATCH 12/22] fix(skills): read Dirent.path when parentPath is missing in the migrate-email test Node 20.11, the oldest release engines allows, has no Dirent.parentPath, so the bundle tests threw before their assertions. Use the same parentPath ?? path fallback as build-phases.js. Co-Authored-By: Claude Opus 5.5 --- scripts/lib/tests/migrate-email-skill.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/lib/tests/migrate-email-skill.test.js b/scripts/lib/tests/migrate-email-skill.test.js index 14044d5ef..67217f510 100644 --- a/scripts/lib/tests/migrate-email-skill.test.js +++ b/scripts/lib/tests/migrate-email-skill.test.js @@ -44,7 +44,7 @@ async function buildSkill(skillId, outputDir) { 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)); + .map((entry) => join(entry.parentPath ?? entry.path, entry.name)); } function unresolvedReferences(skillDir) { From 8dcf6e74a3d41ad5ecce98ab6da44bfe1c47e406 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:49:02 +0000 Subject: [PATCH 13/22] fix(migrate-email): keep a site on Resend when its idempotency key doesn't fit PostHog's PostHog takes an idempotency key of 1 to 255 printable ASCII characters without spaces. A site whose key can hold a space or a non-ASCII character, or run longer, stays on Resend. The report names each key a retry across the deploy could send twice, since the providers don't share keys. Co-Authored-By: Claude Opus 5.5 --- context/skills/migrate-email/references/7-report.md | 2 +- context/skills/migrate-email/references/resend/mapping.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/context/skills/migrate-email/references/7-report.md b/context/skills/migrate-email/references/7-report.md index 8a321c0f0..fc974a0c9 100644 --- a/context/skills/migrate-email/references/7-report.md +++ b/context/skills/migrate-email/references/7-report.md @@ -51,7 +51,7 @@ Read `.posthog-email-migration-plan.md`. Write `posthog-migrate-email- ## Before cutover - + ## Summary ``` diff --git a/context/skills/migrate-email/references/resend/mapping.md b/context/skills/migrate-email/references/resend/mapping.md index 31be4fd5e..a3d12a58e 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` | none | Always | | `tags` | none | Always | | `attachments` | none | Always | -| `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 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 | From 07dbf3a6d437e002f66801ef7a973514a96e870c Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:49:17 +0000 Subject: [PATCH 14/22] fix(migrate-email): name PostHog's idempotency key rule exactly Co-Authored-By: Claude Opus 5.5 --- 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 a3d12a58e..d72d5ed3a 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` | none | Always | | `tags` | none | Always | | `attachments` | none | Always | -| `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 | +| `idempotencyKey` / `Idempotency-Key` | `idempotencyKey` / `Idempotency-Key` | The key can hold anything but visible ASCII characters, such as a space, or be longer than 255 characters. PostHog takes 1 to 255 visible ASCII 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 | From 5c9224593d631e1dc7e138c18f2977881c9a9ec1 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 5 Nov 2026 07:44:49 +0000 Subject: [PATCH 15/22] docs(migrate-email): ask for the transactional_email:write scope Code sends now need transactional_email:write. A key with hog_flow:write keeps working through the legacy scope fallback. Co-Authored-By: Claude Opus 5.5 --- context/skills/migrate-email/references/6-key.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/context/skills/migrate-email/references/6-key.md b/context/skills/migrate-email/references/6-key.md index 0b4a08208..c49eed2e7 100644 --- a/context/skills/migrate-email/references/6-key.md +++ b/context/skills/migrate-email/references/6-key.md @@ -15,7 +15,7 @@ PostHog's send needs a project secret API key on the server. The provider's 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. +- Ask the person to create a project secret API key (it starts with `phs_`) with the `transactional_email:write` scope in their PostHog project settings. A key that already has `hog_flow:write` works too. - 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. From 254dbb9fba905fbb690253eb42f1941d0d8dab8b Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 5 Nov 2026 08:09:32 +0000 Subject: [PATCH 16/22] fix(migrate-email): name the transactional email scope in the preflight The make-transactional tools move to transactional_email:write, and a login with hog_flow:write still works. Co-Authored-By: Claude Opus 5.5 --- context/skills/migrate-email/references/2-preflight.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/context/skills/migrate-email/references/2-preflight.md b/context/skills/migrate-email/references/2-preflight.md index a643d9be2..e6ee87ee5 100644 --- a/context/skills/migrate-email/references/2-preflight.md +++ b/context/skills/migrate-email/references/2-preflight.md @@ -19,7 +19,7 @@ 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 `workflows-make-email-template-transactional-prepare` and `workflows-make-email-template-transactional-execute` exist, the API is on. Record `api: on`. -- When other PostHog tools exist but that pair doesn't, either the login lacks the `hog_flow:write` scope, or transactional email is not on for the project yet. A missing tool cannot tell these apart. Tell the person both causes, record `api: not confirmed`, and continue. The report asks them to confirm it before cutover. +- When other PostHog tools exist but that pair doesn't, either the login lacks the `transactional_email:write` scope (a login with `hog_flow:write` works too), or transactional email is not on for the project yet. A missing tool cannot tell these apart. Tell the person both causes, record `api: not confirmed`, and continue. The report asks them to confirm it before cutover. Do not call a tool that changes anything to find out. From 487262bc3c73dda2b8b5d01d3ff7977eae0e274e Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:02:06 +0000 Subject: [PATCH 17/22] fix(skills): close the migrate-email preparation gaps the QA swarm found - Step 6 ticks the key only when a check finds it in the env file, and the report lists a key that is not set. - Step 3 asks for a template to be made transactional only once a verified sender covers its address, since PostHog refuses it before. - Step 2 and the report name sends with content from the code, which PostHog enables apart from template sends. - The report asks for a last suppression import right before cutover, since the provider keeps adding entries. - A template site that sets `reply_to` stays on Resend, as `replyTo` does. - The CLI test reads the entries the Wizard gets from the menu. Co-Authored-By: Claude Opus 5.5 --- .../migrate-email/references/2-preflight.md | 2 + .../migrate-email/references/3-inventory.md | 2 +- .../references/5-suppressions.md | 1 + .../skills/migrate-email/references/6-key.md | 14 ++- .../migrate-email/references/7-report.md | 2 +- .../references/resend/mapping.md | 4 +- scripts/lib/tests/migrate-email-skill.test.js | 87 +++++++++++++++++-- 7 files changed, 101 insertions(+), 11 deletions(-) diff --git a/context/skills/migrate-email/references/2-preflight.md b/context/skills/migrate-email/references/2-preflight.md index e6ee87ee5..129d17bae 100644 --- a/context/skills/migrate-email/references/2-preflight.md +++ b/context/skills/migrate-email/references/2-preflight.md @@ -23,6 +23,8 @@ Check which PostHog MCP tools exist. Do not call a tool that changes anything to find out. +Those tools confirm sends by template. PostHog enables sends with content from the code separately, and no tool shows it. The report asks the person to confirm it before cutover when a site that moves sends its content from the code. + ## 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. diff --git a/context/skills/migrate-email/references/3-inventory.md b/context/skills/migrate-email/references/3-inventory.md index 8ad9ba5ec..a39070527 100644 --- a/context/skills/migrate-email/references/3-inventory.md +++ b/context/skills/migrate-email/references/3-inventory.md @@ -47,7 +47,7 @@ When the code passes a value you can't read, such as a list of recipients built A `template` site moves only to a template made in PostHog, and the person makes that template. Check the site's other fields first: a site that another field keeps on the provider stays with that reason, and needs no question about its template. Ask the person once, for every other template the sites send, whether to move it to PostHog or keep it on the provider for now. A template they keep stays, and the reason is `kept on for now`. For each template they move, ask them to copy it from the provider's dashboard into a file outside the repository, and to tell you where they saved it. Read those files to classify the site, and never write the content into the plan. When the person can't give the content, the site stays, and the reason is `template content not checked`. -A template whose content passes the checks moves only once the person has made it in PostHog. Ask them once, for every such template, to make it in PostHog from the provider's template, make it transactional, and give you its template ID and the address it sends from, usually the provider template's sender. Tell them how its placeholders change, as `mapping.md` says under "Content". Record each template ID with its site under the sites table, and write the address in the site's `from` column, so Step 4 sets up a sender for its domain. A template without an ID stays, and the reason is `PostHog template not made yet`. +A template whose content passes the checks moves only once the person has made it in PostHog. Ask them once, for every such template, to make it in PostHog from the provider's template and give you its template ID and the address it sends from, usually the provider template's sender. PostHog makes a template transactional only once a verified sender covers its address: ask them to make it transactional now when the project has one, and otherwise once Step 4 verifies the sender. The report lists it before cutover either way. Tell them how its placeholders change, as `mapping.md` says under "Content". Record each template ID with its site under the sites table, and write the address in the site's `from` column, so Step 4 sets up a sender for its domain. A template without an ID stays, and the reason is `PostHog template not made yet`. ## Write the plan file diff --git a/context/skills/migrate-email/references/5-suppressions.md b/context/skills/migrate-email/references/5-suppressions.md index 916a21439..eb723bf2a 100644 --- a/context/skills/migrate-email/references/5-suppressions.md +++ b/context/skills/migrate-email/references/5-suppressions.md @@ -31,6 +31,7 @@ Read the provider's `account.md`. It says how to export the list, which entries - 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. +- The provider keeps adding to its list while it sends, so the report asks the person for one last import right before cutover. - 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. diff --git a/context/skills/migrate-email/references/6-key.md b/context/skills/migrate-email/references/6-key.md index c49eed2e7..6d5a59f28 100644 --- a/context/skills/migrate-email/references/6-key.md +++ b/context/skills/migrate-email/references/6-key.md @@ -21,6 +21,18 @@ PostHog's send needs a project secret API key on the server. The provider's key - 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. +## Check that the key is set + +The key is set only when the env file holds it. Inside the Wizard, it is set once `set_env_values` succeeds. Otherwise, once the person says they added it, check the file with a command that prints a count and never the key. It reads the last line that sets the variable, as the app's env loader does, and checks that the value starts with `phs_`: + +```bash +grep -E '^[[:space:]]*(export[[:space:]]+)?POSTHOG_PROJECT_SECRET_API_KEY[[:space:]]*=' .env | tail -n 1 | grep -cE '^[^=]*=[[:space:]]*[^#[:space:]]?phs_' +``` + +Use the env file the key went into in place of `.env`. A count of `0` means the key is not set. + +A person who says they will add the key later has not set it, and neither has one who declines. Don't wait for them: record the key as not set, and go on. + ## 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. @@ -29,4 +41,4 @@ List each place with its file and line. Each one needs `POSTHOG_PROJECT_SECRET_A ## Write down -Tick the phase in the plan file: `- [x] key — POSTHOG_PROJECT_SECRET_API_KEY set in `, or `- [ ] key — not set: `. Under the phase list, add the places of the provider key. +Tick the phase in the plan file only when the check above found the key: `- [x] key — POSTHOG_PROJECT_SECRET_API_KEY set in `. Otherwise write `- [ ] key — not set: `, such as `the person adds it to .env`. 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 index fc974a0c9..6795e92df 100644 --- a/context/skills/migrate-email/references/7-report.md +++ b/context/skills/migrate-email/references/7-report.md @@ -51,7 +51,7 @@ Read `.posthog-email-migration-plan.md`. Write `posthog-migrate-email- ## Before cutover - + ## Summary ``` diff --git a/context/skills/migrate-email/references/resend/mapping.md b/context/skills/migrate-email/references/resend/mapping.md index d72d5ed3a..63b6fe064 100644 --- a/context/skills/migrate-email/references/resend/mapping.md +++ b/context/skills/migrate-email/references/resend/mapping.md @@ -12,7 +12,7 @@ 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 template made in PostHog, sent by its `templateId` with `templateData` | The person keeps it on Resend for now, its content can't be read, it uses syntax beyond variables and fallbacks, the app fills a variable with HTML, or the site sets `from`, `replyTo`, or `subject` next to it | +| `template` | a template made in PostHog, sent by its `templateId` with `templateData` | The person keeps it on Resend for now, its content can't be read, it uses syntax beyond variables and fallbacks, the app fills a variable with HTML, or the site sets `from`, `replyTo` / `reply_to`, or `subject` next to it | | `cc`, `bcc` | none | Always | | `replyTo` / `reply_to` | `replyTo` / `reply_to`, as a list | An entry carries a name, such as `Jane `, there are more than 10, or the code builds the value at run time and you cannot tell that it holds bare addresses only. A single address moves as a list of one | | `headers` | none | Always | @@ -49,4 +49,4 @@ This file decides, field by field, whether PostHog can send a Resend call site. - 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. - Resend inserts a variable's value as it is. PostHog's Liquid escapes HTML in a value, so `` shows as text. A variable is HTML when the code fills it with markup or with a renderer's output, and such a variable keeps the site on Resend. 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. So does a fallback that holds both `'` and `"`, and a fallback that holds HTML, such as a link: Liquid escapes the fallback too, so the markup shows as text. -- The PostHog template carries its own sender, reply-to address, and subject. A site that sets `from`, `replyTo`, or `subject` next to `template` stays on Resend. +- The PostHog template carries its own sender, reply-to address, and subject. A site that sets `from`, `replyTo` / `reply_to`, or `subject` next to `template` stays on Resend. diff --git a/scripts/lib/tests/migrate-email-skill.test.js b/scripts/lib/tests/migrate-email-skill.test.js index 67217f510..0d6c353fb 100644 --- a/scripts/lib/tests/migrate-email-skill.test.js +++ b/scripts/lib/tests/migrate-email-skill.test.js @@ -1,7 +1,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'fs'; +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { spawnSync } from 'child_process'; import { join, relative } from 'path'; import { tmpdir } from 'os'; +import { generateCliEntries } from '../build-phases.js'; const CONFIG_DIR = join(process.cwd(), 'context'); const DOCS_PAGES = { @@ -9,6 +11,7 @@ const DOCS_PAGES = { 'https://posthog.com/docs/workflows/transactional-emails/api.md': '# Send transactional emails with the API\n', }; const REFERENCE_PATH = /references\/[A-Za-z0-9_./-]+\.md/g; +const read = (file) => readFileSync(join(CONFIG_DIR, 'skills', 'migrate-email', 'references', file), 'utf8'); async function loadSkills() { vi.resetModules(); @@ -90,11 +93,83 @@ describe('migrate-email skill', () => { 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; + describe('the key check in step 6', () => { + const keyStep = readFileSync(join(CONFIG_DIR, 'skills', 'migrate-email', 'references', '6-key.md'), 'utf8'); + const command = keyStep.match(/```bash\n(grep .+)\n```/)?.[1]; + let envDir; - expect(cliOf('migrate-email-resend')).toEqual({ role: 'command', parentCommand: 'migrate', command: 'resend' }); - expect(cliOf('migrate-statsig')).toEqual({ role: 'command', parentCommand: 'migrate', command: 'statsig' }); + afterEach(() => { + if (envDir) rmSync(envDir, { recursive: true, force: true }); + envDir = undefined; + }); + + function check(envFile) { + envDir = mkdtempSync(join(tmpdir(), 'migrate-key-')); + writeFileSync(join(envDir, '.env'), envFile); + return spawnSync('bash', ['-c', command], { cwd: envDir, encoding: 'utf8' }); + } + + it.each([ + ['a plain key', 'POSTHOG_PROJECT_SECRET_API_KEY=phs_abc\n', '1'], + ['an exported, spaced and double-quoted key', ' export POSTHOG_PROJECT_SECRET_API_KEY = "phs_abc"\n', '1'], + ['a single-quoted key', "POSTHOG_PROJECT_SECRET_API_KEY='phs_abc'\n", '1'], + ['a key after an empty line for it', 'POSTHOG_PROJECT_SECRET_API_KEY=\nPOSTHOG_PROJECT_SECRET_API_KEY=phs_abc\n', '1'], + ['no line for the key', 'RESEND_API_KEY=re_abc\n', '0'], + ['an empty key', 'POSTHOG_PROJECT_SECRET_API_KEY=\n', '0'], + ['an empty quoted key', 'POSTHOG_PROJECT_SECRET_API_KEY=""\n', '0'], + ['a commented-out line', '# POSTHOG_PROJECT_SECRET_API_KEY=phs_abc\n', '0'], + ['a value that is only a comment', 'POSTHOG_PROJECT_SECRET_API_KEY= #phs_abc\n', '0'], + ['a key overridden by a later empty line', 'POSTHOG_PROJECT_SECRET_API_KEY=phs_abc\nPOSTHOG_PROJECT_SECRET_API_KEY=\n', '0'], + ['another variable ending in the name', 'OTHER_POSTHOG_PROJECT_SECRET_API_KEY=phs_abc\n', '0'], + ])('finds the key in %s only when it is set, and never prints it', (_name, envFile, count) => { + expect(command).toBeDefined(); + const result = check(envFile); + + expect(result.stdout.trim()).toBe(count); + expect(result.stdout).not.toContain('phs_'); + expect(result.stderr).toBe(''); + }); + + it('ticks the phase only when the check found the key, and the report asks for a key that is not set', () => { + expect(keyStep).toMatch(/A person who says they will add the key later has not set it/); + expect(keyStep).toMatch(/Tick the phase in the plan file only when the check above found the key/); + expect(keyStep).toMatch(/`- \[ \] key — not set: `/); + expect(read('7-report.md')).toMatch(/`POSTHOG_PROJECT_SECRET_API_KEY` in the env file when the plan says it is not set/); + }); + }); + + describe('what the run cannot finish before cutover', () => { + it('asks for a last suppression import right before cutover, since the provider keeps adding entries', () => { + expect(read('5-suppressions.md')).toMatch(/The provider keeps adding to its list while it sends/); + expect(read('7-report.md')).toMatch(/import the entries the provider added to its suppression list since the export, right before cutover/); + }); + + it('lets a template made in PostHog become transactional only once its sender is verified', () => { + const inventory = read('3-inventory.md'); + + expect(inventory).toMatch(/PostHog makes a template transactional only once a verified sender covers its address/); + expect(inventory).not.toMatch(/make it in PostHog from the provider's template, make it transactional, and give you/); + }); + + it('has the person confirm sends with content from the code, which the template tools do not show', () => { + expect(read('2-preflight.md')).toMatch(/PostHog enables sends with content from the code separately/); + expect(read('7-report.md')).toMatch(/sends with content from the code \(`html`, `text`, or `react`\) when a site that moves has them \(confirm with PostHog/); + }); + }); + + it('keeps a template site on Resend when it sets a reply-to, in either spelling', () => { + const mapping = read('resend/mapping.md'); + + expect(mapping).toMatch(/the site sets `from`, `replyTo` \/ `reply_to`, or `subject` next to it \|/); + expect(mapping).toMatch(/A site that sets `from`, `replyTo` \/ `reply_to`, or `subject` next to `template` stays on Resend/); + }); + + it('publishes Resend and Statsig as wizard migrate subcommands in the menu the Wizard reads', async () => { + const { generator, skills } = await loadSkills(); + const entries = generateCliEntries({ allSkills: skills.map(generator.serializeSkill) }); + const entryOf = (skillId) => entries.find((entry) => entry.skillId === skillId); + + expect(entryOf('migrate-email-resend')).toMatchObject({ role: 'command', parentCommand: 'migrate', command: 'resend' }); + expect(entryOf('migrate-statsig')).toMatchObject({ role: 'command', parentCommand: 'migrate', command: 'statsig' }); }); }); From 00b9aa37ef94c69a09d6ec4b845ebe25d377f498 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:36:18 +0000 Subject: [PATCH 18/22] fix(skills): tighten the migrate-email key check and template rules - Step 6's check accepts only a quote before `phs_`, so a placeholder such as `` or a value like `xphs_...` no longer counts as a set key. - `mapping.md` matches Step 3: the person makes a template transactional only once a verified sender covers its address. - A template made in PostHog for an email with a login code or reset link is marked sensitive, and the report checks it before cutover. - Drop the tests that only matched skill sentences; the key check keeps its tests against real env files. Co-Authored-By: Claude Opus 5.5 --- .../migrate-email/references/3-inventory.md | 2 +- .../skills/migrate-email/references/6-key.md | 4 +- .../migrate-email/references/7-report.md | 2 +- .../references/resend/mapping.md | 3 +- scripts/lib/tests/migrate-email-skill.test.js | 37 ++----------------- 5 files changed, 9 insertions(+), 39 deletions(-) diff --git a/context/skills/migrate-email/references/3-inventory.md b/context/skills/migrate-email/references/3-inventory.md index a39070527..fdbac1813 100644 --- a/context/skills/migrate-email/references/3-inventory.md +++ b/context/skills/migrate-email/references/3-inventory.md @@ -47,7 +47,7 @@ When the code passes a value you can't read, such as a list of recipients built A `template` site moves only to a template made in PostHog, and the person makes that template. Check the site's other fields first: a site that another field keeps on the provider stays with that reason, and needs no question about its template. Ask the person once, for every other template the sites send, whether to move it to PostHog or keep it on the provider for now. A template they keep stays, and the reason is `kept on for now`. For each template they move, ask them to copy it from the provider's dashboard into a file outside the repository, and to tell you where they saved it. Read those files to classify the site, and never write the content into the plan. When the person can't give the content, the site stays, and the reason is `template content not checked`. -A template whose content passes the checks moves only once the person has made it in PostHog. Ask them once, for every such template, to make it in PostHog from the provider's template and give you its template ID and the address it sends from, usually the provider template's sender. PostHog makes a template transactional only once a verified sender covers its address: ask them to make it transactional now when the project has one, and otherwise once Step 4 verifies the sender. The report lists it before cutover either way. Tell them how its placeholders change, as `mapping.md` says under "Content". Record each template ID with its site under the sites table, and write the address in the site's `from` column, so Step 4 sets up a sender for its domain. A template without an ID stays, and the reason is `PostHog template not made yet`. +A template whose content passes the checks moves only once the person has made it in PostHog. Ask them once, for every such template, to make it in PostHog from the provider's template and give you its template ID and the address it sends from, usually the provider template's sender. PostHog makes a template transactional only once a verified sender covers its address: ask them to make it transactional now when the project has one, and otherwise once Step 4 verifies the sender. When the email carries a login code, a reset link, or another secret, ask them to mark the template sensitive, as `mapping.md` says. The report lists both before cutover either way. Tell them how its placeholders change, as `mapping.md` says under "Content". Record each template ID with its site under the sites table, and write the address in the site's `from` column, so Step 4 sets up a sender for its domain. A template without an ID stays, and the reason is `PostHog template not made yet`. ## Write the plan file diff --git a/context/skills/migrate-email/references/6-key.md b/context/skills/migrate-email/references/6-key.md index 6d5a59f28..2366c5ba0 100644 --- a/context/skills/migrate-email/references/6-key.md +++ b/context/skills/migrate-email/references/6-key.md @@ -23,10 +23,10 @@ PostHog's send needs a project secret API key on the server. The provider's key ## Check that the key is set -The key is set only when the env file holds it. Inside the Wizard, it is set once `set_env_values` succeeds. Otherwise, once the person says they added it, check the file with a command that prints a count and never the key. It reads the last line that sets the variable, as the app's env loader does, and checks that the value starts with `phs_`: +The key is set only when the env file holds it. Inside the Wizard, it is set once `set_env_values` succeeds. Otherwise, once the person says they added it, check the file with a command that prints a count and never the key. It reads the last line that sets the variable, as the app's env loader does, and checks that the value, after an optional quote, starts with `phs_`: ```bash -grep -E '^[[:space:]]*(export[[:space:]]+)?POSTHOG_PROJECT_SECRET_API_KEY[[:space:]]*=' .env | tail -n 1 | grep -cE '^[^=]*=[[:space:]]*[^#[:space:]]?phs_' +grep -E '^[[:space:]]*(export[[:space:]]+)?POSTHOG_PROJECT_SECRET_API_KEY[[:space:]]*=' .env | tail -n 1 | grep -cE "^[^=]*=[[:space:]]*[\"']?phs_" ``` Use the env file the key went into in place of `.env`. A count of `0` means the key is not set. diff --git a/context/skills/migrate-email/references/7-report.md b/context/skills/migrate-email/references/7-report.md index 6795e92df..5745c2496 100644 --- a/context/skills/migrate-email/references/7-report.md +++ b/context/skills/migrate-email/references/7-report.md @@ -51,7 +51,7 @@ Read `.posthog-email-migration-plan.md`. Write `posthog-migrate-email- ## Before cutover - + ## Summary ``` diff --git a/context/skills/migrate-email/references/resend/mapping.md b/context/skills/migrate-email/references/resend/mapping.md index 63b6fe064..3fbf09e79 100644 --- a/context/skills/migrate-email/references/resend/mapping.md +++ b/context/skills/migrate-email/references/resend/mapping.md @@ -43,7 +43,8 @@ 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 moves only to a template made in PostHog. This skill never makes one: the person makes it from the Resend template, makes it transactional, and gives you its template ID. Until then the site stays on Resend. +- A `template` site moves only to a template made in PostHog. This skill never makes one: the person makes it from the Resend template and gives you its template ID, and makes it transactional once a verified sender covers its address, as Step 3 says. Until they give the ID, the site stays on Resend. +- An email that carries a login code, a reset link, or another secret needs its PostHog template marked sensitive: PostHog then keeps no copy of each sent email's body or filled-in subject, and a clicked link keeps only the site's address. Templates are not sensitive by default. - The check needs the template's content from the Resend dashboard, because the code has only its id. - In the PostHog template, `{{{NAME}}}` becomes `{{ variables.NAME }}`, and the name keeps its case. `{{{NAME|there}}}` becomes `{{ variables.NAME | default: 'there' }}`, and 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. diff --git a/scripts/lib/tests/migrate-email-skill.test.js b/scripts/lib/tests/migrate-email-skill.test.js index 0d6c353fb..e55985fdd 100644 --- a/scripts/lib/tests/migrate-email-skill.test.js +++ b/scripts/lib/tests/migrate-email-skill.test.js @@ -11,7 +11,6 @@ const DOCS_PAGES = { 'https://posthog.com/docs/workflows/transactional-emails/api.md': '# Send transactional emails with the API\n', }; const REFERENCE_PATH = /references\/[A-Za-z0-9_./-]+\.md/g; -const read = (file) => readFileSync(join(CONFIG_DIR, 'skills', 'migrate-email', 'references', file), 'utf8'); async function loadSkills() { vi.resetModules(); @@ -121,6 +120,9 @@ describe('migrate-email skill', () => { ['a value that is only a comment', 'POSTHOG_PROJECT_SECRET_API_KEY= #phs_abc\n', '0'], ['a key overridden by a later empty line', 'POSTHOG_PROJECT_SECRET_API_KEY=phs_abc\nPOSTHOG_PROJECT_SECRET_API_KEY=\n', '0'], ['another variable ending in the name', 'OTHER_POSTHOG_PROJECT_SECRET_API_KEY=phs_abc\n', '0'], + ['a placeholder in angle brackets', 'POSTHOG_PROJECT_SECRET_API_KEY=\n', '0'], + ['a value with another prefix', 'POSTHOG_PROJECT_SECRET_API_KEY=xphs_abc\n', '0'], + ['a value that starts with =', 'POSTHOG_PROJECT_SECRET_API_KEY==phs_abc\n', '0'], ])('finds the key in %s only when it is set, and never prints it', (_name, envFile, count) => { expect(command).toBeDefined(); const result = check(envFile); @@ -129,39 +131,6 @@ describe('migrate-email skill', () => { expect(result.stdout).not.toContain('phs_'); expect(result.stderr).toBe(''); }); - - it('ticks the phase only when the check found the key, and the report asks for a key that is not set', () => { - expect(keyStep).toMatch(/A person who says they will add the key later has not set it/); - expect(keyStep).toMatch(/Tick the phase in the plan file only when the check above found the key/); - expect(keyStep).toMatch(/`- \[ \] key — not set: `/); - expect(read('7-report.md')).toMatch(/`POSTHOG_PROJECT_SECRET_API_KEY` in the env file when the plan says it is not set/); - }); - }); - - describe('what the run cannot finish before cutover', () => { - it('asks for a last suppression import right before cutover, since the provider keeps adding entries', () => { - expect(read('5-suppressions.md')).toMatch(/The provider keeps adding to its list while it sends/); - expect(read('7-report.md')).toMatch(/import the entries the provider added to its suppression list since the export, right before cutover/); - }); - - it('lets a template made in PostHog become transactional only once its sender is verified', () => { - const inventory = read('3-inventory.md'); - - expect(inventory).toMatch(/PostHog makes a template transactional only once a verified sender covers its address/); - expect(inventory).not.toMatch(/make it in PostHog from the provider's template, make it transactional, and give you/); - }); - - it('has the person confirm sends with content from the code, which the template tools do not show', () => { - expect(read('2-preflight.md')).toMatch(/PostHog enables sends with content from the code separately/); - expect(read('7-report.md')).toMatch(/sends with content from the code \(`html`, `text`, or `react`\) when a site that moves has them \(confirm with PostHog/); - }); - }); - - it('keeps a template site on Resend when it sets a reply-to, in either spelling', () => { - const mapping = read('resend/mapping.md'); - - expect(mapping).toMatch(/the site sets `from`, `replyTo` \/ `reply_to`, or `subject` next to it \|/); - expect(mapping).toMatch(/A site that sets `from`, `replyTo` \/ `reply_to`, or `subject` next to `template` stays on Resend/); }); it('publishes Resend and Statsig as wizard migrate subcommands in the menu the Wizard reads', async () => { From 56158d100c2924b3953f798764c18e248efdd4eb Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:42:06 +0000 Subject: [PATCH 19/22] fix(migrate-email): keep marketing sends on the provider PostHog's transactional send ignores marketing opt-outs and is only for emails a person needs. A newsletter whose fields all move passed the field check and showed as ready to move. Check what a site sends first, and keep marketing or unclear sends on the provider. Co-Authored-By: Claude Opus 5.5 --- context/skills/migrate-email/references/3-inventory.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/context/skills/migrate-email/references/3-inventory.md b/context/skills/migrate-email/references/3-inventory.md index fdbac1813..87086bacf 100644 --- a/context/skills/migrate-email/references/3-inventory.md +++ b/context/skills/migrate-email/references/3-inventory.md @@ -41,7 +41,9 @@ When you find no site, stop before you write the plan file: ## 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. +Check what each site sends first. PostHog's transactional send ignores marketing opt-outs, so it is only for an email that a person needs, such as a password reset, a receipt, or an invite. A site that sends marketing, such as a newsletter, a promotion, or a product announcement, stays, and the reason is `marketing email`. When neither the code nor the template's name tells you, ask the person once for every such site. A site they call marketing, or don't answer for, stays with that reason. + +Then 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. From 04e7c8424c083bdedef022e4821daf659ffdd7a9 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:44:08 +0000 Subject: [PATCH 20/22] fix(migrate-email): read the provider's report notes without the import tool Step 5 read the provider's account.md only after the import tool check, so without the tool, the facts account.md adds for the report never reached the plan. Read it first on every path, through a "For the report" section, and carry those facts into the report. A manual import also happens right before cutover. Co-Authored-By: Claude Opus 5.5 --- context/skills/migrate-email/references/5-suppressions.md | 8 ++++++-- context/skills/migrate-email/references/7-report.md | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/context/skills/migrate-email/references/5-suppressions.md b/context/skills/migrate-email/references/5-suppressions.md index eb723bf2a..5dfeb60d9 100644 --- a/context/skills/migrate-email/references/5-suppressions.md +++ b/context/skills/migrate-email/references/5-suppressions.md @@ -13,15 +13,19 @@ The provider keeps a suppression list: addresses that hard-bounced or marked an [STATUS] Importing the suppression list ``` +## Read the provider's notes + +Read the provider's `account.md` first, whether or not the import runs. Its "For the report" section, when it has one, names facts the report carries, such as a count of entries the import leaves out. Find each one as the section says, and write it under the phase in the plan file. Never write an address. + ## 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. +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) right before cutover, so the list holds the entries the provider adds until then. ## 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. +The provider's `account.md` 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. diff --git a/context/skills/migrate-email/references/7-report.md b/context/skills/migrate-email/references/7-report.md index 5745c2496..47a273617 100644 --- a/context/skills/migrate-email/references/7-report.md +++ b/context/skills/migrate-email/references/7-report.md @@ -39,7 +39,7 @@ Read `.posthog-email-migration-plan.md`. Write `posthog-migrate-email- ## Suppression list - + ## PostHog secret key From a2994e96cdcd99b26cacab2029bf01d0baa26a9c Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 12 Nov 2026 12:21:55 +0000 Subject: [PATCH 21/22] fix(migrate-email): keep a named recipient on Resend PostHog's send takes one bare address in to, and refuses an entry with a name, such as Jane . A one-entry list with a name passed the field check. Keep it on Resend, as reply-to already does. Co-Authored-By: Claude Opus 5.5 --- 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 3fbf09e79..b529247ca 100644 --- a/context/skills/migrate-email/references/resend/mapping.md +++ b/context/skills/migrate-email/references/resend/mapping.md @@ -7,7 +7,7 @@ This file decides, field by field, whether PostHog can send a Resend call site. | 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 | +| `to` | `to`, one bare address | `to` can hold more than one address, its entry carries a name, such as `Jane `, or the code builds it at run time and you cannot tell that it holds a bare address only. A list with exactly one bare address moves as that address | | `subject` | `subject` | Never | | `html` | `html` | Never | | `text` | `text` | Never | From 1d851554192c112f991d802ac9ddc73cdf7266af Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 13 Nov 2026 05:33:52 +0000 Subject: [PATCH 22/22] fix(migrate-email): tie the kept Resend error names to posthog-node, HTTP sites included Co-Authored-By: Claude Opus 5.5 --- 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 b529247ca..7dbd35876 100644 --- a/context/skills/migrate-email/references/resend/mapping.md +++ b/context/skills/migrate-email/references/resend/mapping.md @@ -36,7 +36,7 @@ This file decides, field by field, whether PostHog can send a Resend call site. | 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` | +| `if (error.name === '...')` | The name is not one of posthog-node's, which every site that moves sends through, an HTTP site too: PostHog's HTTP API answers with `type` and `code`, and only posthog-node gives these names: `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 |