Skip to content

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

Closed
Silthus wants to merge 17 commits into
PostHog:mainfrom
Silthus:feat/migrate-email-resend
Closed

Silthus wants to merge 17 commits into
PostHog:mainfrom
Silthus:feat/migrate-email-resend

Conversation

@Silthus

@Silthus Silthus commented Oct 22, 2026 •

Copy link
Copy Markdown

Problem

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

With this skill, migrate-email-resend does the preparation and changes no send call site: it finds every Resend send, classifies each one as ready to move or staying on Resend with a reason, sets up the PostHog sender next to Resend's records, imports Resend's suppression list, hands over the secret key, checks the sending tier, and writes posthog-migrate-email-resend-report.md. The next slice moves the sites.

Important

Stacked on #433. Merge #433 first. This PR's own commits start after 161a6fd: 161a6fd...0a48cff.

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

Impact

Pays into Grow Workflows through Switched orgs: teams that move their transactional email from Resend to PostHog. Not measured yet: it needs the migration marker on each send (PostHog/posthog#120628) and the Wizard's migration summary. Expected move: large, up, once the cutover slice and wizard migrate resend ship. This slice alone moves no mail.

Merge Danger

Warning

🟡 Merge risk: medium · two-way door (merge-risk rubric)

  • skill-menu.json changes for the Wizard: migrate-statsig moves from command: migrate to parentCommand: migrate, command: statsig, and migrate-email-resend joins as migrate resend. Today's Wizard runs bare wizard migrate from a hardcoded skill id, so Statsig keeps working. The family command comes with the Wizard PR.
  • Two generator changes reach every bundle: a variant's references/<variant-id>/ folder now ships (only migrate has one today), and a partial takes its directive's indentation (every existing {{> }} line is unindented, so their output is unchanged).
  • Like feat(skills): add send-transactional-email skill #433, a clean build needs transactional-email-api.md on posthog.com (PostHog/posthog.com#21306). A revert removes the skill from the next release.

Summary

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

  1. Detect. Package, import, client, api.resend.com, or smtp.resend.com. React Email or Nodemailer alone is not Resend: [ABORT] no email provider sends found before any change.
  2. Preflight. The transactional email API is on (the upload and send MCP tools exist), and the person compares their busiest hour and day with the project's sending allowance.
  3. Inventory. Every site with its file, line, content (template, react, html, text) and fields, written to .posthog-email-migration-plan.md. mapping.md decides field by field: cc, bcc, headers, tags and attachments move within the contract's limits; scheduledAt, topicId, batch sends, SMTP, more than one to, last_event reads, and error names PostHog lacks stay on Resend with a reason.
  4. Sender and DNS. One verified sender per from domain, set up with the sender setup of send-transactional-email. Resend's records stay; PostHog's MAIL FROM label must not be Resend's send.
  5. Suppressions. Resend's hard bounces, complaints and manual entries go to PostHog's suppression list through email-suppressions-add, from the dashboard CSV or GET /suppressions. Addresses never land in a file of the repository, the plan, or the report. That tool does not exist yet (see Open below): until it does, the step records not imported and the report tells the person to add them before cutover.
  6. Key. The person creates a phs_ key with hog_flow:write for POSTHOG_PROJECT_SECRET_API_KEY. The report lists every other place the Resend key lives, such as CI and docs.
  7. Report. Ends with a summary block the Wizard parses:
from: resend
sites_found: 3
sites_rewritten: 0
sites_left: 2
dns_ready: false
suppressions_ready: false

Other changes:

  • send-transactional-email's sender setup moves into context/shared/email-sender-setup.md, included by both skills. The built send skill is unchanged byte for byte.
  • fix(skills): the generator copied only top-level reference files, so migrate-statsig shipped without references/statsig/mapping.md and sdk-reference.md that its steps tell the agent to read. Both now ship.
  • AGENTS.md lists the migrate subcommands.

Evidence

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

  • migrate-email-skill.test.js: migrate-email-resend and migrate-statsig build with every references/... path they name present in the bundle, the API page and the Resend files ship without the send skill, and both publish as migrate subcommands. Red before the skill: 3 of 4 fail (expected undefined to be defined).
  • skill-generator-references-folder.test.js: a variant ships its own folder and no other variant's. Red before the fix: ENOENT ... references/statsig/mapping.md.
  • partial-includes.test.js: an indented directive indents every partial line. Red before: the first bullet lost its indent.
  • Full suite: 30 files, 226 tests pass. pnpm build passes with the docs page stubbed in .docs-cache (it answers 404 until #21306).

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

Query Before (#433) After
migrate from Resend migrate-statsig (126) migrate-email-resend (1162)
replace resend with posthog feature-flags-ios (138) migrate-email-resend (990)
switch email provider omnibus-send-transactional-email (133) migrate-email-resend (1011)

Agent runs on fixture apps. Claude Code on Sonnet 5.5 with only this skill installed, headless, the PostHog MCP not connected, and the person's answers given in the prompt. Every run changed no send call site: the diff is one commented POSTHOG_PROJECT_SECRET_API_KEY line in the env example, plus the report.

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

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

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

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

## Before cutover

1. Confirm that the transactional email API is enabled for your PostHog project. ...
4. Export Resend's suppression list and import it into PostHog.
  • Test seams: the bundle build (files a skill ships and the paths it names), the cliEntries the Wizard reads, the MCP SkillCatalog ranking, and the files an agent run leaves in a fixture.
  • Verify: pnpm exec vitest run scripts/lib/tests/migrate-email-skill.test.js → 4 passed.
  • Gaps: no run inside the Wizard (wizard migrate resend needs the Wizard PR), no run with the PostHog MCP connected, so sender creation, DNS publish, and sender verification on a deployed project are unproven. The suppression import has no tool to run against yet.

Decisions

Question Answer Who Where
Suppression list Import the provider's list before cutover 👤 Human lane brief, 2026-10-22
Providers One migrate-email group, one variant per provider; Resend parts stay in its folder 👤 Human lane brief, 2026-10-22
Import path Write the step against a new bulk tool, email-suppressions-add, and record not imported until it exists; the existing one-address route has no MCP tool 🤖 Agent this PR
Unsubscribed contacts Left out: Resend's transactional sends reach them, and PostHog's opt-outs cover marketing only 🤖 Agent this PR
Summary block Adds suppressions_ready after the spec's five keys 🤖 Agent this PR
Sending tier The person compares their volume with the Reputation page; no tool reads the allowance 🤖 Agent this PR
Statsig entry Nest it under migrate now; today's Wizard ignores the entry 🤖 Agent this PR

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

Involvement: no grilling rounds, no prototypes, one Opus 5.5 review of the strand (#435 and #436), proof captured. Its findings were on rules the cutover uses (suppressed recipients, subjects, fallback quoting, idempotency across providers), and the fixes landed in #436, where those rules apply.


🤖 Generated with Claude Code (Claude Opus 5.5)

Silthus and others added 17 commits October 17, 2026 03:02
Teaches a coding agent to send one transactional email from server code
with PostHog: verify a sender through the PostHog MCP first, keep the
template as an HTML file in the repository, upload it by key, and send
it with posthog-node or the HTTP API. Ships the transactional email API
docs page as its reference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Check the PostHog MCP before any sender work, never recreate an existing
sender, bound verification polling, set the default sender through a
verify, ask before any test send, and cover Next.js and edge runtimes.

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

The MCP upload passes source.repository from the origin remote without
credentials, source.path from the repository root and source.ref from HEAD.
A new section sends a change to a code-managed template to its file and
leaves the upload to posthog-node or the deploy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A reverted file goes live only through an upload, because a send with an
older published hash gets the newer content. Any send from the changed
code publishes it. A by-key change is uploaded after the change. An agent
without the repository tells the person or requests the change. The SDK
always sends a path, so only repository and ref keep their stored value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A revert to published content needs an upload only for templateFile().
A by-key change waits for the deploy step when there is one. A test
through workflows-send-email publishes nothing, and an agent without
the repository shares the PostHog Code task link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The skill now writes a local template in the app's code and sends it
with templateKey, subject, html and templateData. The first send
uploads it, so the MCP upload, templateFile() and the deploy-time
upload by key go. Rendered content and templates made in PostHog
(templateId) are the other two forms.

Sends carry an idempotency key and retry with it. Job runners pass
maxRetries: 0 and a key derived from the email's purpose. STEP 9
lists the contract's codes, including the 409 for a template edited
in PostHog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Inside the Wizard the agent asks through wizard_ask, writes the secret
key with set_env_values, reaches PostHog tools through posthog_exec,
and ends a blocked run with an [ABORT] reason the Wizard's
transactional-email program matches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Wizard's login may lack the workflows and integrations scopes, so a
hidden tool there no longer stops the run as "API not enabled". The
Wizard section names its tools as the runtime shows them, and replyTo
no longer needs a verified sender.

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

Silthus commented Oct 24, 2026

Copy link
Copy Markdown
Author

Note

🤖 Claude Opus 5.5 writing on behalf of Michael

Decisions made while building this PR:

  • 👤 Suppression list Import the provider's list before cutover.
  • 👤 Providers One migrate-email group with one variant per provider. The Resend parts stay in references/resend/.
  • 🤖 Import path Write step 5 against a new bulk tool, email-suppressions-add, and record not imported until it exists. The only route today adds one address at a time and has no MCP tool.
  • 🤖 Unsubscribed contacts Leave them out. Resend's transactional sends still reach them, and PostHog's opt-outs cover marketing only.
  • 🤖 Summary block Add suppressions_ready after the spec's five keys.
  • 🤖 Sending tier The person compares their volume with the Reputation page. No tool reads the allowance.
  • 🤖 Statsig entry Nest it under migrate now. Today's Wizard runs bare wizard migrate from a hardcoded skill id and ignores the entry.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant