Skip to content

feat(skills): add the migrate-email skill with a Resend variant - #450

Merged
Silthus merged 23 commits into
mainfrom
feat/migrate-email-resend
Nov 13, 2026
Merged

Silthus merged 23 commits into
mainfrom
feat/migrate-email-resend

Conversation

@Silthus

@Silthus Silthus commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Moved from Silthus#1.

Problem

A team on Resend that wants its transactional email on PostHog has no skill for the move. Its coding agent would have to find every send, decide which ones PostHog can take, set up the sender without breaking Resend's DNS, and carry the suppression list over, all by hand.

With this skill, migrate-email-resend does the preparation 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 records, imports Resend's suppression list, hands over the secret key, checks the sending tier, and writes posthog-migrate-email-resend-report.md. The next slice moves the sites.

Important

Based on main now. The send skill it used to stack on (Silthus#3) is closed, because PostHog/posthog#125415 adds the sending-transactional-emails skill. This skill names that skill for the general move and keeps only the Resend-specific preparation.

Tracking: Silthus/posthog#539, slice 14 of the Resend parity spec. Contract: PostHog/posthog#120921.

Merge risk

Caution

🔴 Merge risk: high · two-way door until the next context-mill release ships the skill; one-way once agents run it, because a migration's changes persist in each app's code and PostHog account (senders, imported suppressions, keys). The grade also follows the merge-risk rubric: more than 400 changed skill lines while the agent run of the skill has no live proof.

  • A new skill, migrate-email-resend, ships to the MCP's skill catalog and as migrate resend in skill-menu.json. An agent that runs it changes no send call site: it writes a plan, a report, and one commented env example line. Its sender, DNS, suppression and key steps have no live run yet (gap below). Residual incidents on those uncovered rows: a wrong sender setup leaves the sender unverified, and PostHog then refuses every send from that address once the person moves a site; a wrong suppression import adds addresses that should get mail, and PostHog then refuses sends to them without any sign in the app.
  • skill-menu.json changes for the Wizard: migrate-statsig moves from command: migrate to parentCommand: migrate, command: statsig. Today's Wizard registers wizard migrate natively with the skill id migrate-statsig and never reads these entries, so Statsig keeps working.
  • One generator change reaches every bundle. A shadow diff of every bundle from main to this head shows only the new skill, migrate-statsig's new references/statsig/ folder, and the menu entries; every other bundle is byte-identical.
  • Tier: critical: the skill rewrites how apps send transactional email through PostHog Workflows, the critical path of email delivery, where a wrong rewrite drops or doubles sends without an error.
  • Switch-off: none: a revert only stops new runs; a migration an agent already ran stays in that app's code and PostHog account. Fix forward.

Rollout

  • Stages: ships with the next context-mill release; the Wizard runs migrate resend once it makes migrate a family.
  • Watch: no alert; the MCP's skill usage for migrate-email-resend.

Summary

What this skill adds beyond moving-from-other-providers.md. That reference maps any provider's sends to a PostHog template. This skill adds the Resend-specific preparation: send detection for the SDK, REST, and SMTP; the sender and DNS setup next to Resend's records; the suppression list import; the key handover; the sending-tier check; and a report the Wizard parses.

A new skill group, context/skills/migrate-email/, with one variant per provider. Resend is the first. The seven steps are provider-neutral; references/resend/ holds everything specific to Resend, so a SendGrid or Postmark variant is mostly three reference files.

  1. Detect. Package, import, client, api.resend.com, smtp.resend.com in code or mail config, or a framework's resend mailer, with at least one send. React Email or Nodemailer alone, or a Resend client used only for contacts or domains, is not a migration: [ABORT] no email provider sends found before any change. Step 3 stops the same way when it finds no site.
  2. Preflight. The transactional email API is on (the MCP has the tools that make a template transactional), and the person compares their busiest hour and day with the project's sending allowance.
  3. Inventory. Every site with its file, line, content (template, react, html, text) and fields, written to .posthog-email-migration-plan.md. A site that sends marketing, such as a newsletter, stays on Resend: PostHog's transactional send ignores marketing opt-outs. The person decides per Resend template whether to move it or keep it on Resend. A template they move needs a copy from the dashboard, or stays with template content not checked. It moves only to a template the person makes in PostHog: the skill asks for its template ID and the address it sends from, and Step 4 sets up a sender for that address. mapping.md decides field by field: a to with a name, cc, bcc, headers, tags, attachments, scheduledAt, topicId, batch sends, SMTP, more than one to, last_event reads, and error names PostHog lacks stay on Resend with a reason.
  4. Sender and DNS. One verified sender per from domain, on the domain the app already sends from. Resend's records stay; PostHog's MAIL FROM label must not be Resend's send. Pending DNS, a 403, or missing sender tools record the domain as not verified, and the run goes on.
  5. Suppressions. Resend's hard bounces, complaints and manual entries go to PostHog's suppression list through email-suppressions-add, from the dashboard CSV or GET /suppressions. Addresses never land in a file of the repository, the plan, or the report. The phase is ticked only when every entry is imported; a partial import keeps its count and lands in "Before cutover". That tool does not exist yet (see Open below): until it does, the step records not imported and the report tells the person to add them right before cutover. The step reads the provider's account.md first on every path, so a provider's facts for the report, in its "For the report" section, reach the report without the tool too.
  6. Key. The person creates a phs_ key with hog_flow:write for POSTHOG_PROJECT_SECRET_API_KEY. The report lists every other place the Resend key lives, such as CI and docs.
  7. Report. Ends with a summary block the Wizard parses:
from: resend
sites_found: 3
sites_rewritten: 0
sites_left: 2
dns_ready: false
suppressions_ready: false

Other changes:

  • description.md names the sending-transactional-emails skill (feat(messaging): add a skill that offers and sets up transactional emails posthog#125415) for a general move off any provider, and mapping.md points at transactional-emails/api.md.
  • fix(skills): the generator copied only top-level reference files, so migrate-statsig shipped without references/statsig/mapping.md and sdk-reference.md that its steps tell the agent to read. Both now ship, through the same loop as the root files.
  • AGENTS.md lists the migrate subcommands and says they run once the Wizard runs migrate as a family.

Evidence

Bundle and generator tests (vitest, red then green):

  • migrate-email-skill.test.js: migrate-email-resend builds with every references/... path it names present in the bundle, the API page and the Resend files ship without the send skill, and Resend and Statsig publish as migrate subcommands. Red before the skill: expected undefined to be defined.
  • skill-generator-references-folder.test.js: a variant ships its own folder and no other variant's. Red before the fix: ENOENT ... references/statsig/mapping.md. Green before and after the 0e526b9 refactor.
  • Up to date with main on 2026-11-02: 07dbf3a holds main at b699b20.
  • Lane gate on 07dbf3a: 29 files, 224 tests pass, and pnpm build passes: the posthog.com pages the skill fetches are published.
  • Bundle shadow diff (0286e23 against 0e526b9): every skill without example projects, 446 bundles, built with generateSkill and stubbed docs on both commits. Only the six migrate-email-resend step files whose text 41ca66a changed differ; migrate-statsig with its statsig/ folder and every other bundle are byte-identical. Saved in proof storage.

Ranking in the MCP's SkillCatalog (its skill-catalog.ts from PostHog/posthog master over every built bundle):

Query Before (on the old send skill, earlier revision) After
migrate from Resend migrate-statsig (126) migrate-email-resend (1162)
replace resend with posthog feature-flags-ios (138) migrate-email-resend (990)
switch email provider omnibus-send-transactional-email (133) migrate-email-resend (1011)

Agent runs on fixture apps, earlier revisions, before the scope cut: at this head the REST fixture's tagged order confirmation stays on Resend. Claude Code on Sonnet 5.5 with only this skill installed, headless, the PostHog MCP not connected, and the person's answers given in the prompt. Every run changed no send call site: the diff is one commented POSTHOG_PROJECT_SECRET_API_KEY line in the env example, plus the report.

Fixture Runs Result
SvelteKit app on the resend SDK (PostHog/wizard-workbench#2221) 3 2 sites, both ready to move; the one-recipient list traced through sendUserEmail. Summary parses every time
Node.js app on fetch('https://api.resend.com/emails'), no resend package 4 3 sites: the order confirmation moves with its tag and idempotency key, the digest stays (scheduled_at), the invoice stays (attachment by path). The Resend key in the deploy workflow is listed
Node.js app with React Email and Nodemailer, no Resend 2 Stops at step 1, no diff, no report

The first two REST runs found two skill gaps, fixed in 0a48cff: a run wrote reasons into a summary value, and runs disagreed whether a helper that only forwards its callers' payload is one site. The reruns agree and parse.

Happy fixture report, excerpt
| File | Line | Content | Status |
|------|------|---------|--------|
| src/lib/mailer.ts (`sendAdminEmail`) | 30 | text | Ready to move |
| src/lib/mailer.ts (`sendTemplatedEmail`) | 173 | html and text, rendered in the app with Handlebars | Ready to move |

Keep all of Resend's records until cutover is done: `resend._domainkey` (DKIM), `send` (MX and SPF), and the shared `_dmarc`. PostHog's MAIL FROM label is `feedback` by default. Do not give it `send`.

## Before cutover

1. Confirm that the transactional email API is enabled for your PostHog project. ...
4. Export Resend's suppression list and import it into PostHog.
  • Test seams: the bundle build (files a skill ships and the paths it names), the cliEntries the Wizard reads, the MCP SkillCatalog ranking, and the files an agent run leaves in a fixture.
  • Verify: pnpm exec vitest run scripts/lib/tests/migrate-email-skill.test.js passes.
  • Agent dry run on 5c8a34b. Claude Code on Sonnet 5.5, headless, read tools only, on a fixture app with four Resend sends, with the person's and the MCP's answers in the prompt. The invoice template site moves with its PostHog template ID, and its from column holds the template's sender. 2 more sites are ready, and the attachment send stays. The report ends with sites_left: 1, and "Before cutover" lists a test of the PostHog template: transactional, verified sender, INVOICE_URL filled. The run changed no file.
  • Cross-version on 8f77354, which has the same skill list and config.yaml as 5c8a34b, against the Wizard's main (35f489a1): pnpm build passes, and skill-menu.json has migrate-statsig and migrate-email-resend as role: command, parentCommand: migrate, commands statsig and resend. The Wizard's main registers migrate as a native command with the skill ID migrate-statsig (src/cli/commands/migrate.ts, src/programs/migration/index.ts), and builds a family only for audit. So wizard migrate keeps running Statsig with this manifest, and the two subcommands run once the Wizard makes migrate a family.
  • efa4933 fixes the bundle test's file listing for Node 20.11, the oldest release engines allows, which has no Dirent.parentPath.
  • 8dcf6e7 and 07dbf3a keep a site on Resend when its idempotency key isn't 1 to 255 visible ASCII characters, and the report lists each key a retry across the deploy could send twice.
  • QA swarm, 2026-11-12 on e22fa6c: a two-family panel (Opus 5.5 fresh eyes, GPT-6.1 Sol on correctness, risk, design, anti-overengineering, systems, sweep, and five team perspectives) raised eight findings; 487262b fixes six, the description takes one, and the owed-proof finding is half fixed (grade and shadow diff) and half rejected (prod shape, cross-version):
    • Step 6 ticks the key only when a count-only check finds it in the env file (tests run the check on fourteen env files), and the report lists a key that is not set.
    • Step 3 asks for the transactional step only once a verified sender covers the template's address: PostHog refuses it before.
    • Step 2 and the report name sends with content from the code (html, text, react), which PostHog enables apart from template sends.
    • After an import, the report asks for a last suppression import right before cutover, since Resend keeps adding entries.
    • A template site that sets reply_to stays on Resend, as replyTo does.
    • The CLI test reads the entries generateCliEntries writes to the menu, not an internal field.
  • Shadow diff from main (5bf2016) to 487262b, every bundle unpacked: the only differences are the new migrate-email-resend bundle, migrate-statsig's references/statsig/mapping.md and sdk-reference.md, the manifest and menu entries for them, and the build timestamp. Saved in proof storage.
  • QA swarm round 2 on 487262b (five lenses on the fixes): 00b9aa3 tightens the key check to an optional quote before phs_ (tests for <phs_your_key>, xphs_abc, and =phs_abc failed before), deletes five tests that matched skill sentences, and orders the template steps in mapping.md as Step 3 does. Prod shape for env files rejected: no PostHog store holds them.
  • ReviewHog round 1 on 487262b: a template whose email carries a login code or reset link needs PostHog's sensitive setting, which is off by default; 00b9aa3 asks for it in Step 3 and the report. Round 2 on 00b9aa3: 56158d1 keeps marketing sends on Resend (PostHog's transactional email docs: only for emails a person needs); the finding that HTTP sites lack Resend's error names now has its answer in the text: 1d85155 names posthog-node's error names in the rule and says every site that moves, an HTTP one too, sends through posthog-node, whose names those are; PostHog's HTTP API answers with type and code. Rounds 3, 4 and 5, on 04e7c84, a2994e9 and 1d85155: no findings.
  • Relayed from the provider lanes: 04e7c84 has Step 5 read the provider's account.md before the import tool check, so its report facts survive a missing tool, and a manual import happens right before cutover.
  • From ReviewHog on the next layer: a2994e9 keeps a site on Resend when its to entry carries a name, such as Jane <jane@example.com>: PostHog's to is a bare address (EmailField in PostHog's transactional serializer).
  • Final reads: clean on 00b9aa3; one finding on 56158d1 (a behavioral test for the marketing check) rejected, since only the owed live run shows what the agent classifies; clean on 04e7c84, a2994e9 and 1d85155.
  • Shadow diff from main (5bf2016) to 1d85155: the same differences as on 487262b, and every other bundle byte-identical. Saved in proof storage.
  • Lane gate on 1d85155: 30 files, 339 tests pass, and node scripts/build.js passes.
  • Gaps accepted on a2994e9, carried to 1d85155:
    • Live run (accepted): an agent run of the migration on a real app needs the released email SDKs and the production send API; Michael's planned trial on generated codebases runs it after both land.
    • Prod shape: the inputs are customer-written send code and config and each person's Resend account content; there is no store of real customer codebases to sample. Michael's planned trial on generated codebases covers it after the SDKs land.
  • Earlier gaps, on 07dbf3a:
    • Live run: the dry run on 5c8a34b calls no tool. No run inside the Wizard (wizard migrate resend needs the Wizard PR), no run with the PostHog MCP connected, so sender creation, DNS publish, and sender verification on a deployed project are unproven. The suppression import has no tool to run against yet.
    • Prod shape: the skill reads the person's own code, mail config, and Resend exports, which PostHog does not store, so there is nothing to measure. No fixture app sends only over SMTP or through a framework mailer yet.

Decisions

Question Answer Who Where
Suppression list Import the provider's list before cutover 👤 Human lane brief, 2026-10-22
Providers One migrate-email group, one variant per provider; Resend parts stay in its folder 👤 Human lane brief, 2026-10-22
Import path Write the step against a new bulk tool, email-suppressions-add, and record not imported until it exists; the existing one-address route has no MCP tool 🤖 Agent this PR
Unsubscribed contacts Left out: Resend's transactional sends reach them, and PostHog's opt-outs cover marketing only 🤖 Agent this PR
Summary block Adds suppressions_ready after the spec's five keys 🤖 Agent this PR
Sending tier The person compares their volume with the Reputation page; no tool reads the allowance 🤖 Agent this PR
Statsig entry Nest it under migrate now; today's Wizard ignores the entry 🤖 Agent this PR
Templates and fields the first release lacks A Resend template moves only to a template the person makes in PostHog; cc, bcc, headers, tags and attachments stay on Resend 👤 Human Silthus/posthog#461
A template made in PostHog The person makes it and gives its ID and sender in Step 3, and makes it transactional once a verified sender covers its address; a template without an ID stays on Resend 🤖 Agent review round 1, QA swarm 2026-11-12
Base after the send skill moved upstream Rebase onto main without Silthus#3, and name sending-transactional-emails instead 🤖 Agent Silthus/posthog#461
migrated_from The Wizard sets it on its existing setup wizard finished event; no new event 🤖 Agent Silthus/posthog#461
Transactional tools missing in preflight Record api: not confirmed and continue, instead of aborting; never probe with a mutating tool 🤖 Agent review round 1
replyTo the API can't take Keep that site on Resend: a display name, more than 10 entries, or a value built at run time 🤖 Agent review round 1
Statsig as the migrate default leaf No default: true: it would hide resend from the family picker and not save CI scripts, which need a subcommand either way; the release notes belong to the Wizard PR that makes migrate a family 🤖 Agent pending approval
Sender setup Inlined into step 4 and the shared partial deleted: one includer, and its "pick a subdomain" and "wait until verified" rules contradicted step 4 🤖 Agent pending approval
Variant reference files Ship through the existing reference loop instead of a second copy helper 🤖 Agent pending approval
Merge risk High: more than 400 changed skill lines while the live run is a gap; owes a shadow diff, now saved 🤖 Agent QA swarm 2026-11-12
Suppressions added after the export The report asks for a last import right before cutover, instead of tracking an export date in the plan 🤖 Agent QA swarm 2026-11-12
Sensitive templates Ask for PostHog's sensitive setting on a template whose email carries a code or reset link; the report checks it 🤖 Agent ReviewHog 2026-11-12
Marketing sends Stay on Resend; when the code and template name don't tell, ask the person once, and no answer keeps the site 🤖 Agent ReviewHog 2026-11-12
Error names an HTTP site branches on posthog-node's names, since every site that moves, an HTTP one too, sends through it 🤖 Agent ReviewHog 2026-11-13
Provider facts for the report account.md names them in a "For the report" section, which Step 5 reads on every path 🤖 Agent provider lanes 2026-11-12
Prod shape Owed, recorded as a gap: the inputs are customer-written code, config and each person's Resend account, and no store of real customer codebases exists to sample; Michael's trial on generated codebases covers it 🤖 Agent Fleet judge 2026-11-12
Cross-version Not owed: skill-menu.json keeps its format 🤖 Agent QA swarm 2026-11-12

Open: the server half of the suppression import, a bulk route and the email-suppressions-add MCP tool in PostHog/posthog. Recommended as its own slice.

Open: accept the prod-shape gap; the live-run gap is accepted. Recommended: accept.

Follow-up: the Wizard PR that turns migrate into a family names the bare wizard migrate change in its release notes (CONTRIBUTING.md, flat to family, step 2).

Involvement: no grilling rounds, no prototypes, one Opus 5.5 review of the strand (#435 and #436), and one GPT-6.1 Sol (high) round on this revision (two findings, both fixed) with a clean read of the fixes. QA swarm 2026-11-01: round 3, the first two-family panel (Opus 5.5 fresh eyes, GPT-6.1 Sol on nine lenses), round 4 on the fixes (six lenses), and one clean final read: 12 findings, 11 fixed, 1 rejected (Statsig default leaf), 1 follow-up. Proof captured. Its findings were on rules the cutover uses (suppressed recipients, subjects, fallback quoting, idempotency across providers), and the fixes landed in #436, where those rules apply. QA swarm 2026-11-12 on this repository: round 1 with every lens and five team perspectives, 8 findings, 7 fixed, 1 partly rejected (prod shape and cross-version not owed); shadow diff captured. Round 2 on the fixes: 4 distinct findings, 3 fixed, 1 rejected. ReviewHog: 3 findings, 2 fixed, 1 answered in the text, then clean. Final reads: 1 finding, rejected, then clean.

Agent context

Agent: Claude Code, Claude Opus 5.5 (claude-opus-5-5). Reviews: GPT-6.1 Sol (high) and Claude Opus 5.5 (high) in fleet proof panels on the fork. 8f77354 adds the scope cut of 2026-11-02 on top of the reviewed 4ad8c6e. In fleet proof, Claude Opus 5.5 (high) reviewed the cut on fresh eyes, correctness and risk; its findings are fixed in 5c8a34b. A GPT-6.1 Sol (high) final read on 5c8a34b raised one finding, rejected: PostHog renders subject and text without HTML escaping. The final reads on efa4933 and 07dbf3a found nothing. Commits are signed.

🤖 Generated with Claude Code

Silthus and others added 9 commits October 31, 2026 09:51
The migrate group keeps Statsig's mapping and SDK reference under
references/statsig/, and its step files point there, but the generator
copied only the top-level reference files. Copy references/<variant-id>/
into each variant's bundle, and nothing of the other variants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Move the sender setup of send-transactional-email's STEP 3 into
context/shared/email-sender-setup.md, so the email migration skill
includes the same text. A partial now takes the indentation of its
{{> name}} line on every line, so it nests under a step. The built
send skill is unchanged byte for byte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
v0 prepares a move off Resend and changes no send call site. It finds
every Resend send, classifies each one as ready to move or staying on
Resend with a reason, sets up the PostHog sender next to Resend's DNS
records, imports Resend's suppression list when the import tool exists,
hands over the project secret key, checks the sending tier, and writes
posthog-migrate-email-resend-report.md with a summary block.

Steps are provider-neutral; references/resend/ holds what is specific
to Resend. Statsig and Resend now publish as wizard migrate subcommands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fixture runs wrote reasons into the summary block, which the Wizard
can't parse, and counted a forwarding helper as one site or as each
caller. The report step now shows bare values, and a site is the place
that decides a send's content and fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
…e loop

The selected variant's `references/<variant-id>/` 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 <noreply@anthropic.com>
…tes 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 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown

🧙 Wizard CI

Run the Wizard CI and test your changes against wizard-workbench example apps by replying with a GitHub comment using one of the following commands:

Test all apps:

  • /wizard-ci all

Test all apps in a directory:

  • /wizard-ci ai-observability
  • /wizard-ci basic-integration
  • /wizard-ci feature-flags
  • /wizard-ci mcp-analytics
  • /wizard-ci replay-vision
  • /wizard-ci revenue
  • /wizard-ci self-driving
  • /wizard-ci warehouse
  • /wizard-ci warehouse-seeded

Test an individual app:

  • /wizard-ci ai-observability/anthropic
  • /wizard-ci ai-observability/google-adk
  • /wizard-ci ai-observability/groq
Show more apps
  • /wizard-ci ai-observability/manual-capture
  • /wizard-ci ai-observability/openai
  • /wizard-ci ai-observability/openai-agents
  • /wizard-ci ai-observability/opentelemetry
  • /wizard-ci ai-observability/vercel-ai
  • /wizard-ci basic-integration/android
  • /wizard-ci basic-integration/angular
  • /wizard-ci basic-integration/astro
  • /wizard-ci basic-integration/django
  • /wizard-ci basic-integration/fastapi
  • /wizard-ci basic-integration/flask
  • /wizard-ci basic-integration/flutter
  • /wizard-ci basic-integration/javascript-node
  • /wizard-ci basic-integration/javascript-web
  • /wizard-ci basic-integration/laravel
  • /wizard-ci basic-integration/next-js
  • /wizard-ci basic-integration/nuxt
  • /wizard-ci basic-integration/python
  • /wizard-ci basic-integration/rails
  • /wizard-ci basic-integration/react-native
  • /wizard-ci basic-integration/react-router
  • /wizard-ci basic-integration/sveltekit
  • /wizard-ci basic-integration/swift
  • /wizard-ci basic-integration/tanstack-router
  • /wizard-ci basic-integration/tanstack-start
  • /wizard-ci basic-integration/vue
  • /wizard-ci feature-flags/django
  • /wizard-ci feature-flags/next-js
  • /wizard-ci mcp-analytics/custom-dispatcher
  • /wizard-ci mcp-analytics/typescript-sdk
  • /wizard-ci replay-vision/javascript-node
  • /wizard-ci replay-vision/next-js
  • /wizard-ci replay-vision/react-vite
  • /wizard-ci revenue/stripe
  • /wizard-ci self-driving/astro
  • /wizard-ci self-driving/fastapi
  • /wizard-ci self-driving/nuxt
  • /wizard-ci self-driving/react-router
  • /wizard-ci self-driving/sveltekit
  • /wizard-ci warehouse/monorepo-env
  • /wizard-ci warehouse/multi-source-next
  • /wizard-ci warehouse/stripe-node
  • /wizard-ci warehouse/zero-source
  • /wizard-ci warehouse-seeded/next-stripe
  • /wizard-ci warehouse-seeded/next-stripe-declined

Test against a wizard branch:

  • /wizard-ci all wizard:my-branch

Add wizard:<branch> to any command above to pin the wizard branch. It defaults to main.

Results will be posted here when complete.

Silthus and others added 7 commits November 2, 2026 15:02
…end, and move templates only to PostHog

The first PostHog release sends a template made in PostHog by template id, or rendered html and text. A Resend template site moves only once the person makes the template in PostHog, or stays on Resend for now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a template site moves

Review round 1 on #450:
- Step 3 checks a template site's other fields before it asks about the
  template, and asks for the PostHog template ID and the address it sends
  from. A template without an ID stays: PostHog template not made yet.
  The address goes in the from column, so Step 4 sets up its domain.
- mapping.md quotes a fallback that holds ' with ", carries a
  definition-level fallback_value, notes that default replaces an empty
  string, and keeps a fallback with both quotes or HTML on Resend.
- The report asks the person to test each PostHog template before cutover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…te-email test

Node 20.11, the oldest release engines allows, has no Dirent.parentPath,
so the bundle tests threw before their assertions. Use the same
parentPath ?? path fallback as build-phases.js.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…esn't fit PostHog's

PostHog takes an idempotency key of 1 to 255 printable ASCII characters
without spaces. A site whose key can hold a space or a non-ASCII
character, or run longer, stays on Resend. The report names each key a
retry across the deploy could send twice, since the providers don't
share keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Code sends now need transactional_email:write. A key with hog_flow:write
keeps working through the legacy scope fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The make-transactional tools move to transactional_email:write, and a
login with hog_flow:write still works.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus
Silthus marked this pull request as ready for review November 5, 2026 08:42
@Silthus
Silthus requested a review from a team as a code owner November 5, 2026 08:42
Silthus and others added 2 commits November 12, 2026 07:43
- Step 6 ticks the key only when a check finds it in the env file, and the
  report lists a key that is not set.
- Step 3 asks for a template to be made transactional only once a verified
  sender covers its address, since PostHog refuses it before.
- Step 2 and the report name sends with content from the code, which PostHog
  enables apart from template sends.
- The report asks for a last suppression import right before cutover, since
  the provider keeps adding entries.
- A template site that sets `reply_to` stays on Resend, as `replyTo` does.
- The CLI test reads the entries the Wizard gets from the menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 487262b: 1 Should fix.

See this review and its findings in PostHog Review. Comment @posthog review --deep for a Deep review. A Deep review takes longer and looks deeper than a Standard review.

- Step 6's check accepts only a quote before `phs_`, so a placeholder such as
  `<phs_your_key>` or a value like `xphs_...` no longer counts as a set key.
- `mapping.md` matches Step 3: the person makes a template transactional only
  once a verified sender covers its address.
- A template made in PostHog for an email with a login code or reset link is
  marked sensitive, and the report checks it before cutover.
- Drop the tests that only matched skill sentences; the key check keeps its
  tests against real env files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 00b9aa3: 2 Should fix.

PostHog's transactional send ignores marketing opt-outs and is only for
emails a person needs. A newsletter whose fields all move passed the
field check and showed as ready to move. Check what a site sends first,
and keep marketing or unclear sends on the provider.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rt tool

Step 5 read the provider's account.md only after the import tool check,
so without the tool, the facts account.md adds for the report never
reached the plan. Read it first on every path, through a "For the report"
section, and carry those facts into the report. A manual import also
happens right before cutover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 04e7c84: no findings.

PostHog's send takes one bare address in to, and refuses an entry with a
name, such as Jane <jane@example.com>. A one-entry list with a name
passed the field check. Keep it on Resend, as reply-to already does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed a2994e9: no findings.

…HTTP sites included

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 1d85155: no findings.

Silthus added a commit that referenced this pull request Nov 13, 2026
…-cutover

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus
Silthus merged commit ff49233 into main Nov 13, 2026
17 checks passed
Silthus added a commit that referenced this pull request Nov 13, 2026
Resolves add/add conflicts with #451's versions: main's migrate-email files equal #450's head, which #451 builds on. Brings in #512.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Silthus added a commit that referenced this pull request Nov 13, 2026
* fix(skills): ship a variant's own reference folder in its bundle

The migrate group keeps Statsig's mapping and SDK reference under
references/statsig/, and its step files point there, but the generator
copied only the top-level reference files. Copy references/<variant-id>/
into each variant's bundle, and nothing of the other variants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(skills): share the email sender setup as a partial

Move the sender setup of send-transactional-email's STEP 3 into
context/shared/email-sender-setup.md, so the email migration skill
includes the same text. A partial now takes the indentation of its
{{> name}} line on every line, so it nests under a step. The built
send skill is unchanged byte for byte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(skills): add the migrate-email skill with a Resend variant

v0 prepares a move off Resend and changes no send call site. It finds
every Resend send, classifies each one as ready to move or staying on
Resend with a reason, sets up the PostHog sender next to Resend's DNS
records, imports Resend's suppression list when the import tool exists,
hands over the project secret key, checks the sending tier, and writes
posthog-migrate-email-resend-report.md with a summary block.

Steps are provider-neutral; references/resend/ holds what is specific
to Resend. Statsig and Resend now publish as wizard migrate subcommands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(skills): keep migrate-email's summary values bare and define a site

Fixture runs wrote reasons into the summary block, which the Wizard
can't parse, and counted a forwarding helper as one site or as each
caller. The report step now shows bare values, and a site is the place
that decides a send's content and fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* refactor(skills): share the posthog-node client setup as a partial

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(skills): move Resend sends to posthog-node in migrate-email

The migrate-email skill now rewrites every call site PostHog can send:
hosted Resend templates become local templates, React and html/text sites
send rendered content, and the client carries migratedFrom. It checks the
build and one email on one person before it writes the report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(skills): keep migrate-email from splitting sends or faking the old provider in tests

Fixture runs looped over or truncated a one-recipient list, and one run
mocked posthog-node to rebuild a Resend request. The rewrite now narrows
the helper's parameter to one address, and the verify step asserts on the
fields posthog.emails.send gets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(skills): handle suppressed recipients and dynamic subjects in migrate-email

A moved site now treats recipient_suppressed like a send that went out,
a site-built subject becomes a module placeholder so the template hash
stays stable, fallbacks use single quotes, and the report warns that
idempotency keys don't cross providers. The rules and Step 7 no longer
contradict the mapping.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(skills): keep a suppressed copy address from passing as a sent email

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(skills): warn that retrying a send with a suppressed copy address uses sending cap

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(skills): leave migratedFrom off the posthog-node client in migrate-email

posthog-node has no migratedFrom option. The Wizard records the migration
on its setup wizard finished event instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(skills): check that posthog-node has the code form before migrate-email rewrites a site

A posthog-node release with posthog.emails can still lack templateKey,
local template content, or the extra send fields. The rewrite now checks
the installed types for what each moving site needs, and keeps a site
on Resend when the latest release lacks it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* refactor(scripts): ship variant references through the local reference loop

The selected variant's `references/<variant-id>/` 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 <noreply@anthropic.com>

* fix(skills): tie migrate-email's test send to its own events and report every behaviour change

Step 8 checks that the app sends to the MCP's project, notes the start of the
test, and counts only events since then whose workflow matches the send. It
compares typecheck errors with a baseline step 7 takes before it changes code.
A template's content waits in a temporary file for step 7 instead of in memory,
an HTML fallback keeps a template on Resend, and a derived templateKey is
normalized and unique. The report lists each suppressed-recipient branch,
copy-address refusal, and cross-provider idempotency risk, and reminds the
person to delete the full-access Resend key. The posthog-node client setup is
inlined into step 7, and description.md says this skill wins over the general
moving guide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* fix(skills): pin migrate-email's test start to the database clock and keep template files traceable

Step 8 takes the start of the test from `execute-sql` and compares Unix
timestamps, so the project's timezone can't shift the window. The plan lists
each saved template file, step 7 reads the module from it, and step 9 deletes
every listed file. A derived templateKey falls back to the template id when
the name normalizes to nothing and stays unique across the run. Step 8 may fix
other posthog-node code the upgrade breaks, and the report lists those files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(skills): keep migrate-email's test clock uncached and report dependency upgrades

The clock query carries a fresh random literal so a cached answer can't widen
the test window. Step 3 may write the template files it saves, the dashboard
copy includes the template's alias or name for its key, and a major
dependency upgrade lands in the report's "Before cutover" list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(skills): keep migrate-email's chain query uncached on its rerun

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(skills): keep cc, bcc, headers, tags and attachments sites on Resend, and move templates only to PostHog

The first PostHog release sends a template made in PostHog by template id, or rendered html and text. A Resend template site moves only once the person makes the template in PostHog, or stays on Resend for now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop local templates and parked fields from the cutover steps

Under the scope cut, a moved template site sends a template made in
PostHog by templateId with templateData. Rendered sends keep templateKey,
subject, html and text. No local-template module, no upload on first send,
and no cc, bcc, headers, tags or attachments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(migrate-email): set migratedFrom on the posthog-node client

posthog-node's migratedFrom client option sends the provider a send
moved from with every email request. Step 7 now sets it, and the
Resend mapping names its value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): ask for the PostHog template ID before a template site moves

Review round 1 on #451:
- Step 3 asks for the template ID once the person made the template in
  PostHog. A template without one stays: PostHog template not made yet.
- Step 7 checks that the client takes migratedFrom, and undoes the
  posthog-node install when no site is left to move.
- Template files from an API read are deleted once their site is
  classified, and the full-access Resend key right after Step 5.
- Step 8 reads only the token's line of the env file, and picks a
  signed-in flow for the test email.
- replyTo moves as one address or a list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): check each PostHog template and its sender before cutover

