Skip to content

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

Closed
Silthus wants to merge 14 commits into
feat/migrate-email-resendfrom
feat/migrate-email-resend-cutover
Closed

Silthus wants to merge 14 commits into
feat/migrate-email-resendfrom
feat/migrate-email-resend-cutover

Conversation

@Silthus

@Silthus Silthus commented Oct 24, 2026 •

Copy link
Copy Markdown
Owner

Moved here from PostHog#436, closed on 2026-10-25 so this fork stack is the only copy. It opens upstream again after review.

This revision

Restacked on #1 on 2026-10-31. What changed:

  • 03011c3 removes migratedFrom from the posthog-node client in step 7, because posthog-node has no such option. The Wizard records migrated_from: resend on its existing setup wizard finished event.
  • 9cd85f5 (review fix): step 7 installs the latest posthog-node and checks its types for what each moving site needs: templateKey with inline content, templateData with local content for a template stored at Resend, and each extra send field the site sets. A site that needs a missing capability stays on Resend with moves: no — posthog-node has no <capability> yet, and the rest go on.
  • Step 8 now names the posthog.com pages transactional-emails.md and api.md for error codes.
  • e51df39 merges feat(skills): add the migrate-email skill with a Resend variant #1's QA swarm fixes (41ca66a, 0e526b9).
  • 8c841cd (QA swarm round 3): 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_id matches the send. Step 7 takes a typecheck baseline that step 8 compares with. A template's content waits in a temporary file for step 7, an HTML fallback keeps a template on Resend, and a derived templateKey is normalized and unique. The report carries each behaviour note from the rewrite and the reminder to delete the full-access Resend key. The email-node-client partial is inlined into step 7.
  • 020b877 merges feat(skills): add the migrate-email skill with a Resend variant #1's 4ad8c6e. 8452a82 (QA swarm fixes round): step 8 takes the start of the test from execute-sql and compares Unix timestamps; the plan lists each saved template file, step 7 reads it, and step 9 deletes it; a templateKey falls back to the template id and stays unique across the run; step 8 may fix other posthog-node code the upgrade breaks, and the report lists it.
  • 53a190c and 50b5f27 (QA swarm final reads): both of step 8's queries carry a fresh random literal so execute-sql can't answer from its cache; step 3 may write the template files it saves; the dashboard copy includes the template's alias or name; a major dependency upgrade lands in "Before cutover".
  • What this adds beyond moving-from-other-providers.md (feat(messaging): add a skill that offers and sets up transactional emails 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.

Problem

migrate-email-resend (Silthus/context-mill#1) 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.send from posthog-node, leaves the rest on Resend with a reason, checks the build and one email on one person, and writes the report with the real sites_rewritten.

Important

Stacked on Silthus/context-mill#1, which is based on main. Merge it first. This PR's own commits start after 6e902dc: 6e902dc...03011c3.
posthog.emails.send with a template ID is in posthog-node (PostHog/posthog-js#5418). This skill sends with templateKey, the code form, which posthog-node does not have yet. Hold the merge until a posthog-node release has the code form.

Tracking: Silthus/posthog#539, slice 15 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. This is the slice that moves mail. The Wizard records migrated_from: resend on its existing setup wizard finished event when a run uses this skill, so a migration shows in the Wizard's own events. Expected move: large, up, once wizard migrate resend ships and the skill is released.

Merge Danger

Warning

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

  • The skill now edits the person's send code. A wrong rewrite would send mail differently once they deploy. Fixture runs on five apps found two such patterns (a recipient list looped or truncated, a test faking Resend), and the review found four more (suppressed recipients, per-email subjects, fallback quoting, idempotency across providers). All are fixed, and the fixtures were rerun.
  • A revert removes the cutover steps and leaves v0.

Summary

Three steps replace the old step 7:

  1. 7, rewrite. A typecheck baseline, then one posthog-node client with secretKey. Then each site whose plan row says it moves:
    • A Resend template becomes a module in the app's code: {{{NAME}}} turns into {{ variables.NAME }}, and a fallback turns into Liquid's default. The call site sends templateKey, subject, html and templateData with the old variables. 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. The templateKey is the alias or name, lowercased, with other characters turned into -, and unique.
    • React sites render in the app with @react-email/render, and html/text sites send their content as is. Each gets a templateKey named for the kind of email.
    • from, replyTo, cc, bcc, headers, tags, attachments and the idempotency key move unchanged. error.name branches keep working. distinctId is the ID the app already identifies people with.
    • A subject the site builds per email becomes {{ variables.* }} placeholders in the module, so the template hash stays stable and doesn't upload a new version on every send. Rendered html and react content sends without templateData and uploads nothing.
    • PostHog refuses a send to a suppressed address (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 sets cc or bcc, where the report explains that a suppressed copy address now stops the email.
    • The report warns that Resend's and PostHog's idempotency keys don't cross: a retry that crosses the deploy can send twice.
    • A helper that takes a list of recipients but always gets one is narrowed to one address. The skill never loops or drops addresses.
  2. 8, verify. Typecheck against step 7's baseline, the tests that cover the changed files, and, with the person's consent and when the app's project token matches the MCP's project, one real 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's execute-sql.
  3. 9, report. Moved sites with their keys, the sites that stay with what would let them move, the checks, each behaviour note from the rewrite, a "Before cutover" list that has to be done before the deploy, and "After cutover" for removing Resend and the full-access key. The summary block keeps its keys, and sites_rewritten is now real.

mapping.md gains a Rewrite section, account.md covers getting a template's content (dashboard copy or GET /templates/{id} with a full-access key), and sdk-reference.md adds template fallbacks and defaults.

Evidence

Bundle test (vitest, red then green): runs the rewrite and verify steps between the key and the report expects the workflow 1-detect … 6-key, 7-rewrite, 8-verify, 9-report. Red before: 7-report.md where the three new steps belong. Lane gate on 50b5f27: vitest run scripts/plugins/tests scripts/lib/tests, 29 files, 225 tests pass. Fork CI runs no workflows on pull requests.

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-node came from a local build of the release branch, because the release isn't out yet.

Fixture Runs Result
SvelteKit app on the resend SDK (PostHog/wizard-workbench#2221) 2 + 2 after the fix 2 of 2 sites moved, no from 'resend' left, 2 posthog.emails.send for 2 old sends. svelte-check 0 errors, mailer tests 3 passed. The first two runs looped or truncated a one-recipient list; after the fix both narrow it to one address
Node app with two Resend templates (written for this PR) 3 welcome (a plain variable and a fallback) moved, order-receipt (HTML rows in a variable) stayed with that reason. tsc passes. Every report notes that an empty name now shows the fallback
Node app on Resend REST 2 The order confirmation moved with its tag and idempotency key; the scheduled digest and the invoice with a path attachment stayed
Convex app on Resend REST with an .ics attachment (Silthus/next-session) 2 + 1 after the fix + 1 on 4499957 The calendar send moved with its attachment and its derived idempotency key, with maxRetries: 0 because the app's retrier retries. Convex tsc passes, 665 tests pass. Before the fix, one run kept the tests green by mocking posthog-node to rebuild a Resend request; the rerun fakes posthog.emails.send and asserts on its options. The run on the reviewed head also returns early on recipient_suppressed (the site sets no cc) and lists the cross-provider duplicate in "Before cutover"
Node app with React Email and Nodemailer, no Resend 1 Stops at step 1, no diff

Templates render as before. Each moved welcome module, rendered with liquidjs (HTML escaping on) and compared with Resend's triple-brace output:

"Jane" subject: same              "Jane" html: same after entity decode
(missing) subject: same           (missing) html: same after entity decode
"" subject: DIFFERENT             "" html: DIFFERENT   ← the fallback now shows, noted in every report

"After entity decode" is & in the link's href written as &amp;, which browsers read back as &.

On the wire (earlier revision, with the migratedFrom client option that 03011c3 removes). The migrated template app's sendWelcome against a local stub of the PostHog API:

POST /api/projects/@current/messaging/send/email/    X-PostHog-Migrated-From: resend  Idempotency-Key: welcome:user_42  distinct_id: user_42  template: {key: welcome, hash: v1:11d8…}
PUT  /api/projects/@current/messaging_templates/by_key/welcome/   (after the stub's 409 template_outdated)
POST /api/projects/@current/messaging/send/email/    same Idempotency-Key, same body
  • Test seams: the bundle's workflow order, the files an agent run leaves in a fixture (diff, typecheck, tests, report), the rendered output of a converted template, and the request a migrated send makes.
  • Verify: pnpm exec vitest run scripts/lib/tests/migrate-email-skill.test.js passes.
  • Gaps:
    • No run against a local PostHog stack, so the dev mail catcher is unproven. The code form of the send route and of posthog-node is still open.
    • No agent rerun after 03011c3. It drops one client option and renames one docs file in step 8, so the rewrite steps the runs above exercised are unchanged.
    • No run with the PostHog MCP connected, so the chain check in step 8 has not run.
    • No run inside the Wizard.
    • Real SES delivery and DNS publish.
    • Resend's handling of an empty string with a fallback is assumed to insert the empty string. The skill tells the person either way.
    • No agent rerun on a fixture app after 8c841cd, which changes step 8's query and adds the typecheck baseline and the temporary template file.
    • Prod shape, accepted by the standing rule until 2026-11-02: the rewrite reads the person's own code and Resend templates, which PostHog does not store, so there is nothing to measure. No fixture has run the template-name key normalization or an HTML fallback.

Decisions

Question Answer Who Where
Template content From the dashboard copy, or 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) 🤖 Agent this PR
One-recipient list Narrow the helper to one address and update its callers; never loop or drop 🤖 Agent this PR
Resend package and key Keep them; the report's "After cutover" tells the person when to remove them 🤖 Agent this PR
Chain proof The person triggers one moved email as themselves; the skill reads the three events through execute-sql 🤖 Agent this PR
Suppressed recipient Treat recipient_suppressed like a sent email, except on sites with cc/bcc, which keep failing with a report note 🤖 Agent review round 1
Escaping in subjects No rule: PostHog renders subject and text without HTML escaping (EMAIL_PLAIN_TEXT_FIELDS in nodejs/src/cdp/services/hog-inputs.service.ts) 🤖 Agent final read 1
migratedFrom client option Removed: posthog-node has no such option. The Wizard sets migrated_from on setup wizard finished instead 🤖 Agent Silthus/posthog#461
Summary block Same keys as #1; sites_rewritten counts moved sites 🤖 Agent this PR
posthog-node without the code form Check the installed types per site; a site that needs a missing capability stays on Resend 🤖 Agent review round 1
Template content between steps 3 and 7 A file in the system's temporary folder, deleted once the module is written; not a second fetch or a second ask in step 7 🤖 Agent pending approval
Test email correlation Events since the start of the test, with the engagement event's $workflow_id matching the send's; no message id is on the events 🤖 Agent pending approval
Typecheck errors Step 7 keeps a baseline; step 8 fixes every new error in any file, including other posthog-node code the upgrade breaks, and the report lists those files; not a pinned older release 🤖 Agent pending approval
Template key already in the project Not checked: the upload carries if_code_managed and if_not_superseded, so it never replaces a template edited in PostHog; another app's code-managed key is a follow-up 🤖 Agent pending approval
Template churn on rendered sites No rule: posthog-node sends rendered content without templateData and uploads nothing 🤖 Agent pending approval

Follow-up: a templateKey that 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 (PostHog#435 and PostHog#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.


🤖 Generated with Claude Code

@Silthus
Silthus force-pushed the feat/migrate-email-resend branch from 0a48cff to 6e902dc Compare October 31, 2026 09:53
@Silthus
Silthus force-pushed the feat/migrate-email-resend-cutover branch from 4499957 to 03011c3 Compare October 31, 2026 09:53
Silthus and others added 8 commits November 1, 2026 13:02
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>
@Silthus
Silthus force-pushed the feat/migrate-email-resend-cutover branch from 03011c3 to 9cd85f5 Compare November 1, 2026 13:04
Silthus and others added 6 commits November 1, 2026 18:37
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>
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>
@Silthus

Silthus commented Nov 2, 2026

Copy link
Copy Markdown
Owner Author

Moved to PostHog#451.

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