Repository navigation
feat(skills): move Resend sends to posthog-node in migrate-email - #451
Conversation
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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
…d 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>
…rate-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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… uses sending cap Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e-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>
…-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>
…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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rt 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>
…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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… 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>
…ndency 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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
🧙 Wizard CIRun 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:
Test all apps in a directory:
Test an individual app:
Show more apps
Test against a wizard branch:
Add Results will be posted here when complete. |
…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>
Resolves the inventory, mapping and report conflicts toward the scope cut: a Resend template site moves only to a template made in PostHog, sent by templateId, and headers, tags and attachments stay on Resend. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
…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>
There was a problem hiding this comment.
PostHog Review · Standard · reviewed 497d017: 1 Should fix.
This is review round 5 on this pull request. When findings keep coming back in the same code, the approach is usually the problem. Before the next fix, check whether a different design would remove the whole class of findings.
…n 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>
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>
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>
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>
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>
…l-all-sdks Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> # Conflicts: # context/skills/migrate-email/references/7-rewrite.md
…state the SDK limit for sites without a deadline Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… old version Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…re new Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…HTTP sites included Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-cutover Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
PostHog Review · Standard · reviewed 013a98e: 1 Must fix, 2 Should fix.
This is review round 6 on this pull request. When findings keep coming back in the same code, the approach is usually the problem. Before the next fix, check whether a different design would remove the whole class of findings.
…, and ask before adding an open pixel Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ly when posthog-node takes it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
PostHog Review · Standard · reviewed fdea2d3: 1 Should fix.
This is review round 7 on this pull request. When findings keep coming back in the same code, the approach is usually the problem. Before the next fix, check whether a different design would remove the whole class of findings.
…rors 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>
…emoved 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>
There was a problem hiding this comment.
PostHog Review · Standard · reviewed a090b5b: 1 Should fix.
This is review round 8 on this pull request. When findings keep coming back in the same code, the approach is usually the problem. Before the next fix, check whether a different design would remove the whole class of findings.
#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>
Problem
migrate-email-resend(#450) finds every Resend send and gets the account ready, but it leaves every send on Resend. The person then rewrites each send by hand.With this PR the skill moves the sends in code. Each send PostHog can take moves to
posthog.emails.sendin posthog-node. Every other send stays on Resend with a reason. The skill then checks the build and one email on one person, and writes the report with the realsites_rewritten.Important
Stacked on #450, which is based on
main. Merge that first. This PR's own commits:1d85155...2fe8430.The skill sends content from code with
templateKey, and setsmigratedFromon the client. Both ship in the next posthog-node minor release, from PostHog/posthog-js#5509. Hold the merge until that release is out.Impact
No metric: PostHog's send API does not record the
X-PostHog-Migrated-Fromheader thatmigratedFromsets, so no insight can count the sends this skill moves. Recording it on$workflows_email_sentwould let one insight count moved sends per provider and per organization.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 (rewritten send code, senders, imported suppressions, keys).
POSTHOG_PROJECT_SECRET_API_KEY, token or host fails every moved send).renderor a missedtextchanges what each email of the site holds, without an error); identity (a wrong or missingdistinctIdrecords each send against a separate person, so the user's sent, opened and clicked events go missing); a kept deadline (a wrongemailRequestTimeoutMsormaxRetrieslets a send run past the site's deadline, so a caller that retries on its own sends the email twice); and suppression and error handling (a missedrecipient_suppressedbranch sends a suppressed send down the site's failure path, retrying or failing the request, and a wrongerror.namecheck stops a branch the site relied on from running).network_errorwhere it used to wait).sensitive: true, or whose key a template saved in PostHog already has, takes that template's settings, so its links go through PostHog's click redirect and its subject lands on engagement events, and a key whose template isn't transactional fails each send); open-pixel consent (a site whose emails Resend never tracked moves only with the person's yes, and the report names it; a wrong answer on whether Resend tracked opens adds PostHog's open pixel to emails that never had one); and the app's Node.js version (a wrong read of the deployed runtime installs a posthog-node release the app's Node can't run, and every moved send fails).Rollout
Changes
Three steps take the place of the old report step:
secretKeyandmigratedFrom: 'resend', and never changes the app's own PostHog client. Each site whose plan row says it moves is rewritten in one of two forms:@react-email/renderand sends the result ashtml. Anhtmlortextsite sends its content as it is. Each send gets atemplateKeynamed for the kind of email.templateIdwithtemplateData, which takes the place of Resend'svariables.from,replyTo, the idempotency key anderror.namebranches keep working.distinctIdis the ID the app already identifies people with. A send to a suppressed address gets a branch that treatsrecipient_suppressedlike a sent email. A site whose code returns or stores the message id stays, since a suppressed send has none.signal, in afetchor axios request or in the Resend SDK's options, stays on Resend: PostHog's send takes no signal.cc,bcc,headers,tags,attachments, scheduled sends and batch sends stay on Resend.$workflows_email_sent, an engagement event of the same send by$workflow_invocation_idand template, and a product event on the same distinct ID, read through the MCP'sexecute-sql.Step 3 asks the person once per Resend template whether to move it or keep it on Resend, reads its content to classify it, and asks for the PostHog template ID and the address it sends from. Step 4 then sets up a sender for that address.
mapping.mdgains the rewrite rules, andaccount.mdcovers reading a template's content.This revision (2026-11-03) adds "Link emails to your users" to Step 7: pass the
distinctIdthe app already passes toidentify(), so each send and its sent, opened and clicked events land on the user's person. Without it, PostHog records the email against a person whose distinct ID is the recipient's address, as master does today (distinct_id or to). An invite or an address check before the account exists leaves it out. Step 8's test email now comes from a site whose plan row passesdistinctId.The revision before (2026-11-02) matches the skill to the first release of the code send:
cc,bcc,headers,tagsandattachmentsstay on Resend.migratedFromis back on the client, now that posthog-node has the option.Evidence
Bundle test (vitest):
chains the steps from detection through rewrite and verify to the reportfollows the built "continue with" links from1-detectthrough6-key,7-rewriteand8-verifyto9-report. On2fe8430,vitest run scriptspasses 340 tests in 30 files, andnode scripts/build.jsbuildsmigrate-email-resend.zip.Agent runs on
85f6096. Claude Code on Sonnet 5.5 ran the skill as a dry run, headless, with only read tools, on a fixture app with four Resend sends: a React email,htmlandtextwithreplyTo, the Resend templateinvoice, and one with an attachment. The person's answers were in the prompt. Neither run changed a file in the fixture.invoiceon Resendrender(<WelcomeEmail … />)and sendshtmlwithtemplateKey: 'welcome'; thehtmlandtextemail keepsreplyTo.invoicestays (kept on Resend for now), the attachment email stays (attachments). The client setsmigratedFrom: 'resend'invoice; its PostHog template ID and senderinvoicemoves withtemplateIdandtemplateData: { INVOICE_URL: invoiceUrl }, and itsfromcolumn holds the template's sender, so Step 4 covers that domain. "Before cutover" lists a test of that template: transactional, verified sender,INVOICE_URLfilledThe runs are on
85f6096. Since then,a759ff4changes only Steps 5 and 8 and a comment in Step 7's example, the merges of feat(skills): add the migrate-email skill with a Resend variant #450 add one sentence to Step 3 for a template site that another field keeps on Resend,2c36853rewordsaccount.mdto match Step 3,21f683eends Step 8's email check when the person declines the test email, andd157859and5dcdd21rewrite Step 7'sdistinctIdsection and change Step 8's example site. ThedistinctIdsection changes which ID a moved site passes, not which sites move. The fixture's template site sets no other field, so the runs take the same path. Raw sessions and prompts are in proof storage.QA swarm 2026-11-12, on the stack with feat(skills): add the migrate-email skill with a Resend variant #450's fixes merged in:
708f7f1,0186ee2and30f3873, and prod shape rejected, since the rewrite reads the person's code; it is now recorded as a gap. The fixes: a client of its own for email, posthog-node upgraded only when its types lack a capability, a deadline kept on a client of its own withmaxRetries: 0, an abortablesignalkeeping a site on Resend, tests kept at their boundary, Step 8 matching on$workflow_invocation_idand the template, the full-access key's variable name in the plan, and a test that walks the step chain.dc18f5d(baseline errors without the line, then with their count). Final reads: one finding fixed, then clean ondc18f5d,bde40bc,497d017and69edcdd.dc18f5d(a recipient with a name, fixed on feat(skills): add the migrate-email skill with a Resend variant #450 ina2994e9), round 2 onbde40bc(a site that needs a suppressed send's id, fixed in497d017), round 3 on497d017(a Resend SDK send with asignal, fixed in69edcdd; resend 6.38.0 passes it tofetch).emailRequestTimeoutMsandmaxRetriesinmapping.md.a66e15b). It uses the suite's tool,msw,nock14 or later, or undici'sMockAgent, or with none of them a fake globalfetch, so no dependency is added. A final read ona66e15bfound that the first version kept a mock of the send when the person declined a new tool; the fetch fake replaces it in18ab351. ReviewHog round 4 ona66e15band the final read on18ab351, both under the standing review rule, found nothing.18ab351found that a Resend network failure,application_error, comes from posthog-node asnetwork_errornamedservice_unavailable, so unchanged name branches would take it down another path. A branch onapplication_errornow also takesnetwork_error, and a branch onservice_unavailableskips it (cff58f5). The final read oncff58f5, under the standing review rule, found nothing.project-gettool, called without an ID, the one tool that returns the active project'sapi_token;projects-getlists projects without their tokens (2df1f26). A final read on feat(migrate-email): move Resend sends in every PostHog SDK's language #472 found it; the final read on2df1f26, under the standing review rule, found nothing.resend/mapping.mdsays that a site with no deadline of its own gets posthog-node's limit: 15 seconds per request and two retries with the same idempotency key, so a send slower than that on every try ends innetwork_errorwhere it used to wait (5e0607f). Thefleet proof gatejudge on2df1f26found both; the final read on5e0607f, under the standing review rule, found nothing.git diffhad it, since the rewrite can remove one error and add one just like it, which the count alone misses; and, when a listed error still shows more times, the extra ones in unchanged lines that use the send's result, its error or the client. Every other listed error is left (eeed7cb,77b680b). Thefleet proof gatejudge on5e0607ffound the count gap, the rest of the finding from final read 1, and final read 30 on feat(migrate-email): move Resend sends in every PostHog SDK's language #472 found the clauses disagreeing.013a98emerges feat(skills): add the migrate-email skill with a Resend variant #450's1d85155, whose error-name rule names posthog-node's names, which every moved site, an HTTP one too, sends through.013a98e(19b1b90): an email that carries a login code, a reset link or another secret getssensitive: true, since a send whose key matches a template saved in PostHog takes that template's setting, off by default; Step 7 asks the person once whether a saved template already has any of its keys, since such a send goes through that template's settings and fails when it isn't transactional; and Step 3 asks before PostHog adds an open pixel to emails of a domain whose Resend open tracking was off, or keeps those sites, as feat(migrate-email): move Resend sends in every PostHog SDK's language #472 does. Checked against posthog-node's send options and PostHog's transactional email API. Final read 13 on19b1b90limitedsensitiveto sends with rendered content, since PostHog refuses it on atemplateIdsend, and added it to Step 7's check of what the installed posthog-node takes (fdea2d3).fdea2d3(2fe8430): before it installs or upgrades posthog-node, Step 7 checks the Node.js version the app runs in production against theenginesof that release, asks the person when it can't tell, and keeps every site on Resend when the release doesn't support it, since an older Node lackscrypto.randomUUID(), which the send uses for its idempotency key. Checked against posthog-node 5.71.0'senginesand its send code. From final read 31 on feat(migrate-email): move Resend sends in every PostHog SDK's language #472, 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. Final read 15 on2fe8430, under the standing review rule, and ReviewHog on2fe8430found nothing.Lane gate on
2fe8430:vitest run scripts, 340 tests pass, andnode scripts/build.jspasses.Shadow diff from feat(skills): add the migrate-email skill with a Resend variant #450's head
1d85155to2fe8430: only themigrate-email-resendbundle,manifest.jsonandskill-menu.jsonchange; every other bundle is byte-identical. Saved in proof storage.SDK facts come from posthog-node at #5509:
migratedFrom?: stringon the client options, sent as theX-PostHog-Migrated-Fromheader, and theerror.namevalues the rewrite keeps, such asvalidation_errorforrecipient_suppressed.Gaps accepted on
cff58f5, carried to2fe8430:build.ymlruns only on PRs intomain, and this PR's base is feat(skills): add the migrate-email skill with a Resend variant #450's branch. The lane gate ran it locally.69edcdd; Michael accepted them again on18ab351, and the acceptance carries to later heads. The judge gap on the Step 8 test point, accepted on69edcdd, is not carried over:18ab351fixes that point.Not checked:
Decisions
templateId, ortemplateKeywith renderedhtmlortext. No templates in code, no upload@react-email/renderand sendshtmlcc,bcc,headers,tags,attachmentsmigratedFromon the clientrecipient_suppressedlike a sent email at a site that throws or retries on errors$workflow_invocation_id,$email_template_keyand$email_template_id, and a product event on the same distinct ID, all after the start of the test. Replaces matching by workflow onlyemailRequestTimeoutMsandmaxRetries: 0; sites without one keep the shared clientsignalfetch, axios and the Resend SDK's options; a fixed timeout is a deadlinedata.idstays, unless its callers handle a send without an idproject-get, called without an ID, the one tool that returnsapi_token; replacesprojects-get, which lists projects without itsensitive: trueon a send with rendered content, when posthog-node takes it, so a key that matches a saved non-sensitive template doesn't send its links through click trackingenginesof the posthog-node release Step 7 installs; a version that release doesn't support keeps every site on Resend🤖 Agent context
Agent: Claude Code, Claude Opus 5.5 (
claude-opus-5-5), as the docs, skills and wizard slice of Silthus/posthog#461. Reviews infleet proof, by Claude Opus 5.5 (high) on fresh eyes, correctness and risk: round 1 on5edeb92(findings fixed in64cf8ef) and round 2 (findings fixed in85f6096), and round 3 (findings fixed ina759ff4). GPT-6.1 Sol (high) final reads ona759ff4,e053dab,2c36853and21f683e: no findings. Opus 5.5 (high) final reads on thedistinctIdsection:d157859(one minor finding, fixed in5dcdd21) and5dcdd21(no findings).Involvement: no grilling rounds, no prototypes. QA swarm 2026-11-12: one two-family panel with five team lenses, one round on the fixes, and final reads ending clean on
2fe8430; seven ReviewHog rounds, the last clean, every finding fixed; relayed findings from the provider lanes settled. Proof captured: shadow diff; live run, CI and prod shape accepted as gaps.Earlier revisions of this description
The earlier text described templates converted to modules in the app's code, an upload on the wire, and
cc,bcc,headers,tagsandattachmentsmoving. The first release has none of these. Its "Impact" paragraph is removed. The rest is kept as it was.Moved from Silthus#2.
Problem
migrate-email-resend(PostHog/context-mill#450) finds every Resend send and prepares the account, but it leaves every call site on Resend. The person still rewrites each send by hand.With this PR the skill does the cutover in code. It moves each send PostHog can take to
posthog.emails.sendfromposthog-node, leaves the rest on Resend with a reason, checks the build and one email on one person, and writes the report with the realsites_rewritten.Tracking: Silthus/posthog#539, slice 15 of the Resend parity spec. Contract: PostHog/posthog#120921.
Merge Danger
Summary
What this adds beyond
moving-from-other-providers.md(PostHog/posthog#125415): that reference maps any provider's send to a PostHog template. This PR moves Resend sends in code to the code form of posthog-node: the field mapping, the Resend template conversion to Liquid, the suppressed-recipient and idempotency rules, and the verification chain from the send to the engagement and product events.Three steps replace the old step 7:
posthog-nodeclient withsecretKey. Then each site whose plan row says it moves:{{{NAME}}}turns into{{ variables.NAME }}, and a fallback turns into Liquid'sdefault. The call site sendstemplateKey,subject,htmlandtemplateDatawith the oldvariables. A template with an HTML-valued variable or an HTML fallback, or anything beyond plain variables and fallbacks, stays on Resend. Step 3 saves the content in a temporary file, and step 7 writes the module from it. ThetemplateKeyis the alias or name, lowercased, with other characters turned into-, and unique.@react-email/render, andhtml/textsites send their content as is. Each gets atemplateKeynamed for the kind of email.from,replyTo,cc,bcc,headers,tags,attachmentsand the idempotency key move unchanged.error.namebranches keep working.distinctIdis the ID the app already identifies people with.{{ variables.* }}placeholders in the module, so the template hash stays stable and doesn't upload a new version on every send. Renderedhtmlandreactcontent sends withouttemplateDataand uploads nothing.recipient_suppressed), where Resend dropped it later. A site that throws or retries on errors gets a branch that treats it like a sent email, unless it setsccorbcc, where the report explains that a suppressed copy address now stops the email.$workflows_email_sent, an engagement event with the same$workflow_id, and a product event on the same distinct ID, all after the start of the test, read through the MCP'sexecute-sql.sites_rewrittenis now real.mapping.mdgains a Rewrite section,account.mdcovers getting a template's content (dashboard copy orGET /templates/{id}with a full-access key), andsdk-reference.mdadds template fallbacks and defaults.Evidence
Bundle test (vitest, red then green):
runs the rewrite and verify steps between the key and the reportexpects the workflow1-detect…6-key,7-rewrite,8-verify,9-report. Red before:7-report.mdwhere the three new steps belong. Lane gate on50b5f27:vitest run scripts/plugins/tests scripts/lib/tests, 29 files, 225 tests pass. CI runs on this PR. Up to date on 2026-11-02:50b5f27already holds its base, #450 at4ad8c6e, andmainatb699b20.Agent runs on fixture apps. Claude Code on Sonnet 5.5 with only this skill installed, headless, with no PostHog MCP and the person's answers in the prompt.
posthog-nodecame from a local build of the release branch, because the release isn't out yet.resendSDK (PostHog/wizard-workbench#2221)from 'resend'left, 2posthog.emails.sendfor 2 old sends.svelte-check0 errors, mailer tests 3 passed. The first two runs looped or truncated a one-recipient list; after the fix both narrow it to one addresswelcome(a plain variable and a fallback) moved,order-receipt(HTML rows in a variable) stayed with that reason.tscpasses. Every report notes that an empty name now shows the fallbackpathattachment stayed.icsattachment (Silthus/next-session)4499957maxRetries: 0because the app's retrier retries. Convextscpasses, 665 tests pass. Before the fix, one run kept the tests green by mocking posthog-node to rebuild a Resend request; the rerun fakesposthog.emails.sendand asserts on its options. The run on the reviewed head also returns early onrecipient_suppressed(the site sets no cc) and lists the cross-provider duplicate in "Before cutover"Templates render as before. Each moved
welcomemodule, rendered with liquidjs (HTML escaping on) and compared with Resend's triple-brace output:"After entity decode" is
&in the link'shrefwritten as&, which browsers read back as&.On the wire (earlier revision, with the
migratedFromclient option that03011c3removes). The migrated template app'ssendWelcomeagainst a local stub of the PostHog API:pnpm exec vitest run scripts/lib/tests/migrate-email-skill.test.jspasses.50b5f27:03011c3. It drops one client option and renames one docs file in step 8, so the rewrite steps the runs above exercised are unchanged.8c841cd, which changes step 8's query and adds the typecheck baseline and the temporary template file.Decisions
GET /templates/{id}with a full-access key the person creates and deletes afterwards; the same key now also reads suppressions (RESEND_FULL_ACCESS_API_KEY)execute-sqlrecipient_suppressedlike a sent email, except on sites withcc/bcc, which keep failing with a report notesubjectandtextwithout HTML escaping (EMAIL_PLAIN_TEXT_FIELDSinnodejs/src/cdp/services/hog-inputs.service.ts)migratedFromclient optionmigrated_fromonsetup wizard finishedinsteadsites_rewrittencounts moved sites$workflow_idmatching the send's; no message id is on the eventsif_code_managedandif_not_superseded, so it never replaces a template edited in PostHog; another app's code-managed key is a follow-uptemplateDataand uploads nothingOpen: accept the live-run and prod-shape gaps. Recommended: accept; the fixture-app rerun of the skill on this head is owed.
Follow-up: a
templateKeythat another app's code-managed template already uses in the same project. It needs a tool that lists the project's template keys, which the skill's MCP tools don't name today.Involvement: no grilling rounds, no prototypes, one Opus 5.5 review of the strand (#435 and #436) with three final reads, ending clean, and one GPT-6.1 Sol (high) round on this revision (one finding, fixed) with a clean read of the fix. 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 (seven lenses), and three final reads ending clean: 23 findings, 21 fixed, 1 rejected (template churn on rendered sites), 1 follow-up. Proof captured.
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. The reviewed head50b5f27is pushed here unchanged; its commits are signed.🤖 Generated with Claude Code