Review round 2 on #451:
- Step 3 asks for the address a PostHog template sends from and writes it
  in the site's from column, so Step 4 sets up a sender for its domain.
- The report asks the person to send a test of each PostHog template and
  check that it is transactional, has a verified sender and fills its
  variables.
- Step 5 asks to delete the full-access key on every way out of the step,
  and after a partial import only once the person won't repeat it.
- Step 8 allows a signed-out flow: the person signs in as the recipient
  afterwards, so product events show on the same distinct ID.
- migratedFrom is set only when the client options take it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): ask about a repeat import and test a send that names a person

Review round 3 on #451:
- Step 5 asks once whether to repeat an import that stopped partway, and
  asks about the full-access key after that answer or the repeat.
- Step 8's test email comes from a moved site that passes distinctId;
  without one, the chain is recorded as not checked.
- The client example marks migratedFrom as conditional.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): ask for the PostHog template ID and sender before a template site moves

Review round 1 on #450:
- Step 3 checks a template site's other fields before it asks about the
  template, and asks for the PostHog template ID and the address it sends
  from. A template without an ID stays: PostHog template not made yet.
  The address goes in the from column, so Step 4 sets up its domain.
- mapping.md quotes a fallback that holds ' with ", carries a
  definition-level fallback_value, notes that default replaces an empty
  string, and keeps a fallback with both quotes or HTML on Resend.
