Skip to content

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

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

Silthus wants to merge 6 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.

Problem

migrate-email-resend (PostHog#435) 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 PostHog#435, which is stacked on PostHog#433. Merge them first. This PR's own commits start after 0a48cff: 0a48cff...4499957.
It also waits for the posthog-node release with posthog.emails (PostHog/posthog-js#5312): the skill installs a version that has it.

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: every moved send carries migratedFrom: 'resend', which send attribution (PostHog/posthog#120628) records as migrated_from. 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.
  • The send skill's client bullets move into a partial. Its built output is unchanged byte for byte. A revert removes the cutover steps and leaves v0.

Summary

Three steps replace the old step 7:

  1. 7, rewrite. One posthog-node client with secretKey and migratedFrom, set up as the send skill says (its client bullets are now the shared partial email-node-client). 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 anything beyond plain variables and fallbacks, stays on Resend.
    • 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.
    • 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, the tests that cover the changed files, and, with the person's consent, one real email: $workflows_email_sent, an engagement event and a product event on the same distinct ID, 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, a "Before cutover" list that has to be done before the deploy, and "After cutover" for removing Resend. 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. Full suite: 30 files, 227 tests pass. The built omnibus-send-transactional-email is byte-identical before and after the partial (diff -r).

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 &, which browsers read back as &.

On the wire. 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 → 5 passed.
  • Gaps:
    • No run against a local PostHog stack, so the dev mail catcher and the migrated_from property on the send event are unproven. The send API slices are still open PRs.
    • 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.

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
Summary block Same keys as PostHog#435; sites_rewritten counts moved sites 🤖 Agent this PR

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. Proof captured.


🤖 Generated with Claude Code (Claude Opus 5.5)

Silthus and others added 6 commits October 22, 2026 07:00
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>
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