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 1/9] 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 454142ac..9af3e045 100644 --- a/scripts/lib/skill-generator.js +++ b/scripts/lib/skill-generator.js @@ -594,6 +594,31 @@ function generateFrontmatter(skill, version) { return '---\n' + yaml.dump(frontmatter) + '---\n\n'; } +/** + * Copy the markdown files of a variant's own folder, `references//`, + * into the bundle. A group keeps what differs per vendor or platform there, so + * each bundle ships only its own variant's files. Returns their listing entries. + */ +function copyVariantReferences({ sourceDir, targetDir, variantId, configDir }) { + if (!fs.existsSync(sourceDir)) return []; + + const files = fs.readdirSync(sourceDir, { withFileTypes: true }) + .filter(entry => entry.isFile() && entry.name.endsWith('.md')) + .sort((a, b) => a.name.localeCompare(b.name)); + fs.mkdirSync(targetDir, { recursive: true }); + + return files.map(file => { + const parsed = matter(fs.readFileSync(path.join(sourceDir, file.name), 'utf8')); + const body = expandPartials(parsed.content, configDir).replace(/^\n+/, '').replace(/\s+$/, ''); + fs.writeFileSync(path.join(targetDir, file.name), body, 'utf8'); + const heading = body.match(/^#\s+(.+)$/m)?.[1]; + return { + filename: `${variantId}/${file.name}`, + description: parsed.data.description || heading || file.name, + }; + }); +} + /** * Generate a complete skill package * @@ -717,6 +742,13 @@ async function generateSkill({ }); } } + + references.push(...copyVariantReferences({ + sourceDir: path.join(sourceReferencesDir, skill._shortId), + targetDir: path.join(referencesDir, skill._shortId), + variantId: skill._shortId, + configDir, + })); } // Helper to process a doc entry (string URL or {url, title} object). diff --git a/scripts/lib/tests/skill-generator-references-folder.test.js b/scripts/lib/tests/skill-generator-references-folder.test.js index d5108e5f..925f520b 100644 --- a/scripts/lib/tests/skill-generator-references-folder.test.js +++ b/scripts/lib/tests/skill-generator-references-folder.test.js @@ -69,4 +69,51 @@ describe('generateSkill local references', () => { expect(readFileSync(generatedRef, 'utf8')).toBe('# Product analytics best practices\n\nDetails'); expect(readFileSync(generatedSkill, 'utf8')).toContain('references/product-analytics.md'); }); + + it("ships a variant's own reference folder and no other variant's", async () => { + createFixture({ + skills: { + migrate: { + 'description.md': '# Migrate\n\n{references}\n', + references: { + '1-presence.md': '---\nnext_step: null\n---\n\n# Presence\n\nRead `references//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 2/9] 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 00000000..bdf95e3c --- /dev/null +++ b/context/shared/email-sender-setup.md @@ -0,0 +1,15 @@ +--- +title: Set up an email sender +--- + +- First check that the PostHog MCP has the sender tools: `integrations-email-create`, `integrations-email-verify-create`, `integrations-domain-connect-check-retrieve`, and `integrations-domain-connect-apply-url-create`. When they are missing, go to the last bullet of this step. +- When the `setting-up-an-email-domain` skill is available, follow it. It covers the details. +- Ask which domain to send from. Recommend a subdomain such as `mail.example.com`. Use the person's own domain, never a free mailbox domain such as gmail.com. +- Never create a sender for an address that already has one: creating it again marks it unverified. For an existing unverified sender, skip to its records. +- Create the sender with `integrations-email-create`, then get its DNS records with `integrations-email-verify-create`. +- PostHog never writes DNS. Call `integrations-domain-connect-check-retrieve` with the domain. When `supported` is true, give the person the URL from `integrations-domain-connect-apply-url-create`: they approve the records at their DNS host. Otherwise, give them the records that are not `success` as a table with type, name, value, and priority. +- Always include both SPF records, merged into an existing `v=spf1` record when there is one. Never add a second `_dmarc` record when the domain already has one. +- Call `integrations-email-verify-create` again after the records are published, at least a minute apart. `success` means done. `failed` means fix the records, then verify again. After about 15 minutes of `pending` with every record published, stop and tell the person that DNS is still propagating and they can ask you to check again later. +- A 403 that says the person lacks permissions in the project means a project admin must verify the sender. Do not retry. +- **When the sender tools are missing or refused**, the agent setup for sender domains is not available to this person yet. Do not try other integration tools. Ask the person to verify a sender in PostHog: Workflows, then Channels, then add an email sender on their domain and follow the DNS steps (https://posthog.com/docs/workflows/configure-channels). Wait until they confirm that the sender shows as verified, then continue. + diff --git a/scripts/lib/skill-generator.js b/scripts/lib/skill-generator.js index 9af3e045..35eb4697 100644 --- a/scripts/lib/skill-generator.js +++ b/scripts/lib/skill-generator.js @@ -55,16 +55,18 @@ function loadYaml(configPath) { // A line that is exactly `{{> name}}` is replaced at build time with the body // of `/shared/name.md` (frontmatter stripped), so shared prose like // the MCP tool-calling grammar lives in one canonical file and every skill that -// includes it stays in sync. -const PARTIAL_DIRECTIVE = /^[ \t]*\{\{>\s*([a-z0-9-]+)\s*\}\}[ \t]*$/gm; +// includes it stays in sync. The directive's indentation applies to every line +// of the partial, so a partial can nest under a step. +const PARTIAL_DIRECTIVE = /^([ \t]*)\{\{>\s*([a-z0-9-]+)\s*\}\}[ \t]*$/gm; function expandPartials(body, configDir) { - return body.replace(PARTIAL_DIRECTIVE, (_match, name) => { + return body.replace(PARTIAL_DIRECTIVE, (_match, indent, name) => { const partialPath = path.join(configDir, 'shared', `${name}.md`); if (!fs.existsSync(partialPath)) { throw new Error(`Partial include {{> ${name}}} references missing file ${partialPath}`); } - return matter(fs.readFileSync(partialPath, 'utf8')).content.trim(); + const partial = matter(fs.readFileSync(partialPath, 'utf8')).content.trim(); + return partial.replace(/^(?=.)/gm, indent); }); } diff --git a/scripts/lib/tests/partial-includes.test.js b/scripts/lib/tests/partial-includes.test.js index 5ebaa8e6..3741c14a 100644 --- a/scripts/lib/tests/partial-includes.test.js +++ b/scripts/lib/tests/partial-includes.test.js @@ -42,6 +42,14 @@ describe('expandPartials', () => { expect(out).toContain('After.'); }); + it("indents every line of the partial as far as the directive, so it nests in a step's list", () => { + writeFileSync(join(tmpDir, 'shared', 'sender.md'), '---\ntitle: Sender\n---\n\n- First bullet.\n- Second bullet.\n'); + + const out = expandPartials('STEP 3: Set up a sender.\n {{> sender}}\n\nSTEP 4: Send.', tmpDir); + + expect(out).toBe('STEP 3: Set up a sender.\n - First bullet.\n - Second bullet.\n\nSTEP 4: Send.'); + }); + it('throws when the referenced partial does not exist', () => { expect(() => expandPartials('{{> missing-partial}}', tmpDir)).toThrow(/missing-partial/); }); From 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 3/9] 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 9bbb4e2c..2d5c0779 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,11 +72,12 @@ longer exist — only some keep an alias. | `wizard integrate` | `wizard` (default flow) | command removed | | `wizard events-audit` | `wizard audit events` | moved into `audit` family | | `wizard audit` (single) | `wizard audit ` | 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 00000000..6b0b83c3 --- /dev/null +++ b/context/skills/migrate-email/config.yaml @@ -0,0 +1,17 @@ +# Email migration skill group - move transactional email from another provider to PostHog, one variant per provider +type: skill +template: description.md +category: migrate +description: Migrate transactional email from {display_name} to PostHog. Use when asked to migrate from {display_name}, replace {display_name} with PostHog, or switch email providers. It finds every {display_name} send in the code, sets up the sender and DNS next to {display_name}'s records, imports {display_name}'s suppression list, hands over the PostHog secret key, and writes a migration report. It changes no send call site yet. +tags: [migration, workflows] +cli: + role: command + parentCommand: migrate +references: + preamble: "**Read ONLY this file.** Do not read any other reference file until this one tells you to." +shared_docs: + - https://posthog.com/docs/workflows/transactional-email-api.md +variants: + - id: resend + display_name: Resend + tags: [resend] diff --git a/context/skills/migrate-email/description.md b/context/skills/migrate-email/description.md new file mode 100644 index 00000000..174a82b6 --- /dev/null +++ b/context/skills/migrate-email/description.md @@ -0,0 +1,55 @@ +# Migrate transactional email from {display_name} to PostHog + +Use this skill to move an app's transactional email, such as welcome emails, password resets, and receipts, from {display_name} to PostHog. + +This version prepares the move and changes no send call site. It finds every {display_name} send, sets up the PostHog sender and its DNS records next to {display_name}'s, imports {display_name}'s suppression list, hands over the PostHog secret key, and writes a report. Both providers can send during the cutover. + +The transactional email API is in beta. PostHog enables it per project. + +## Workflow + +{workflow} + +Read the step files in order, one at a time. Each step names the next one. Do not read a step before the one before it tells you to. + +## Provider files + +The folder `references//` 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 00000000..94881bc9 --- /dev/null +++ b/context/skills/migrate-email/references/1-detect.md @@ -0,0 +1,38 @@ +--- +title: Find the email provider in the code +next_step: 2-preflight.md +--- + +# Step 1, find the email provider in the code + +Discovery only. Do not change any file. + +## Status + +``` +[STATUS] Looking for the email provider +``` + +## Procedure + +Read the provider's `sdk-reference.md`. It lists the provider's packages, imports, clients, and HTTP host, and the packages that look related but are not the provider. + +The provider is in this project when at least one of these is true: + +- Its package is in a dependency manifest, such as `package.json`, `requirements.txt`, `pyproject.toml`, `Gemfile`, `composer.json`, or `go.mod`. +- Source code imports it or creates its client. +- Source code calls its HTTP host. + +Search the source code, not only the manifest: a package without a call site is not a migration. Skip `node_modules`, build output, and lockfiles. + +A rendering package alone, such as `@react-email/components`, is not the provider. It only tells you how some emails are rendered. + +When you find nothing, stop before any change: + +``` +[ABORT] no email provider sends found +``` + +Do not widen the search to other email providers. That outcome is correct, not a failure to work around. + +When you find the provider, note each file that uses it for Step 3, and continue. diff --git a/context/skills/migrate-email/references/2-preflight.md b/context/skills/migrate-email/references/2-preflight.md new file mode 100644 index 00000000..ffd88264 --- /dev/null +++ b/context/skills/migrate-email/references/2-preflight.md @@ -0,0 +1,41 @@ +--- +title: Check that the project can send +next_step: 3-inventory.md +--- + +# Step 2, check that the project can send + +Do not change any file. + +## Status + +``` +[STATUS] Checking the PostHog project +``` + +## The transactional email API + +Check which PostHog MCP tools exist. + +- When there are no PostHog tools at all, the PostHog MCP is not connected or not signed in. Ask the person to connect it, and wait. +- When other PostHog tools exist but `workflows-upload-email-template` and `workflows-send-email` don't, the transactional email API is not enabled for this project. Stop and tell the person: the API is in beta, and they can ask PostHog in-app to enable it for their project. Then emit: + +``` +[ABORT] transactional email api not enabled +``` + +- When both tools exist, the API is on. Record `api: on`. + +Inside the Wizard, a missing tool can mean that the login lacks its scope, so missing tools do not stop the run. Record `api: not confirmed` and continue. The report asks the person to confirm it. + +## The sending tier + +PostHog gives each project a sending allowance: how many emails it sends per hour and per day. A new project starts on a low tier, and the tier grows with low bounce and complaint rates. Emails above the allowance are not dropped, they are sent later. So a cutover with more mail than the tier allows delays email, such as password resets. + +- Ask the person how many emails the app sends in its busiest hour and on its busiest day. +- Ask them to compare these numbers with the "Sending allowance" in PostHog: Workflows, then Messaging, then Reputation. +- Record the answer as `tier: fits`, `tier: too low`, or `tier: not checked` when they don't know. Do not stop on `too low`: the report tells the person to move the sends in parts, or to ask PostHog about a higher tier, before cutover. + +## Write down + +Keep `api` and `tier` for Step 3, which writes them into the plan file. diff --git a/context/skills/migrate-email/references/3-inventory.md b/context/skills/migrate-email/references/3-inventory.md new file mode 100644 index 00000000..1755818f --- /dev/null +++ b/context/skills/migrate-email/references/3-inventory.md @@ -0,0 +1,61 @@ +--- +title: List and classify every send +next_step: 4-sender.md +--- + +# Step 3, list and classify every send + +List every place the code sends email through the provider, and decide for each one whether PostHog can send it. Change no source file. The only file you write is the plan file. + +## Status + +``` +[STATUS] Listing every send +[STATUS] Writing the migration plan +``` + +## Find the sends + +Read the provider's `sdk-reference.md` for the call shapes, and its `mapping.md` for the fields. + +A **site** is one place in the code that calls the provider to send, such as one `emails.send(...)` call or one HTTP request to the send endpoint. A helper that wraps the call is one site, however many places call the helper. Read the callers of a helper anyway: they decide values such as how many recipients a send has. + +For each site, read the surrounding code and record: + +- The file and the line of the call. +- How it sends: `sdk`, `http`, or `smtp`. +- The content: `template` for a template stored at the provider, `react` for a component the provider renders, `html`, or `text`. A site that sets both `html` and `text` is `html`. +- Every field the site sets, including fields set on the payload object after it is created and fields set through options, such as an idempotency key. +- Where `from` comes from: the literal address, or the name of the environment variable or config value that holds it. + +Also list every other call to the provider that is not a send, such as reading an email's status, contacts, or domains. These calls stay on the provider. Note what each one does, because a site whose code reads the status back stays on the provider too. + +## Classify each site + +Go through `mapping.md` field by field. A site **moves** when PostHog can send every field it sets. A site **stays** on the provider when any one field or feature keeps it there: record that field or feature as the reason. Never plan to drop a field. + +When the code passes a value you can't read, such as a list of recipients built at run time, follow it to its callers. When you still can't tell, the site stays, and the reason says what you could not check. + +## Write the plan file + +Write `.posthog-email-migration-plan.md` at the project root with three sections. Never write an email address of a recipient into it. A `from` address is fine. + +A phase checklist, in this order. Tick `preflight` with what Step 2 recorded, and `inventory` once the file is written. Later steps tick the rest. + +``` +- [x] preflight — api: — 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 00000000..87a968c0 --- /dev/null +++ b/context/skills/migrate-email/references/4-sender.md @@ -0,0 +1,38 @@ +--- +title: Set up the senders and their DNS records +next_step: 5-suppressions.md +--- + +# Step 4, set up the senders and their DNS records + +PostHog sends from a verified sender: an address on a domain whose DNS records PostHog checked. Set up a sender for each domain the app sends from, next to the provider's records, so both providers can send during the cutover. Never change DNS yourself, and never change the provider account. + +## Status + +``` +[STATUS] Setting up the PostHog sender +``` + +## List the addresses + +Collect every `from` in the plan's sites table. When a `from` comes from an environment variable or a config value, read it from the example env file or the config, or ask the person for the address. A `from` can carry a name, such as `Example `: 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 00000000..02471b58 --- /dev/null +++ b/context/skills/migrate-email/references/5-suppressions.md @@ -0,0 +1,39 @@ +--- +title: Import the suppression list +next_step: 6-key.md +--- + +# Step 5, import the suppression list + +The provider keeps a suppression list: addresses that hard-bounced or marked an email as spam. It skips them on every send. PostHog keeps its own list and starts empty, so without an import the first PostHog sends go to these addresses again. That hurts the sending reputation of the project and the domain. Import the list before cutover. + +## Status + +``` +[STATUS] Importing the suppression list +``` + +## Check the import tool + +Look for the PostHog MCP tool `email-suppressions-add`, and read its schema with `info`. + +When it is missing, the import is not available to this person yet. Do not try other tools, and do not use the opt-out tools: opt-outs are marketing preferences, and transactional email ignores them. Tick the phase as `- [ ] suppressions — not imported: no import tool`, and continue to the next step. The report tells the person to add the addresses in PostHog (Workflows, then Messaging, then Suppression list) before cutover. + +## Get the list + +Read the provider's `account.md`. It says how to export the list, which entries to take, and how each entry maps to PostHog. + +- Prefer the export the person downloads from the provider's dashboard. Ask them where they saved the file. +- Read the list from the provider's API only when the person gives you a key that can read it, through an environment variable that `account.md` names. The app's own sending key usually can't. +- Keep the list out of the repository. Read the file where the person saved it, or write the API's answer to a file in the system's temporary folder, and delete that file when the import is done. + +## Import it + +- Send the entries to `email-suppressions-add` in batches of the size its schema allows. Give each entry the source and the reason `account.md` names. +- Repeating an import is safe: an address already on the list stays on it. +- Never print the addresses, and never write them into the plan or the report. Count them. +- When a call fails, read its error and fix the call. When it fails again, stop the import and record how many entries you imported. + +## Write down + +Tick the phase in the plan file: `- [x] suppressions — / imported`. 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 00000000..0b4a0820 --- /dev/null +++ b/context/skills/migrate-email/references/6-key.md @@ -0,0 +1,32 @@ +--- +title: Hand over the PostHog secret key +next_step: 7-report.md +--- + +# Step 6, hand over the PostHog secret key + +PostHog's send needs a project secret API key on the server. The provider's key stays where it is until the cutover is done. + +## Status + +``` +[STATUS] Setting up the PostHog secret key +``` + +## The PostHog key + +- Ask the person to create a project secret API key (it starts with `phs_`) with the `hog_flow:write` scope in their PostHog project settings. +- The key goes into `POSTHOG_PROJECT_SECRET_API_KEY`, in the env file the app loads its provider key from, such as `.env` or `.env.local`. Check that git ignores that file. When it doesn't, ask the person where the key goes. +- Ask the person to add the key to that file themselves. Inside the Wizard, ask for it and store it as the "Inside the PostHog Wizard" section of `SKILL.md` says. +- Add the variable name, without a value, to the project's env example file when it has one. +- Never hardcode the key, never commit it, and never ask the person to paste it into the chat. + +## Every other place the provider key lives + +The provider's `sdk-reference.md` names its key's environment variable. Search the whole repository for that name, including CI workflows, deploy and hosting config, Docker and compose files, env example files, and docs. Skip `node_modules` and build output. + +List each place with its file and line. Each one needs `POSTHOG_PROJECT_SECRET_API_KEY` too before the cutover, for example as a CI secret or a hosting environment variable. Do not change these files: the person adds the secret where the platform stores it. + +## Write down + +Tick the phase in the plan file: `- [x] key — POSTHOG_PROJECT_SECRET_API_KEY set in `, 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 00000000..0dfb9a33 --- /dev/null +++ b/context/skills/migrate-email/references/7-report.md @@ -0,0 +1,76 @@ +--- +title: Write the migration report +next_step: null +--- + +# Step 7, write the migration report + +The report comes from the plan file. Invent nothing. + +## Status + +``` +[STATUS] Writing the migration report +``` + +## Write the report + +Read `.posthog-email-migration-plan.md`. Write `posthog-migrate-email--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 00000000..dfd42d4b --- /dev/null +++ b/context/skills/migrate-email/references/resend/account.md @@ -0,0 +1,42 @@ +# Resend account + +What the migration needs from the person's Resend account. The agent never changes the account: it reads what the person exports or allows. + +## DNS records + +A domain verified at Resend carries these records. Keep all of them until the cutover is done. + +| Record | Name | What it does | +|---|---|---| +| `TXT` (DKIM) | `resend._domainkey` | Signs Resend's mail | +| `MX` | `send` | Return path for Resend's bounces | +| `TXT` (SPF) | `send` | Allows Resend to send for the return path | +| `TXT` (DMARC) | `_dmarc` | Optional, shared by every provider on the domain | + +- `send` is Resend's default return-path label. A domain can use a custom one: the Resend dashboard shows it under Domains. +- PostHog's sender uses its own label for its MAIL FROM, `feedback` by default. Never give PostHog the label Resend uses. +- PostHog's DKIM records have their own names, so they sit next to `resend._domainkey`. +- Keep the one `_dmarc` record. Never add a second one. + +## Suppression list + +Resend suppresses an address after a hard bounce or a spam complaint, and keeps addresses the team added by hand. + +**Export from the dashboard (preferred).** In Resend: Emails, then Suppressions, then **Download CSV**. Read the header row to find the address column and the origin column. + +**Read from the API.** `GET https://api.resend.com/suppressions?limit=100` with `Authorization: Bearer $RESEND_SUPPRESSIONS_API_KEY`. + +- The answer is `{ "object": "list", "has_more": , "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 00000000..eecb90fb --- /dev/null +++ b/context/skills/migrate-email/references/resend/mapping.md @@ -0,0 +1,46 @@ +# Resend to PostHog mapping + +This file decides, field by field, whether PostHog can send a Resend call site. A site moves only when every field it sets moves. Otherwise it stays on Resend, and the reason names the first field or feature that keeps it there. `transactional-email-api.md` has the PostHog side in full. + +## Fields + +| Resend | PostHog | A site stays on Resend when | +|---|---|---| +| `from` | `from` | Never. It needs a verified sender (Step 4) | +| `to` | `to` | `to` can hold more than one address. A list with exactly one address moves as that address | +| `subject` | `subject` | Never | +| `html` | `html` | Never | +| `text` | `text` | Never | +| `react` | `html`, rendered in the app | Never. The app renders the component | +| `template` | a local template in the app's code | The template uses syntax beyond variables and fallbacks | +| `cc`, `bcc` | `cc`, `bcc` | An entry carries a name, such as `Jane `, or there are more than 10 | +| `replyTo` / `reply_to` | `replyTo` / `reply_to` | Never | +| `headers` | `headers` | A header is one PostHog sets itself (`From`, `To`, `Cc`, `Bcc`, `Reply-To`, `Subject`, `Date`, `Message-ID`, `MIME-Version`, `Sender`, `Return-Path`, `Auto-Submitted`, any `Content-*`, `X-PostHog-*`, `X-SES-*`), a value has a line break or a non-ASCII character, or there are more than 10 | +| `tags` | `tags` | A name or value has characters other than ASCII letters, digits, `_`, and `-`, a name starts with `ph_`, or there are more than 10 | +| `attachments` | `attachments` | An attachment uses `path` (a URL Resend fetches), the files can be more than 10 or more than 512 KB in total, or a file type is one mailbox providers block, such as `.exe` | +| `idempotencyKey` / `Idempotency-Key` | `idempotencyKey` / `Idempotency-Key` | Never. The key keeps its value | +| `scheduledAt` / `scheduled_at` | none | Always | +| `topicId` / `topic_id` | none | Always | + +## Calls + +| Resend call | A site stays on Resend when | +|---|---| +| `emails.send`, `emails.create`, `Emails.send`, `POST /emails` | A field above keeps it | +| `batch.send`, `Batch.send`, `POST /emails/batch` | Always | +| SMTP through `smtp.resend.com` | Always. PostHog has no SMTP relay | +| Laravel's or Rails' `resend` mailer | Always. PostHog has no mailer driver | +| A send in a language other than JavaScript or TypeScript | Always, until the skill moves that language | + +## Code around the send + +| Code | A site stays on Resend when | +|---|---| +| `if (error.name === '...')` | The name is not one of PostHog's: `validation_error`, `missing_api_key`, `invalid_permission`, `not_found`, `concurrent_idempotent_requests`, `invalid_idempotent_request`, `rate_limit_exceeded`, `monthly_quota_exceeded`, `service_unavailable`, `application_error` | +| `emails.get(id)` and a branch on `last_event` | Always: the send and its status read stay together. PostHog's status is not `last_event` | +| `data.id` stored for a webhook or a status read | Always, when Resend webhooks or status reads use the id | + +## Content + +- `html`, `text`, and `react` content is rendered for one recipient before the send. PostHog sends it as it is. +- A `template` site needs the template's content from the Resend dashboard, because the code has only its id. PostHog's Liquid escapes HTML in a variable's value. A variable the app fills with HTML keeps the site on Resend. diff --git a/context/skills/migrate-email/references/resend/sdk-reference.md b/context/skills/migrate-email/references/resend/sdk-reference.md new file mode 100644 index 00000000..fa3cf5f0 --- /dev/null +++ b/context/skills/migrate-email/references/resend/sdk-reference.md @@ -0,0 +1,90 @@ +# Resend SDK reference + +This file says what Resend looks like in a codebase. It does not describe PostHog. `mapping.md` maps each field to PostHog. + +## Key + +The API key lives in `RESEND_API_KEY` by convention. Apps also use their own names, such as `PRIVATE_RESEND_API_KEY`: take the name the client reads. + +## Packages + +- Node.js: `resend`. +- Python: `resend` (PyPI). +- Ruby: `resend` (RubyGems), and `resend-rails`. +- PHP: `resend/resend-php`, and `resend/resend-laravel` for Laravel. +- Go: `github.com/resend/resend-go`. +- Elixir: `resend` (Hex). +- Java: `com.resend:resend-java`. +- .NET: `Resend`. +- Rust: `resend-rs`. + +These are not Resend, and alone they are not a migration: `@react-email/components`, `@react-email/render`, `react-email`, and `jsx-email` render emails, and `nodemailer` sends through any SMTP server. + +## Clients + +```js +import { Resend } from 'resend' +const resend = new Resend(process.env.RESEND_API_KEY) +``` + +```python +import resend +resend.api_key = os.environ["RESEND_API_KEY"] +``` + +Look for `new Resend(`, `from 'resend'`, `require('resend')`, `import resend`, `Resend::`, and `resend.NewClient(`. + +## Sends + +**Node.js.** `resend.emails.send(payload, options?)`, and its alias `resend.emails.create(...)`. The answer is `{ data, error }`: `data.id` is the email's id, and `error` has `name`, `message`, and `statusCode`. The SDK does not throw on API errors. + +```js +const { data, error } = await resend.emails.send({ + from: 'Example ', + 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 c56409d5..d4e2cb5a 100644 --- a/context/skills/migrate/config.yaml +++ b/context/skills/migrate/config.yaml @@ -5,7 +5,7 @@ description: Migrate an existing analytics or feature-flag vendor to PostHog. Re tags: [migration] cli: role: command - command: migrate + parentCommand: migrate references: preamble: "**Read ONLY this file.** Do not read any other reference file until this one tells you to." shared_docs: [] diff --git a/scripts/lib/tests/migrate-email-skill.test.js b/scripts/lib/tests/migrate-email-skill.test.js new file mode 100644 index 00000000..29ca4ea6 --- /dev/null +++ b/scripts/lib/tests/migrate-email-skill.test.js @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'fs'; +import { join, relative } from 'path'; +import { tmpdir } from 'os'; + +const CONFIG_DIR = join(process.cwd(), 'context'); +const DOCS_URL = 'https://posthog.com/docs/workflows/transactional-email-api.md'; +const DOCS_PAGE = '# Transactional email API\n\nSend one email to one person from your server code.\n'; +const REFERENCE_PATH = /references\/[A-Za-z0-9_./-]+\.md/g; + +async function loadSkills() { + vi.resetModules(); + vi.stubEnv('DOCS_CACHE_TTL_MS', '0'); + vi.stubGlobal('fetch', async (url) => { + if (url !== DOCS_URL) return new Response('not found', { status: 404, statusText: 'Not Found' }); + return new Response(DOCS_PAGE, { status: 200 }); + }); + const generator = await import('../skill-generator.js'); + const skills = generator.expandSkillGroups(generator.loadSkillsConfig(CONFIG_DIR), CONFIG_DIR); + return { generator, skills }; +} + +async function buildSkill(skillId, outputDir) { + const { generator, skills } = await loadSkills(); + const skill = skills.find((candidate) => candidate.id === skillId); + expect(skill).toBeDefined(); + + await generator.generateSkill({ + skill, + version: 'test', + repoRoot: process.cwd(), + configDir: CONFIG_DIR, + outputDir, + skipPatterns: { global: [], examples: {} }, + commandmentsConfig: { commandments: {} }, + skillTemplate: skill._template, + sharedDocs: skill._sharedDocs, + }); + return join(outputDir, skillId); +} + +function markdownFiles(dir) { + return readdirSync(dir, { withFileTypes: true, recursive: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith('.md')) + .map((entry) => join(entry.parentPath, entry.name)); +} + +function unresolvedReferences(skillDir) { + const missing = []; + for (const file of markdownFiles(skillDir)) { + for (const reference of readFileSync(file, 'utf8').match(REFERENCE_PATH) ?? []) { + if (!existsSync(join(skillDir, reference))) missing.push(`${relative(skillDir, file)} → ${reference}`); + } + } + return missing; +} + +describe('migrate-email skill', () => { + let outputDir; + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + if (outputDir) rmSync(outputDir, { recursive: true, force: true }); + }); + + it.each(['migrate-email-resend', 'migrate-statsig'])( + 'builds %s as a bundle whose every reference path resolves', + async (skillId) => { + outputDir = mkdtempSync(join(tmpdir(), 'migrate-skill-')); + + const skillDir = await buildSkill(skillId, outputDir); + + expect(readFileSync(join(skillDir, 'SKILL.md'), 'utf8')).toMatch(new RegExp(`^---\\nname: ${skillId}\\n`)); + expect(unresolvedReferences(skillDir)).toEqual([]); + }, + ); + + it('bundles the send API reference and the Resend files without the send skill', async () => { + outputDir = mkdtempSync(join(tmpdir(), 'migrate-skill-')); + + const skillDir = await buildSkill('migrate-email-resend', outputDir); + + expect(readFileSync(join(skillDir, 'references', 'transactional-email-api.md'), 'utf8')).toBe(DOCS_PAGE); + expect(existsSync(join(skillDir, 'references', 'resend', 'sdk-reference.md'))).toBe(true); + expect(markdownFiles(skillDir).map((file) => readFileSync(file, 'utf8')).join('\n')).not.toMatch(/\{\{>|\{display_name\}|\{references\}/); + }); + + it('publishes Resend and Statsig as wizard migrate subcommands', async () => { + const { skills } = await loadSkills(); + const cliOf = (id) => skills.find((skill) => skill.id === id)?._cli; + + expect(cliOf('migrate-email-resend')).toEqual({ role: 'command', parentCommand: 'migrate', command: 'resend' }); + expect(cliOf('migrate-statsig')).toEqual({ role: 'command', parentCommand: 'migrate', command: 'statsig' }); + }); +}); From 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 4/9] 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 1755818f..884e9005 100644 --- a/context/skills/migrate-email/references/3-inventory.md +++ b/context/skills/migrate-email/references/3-inventory.md @@ -18,7 +18,10 @@ List every place the code sends email through the provider, and decide for each Read the provider's `sdk-reference.md` for the call shapes, and its `mapping.md` for the fields. -A **site** is one place in the code that calls the provider to send, such as one `emails.send(...)` call or one HTTP request to the send endpoint. A helper that wraps the call is one site, however many places call the helper. Read the callers of a helper anyway: they decide values such as how many recipients a send has. +A **site** is one place in the code that decides what a send contains: its content and its fields. Usually that is the `emails.send(...)` call or the HTTP request itself. + +- When a helper builds the send from a few parameters, such as a subject and a template name, the helper is one site, however many places call it. Read its callers anyway: they decide values such as how many recipients a send has. +- When a helper only forwards a payload that its callers build, each caller is a site, and the helper is not. For each site, read the surrounding code and record: diff --git a/context/skills/migrate-email/references/7-report.md b/context/skills/migrate-email/references/7-report.md index 0dfb9a33..6869e754 100644 --- a/context/skills/migrate-email/references/7-report.md +++ b/context/skills/migrate-email/references/7-report.md @@ -56,18 +56,25 @@ Read `.posthog-email-migration-plan.md`. Write `posthog-migrate-email- ## 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 5/9] 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 6b0b83c3..2accfcd5 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 174a82b6..7bb85a8d 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 eecb90fb..8649ddbd 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 29ca4ea6..8ffca7b6 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 6/9] 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 ffd88264..a643d9be 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 8649ddbd..097d84c8 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 7/9] 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 2d5c0779..45bd33c7 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 bdf95e3c..00000000 --- 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 94881bc9..fd4a47d3 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 884e9005..174ffc47 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 87a968c0..e5901d7d 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 02471b58..916a2143 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 6869e754..eaa7a69e 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 097d84c8..8a27a39c 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 8/9] 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 35eb4697..c5b35505 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 8ffca7b6..14044d5e 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 3741c14a..5ebaa8e6 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 9/9] 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 174ffc47..160f3287 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 e5901d7d..10dee345 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.