- The report asks the person to test each PostHog template before cutover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(skills): read Dirent.path when parentPath is missing in the migrate-email test

Node 20.11, the oldest release engines allows, has no Dirent.parentPath,
so the bundle tests threw before their assertions. Use the same
parentPath ?? path fallback as build-phases.js.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): match account.md's template-content rule to Step 3

account.md said template content goes only into a temporary file, while
Step 3 reads the person's dashboard copies where they saved them. Only an
API answer goes into the temporary file now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): keep a site on Resend when its idempotency key doesn't fit PostHog's

PostHog takes an idempotency key of 1 to 255 printable ASCII characters
without spaces. A site whose key can hold a space or a non-ASCII
character, or run longer, stays on Resend. The report names each key a
retry across the deploy could send twice, since the providers don't
share keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): name PostHog's idempotency key rule exactly

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): end Step 8's email check when the person declines the test email

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(migrate-email): say where a send without distinctId lands

Rename "Find the user ID" to "Link emails to your users", name the
identify() ID, say that without distinctId PostHog records the email on a
separate person keyed by the address, and add invites and signup checks to
the cases that leave it out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(migrate-email): test Step 8 on a site that passes distinctId

Step 7 now leaves distinctId off an address check before the account
exists, so Step 8 picks its test site from the plan rows that pass one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(migrate-email): say why cc, bcc and attachments stay on Resend

The rows said only "Always", so an agent made up a reason, such as
posthog-node not taking attachments. They now say this skill doesn't
move them yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(migrate-email): name the real reason a field stays on Resend

Step 9's report tells the person what would let a site move, from the
row's reason. cc, bcc, attachments, headers and tags now say PostHog's
send doesn't take them yet, not that this skill doesn't move them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(migrate-email): give scheduled sends and topics a reason too

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): keep a site on Resend when its key length is unbounded

A key built from a URL or caller text could read as either "can be
longer than 255 characters" or not. Such a key now counts as one that
can, so the site stays on Resend and the report names the value to bound.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): quote Resend fallbacks with single quotes only

A double-quoted Liquid fallback inside the template's JSON body raises
TokenizationError. Every fallback is quoted with ', and a fallback that
holds a ' keeps the site on Resend, since Liquid has no escape character.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): record the PostHog key as set only after a check finds it

Step 6 ticked the key phase as set when the person said they would add
the key themselves. The step now checks the env file with a count-only
grep (or a successful set_env_values in the Wizard). A promise to add it
later, or a refusal, records the key as not set, and the report lists it
before cutover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): accept spaces around = in the key check

dotenv loads a key with leading whitespace or spaces around =, so the
count-only grep accepts them too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): check the last assignment of the PostHog key

The count-only grep counted a key that an env loader reads as empty: a
value that is only a comment (= #phs_x), and a key overridden by a later
empty line. The check now reads only the last line that sets the
variable and needs its value to start with phs_, still printing only a
count. The test runs the documented command against fixture env files
in place of matching its wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(migrate-email): ask for the transactional_email:write scope

Code sends now need transactional_email:write. A key with hog_flow:write
keeps working through the legacy scope fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): name the transactional email scope in the preflight

The make-transactional tools move to transactional_email:write, and a
login with hog_flow:write still works.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(migrate-email): record engagement events only when engagement capture is on

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): keep an app's PostHog key, and a Resend HTTP call's deadline

A reused posthog-node client that already sets secretKey or personalApiKey
keeps it, and the sends get their own client: posthog-node uses secretKey
for local flag evaluation and remote config too, and prefers it over
personalApiKey, so the email key would replace the app's key.

A Resend HTTP call with its own timeout keeps it through
emailRequestTimeoutMs, with maxRetries: 0 on the send, so a send never runs
past the site's deadline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): keep email off a disabled client, and pass numeric IDs as strings

A client that can be disabled answers every send with client_disabled
while it is, so a reused analytics client would stop emails where the old
provider still sent them: the sends get their own client. distinctId takes
a string, so a numeric user ID is passed as String(user.id), the same
string posthog-js identifies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(skills): close the migrate-email preparation gaps the QA swarm found

- Step 6 ticks the key only when a check finds it in the env file, and the
  report lists a key that is not set.
- Step 3 asks for a template to be made transactional only once a verified
  sender covers its address, since PostHog refuses it before.
- Step 2 and the report name sends with content from the code, which PostHog
  enables apart from template sends.
- The report asks for a last suppression import right before cutover, since
  the provider keeps adding entries.
- A template site that sets `reply_to` stays on Resend, as `replyTo` does.
- The CLI test reads the entries the Wizard gets from the menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(skills): tighten the migrate-email key check and template rules

- Step 6's check accepts only a quote before `phs_`, so a placeholder such as
  `<phs_your_key>` or a value like `xphs_...` no longer counts as a set key.
- `mapping.md` matches Step 3: the person makes a template transactional only
  once a verified sender covers its address.
- A template made in PostHog for an email with a login code or reset link is
  marked sensitive, and the report checks it before cutover.
- Drop the tests that only matched skill sentences; the key check keeps its
  tests against real env files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): keep each site's own HTTP deadline

A moved HTTP send with its own deadline set the shortest deadline on one
shared client, which shortened other sites' deadlines. Give each distinct
deadline its own client instead, so no site gets a deadline it didn't have.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): keep marketing sends on the provider

PostHog's transactional send ignores marketing opt-outs and is only for
emails a person needs. A newsletter whose fields all move passed the
field check and showed as ready to move. Check what a site sends first,
and keep marketing or unclear sends on the provider.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): read the provider's report notes without the import tool

Step 5 read the provider's account.md only after the import tool check,
so without the tool, the facts account.md adds for the report never
reached the plan. Read it first on every path, through a "For the report"
section, and carry those facts into the report. A manual import also
happens right before cutover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): answer the round 1 review of the rewrite and verify steps

- Step 8 keeps each test at its boundary: an HTTP test intercepts
  PostHog's send with the real posthog-node client.
- Email always gets its own posthog-node client; changing the app's
  client would change how its flags evaluate.
- A request with a signal the code aborts stays on Resend.
- Step 7 allows the fields mapping.md adds, such as maxRetries: 0.
- Step 8 matches the moved site's send by template and its engagement
  by send, not by workflow.
- The plan records the full-access key and the baseline typecheck
  errors; posthog-node is upgraded only when it lacks a capability.
- The step test walks the built continuation chain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): match baseline typecheck errors without their line

The rewrite adds lines above an old error, so a file-and-line match made
Step 8 fix errors that were there before. Record and match each error by
file, code, and message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): count each baseline typecheck error

An error that was there before could hide a new copy of itself. Record
how many times each error shows, and fix the extra ones in lines the
rewrite changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): keep a named recipient on Resend

PostHog's send takes one bare address in to, and refuses an entry with a
name, such as Jane <jane@example.com>. A one-entry list with a name
passed the field check. Keep it on Resend, as reply-to already does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): keep a site on Resend when it needs a suppressed send's id

PostHog refuses a send to a suppressed address with no data.id, where
Resend accepted it and returned one. A helper that returns or stores the
id would get null on the success path, so such a site stays unless its
callers already handle a send without an id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): keep a Resend SDK send with an abortable signal on Resend

Resend's SDK takes a signal in the options of emails.send and passes it
to fetch. Dropping the options object would send an email the old code
cancelled, so the signal rule now covers SDK sends as well as fetch and
axios requests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): upgrade mocked send tests to the HTTP boundary

A test that mocked the provider's client now runs the real posthog-node
client and intercepts PostHog's send request, asserting on that request
and on what the app does. It uses the suite's interception tool, asks
before adding one, and keeps the mock only when the person declines,
which the report lists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): fake fetch instead of keeping a send mock

posthog-node sends with the global fetch, so a test suite without an
HTTP interception tool replaces globalThis.fetch with a fake for the
test. No dependency is added, and no test stays on a mock of the send.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): route a network failure the way Resend's code did

Resend answers a network failure as application_error with a null
statusCode, and posthog-node as network_error named
service_unavailable. A branch on application_error now also takes
network_error, and a branch on service_unavailable skips it, so a
network failure takes the path the old code gave it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): read the project token with project-get

projects-get lists the organization's projects without their API token;
project-get, called without an ID, is the one tool that returns the
active project and its api_token. Step 7's token lookup and Step 8's
project check now name it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): keep sites a Resend webhook handler acts on, and state the SDK limit for sites without a deadline

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): check every error in a rewritten line against its old version

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): give Step 8 one rule for which typecheck errors are new

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): tie the kept Resend error names to posthog-node, HTTP sites included

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): mark secret emails sensitive, check template keys, and ask before adding an open pixel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): send sensitive only with rendered content, and only when posthog-node takes it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): check the app's Node version and count removed errors

Step 7 now checks the Node.js version the app runs against the engines
of the posthog-node release it installs, and keeps every site on Resend
when that release doesn't support it: an older Node lacks
crypto.randomUUID(), which the send uses for its idempotency key.

Step 8 counts a listed error against the list's number less the copies
the changed lines no longer have, so an identical error the rewrite adds
in an unchanged line is found even when it replaces one it removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Silthus added a commit that referenced this pull request Nov 13, 2026
#472)

* fix(skills): ship a variant's own reference folder in its bundle

The migrate group keeps Statsig's mapping and SDK reference under
references/statsig/, and its step files point there, but the generator
copied only the top-level reference files. Copy references/<variant-id>/
into each variant's bundle, and nothing of the other variants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(skills): share the email sender setup as a partial

Move the sender setup of send-transactional-email's STEP 3 into
context/shared/email-sender-setup.md, so the email migration skill
includes the same text. A partial now takes the indentation of its
{{> name}} line on every line, so it nests under a step. The built
send skill is unchanged byte for byte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(skills): add the migrate-email skill with a Resend variant

v0 prepares a move off Resend and changes no send call site. It finds
every Resend send, classifies each one as ready to move or staying on
Resend with a reason, sets up the PostHog sender next to Resend's DNS
records, imports Resend's suppression list when the import tool exists,
hands over the project secret key, checks the sending tier, and writes
posthog-migrate-email-resend-report.md with a summary block.

Steps are provider-neutral; references/resend/ holds what is specific
to Resend. Statsig and Resend now publish as wizard migrate subcommands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(skills): keep migrate-email's summary values bare and define a site

Fixture runs wrote reasons into the summary block, which the Wizard
can't parse, and counted a forwarding helper as one site or as each
caller. The report step now shows bare values, and a site is the place
that decides a send's content and fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* refactor(skills): share the posthog-node client setup as a partial

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(skills): move Resend sends to posthog-node in migrate-email

The migrate-email skill now rewrites every call site PostHog can send:
hosted Resend templates become local templates, React and html/text sites
send rendered content, and the client carries migratedFrom. It checks the
build and one email on one person before it writes the report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(skills): keep migrate-email from splitting sends or faking the old provider in tests

Fixture runs looped over or truncated a one-recipient list, and one run
mocked posthog-node to rebuild a Resend request. The rewrite now narrows
the helper's parameter to one address, and the verify step asserts on the
fields posthog.emails.send gets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(skills): handle suppressed recipients and dynamic subjects in migrate-email

A moved site now treats recipient_suppressed like a send that went out,
a site-built subject becomes a module placeholder so the template hash
stays stable, fallbacks use single quotes, and the report warns that
idempotency keys don't cross providers. The rules and Step 7 no longer
contradict the mapping.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(skills): keep a suppressed copy address from passing as a sent email

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(skills): warn that retrying a send with a suppressed copy address uses sending cap

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(skills): leave migratedFrom off the posthog-node client in migrate-email

posthog-node has no migratedFrom option. The Wizard records the migration
on its setup wizard finished event instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(skills): check that posthog-node has the code form before migrate-email rewrites a site

A posthog-node release with posthog.emails can still lack templateKey,
local template content, or the extra send fields. The rewrite now checks
the installed types for what each moving site needs, and keeps a site
on Resend when the latest release lacks it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* refactor(scripts): ship variant references through the local reference loop

The selected variant's `references/<variant-id>/` 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 <noreply@anthropic.com>

* fix(skills): tie migrate-email's test send to its own events and report every behaviour change

Step 8 checks that the app sends to the MCP's project, notes the start of the
test, and counts only events since then whose workflow matches the send. It
compares typecheck errors with a baseline step 7 takes before it changes code.
A template's content waits in a temporary file for step 7 instead of in memory,
an HTML fallback keeps a template on Resend, and a derived templateKey is
normalized and unique. The report lists each suppressed-recipient branch,
copy-address refusal, and cross-provider idempotency risk, and reminds the
person to delete the full-access Resend key. The posthog-node client setup is
inlined into step 7, and description.md says this skill wins over the general
moving guide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* fix(skills): pin migrate-email's test start to the database clock and keep template files traceable

Step 8 takes the start of the test from `execute-sql` and compares Unix
timestamps, so the project's timezone can't shift the window. The plan lists
each saved template file, step 7 reads the module from it, and step 9 deletes
every listed file. A derived templateKey falls back to the template id when
the name normalizes to nothing and stays unique across the run. Step 8 may fix
other posthog-node code the upgrade breaks, and the report lists those files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(skills): keep migrate-email's test clock uncached and report dependency upgrades

The clock query carries a fresh random literal so a cached answer can't widen
the test window. Step 3 may write the template files it saves, the dashboard
copy includes the template's alias or name for its key, and a major
dependency upgrade lands in the report's "Before cutover" list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(skills): keep migrate-email's chain query uncached on its rerun

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(skills): keep cc, bcc, headers, tags and attachments sites on Resend, and move templates only to PostHog

The first PostHog release sends a template made in PostHog by template id, or rendered html and text. A Resend template site moves only once the person makes the template in PostHog, or stays on Resend for now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop local templates and parked fields from the cutover steps

Under the scope cut, a moved template site sends a template made in
PostHog by templateId with templateData. Rendered sends keep templateKey,
subject, html and text. No local-template module, no upload on first send,
and no cc, bcc, headers, tags or attachments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(migrate-email): set migratedFrom on the posthog-node client

posthog-node's migratedFrom client option sends the provider a send
moved from with every email request. Step 7 now sets it, and the
Resend mapping names its value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): ask for the PostHog template ID before a template site moves

Review round 1 on #451:
- Step 3 asks for the template ID once the person made the template in
  PostHog. A template without one stays: PostHog template not made yet.
- Step 7 checks that the client takes migratedFrom, and undoes the
  posthog-node install when no site is left to move.
- Template files from an API read are deleted once their site is
  classified, and the full-access Resend key right after Step 5.
- Step 8 reads only the token's line of the env file, and picks a
  signed-in flow for the test email.
- replyTo moves as one address or a list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): check each PostHog template and its sender before cutover

Review round 2 on #451:
- Step 3 asks for the address a PostHog template sends from and writes it
  in the site's from column, so Step 4 sets up a sender for its domain.
- The report asks the person to send a test of each PostHog template and
  check that it is transactional, has a verified sender and fills its
  variables.
- Step 5 asks to delete the full-access key on every way out of the step,
  and after a partial import only once the person won't repeat it.
- Step 8 allows a signed-out flow: the person signs in as the recipient
  afterwards, so product events show on the same distinct ID.
- migratedFrom is set only when the client options take it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): ask about a repeat import and test a send that names a person

Review round 3 on #451:
- Step 5 asks once whether to repeat an import that stopped partway, and
  asks about the full-access key after that answer or the repeat.
- Step 8's test email comes from a moved site that passes distinctId;
  without one, the chain is recorded as not checked.
- The client example marks migratedFrom as conditional.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): ask for the PostHog template ID and sender before a template site moves

Review round 1 on #450:
- Step 3 checks a template site's other fields before it asks about the
  template, and asks for the PostHog template ID and the address it sends
  from. A template without an ID stays: PostHog template not made yet.
  The address goes in the from column, so Step 4 sets up its domain.
- mapping.md quotes a fallback that holds ' with ", carries a
  definition-level fallback_value, notes that default replaces an empty
  string, and keeps a fallback with both quotes or HTML on Resend.
- The report asks the person to test each PostHog template before cutover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(skills): read Dirent.path when parentPath is missing in the migrate-email test

Node 20.11, the oldest release engines allows, has no Dirent.parentPath,
so the bundle tests threw before their assertions. Use the same
parentPath ?? path fallback as build-phases.js.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): match account.md's template-content rule to Step 3

account.md said template content goes only into a temporary file, while
Step 3 reads the person's dashboard copies where they saved them. Only an
API answer goes into the temporary file now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): keep a site on Resend when its idempotency key doesn't fit PostHog's

PostHog takes an idempotency key of 1 to 255 printable ASCII characters
without spaces. A site whose key can hold a space or a non-ASCII
character, or run longer, stays on Resend. The report names each key a
retry across the deploy could send twice, since the providers don't
share keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): name PostHog's idempotency key rule exactly

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): end Step 8's email check when the person declines the test email

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(migrate-email): say where a send without distinctId lands

Rename "Find the user ID" to "Link emails to your users", name the
identify() ID, say that without distinctId PostHog records the email on a
separate person keyed by the address, and add invites and signup checks to
the cases that leave it out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(migrate-email): test Step 8 on a site that passes distinctId

Step 7 now leaves distinctId off an address check before the account
exists, so Step 8 picks its test site from the plan rows that pass one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(migrate-email): say why cc, bcc and attachments stay on Resend

The rows said only "Always", so an agent made up a reason, such as
posthog-node not taking attachments. They now say this skill doesn't
move them yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(migrate-email): name the real reason a field stays on Resend

Step 9's report tells the person what would let a site move, from the
row's reason. cc, bcc, attachments, headers and tags now say PostHog's
send doesn't take them yet, not that this skill doesn't move them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(migrate-email): give scheduled sends and topics a reason too

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): keep a site on Resend when its key length is unbounded

A key built from a URL or caller text could read as either "can be
longer than 255 characters" or not. Such a key now counts as one that
can, so the site stays on Resend and the report names the value to bound.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): quote Resend fallbacks with single quotes only

A double-quoted Liquid fallback inside the template's JSON body raises
TokenizationError. Every fallback is quoted with ', and a fallback that
holds a ' keeps the site on Resend, since Liquid has no escape character.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): record the PostHog key as set only after a check finds it

Step 6 ticked the key phase as set when the person said they would add
the key themselves. The step now checks the env file with a count-only
grep (or a successful set_env_values in the Wizard). A promise to add it
later, or a refusal, records the key as not set, and the report lists it
before cutover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): accept spaces around = in the key check

dotenv loads a key with leading whitespace or spaces around =, so the
count-only grep accepts them too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): check the last assignment of the PostHog key

The count-only grep counted a key that an env loader reads as empty: a
value that is only a comment (= #phs_x), and a key overridden by a later
empty line. The check now reads only the last line that sets the
variable and needs its value to start with phs_, still printing only a
count. The test runs the documented command against fixture env files
in place of matching its wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(migrate-email): ask for the transactional_email:write scope

Code sends now need transactional_email:write. A key with hog_flow:write
keeps working through the legacy scope fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): name the transactional email scope in the preflight

The make-transactional tools move to transactional_email:write, and a
login with hog_flow:write still works.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(migrate-email): move Resend sends in every PostHog SDK's language

The skill kept every send outside JavaScript and TypeScript on Resend.
PostHog's Node.js, Python, Go, Ruby, PHP, .NET, Rust and Elixir SDKs
all send email now, so Step 7 picks the SDK by the site's language:

- One rewrite file per SDK, with the install, the client, both send
  forms, the answer and its error, and the check to run.
- Resend's mapping gives each language's call and answer, and keeps
  only sends in a language without a PostHog SDK, such as Java.
- Resend's SDK reference lists the clients, sends, batches and mailers
  of every language, so Step 1 and Step 3 find them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): keep the app booting and check the whole project in every SDK

- Read the PostHog env variables without raising, so a missing key
  fails only the send.
- Run each SDK's check on the whole project, and read the changelog on
  a major upgrade.
- Sync and async clients side by side in Python and Rust.
- Move Rust's and Ruby's version requirement on upgrade, and run
  go mod tidy after the rewrite.
- Map Resend's status error classes to a status check.
- Edge runtimes use posthog-node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): use the one Rust client the app's features build

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(migrate-email): carry posthog-sdks.md's rules into the rewrite files

The additions from #459's posthog-sdks.md that #472's rewrite files don't
have yet, so #459 can delete posthog-sdks.md once #472 carries them.

In each rewrite-<lang>.md: cc, bcc and attachments (Node.js, Python, Go,
Elixir), or that the SDK has none yet (Ruby, PHP, .NET, Rust); the network
error left after the retries; the language's bad-argument error; .NET's
missing synchronous send; Go's context.Background(); Python's async extra;
React Email rendered with @react-email/render in Node.js.

Facts that hold for every SDK go into Step 7 rather than into each file,
under "What every SDK's send takes and answers": one `to` per call, the
two forms, what no SDK takes, the attachment shape, PostHog's message id,
branching on the error code, the retries, and where a bad argument fails.
Step 7's capability list also names cc, bcc and attachments.

Facts on which posthog-sdks.md and these files disagree are left out, for
the docs lane to check against the SDK heads first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): check the merged SDK rules against the SDK heads

- No release takes cc, bcc or attachments yet, so drop their lines and
  name them in Step 7's list of what no SDK takes.
- Go and Ruby load flag definitions from their host with the secret
  key: behind a proxy, email gets its own client on PostHog's host.
  Elixir turns local evaluation off behind a proxy.
- PHP gets its own email client, so PostHog::init stays the app's.
- Drop the per-send retry count and the unchecked text-part line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): give Elixir email its own instance when the app uses PostHog

Turning local evaluation off on the app's own config breaks the flags
it evaluates locally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): never pass nil for Elixir's host or token

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): fall back past a nil :posthog host in Elixir

Application.get_env/3 returns an explicitly configured nil, so the
named email instance put the US Cloud fallback after both lookups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): list the error names once and state reply-to per SDK

The rewrite files pointed at a coarse-name list that only the Resend
mapping has. Step 7 now lists the ten names, and each rewrite file
points there. Each rewrite file now says whether reply-to takes one
address, a list, or both, as the SDK heads do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): give email its own client behind a reverse proxy

A client with the secret key loads flag definitions from its host with
it: Go and Ruby when they start, Python and .NET when the app
evaluates a flag. Reusing the app's client behind a reverse proxy
handed the key to the proxy. Step 7 now gives email its own client on
PostHog's host behind a proxy, for every SDK, in place of the Go and
Ruby special cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(migrate-email): pin keeping the secret key off the proxied client

The step 7 proxy test matched only the separate-client wording, so
dropping "leave the secret key off the app's existing client" still
passed. Assert that clause too; it fails without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(migrate-email): bring Resend templates into the code

A send of a template stored at Resend now moves by bringing the email
into the code: its HTML and text become a file next to the send, the
code renders the variables, and the site sends the code form. A
template made in PostHog, sent by its ID, is used only when the person
asks for one.

- template-to-code.md, from the provider stack (#459's 4b15d2e), as is.
- Steps 3, 7 and 9 and description.md: the same change as 4b15d2e,
  ported onto this base's text.
- Resend: a "Templates" part with the in-code table. Resend never
  escapes a value, so the shared escaping rule applies. A fallback
  covers a missing value only, since Resend inserts "" as it is. A
  value the site passes for from, reply-to or subject wins over the
  template's, as Resend does. The Liquid conversion moves under
  "Templates made in PostHog".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(migrate-email): record engagement events only when engagement capture is on

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): keep an app's PostHog key, and a Resend HTTP call's deadline

A reused posthog-node client that already sets secretKey or personalApiKey
keeps it, and the sends get their own client: posthog-node uses secretKey
for local flag evaluation and remote config too, and prefers it over
personalApiKey, so the email key would replace the app's key.

A Resend HTTP call with its own timeout keeps it through
emailRequestTimeoutMs, with maxRetries: 0 on the send, so a send never runs
past the site's deadline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): keep email off a disabled client, and pass numeric IDs as strings

A client that can be disabled answers every send with client_disabled
while it is, so a reused analytics client would stop emails where the old
provider still sent them: the sends get their own client. distinctId takes
a string, so a numeric user ID is passed as String(user.id), the same
string posthog-js identifies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): keep undeadlined HTTP sites on Resend and keep imported templates inert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): record the send's own sender, replace markers in one pass, load Ruby flags off boot

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): map Go's other Resend error checks and keep Outlook comments in Go templates

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): render variables in Go's Outlook blocks and scope the Python async client to its loop

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): keep encoded URL values as they are in Go templates

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(migrate-email): move the shared step changes from the SendGrid layer into the core skill

The provider PRs now only add their own folder, so each can stand on the core alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): list Resend webhook handlers, keep ignored argument errors ignored, check a template's reply-to

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): keep provider content inert in code literals, and keep ignored network failures ignored

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(skills): close the migrate-email preparation gaps the QA swarm found

- Step 6 ticks the key only when a check finds it in the env file, and the
  report lists a key that is not set.
- Step 3 asks for a template to be made transactional only once a verified
  sender covers its address, since PostHog refuses it before.
- Step 2 and the report name sends with content from the code, which PostHog
  enables apart from template sends.
- The report asks for a last suppression import right before cutover, since
  the provider keeps adding entries.
- A template site that sets `reply_to` stays on Resend, as `replyTo` does.
- The CLI test reads the entries the Wizard gets from the menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(skills): tighten the migrate-email key check and template rules

- Step 6's check accepts only a quote before `phs_`, so a placeholder such as
  `<phs_your_key>` or a value like `xphs_...` no longer counts as a set key.
- `mapping.md` matches Step 3: the person makes a template transactional only
  once a verified sender covers its address.
- A template made in PostHog for an email with a login code or reset link is
  marked sensitive, and the report checks it before cutover.
- Drop the tests that only matched skill sentences; the key check keeps its
  tests against real env files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): keep each site's own HTTP deadline

A moved HTTP send with its own deadline set the shortest deadline on one
shared client, which shortened other sites' deadlines. Give each distinct
deadline its own client instead, so no site gets a deadline it didn't have.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): keep marketing sends on the provider

PostHog's transactional send ignores marketing opt-outs and is only for
emails a person needs. A newsletter whose fields all move passed the
field check and showed as ready to move. Check what a site sends first,
and keep marketing or unclear sends on the provider.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): read the provider's report notes without the import tool

Step 5 read the provider's account.md only after the import tool check,
so without the tool, the facts account.md adds for the report never
reached the plan. Read it first on every path, through a "For the report"
section, and carry those facts into the report. A manual import also
happens right before cutover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): answer the round 1 review of the rewrite and verify steps

- Step 8 keeps each test at its boundary: an HTTP test intercepts
  PostHog's send with the real posthog-node client.
- Email always gets its own posthog-node client; changing the app's
  client would change how its flags evaluate.
- A request with a signal the code aborts stays on Resend.
- Step 7 allows the fields mapping.md adds, such as maxRetries: 0.
- Step 8 matches the moved site's send by template and its engagement
  by send, not by workflow.
- The plan records the full-access key and the baseline typecheck
  errors; posthog-node is upgraded only when it lacks a capability.
- The step test walks the built continuation chain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): match baseline typecheck errors without their line

The rewrite adds lines above an old error, so a file-and-line match made
Step 8 fix errors that were there before. Record and match each error by
file, code, and message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): count each baseline typecheck error

An error that was there before could hide a new copy of itself. Record
how many times each error shows, and fix the extra ones in lines the
rewrite changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): keep a named recipient on Resend

PostHog's send takes one bare address in to, and refuses an entry with a
name, such as Jane <jane@example.com>. A one-entry list with a name
passed the field check. Keep it on Resend, as reply-to already does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): settle deadlines, attribute quoting, and suppression calls for every provider

Findings relayed from the provider lanes:
- Each rewrite file names its SDK's email timeout and retry options
  under "A deadline". Go and .NET keep a deadline on the call (context,
  cancellation token); the others get a client per distinct deadline.
  Rust keeps whole seconds only, so a finer deadline stays.
- template-to-code quotes an unquoted attribute value before inserting
  an escaped value: a space, = or a backtick still ends it.
- A lost template copy comes from the dashboard: the full-access key an
  API read needs is gone after Step 5.
- Resend's mapping lists the calls that change its suppression list,
  which Step 5 reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): keep a site on Resend when it needs a suppressed send's id

PostHog refuses a send to a suppressed address with no data.id, where
Resend accepted it and returned one. A helper that returns or stores the
id would get null on the success path, so such a site stays unless its
callers already handle a send without an id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): keep a Resend SDK send with an abortable signal on Resend

Resend's SDK takes a signal in the options of emails.send and passes it
to fetch. Dropping the options object would send an email the old code
cancelled, so the signal rule now covers SDK sends as well as fetch and
axios requests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): own email clients, long deadlines, clicks and template copies in every SDK

- .NET: a PostHogClient of its own for email; the IPostHogEmails that
  AddPostHog registers is the app's client.
- Go and .NET: the SDK ends each request at its own timeout, so a
  deadline longer than that needs a client with that timeout.
- Rust: the blocking client is not awaited.
- Elixir: email always gets a named instance.
- A send in the code form is sensitive in PostHog and counts no clicks:
  the email.clicked row, Step 7 and the report say so, and a site whose
  clicks feed a handler moves only to a non-sensitive PostHog template.
- Temporary template copies are deleted on every way out, not only after
  Step 7 writes the email.
- A template the app's code changes at the provider stays there.
- Go's html/template rewrites a link with another scheme, such as tel:,
  so such a value keeps its site on the provider.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(migrate-email): drop the tests that match the skill's own wording

They read source Markdown and matched sentences, so a paraphrase failed
them and a wrong SDK call passed. One it.each ran no case. The bundle,
step chain, key check and menu tests stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): blank .NET host, long .NET deadlines, shared templates and kept Resend handlers

- .NET: a blank POSTHOG_HOST falls back to US Cloud instead of throwing
  in new Uri, and a deadline over 100 seconds keeps its site on Resend:
  the client's HttpClient ends each request at .NET's 100-second default.
- A temporary template copy is deleted only once no site that sends its
  template will bring it into the code.
- A stored message ID works for a send that goes out; the suppressed
  send rule covers the rest.
- Elixir sends always name the email instance.
- An error handler that also covers a call staying on Resend keeps
  Resend's handler next to PostHog's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): keep a Go site whose template fills CSS from a value

Go's html/template writes ZgotmplZ, with no error, for a CSS value such
as rgb(255, 0, 0) or a style with more than one property. A CSS value
the code builds goes in as template.CSS; one from elsewhere keeps its
site on the provider. Checked by rendering both in Go 1.23.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): count brought-in templates as code content, stop Python flag polling

The report's "Before cutover" check names a site whose template was
brought into the code, which still sends with content from the code.
The Python email client sets enable_local_evaluation=False, so a
worker forked under a preloading server doesn't poll flag definitions
with the email key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): keep enable_local_evaluation off the Python async client

AsyncPosthog has no enable_local_evaluation option and never polls
flags; passing it raises TypeError at startup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): keep sites that branch on one kind of network failure

Every SDK retries network failures and reports what is left as one
network_error, so a branch on ECONNRESET, requests.Timeout or
Faraday::ConnectionFailed would never run: the site stays. A kept
deadline's own timeout still matches in Go (errors.Is through the
EmailError's cause) and .NET (the token's OperationCanceledException).
Resend's Node.js SDK names a network failure application_error with a
null status, so those branches also take network_error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): keep separate connect limits, require ext-curl for PHP

Every SDK has one timeout for connecting and reading, so a request
that limits connecting alone or apart from reading has a deadline no
SDK can keep: the site stays. posthog-php sends email through curl,
which Composer doesn't ask for: the app requires ext-curl, and the
report lists enabling it where the app runs when that isn't shown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): keep a Go request's transport limits

posthog-go sends email through Config.Transport, so a dialer timeout,
TLS handshake or response header limit on the request's transport is
kept by giving the email client that transport.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): keep a Python whole-request limit on the provider

posthog-python hands its email timeout to requests or httpx as one
number, which limits each network wait, so an aiohttp total timeout is
a deadline it can't keep: the site stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): settle untracked clicks, timeout kinds, and the message ID

- A site whose clicks the provider didn't track, or nobody knows, moves to
  a template made in PostHog only when that template is marked sensitive,
  since PostHog's click redirect would rewrite its links. Resend's
  account.md says which setting means its clicks were off.
- A library's default timeout, such as httpx's or ky's, is not the site's
  own deadline. The SDK keeps a deadline only of its own kind: Python and
  Ruby limit each wait, the others the whole request, so axios's Node.js
  idle timeout and ky's headers-only timeout can't be kept.
- A sensitive template sends links without the click redirect, in
  Resend's mapping too.
- PostHog's message ID is a UUID, so a .NET Guid takes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): read Faraday's timeout by its adapter

Faraday's timeout limits each wait only on the net_http adapter; on
Typhoeus it limits the whole request, which posthog-ruby can't keep.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): keep a site whose handler needs an event PostHog lacks

Resend stops sending events for a moved site, and an event the table
maps to none, such as email.delivery_delayed, has nothing in PostHog to
take its place. A site whose emails such a handler acts on stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): upgrade mocked send tests to the HTTP boundary

A test that mocked the provider's client now runs the real posthog-node
client and intercepts PostHog's send request, asserting on that request
and on what the app does. It uses the suite's interception tool, asks
before adding one, and keeps the mock only when the person declines,
which the report lists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): fake fetch instead of keeping a send mock

posthog-node sends with the global fetch, so a test suite without an
HTTP interception tool replaces globalThis.fetch with a fake for the
test. No dependency is added, and no test stays on a mock of the send.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): keep a Ruby site with a partial Net::HTTP timeout

posthog-ruby sets open, read and write to one number, so a request that
sets only some of them, or sets them apart, has a limit the SDK can't
keep, and its site stays on the provider.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): note a must-be-sensitive template in its plan row

Step 3 writes "must be marked sensitive" with its reason under the
sites table, for a secret in the email or for untracked clicks, so the
report's "Before cutover" check reads it for every provider.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): keep network failures off Resend's 503 branch

Every PostHog SDK names a network failure service_unavailable, which
Resend's API gives only a 503. A branch on that name now also checks
that the code is not network_error, so a network failure takes the path
the old code gave it, whatever the order of the branches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): allow a loopback app host in Go and .NET tests

Go and .NET refuse an http app host unless AllowInsecureAppHost is set.
A test's local server needs it, so the email client sets it only for a
loopback app host, and the secret key never leaves the machine over
plain HTTP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(migrate-email): ask before adding an open pixel to untracked email

PostHog adds its open pixel to every email it sends. Step 3 now asks the
person once whether PostHog may add it to the emails of sites whose
provider never tracked their opens: a yes moves them and the report says
PostHog now counts their opens; a no stays as "open tracking off", and a
site nobody can tell about stays as "open tracking not checked". Step 7
points there, and Resend's account file says where its per-domain open
tracking shows. A bundle test checks that every "Step N says under"
reference names a section that step has.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): route a network failure the way Resend's code did

Resend answers a network failure as application_error with a null
statusCode, and posthog-node as network_error named
service_unavailable. A branch on application_error now also takes
network_error, and a branch on service_unavailable skips it, so a
network failure takes the path the old code gave it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): test with a standard-library server, never a mock of the send

When the suite has no HTTP interception tool and the person declines adding
one, Step 8's test now runs a local server from the language's standard
library (Python http.server, Ruby TCPServer, Rust TcpListener, Erlang's
httpd or gen_tcp) instead of mocking the PostHog SDK's send. The report
drops the line that listed such mocks.

The skill test now runs the Step pointer check for every provider and
fails on any provider directory in a bundle other than its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): ask about a test dependency only where one is needed

Node.js, Go, .NET and PHP intercept without a new dependency, so Step 8
asks the person about a dev dependency only in Python, Ruby, Rust and
Elixir. The Step pointer check gets its own per-provider test again, so a
broken pointer fails under a name that says what broke.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): ask before adding any test tool, in every language

Step 8 offered WireMock.Net to a .NET suite while exempting .NET from the
question, so an agent could add it without the person's yes. The rule to
ask now covers every language; Node.js, Go, .NET and PHP still need no
tool beyond what comes with them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(migrate-email): re-import the suppression list weekly while sites stay

A site that stays on the provider after cutover keeps adding bounces and
complaints to the provider's list, which PostHog never sees, so a moved
send can mail a suppressed address. Step 5 gains a "Re-import only" path
that runs the import alone, the skill entry points to it, and the report
asks for a weekly re-import while any site stays, with the prompt that
runs it, and one final import once the last site leaves. When every site
moved, the import before cutover stays the last one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): check the project before a suppression re-import

A re-import runs alone, with no plan and no Step 8 after it, so it now
checks first that the app sends to the MCP's project and stops when it
doesn't. When the import tool is missing, it tells the person where to
add the new addresses by hand. The per-provider pointer test already
covers the entry's pointer, so the separate re-import test goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): write the re-import's project check out in Step 5

The re-import pointed at Step 8's whole section, whose opening skip rule
a run with no plan can't evaluate, so an agent could skip the project
check. Step 5 now states the comparison itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): confirm the production project before a re-import

The local env file can name a development project while production sends
from another, so the re-import now asks the person whether production
sends from the MCP's project, with a differing local token as a hint,
and stops on a no or when they can't tell.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): read the project token with project-get

projects-get lists the organization's projects without their API token;
project-get, called without an ID, is the one tool that returns the
active project and its api_token. Steps 5, 7 and 8 now name it. On a no,
the re-import asks which project production sends from and offers to
switch the MCP to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): switch the MCP back after a re-import

A re-import that switched the MCP to the production project now switches
back to the project it started from when Step 5 ends, and stops when the
production project isn't in the current organization's list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): name the production project in a by-hand re-import

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): let a re-import without the tool say so

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): let a re-import say why it got no list

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): read the project token with project-get

projects-get lists the organization's projects without their API token;
project-get, called without an ID, is the one tool that returns the
active project and its api_token. Step 7's token lookup and Step 8's
project check now name it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): ask for re-imports only when the provider keeps a list

A provider with no suppression list of its own, such as a mailbox
provider behind an SMTP host, has no list that grows while sites stay.
Step 5 writes that into the plan, and the report then leaves out the
weekly re-import ask.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): record a provider with no list before the tool check

Step 5 checked the import tool before it looked at whether the provider
keeps a list, so a provider with none, for a person without the import
tool, got by-hand imports for a list that doesn't exist. The no-list
line is now written in "Read the provider's notes", which runs first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): end a re-import early when the provider keeps no suppression list

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): keep sites a Resend webhook handler acts on, and state the SDK limit for sites without a deadline

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): check every error in a rewritten line against its old version

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): check for a provider without a suppression list before the re-import's project check

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): give Step 8 one rule for which typecheck errors are new

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): tie the kept Resend error names to posthog-node, HTTP sites included

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): mark secret emails sensitive, check template keys, and ask before adding an open pixel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): send sensitive only with rendered content, and only when posthog-node takes it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): check the app's Node version and count removed errors

Step 7 now checks the Node.js version the app runs against the engines
of the posthog-node release it installs, and keeps every site on Resend
when that release doesn't support it: an older Node lacks
crypto.randomUUID(), which the send uses for its idempotency key.

Step 8 counts a listed error against the list's number less the copies
the changed lines no longer have, so an identical error the rewrite adds
in an unchanged line is found even when it replaces one it removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): carry the key, sensitive and Node checks; count removed errors

Carry #451's fixes in #472's form:

- Step 7 asks whether a template saved in PostHog already has a send's
  key and gives such sends another key, so a code send never takes a
  saved template's settings, and sets the sensitive flag on a send with
  rendered content that carries a secret.
- Step 7 checks the language version the app runs against what the SDK
  release supports, and keeps that language's sites on the provider when
  it doesn't.
- Step 8 counts a listed error against the list's number less the copies
  the changed lines no longer have, and says that its rule covers
  warnings too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): name each SDK's sensitive flag; list the moved keys

- Each rewrite file's "The send" names the sensitive option in its SDK's
  form, which every SDK's latest release takes, and Step 7 points there.
- The report's "Before cutover" list names the template keys the moved
  sends use, which a template saved in PostHog later must not reuse.
- The runtime check says what to check on an edge runtime, which has no
  Node.js version of its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): move the template keys to after cutover; edge reason

- The report lists the moved sends' template keys under "After cutover",
  as a rule for templates the person saves from now on, since it asks
  for nothing before the deploy.
- An edge runtime the rewrite file doesn't name gets its own reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): scope the runtime check to where each site runs

- A runtime the SDK release doesn't support keeps only the sites that
  run there, and an edge runtime the rewrite file doesn't name keeps only
  its own sites, each with its own reason.
- The report's note on template keys names every setting a saved
  template with a moved key changes: transactional, and the sensitive
  setting with its click redirect and subject on engagement events.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): check every server that shares the SDK's manifest

The runtime check now compares the SDK release with every server that
installs from the dependency manifest the SDK goes into, and keeps the
sites of all of them when one runs a version the release doesn't
support, since the install reaches that server too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): keep every site on a manifest an unsupported server shares

When the SDK release doesn't support a server that installs from the
manifest the SDK goes into, every moving site on that manifest stays,
edge sites included. Only then does an edge site get its own check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): count workspace and root lockfile installs in the runtime check

A server whose build installs the SDK's manifest through a workspace or
a root lockfile install counts like one that installs it directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): keep a site whose HTTP client retries a request with a deadline

A request with its own deadline moves with no retries, since it never
retried. When the code also set its HTTP client up to retry the request,
such as a requests session with a urllib3 Retry adapter, it did retry,
and the moved send would drop those retries. Such a site now stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): count only a retry setup that covers the POST

Faraday's retry middleware skips a POST by default, and urllib3's Retry
retries a POST only on a failed connection, so a site whose retry setup
skips the POST never retried the send and keeps its deadline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): name the retry setups as examples, not a full list

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): read the retry setup's options before counting it

axios-retry and urllib3's Retry retry a POST only at their defaults; the
code's options can turn that off. The agent now reads them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): count a requests adapter whose max_retries is a number

HTTPAdapter(max_retries=3) builds urllib3's Retry itself, so it retries a
failed connection on a POST too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): keep a site that reads Resend's response headers

Resend's answers carry response headers: headers in Node.js,
http_headers in Python, headers and rate-limit fields on Ruby, Go,
.NET and Rust errors. PostHog's answer carries none, and its error
carries only the retry delay. A site that reads any other header value
now stays on Resend, and a read of the retry delay moves to the PostHog
error's, with each SDK's name and unit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): name each SDK's retry delay in its rewrite file

Each PostHog SDK's error shape lives in its rewrite file under "The
answer", so the retry delay's name and unit move there, and Resend's
mapping keeps only where Resend carries the value. A site that keeps its
own deadline sends with no retries, so its own wait and retry stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): keep a send that turns Resend's plain-text part off

Resend sends no text part when text is an empty string, on a send or a
template, and makes one from the HTML when text is left out. PostHog
makes the text part from the HTML whenever a send has none, so a send or
template with an empty text now stays on Resend, and one without text
moves as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): cover react and run-time empty text, word the template rule per provider

Resend also drops the text part for an empty text next to react, and for
a text the code builds that can be empty. The shared template rule now
keeps any template whose provider sent its emails with no text part, as
that provider's mapping says.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): check the text-part rule in Step 3, count Go's empty Text as left out

Step 3 reads only the Check part of the template file, so the rule that
keeps a template whose provider sent no text part moves there. Resend's
Go SDK leaves an empty Text out of the request, so Resend made that text
part from the HTML, and such a site moves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): ask whether a copied Resend template turns its plain text off

A dashboard copy shows no plain text both for a template without one and
for one whose plain text is an empty string. Ask which, or read the
template from the API; when nobody can tell, the site stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): offer the full-access key for the plain-text question and keep the answer

A dashboard copy has no key to read the template from the API, so the
question offers to make one. The answer goes into the template's plan
note, so a copy made again in Step 7 asks nothing new.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): note every template's plain text, offer the key only in Step 3

The template's note records its plain text however the template was
read, so Step 7 never asks again, and the key is offered only in Step 3,
before Step 5 asks the person to delete it. A site stays whenever
neither the answer nor the API read settles it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): sync suppression calls on the reasons PostHog stores

PostHog's bulk add takes only bounce, complaint or manual, and writes its own
reason text, such as "Imported (bounced)". The removal guard now keys on that
prefix, and the import maps each Resend origin to the reason value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): report suppression-list calls instead of syncing them

PostHog's list records no source an app could check before a removal, a bulk
add skips an address removed earlier, and an app can't call the import tool.
PostHog blocks a transactional send only to an address that hard-bounced, and
records that itself. So each such call becomes a "Before cutover" item.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): say only hard bounces block a moved send

PostHog blocks a transactional send only to an address that hard-bounced, so
the weekly re-import limits mail to bounced addresses, not complaints. A call
that removes an address becomes an "After cutover" item the person acts on
when a moved send fails with recipient_suppressed. A call that adds one names
the action before the deploy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): give the add item a check the app can make

Re-import only now names bounces alone, and an app call that adds an address
also records it where the moved sends can skip it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): ask in Step 3 which sites rely on a suppression add

A moved send reaches every address an app's add call put on the provider's
list, and an app-side flag misses past adds and survives a reactivation. So
Step 3 asks once which sites rely on the call, and keeps them on the provider.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): ask about hand-added suppressions too

Step 3 asks which sites rely on the provider's list for addresses added by
hand or by the app, and a non-answer keeps every site when the app adds
addresses itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): keep the sites when nobody can say who relies on the list

The suppression-list question gets its own Step 3 section, asks only about
sites that would move otherwise, keeps them on a non-answer, and says what
would let them move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): ask about the suppression list before the template work

The question now comes right after the field checks, so a site it keeps
needs no template copy or PostHog template, and the plan note carries what
would let the site move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): list the suppression note in the plan file rules

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): keep Rust sites on runtimes other than Tokio

posthog-rs's async email send waits between retries with tokio::time::sleep
and sends through reqwest, so a site on async-std or smol stays on Resend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(migrate-email): require Tokio's timers for the Rust async client

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants